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