deps_pypi/name.rs
1//! Canonical PyPI package name normalization (PEP 503).
2//!
3//! This is the single normalization used across deps-pypi — registry lookups,
4//! `PypiFormatter`'s [`normalize_package_name`](deps_core::lsp_helpers::PackageNaming::normalize_package_name)
5//! override, and lock file parsing — so a name declared any of several ways
6//! (`Zope.Interface`, `zope_interface`, `zope-interface`) resolves to the
7//! same lookup key everywhere. See [`crate::types::PypiDependency::name`] for
8//! why the *declared* name is not itself always normalized.
9
10/// Normalizes `name` per [PEP 503](https://peps.python.org/pep-0503/#normalized-names):
11/// lowercases, then collapses any run of `-`, `_`, or `.` into a single `-`.
12///
13/// This matches the actual PyPI Simple API URL contract
14/// (`https://pypi.org/simple/<name>/`) and what both `poetry.lock` and
15/// `uv.lock` store, unlike the historical `_`-based key space this replaces.
16///
17/// **Deliberate deviation from PEP 503's own regex** (`re.sub(r"[-_.]+",
18/// "-", name).lower()`): that regex collapses a *leading or trailing*
19/// separator run into a single leading/trailing `-` (`"_package_"` ->
20/// `"-package-"`), which this function strips entirely instead
21/// (`"_package_"` -> `"package"`). PyPI's own package-name validation
22/// regex forbids a name from starting or ending with a separator, so no
23/// real published package name can exercise this difference — stripping
24/// matches what a human clearly meant by a manifest name like `_package_`
25/// better than preserving a leading/trailing hyphen would.
26///
27/// # Examples
28///
29/// ```
30/// use deps_pypi::name::normalize;
31///
32/// assert_eq!(normalize("Flask"), "flask");
33/// assert_eq!(normalize("django_rest_framework"), "django-rest-framework");
34/// assert_eq!(normalize("Pillow.Image"), "pillow-image");
35/// assert_eq!(normalize("my__package"), "my-package");
36/// assert_eq!(normalize("---"), "");
37/// ```
38pub fn normalize(name: &str) -> String {
39 name.to_lowercase()
40 .replace(['_', '.'], "-")
41 .split('-')
42 .filter(|s| !s.is_empty())
43 .collect::<Vec<_>>()
44 .join("-")
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn test_normalize_lowercase() {
53 assert_eq!(normalize("Flask"), "flask");
54 assert_eq!(normalize("DJANGO"), "django");
55 assert_eq!(normalize("Requests"), "requests");
56 }
57
58 #[test]
59 fn test_normalize_underscores() {
60 assert_eq!(normalize("django_rest_framework"), "django-rest-framework");
61 assert_eq!(normalize("my_package"), "my-package");
62 }
63
64 #[test]
65 fn test_normalize_dots() {
66 assert_eq!(normalize("Pillow.Image"), "pillow-image");
67 assert_eq!(normalize("zope.interface"), "zope-interface");
68 }
69
70 #[test]
71 fn test_normalize_consecutive_separators() {
72 assert_eq!(normalize("my__package"), "my-package");
73 assert_eq!(normalize("my..package"), "my-package");
74 assert_eq!(normalize("my_.package"), "my-package");
75 }
76
77 #[test]
78 fn test_normalize_mixed() {
79 assert_eq!(normalize("My_Package.Name"), "my-package-name");
80 assert_eq!(normalize("SOME__Weird.._Package"), "some-weird-package");
81 }
82
83 #[test]
84 fn test_normalize_already_normalized() {
85 assert_eq!(normalize("my-package"), "my-package");
86 assert_eq!(normalize("django-rest-framework"), "django-rest-framework");
87 }
88
89 #[test]
90 fn test_normalize_edge_cases() {
91 assert_eq!(normalize("a"), "a");
92 assert_eq!(normalize("A_B_C"), "a-b-c");
93 assert_eq!(normalize("---"), "");
94 assert_eq!(normalize(""), "");
95 }
96
97 #[test]
98 fn test_normalize_leading_trailing_separators() {
99 assert_eq!(normalize("_package_"), "package");
100 assert_eq!(normalize(".package."), "package");
101 assert_eq!(normalize("__package__"), "package");
102 }
103}