Skip to main content

ty_module_resolver/
path.rs

1//! Internal abstractions for differentiating between different kinds of search paths.
2
3use std::fmt;
4use std::sync::Arc;
5
6use camino::{Utf8Path, Utf8PathBuf};
7use ruff_db::files::{
8    File, FilePath, directory_listing, system_path_to_file, vendored_path_to_file,
9};
10use ruff_db::source::source_text;
11use ruff_db::system::{System, SystemPath, SystemPathBuf};
12use ruff_db::vendored::{VendoredPath, VendoredPathBuf};
13
14use crate::Db;
15use crate::module_name::ModuleName;
16use crate::resolve::{PyTyped, ResolverContext};
17use crate::typeshed::TypeshedVersionsQueryResult;
18
19/// A path that points to a Python module.
20///
21/// A `ModulePath` is made up of two elements:
22/// - The [`SearchPath`] that was used to find this module.
23///   This could point to a directory on disk or a directory
24///   in the vendored zip archive.
25/// - A relative path from the search path to the file
26///   that contains the source code of the Python module in question.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub(crate) struct ModulePath {
29    search_path: SearchPath,
30    relative_path: Utf8PathBuf,
31}
32
33impl ModulePath {
34    #[must_use]
35    fn is_standard_library(&self) -> bool {
36        matches!(
37            &*self.search_path.0,
38            SearchPathInner::StandardLibraryCustom(_) | SearchPathInner::StandardLibraryVendored(_)
39        )
40    }
41
42    /// Returns true if this is a path to a "stub file."
43    ///
44    /// i.e., A module whose file extension is `pyi`.
45    #[must_use]
46    pub(crate) fn is_stub_file(&self) -> bool {
47        self.relative_path.extension() == Some("pyi")
48    }
49
50    /// Returns true if this is a path to a "stub package."
51    ///
52    /// i.e., A module whose top-most parent package corresponds to a
53    /// directory with a `-stubs` suffix in its name.
54    #[must_use]
55    pub(crate) fn is_stub_package(&self) -> bool {
56        let Some(first) = self.relative_path.components().next() else {
57            return false;
58        };
59        first.as_str().ends_with("-stubs")
60    }
61
62    pub(crate) fn push(&mut self, component: &str) {
63        if let Some(component_extension) = camino::Utf8Path::new(component).extension() {
64            assert!(
65                self.relative_path.extension().is_none(),
66                "Cannot push part {component} to {self:?}, which already has an extension"
67            );
68            if self.is_standard_library() {
69                assert_eq!(
70                    component_extension, "pyi",
71                    "Extension must be `pyi`; got `{component_extension}`"
72                );
73            } else {
74                assert!(
75                    matches!(component_extension, "pyi" | "py"),
76                    "Extension must be `py` or `pyi`; got `{component_extension}`"
77                );
78            }
79        }
80        self.relative_path.push(component);
81    }
82
83    pub(crate) fn pop(&mut self) -> bool {
84        self.relative_path.pop()
85    }
86
87    pub(super) fn search_path(&self) -> &SearchPath {
88        &self.search_path
89    }
90
91    #[must_use]
92    pub(super) fn is_directory(&self, resolver: &ResolverContext) -> bool {
93        let ModulePath {
94            search_path,
95            relative_path,
96        } = self;
97        match &*search_path.0 {
98            SearchPathInner::Extra(search_path)
99            | SearchPathInner::FirstParty(search_path)
100            | SearchPathInner::SitePackages(search_path)
101            | SearchPathInner::Editable(search_path)
102            | SearchPathInner::StandardLibraryReal(search_path) => {
103                system_path_is_directory(resolver.db, &search_path.join(relative_path))
104            }
105            SearchPathInner::StandardLibraryCustom(stdlib_root) => {
106                match query_stdlib_version(relative_path, resolver) {
107                    TypeshedVersionsQueryResult::DoesNotExist => false,
108                    TypeshedVersionsQueryResult::Exists
109                    | TypeshedVersionsQueryResult::MaybeExists => {
110                        system_path_is_directory(resolver.db, &stdlib_root.join(relative_path))
111                    }
112                }
113            }
114            SearchPathInner::StandardLibraryVendored(stdlib_root) => {
115                match query_stdlib_version(relative_path, resolver) {
116                    TypeshedVersionsQueryResult::DoesNotExist => false,
117                    TypeshedVersionsQueryResult::Exists
118                    | TypeshedVersionsQueryResult::MaybeExists => resolver
119                        .vendored()
120                        .is_directory(stdlib_root.join(relative_path)),
121                }
122            }
123        }
124    }
125
126    #[must_use]
127    pub(super) fn is_regular_package(&self, resolver: &ResolverContext) -> bool {
128        let ModulePath {
129            search_path,
130            relative_path,
131        } = self;
132
133        match &*search_path.0 {
134            SearchPathInner::Extra(search_path)
135            | SearchPathInner::FirstParty(search_path)
136            | SearchPathInner::SitePackages(search_path)
137            | SearchPathInner::Editable(search_path) => {
138                let absolute_path = search_path.join(relative_path);
139
140                directory_contains_file(
141                    resolver.db,
142                    &absolute_path,
143                    &["__init__.py", "__init__.pyi"],
144                )
145            }
146            SearchPathInner::StandardLibraryReal(search_path) => {
147                let absolute_path = search_path.join(relative_path);
148
149                directory_contains_file(resolver.db, &absolute_path, &["__init__.py"])
150            }
151            SearchPathInner::StandardLibraryCustom(search_path) => {
152                match query_stdlib_version(relative_path, resolver) {
153                    TypeshedVersionsQueryResult::DoesNotExist => false,
154                    TypeshedVersionsQueryResult::Exists
155                    | TypeshedVersionsQueryResult::MaybeExists => directory_contains_file(
156                        resolver.db,
157                        &search_path.join(relative_path),
158                        &["__init__.pyi"],
159                    ),
160                }
161            }
162            SearchPathInner::StandardLibraryVendored(search_path) => {
163                match query_stdlib_version(relative_path, resolver) {
164                    TypeshedVersionsQueryResult::DoesNotExist => false,
165                    TypeshedVersionsQueryResult::Exists
166                    | TypeshedVersionsQueryResult::MaybeExists => resolver
167                        .vendored()
168                        .exists(search_path.join(relative_path).join("__init__.pyi")),
169                }
170            }
171        }
172    }
173
174    /// Get the `py.typed` info for this package (not considering parent packages)
175    pub(super) fn py_typed(&self, resolver: &ResolverContext) -> PyTyped {
176        let Some(py_typed_file) = self.to_system_path().and_then(|path| {
177            if !directory_contains_file(resolver.db, &path, &["py.typed"]) {
178                return None;
179            }
180            let py_typed_path = path.join("py.typed");
181            system_path_to_file(resolver.db, py_typed_path).ok()
182        }) else {
183            return PyTyped::Untyped;
184        };
185
186        // Different module names revisit the same package. Share the tracked contents instead of
187        // reading its marker from disk again for every module resolution.
188        let py_typed_contents = source_text(resolver.db, py_typed_file);
189        // If we fail to read it let's say that's like it doesn't exist
190        // (right now the difference between Untyped and Full is academic)
191        if py_typed_contents.read_error().is_some() {
192            return PyTyped::Untyped;
193        }
194
195        // The python typing spec says to look for "partial\n" but in the wild we've seen:
196        //
197        // * PARTIAL\n
198        // * partial\\n (as in they typed "\n")
199        // * partial/n
200        //
201        // since the py.typed file never really grew any other contents, let's be permissive
202        if py_typed_contents.to_ascii_lowercase().contains("partial") {
203            PyTyped::Partial
204        } else {
205            PyTyped::Full
206        }
207    }
208
209    pub(super) fn to_system_path(&self) -> Option<SystemPathBuf> {
210        let ModulePath {
211            search_path,
212            relative_path,
213        } = self;
214        match &*search_path.0 {
215            SearchPathInner::Extra(search_path)
216            | SearchPathInner::FirstParty(search_path)
217            | SearchPathInner::SitePackages(search_path)
218            | SearchPathInner::Editable(search_path) => Some(search_path.join(relative_path)),
219            SearchPathInner::StandardLibraryReal(stdlib_root)
220            | SearchPathInner::StandardLibraryCustom(stdlib_root) => {
221                Some(stdlib_root.join(relative_path))
222            }
223            SearchPathInner::StandardLibraryVendored(_) => None,
224        }
225    }
226
227    #[must_use]
228    pub(super) fn to_file(&self, resolver: &ResolverContext) -> Option<File> {
229        let db = resolver.db;
230        let ModulePath {
231            search_path,
232            relative_path,
233        } = self;
234        match &*search_path.0 {
235            SearchPathInner::Extra(search_path)
236            | SearchPathInner::FirstParty(search_path)
237            | SearchPathInner::SitePackages(search_path)
238            | SearchPathInner::Editable(search_path) => {
239                system_path_to_file_if_listed(db, &search_path.join(relative_path))
240            }
241            SearchPathInner::StandardLibraryReal(search_path) => {
242                system_path_to_file_if_listed(db, &search_path.join(relative_path))
243            }
244            SearchPathInner::StandardLibraryCustom(stdlib_root) => {
245                match query_stdlib_version(relative_path, resolver) {
246                    TypeshedVersionsQueryResult::DoesNotExist => None,
247                    TypeshedVersionsQueryResult::Exists
248                    | TypeshedVersionsQueryResult::MaybeExists => {
249                        system_path_to_file_if_listed(db, &stdlib_root.join(relative_path))
250                    }
251                }
252            }
253            SearchPathInner::StandardLibraryVendored(stdlib_root) => {
254                match query_stdlib_version(relative_path, resolver) {
255                    TypeshedVersionsQueryResult::DoesNotExist => None,
256                    TypeshedVersionsQueryResult::Exists
257                    | TypeshedVersionsQueryResult::MaybeExists => {
258                        vendored_path_to_file(db, stdlib_root.join(relative_path)).ok()
259                    }
260                }
261            }
262        }
263    }
264
265    #[must_use]
266    pub(crate) fn to_module_name(&self) -> Option<ModuleName> {
267        fn strip_stubs(component: &str) -> &str {
268            component.strip_suffix("-stubs").unwrap_or(component)
269        }
270
271        let ModulePath {
272            search_path: _,
273            relative_path,
274        } = self;
275        if self.is_standard_library() {
276            stdlib_path_to_module_name(relative_path)
277        } else {
278            let parent = relative_path.parent()?;
279            let name = relative_path.file_stem()?;
280            if parent.as_str().is_empty() {
281                // Stubs should only be stripped when there is no
282                // extension. e.g., `foo-stubs` should be stripped
283                // by not `foo-stubs.pyi`. In the latter case,
284                // `ModuleName::new` will fail (which is what we want).
285                return ModuleName::new(if relative_path.extension().is_some() {
286                    name
287                } else {
288                    strip_stubs(relative_path.as_str())
289                });
290            }
291
292            let parent_components = parent.components().enumerate().map(|(index, component)| {
293                let component = component.as_str();
294
295                // For stub packages, strip the `-stubs` suffix from
296                // the first component because it isn't a valid module
297                // name part AND the module name is the name without
298                // the `-stubs`.
299                if index == 0 {
300                    strip_stubs(component)
301                } else {
302                    component
303                }
304            });
305
306            let skip_final_part =
307                relative_path.ends_with("__init__.py") || relative_path.ends_with("__init__.pyi");
308            if skip_final_part {
309                ModuleName::from_components(parent_components)
310            } else {
311                ModuleName::from_components(parent_components.chain([name]))
312            }
313        }
314    }
315
316    #[must_use]
317    pub(crate) fn with_pyi_extension(&self) -> Self {
318        let ModulePath {
319            search_path,
320            relative_path,
321        } = self;
322        ModulePath {
323            search_path: search_path.clone(),
324            relative_path: relative_path.with_extension("pyi"),
325        }
326    }
327
328    #[must_use]
329    pub(crate) fn with_py_extension(&self) -> Option<Self> {
330        if self.is_standard_library() {
331            return None;
332        }
333        let ModulePath {
334            search_path,
335            relative_path,
336        } = self;
337        Some(ModulePath {
338            search_path: search_path.clone(),
339            relative_path: relative_path.with_extension("py"),
340        })
341    }
342
343    pub(crate) fn into_search_path(self) -> SearchPath {
344        self.search_path
345    }
346}
347
348impl PartialEq<SystemPathBuf> for ModulePath {
349    fn eq(&self, other: &SystemPathBuf) -> bool {
350        let ModulePath {
351            search_path,
352            relative_path,
353        } = self;
354        search_path
355            .as_system_path()
356            .and_then(|search_path| other.strip_prefix(search_path).ok())
357            .is_some_and(|other_relative_path| other_relative_path.as_utf8_path() == relative_path)
358    }
359}
360
361impl PartialEq<ModulePath> for SystemPathBuf {
362    fn eq(&self, other: &ModulePath) -> bool {
363        other.eq(self)
364    }
365}
366
367impl PartialEq<VendoredPathBuf> for ModulePath {
368    fn eq(&self, other: &VendoredPathBuf) -> bool {
369        let ModulePath {
370            search_path,
371            relative_path,
372        } = self;
373        search_path
374            .as_vendored_path()
375            .and_then(|search_path| other.strip_prefix(search_path).ok())
376            .is_some_and(|other_relative_path| other_relative_path.as_utf8_path() == relative_path)
377    }
378}
379
380impl PartialEq<ModulePath> for VendoredPathBuf {
381    fn eq(&self, other: &ModulePath) -> bool {
382        other.eq(self)
383    }
384}
385
386fn directory_contains_file(db: &dyn Db, directory: &SystemPath, names: &[&str]) -> bool {
387    let Ok(listing) = directory_listing(db, directory) else {
388        return false;
389    };
390
391    names
392        .iter()
393        .any(|name| listing.entry_is_file(db, directory, name))
394}
395
396fn system_path_to_file_if_listed(db: &dyn Db, path: &SystemPath) -> Option<File> {
397    let Some((parent, name)) = path.parent().zip(path.file_name()) else {
398        return system_path_to_file(db, path).ok();
399    };
400
401    let listing = directory_listing(db, parent).ok()?;
402    if listing.entry_is_file(db, parent, name) {
403        system_path_to_file(db, path).ok()
404    } else {
405        None
406    }
407}
408
409fn system_path_is_directory(db: &dyn Db, path: &SystemPath) -> bool {
410    let Some((parent, name)) = path.parent().zip(path.file_name()) else {
411        return db.system().is_directory(path);
412    };
413
414    directory_listing(db, parent).is_ok_and(|listing| listing.entry_is_directory(db, parent, name))
415}
416
417#[must_use]
418fn stdlib_path_to_module_name(relative_path: &Utf8Path) -> Option<ModuleName> {
419    let parent_components = relative_path
420        .parent()?
421        .components()
422        .map(|component| component.as_str());
423    let skip_final_part = relative_path.ends_with("__init__.pyi");
424    if skip_final_part {
425        ModuleName::from_components(parent_components)
426    } else {
427        ModuleName::from_components(parent_components.chain(relative_path.file_stem()))
428    }
429}
430
431#[must_use]
432fn query_stdlib_version(
433    relative_path: &Utf8Path,
434    context: &ResolverContext,
435) -> TypeshedVersionsQueryResult {
436    let Some(module_name) = stdlib_path_to_module_name(relative_path) else {
437        return TypeshedVersionsQueryResult::DoesNotExist;
438    };
439    context
440        .resolver_environment
441        .search_paths(context.db)
442        .typeshed_versions()
443        .query_module(
444            &module_name,
445            context.resolver_environment.python_version(context.db),
446        )
447}
448
449#[derive(Debug, thiserror::Error)]
450pub enum SearchPathError {
451    /// The path provided by the user was not a directory
452    #[error("{0} does not point to a directory")]
453    NotADirectory(SystemPathBuf),
454
455    /// The path provided by the user is a directory,
456    /// but no `stdlib/` subdirectory exists.
457    /// (This is only relevant for stdlib search paths.)
458    #[error("The directory at {0} has no `stdlib/` subdirectory")]
459    NoStdlibSubdirectory(SystemPathBuf),
460}
461
462type SearchPathResult<T> = Result<T, SearchPathError>;
463
464#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
465enum SearchPathInner {
466    Extra(SystemPathBuf),
467    FirstParty(SystemPathBuf),
468    StandardLibraryCustom(SystemPathBuf),
469    StandardLibraryVendored(VendoredPathBuf),
470    StandardLibraryReal(SystemPathBuf),
471    SitePackages(SystemPathBuf),
472    Editable(SystemPathBuf),
473}
474
475/// Unification of the various kinds of search paths
476/// that can be used to locate Python modules.
477///
478/// The different kinds of search paths are:
479/// - "Extra" search paths: these go at the start of the module resolution order
480/// - First-party search paths: the user code that we are directly invoked on.
481/// - Standard-library search paths: these come in three different forms:
482///   - Custom standard-library search paths: paths provided by the user
483///     pointing to a custom typeshed directory on disk
484///   - Vendored standard-library search paths: paths pointing to a directory
485///     in the vendored zip archive.
486///   - Real standard-library search paths: path pointing to a directory
487///     of the real python stdlib for the environment.
488/// - Site-packages search paths: search paths that point to the `site-packages`
489///   directory, in which packages are installed from ``PyPI``.
490/// - Editable search paths: Additional search paths added to the end of the module
491///   resolution order. We discover these by iterating through `.pth` files in
492///   the `site-packages` directory and searching for lines in those `.pth` files
493///   that point to existing directories on disk. Such lines indicate editable
494///   installations, which will be appended to `sys.path` at runtime,
495///   and thus should also be considered valid search paths for our purposes.
496///
497/// For some of the above categories, there may be an arbitrary number
498/// in any given list of search paths: for example, the "Extra" category
499/// or the "Editable" category. For the "First-party", "Site-packages"
500/// and "Standard-library" categories, however, there will always be exactly
501/// one search path from that category in any given list of search paths.
502#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
503pub struct SearchPath(Arc<SearchPathInner>);
504
505impl SearchPath {
506    fn directory_path(system: &dyn System, root: SystemPathBuf) -> SearchPathResult<SystemPathBuf> {
507        if system.is_directory(&root) {
508            Ok(root)
509        } else {
510            Err(SearchPathError::NotADirectory(root))
511        }
512    }
513
514    /// Create a new "Extra" search path
515    pub(crate) fn extra(system: &dyn System, root: SystemPathBuf) -> SearchPathResult<Self> {
516        Ok(Self(Arc::new(SearchPathInner::Extra(
517            Self::directory_path(system, root)?,
518        ))))
519    }
520
521    /// Create a new first-party search path, pointing to the user code we were directly invoked on
522    pub(crate) fn first_party(system: &dyn System, root: SystemPathBuf) -> SearchPathResult<Self> {
523        Ok(Self(Arc::new(SearchPathInner::FirstParty(
524            Self::directory_path(system, root)?,
525        ))))
526    }
527
528    /// Create a new standard-library search path pointing to a custom directory on disk
529    pub(crate) fn custom_stdlib(
530        system: &dyn System,
531        typeshed: &SystemPath,
532    ) -> SearchPathResult<Self> {
533        if !system.is_directory(typeshed) {
534            return Err(SearchPathError::NotADirectory(typeshed.to_path_buf()));
535        }
536
537        let stdlib =
538            Self::directory_path(system, typeshed.join("stdlib")).map_err(|err| match err {
539                SearchPathError::NotADirectory(_) => {
540                    SearchPathError::NoStdlibSubdirectory(typeshed.to_path_buf())
541                }
542                SearchPathError::NoStdlibSubdirectory(_) => err,
543            })?;
544
545        Ok(Self(Arc::new(SearchPathInner::StandardLibraryCustom(
546            stdlib,
547        ))))
548    }
549
550    /// Create a new search path pointing to the `stdlib/` subdirectory in the vendored zip archive
551    #[must_use]
552    pub(crate) fn vendored_stdlib() -> Self {
553        Self(Arc::new(SearchPathInner::StandardLibraryVendored(
554            VendoredPathBuf::from("stdlib"),
555        )))
556    }
557
558    /// Create a new search path pointing to the real stdlib of a python install
559    pub(crate) fn real_stdlib(system: &dyn System, root: SystemPathBuf) -> SearchPathResult<Self> {
560        Ok(Self(Arc::new(SearchPathInner::StandardLibraryReal(
561            Self::directory_path(system, root)?,
562        ))))
563    }
564
565    /// Create a new search path pointing to the `site-packages` directory on disk
566    ///
567    /// TODO: the validation done here is somewhat redundant given that `site-packages`
568    /// are already validated at a higher level by the time we get here.
569    /// However, removing the validation here breaks some file-watching tests -- and
570    /// ultimately we'll probably want all search paths to be validated before a
571    /// `Program` is instantiated, so it doesn't seem like a huge priority right now.
572    pub(crate) fn site_packages(
573        system: &dyn System,
574        root: SystemPathBuf,
575    ) -> SearchPathResult<Self> {
576        Ok(Self(Arc::new(SearchPathInner::SitePackages(
577            Self::directory_path(system, root)?,
578        ))))
579    }
580
581    /// Create a new search path pointing to an editable installation
582    pub(crate) fn editable(system: &dyn System, root: SystemPathBuf) -> SearchPathResult<Self> {
583        Ok(Self(Arc::new(SearchPathInner::Editable(
584            Self::directory_path(system, root)?,
585        ))))
586    }
587
588    #[must_use]
589    pub(crate) fn to_module_path(&self) -> ModulePath {
590        ModulePath {
591            search_path: self.clone(),
592            relative_path: Utf8PathBuf::new(),
593        }
594    }
595
596    /// Does this search path point to the standard library?
597    #[must_use]
598    pub fn is_standard_library(&self) -> bool {
599        matches!(
600            &*self.0,
601            SearchPathInner::StandardLibraryCustom(_)
602                | SearchPathInner::StandardLibraryVendored(_)
603                | SearchPathInner::StandardLibraryReal(_)
604        )
605    }
606
607    /// Is this a user-provided extra search path?
608    pub(crate) fn is_extra(&self) -> bool {
609        matches!(&*self.0, SearchPathInner::Extra(_))
610    }
611
612    /// Is this search path in "first party" code? i.e., The
613    /// end user's project code.
614    pub fn is_first_party(&self) -> bool {
615        matches!(&*self.0, SearchPathInner::FirstParty(_))
616    }
617
618    /// Is the module in a site-packages directory?
619    pub fn is_site_packages(&self) -> bool {
620        matches!(&*self.0, SearchPathInner::SitePackages(_))
621    }
622
623    /// Is it plausible that this search path contains third-party code?
624    pub(crate) fn can_contain_third_party_code(&self) -> bool {
625        match &*self.0 {
626            SearchPathInner::SitePackages(_)
627            | SearchPathInner::Editable(_)
628            | SearchPathInner::Extra(_) => true,
629            SearchPathInner::FirstParty(_)
630            | SearchPathInner::StandardLibraryCustom(_)
631            | SearchPathInner::StandardLibraryVendored(_)
632            | SearchPathInner::StandardLibraryReal(_) => false,
633        }
634    }
635
636    fn is_valid_extension(&self, extension: &str) -> bool {
637        if self.is_standard_library() {
638            extension == "pyi"
639        } else {
640            matches!(extension, "pyi" | "py")
641        }
642    }
643
644    #[must_use]
645    pub(crate) fn relativize_system_path(&self, path: &SystemPath) -> Option<ModulePath> {
646        self.relativize_system_path_only(path)
647            .map(|relative_path| ModulePath {
648                search_path: self.clone(),
649                relative_path: relative_path.as_utf8_path().to_path_buf(),
650            })
651    }
652
653    #[must_use]
654    pub(crate) fn relativize_system_path_only<'a>(
655        &self,
656        path: &'a SystemPath,
657    ) -> Option<&'a SystemPath> {
658        if path
659            .extension()
660            .is_some_and(|extension| !self.is_valid_extension(extension))
661        {
662            return None;
663        }
664
665        match &*self.0 {
666            SearchPathInner::Extra(search_path)
667            | SearchPathInner::FirstParty(search_path)
668            | SearchPathInner::StandardLibraryCustom(search_path)
669            | SearchPathInner::StandardLibraryReal(search_path)
670            | SearchPathInner::SitePackages(search_path)
671            | SearchPathInner::Editable(search_path) => path.strip_prefix(search_path).ok(),
672            SearchPathInner::StandardLibraryVendored(_) => None,
673        }
674    }
675
676    #[must_use]
677    pub(crate) fn relativize_vendored_path(&self, path: &VendoredPath) -> Option<ModulePath> {
678        if path
679            .extension()
680            .is_some_and(|extension| !self.is_valid_extension(extension))
681        {
682            return None;
683        }
684
685        match &*self.0 {
686            SearchPathInner::Extra(_)
687            | SearchPathInner::FirstParty(_)
688            | SearchPathInner::StandardLibraryCustom(_)
689            | SearchPathInner::StandardLibraryReal(_)
690            | SearchPathInner::SitePackages(_)
691            | SearchPathInner::Editable(_) => None,
692            SearchPathInner::StandardLibraryVendored(search_path) => path
693                .strip_prefix(search_path)
694                .ok()
695                .map(|relative_path| ModulePath {
696                    search_path: self.clone(),
697                    relative_path: relative_path.as_utf8_path().to_path_buf(),
698                }),
699        }
700    }
701
702    #[must_use]
703    pub(super) fn as_path(&self) -> SystemOrVendoredPathRef<'_> {
704        match *self.0 {
705            SearchPathInner::Extra(ref path)
706            | SearchPathInner::FirstParty(ref path)
707            | SearchPathInner::StandardLibraryCustom(ref path)
708            | SearchPathInner::StandardLibraryReal(ref path)
709            | SearchPathInner::SitePackages(ref path)
710            | SearchPathInner::Editable(ref path) => SystemOrVendoredPathRef::System(path),
711            SearchPathInner::StandardLibraryVendored(ref path) => {
712                SystemOrVendoredPathRef::Vendored(path)
713            }
714        }
715    }
716
717    #[must_use]
718    pub(crate) fn as_system_path(&self) -> Option<&SystemPath> {
719        self.as_path().as_system_path()
720    }
721
722    #[must_use]
723    fn as_vendored_path(&self) -> Option<&VendoredPath> {
724        self.as_path().as_vendored_path()
725    }
726
727    /// Returns a succinct string representing the *internal kind* of this
728    /// search path. This is useful in snapshot tests where one wants to
729    /// capture this specific detail about search paths.
730    #[cfg(test)]
731    #[must_use]
732    pub(crate) fn debug_kind(&self) -> &'static str {
733        match *self.0 {
734            SearchPathInner::Extra(_) => "extra",
735            SearchPathInner::FirstParty(_) => "first-party",
736            SearchPathInner::StandardLibraryCustom(_) => "std-custom",
737            SearchPathInner::StandardLibraryReal(_) => "std-real",
738            SearchPathInner::SitePackages(_) => "site-packages",
739            SearchPathInner::Editable(_) => "editable",
740            SearchPathInner::StandardLibraryVendored(_) => "std-vendored",
741        }
742    }
743
744    /// Returns a string suitable for describing what kind of search path this is
745    /// in user-facing diagnostics.
746    #[must_use]
747    pub fn describe_kind(&self) -> &'static str {
748        match *self.0 {
749            SearchPathInner::Extra(_) => {
750                "extra search path specified on the CLI or in your config file"
751            }
752            SearchPathInner::FirstParty(_) => "first-party code",
753            SearchPathInner::StandardLibraryCustom(_) => {
754                "custom stdlib stubs specified on the CLI or in your config file"
755            }
756            SearchPathInner::StandardLibraryReal(_) => "runtime stdlib source code",
757            SearchPathInner::SitePackages(_) => "site-packages",
758            SearchPathInner::Editable(_) => "editable install",
759            SearchPathInner::StandardLibraryVendored(_) => "stdlib typeshed stubs vendored by ty",
760        }
761    }
762}
763
764impl PartialEq<SystemPath> for SearchPath {
765    fn eq(&self, other: &SystemPath) -> bool {
766        self.as_system_path().is_some_and(|path| path == other)
767    }
768}
769
770impl PartialEq<SearchPath> for SystemPath {
771    fn eq(&self, other: &SearchPath) -> bool {
772        other.eq(self)
773    }
774}
775
776impl PartialEq<SystemPathBuf> for SearchPath {
777    fn eq(&self, other: &SystemPathBuf) -> bool {
778        self.eq(&**other)
779    }
780}
781
782impl PartialEq<SearchPath> for SystemPathBuf {
783    fn eq(&self, other: &SearchPath) -> bool {
784        other.eq(self)
785    }
786}
787
788impl PartialEq<VendoredPath> for SearchPath {
789    fn eq(&self, other: &VendoredPath) -> bool {
790        self.as_vendored_path().is_some_and(|path| path == other)
791    }
792}
793
794impl PartialEq<SearchPath> for VendoredPath {
795    fn eq(&self, other: &SearchPath) -> bool {
796        other.eq(self)
797    }
798}
799
800impl PartialEq<VendoredPathBuf> for SearchPath {
801    fn eq(&self, other: &VendoredPathBuf) -> bool {
802        self.eq(&**other)
803    }
804}
805
806impl PartialEq<SearchPath> for VendoredPathBuf {
807    fn eq(&self, other: &SearchPath) -> bool {
808        other.eq(self)
809    }
810}
811
812impl fmt::Display for SearchPath {
813    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
814        match &*self.0 {
815            SearchPathInner::Extra(system_path_buf)
816            | SearchPathInner::FirstParty(system_path_buf)
817            | SearchPathInner::SitePackages(system_path_buf)
818            | SearchPathInner::Editable(system_path_buf)
819            | SearchPathInner::StandardLibraryReal(system_path_buf)
820            | SearchPathInner::StandardLibraryCustom(system_path_buf) => system_path_buf.fmt(f),
821            SearchPathInner::StandardLibraryVendored(vendored_path_buf) => vendored_path_buf.fmt(f),
822        }
823    }
824}
825
826#[derive(Debug, Clone, Copy)]
827pub(super) enum SystemOrVendoredPathRef<'db> {
828    System(&'db SystemPath),
829    Vendored(&'db VendoredPath),
830}
831
832impl<'db> SystemOrVendoredPathRef<'db> {
833    pub(super) fn try_from_file(db: &'db dyn Db, file: File) -> Option<Self> {
834        match file.path(db) {
835            FilePath::System(system) => Some(Self::System(system)),
836            FilePath::Vendored(vendored) => Some(Self::Vendored(vendored)),
837            FilePath::SystemVirtual(_) => None,
838        }
839    }
840
841    pub(super) fn file_name(&self) -> Option<&str> {
842        match self {
843            Self::System(system) => system.file_name(),
844            Self::Vendored(vendored) => vendored.file_name(),
845        }
846    }
847
848    pub(super) fn extension(&self) -> Option<&str> {
849        match self {
850            Self::System(system) => system.extension(),
851            Self::Vendored(vendored) => vendored.extension(),
852        }
853    }
854
855    pub(super) fn parent<'a>(&'a self) -> Option<SystemOrVendoredPathRef<'a>>
856    where
857        'a: 'db,
858    {
859        match self {
860            Self::System(system) => system.parent().map(Self::System),
861            Self::Vendored(vendored) => vendored.parent().map(Self::Vendored),
862        }
863    }
864
865    fn as_system_path(&self) -> Option<&'db SystemPath> {
866        match self {
867            SystemOrVendoredPathRef::System(path) => Some(path),
868            SystemOrVendoredPathRef::Vendored(_) => None,
869        }
870    }
871
872    fn as_vendored_path(&self) -> Option<&'db VendoredPath> {
873        match self {
874            SystemOrVendoredPathRef::Vendored(path) => Some(path),
875            SystemOrVendoredPathRef::System(_) => None,
876        }
877    }
878}
879
880impl<'a> From<&'a SystemPath> for SystemOrVendoredPathRef<'a> {
881    fn from(path: &'a SystemPath) -> SystemOrVendoredPathRef<'a> {
882        SystemOrVendoredPathRef::System(path)
883    }
884}
885
886impl<'a> From<&'a VendoredPath> for SystemOrVendoredPathRef<'a> {
887    fn from(path: &'a VendoredPath) -> SystemOrVendoredPathRef<'a> {
888        SystemOrVendoredPathRef::Vendored(path)
889    }
890}
891
892impl std::fmt::Display for SystemOrVendoredPathRef<'_> {
893    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
894        match self {
895            SystemOrVendoredPathRef::System(system) => system.fmt(f),
896            SystemOrVendoredPathRef::Vendored(vendored) => vendored.fmt(f),
897        }
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use ruff_db::Db;
904    use ruff_python_ast::PythonVersion;
905
906    use crate::ResolverEnvironment;
907    use crate::db::tests::TestDb;
908    use crate::resolve::ModuleResolveMode;
909    use crate::testing::{FileSpec, MockedTypeshed, TestCase, TestCaseBuilder};
910
911    use super::*;
912
913    impl ModulePath {
914        #[must_use]
915        fn join(&self, component: &str) -> ModulePath {
916            let mut result = self.clone();
917            result.push(component);
918            result
919        }
920    }
921
922    impl SearchPath {
923        fn join(&self, component: &str) -> ModulePath {
924            self.to_module_path().join(component)
925        }
926    }
927
928    #[test]
929    fn with_extension_methods() {
930        let TestCase {
931            db, src, stdlib, ..
932        } = TestCaseBuilder::new()
933            .with_mocked_typeshed(MockedTypeshed::default())
934            .build();
935
936        assert_eq!(
937            SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap())
938                .unwrap()
939                .to_module_path()
940                .with_py_extension(),
941            None
942        );
943
944        assert_eq!(
945            &SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap())
946                .unwrap()
947                .join("foo")
948                .with_pyi_extension(),
949            &stdlib.join("foo.pyi")
950        );
951
952        assert_eq!(
953            &SearchPath::first_party(db.system(), src.clone())
954                .unwrap()
955                .join("foo/bar")
956                .with_py_extension()
957                .unwrap(),
958            &src.join("foo/bar.py")
959        );
960    }
961
962    #[test]
963    fn module_name_1_part() {
964        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
965        let src_search_path = SearchPath::first_party(db.system(), src).unwrap();
966        let foo_module_name = ModuleName::new_static("foo").unwrap();
967
968        assert_eq!(
969            src_search_path
970                .to_module_path()
971                .join("foo")
972                .to_module_name()
973                .as_ref(),
974            Some(&foo_module_name)
975        );
976
977        assert_eq!(
978            src_search_path.join("foo.pyi").to_module_name().as_ref(),
979            Some(&foo_module_name)
980        );
981
982        assert_eq!(
983            src_search_path
984                .join("foo/__init__.pyi")
985                .to_module_name()
986                .as_ref(),
987            Some(&foo_module_name)
988        );
989    }
990
991    #[test]
992    fn module_name_2_parts() {
993        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
994        let src_search_path = SearchPath::first_party(db.system(), src).unwrap();
995        let foo_bar_module_name = ModuleName::new_static("foo.bar").unwrap();
996
997        assert_eq!(
998            src_search_path.join("foo/bar").to_module_name().as_ref(),
999            Some(&foo_bar_module_name)
1000        );
1001
1002        assert_eq!(
1003            src_search_path
1004                .join("foo/bar.pyi")
1005                .to_module_name()
1006                .as_ref(),
1007            Some(&foo_bar_module_name)
1008        );
1009
1010        assert_eq!(
1011            src_search_path
1012                .join("foo/bar/__init__.pyi")
1013                .to_module_name()
1014                .as_ref(),
1015            Some(&foo_bar_module_name)
1016        );
1017    }
1018
1019    #[test]
1020    fn module_name_3_parts() {
1021        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1022        let src_search_path = SearchPath::first_party(db.system(), src).unwrap();
1023        let foo_bar_baz_module_name = ModuleName::new_static("foo.bar.baz").unwrap();
1024
1025        assert_eq!(
1026            src_search_path
1027                .join("foo/bar/baz")
1028                .to_module_name()
1029                .as_ref(),
1030            Some(&foo_bar_baz_module_name)
1031        );
1032
1033        assert_eq!(
1034            src_search_path
1035                .join("foo/bar/baz.pyi")
1036                .to_module_name()
1037                .as_ref(),
1038            Some(&foo_bar_baz_module_name)
1039        );
1040
1041        assert_eq!(
1042            src_search_path
1043                .join("foo/bar/baz/__init__.pyi")
1044                .to_module_name()
1045                .as_ref(),
1046            Some(&foo_bar_baz_module_name)
1047        );
1048    }
1049
1050    #[test]
1051    #[should_panic(expected = "Extension must be `pyi`; got `py`")]
1052    fn stdlib_path_invalid_join_py() {
1053        let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
1054            .with_mocked_typeshed(MockedTypeshed::default())
1055            .build();
1056        SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap())
1057            .unwrap()
1058            .to_module_path()
1059            .push("bar.py");
1060    }
1061
1062    #[test]
1063    #[should_panic(expected = "Extension must be `pyi`; got `rs`")]
1064    fn stdlib_path_invalid_join_rs() {
1065        let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
1066            .with_mocked_typeshed(MockedTypeshed::default())
1067            .build();
1068        SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap())
1069            .unwrap()
1070            .to_module_path()
1071            .push("bar.rs");
1072    }
1073
1074    #[test]
1075    #[should_panic(expected = "Extension must be `py` or `pyi`; got `rs`")]
1076    fn non_stdlib_path_invalid_join_rs() {
1077        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1078        SearchPath::first_party(db.system(), src)
1079            .unwrap()
1080            .to_module_path()
1081            .push("bar.rs");
1082    }
1083
1084    #[test]
1085    #[should_panic(expected = "already has an extension")]
1086    fn too_many_extensions() {
1087        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1088        SearchPath::first_party(db.system(), src)
1089            .unwrap()
1090            .join("foo.py")
1091            .push("bar.py");
1092    }
1093
1094    #[test]
1095    fn relativize_stdlib_path_errors() {
1096        let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
1097            .with_mocked_typeshed(MockedTypeshed::default())
1098            .build();
1099
1100        let root = SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap()).unwrap();
1101
1102        // Must have a `.pyi` extension or no extension:
1103        let bad_absolute_path = SystemPath::new("foo/stdlib/x.py");
1104        assert_eq!(root.relativize_system_path(bad_absolute_path), None);
1105        let second_bad_absolute_path = SystemPath::new("foo/stdlib/x.rs");
1106        assert_eq!(root.relativize_system_path(second_bad_absolute_path), None);
1107
1108        // Must be a path that is a child of `root`:
1109        let third_bad_absolute_path = SystemPath::new("bar/stdlib/x.pyi");
1110        assert_eq!(root.relativize_system_path(third_bad_absolute_path), None);
1111    }
1112
1113    #[test]
1114    fn relativize_non_stdlib_path_errors() {
1115        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1116
1117        let root = SearchPath::extra(db.system(), src.clone()).unwrap();
1118        // Must have a `.py` extension, a `.pyi` extension, or no extension:
1119        let bad_absolute_path = src.join("x.rs");
1120        assert_eq!(root.relativize_system_path(&bad_absolute_path), None);
1121        // Must be a path that is a child of `root`:
1122        let second_bad_absolute_path = SystemPath::new("bar/src/x.pyi");
1123        assert_eq!(root.relativize_system_path(second_bad_absolute_path), None);
1124    }
1125
1126    #[test]
1127    fn relativize_path() {
1128        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1129        let src_search_path = SearchPath::first_party(db.system(), src.clone()).unwrap();
1130        let eggs_package = src.join("eggs/__init__.pyi");
1131        let module_path = src_search_path
1132            .relativize_system_path(&eggs_package)
1133            .unwrap();
1134        assert_eq!(
1135            &module_path.relative_path,
1136            Utf8Path::new("eggs/__init__.pyi")
1137        );
1138    }
1139
1140    fn typeshed_test_case(
1141        typeshed: MockedTypeshed,
1142        python_version: PythonVersion,
1143    ) -> (TestDb, SearchPath) {
1144        let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
1145            .with_mocked_typeshed(typeshed)
1146            .with_python_version(python_version)
1147            .build();
1148        let stdlib = SearchPath::custom_stdlib(db.system(), stdlib.parent().unwrap()).unwrap();
1149        (db, stdlib)
1150    }
1151
1152    fn py38_typeshed_test_case(typeshed: MockedTypeshed) -> (TestDb, SearchPath) {
1153        typeshed_test_case(typeshed, PythonVersion::PY38)
1154    }
1155
1156    fn py39_typeshed_test_case(typeshed: MockedTypeshed) -> (TestDb, SearchPath) {
1157        typeshed_test_case(typeshed, PythonVersion::PY39)
1158    }
1159
1160    #[test]
1161    fn mocked_typeshed_existing_regular_stdlib_pkg_py38() {
1162        const VERSIONS: &str = "\
1163            asyncio: 3.8-
1164            asyncio.tasks: 3.9-3.11
1165        ";
1166
1167        const TYPESHED: MockedTypeshed = MockedTypeshed {
1168            versions: VERSIONS,
1169            stdlib_files: &[("asyncio/__init__.pyi", ""), ("asyncio/tasks.pyi", "")],
1170        };
1171
1172        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1173        let resolver = ResolverContext::new(
1174            &db,
1175            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1176            ModuleResolveMode::Typing,
1177        );
1178
1179        let asyncio_regular_package = stdlib_path.join("asyncio");
1180        assert!(asyncio_regular_package.is_directory(&resolver));
1181        assert!(asyncio_regular_package.is_regular_package(&resolver));
1182        // Paths to directories don't resolve to VfsFiles
1183        assert_eq!(asyncio_regular_package.to_file(&resolver), None);
1184        assert!(
1185            asyncio_regular_package
1186                .join("__init__.pyi")
1187                .to_file(&resolver)
1188                .is_some()
1189        );
1190
1191        // The `asyncio` package exists on Python 3.8, but the `asyncio.tasks` submodule does not,
1192        // according to the `VERSIONS` file in our typeshed mock:
1193        let asyncio_tasks_module = stdlib_path.join("asyncio/tasks.pyi");
1194        assert_eq!(asyncio_tasks_module.to_file(&resolver), None);
1195        assert!(!asyncio_tasks_module.is_directory(&resolver));
1196        assert!(!asyncio_tasks_module.is_regular_package(&resolver));
1197    }
1198
1199    #[test]
1200    fn mocked_typeshed_existing_namespace_stdlib_pkg_py38() {
1201        const TYPESHED: MockedTypeshed = MockedTypeshed {
1202            versions: "xml: 3.8-3.8",
1203            stdlib_files: &[("xml/etree.pyi", "")],
1204        };
1205
1206        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1207        let resolver = ResolverContext::new(
1208            &db,
1209            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1210            ModuleResolveMode::Typing,
1211        );
1212
1213        let xml_namespace_package = stdlib_path.join("xml");
1214        assert!(xml_namespace_package.is_directory(&resolver));
1215        // Paths to directories don't resolve to VfsFiles
1216        assert_eq!(xml_namespace_package.to_file(&resolver), None);
1217        assert!(!xml_namespace_package.is_regular_package(&resolver));
1218
1219        let xml_etree = stdlib_path.join("xml/etree.pyi");
1220        assert!(!xml_etree.is_directory(&resolver));
1221        assert!(xml_etree.to_file(&resolver).is_some());
1222        assert!(!xml_etree.is_regular_package(&resolver));
1223    }
1224
1225    #[test]
1226    fn mocked_typeshed_single_file_stdlib_module_py38() {
1227        const TYPESHED: MockedTypeshed = MockedTypeshed {
1228            versions: "functools: 3.8-",
1229            stdlib_files: &[("functools.pyi", "")],
1230        };
1231
1232        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1233        let resolver = ResolverContext::new(
1234            &db,
1235            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1236            ModuleResolveMode::Typing,
1237        );
1238
1239        let functools_module = stdlib_path.join("functools.pyi");
1240        assert!(functools_module.to_file(&resolver).is_some());
1241        assert!(!functools_module.is_directory(&resolver));
1242        assert!(!functools_module.is_regular_package(&resolver));
1243    }
1244
1245    #[test]
1246    fn mocked_typeshed_nonexistent_regular_stdlib_pkg_py38() {
1247        const TYPESHED: MockedTypeshed = MockedTypeshed {
1248            versions: "collections: 3.9-",
1249            stdlib_files: &[("collections/__init__.pyi", "")],
1250        };
1251
1252        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1253        let resolver = ResolverContext::new(
1254            &db,
1255            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1256            ModuleResolveMode::Typing,
1257        );
1258
1259        let collections_regular_package = stdlib_path.join("collections");
1260        assert_eq!(collections_regular_package.to_file(&resolver), None);
1261        assert!(!collections_regular_package.is_directory(&resolver));
1262        assert!(!collections_regular_package.is_regular_package(&resolver));
1263    }
1264
1265    #[test]
1266    fn mocked_typeshed_nonexistent_namespace_stdlib_pkg_py38() {
1267        const TYPESHED: MockedTypeshed = MockedTypeshed {
1268            versions: "importlib: 3.9-",
1269            stdlib_files: &[("importlib/abc.pyi", "")],
1270        };
1271
1272        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1273        let resolver = ResolverContext::new(
1274            &db,
1275            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1276            ModuleResolveMode::Typing,
1277        );
1278
1279        let importlib_namespace_package = stdlib_path.join("importlib");
1280        assert_eq!(importlib_namespace_package.to_file(&resolver), None);
1281        assert!(!importlib_namespace_package.is_directory(&resolver));
1282        assert!(!importlib_namespace_package.is_regular_package(&resolver));
1283
1284        let importlib_abc = stdlib_path.join("importlib/abc.pyi");
1285        assert_eq!(importlib_abc.to_file(&resolver), None);
1286        assert!(!importlib_abc.is_directory(&resolver));
1287        assert!(!importlib_abc.is_regular_package(&resolver));
1288    }
1289
1290    #[test]
1291    fn mocked_typeshed_nonexistent_single_file_module_py38() {
1292        const TYPESHED: MockedTypeshed = MockedTypeshed {
1293            versions: "foo: 2.6-",
1294            stdlib_files: &[("foo.pyi", "")],
1295        };
1296
1297        let (db, stdlib_path) = py38_typeshed_test_case(TYPESHED);
1298        let resolver = ResolverContext::new(
1299            &db,
1300            ResolverEnvironment::new(&db, PythonVersion::PY38, db.search_paths()),
1301            ModuleResolveMode::Typing,
1302        );
1303
1304        let non_existent = stdlib_path.join("doesnt_even_exist");
1305        assert_eq!(non_existent.to_file(&resolver), None);
1306        assert!(!non_existent.is_directory(&resolver));
1307        assert!(!non_existent.is_regular_package(&resolver));
1308    }
1309
1310    #[test]
1311    fn mocked_typeshed_existing_regular_stdlib_pkgs_py39() {
1312        const VERSIONS: &str = "\
1313            asyncio: 3.8-
1314            asyncio.tasks: 3.9-3.11
1315            collections: 3.9-
1316        ";
1317
1318        const STDLIB: &[FileSpec] = &[
1319            ("asyncio/__init__.pyi", ""),
1320            ("asyncio/tasks.pyi", ""),
1321            ("collections/__init__.pyi", ""),
1322        ];
1323
1324        const TYPESHED: MockedTypeshed = MockedTypeshed {
1325            versions: VERSIONS,
1326            stdlib_files: STDLIB,
1327        };
1328
1329        let (db, stdlib_path) = py39_typeshed_test_case(TYPESHED);
1330        let resolver = ResolverContext::new(
1331            &db,
1332            ResolverEnvironment::new(&db, PythonVersion::PY39, db.search_paths()),
1333            ModuleResolveMode::Typing,
1334        );
1335
1336        // Since we've set the target version to Py39,
1337        // `collections` should now exist as a directory, according to VERSIONS...
1338        let collections_regular_package = stdlib_path.join("collections");
1339        assert!(collections_regular_package.is_directory(&resolver));
1340        assert!(collections_regular_package.is_regular_package(&resolver));
1341        // (This is still `None`, as directories don't resolve to `Vfs` files)
1342        assert_eq!(collections_regular_package.to_file(&resolver), None);
1343        assert!(
1344            collections_regular_package
1345                .join("__init__.pyi")
1346                .to_file(&resolver)
1347                .is_some()
1348        );
1349
1350        // ...and so should the `asyncio.tasks` submodule (though it's still not a directory):
1351        let asyncio_tasks_module = stdlib_path.join("asyncio/tasks.pyi");
1352        assert!(asyncio_tasks_module.to_file(&resolver).is_some());
1353        assert!(!asyncio_tasks_module.is_directory(&resolver));
1354        assert!(!asyncio_tasks_module.is_regular_package(&resolver));
1355    }
1356
1357    #[test]
1358    fn mocked_typeshed_existing_namespace_stdlib_pkg_py39() {
1359        const TYPESHED: MockedTypeshed = MockedTypeshed {
1360            versions: "importlib: 3.9-",
1361            stdlib_files: &[("importlib/abc.pyi", "")],
1362        };
1363
1364        let (db, stdlib_path) = py39_typeshed_test_case(TYPESHED);
1365        let resolver = ResolverContext::new(
1366            &db,
1367            ResolverEnvironment::new(&db, PythonVersion::PY39, db.search_paths()),
1368            ModuleResolveMode::Typing,
1369        );
1370
1371        // The `importlib` directory now also exists
1372        let importlib_namespace_package = stdlib_path.join("importlib");
1373        assert!(importlib_namespace_package.is_directory(&resolver));
1374        assert!(!importlib_namespace_package.is_regular_package(&resolver));
1375        // (This is still `None`, as directories don't resolve to `Vfs` files)
1376        assert_eq!(importlib_namespace_package.to_file(&resolver), None);
1377
1378        // Submodules in the `importlib` namespace package also now exist:
1379        let importlib_abc = importlib_namespace_package.join("abc.pyi");
1380        assert!(!importlib_abc.is_directory(&resolver));
1381        assert!(!importlib_abc.is_regular_package(&resolver));
1382        assert!(importlib_abc.to_file(&resolver).is_some());
1383    }
1384
1385    #[test]
1386    fn mocked_typeshed_nonexistent_namespace_stdlib_pkg_py39() {
1387        const TYPESHED: MockedTypeshed = MockedTypeshed {
1388            versions: "xml: 3.8-3.8",
1389            stdlib_files: &[("xml/etree.pyi", "")],
1390        };
1391
1392        let (db, stdlib_path) = py39_typeshed_test_case(TYPESHED);
1393        let resolver = ResolverContext::new(
1394            &db,
1395            ResolverEnvironment::new(&db, PythonVersion::PY39, db.search_paths()),
1396            ModuleResolveMode::Typing,
1397        );
1398
1399        // The `xml` package no longer exists on py39:
1400        let xml_namespace_package = stdlib_path.join("xml");
1401        assert_eq!(xml_namespace_package.to_file(&resolver), None);
1402        assert!(!xml_namespace_package.is_directory(&resolver));
1403        assert!(!xml_namespace_package.is_regular_package(&resolver));
1404
1405        let xml_etree = xml_namespace_package.join("etree.pyi");
1406        assert_eq!(xml_etree.to_file(&resolver), None);
1407        assert!(!xml_etree.is_directory(&resolver));
1408        assert!(!xml_etree.is_regular_package(&resolver));
1409    }
1410
1411    #[test]
1412    fn strip_not_top_level_stubs_suffix() {
1413        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1414        let sp = SearchPath::first_party(db.system(), src).unwrap();
1415        let mut mp = sp.to_module_path();
1416        mp.push("foo-stubs");
1417        mp.push("quux");
1418        assert_eq!(
1419            mp.to_module_name(),
1420            Some(ModuleName::new_static("foo.quux").unwrap())
1421        );
1422    }
1423
1424    /// Tests that a module path of just `foo-stubs` will correctly be
1425    /// converted to a module name of just `foo`.
1426    ///
1427    /// This is a regression test where this conversion ended up
1428    /// treating the module path as invalid and returning `None` from
1429    /// `ModulePath::to_module_name` instead.
1430    #[test]
1431    fn strip_top_level_stubs_suffix() {
1432        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1433        let sp = SearchPath::first_party(db.system(), src).unwrap();
1434        let mut mp = sp.to_module_path();
1435        mp.push("foo-stubs");
1436        assert_eq!(
1437            mp.to_module_name(),
1438            Some(ModuleName::new_static("foo").unwrap())
1439        );
1440    }
1441
1442    /// Tests that paths like `foo-stubs.pyi` don't have `-stubs`
1443    /// stripped. (And this leads to failing to create a `ModuleName`,
1444    /// which is what we want.)
1445    #[test]
1446    fn no_strip_with_extension() {
1447        let TestCase { db, src, .. } = TestCaseBuilder::new().build();
1448        let sp = SearchPath::first_party(db.system(), src).unwrap();
1449        let mut mp = sp.to_module_path();
1450        mp.push("foo-stubs.pyi");
1451        assert_eq!(mp.to_module_name(), None);
1452    }
1453}