Skip to main content

uv_normalize/
lib.rs

1use std::error::Error;
2use std::fmt::{Display, Formatter};
3
4pub use dist_info_name::DistInfoName;
5pub use extra_name::{DefaultExtras, ExtraName};
6pub use group_name::{DEV_DEPENDENCIES, DefaultGroups, GroupName, PipGroupName};
7pub use package_name::PackageName;
8
9use uv_small_str::SmallString;
10
11mod dist_info_name;
12mod extra_name;
13mod group_name;
14mod package_name;
15
16/// Validate and normalize an unowned package or extra name.
17pub(crate) fn validate_and_normalize_ref(
18    name: impl AsRef<str>,
19) -> Result<SmallString, InvalidNameError> {
20    let name = name.as_ref();
21    if is_normalized(name)? {
22        Ok(SmallString::from(name))
23    } else {
24        Ok(SmallString::from(normalize(name)?))
25    }
26}
27
28/// Normalize an unowned package or extra name.
29fn normalize(name: &str) -> Result<String, InvalidNameError> {
30    // An empty string is not a valid package, extra, or group name.
31    if name.is_empty() {
32        return Err(InvalidNameError(name.to_string()));
33    }
34
35    let mut normalized = String::with_capacity(name.len());
36
37    let mut last = None;
38    for char in name.bytes() {
39        match char {
40            b'A'..=b'Z' => {
41                normalized.push(char.to_ascii_lowercase() as char);
42            }
43            b'a'..=b'z' | b'0'..=b'9' => {
44                normalized.push(char as char);
45            }
46            b'-' | b'_' | b'.' => {
47                match last {
48                    // Names can't start with punctuation.
49                    None => return Err(InvalidNameError(name.to_string())),
50                    Some(b'-' | b'_' | b'.') => {}
51                    Some(_) => normalized.push('-'),
52                }
53            }
54            _ => return Err(InvalidNameError(name.to_string())),
55        }
56        last = Some(char);
57    }
58
59    // Names can't end with punctuation.
60    if matches!(last, Some(b'-' | b'_' | b'.')) {
61        return Err(InvalidNameError(name.to_string()));
62    }
63
64    Ok(normalized)
65}
66
67/// Returns `true` if the name is already normalized.
68fn is_normalized(name: impl AsRef<str>) -> Result<bool, InvalidNameError> {
69    // An empty string is not a valid package, extra, or group name.
70    if name.as_ref().is_empty() {
71        return Err(InvalidNameError(name.as_ref().to_string()));
72    }
73
74    let mut last = None;
75    for char in name.as_ref().bytes() {
76        match char {
77            b'A'..=b'Z' => {
78                // Uppercase characters need to be converted to lowercase.
79                return Ok(false);
80            }
81            b'a'..=b'z' | b'0'..=b'9' => {}
82            b'_' | b'.' => {
83                // `_` and `.` are normalized to `-`.
84                return Ok(false);
85            }
86            b'-' => {
87                match last {
88                    // Names can't start with punctuation.
89                    None => return Err(InvalidNameError(name.as_ref().to_string())),
90                    Some(b'-') => {
91                        // Runs of `-` are normalized to a single `-`.
92                        return Ok(false);
93                    }
94                    Some(_) => {}
95                }
96            }
97            _ => return Err(InvalidNameError(name.as_ref().to_string())),
98        }
99        last = Some(char);
100    }
101
102    // Names can't end with punctuation.
103    if matches!(last, Some(b'-' | b'_' | b'.')) {
104        return Err(InvalidNameError(name.as_ref().to_string()));
105    }
106
107    Ok(true)
108}
109
110/// Invalid [`PackageName`] or [`ExtraName`].
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct InvalidNameError(String);
113
114impl Display for InvalidNameError {
115    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
116        write!(
117            f,
118            "Not a valid package or extra name: \"{}\". Names must start and end with a letter or \
119            digit and may only contain -, _, ., and alphanumeric characters.",
120            self.0
121        )
122    }
123}
124
125impl Error for InvalidNameError {}
126
127/// Path didn't end with `pyproject.toml`
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct InvalidPipGroupPathError(String);
130
131impl Display for InvalidPipGroupPathError {
132    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
133        write!(
134            f,
135            "The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}",
136            self.0,
137        )
138    }
139}
140impl Error for InvalidPipGroupPathError {}
141
142/// Possible errors from reading a [`PipGroupName`].
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub enum InvalidPipGroupError {
145    Name(InvalidNameError),
146    Path(InvalidPipGroupPathError),
147}
148
149impl Display for InvalidPipGroupError {
150    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Self::Name(e) => e.fmt(f),
153            Self::Path(e) => e.fmt(f),
154        }
155    }
156}
157impl Error for InvalidPipGroupError {}
158impl From<InvalidNameError> for InvalidPipGroupError {
159    fn from(value: InvalidNameError) -> Self {
160        Self::Name(value)
161    }
162}
163impl From<InvalidPipGroupPathError> for InvalidPipGroupError {
164    fn from(value: InvalidPipGroupPathError) -> Self {
165        Self::Path(value)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn normalize() {
175        let inputs = [
176            "friendly-bard",
177            "Friendly-Bard",
178            "FRIENDLY-BARD",
179            "friendly.bard",
180            "friendly_bard",
181            "friendly--bard",
182            "friendly-.bard",
183            "FrIeNdLy-._.-bArD",
184        ];
185        for input in inputs {
186            assert_eq!(
187                validate_and_normalize_ref(input).unwrap().as_ref(),
188                "friendly-bard"
189            );
190        }
191    }
192
193    #[test]
194    fn check() {
195        let inputs = ["friendly-bard", "friendlybard"];
196        for input in inputs {
197            assert!(is_normalized(input).unwrap(), "{input:?}");
198        }
199
200        let inputs = [
201            "friendly.bard",
202            "friendly.BARD",
203            "friendly_bard",
204            "friendly--bard",
205            "friendly-.bard",
206            "FrIeNdLy-._.-bArD",
207        ];
208        for input in inputs {
209            assert!(!is_normalized(input).unwrap(), "{input:?}");
210        }
211    }
212
213    #[test]
214    fn unchanged() {
215        // Unchanged
216        let unchanged = ["friendly-bard", "1okay", "okay2"];
217        for input in unchanged {
218            assert_eq!(validate_and_normalize_ref(input).unwrap().as_ref(), input);
219            assert!(is_normalized(input).unwrap());
220        }
221    }
222
223    #[test]
224    fn failures() {
225        let failures = [
226            "",
227            " starts-with-space",
228            "-starts-with-dash",
229            "ends-with-dash-",
230            "ends-with-space ",
231            "includes!invalid-char",
232            "space in middle",
233            "alpha-α",
234        ];
235        for input in failures {
236            assert!(validate_and_normalize_ref(input).is_err());
237            assert!(is_normalized(input).is_err());
238        }
239    }
240}