Skip to main content

uv_normalize/
lib.rs

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