Skip to main content

ty_module_resolver/
list.rs

1use std::borrow::Cow;
2use std::collections::btree_map::{BTreeMap, Entry};
3
4use ruff_db::files::directory_listing;
5
6use crate::ResolverEnvironment;
7use crate::db::Db;
8use crate::module::{Module, ModuleKind};
9use crate::module_name::ModuleName;
10use crate::path::{ModulePath, SearchPath, SystemOrVendoredPathRef};
11use crate::resolve::{ModuleResolveMode, ResolverContext, resolve_file_module, search_paths};
12
13/// List all available modules, including all sub-modules, sorted in lexicographic order.
14pub fn all_modules<'db>(
15    db: &'db dyn Db,
16    resolver_environment: ResolverEnvironment<'db>,
17) -> Vec<Module<'db>> {
18    let mut modules = list_modules(db, resolver_environment).to_vec();
19    let mut stack = modules.clone();
20    while let Some(module) = stack.pop() {
21        for &submodule in module.all_submodules(db) {
22            modules.push(submodule);
23            stack.push(submodule);
24        }
25    }
26    modules.sort_by_key(|module| module.name(db));
27    modules
28}
29
30/// List all available top-level modules.
31pub fn list_modules<'db>(
32    db: &'db dyn Db,
33    resolver_environment: ResolverEnvironment<'db>,
34) -> &'db [Module<'db>] {
35    list_modules_impl(db, resolver_environment)
36}
37
38#[salsa::tracked(returns(deref))]
39fn list_modules_impl<'db>(
40    db: &'db dyn Db,
41    resolver_environment: ResolverEnvironment<'db>,
42) -> Box<[Module<'db>]> {
43    let mut modules: BTreeMap<&ModuleName, ListedModule<'_>> = BTreeMap::new();
44    for search_path in search_paths(db, resolver_environment, ModuleResolveMode::Typing) {
45        for &new in list_modules_in(
46            db,
47            SearchPathIngredient::new(db, resolver_environment, search_path.clone()),
48        ) {
49            match modules.entry(new.module(db).name(db)) {
50                Entry::Vacant(entry) => {
51                    entry.insert(new);
52                }
53                Entry::Occupied(mut entry) => {
54                    // A module can override a module with the same name in
55                    // a higher precedent search path when either of the following
56                    // are true:
57                    //
58                    // 1. The higher precedent search path contained a namespace
59                    //    package and the lower precedent search path contained
60                    //    a "regular" module/package.
61                    // 2. The new module is from a stub package (`foo-stubs`),
62                    //    which has priority regardless of search path ordering
63                    //    per the typing spec's import resolution ordering.
64                    let existing = entry.get();
65                    let existing_is_namespace = existing.module(db).search_path(db).is_none();
66                    let new_is_non_namespace = new.module(db).search_path(db).is_some();
67                    if (existing_is_namespace && new_is_non_namespace)
68                        || (!existing.is_stub_package(db) && new.is_stub_package(db))
69                    {
70                        entry.insert(new);
71                    }
72                }
73            }
74        }
75    }
76    modules
77        .into_values()
78        .map(|listed| listed.module(db))
79        .collect()
80}
81
82#[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)]
83struct SearchPathIngredient<'db> {
84    #[returns(copy)]
85    resolver_environment: ResolverEnvironment<'db>,
86    #[returns(ref)]
87    path: SearchPath,
88}
89
90/// List all available top-level modules in the given `SearchPath`.
91#[salsa::tracked(returns(deref))]
92fn list_modules_in<'db>(
93    db: &'db dyn Db,
94    search_path: SearchPathIngredient<'db>,
95) -> Vec<ListedModule<'db>> {
96    let path = search_path.path(db);
97    tracing::debug!("Listing modules in search path '{}'", path);
98    let mut lister = Lister::new(db, search_path.resolver_environment(db), path);
99    match path.as_path() {
100        SystemOrVendoredPathRef::System(system_search_path) => {
101            let Ok(listing) = directory_listing(db, system_search_path) else {
102                return vec![];
103            };
104            for (name, file_type) in listing.iter() {
105                let path = system_search_path.join(name);
106                lister.add_path(&path.as_path().into(), file_type.into());
107            }
108        }
109        SystemOrVendoredPathRef::Vendored(vendored_search_path) => {
110            for entry in db.vendored().read_directory(vendored_search_path) {
111                lister.add_path(&entry.path().into(), entry.file_type().into());
112            }
113        }
114    }
115    lister.into_modules()
116}
117
118/// A module paired with whether it came from a stub package.
119#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)]
120struct ListedModule<'db> {
121    #[returns(copy)]
122    module: Module<'db>,
123    #[returns(copy)]
124    is_stub_package: bool,
125}
126
127impl get_size2::GetSize for ListedModule<'_> {}
128
129/// An implementation helper for "list all modules."
130///
131/// This is responsible for accumulating modules indexed by
132/// module name. It also handles precedence by implementing the
133/// rules that determine which module gets priority when there is
134/// otherwise ambiguity (e.g., `foo.py` versus `foo/__init__.py`
135/// in the same directory).
136struct Lister<'db> {
137    db: &'db dyn Db,
138    search_path: &'db SearchPath,
139    resolver_environment: ResolverEnvironment<'db>,
140    modules: BTreeMap<&'db ModuleName, ListedModule<'db>>,
141}
142
143impl<'db> Lister<'db> {
144    /// Create new state that can accumulate modules from a list
145    /// of file paths.
146    fn new(
147        db: &'db dyn Db,
148        resolver_environment: ResolverEnvironment<'db>,
149        search_path: &'db SearchPath,
150    ) -> Lister<'db> {
151        Lister {
152            db,
153            search_path,
154            resolver_environment,
155            modules: BTreeMap::new(),
156        }
157    }
158
159    /// Returns the modules collected, sorted by module name.
160    fn into_modules(self) -> Vec<ListedModule<'db>> {
161        self.modules.into_values().collect()
162    }
163
164    /// Add the given `path` as a possible module to this lister. The
165    /// `file_type` should be the type of `path` (file, directory or
166    /// symlink).
167    ///
168    /// This may decide that the given path does not correspond to
169    /// a valid Python module. In which case, it is dropped and this
170    /// is a no-op.
171    ///
172    /// Callers must ensure that the path given came from the same
173    /// `SearchPath` used to create this `Lister`.
174    fn add_path(&mut self, path: &SystemOrVendoredPathRef<'_>, file_type: FileType) {
175        let mut has_py_extension = false;
176        // We must have no extension, a Python source file extension (`.py`)
177        // or a Python stub file extension (`.pyi`).
178        if let Some(ext) = path.extension() {
179            has_py_extension = is_python_extension(ext);
180            if !has_py_extension {
181                return;
182            }
183        }
184
185        let Some(name) = path.file_name() else { return };
186        let mut module_path = self.search_path.to_module_path();
187        module_path.push(name);
188        let Some(module_name) = module_path.to_module_name() else {
189            return;
190        };
191
192        // Some modules cannot shadow a subset of special
193        // modules from the standard library.
194        if !self.search_path.is_standard_library() && self.is_non_shadowable(&module_name) {
195            return;
196        }
197
198        if file_type.is_possibly_directory() {
199            if module_path.is_regular_package(&self.context()) {
200                module_path.push("__init__");
201                if let Some(file) = resolve_file_module(&module_path, &self.context()) {
202                    self.add_module(
203                        &module_path,
204                        Module::file_module(
205                            self.db,
206                            file,
207                            self.resolver_environment,
208                            Cow::Owned(module_name),
209                            ModuleKind::Package,
210                            self.search_path.clone(),
211                        ),
212                    );
213                    return;
214                }
215                module_path.pop();
216            }
217
218            // Otherwise, we kind of have to assume that we have a
219            // namespace package, which can be any directory that
220            // *doesn't* contain an `__init__.{py,pyi}`. We do need to
221            // know if we have a real directory or not. If we have a
222            // symlink, then this requires hitting the file system.
223            //
224            // Note though that if we find a "regular" module in a
225            // lower priority search path, that will be allowed to
226            // overwrite this namespace package.
227            //
228            // We only do this when in a standard library search
229            // path, which matches how the "resolve this module"
230            // implementation works. In particular, typeshed doesn't
231            // use any namespace packages at time of writing
232            // (2025-08-08), so if we're in a standard library search
233            // path, we "know" this can't actually be a package.
234            //
235            // NOTE: Note that the
236            // `module_path.is_regular_package()` check above takes
237            // `VERSIONS` into consideration. Which means it can return
238            // `false` even when, say, `package/__init__.py` exists. In
239            // that case, outside of a standard library search path,
240            // we'd incorrectly report it here as a namespace package.
241            // HOWEVER, `VERSIONS` is only applicable for typeshed, so
242            // this ends up working okay. But if typeshed ever uses
243            // namespace packages, then this will need to be accounted
244            // for.
245            let is_dir =
246                file_type.is_definitely_directory() || module_path.is_directory(&self.context());
247            if is_dir {
248                if !self.search_path.is_standard_library() {
249                    self.add_module(
250                        &module_path,
251                        Module::namespace_package(
252                            self.db,
253                            self.resolver_environment,
254                            Cow::Owned(module_name),
255                        ),
256                    );
257                }
258                return;
259            }
260            // At this point, we have a symlink that we know is not a
261            // directory, so press on as if it were a regular file...
262        }
263
264        // At this point, we're looking for a file module.
265        // For a file module, we require a `.py` or `.pyi`
266        // extension.
267        if !has_py_extension {
268            return;
269        }
270        // We also require stub packages to be packages, not
271        // single-file modules.
272        if module_path.is_stub_package() {
273            return;
274        }
275
276        let Some(file) = module_path.to_file(&self.context()) else {
277            return;
278        };
279        self.add_module(
280            &module_path,
281            Module::file_module(
282                self.db,
283                file,
284                self.resolver_environment,
285                Cow::Owned(module_name),
286                ModuleKind::Module,
287                self.search_path.clone(),
288            ),
289        );
290    }
291
292    /// Adds the given module to the collection.
293    ///
294    /// If the module had already been added and shouldn't override any
295    /// existing entry, then this is a no-op. That is, this assumes that the
296    /// caller looks for modules in search path priority order.
297    fn add_module(&mut self, path: &ModulePath, module: Module<'db>) {
298        let listed = ListedModule::new(self.db, module, path.is_stub_package());
299        let mut entry = match self.modules.entry(module.name(self.db)) {
300            Entry::Vacant(entry) => {
301                entry.insert(listed);
302                return;
303            }
304            Entry::Occupied(entry) => entry,
305        };
306
307        let existing = entry.get().module(self.db);
308        match (existing.search_path(self.db), module.search_path(self.db)) {
309            // When we had a namespace package and now try to
310            // insert a non-namespace package, the latter always
311            // takes precedent, even if it's in a lower priority
312            // search path.
313            (None, Some(_)) => {
314                entry.insert(listed);
315            }
316            (Some(_), Some(_)) => {
317                // Merging across search paths is only necessary for
318                // namespace packages. For all other modules, entries
319                // from earlier search paths take precedence. Thus, all
320                // of the cases below require that we're in the same
321                // directory. ... Which is true here, because a `Lister`
322                // only works for one specific search path.
323
324                // When we have a `foo/__init__.py` and a `foo.py` in
325                // the same directory, the former takes precedent.
326                // (This case can only occur when both have a search
327                // path.)
328                // Or if we have two file modules and the new one
329                // is a stub, then the stub takes priority.
330                if existing.kind(self.db) == ModuleKind::Module
331                    && let module_kind = module.kind(self.db)
332                    && (module_kind == ModuleKind::Package
333                        || module_kind == ModuleKind::Module && path.is_stub_file())
334                {
335                    entry.insert(listed);
336                    return;
337                }
338                // Or... if we have a stub package, the stub package
339                // always gets priority.
340                if path.is_stub_package() {
341                    entry.insert(listed);
342                }
343            }
344            _ => {}
345        }
346    }
347
348    /// Returns true if the given module name cannot be shadowable.
349    fn is_non_shadowable(&self, name: &ModuleName) -> bool {
350        ModuleResolveMode::Typing.is_non_shadowable(
351            self.resolver_environment.python_version(self.db).minor,
352            name.as_str(),
353        )
354    }
355
356    /// Constructs a resolver context for use with some APIs that require it.
357    fn context(&self) -> ResolverContext<'db> {
358        ResolverContext {
359            db: self.db,
360            resolver_environment: self.resolver_environment,
361            // We don't currently support listing modules
362            // in a "no stubs allowed" mode.
363            mode: ModuleResolveMode::Typing,
364        }
365    }
366}
367
368/// The type of a file.
369#[derive(Clone, Copy, Debug)]
370enum FileType {
371    File,
372    Directory,
373    Symlink,
374}
375
376impl FileType {
377    fn is_possibly_directory(self) -> bool {
378        matches!(self, FileType::Directory | FileType::Symlink)
379    }
380
381    fn is_definitely_directory(self) -> bool {
382        matches!(self, FileType::Directory)
383    }
384}
385
386impl From<ruff_db::vendored::FileType> for FileType {
387    fn from(ft: ruff_db::vendored::FileType) -> FileType {
388        match ft {
389            ruff_db::vendored::FileType::File => FileType::File,
390            ruff_db::vendored::FileType::Directory => FileType::Directory,
391        }
392    }
393}
394
395impl From<ruff_db::system::FileType> for FileType {
396    fn from(ft: ruff_db::system::FileType) -> FileType {
397        match ft {
398            ruff_db::system::FileType::File => FileType::File,
399            ruff_db::system::FileType::Directory => FileType::Directory,
400            ruff_db::system::FileType::Symlink => FileType::Symlink,
401        }
402    }
403}
404
405/// Returns true if and only if the given file extension corresponds
406/// to a Python source or stub file.
407fn is_python_extension(ext: &str) -> bool {
408    matches!(ext, "py" | "pyi")
409}
410
411#[cfg(test)]
412mod tests {
413    #![expect(
414        clippy::disallowed_methods,
415        reason = "These are tests, so it's fine to do I/O by-passing System."
416    )]
417
418    use camino::{Utf8Component, Utf8Path};
419    use ruff_db::Db as _;
420    use ruff_db::files::{File, FilePath, FileRootKind};
421    use ruff_db::system::{DbWithTestSystem, DbWithWritableSystem, SystemPath, SystemPathBuf};
422    use ruff_db::testing::{
423        assert_function_query_was_not_run, assert_function_query_was_not_run_by_name,
424    };
425    use ruff_python_ast::PythonVersion;
426    use salsa::plumbing::AsId as _;
427
428    use crate::db::{Db, tests::TestDb};
429    use crate::module::Module;
430    use crate::resolve::{
431        ModuleResolveMode, ModuleResolveModeIngredient, dynamic_resolution_paths,
432    };
433    use crate::settings::SearchPathSettings;
434    use crate::strategy::FallibleStrategy;
435    use crate::testing::{FileSpec, MockedTypeshed, TestCase, TestCaseBuilder};
436
437    fn list_modules(db: &TestDb) -> &[Module<'_>] {
438        super::list_modules(db, db.resolver_environment())
439    }
440
441    struct ModuleDebugSnapshot<'db> {
442        db: &'db dyn Db,
443        module: Module<'db>,
444    }
445
446    impl std::fmt::Debug for ModuleDebugSnapshot<'_> {
447        fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
448            match self.module {
449                Module::Namespace(pkg) => {
450                    write!(f, "Module::Namespace({name:?})", name = pkg.name(self.db))
451                }
452                Module::File(module) => {
453                    // For snapshots, just normalize all paths to using
454                    // Unix slashes for simplicity.
455                    let path_components = match module.file(self.db).path(self.db) {
456                        FilePath::System(path) => path.components(),
457                        FilePath::Vendored(path) => path.components(),
458                        FilePath::SystemVirtual(path) => Utf8Path::new(path.as_str()).components(),
459                    };
460                    let nice_path = path_components
461                        // Avoid including a root component, since that
462                        // results in a platform dependent separator.
463                        // Convert to an empty string so that we get a
464                        // path beginning with `/` regardless of platform.
465                        .map(|component| {
466                            if let Utf8Component::RootDir = component {
467                                Utf8Component::Normal("")
468                            } else {
469                                component
470                            }
471                        })
472                        .map(|component| component.as_str())
473                        .collect::<Vec<&str>>()
474                        .join("/");
475                    write!(
476                        f,
477                        "Module::File({name:?}, {search_path:?}, {path:?}, {kind:?}, {known:?})",
478                        name = module.name(self.db).as_str(),
479                        search_path = module.search_path(self.db).debug_kind(),
480                        path = nice_path,
481                        kind = module.kind(self.db),
482                        known = module.known(self.db),
483                    )
484                }
485            }
486        }
487    }
488
489    fn sorted_list(db: &TestDb) -> Vec<Module<'_>> {
490        let mut modules = list_modules(db).to_vec();
491        modules.sort_by(|m1, m2| m1.name(db).cmp(m2.name(db)));
492        modules
493    }
494
495    fn list_snapshot(db: &TestDb) -> Vec<ModuleDebugSnapshot<'_>> {
496        list_snapshot_filter(db, |_| true)
497    }
498
499    fn list_snapshot_filter<'db>(
500        db: &'db TestDb,
501        predicate: impl Fn(&Module<'db>) -> bool,
502    ) -> Vec<ModuleDebugSnapshot<'db>> {
503        sorted_list(db)
504            .into_iter()
505            .filter(predicate)
506            .map(|module| ModuleDebugSnapshot { db, module })
507            .collect()
508    }
509
510    #[test]
511    fn first_party_module() {
512        let TestCase { db, .. } = TestCaseBuilder::new()
513            .with_src_files(&[("foo.py", "")])
514            .build();
515
516        insta::assert_debug_snapshot!(
517            list_snapshot(&db),
518            @r#"
519        [
520            Module::File("foo", "first-party", "/src/foo.py", Module, None),
521        ]
522        "#,
523        );
524    }
525
526    #[test]
527    fn stubs_over_module_source() {
528        let TestCase { db, .. } = TestCaseBuilder::new()
529            .with_src_files(&[("foo.py", ""), ("foo.pyi", "")])
530            .build();
531
532        insta::assert_debug_snapshot!(
533            list_snapshot(&db),
534            @r#"
535        [
536            Module::File("foo", "first-party", "/src/foo.pyi", Module, None),
537        ]
538        "#,
539        );
540    }
541
542    #[test]
543    fn stubs_over_package_source() {
544        let TestCase { db, .. } = TestCaseBuilder::new()
545            .with_src_files(&[("foo/__init__.py", ""), ("foo.pyi", "")])
546            .build();
547
548        // NOTE: This matches the behavior of the "resolve this module"
549        // implementation, even though it seems inconsistent with the
550        // `stubs_over_module_source` test.
551        //
552        // TODO: Check what other type checkers do. It seems like this (and
553        // "resolve this module") should prefer the stub file, although the
554        // typing spec isn't perfectly clear on this point:
555        // https://typing.python.org/en/latest/spec/distributing.html#stub-files
556        insta::assert_debug_snapshot!(
557            list_snapshot(&db),
558            @r#"
559        [
560            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
561        ]
562        "#,
563        );
564    }
565
566    /// Tests that if we have a `foo.py` and a `foo/__init__.py`, then the
567    /// latter takes precedence.
568    ///
569    /// This is somewhat difficult to test using the in-memory file system,
570    /// since it always returns directory entries in lexicographic order. This
571    /// in turn implies that `foo` will always appear before `foo.py`. But to
572    /// truly test this, we would like to also be correct in the case where
573    /// `foo.py` appears before `foo` (which can certainly happen in the real
574    /// world).
575    #[test]
576    fn package_over_module1() {
577        let TestCase { db, .. } = TestCaseBuilder::new()
578            .with_src_files(&[("foo.py", ""), ("foo/__init__.py", "")])
579            .build();
580
581        insta::assert_debug_snapshot!(
582            list_snapshot(&db),
583            @r#"
584        [
585            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
586        ]
587        "#,
588        );
589    }
590
591    /// Similar to `package_over_module1`, but flips the order of files.
592    ///
593    /// (At time of writing, 2025-08-07, this doesn't actually make a
594    /// difference since the in-memory file system sorts directory entries.)
595    #[test]
596    fn package_over_module2() {
597        let TestCase { db, .. } = TestCaseBuilder::new()
598            .with_src_files(&[("foo/__init__.py", ""), ("foo.py", "")])
599            .build();
600
601        insta::assert_debug_snapshot!(
602            list_snapshot(&db),
603            @r#"
604        [
605            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
606        ]
607        "#,
608        );
609    }
610
611    #[test]
612    fn builtins_vendored() {
613        let TestCase { db, .. } = TestCaseBuilder::new()
614            .with_vendored_typeshed()
615            .with_src_files(&[("builtins.py", "FOOOO = 42")])
616            .build();
617
618        insta::assert_debug_snapshot!(
619            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "builtins"),
620            @r#"
621        [
622            Module::File("builtins", "std-vendored", "stdlib/builtins.pyi", Module, Some(Builtins)),
623        ]
624        "#,
625        );
626    }
627
628    #[test]
629    fn ty_extensions_vendored() {
630        let TestCase { db, .. } = TestCaseBuilder::new().with_vendored_typeshed().build();
631
632        insta::assert_debug_snapshot!(
633            list_snapshot_filter(&db, |module| module.name(&db).as_str() == "ty_extensions"),
634            @r#"
635        [
636            Module::File("ty_extensions", "std-vendored", "stdlib/ty_extensions/__init__.pyi", Package, Some(TyExtensions)),
637        ]
638        "#,
639        );
640    }
641
642    #[test]
643    fn builtins_custom() {
644        const TYPESHED: MockedTypeshed = MockedTypeshed {
645            stdlib_files: &[("builtins.pyi", "def min(a, b): ...")],
646            versions: "builtins: 3.8-",
647        };
648
649        const SRC: &[FileSpec] = &[("builtins.py", "FOOOO = 42")];
650
651        let TestCase { db, .. } = TestCaseBuilder::new()
652            .with_src_files(SRC)
653            .with_mocked_typeshed(TYPESHED)
654            .with_python_version(PythonVersion::PY38)
655            .build();
656
657        insta::assert_debug_snapshot!(
658            list_snapshot(&db),
659            @r#"
660        [
661            Module::File("builtins", "std-custom", "/typeshed/stdlib/builtins.pyi", Module, Some(Builtins)),
662        ]
663        "#,
664        );
665    }
666
667    #[test]
668    fn stdlib() {
669        const TYPESHED: MockedTypeshed = MockedTypeshed {
670            stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
671            versions: "functools: 3.8-",
672        };
673
674        let TestCase { db, .. } = TestCaseBuilder::new()
675            .with_mocked_typeshed(TYPESHED)
676            .with_python_version(PythonVersion::PY38)
677            .build();
678
679        insta::assert_debug_snapshot!(
680            list_snapshot(&db),
681            @r#"
682        [
683            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
684        ]
685        "#,
686        );
687    }
688
689    #[test]
690    fn stdlib_resolution_respects_versions_file_py38_existing_modules() {
691        const VERSIONS: &str = "\
692            asyncio: 3.8-               # 'Regular' package on py38+
693            asyncio.tasks: 3.9-3.11     # Submodule on py39+ only
694            functools: 3.8-             # Top-level single-file module
695            random: 3.8-                # 'Regular' file module on py38+
696            xml: 3.8-3.8                # Namespace package on py38 only
697        ";
698
699        const STDLIB: &[FileSpec] = &[
700            ("asyncio/__init__.pyi", ""),
701            ("asyncio/tasks.pyi", ""),
702            ("functools.pyi", ""),
703            ("random.pyi", ""),
704            ("xml/etree.pyi", ""),
705        ];
706
707        const TYPESHED: MockedTypeshed = MockedTypeshed {
708            stdlib_files: STDLIB,
709            versions: VERSIONS,
710        };
711
712        let TestCase { db, .. } = TestCaseBuilder::new()
713            .with_mocked_typeshed(TYPESHED)
714            .with_python_version(PythonVersion::PY38)
715            .build();
716
717        // NOTE: This currently doesn't return `xml` since
718        // the implementation assumes that typeshed doesn't
719        // have namespace packages. But our test setup (copied
720        // from the "resolve this module" tests) does.
721        insta::assert_debug_snapshot!(
722            list_snapshot(&db),
723            @r#"
724        [
725            Module::File("asyncio", "std-custom", "/typeshed/stdlib/asyncio/__init__.pyi", Package, None),
726            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
727            Module::File("random", "std-custom", "/typeshed/stdlib/random.pyi", Module, None),
728        ]
729        "#,
730        );
731    }
732
733    #[test]
734    fn stdlib_resolution_respects_versions_file_py38_nonexisting_modules() {
735        const VERSIONS: &str = "\
736            asyncio: 3.8-               # 'Regular' package on py38+
737            asyncio.tasks: 3.9-3.11     # Submodule on py39+ only
738            collections: 3.9-           # 'Regular' package on py39+
739            importlib: 3.9-             # Namespace package on py39+
740            random: 3.9-                # 'Regular' file module on py39+
741            xml: 3.8-3.8                # Namespace package on 3.8 only
742            foo: 3.9-
743        ";
744
745        const STDLIB: &[FileSpec] = &[
746            ("collections/__init__.pyi", ""),
747            ("asyncio/__init__.pyi", ""),
748            ("asyncio/tasks.pyi", ""),
749            ("importlib/abc.pyi", ""),
750            ("random.pyi", ""),
751            ("xml/etree.pyi", ""),
752        ];
753
754        const TYPESHED: MockedTypeshed = MockedTypeshed {
755            stdlib_files: STDLIB,
756            versions: VERSIONS,
757        };
758
759        let TestCase { db, .. } = TestCaseBuilder::new()
760            .with_mocked_typeshed(TYPESHED)
761            .with_python_version(PythonVersion::PY38)
762            .build();
763
764        // NOTE: This currently doesn't return any of the namespace
765        // packages defined above in our mock typeshed (that is,
766        // `importlib` and `xml`) because our implementation assumes
767        // namespace packages cannot occur in typeshed.
768        //
769        // Relatedly, `collections` and `random` should not appear
770        // because they are limited to 3.9+.
771        insta::assert_debug_snapshot!(
772            list_snapshot(&db),
773            @r#"
774        [
775            Module::File("asyncio", "std-custom", "/typeshed/stdlib/asyncio/__init__.pyi", Package, None),
776        ]
777        "#,
778        );
779    }
780
781    #[test]
782    fn stdlib_resolution_respects_versions_file_py39_existing_modules() {
783        const VERSIONS: &str = "\
784            asyncio: 3.8-               # 'Regular' package on py38+
785            asyncio.tasks: 3.9-3.11     # Submodule on py39+ only
786            collections: 3.9-           # 'Regular' package on py39+
787            functools: 3.8-             # Top-level single-file module
788            importlib: 3.9-             # Namespace package on py39+
789        ";
790
791        const STDLIB: &[FileSpec] = &[
792            ("asyncio/__init__.pyi", ""),
793            ("asyncio/tasks.pyi", ""),
794            ("collections/__init__.pyi", ""),
795            ("functools.pyi", ""),
796            ("importlib/abc.pyi", ""),
797        ];
798
799        const TYPESHED: MockedTypeshed = MockedTypeshed {
800            stdlib_files: STDLIB,
801            versions: VERSIONS,
802        };
803
804        let TestCase { db, .. } = TestCaseBuilder::new()
805            .with_mocked_typeshed(TYPESHED)
806            .with_python_version(PythonVersion::PY39)
807            .build();
808
809        // NOTE: This currently doesn't return any of the namespace
810        // packages defined above in our mock typeshed (that is,
811        // `importlib`) because our implementation assumes namespace
812        // packages cannot occur in typeshed.
813        insta::assert_debug_snapshot!(
814            list_snapshot(&db),
815            @r#"
816        [
817            Module::File("asyncio", "std-custom", "/typeshed/stdlib/asyncio/__init__.pyi", Package, None),
818            Module::File("collections", "std-custom", "/typeshed/stdlib/collections/__init__.pyi", Package, Some(Collections)),
819            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
820        ]
821        "#,
822        );
823    }
824
825    #[test]
826    fn stdlib_resolution_respects_versions_file_py39_nonexisting_modules() {
827        const VERSIONS: &str = "\
828            importlib: 3.9-   # 'Regular' package on py39+
829            xml: 3.8-3.8      # 'Regular' package on 3.8 only
830        ";
831
832        // Since our implementation assumes typeshed doesn't contain
833        // any namespace packages (as an optimization), this test case
834        // is modified from the corresponding test in the "resolve a
835        // file" implementation so that both namespace packages are
836        // just regular packages. ---AG
837        const STDLIB: &[FileSpec] = &[
838            ("importlib/__init__.pyi", ""),
839            ("importlib/abc.pyi", ""),
840            ("xml/__init__.pyi", ""),
841            ("xml/etree.pyi", ""),
842        ];
843
844        const TYPESHED: MockedTypeshed = MockedTypeshed {
845            stdlib_files: STDLIB,
846            versions: VERSIONS,
847        };
848
849        let TestCase { db, .. } = TestCaseBuilder::new()
850            .with_mocked_typeshed(TYPESHED)
851            .with_python_version(PythonVersion::PY39)
852            .build();
853
854        insta::assert_debug_snapshot!(
855            list_snapshot(&db),
856            @r#"
857        [
858            Module::File("importlib", "std-custom", "/typeshed/stdlib/importlib/__init__.pyi", Package, Some(ImportLib)),
859        ]
860        "#,
861        );
862    }
863
864    #[test]
865    fn first_party_precedence_over_stdlib() {
866        const SRC: &[FileSpec] = &[("functools.py", "def update_wrapper(): ...")];
867
868        const TYPESHED: MockedTypeshed = MockedTypeshed {
869            stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
870            versions: "functools: 3.8-",
871        };
872
873        let TestCase { db, .. } = TestCaseBuilder::new()
874            .with_src_files(SRC)
875            .with_mocked_typeshed(TYPESHED)
876            .with_python_version(PythonVersion::PY38)
877            .build();
878
879        insta::assert_debug_snapshot!(
880            list_snapshot(&db),
881            @r#"
882        [
883            Module::File("functools", "first-party", "/src/functools.py", Module, None),
884        ]
885        "#,
886        );
887    }
888
889    #[test]
890    fn stdlib_uses_vendored_typeshed_when_no_custom_typeshed_supplied() {
891        let TestCase { db, .. } = TestCaseBuilder::new().with_vendored_typeshed().build();
892
893        insta::assert_debug_snapshot!(
894            list_snapshot_filter(&db, |m| m.name(&db).as_str().contains("pydoc_data")),
895            @r#"
896        [
897            Module::File("pydoc_data", "std-vendored", "stdlib/pydoc_data/__init__.pyi", Package, None),
898        ]
899        "#,
900        );
901    }
902
903    #[test]
904    fn resolve_package() {
905        let TestCase { db, .. } = TestCaseBuilder::new()
906            .with_src_files(&[("foo/__init__.py", "print('Hello, world!'")])
907            .build();
908
909        insta::assert_debug_snapshot!(
910            list_snapshot(&db),
911            @r#"
912        [
913            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
914        ]
915        "#,
916        );
917    }
918
919    #[test]
920    fn package_priority_over_module() {
921        const SRC: &[FileSpec] = &[
922            ("foo/__init__.py", "print('Hello, world!')"),
923            ("foo.py", "print('Hello, world!')"),
924        ];
925
926        let TestCase { db, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
927
928        insta::assert_debug_snapshot!(
929            list_snapshot(&db),
930            @r#"
931        [
932            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
933        ]
934        "#,
935        );
936    }
937
938    #[test]
939    fn typing_stub_over_module() {
940        const SRC: &[FileSpec] = &[("foo.py", "print('Hello, world!')"), ("foo.pyi", "x: int")];
941
942        let TestCase { db, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
943
944        insta::assert_debug_snapshot!(
945            list_snapshot(&db),
946            @r#"
947        [
948            Module::File("foo", "first-party", "/src/foo.pyi", Module, None),
949        ]
950        "#,
951        );
952    }
953
954    #[test]
955    fn sub_packages() {
956        const SRC: &[FileSpec] = &[
957            ("foo/__init__.py", ""),
958            ("foo/bar/__init__.py", ""),
959            ("foo/bar/baz.py", "print('Hello, world!)'"),
960        ];
961
962        let TestCase { db, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
963
964        insta::assert_debug_snapshot!(
965            list_snapshot(&db),
966            @r#"
967        [
968            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
969        ]
970        "#,
971        );
972    }
973
974    #[test]
975    fn module_search_path_priority() {
976        let TestCase { db, .. } = TestCaseBuilder::new()
977            .with_src_files(&[("foo.py", "")])
978            .with_site_packages_files(&[("foo.py", "")])
979            .build();
980
981        insta::assert_debug_snapshot!(
982            list_snapshot(&db),
983            @r#"
984        [
985            Module::File("foo", "first-party", "/src/foo.py", Module, None),
986        ]
987        "#,
988        );
989    }
990
991    #[test]
992    #[cfg(target_family = "unix")]
993    fn symlink() -> anyhow::Result<()> {
994        use anyhow::Context;
995
996        let mut db = TestDb::new().with_python_version(PythonVersion::PY38);
997
998        let temp_dir = tempfile::TempDir::with_prefix("PREFIX-SENTINEL")?;
999        let root = temp_dir
1000            .path()
1001            .canonicalize()
1002            .context("Failed to canonicalize temp dir")?;
1003        let root = SystemPath::from_std_path(&root).unwrap();
1004        db.use_system(ruff_db::system::OsSystem::new(root));
1005
1006        let src = root.join("src");
1007        let site_packages = root.join("site-packages");
1008        let custom_typeshed = root.join("typeshed");
1009
1010        let foo = src.join("foo.py");
1011        let bar = src.join("bar.py");
1012
1013        std::fs::create_dir_all(src.as_std_path())?;
1014        std::fs::create_dir_all(site_packages.as_std_path())?;
1015        std::fs::create_dir_all(custom_typeshed.join("stdlib").as_std_path())?;
1016        std::fs::File::create(custom_typeshed.join("stdlib/VERSIONS").as_std_path())?;
1017
1018        std::fs::write(foo.as_std_path(), "")?;
1019        std::os::unix::fs::symlink(foo.as_std_path(), bar.as_std_path())?;
1020
1021        let settings = SearchPathSettings {
1022            src_roots: vec![src.clone()],
1023            custom_typeshed: Some(custom_typeshed),
1024            site_packages_paths: vec![site_packages],
1025            ..SearchPathSettings::empty()
1026        };
1027
1028        db.set_search_paths(
1029            settings
1030                .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1031                .expect("Valid search path settings"),
1032        );
1033
1034        db.files().try_add_root(&db, &src, FileRootKind::Project);
1035
1036        // From the original test in the "resolve this module"
1037        // implementation, this test seems to symlink a Python module
1038        // and assert that they are treated as two distinct modules.
1039        // That's what we capture here when listing modules as well.
1040        insta::with_settings!({
1041            // Temporary directory often have random chars in them, so
1042            // get rid of that part for a stable snapshot.
1043            filters => [(r#""\S*PREFIX-SENTINEL.*?/"#, r#""/"#)],
1044        }, {
1045            insta::assert_debug_snapshot!(
1046                list_snapshot(&db),
1047                @r#"
1048            [
1049                Module::File("bar", "first-party", "/src/bar.py", Module, None),
1050                Module::File("foo", "first-party", "/src/foo.py", Module, None),
1051            ]
1052            "#,
1053            );
1054        });
1055
1056        Ok(())
1057    }
1058
1059    // NOTE: I've omitted the
1060    // `deleting_an_unrelated_file_doesnt_change_module_resolution`
1061    // test here since it likely seems inapplicable to "listing"
1062    // modules. ---AG
1063
1064    #[test]
1065    fn adding_file_on_which_module_resolution_depends_invalidates_previously_failing_query_that_now_succeeds()
1066    -> anyhow::Result<()> {
1067        let TestCase { mut db, src, .. } = TestCaseBuilder::new().build();
1068        let foo_path = src.join("foo.py");
1069
1070        insta::assert_debug_snapshot!(
1071            list_snapshot(&db),
1072            @"[]",
1073        );
1074
1075        // Now write the foo file
1076        db.write_file(&foo_path, "x = 1")?;
1077
1078        insta::assert_debug_snapshot!(
1079            list_snapshot(&db),
1080            @r#"
1081        [
1082            Module::File("foo", "first-party", "/src/foo.py", Module, None),
1083        ]
1084        "#,
1085        );
1086
1087        Ok(())
1088    }
1089
1090    #[test]
1091    fn deeply_nested_file_does_not_invalidate_top_level_listing() -> anyhow::Result<()> {
1092        let TestCase { mut db, src, .. } = TestCaseBuilder::new()
1093            .with_src_files(&[("package/__init__.py", ""), ("package/sub/__init__.py", "")])
1094            .build();
1095
1096        list_modules(&db);
1097        db.clear_salsa_events();
1098
1099        db.write_file(src.join("package/sub/nested.py"), "")?;
1100        list_modules(&db);
1101
1102        let events = db.take_salsa_events();
1103        assert_function_query_was_not_run_by_name(&db, "list_modules_in", None, &events);
1104
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn sibling_file_does_not_invalidate_package_submodules() -> anyhow::Result<()> {
1110        let TestCase { mut db, src, .. } = TestCaseBuilder::new()
1111            .with_src_files(&[("package/__init__.py", "")])
1112            .build();
1113
1114        let package_id = {
1115            let package = list_modules(&db)
1116                .iter()
1117                .find(|module| module.name(&db).as_str() == "package")
1118                .copied()
1119                .expect("package to exist");
1120            package.all_submodules(&db);
1121            package.as_id()
1122        };
1123        db.clear_salsa_events();
1124
1125        db.write_file(src.join("sibling.py"), "")?;
1126        let package = list_modules(&db)
1127            .iter()
1128            .find(|module| module.name(&db).as_str() == "package")
1129            .copied()
1130            .expect("package to exist");
1131        package.all_submodules(&db);
1132
1133        let events = db.take_salsa_events();
1134        assert_function_query_was_not_run_by_name(
1135            &db,
1136            "all_submodule_names_for_package",
1137            Some(package_id),
1138            &events,
1139        );
1140
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn removing_file_on_which_module_resolution_depends_invalidates_previously_successful_query_that_now_fails()
1146    -> anyhow::Result<()> {
1147        const SRC: &[FileSpec] = &[("foo.py", "x = 1"), ("foo/__init__.py", "x = 2")];
1148
1149        let TestCase { mut db, src, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
1150        let foo_path = src.join("foo/__init__.py");
1151
1152        insta::assert_debug_snapshot!(
1153            list_snapshot(&db),
1154            @r#"
1155        [
1156            Module::File("foo", "first-party", "/src/foo/__init__.py", Package, None),
1157        ]
1158        "#,
1159        );
1160
1161        // Delete `foo/__init__.py` and the `foo` folder. `foo` should
1162        // now resolve to `foo.py`
1163        db.memory_file_system().remove_file(&foo_path)?;
1164        db.memory_file_system()
1165            .remove_directory(foo_path.parent().unwrap())?;
1166        // NOTE: This is present in the test for the "resolve this
1167        // module" implementation as well. It seems like it kind of
1168        // defeats the point to me. Shouldn't this be the thing we're
1169        // testing? ---AG
1170        File::sync_path(&mut db, &foo_path);
1171        File::sync_path(&mut db, foo_path.parent().unwrap());
1172
1173        insta::assert_debug_snapshot!(
1174            list_snapshot(&db),
1175            @r#"
1176        [
1177            Module::File("foo", "first-party", "/src/foo.py", Module, None),
1178        ]
1179        "#,
1180        );
1181
1182        Ok(())
1183    }
1184
1185    // Slightly changed from
1186    // `adding_file_to_search_path_with_lower_priority_does_not_invalidate_query`
1187    // to just check that adding a file doesn't change the results. (i.e., This is
1188    // no longer a test of caching.)
1189    #[test]
1190    fn adding_file_to_search_path_with_lower_priority_does_not_change_results() {
1191        const TYPESHED: MockedTypeshed = MockedTypeshed {
1192            versions: "functools: 3.8-",
1193            stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
1194        };
1195
1196        let TestCase {
1197            mut db,
1198            site_packages,
1199            ..
1200        } = TestCaseBuilder::new()
1201            .with_mocked_typeshed(TYPESHED)
1202            .with_python_version(PythonVersion::PY38)
1203            .build();
1204
1205        insta::assert_debug_snapshot!(
1206            list_snapshot(&db),
1207            @r#"
1208        [
1209            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
1210        ]
1211        "#,
1212        );
1213
1214        // Adding a file to site-packages does not invalidate the query,
1215        // since site-packages takes lower priority in the module resolution
1216        db.clear_salsa_events();
1217        let site_packages_functools_path = site_packages.join("functools.py");
1218        db.write_file(&site_packages_functools_path, "f: int")
1219            .unwrap();
1220
1221        insta::assert_debug_snapshot!(
1222            list_snapshot(&db),
1223            @r#"
1224        [
1225            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
1226        ]
1227        "#,
1228        );
1229    }
1230
1231    #[test]
1232    fn adding_file_to_search_path_with_higher_priority_invalidates_the_query() {
1233        const TYPESHED: MockedTypeshed = MockedTypeshed {
1234            versions: "functools: 3.8-",
1235            stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
1236        };
1237
1238        let TestCase { mut db, src, .. } = TestCaseBuilder::new()
1239            .with_mocked_typeshed(TYPESHED)
1240            .with_python_version(PythonVersion::PY38)
1241            .build();
1242
1243        insta::assert_debug_snapshot!(
1244            list_snapshot(&db),
1245            @r#"
1246        [
1247            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
1248        ]
1249        "#,
1250        );
1251
1252        // Adding a first-party file should do some kind of cache
1253        // invalidation here, since first-party files take higher
1254        // priority in module resolution:
1255        let src_functools_path = src.join("functools.py");
1256        db.write_file(&src_functools_path, "FOO: int").unwrap();
1257
1258        insta::assert_debug_snapshot!(
1259            list_snapshot(&db),
1260            @r#"
1261        [
1262            Module::File("functools", "first-party", "/src/functools.py", Module, None),
1263        ]
1264        "#,
1265        );
1266    }
1267
1268    #[test]
1269    fn deleting_file_from_higher_priority_search_path_invalidates_the_query() {
1270        const SRC: &[FileSpec] = &[("functools.py", "FOO: int")];
1271
1272        const TYPESHED: MockedTypeshed = MockedTypeshed {
1273            versions: "functools: 3.8-",
1274            stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
1275        };
1276
1277        let TestCase { mut db, src, .. } = TestCaseBuilder::new()
1278            .with_src_files(SRC)
1279            .with_mocked_typeshed(TYPESHED)
1280            .with_python_version(PythonVersion::PY38)
1281            .build();
1282        let src_functools_path = src.join("functools.py");
1283
1284        insta::assert_debug_snapshot!(
1285            list_snapshot(&db),
1286            @r#"
1287        [
1288            Module::File("functools", "first-party", "/src/functools.py", Module, None),
1289        ]
1290        "#,
1291        );
1292
1293        // If we now delete the first-party file,
1294        // it should resolve to the stdlib:
1295        db.memory_file_system()
1296            .remove_file(&src_functools_path)
1297            .unwrap();
1298        // NOTE: This is present in the test for the "resolve this
1299        // module" implementation as well. It seems like it kind of
1300        // defeats the point to me. Shouldn't this be the thing we're
1301        // testing? In any case, removing it results in the cache not
1302        // being invalidated. ---AG
1303        File::sync_path(&mut db, &src_functools_path);
1304
1305        insta::assert_debug_snapshot!(
1306            list_snapshot(&db),
1307            @r#"
1308        [
1309            Module::File("functools", "std-custom", "/typeshed/stdlib/functools.pyi", Module, Some(Functools)),
1310        ]
1311        "#,
1312        );
1313    }
1314
1315    #[test]
1316    fn editable_install_absolute_path() {
1317        const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
1318        let x_directory = [("/x/src/foo/__init__.py", ""), ("/x/src/foo/bar.py", "")];
1319
1320        let TestCase { mut db, .. } = TestCaseBuilder::new()
1321            .with_site_packages_files(SITE_PACKAGES)
1322            .with_library_root("/x")
1323            .build();
1324
1325        db.write_files(x_directory).unwrap();
1326
1327        insta::assert_debug_snapshot!(
1328            list_snapshot(&db),
1329            @r#"
1330        [
1331            Module::File("foo", "editable", "/x/src/foo/__init__.py", Package, None),
1332        ]
1333        "#,
1334        );
1335    }
1336
1337    #[test]
1338    fn editable_install_pth_file_with_whitespace() {
1339        const SITE_PACKAGES: &[FileSpec] = &[
1340            ("_foo.pth", "        /x/src"),
1341            ("_bar.pth", "/y/src        "),
1342        ];
1343        let external_files = [("/x/src/foo.py", ""), ("/y/src/bar.py", "")];
1344
1345        let TestCase { mut db, .. } = TestCaseBuilder::new()
1346            .with_site_packages_files(SITE_PACKAGES)
1347            .with_library_root("/y/src")
1348            .build();
1349
1350        db.write_files(external_files).unwrap();
1351
1352        // Lines with leading whitespace in `.pth` files do not parse,
1353        // so this excludes `foo`. Lines with trailing whitespace in
1354        // `.pth` files do parse, so this includes `bar`.
1355        insta::assert_debug_snapshot!(
1356            list_snapshot(&db),
1357            @r#"
1358        [
1359            Module::File("bar", "editable", "/y/src/bar.py", Module, None),
1360        ]
1361        "#,
1362        );
1363    }
1364
1365    #[test]
1366    fn editable_install_relative_path() {
1367        const SITE_PACKAGES: &[FileSpec] = &[
1368            ("_foo.pth", "../../x/../x/y/src"),
1369            ("../x/y/src/foo.pyi", ""),
1370        ];
1371
1372        let TestCase { db, .. } = TestCaseBuilder::new()
1373            .with_site_packages_files(SITE_PACKAGES)
1374            .with_library_root("/x")
1375            .build();
1376
1377        insta::assert_debug_snapshot!(
1378            list_snapshot(&db),
1379            @r#"
1380        [
1381            Module::File("foo", "editable", "/x/y/src/foo.pyi", Module, None),
1382        ]
1383        "#,
1384        );
1385    }
1386
1387    #[test]
1388    fn editable_install_multiple_pth_files_with_multiple_paths() {
1389        const COMPLEX_PTH_FILE: &str = "\
1390/
1391
1392# a comment
1393/baz
1394
1395import not_an_editable_install; do_something_else_crazy_dynamic()
1396
1397# another comment
1398spam
1399
1400not_a_directory
1401";
1402
1403        const SITE_PACKAGES: &[FileSpec] = &[
1404            ("_foo.pth", "../../x/../x/y/src"),
1405            ("_lots_of_others.pth", COMPLEX_PTH_FILE),
1406            ("../x/y/src/foo.pyi", ""),
1407            ("spam/spam.py", ""),
1408        ];
1409
1410        let root_files = [("/a.py", ""), ("/baz/b.py", "")];
1411
1412        let TestCase { mut db, .. } = TestCaseBuilder::new()
1413            .with_site_packages_files(SITE_PACKAGES)
1414            .with_library_root("/x/y/src")
1415            .with_library_root("/")
1416            .with_library_root("/baz")
1417            .build();
1418
1419        db.write_files(root_files).unwrap();
1420
1421        // NOTE: The `src`, `typeshed` and `x` namespace packages here
1422        // are a bit odd, but this seems to be a result of `/` in the
1423        // pth file. It's also consistent with "resolve this module,"
1424        // which will indeed happily resolve `src`, `typeshed` or `x`
1425        // as top-level modules. ---AG
1426        insta::assert_debug_snapshot!(
1427            list_snapshot(&db),
1428            @r#"
1429        [
1430            Module::File("a", "editable", "/a.py", Module, None),
1431            Module::File("b", "editable", "/baz/b.py", Module, None),
1432            Module::Namespace(ModuleName("baz")),
1433            Module::File("foo", "editable", "/x/y/src/foo.pyi", Module, None),
1434            Module::File("spam", "editable", "/site-packages/spam/spam.py", Module, None),
1435            Module::Namespace(ModuleName("src")),
1436            Module::Namespace(ModuleName("typeshed")),
1437            Module::Namespace(ModuleName("x")),
1438        ]
1439        "#,
1440        );
1441    }
1442
1443    #[test]
1444    fn module_resolution_paths_cached_between_different_module_resolutions() {
1445        const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src"), ("_bar.pth", "/y/src")];
1446        let external_directories = [("/x/src/foo.py", ""), ("/y/src/bar.py", "")];
1447
1448        let TestCase { mut db, .. } = TestCaseBuilder::new()
1449            .with_site_packages_files(SITE_PACKAGES)
1450            .with_library_root("/x")
1451            .with_library_root("/y")
1452            .build();
1453
1454        db.write_files(external_directories).unwrap();
1455
1456        insta::assert_debug_snapshot!(
1457            list_snapshot(&db),
1458            @r#"
1459        [
1460            Module::File("bar", "editable", "/y/src/bar.py", Module, None),
1461            Module::File("foo", "editable", "/x/src/foo.py", Module, None),
1462        ]
1463        "#,
1464        );
1465
1466        db.clear_salsa_events();
1467
1468        insta::assert_debug_snapshot!(
1469            list_snapshot(&db),
1470            @r#"
1471        [
1472            Module::File("bar", "editable", "/y/src/bar.py", Module, None),
1473            Module::File("foo", "editable", "/x/src/foo.py", Module, None),
1474        ]
1475        "#,
1476        );
1477
1478        let events = db.take_salsa_events();
1479        assert_function_query_was_not_run(
1480            &db,
1481            dynamic_resolution_paths,
1482            ModuleResolveModeIngredient::new(
1483                &db,
1484                db.resolver_environment(),
1485                ModuleResolveMode::Typing,
1486            ),
1487            &events,
1488        );
1489    }
1490
1491    #[test]
1492    fn deleting_pth_file_on_which_module_resolution_depends_invalidates_cache() {
1493        const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
1494        let x_directory = [("/x/src/foo.py", "")];
1495
1496        let TestCase {
1497            mut db,
1498            site_packages,
1499            ..
1500        } = TestCaseBuilder::new()
1501            .with_site_packages_files(SITE_PACKAGES)
1502            .with_library_root("/x")
1503            .build();
1504
1505        db.write_files(x_directory).unwrap();
1506
1507        insta::assert_debug_snapshot!(
1508            list_snapshot(&db),
1509            @r#"
1510        [
1511            Module::File("foo", "editable", "/x/src/foo.py", Module, None),
1512        ]
1513        "#,
1514        );
1515
1516        db.memory_file_system()
1517            .remove_file(site_packages.join("_foo.pth"))
1518            .unwrap();
1519        // NOTE: This is present in the test for the "resolve this
1520        // module" implementation as well. It seems like it kind of
1521        // defeats the point to me. Shouldn't this be the thing we're
1522        // testing? ---AG
1523        File::sync_path(&mut db, &site_packages.join("_foo.pth"));
1524
1525        insta::assert_debug_snapshot!(
1526            list_snapshot(&db),
1527            @"[]",
1528        );
1529    }
1530
1531    #[test]
1532    fn deleting_editable_install_on_which_module_resolution_depends_invalidates_cache() {
1533        const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
1534        let x_directory = [("/x/src/foo.py", "")];
1535
1536        let TestCase { mut db, .. } = TestCaseBuilder::new()
1537            .with_site_packages_files(SITE_PACKAGES)
1538            .with_library_root("/x")
1539            .build();
1540        let src_path = SystemPathBuf::from("/x/src");
1541
1542        db.write_files(x_directory).unwrap();
1543
1544        insta::assert_debug_snapshot!(
1545            list_snapshot(&db),
1546            @r#"
1547        [
1548            Module::File("foo", "editable", "/x/src/foo.py", Module, None),
1549        ]
1550        "#,
1551        );
1552
1553        db.memory_file_system()
1554            .remove_file(src_path.join("foo.py"))
1555            .unwrap();
1556        db.memory_file_system().remove_directory(&src_path).unwrap();
1557        // NOTE: This is present in the test for the "resolve this
1558        // module" implementation as well. It seems like it kind of
1559        // defeats the point to me. Shouldn't this be the thing we're
1560        // testing? ---AG
1561        File::sync_path(&mut db, &src_path.join("foo.py"));
1562        File::sync_path(&mut db, &src_path);
1563
1564        insta::assert_debug_snapshot!(
1565            list_snapshot(&db),
1566            @"[]",
1567        );
1568    }
1569
1570    #[test]
1571    fn editable_installs_into_first_party_search_path() {
1572        let mut db = TestDb::new();
1573
1574        let src = SystemPath::new("/src");
1575        let venv_site_packages = SystemPathBuf::from("/venv-site-packages");
1576        let site_packages_pth = venv_site_packages.join("foo.pth");
1577        let editable_install_location = src.join("x/y/a.py");
1578
1579        db.write_files([
1580            (&site_packages_pth, "/src/x/y/"),
1581            (&editable_install_location, ""),
1582        ])
1583        .unwrap();
1584
1585        db.files()
1586            .try_add_root(&db, SystemPath::new("/src"), FileRootKind::Project);
1587
1588        let settings = SearchPathSettings {
1589            site_packages_paths: vec![venv_site_packages],
1590            ..SearchPathSettings::new(vec![src.to_path_buf()])
1591        };
1592
1593        db.set_search_paths(
1594            settings
1595                .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1596                .expect("Valid search path settings"),
1597        );
1598
1599        insta::assert_debug_snapshot!(
1600            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "a"),
1601            @r#"
1602        [
1603            Module::File("a", "editable", "/src/x/y/a.py", Module, None),
1604        ]
1605        "#,
1606        );
1607
1608        let editable_root = db
1609            .files()
1610            .root(&db, &editable_install_location)
1611            .expect("file root for editable install");
1612
1613        assert_eq!(editable_root.path(&db), src);
1614    }
1615
1616    #[test]
1617    fn multiple_site_packages_with_editables() {
1618        let mut db = TestDb::new();
1619
1620        let venv_site_packages = SystemPathBuf::from("/venv-site-packages");
1621        let site_packages_pth = venv_site_packages.join("foo.pth");
1622        let system_site_packages = SystemPathBuf::from("/system-site-packages");
1623        let editable_install_location = SystemPathBuf::from("/x/y/a.py");
1624        let system_site_packages_location = system_site_packages.join("a.py");
1625
1626        db.memory_file_system()
1627            .create_directory_all("/src")
1628            .unwrap();
1629        db.write_files([
1630            (&site_packages_pth, "/x/y"),
1631            (&editable_install_location, ""),
1632            (&system_site_packages_location, ""),
1633        ])
1634        .unwrap();
1635
1636        db.files()
1637            .try_add_root(&db, SystemPath::new("/src"), FileRootKind::Project);
1638
1639        let settings = SearchPathSettings {
1640            site_packages_paths: vec![venv_site_packages, system_site_packages],
1641            ..SearchPathSettings::new(vec![SystemPathBuf::from("/src")])
1642        };
1643
1644        db.set_search_paths(
1645            settings
1646                .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1647                .expect("Valid search path settings"),
1648        );
1649
1650        // The editable installs discovered from the `.pth` file in the
1651        // first `site-packages` directory take precedence over the
1652        // second `site-packages` directory...
1653        insta::assert_debug_snapshot!(
1654            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "a"),
1655            @r#"
1656        [
1657            Module::File("a", "editable", "/x/y/a.py", Module, None),
1658        ]
1659        "#,
1660        );
1661
1662        db.memory_file_system()
1663            .remove_file(&site_packages_pth)
1664            .unwrap();
1665        // NOTE: This is present in the test for the "resolve this
1666        // module" implementation as well. It seems like it kind of
1667        // defeats the point to me. Shouldn't this be the thing we're
1668        // testing? ---AG
1669        File::sync_path(&mut db, &site_packages_pth);
1670
1671        // ...But now that the `.pth` file in the first `site-packages`
1672        // directory has been deleted, the editable install no longer
1673        // exists, so the module now resolves to the file in the second
1674        // `site-packages` directory
1675        insta::assert_debug_snapshot!(
1676            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "a"),
1677            @r#"
1678        [
1679            Module::File("a", "site-packages", "/system-site-packages/a.py", Module, None),
1680        ]
1681        "#,
1682        );
1683    }
1684
1685    #[test]
1686    #[cfg(unix)]
1687    fn case_sensitive_resolution_with_symlinked_directory() -> anyhow::Result<()> {
1688        use anyhow::Context as _;
1689
1690        let temp_dir = tempfile::TempDir::with_prefix("PREFIX-SENTINEL")?;
1691        let root = SystemPathBuf::from_path_buf(
1692            temp_dir
1693                .path()
1694                .canonicalize()
1695                .context("Failed to canonicalized path")?,
1696        )
1697        .expect("UTF8 path for temp dir");
1698
1699        let mut db = TestDb::new();
1700
1701        let src = root.join("src");
1702        let a_package_target = root.join("a-package");
1703        let a_src = src.join("a");
1704
1705        db.use_system(ruff_db::system::OsSystem::new(&root));
1706
1707        db.write_file(
1708            a_package_target.join("__init__.py"),
1709            "class Foo: x: int = 4",
1710        )
1711        .context("Failed to write `a-package/__init__.py`")?;
1712
1713        db.write_file(src.join("main.py"), "print('Hy')")
1714            .context("Failed to write `main.py`")?;
1715
1716        // The symlink triggers the slow-path in the `OsSystem`'s
1717        // `exists_path_case_sensitive` code because canonicalizing the path
1718        // for `a/__init__.py` results in `a-package/__init__.py`
1719        std::os::unix::fs::symlink(a_package_target.as_std_path(), a_src.as_std_path())
1720            .context("Failed to symlink `src/a` to `a-package`")?;
1721
1722        db.files().try_add_root(&db, &root, FileRootKind::Project);
1723
1724        let settings = SearchPathSettings::new(vec![src]);
1725        let search_paths = settings
1726            .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1727            .expect("valid search path settings");
1728        db.set_search_paths(search_paths);
1729
1730        insta::with_settings!({
1731            // Temporary directory often have random chars in them, so
1732            // get rid of that part for a stable snapshot.
1733            filters => [(r#""\S*PREFIX-SENTINEL.*?/"#, r#""/"#)],
1734        }, {
1735            insta::assert_debug_snapshot!(
1736                list_snapshot_filter(&db, |m| matches!(m.name(&db).as_str(), "A" | "a")),
1737                @r#"
1738            [
1739                Module::File("a", "first-party", "/src/a/__init__.py", Package, None),
1740            ]
1741            "#,
1742            );
1743        });
1744
1745        Ok(())
1746    }
1747
1748    #[test]
1749    fn file_to_module_where_one_search_path_is_subdirectory_of_other() {
1750        let project_directory = SystemPathBuf::from("/project");
1751        let site_packages = project_directory.join(".venv/lib/python3.13/site-packages");
1752        let installed_foo_module = site_packages.join("foo/__init__.py");
1753
1754        let mut db = TestDb::new();
1755        db.write_file(&installed_foo_module, "").unwrap();
1756
1757        db.files()
1758            .try_add_root(&db, &project_directory, FileRootKind::Project);
1759
1760        let settings = SearchPathSettings {
1761            site_packages_paths: vec![site_packages],
1762            ..SearchPathSettings::new(vec![project_directory])
1763        };
1764        db.set_search_paths(
1765            settings
1766                .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1767                .unwrap(),
1768        );
1769
1770        insta::assert_debug_snapshot!(
1771            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "foo"),
1772            @r#"
1773        [
1774            Module::File("foo", "site-packages", "/project/.venv/lib/python3.13/site-packages/foo/__init__.py", Package, None),
1775        ]
1776        "#,
1777        );
1778    }
1779
1780    #[test]
1781    fn namespace_package() {
1782        let TestCase { db, .. } = TestCaseBuilder::new()
1783            .with_src_files(&[("foo/bar.py", "")])
1784            .build();
1785
1786        insta::assert_debug_snapshot!(
1787            list_snapshot(&db),
1788            @r#"
1789        [
1790            Module::Namespace(ModuleName("foo")),
1791        ]
1792        "#,
1793        );
1794    }
1795
1796    /// Regardless of search path priority, if we have a "regular" package of
1797    /// the same name as a namespace package, the regular package always takes
1798    /// priority.
1799    #[test]
1800    fn namespace_package_precedence() {
1801        let TestCase { db, .. } = TestCaseBuilder::new()
1802            .with_src_files(&[("foo/bar.py", "")])
1803            .with_site_packages_files(&[("foo.py", "")])
1804            .build();
1805
1806        insta::assert_debug_snapshot!(
1807            list_snapshot(&db),
1808            @r#"
1809        [
1810            Module::File("foo", "site-packages", "/site-packages/foo.py", Module, None),
1811        ]
1812        "#,
1813        );
1814
1815        let TestCase { db, .. } = TestCaseBuilder::new()
1816            .with_src_files(&[("foo.py", "")])
1817            .with_site_packages_files(&[("foo/bar.py", "")])
1818            .build();
1819
1820        insta::assert_debug_snapshot!(
1821            list_snapshot(&db),
1822            @r#"
1823        [
1824            Module::File("foo", "first-party", "/src/foo.py", Module, None),
1825        ]
1826        "#,
1827        );
1828    }
1829
1830    #[test]
1831    fn stub_package() {
1832        let TestCase { db, .. } = TestCaseBuilder::new()
1833            .with_src_files(&[("foo-stubs/__init__.pyi", "")])
1834            .build();
1835
1836        insta::assert_debug_snapshot!(
1837            list_snapshot(&db),
1838            @r#"
1839        [
1840            Module::File("foo", "first-party", "/src/foo-stubs/__init__.pyi", Package, None),
1841        ]
1842        "#,
1843        );
1844    }
1845
1846    #[test]
1847    fn stub_file_module_not_allowed() {
1848        let TestCase { db, .. } = TestCaseBuilder::new()
1849            .with_src_files(&[("foo-stubs.pyi", "")])
1850            .build();
1851
1852        insta::assert_debug_snapshot!(
1853            list_snapshot(&db),
1854            @"[]",
1855        );
1856    }
1857
1858    #[test]
1859    fn stub_package_precedence() {
1860        let TestCase { db, .. } = TestCaseBuilder::new()
1861            .with_src_files(&[("foo/__init__.py", ""), ("foo-stubs/__init__.pyi", "")])
1862            .build();
1863
1864        insta::assert_debug_snapshot!(
1865            list_snapshot(&db),
1866            @r#"
1867        [
1868            Module::File("foo", "first-party", "/src/foo-stubs/__init__.pyi", Package, None),
1869        ]
1870        "#,
1871        );
1872    }
1873
1874    #[test]
1875    fn stub_package_not_allowed_in_typeshed() {
1876        const TYPESHED: MockedTypeshed = MockedTypeshed {
1877            versions: "foo: 3.8-",
1878            stdlib_files: &[("foo-stubs/__init__.pyi", "")],
1879        };
1880
1881        let TestCase { db, .. } = TestCaseBuilder::new()
1882            .with_mocked_typeshed(TYPESHED)
1883            .with_python_version(PythonVersion::PY38)
1884            .build();
1885
1886        insta::assert_debug_snapshot!(
1887            list_snapshot(&db),
1888            @"[]",
1889        );
1890    }
1891
1892    /// This is a regression test for mishandling of file root matching.
1893    ///
1894    /// In particular, in some cases, `/` is added as a search root. This
1895    /// should in turn match everything. But the way we were setting up the
1896    /// wildcard for matching was incorrect for this one specific case. That in
1897    /// turn meant that the module resolver couldn't find an appropriate file
1898    /// root which in turn caused a panic.
1899    ///
1900    /// See: <https://github.com/astral-sh/ty/issues/1277>
1901    #[test]
1902    fn root_directory_for_search_path_is_okay() {
1903        let project_directory = SystemPathBuf::from("/project");
1904        let installed_foo_module = project_directory.join("foo/__init__.py");
1905
1906        let mut db = TestDb::new();
1907        db.write_file(&installed_foo_module, "").unwrap();
1908
1909        db.files()
1910            .try_add_root(&db, SystemPath::new("/"), FileRootKind::Project);
1911
1912        let settings = SearchPathSettings::new(vec![project_directory]);
1913        let search_paths = settings
1914            .to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
1915            .expect("Valid search path settings");
1916        db.set_search_paths(search_paths);
1917
1918        insta::assert_debug_snapshot!(
1919            list_snapshot_filter(&db, |m| m.name(&db).as_str() == "foo"),
1920            @r#"
1921        [
1922            Module::File("foo", "first-party", "/project/foo/__init__.py", Package, None),
1923        ]
1924        "#,
1925        );
1926    }
1927}