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 InvalidNameError {
115    /// Returns the invalid name.
116    pub fn as_str(&self) -> &str {
117        &self.0
118    }
119}
120
121impl Display for InvalidNameError {
122    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
123        write!(
124            f,
125            "Not a valid package or extra name: \"{}\". Names must start and end with a letter or \
126            digit and may only contain -, _, ., and alphanumeric characters.",
127            self.0
128        )
129    }
130}
131
132impl Error for InvalidNameError {}
133
134/// Path didn't end with `pyproject.toml`
135#[derive(Clone, Debug, Eq, PartialEq)]
136pub struct InvalidPipGroupPathError(String);
137
138impl InvalidPipGroupPathError {
139    /// Returns the invalid path.
140    pub fn as_str(&self) -> &str {
141        &self.0
142    }
143}
144
145impl Display for InvalidPipGroupPathError {
146    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147        write!(
148            f,
149            "The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}",
150            self.0,
151        )
152    }
153}
154impl Error for InvalidPipGroupPathError {}
155
156/// Possible errors from reading a [`PipGroupName`].
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum InvalidPipGroupError {
159    Name(InvalidNameError),
160    Path(InvalidPipGroupPathError),
161}
162
163impl Display for InvalidPipGroupError {
164    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::Name(e) => e.fmt(f),
167            Self::Path(e) => e.fmt(f),
168        }
169    }
170}
171impl Error for InvalidPipGroupError {}
172impl From<InvalidNameError> for InvalidPipGroupError {
173    fn from(value: InvalidNameError) -> Self {
174        Self::Name(value)
175    }
176}
177impl From<InvalidPipGroupPathError> for InvalidPipGroupError {
178    fn from(value: InvalidPipGroupPathError) -> Self {
179        Self::Path(value)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn normalize() {
189        let inputs = [
190            "friendly-bard",
191            "Friendly-Bard",
192            "FRIENDLY-BARD",
193            "friendly.bard",
194            "friendly_bard",
195            "friendly--bard",
196            "friendly-.bard",
197            "FrIeNdLy-._.-bArD",
198        ];
199        for input in inputs {
200            assert_eq!(
201                validate_and_normalize_ref(input).unwrap().as_ref(),
202                "friendly-bard"
203            );
204        }
205    }
206
207    #[test]
208    fn check() {
209        let inputs = ["friendly-bard", "friendlybard"];
210        for input in inputs {
211            assert!(is_normalized(input).unwrap(), "{input:?}");
212        }
213
214        let inputs = [
215            "friendly.bard",
216            "friendly.BARD",
217            "friendly_bard",
218            "friendly--bard",
219            "friendly-.bard",
220            "FrIeNdLy-._.-bArD",
221        ];
222        for input in inputs {
223            assert!(!is_normalized(input).unwrap(), "{input:?}");
224        }
225    }
226
227    #[test]
228    fn unchanged() {
229        // Unchanged
230        let unchanged = ["friendly-bard", "1okay", "okay2"];
231        for input in unchanged {
232            assert_eq!(validate_and_normalize_ref(input).unwrap().as_ref(), input);
233            assert!(is_normalized(input).unwrap());
234        }
235    }
236
237    #[test]
238    fn failures() {
239        let failures = [
240            "",
241            " starts-with-space",
242            "-starts-with-dash",
243            "ends-with-dash-",
244            "ends-with-space ",
245            "includes!invalid-char",
246            "space in middle",
247            "alpha-α",
248        ];
249        for input in failures {
250            assert!(validate_and_normalize_ref(input).is_err());
251            assert!(is_normalized(input).is_err());
252        }
253    }
254}