Skip to main content

uv_pep508/
lib.rs

1//! A library for [dependency specifiers](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
2//! previously known as [PEP 508](https://peps.python.org/pep-0508/)
3//!
4//! ## Usage
5//!
6//! ```
7//! use std::str::FromStr;
8//! use uv_pep508::{Requirement, VerbatimUrl};
9//! use uv_normalize::ExtraName;
10//!
11//! let marker = r#"requests [security,tests] >= 2.8.1, == 2.8.* ; python_version > "3.8""#;
12//! let dependency_specification = Requirement::<VerbatimUrl>::from_str(marker).unwrap();
13//! assert_eq!(dependency_specification.name.as_ref(), "requests");
14//! assert_eq!(dependency_specification.extras, vec![ExtraName::from_str("security").unwrap(), ExtraName::from_str("tests").unwrap()].into());
15//! ```
16
17#![warn(missing_docs)]
18
19#[cfg(feature = "schemars")]
20use std::borrow::Cow;
21use std::error::Error;
22use std::fmt::{Debug, Display, Formatter};
23use std::path::Path;
24use std::str::FromStr;
25
26use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
27use thiserror::Error;
28use url::Url;
29
30use uv_cache_key::{CacheKey, CacheKeyHasher};
31use uv_normalize::{ExtraName, PackageName};
32
33use crate::cursor::Cursor;
34pub use crate::marker::{
35    CanonicalMarkerValueExtra, CanonicalMarkerValueString, CanonicalMarkerValueVersion,
36    ContainsMarkerTree, ExtraMarkerTree, ExtraOperator, InMarkerTree, MarkerEnvironment,
37    MarkerEnvironmentBuilder, MarkerExpression, MarkerOperator, MarkerTree, MarkerTreeContents,
38    MarkerTreeKind, MarkerValue, MarkerValueExtra, MarkerValueList, MarkerValueString,
39    MarkerValueVersion, MarkerWarningKind, StringMarkerTree, StringVersion, VersionMarkerTree,
40};
41pub use crate::origin::RequirementOrigin;
42#[cfg(feature = "non-pep508-extensions")]
43pub use crate::unnamed::{UnnamedRequirement, UnnamedRequirementUrl};
44pub use crate::verbatim_url::{
45    Scheme, VerbatimUrl, VerbatimUrlError, expand_env_vars, looks_like_git_repository,
46    split_scheme, strip_host,
47};
48/// Version and version specifiers used in requirements (reexport).
49// https://github.com/konstin/pep508_rs/issues/19
50pub use uv_pep440;
51use uv_pep440::{VersionSpecifier, VersionSpecifiers};
52
53mod cursor;
54pub mod marker;
55mod origin;
56#[cfg(feature = "non-pep508-extensions")]
57mod unnamed;
58mod verbatim_url;
59
60/// Error with a span attached. Not that those aren't `String` but `Vec<char>` indices.
61#[derive(Debug)]
62pub struct Pep508Error<T: Pep508Url = VerbatimUrl> {
63    /// Either we have an error string from our parser or an upstream error from `url`
64    pub message: Pep508ErrorSource<T>,
65    /// Span start index
66    pub start: usize,
67    /// Span length
68    pub len: usize,
69    /// The input string so we can print it underlined
70    pub input: String,
71}
72
73/// Either we have an error string from our parser or an upstream error from `url`
74#[derive(Debug, Error)]
75pub enum Pep508ErrorSource<T: Pep508Url = VerbatimUrl> {
76    /// An error from our parser.
77    #[error("{0}")]
78    String(String),
79    /// A URL parsing error.
80    #[error(transparent)]
81    UrlError(T::Err),
82    /// The version requirement is not supported.
83    #[error("{0}")]
84    UnsupportedRequirement(String),
85}
86
87impl<T: Pep508Url> Display for Pep508Error<T> {
88    /// Pretty formatting with underline.
89    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
90        // We can use char indices here since it's a Vec<char>
91        let start_offset = self.input[..self.start]
92            .chars()
93            .filter_map(unicode_width::UnicodeWidthChar::width)
94            .sum::<usize>();
95        let underline_len = if self.start == self.input.len() {
96            // We also allow 0 here for convenience
97            assert!(
98                self.len <= 1,
99                "Can only go one past the input not {}",
100                self.len
101            );
102            1
103        } else {
104            self.input[self.start..self.start + self.len]
105                .chars()
106                .filter_map(unicode_width::UnicodeWidthChar::width)
107                .sum::<usize>()
108        };
109        write!(
110            f,
111            "{}\n{}\n{}{}",
112            self.message,
113            self.input,
114            " ".repeat(start_offset),
115            "^".repeat(underline_len)
116        )
117    }
118}
119
120/// We need this to allow anyhow's `.context()` and `AsDynError`.
121impl<E: Error + Debug, T: Pep508Url<Err = E>> std::error::Error for Pep508Error<T> {}
122
123/// A PEP 508 dependency specifier.
124#[derive(Hash, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
125pub struct Requirement<T: Pep508Url = VerbatimUrl> {
126    /// The distribution name such as `requests` in
127    /// `requests [security,tests] >= 2.8.1, == 2.8.* ; python_version > "3.8"`.
128    pub name: PackageName,
129    /// The list of extras such as `security`, `tests` in
130    /// `requests [security,tests] >= 2.8.1, == 2.8.* ; python_version > "3.8"`.
131    pub extras: Box<[ExtraName]>,
132    /// The version specifier such as `>= 2.8.1`, `== 2.8.*` in
133    /// `requests [security,tests] >= 2.8.1, == 2.8.* ; python_version > "3.8"`.
134    /// or a URL.
135    pub version_or_url: Option<VersionOrUrl<T>>,
136    /// The markers such as `python_version > "3.8"` in
137    /// `requests [security,tests] >= 2.8.1, == 2.8.* ; python_version > "3.8"`.
138    /// Those are a nested and/or tree.
139    pub marker: MarkerTree,
140    /// The source file containing the requirement.
141    pub origin: Option<RequirementOrigin>,
142}
143
144impl<T: Pep508Url> Requirement<T> {
145    /// Removes the URL specifier from this requirement.
146    pub fn clear_url(&mut self) {
147        if matches!(self.version_or_url, Some(VersionOrUrl::Url(_))) {
148            self.version_or_url = None;
149        }
150    }
151
152    /// Returns a [`Display`] implementation that doesn't mask credentials.
153    pub fn displayable_with_credentials(&self) -> impl Display {
154        RequirementDisplay {
155            requirement: self,
156            display_credentials: true,
157        }
158    }
159}
160
161impl<T: Pep508Url + Display> Display for Requirement<T> {
162    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
163        RequirementDisplay {
164            requirement: self,
165            display_credentials: false,
166        }
167        .fmt(f)
168    }
169}
170
171struct RequirementDisplay<'a, T>
172where
173    T: Pep508Url + Display,
174{
175    requirement: &'a Requirement<T>,
176    display_credentials: bool,
177}
178
179impl<T: Pep508Url + Display> Display for RequirementDisplay<'_, T> {
180    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
181        write!(f, "{}", self.requirement.name)?;
182        if !self.requirement.extras.is_empty() {
183            write!(
184                f,
185                "[{}]",
186                self.requirement
187                    .extras
188                    .iter()
189                    .map(ToString::to_string)
190                    .collect::<Vec<_>>()
191                    .join(",")
192            )?;
193        }
194        if let Some(version_or_url) = &self.requirement.version_or_url {
195            match version_or_url {
196                VersionOrUrl::VersionSpecifier(version_specifier) => {
197                    let version_specifier: Vec<String> =
198                        version_specifier.iter().map(ToString::to_string).collect();
199                    write!(f, "{}", version_specifier.join(","))?;
200                }
201                VersionOrUrl::Url(url) => {
202                    let url_string = if self.display_credentials {
203                        url.displayable_with_credentials().to_string()
204                    } else {
205                        url.to_string()
206                    };
207                    // We add the space for markers later if necessary
208                    write!(f, " @ {url_string}")?;
209                }
210            }
211        }
212        if let Some(marker) = self.requirement.marker.contents() {
213            write!(f, " ; {marker}")?;
214        }
215        Ok(())
216    }
217}
218
219/// <https://github.com/serde-rs/serde/issues/908#issuecomment-298027413>
220impl<'de, T: Pep508Url> Deserialize<'de> for Requirement<T> {
221    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
222    where
223        D: Deserializer<'de>,
224    {
225        struct RequirementVisitor<T>(std::marker::PhantomData<T>);
226
227        impl<T: Pep508Url> serde::de::Visitor<'_> for RequirementVisitor<T> {
228            type Value = Requirement<T>;
229
230            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
231                formatter.write_str("a string containing a PEP 508 requirement")
232            }
233
234            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
235            where
236                E: de::Error,
237            {
238                FromStr::from_str(v).map_err(de::Error::custom)
239            }
240        }
241
242        deserializer.deserialize_str(RequirementVisitor(std::marker::PhantomData))
243    }
244}
245
246/// <https://github.com/serde-rs/serde/issues/1316#issue-332908452>
247impl<T: Pep508Url> Serialize for Requirement<T> {
248    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
249    where
250        S: Serializer,
251    {
252        serializer.collect_str(self)
253    }
254}
255
256impl<T: Pep508Url> CacheKey for Requirement<T> {
257    fn cache_key(&self, state: &mut CacheKeyHasher) {
258        self.name.as_str().cache_key(state);
259
260        self.extras.len().cache_key(state);
261        for extra in &self.extras {
262            extra.as_str().cache_key(state);
263        }
264
265        // TODO(zanieb): We inline cache key handling for the child types here, but we could
266        // move the implementations to the children. The intent here was to limit the scope of
267        // types exposing the `CacheKey` trait for now.
268        if let Some(version_or_url) = &self.version_or_url {
269            1u8.cache_key(state);
270            match version_or_url {
271                VersionOrUrl::VersionSpecifier(spec) => {
272                    0u8.cache_key(state);
273                    spec.len().cache_key(state);
274                    for specifier in spec.iter() {
275                        specifier.operator().as_str().cache_key(state);
276                        specifier.version().cache_key(state);
277                    }
278                }
279                VersionOrUrl::Url(url) => {
280                    1u8.cache_key(state);
281                    url.cache_key(state);
282                }
283            }
284        } else {
285            0u8.cache_key(state);
286        }
287
288        if let Some(marker) = self.marker.contents() {
289            1u8.cache_key(state);
290            marker.to_string().cache_key(state);
291        } else {
292            0u8.cache_key(state);
293        }
294
295        // `origin` is intentionally omitted
296    }
297}
298
299impl<T: Pep508Url> Requirement<T> {
300    /// Returns whether the markers apply for the given environment
301    pub fn evaluate_markers(&self, env: &MarkerEnvironment, extras: &[ExtraName]) -> bool {
302        self.marker.evaluate(env, extras)
303    }
304
305    /// Return the requirement with an additional marker added, to require the given extra.
306    ///
307    /// For example, given `flask >= 2.0.2`, calling `with_extra_marker("dotenv")` would return
308    /// `flask >= 2.0.2 ; extra == "dotenv"`.
309    #[must_use]
310    pub fn with_extra_marker(mut self, extra: &ExtraName) -> Self {
311        self.marker
312            .and(MarkerTree::expression(MarkerExpression::Extra {
313                operator: ExtraOperator::Equal,
314                name: MarkerValueExtra::Extra(extra.clone()),
315            }));
316
317        self
318    }
319
320    /// Set the source file containing the requirement.
321    #[must_use]
322    pub fn with_origin(self, origin: RequirementOrigin) -> Self {
323        Self {
324            origin: Some(origin),
325            ..self
326        }
327    }
328}
329
330/// Type to parse URLs from `name @ <url>` into. Defaults to [`Url`].
331pub trait Pep508Url: Display + Debug + Sized + CacheKey {
332    /// String to URL parsing error
333    type Err: Error + Debug;
334
335    /// Parse a url from `name @ <url>`. Defaults to [`Url::parse_url`].
336    fn parse_url(url: &str, working_dir: Option<&Path>) -> Result<Self, Self::Err>;
337
338    /// Returns a [`Display`] implementation that doesn't mask credentials.
339    fn displayable_with_credentials(&self) -> impl Display;
340}
341
342impl Pep508Url for Url {
343    type Err = url::ParseError;
344
345    fn parse_url(url: &str, _working_dir: Option<&Path>) -> Result<Self, Self::Err> {
346        Self::parse(url)
347    }
348
349    fn displayable_with_credentials(&self) -> impl Display {
350        self
351    }
352}
353
354/// A reporter for warnings that occur during marker parsing or evaluation.
355pub trait Reporter {
356    /// Report a warning.
357    fn report(&mut self, kind: MarkerWarningKind, warning: String);
358}
359
360impl<F> Reporter for F
361where
362    F: FnMut(MarkerWarningKind, String),
363{
364    fn report(&mut self, kind: MarkerWarningKind, warning: String) {
365        (self)(kind, warning);
366    }
367}
368
369/// A simple [`Reporter`] that logs to tracing when the `tracing` feature is enabled.
370pub struct TracingReporter;
371
372impl Reporter for TracingReporter {
373    #[allow(unused_variables)]
374    fn report(&mut self, _kind: MarkerWarningKind, message: String) {
375        #[cfg(feature = "tracing")]
376        {
377            tracing::warn!("{message}");
378        }
379    }
380}
381
382#[cfg(feature = "schemars")]
383impl<T: Pep508Url> schemars::JsonSchema for Requirement<T> {
384    fn schema_name() -> Cow<'static, str> {
385        Cow::Borrowed("Requirement")
386    }
387
388    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
389        schemars::json_schema!({
390            "type": "string",
391            "description": "A PEP 508 dependency specifier, e.g., `ruff >= 0.6.0`"
392        })
393    }
394}
395
396impl<T: Pep508Url> FromStr for Requirement<T> {
397    type Err = Pep508Error<T>;
398
399    /// Parse a [Dependency Specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/).
400    fn from_str(input: &str) -> Result<Self, Self::Err> {
401        parse_pep508_requirement::<T>(&mut Cursor::new(input), None, &mut TracingReporter)
402    }
403}
404
405impl<T: Pep508Url> Requirement<T> {
406    /// Parse a [Dependency Specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/).
407    pub fn parse(input: &str, working_dir: impl AsRef<Path>) -> Result<Self, Pep508Error<T>> {
408        parse_pep508_requirement(
409            &mut Cursor::new(input),
410            Some(working_dir.as_ref()),
411            &mut TracingReporter,
412        )
413    }
414
415    /// Parse a [Dependency Specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
416    /// with the given reporter for warnings.
417    pub fn parse_reporter(
418        input: &str,
419        working_dir: impl AsRef<Path>,
420        reporter: &mut impl Reporter,
421    ) -> Result<Self, Pep508Error<T>> {
422        parse_pep508_requirement(
423            &mut Cursor::new(input),
424            Some(working_dir.as_ref()),
425            reporter,
426        )
427    }
428}
429
430/// A list of [`ExtraName`] that can be attached to a [`Requirement`].
431#[derive(Debug, Clone, Eq, Hash, PartialEq)]
432pub struct Extras(Vec<ExtraName>);
433
434impl Extras {
435    /// Parse a list of extras.
436    pub fn parse<T: Pep508Url>(input: &str) -> Result<Self, Pep508Error<T>> {
437        Ok(Self(parse_extras_cursor(&mut Cursor::new(input))?))
438    }
439}
440
441/// The actual version specifier or URL to install.
442#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
443pub enum VersionOrUrl<T: Pep508Url = VerbatimUrl> {
444    /// A PEP 440 version specifier set
445    VersionSpecifier(VersionSpecifiers),
446    /// A installable URL
447    Url(T),
448}
449
450impl<T: Pep508Url> Display for VersionOrUrl<T> {
451    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
452        match self {
453            Self::VersionSpecifier(version_specifier) => Display::fmt(version_specifier, f),
454            Self::Url(url) => Display::fmt(url, f),
455        }
456    }
457}
458
459/// Unowned version specifier or URL to install.
460#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
461pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> {
462    /// A PEP 440 version specifier set
463    VersionSpecifier(&'a VersionSpecifiers),
464    /// A installable URL
465    Url(&'a T),
466}
467
468impl<T: Pep508Url> Display for VersionOrUrlRef<'_, T> {
469    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
470        match self {
471            Self::VersionSpecifier(version_specifier) => Display::fmt(version_specifier, f),
472            Self::Url(url) => Display::fmt(url, f),
473        }
474    }
475}
476
477impl<'a> From<&'a VersionOrUrl> for VersionOrUrlRef<'a> {
478    fn from(value: &'a VersionOrUrl) -> Self {
479        match value {
480            VersionOrUrl::VersionSpecifier(version_specifier) => {
481                VersionOrUrlRef::VersionSpecifier(version_specifier)
482            }
483            VersionOrUrl::Url(url) => VersionOrUrlRef::Url(url),
484        }
485    }
486}
487
488fn parse_name<T: Pep508Url>(cursor: &mut Cursor) -> Result<PackageName, Pep508Error<T>> {
489    // https://peps.python.org/pep-0508/#names
490    // ^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$ with re.IGNORECASE
491    let start = cursor.pos();
492
493    if let Some((index, char)) = cursor.next() {
494        if !matches!(char, 'A'..='Z' | 'a'..='z' | '0'..='9') {
495            // Check if the user added a filesystem path without a package name. pip supports this
496            // in `requirements.txt`, but it doesn't adhere to the PEP 508 grammar.
497            let mut clone = cursor.clone().at(start);
498            return if looks_like_unnamed_requirement(&mut clone) {
499                Err(Pep508Error {
500                    message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ /path/to/file`).".to_string()),
501                    start,
502                    len: clone.pos() - start,
503                    input: clone.to_string(),
504                })
505            } else {
506                Err(Pep508Error {
507                    message: Pep508ErrorSource::String(format!(
508                        "Expected package name starting with an alphanumeric character, found `{char}`"
509                    )),
510                    start: index,
511                    len: char.len_utf8(),
512                    input: cursor.to_string(),
513                })
514            };
515        }
516    } else {
517        return Err(Pep508Error {
518            message: Pep508ErrorSource::String("Empty field is not allowed for PEP508".to_string()),
519            start: 0,
520            len: 1,
521            input: cursor.to_string(),
522        });
523    }
524
525    cursor.take_while(|char| matches!(char, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_'));
526    let len = cursor.pos() - start;
527    // Unwrap-safety: The block above ensures that there is at least one char in the buffer.
528    let last = cursor.slice(start, len).chars().last().unwrap();
529    // [.-_] can't be the final character
530    if !matches!(last, 'A'..='Z' | 'a'..='z' | '0'..='9') {
531        return Err(Pep508Error {
532            message: Pep508ErrorSource::String(format!(
533                "Package name must end with an alphanumeric character, not `{last}`"
534            )),
535            start: cursor.pos() - last.len_utf8(),
536            len: last.len_utf8(),
537            input: cursor.to_string(),
538        });
539    }
540    Ok(PackageName::from_str(cursor.slice(start, len)).unwrap())
541}
542
543/// Parse a potential URL from the [`Cursor`], advancing the [`Cursor`] to the end of the URL.
544///
545/// Returns `true` if the URL appears to be a viable unnamed requirement, and `false` otherwise.
546fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool {
547    // Read the entire path.
548    let (start, len) = cursor.take_while(|char| !char.is_whitespace());
549    let url = cursor.slice(start, len);
550
551    // Expand any environment variables in the path.
552    let expanded = expand_env_vars(url);
553
554    // Strip extras.
555    let url = split_extras(&expanded)
556        .map(|(url, _)| url)
557        .unwrap_or(&expanded);
558
559    // Analyze the path.
560    let mut chars = url.chars();
561
562    let Some(first_char) = chars.next() else {
563        return false;
564    };
565
566    // Ex) `/bin/ls`
567    if first_char == '\\' || first_char == '/' || first_char == '.' {
568        return true;
569    }
570
571    // Ex) `https://` or `C:`
572    if split_scheme(url).is_some() {
573        return true;
574    }
575
576    // Ex) `foo/bar`
577    if url.contains('/') || url.contains('\\') {
578        return true;
579    }
580
581    // Ex) `foo.tar.gz`
582    if looks_like_archive(url) {
583        return true;
584    }
585
586    false
587}
588
589/// Returns `true` if a file looks like an archive.
590///
591/// See <https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8>
592/// for the list of supported archive extensions.
593fn looks_like_archive(file: impl AsRef<Path>) -> bool {
594    let file = file.as_ref();
595
596    // E.g., `gz` in `foo.tar.gz`
597    let Some(extension) = file.extension().and_then(|ext| ext.to_str()) else {
598        return false;
599    };
600
601    // E.g., `tar` in `foo.tar.gz`
602    let pre_extension = file
603        .file_stem()
604        .and_then(|stem| Path::new(stem).extension().and_then(|ext| ext.to_str()));
605
606    matches!(
607        (pre_extension, extension),
608        (_, "whl" | "tbz" | "txz" | "tlz" | "zip" | "tgz" | "tar")
609            | (Some("tar"), "bz2" | "xz" | "lz" | "lzma" | "gz")
610    )
611}
612
613/// parses extras in the `[extra1,extra2] format`
614fn parse_extras_cursor<T: Pep508Url>(
615    cursor: &mut Cursor,
616) -> Result<Vec<ExtraName>, Pep508Error<T>> {
617    let Some(bracket_pos) = cursor.eat_char('[') else {
618        return Ok(vec![]);
619    };
620    cursor.eat_whitespace();
621
622    let mut extras = Vec::new();
623    let mut is_first_iteration = true;
624
625    loop {
626        // End of the extras section. (Empty extras are allowed.)
627        if let Some(']') = cursor.peek_char() {
628            cursor.next();
629            break;
630        }
631
632        // Comma separator
633        match (cursor.peek(), is_first_iteration) {
634            // For the first iteration, we don't expect a comma.
635            (Some((pos, ',')), true) => {
636                return Err(Pep508Error {
637                    message: Pep508ErrorSource::String(
638                        "Expected either alphanumerical character (starting the extra name) or `]` (ending the extras section), found `,`".to_string()
639                    ),
640                    start: pos,
641                    len: 1,
642                    input: cursor.to_string(),
643                });
644            }
645            // For the other iterations, the comma is required.
646            (Some((_, ',')), false) => {
647                cursor.next();
648            }
649            (Some((pos, other)), false) => {
650                return Err(Pep508Error {
651                    message: Pep508ErrorSource::String(format!(
652                        "Expected either `,` (separating extras) or `]` (ending the extras section), found `{other}`"
653                    )),
654                    start: pos,
655                    len: 1,
656                    input: cursor.to_string(),
657                });
658            }
659            _ => {}
660        }
661
662        // wsp* before the identifier
663        cursor.eat_whitespace();
664        let mut buffer = String::new();
665        let early_eof_error = Pep508Error {
666            message: Pep508ErrorSource::String(
667                "Missing closing bracket (expected ']', found end of dependency specification)"
668                    .to_string(),
669            ),
670            start: bracket_pos,
671            len: 1,
672            input: cursor.to_string(),
673        };
674
675        // First char of the identifier.
676        match cursor.next() {
677            // letterOrDigit
678            Some((_, alphanumeric @ ('a'..='z' | 'A'..='Z' | '0'..='9'))) => {
679                buffer.push(alphanumeric);
680            }
681            Some((pos, other)) => {
682                return Err(Pep508Error {
683                    message: Pep508ErrorSource::String(format!(
684                        "Expected an alphanumeric character starting the extra name, found `{other}`"
685                    )),
686                    start: pos,
687                    len: other.len_utf8(),
688                    input: cursor.to_string(),
689                });
690            }
691            None => return Err(early_eof_error),
692        }
693        // Parse from the second char of the identifier
694        // We handle the illegal character case below
695        // identifier_end = letterOrDigit | (('-' | '_' | '.' )* letterOrDigit)
696        // identifier_end*
697        let (start, len) = cursor
698            .take_while(|char| matches!(char, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.'));
699        buffer.push_str(cursor.slice(start, len));
700        match cursor.peek() {
701            Some((pos, char)) if char != ',' && char != ']' && !char.is_whitespace() => {
702                return Err(Pep508Error {
703                    message: Pep508ErrorSource::String(format!(
704                        "Invalid character in extras name, expected an alphanumeric character, `-`, `_`, `.`, `,` or `]`, found `{char}`"
705                    )),
706                    start: pos,
707                    len: char.len_utf8(),
708                    input: cursor.to_string(),
709                });
710            }
711            _ => {}
712        }
713        // wsp* after the identifier
714        cursor.eat_whitespace();
715
716        // Add the parsed extra
717        extras.push(
718            ExtraName::from_str(&buffer)
719                .expect("`ExtraName` validation should match PEP 508 parsing"),
720        );
721        is_first_iteration = false;
722    }
723
724    Ok(extras)
725}
726
727/// Parse a raw string for a URL requirement, which could be either a URL or a local path, and which
728/// could contain unexpanded environment variables.
729///
730/// When parsing, we eat characters until we see any of the following:
731/// - A newline.
732/// - A semicolon (marker) or hash (comment), _preceded_ by a space. We parse the URL until the last
733///   non-whitespace character (inclusive).
734/// - A semicolon (marker) or hash (comment) _followed_ by a space. We treat this as an error, since
735///   the end of the URL is ambiguous.
736///
737/// For example:
738/// - `https://pypi.org/project/requests/...`
739/// - `file:///home/ferris/project/scripts/...`
740/// - `file:../editable/`
741/// - `../editable/`
742/// - `../path to editable/`
743/// - `https://download.pytorch.org/whl/torch_stable.html`
744fn parse_url<T: Pep508Url>(
745    cursor: &mut Cursor,
746    working_dir: Option<&Path>,
747) -> Result<T, Pep508Error<T>> {
748    // wsp*
749    cursor.eat_whitespace();
750    // <URI_reference>
751    let (start, len) = {
752        let start = cursor.pos();
753        let mut len = 0;
754        while let Some((_, c)) = cursor.next() {
755            // If we see a line break, we're done.
756            if matches!(c, '\r' | '\n') {
757                break;
758            }
759
760            // If we see top-level whitespace, check if it's followed by a semicolon or hash. If so,
761            // end the URL at the last non-whitespace character.
762            if c.is_whitespace() {
763                let mut cursor = cursor.clone();
764                cursor.eat_whitespace();
765                if matches!(cursor.peek_char(), None | Some(';' | '#')) {
766                    break;
767                }
768            }
769
770            len += c.len_utf8();
771
772            // If we see a top-level semicolon or hash followed by whitespace, we're done.
773            if cursor.peek_char().is_some_and(|c| matches!(c, ';' | '#')) {
774                let mut cursor = cursor.clone();
775                cursor.next();
776                if cursor.peek_char().is_some_and(char::is_whitespace) {
777                    break;
778                }
779            }
780        }
781        (start, len)
782    };
783
784    let url = cursor.slice(start, len);
785    if url.is_empty() {
786        return Err(Pep508Error {
787            message: Pep508ErrorSource::String("Expected URL".to_string()),
788            start,
789            len,
790            input: cursor.to_string(),
791        });
792    }
793
794    let url = T::parse_url(url, working_dir).map_err(|err| Pep508Error {
795        message: Pep508ErrorSource::UrlError(err),
796        start,
797        len,
798        input: cursor.to_string(),
799    })?;
800
801    Ok(url)
802}
803
804/// Identify the extras in a relative URL (e.g., `../editable[dev]`).
805///
806/// Pip uses `m = re.match(r'^(.+)(\[[^]]+])$', path)`. Our strategy is:
807/// - If the string ends with a closing bracket (`]`)...
808/// - Iterate backwards until you find the open bracket (`[`)...
809/// - But abort if you find another closing bracket (`]`) first.
810pub fn split_extras(given: &str) -> Option<(&str, &str)> {
811    let mut chars = given.char_indices().rev();
812
813    // If the string ends with a closing bracket (`]`)...
814    if !matches!(chars.next(), Some((_, ']'))) {
815        return None;
816    }
817
818    // Iterate backwards until you find the open bracket (`[`)...
819    let (index, _) = chars
820        .take_while(|(_, c)| *c != ']')
821        .find(|(_, c)| *c == '[')?;
822
823    Some(given.split_at(index))
824}
825
826/// PEP 440 wrapper
827fn parse_specifier<T: Pep508Url>(
828    cursor: &mut Cursor,
829    buffer: &str,
830    start: usize,
831    end: usize,
832) -> Result<VersionSpecifier, Pep508Error<T>> {
833    VersionSpecifier::from_str(buffer).map_err(|err| Pep508Error {
834        message: Pep508ErrorSource::String(err.to_string()),
835        start,
836        len: end - start,
837        input: cursor.to_string(),
838    })
839}
840
841/// Such as `>=1.19,<2.0`, either delimited by the end of the specifier or a `;` for the marker part
842///
843/// ```text
844/// version_one (wsp* ',' version_one)*
845/// ```
846fn parse_version_specifier<T: Pep508Url>(
847    cursor: &mut Cursor,
848) -> Result<Option<VersionOrUrl<T>>, Pep508Error<T>> {
849    let mut start = cursor.pos();
850    let mut specifiers = Vec::new();
851    let mut buffer = String::new();
852    let requirement_kind = loop {
853        match cursor.peek() {
854            Some((end, ',')) => {
855                let specifier = parse_specifier(cursor, &buffer, start, end)?;
856                specifiers.push(specifier);
857                buffer.clear();
858                cursor.next();
859                start = end + 1;
860            }
861            Some((_, ';')) | None => {
862                let end = cursor.pos();
863                let specifier = parse_specifier(cursor, &buffer, start, end)?;
864                specifiers.push(specifier);
865                break Some(VersionOrUrl::VersionSpecifier(
866                    specifiers.into_iter().collect(),
867                ));
868            }
869            Some((_, char)) => {
870                buffer.push(char);
871                cursor.next();
872            }
873        }
874    };
875    Ok(requirement_kind)
876}
877
878/// Such as `(>=1.19,<2.0)`
879///
880/// ```text
881/// '(' version_one (wsp* ',' version_one)* ')'
882/// ```
883fn parse_version_specifier_parentheses<T: Pep508Url>(
884    cursor: &mut Cursor,
885) -> Result<Option<VersionOrUrl<T>>, Pep508Error<T>> {
886    let brace_pos = cursor.pos();
887    cursor.next();
888    // Makes for slightly better error underline
889    cursor.eat_whitespace();
890    let mut start = cursor.pos();
891    let mut specifiers = Vec::new();
892    let mut buffer = String::new();
893    let requirement_kind = loop {
894        match cursor.next() {
895            Some((end, ',')) => {
896                let specifier =
897                    parse_specifier(cursor, &buffer, start, end)?;
898                specifiers.push(specifier);
899                buffer.clear();
900                start = end + 1;
901            }
902            Some((end, ')')) => {
903                let specifier = parse_specifier(cursor, &buffer, start, end)?;
904                specifiers.push(specifier);
905                break Some(VersionOrUrl::VersionSpecifier(specifiers.into_iter().collect()));
906            }
907            Some((_, char)) => buffer.push(char),
908            None => return Err(Pep508Error {
909                message: Pep508ErrorSource::String("Missing closing parenthesis (expected ')', found end of dependency specification)".to_string()),
910                start: brace_pos,
911                len: 1,
912                input: cursor.to_string(),
913            }),
914        }
915    };
916    Ok(requirement_kind)
917}
918
919/// Parse a PEP 508-compliant [dependency specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers).
920fn parse_pep508_requirement<T: Pep508Url>(
921    cursor: &mut Cursor,
922    working_dir: Option<&Path>,
923    reporter: &mut impl Reporter,
924) -> Result<Requirement<T>, Pep508Error<T>> {
925    let start = cursor.pos();
926
927    // Technically, the grammar is:
928    // ```text
929    // name_req      = name wsp* extras? wsp* versionspec? wsp* quoted_marker?
930    // url_req       = name wsp* extras? wsp* urlspec wsp+ quoted_marker?
931    // specification = wsp* ( url_req | name_req ) wsp*
932    // ```
933    // So we can merge this into:
934    // ```text
935    // specification = wsp* name wsp* extras? wsp* (('@' wsp* url_req) | ('(' versionspec ')') | (versionspec)) wsp* (';' wsp* marker)? wsp*
936    // ```
937    // Where the extras start with '[' if any, then we have '@', '(' or one of the version comparison
938    // operators. Markers start with ';' if any
939    // wsp*
940    cursor.eat_whitespace();
941    // name
942    let name_start = cursor.pos();
943    let name = parse_name(cursor)?;
944    let name_end = cursor.pos();
945    // wsp*
946    cursor.eat_whitespace();
947    // extras?
948    let extras = parse_extras_cursor(cursor)?;
949    // wsp*
950    cursor.eat_whitespace();
951
952    // ( url_req | name_req )?
953    let requirement_kind = match cursor.peek_char() {
954        // url_req
955        Some('@') => {
956            cursor.next();
957            Some(VersionOrUrl::Url(parse_url(cursor, working_dir)?))
958        }
959        // name_req
960        Some('(') => parse_version_specifier_parentheses(cursor)?,
961        // name_req
962        Some('<' | '=' | '>' | '~' | '!') => parse_version_specifier(cursor)?,
963        // No requirements / any version
964        Some(';') | None => None,
965        Some(other) => {
966            // Rewind to the start of the version specifier, to see if the user added a URL without
967            // a package name. pip supports this in `requirements.txt`, but it doesn't adhere to
968            // the PEP 508 grammar.
969            let mut clone = cursor.clone().at(start);
970            return if looks_like_unnamed_requirement(&mut clone) {
971                Err(Pep508Error {
972                    message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).".to_string()),
973                    start,
974                    len: clone.pos() - start,
975                    input: clone.to_string(),
976                })
977            } else {
978                Err(Pep508Error {
979                    message: Pep508ErrorSource::String(format!(
980                        "Expected one of `@`, `(`, `<`, `=`, `>`, `~`, `!`, `;`, found `{other}`"
981                    )),
982                    start: cursor.pos(),
983                    len: other.len_utf8(),
984                    input: cursor.to_string(),
985                })
986            };
987        }
988    };
989
990    // If the requirement consists solely of a package name, and that name appears to be an archive,
991    // treat it as a URL requirement, for consistency and security. (E.g., `requests-2.26.0.tar.gz`
992    // is a valid Python package name, but we should treat it as a reference to a file.)
993    //
994    // See: https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8
995    if requirement_kind.is_none() {
996        if looks_like_archive(cursor.slice(name_start, name_end - name_start)) {
997            let clone = cursor.clone().at(start);
998            return Err(Pep508Error {
999                message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).".to_string()),
1000                start,
1001                len: clone.pos() - start,
1002                input: clone.to_string(),
1003            });
1004        }
1005    }
1006
1007    // wsp*
1008    cursor.eat_whitespace();
1009    // quoted_marker?
1010    let marker = if cursor.peek_char() == Some(';') {
1011        // Skip past the semicolon
1012        cursor.next();
1013        marker::parse::parse_markers_cursor(cursor, reporter)?
1014    } else {
1015        None
1016    };
1017
1018    // wsp*
1019    cursor.eat_whitespace();
1020
1021    if let Some((pos, char)) = cursor.next().filter(|(_, c)| *c != '#') {
1022        let message = if char == '#' {
1023            format!(
1024                r"Expected end of input or `;`, found `{char}`; comments must be preceded by a leading space"
1025            )
1026        } else if marker.is_none() {
1027            format!(r"Expected end of input or `;`, found `{char}`")
1028        } else {
1029            format!(r"Expected end of input, found `{char}`")
1030        };
1031        return Err(Pep508Error {
1032            message: Pep508ErrorSource::String(message),
1033            start: pos,
1034            len: char.len_utf8(),
1035            input: cursor.to_string(),
1036        });
1037    }
1038
1039    Ok(Requirement {
1040        name,
1041        extras: extras.into_boxed_slice(),
1042        version_or_url: requirement_kind,
1043        marker: marker.unwrap_or_default(),
1044        origin: None,
1045    })
1046}
1047
1048#[cfg(feature = "rkyv")]
1049/// An [`rkyv`] implementation for [`Requirement`].
1050impl<T: Pep508Url + Display> rkyv::Archive for Requirement<T> {
1051    type Archived = rkyv::string::ArchivedString;
1052    type Resolver = rkyv::string::StringResolver;
1053
1054    #[inline]
1055    fn resolve(&self, resolver: Self::Resolver, out: rkyv::Place<Self::Archived>) {
1056        let as_str = self.to_string();
1057        rkyv::string::ArchivedString::resolve_from_str(&as_str, resolver, out);
1058    }
1059}
1060
1061#[cfg(feature = "rkyv")]
1062impl<T: Pep508Url + Display, S> rkyv::Serialize<S> for Requirement<T>
1063where
1064    S: rkyv::rancor::Fallible + rkyv::ser::Allocator + rkyv::ser::Writer + ?Sized,
1065    S::Error: rkyv::rancor::Source,
1066{
1067    fn serialize(&self, serializer: &mut S) -> Result<Self::Resolver, S::Error> {
1068        let as_str = self.to_string();
1069        rkyv::string::ArchivedString::serialize_from_str(&as_str, serializer)
1070    }
1071}
1072
1073#[cfg(feature = "rkyv")]
1074impl<T: Pep508Url + Display, D: rkyv::rancor::Fallible + ?Sized>
1075    rkyv::Deserialize<Requirement<T>, D> for rkyv::string::ArchivedString
1076{
1077    fn deserialize(&self, _deserializer: &mut D) -> Result<Requirement<T>, D::Error> {
1078        // SAFETY: We only serialize valid requirements.
1079        Ok(Requirement::<T>::from_str(self.as_str()).unwrap())
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    //! Half of these tests are copied from <https://github.com/pypa/packaging/pull/624>
1086
1087    use std::env;
1088    use std::str::FromStr;
1089
1090    use insta::assert_snapshot;
1091    use url::Url;
1092
1093    use uv_normalize::{ExtraName, InvalidNameError, PackageName};
1094    use uv_pep440::{Operator, Version, VersionPattern, VersionSpecifier};
1095
1096    use crate::cursor::Cursor;
1097    use crate::marker::{MarkerExpression, MarkerTree, MarkerValueVersion, parse};
1098    use crate::{
1099        MarkerOperator, MarkerValueString, Requirement, TracingReporter, VerbatimUrl, VersionOrUrl,
1100    };
1101
1102    fn parse_pep508_err(input: &str) -> String {
1103        Requirement::<VerbatimUrl>::from_str(input)
1104            .unwrap_err()
1105            .to_string()
1106    }
1107
1108    #[cfg(feature = "non-pep508-extensions")]
1109    fn parse_unnamed_err(input: &str) -> String {
1110        crate::UnnamedRequirement::<VerbatimUrl>::from_str(input)
1111            .unwrap_err()
1112            .to_string()
1113    }
1114
1115    #[cfg(windows)]
1116    #[test]
1117    fn test_preprocess_url_windows() {
1118        use std::path::PathBuf;
1119
1120        let actual = crate::parse_url::<VerbatimUrl>(
1121            &mut Cursor::new("file:///C:/Users/ferris/wheel-0.42.0.tar.gz"),
1122            None,
1123        )
1124        .unwrap()
1125        .to_file_path();
1126        let expected = PathBuf::from(r"C:\Users\ferris\wheel-0.42.0.tar.gz");
1127        assert_eq!(actual, Ok(expected));
1128    }
1129
1130    #[test]
1131    fn error_empty() {
1132        assert_snapshot!(
1133            parse_pep508_err(""),
1134            @"
1135        Empty field is not allowed for PEP508
1136
1137        ^
1138        "
1139        );
1140    }
1141
1142    #[test]
1143    fn error_start() {
1144        assert_snapshot!(
1145            parse_pep508_err("_name"),
1146            @"
1147        Expected package name starting with an alphanumeric character, found `_`
1148        _name
1149        ^
1150        "
1151        );
1152    }
1153
1154    #[test]
1155    fn error_end() {
1156        assert_snapshot!(
1157            parse_pep508_err("name_"),
1158            @"
1159        Package name must end with an alphanumeric character, not `_`
1160        name_
1161            ^
1162        "
1163        );
1164    }
1165
1166    #[test]
1167    fn basic_examples() {
1168        let input = r"requests[security,tests]==2.8.*,>=2.8.1 ; python_full_version < '2.7'";
1169        let requests = Requirement::<Url>::from_str(input).unwrap();
1170        assert_eq!(input, requests.to_string());
1171        let expected = Requirement {
1172            name: PackageName::from_str("requests").unwrap(),
1173            extras: Box::new([
1174                ExtraName::from_str("security").unwrap(),
1175                ExtraName::from_str("tests").unwrap(),
1176            ]),
1177            version_or_url: Some(VersionOrUrl::VersionSpecifier(
1178                [
1179                    VersionSpecifier::from_pattern(
1180                        Operator::Equal,
1181                        VersionPattern::wildcard(Version::new([2, 8])),
1182                    )
1183                    .unwrap(),
1184                    VersionSpecifier::from_pattern(
1185                        Operator::GreaterThanEqual,
1186                        VersionPattern::verbatim(Version::new([2, 8, 1])),
1187                    )
1188                    .unwrap(),
1189                ]
1190                .into_iter()
1191                .collect(),
1192            )),
1193            marker: MarkerTree::expression(MarkerExpression::Version {
1194                key: MarkerValueVersion::PythonFullVersion,
1195                specifier: VersionSpecifier::from_pattern(
1196                    Operator::LessThan,
1197                    "2.7".parse().unwrap(),
1198                )
1199                .unwrap(),
1200            }),
1201            origin: None,
1202        };
1203        assert_eq!(requests, expected);
1204    }
1205
1206    #[test]
1207    fn leading_whitespace() {
1208        let numpy = Requirement::<Url>::from_str(" numpy").unwrap();
1209        assert_eq!(numpy.name.as_ref(), "numpy");
1210    }
1211
1212    #[test]
1213    fn parenthesized_single() {
1214        let numpy = Requirement::<Url>::from_str("numpy ( >=1.19 )").unwrap();
1215        assert_eq!(numpy.name.as_ref(), "numpy");
1216    }
1217
1218    #[test]
1219    fn parenthesized_double() {
1220        let numpy = Requirement::<Url>::from_str("numpy ( >=1.19, <2.0 )").unwrap();
1221        assert_eq!(numpy.name.as_ref(), "numpy");
1222    }
1223
1224    #[test]
1225    fn versions_single() {
1226        let numpy = Requirement::<Url>::from_str("numpy >=1.19 ").unwrap();
1227        assert_eq!(numpy.name.as_ref(), "numpy");
1228    }
1229
1230    #[test]
1231    fn versions_double() {
1232        let numpy = Requirement::<Url>::from_str("numpy >=1.19, <2.0 ").unwrap();
1233        assert_eq!(numpy.name.as_ref(), "numpy");
1234    }
1235
1236    #[test]
1237    #[cfg(feature = "non-pep508-extensions")]
1238    fn direct_url_no_extras() {
1239        let numpy = crate::UnnamedRequirement::<VerbatimUrl>::from_str("https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl").unwrap();
1240        assert_eq!(
1241            numpy.url.to_string(),
1242            "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl"
1243        );
1244        assert_eq!(*numpy.extras, []);
1245    }
1246
1247    #[test]
1248    #[cfg(all(unix, feature = "non-pep508-extensions"))]
1249    fn direct_url_extras() {
1250        let numpy = crate::UnnamedRequirement::<VerbatimUrl>::from_str(
1251            "/path/to/numpy-1.26.4-cp312-cp312-win32.whl[dev]",
1252        )
1253        .unwrap();
1254        assert_eq!(
1255            numpy.url.to_string(),
1256            "file:///path/to/numpy-1.26.4-cp312-cp312-win32.whl"
1257        );
1258        assert_eq!(*numpy.extras, [ExtraName::from_str("dev").unwrap()]);
1259    }
1260
1261    #[test]
1262    #[cfg(all(windows, feature = "non-pep508-extensions"))]
1263    fn direct_url_extras() {
1264        let numpy = crate::UnnamedRequirement::<VerbatimUrl>::from_str(
1265            "C:\\path\\to\\numpy-1.26.4-cp312-cp312-win32.whl[dev]",
1266        )
1267        .unwrap();
1268        assert_eq!(
1269            numpy.url.to_string(),
1270            "file:///C:/path/to/numpy-1.26.4-cp312-cp312-win32.whl"
1271        );
1272        assert_eq!(*numpy.extras, [ExtraName::from_str("dev").unwrap()]);
1273    }
1274
1275    #[test]
1276    fn error_extras_eof1() {
1277        assert_snapshot!(
1278            parse_pep508_err("black["),
1279            @"
1280        Missing closing bracket (expected ']', found end of dependency specification)
1281        black[
1282             ^
1283        "
1284        );
1285    }
1286
1287    #[test]
1288    fn error_extras_eof2() {
1289        assert_snapshot!(
1290            parse_pep508_err("black[d"),
1291            @"
1292        Missing closing bracket (expected ']', found end of dependency specification)
1293        black[d
1294             ^
1295        "
1296        );
1297    }
1298
1299    #[test]
1300    fn error_extras_eof3() {
1301        assert_snapshot!(
1302            parse_pep508_err("black[d,"),
1303            @"
1304        Missing closing bracket (expected ']', found end of dependency specification)
1305        black[d,
1306             ^
1307        "
1308        );
1309    }
1310
1311    #[test]
1312    fn error_extras_illegal_start1() {
1313        assert_snapshot!(
1314            parse_pep508_err("black[ö]"),
1315            @"
1316        Expected an alphanumeric character starting the extra name, found `ö`
1317        black[ö]
1318              ^
1319        "
1320        );
1321    }
1322
1323    #[test]
1324    fn error_extras_illegal_start2() {
1325        assert_snapshot!(
1326            parse_pep508_err("black[_d]"),
1327            @"
1328        Expected an alphanumeric character starting the extra name, found `_`
1329        black[_d]
1330              ^
1331        "
1332        );
1333    }
1334
1335    #[test]
1336    fn error_extras_illegal_start3() {
1337        assert_snapshot!(
1338            parse_pep508_err("black[,]"),
1339            @"
1340        Expected either alphanumerical character (starting the extra name) or `]` (ending the extras section), found `,`
1341        black[,]
1342              ^
1343        "
1344        );
1345    }
1346
1347    #[test]
1348    fn error_extras_illegal_character() {
1349        assert_snapshot!(
1350            parse_pep508_err("black[jüpyter]"),
1351            @"
1352        Invalid character in extras name, expected an alphanumeric character, `-`, `_`, `.`, `,` or `]`, found `ü`
1353        black[jüpyter]
1354               ^
1355        "
1356        );
1357    }
1358
1359    #[test]
1360    fn error_extras1() {
1361        let numpy = Requirement::<Url>::from_str("black[d]").unwrap();
1362        assert_eq!(*numpy.extras, [ExtraName::from_str("d").unwrap()]);
1363    }
1364
1365    #[test]
1366    fn error_extras2() {
1367        let numpy = Requirement::<Url>::from_str("black[d,jupyter]").unwrap();
1368        assert_eq!(
1369            *numpy.extras,
1370            [
1371                ExtraName::from_str("d").unwrap(),
1372                ExtraName::from_str("jupyter").unwrap(),
1373            ]
1374        );
1375    }
1376
1377    #[test]
1378    fn empty_extras() {
1379        let black = Requirement::<Url>::from_str("black[]").unwrap();
1380        assert_eq!(*black.extras, []);
1381    }
1382
1383    #[test]
1384    fn empty_extras_with_spaces() {
1385        let black = Requirement::<Url>::from_str("black[  ]").unwrap();
1386        assert_eq!(*black.extras, []);
1387    }
1388
1389    #[test]
1390    fn error_extra_with_trailing_comma() {
1391        assert_snapshot!(
1392            parse_pep508_err("black[d,]"),
1393            @"
1394        Expected an alphanumeric character starting the extra name, found `]`
1395        black[d,]
1396                ^
1397        "
1398        );
1399    }
1400
1401    #[test]
1402    fn error_parenthesized_pep440() {
1403        assert_snapshot!(
1404            parse_pep508_err("numpy ( ><1.19 )"),
1405            @r#"
1406        no such comparison operator "><", must be one of ~= == != <= >= < > ===
1407        numpy ( ><1.19 )
1408                ^^^^^^^
1409        "#
1410        );
1411    }
1412
1413    #[test]
1414    fn error_parenthesized_parenthesis() {
1415        assert_snapshot!(
1416            parse_pep508_err("numpy ( >=1.19"),
1417            @"
1418        Missing closing parenthesis (expected ')', found end of dependency specification)
1419        numpy ( >=1.19
1420              ^
1421        "
1422        );
1423    }
1424
1425    #[test]
1426    fn error_whats_that() {
1427        assert_snapshot!(
1428            parse_pep508_err("numpy % 1.16"),
1429            @"
1430        Expected one of `@`, `(`, `<`, `=`, `>`, `~`, `!`, `;`, found `%`
1431        numpy % 1.16
1432              ^
1433        "
1434        );
1435    }
1436
1437    #[test]
1438    fn url() {
1439        let pip_url =
1440            Requirement::from_str("pip @ https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686")
1441                .unwrap();
1442        let url = "https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686";
1443        let expected = Requirement {
1444            name: PackageName::from_str("pip").unwrap(),
1445            extras: Box::new([]),
1446            marker: MarkerTree::TRUE,
1447            version_or_url: Some(VersionOrUrl::Url(Url::parse(url).unwrap())),
1448            origin: None,
1449        };
1450        assert_eq!(pip_url, expected);
1451    }
1452
1453    #[test]
1454    fn test_marker_parsing() {
1455        let marker = r#"python_version == "2.7" and (sys_platform == "win32" or (os_name == "linux" and implementation_name == 'cpython'))"#;
1456        let actual = parse::parse_markers_cursor::<VerbatimUrl>(
1457            &mut Cursor::new(marker),
1458            &mut TracingReporter,
1459        )
1460        .unwrap()
1461        .unwrap();
1462
1463        let mut a = MarkerTree::expression(MarkerExpression::Version {
1464            key: MarkerValueVersion::PythonVersion,
1465            specifier: VersionSpecifier::from_pattern(Operator::Equal, "2.7".parse().unwrap())
1466                .unwrap(),
1467        });
1468        let mut b = MarkerTree::expression(MarkerExpression::String {
1469            key: MarkerValueString::SysPlatform,
1470            operator: MarkerOperator::Equal,
1471            value: arcstr::literal!("win32"),
1472        });
1473        let mut c = MarkerTree::expression(MarkerExpression::String {
1474            key: MarkerValueString::OsName,
1475            operator: MarkerOperator::Equal,
1476            value: arcstr::literal!("linux"),
1477        });
1478        let d = MarkerTree::expression(MarkerExpression::String {
1479            key: MarkerValueString::ImplementationName,
1480            operator: MarkerOperator::Equal,
1481            value: arcstr::literal!("cpython"),
1482        });
1483
1484        c.and(d);
1485        b.or(c);
1486        a.and(b);
1487
1488        assert_eq!(a, actual);
1489    }
1490
1491    #[test]
1492    fn name_and_marker() {
1493        Requirement::<Url>::from_str(r#"numpy; sys_platform == "win32" or (os_name == "linux" and implementation_name == 'cpython')"#).unwrap();
1494    }
1495
1496    #[test]
1497    fn error_marker_incomplete1() {
1498        assert_snapshot!(
1499            parse_pep508_err(r"numpy; sys_platform"),
1500            @"
1501        Expected a valid marker operator (such as `>=` or `not in`), found ``
1502        numpy; sys_platform
1503                           ^
1504        "
1505        );
1506    }
1507
1508    #[test]
1509    fn error_marker_incomplete2() {
1510        assert_snapshot!(
1511            parse_pep508_err(r"numpy; sys_platform =="),
1512            @"
1513        Expected marker value, found end of dependency specification
1514        numpy; sys_platform ==
1515                              ^
1516        "
1517        );
1518    }
1519
1520    #[test]
1521    fn error_marker_incomplete3() {
1522        assert_snapshot!(
1523            parse_pep508_err(r#"numpy; sys_platform == "win32" or"#),
1524            @r#"
1525        Expected marker value, found end of dependency specification
1526        numpy; sys_platform == "win32" or
1527                                         ^
1528        "#
1529        );
1530    }
1531
1532    #[test]
1533    fn error_marker_incomplete4() {
1534        assert_snapshot!(
1535            parse_pep508_err(r#"numpy; sys_platform == "win32" or (os_name == "linux""#),
1536            @r#"
1537        Expected ')', found end of dependency specification
1538        numpy; sys_platform == "win32" or (os_name == "linux"
1539                                          ^
1540        "#
1541        );
1542    }
1543
1544    #[test]
1545    fn error_marker_incomplete5() {
1546        assert_snapshot!(
1547            parse_pep508_err(r#"numpy; sys_platform == "win32" or (os_name == "linux" and"#),
1548            @r#"
1549        Expected marker value, found end of dependency specification
1550        numpy; sys_platform == "win32" or (os_name == "linux" and
1551                                                                 ^
1552        "#
1553        );
1554    }
1555
1556    #[test]
1557    fn error_pep440() {
1558        assert_snapshot!(
1559            parse_pep508_err(r"numpy >=1.1.*"),
1560            @"
1561        Operator >= cannot be used with a wildcard version specifier
1562        numpy >=1.1.*
1563              ^^^^^^^
1564        "
1565        );
1566    }
1567
1568    #[test]
1569    fn error_no_name() {
1570        assert_snapshot!(
1571            parse_pep508_err(r"==0.0"),
1572            @"
1573        Expected package name starting with an alphanumeric character, found `=`
1574        ==0.0
1575        ^
1576        "
1577        );
1578    }
1579
1580    #[test]
1581    fn error_unnamedunnamed_url() {
1582        assert_snapshot!(
1583            parse_pep508_err(r"git+https://github.com/pallets/flask.git"),
1584            @"
1585        URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).
1586        git+https://github.com/pallets/flask.git
1587        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1588        "
1589        );
1590    }
1591
1592    #[test]
1593    fn error_unnamed_file_path() {
1594        assert_snapshot!(
1595            parse_pep508_err(r"/path/to/flask.tar.gz"),
1596            @"
1597        URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ /path/to/file`).
1598        /path/to/flask.tar.gz
1599        ^^^^^^^^^^^^^^^^^^^^^
1600        "
1601        );
1602    }
1603
1604    #[test]
1605    fn error_no_comma_between_extras() {
1606        assert_snapshot!(
1607            parse_pep508_err(r"name[bar baz]"),
1608            @"
1609        Expected either `,` (separating extras) or `]` (ending the extras section), found `b`
1610        name[bar baz]
1611                 ^
1612        "
1613        );
1614    }
1615
1616    #[test]
1617    fn error_extra_comma_after_extras() {
1618        assert_snapshot!(
1619            parse_pep508_err(r"name[bar, baz,]"),
1620            @"
1621        Expected an alphanumeric character starting the extra name, found `]`
1622        name[bar, baz,]
1623                      ^
1624        "
1625        );
1626    }
1627
1628    #[test]
1629    fn error_extras_not_closed() {
1630        assert_snapshot!(
1631            parse_pep508_err(r"name[bar, baz >= 1.0"),
1632            @"
1633        Expected either `,` (separating extras) or `]` (ending the extras section), found `>`
1634        name[bar, baz >= 1.0
1635                      ^
1636        "
1637        );
1638    }
1639
1640    #[test]
1641    fn error_name_at_nothing() {
1642        assert_snapshot!(
1643            parse_pep508_err(r"name @"),
1644            @"
1645        Expected URL
1646        name @
1647              ^
1648        "
1649        );
1650    }
1651
1652    #[test]
1653    fn parse_name_with_star() {
1654        assert_snapshot!(
1655            parse_pep508_err("wheel-*.whl"),
1656            @"
1657        Package name must end with an alphanumeric character, not `-`
1658        wheel-*.whl
1659             ^
1660        ");
1661        assert_snapshot!(
1662            parse_pep508_err("wheelѦ"),
1663            @"
1664        Expected one of `@`, `(`, `<`, `=`, `>`, `~`, `!`, `;`, found `Ѧ`
1665        wheelѦ
1666             ^
1667        ");
1668    }
1669
1670    #[test]
1671    fn test_error_invalid_marker_key() {
1672        assert_snapshot!(
1673            parse_pep508_err(r"name; invalid_name"),
1674            @"
1675        Expected a quoted string or a valid marker name, found `invalid_name`
1676        name; invalid_name
1677              ^^^^^^^^^^^^
1678        "
1679        );
1680    }
1681
1682    #[test]
1683    fn error_markers_invalid_order() {
1684        assert_snapshot!(
1685            parse_pep508_err("name; '3.7' <= invalid_name"),
1686            @"
1687        Expected a quoted string or a valid marker name, found `invalid_name`
1688        name; '3.7' <= invalid_name
1689                       ^^^^^^^^^^^^
1690        "
1691        );
1692    }
1693
1694    #[test]
1695    fn error_markers_notin() {
1696        assert_snapshot!(
1697            parse_pep508_err("name; '3.7' notin python_version"),
1698            @"
1699        Expected a valid marker operator (such as `>=` or `not in`), found `notin`
1700        name; '3.7' notin python_version
1701                    ^^^^^
1702        "
1703        );
1704    }
1705
1706    #[test]
1707    fn error_missing_quote() {
1708        assert_snapshot!(
1709            parse_pep508_err("name; python_version == 3.10"),
1710            @"
1711        Expected a quoted string or a valid marker name, found `3.10`
1712        name; python_version == 3.10
1713                                ^^^^
1714        "
1715        );
1716    }
1717
1718    #[test]
1719    fn error_markers_inpython_version() {
1720        assert_snapshot!(
1721            parse_pep508_err("name; '3.6'inpython_version"),
1722            @"
1723        Expected a valid marker operator (such as `>=` or `not in`), found `inpython_version`
1724        name; '3.6'inpython_version
1725                   ^^^^^^^^^^^^^^^^
1726        "
1727        );
1728    }
1729
1730    #[test]
1731    fn error_markers_not_python_version() {
1732        assert_snapshot!(
1733            parse_pep508_err("name; '3.7' not python_version"),
1734            @"
1735        Expected `i`, found `p`
1736        name; '3.7' not python_version
1737                        ^
1738        "
1739        );
1740    }
1741
1742    #[test]
1743    fn error_markers_invalid_operator() {
1744        assert_snapshot!(
1745            parse_pep508_err("name; '3.7' ~ python_version"),
1746            @"
1747        Expected a valid marker operator (such as `>=` or `not in`), found `~`
1748        name; '3.7' ~ python_version
1749                    ^
1750        "
1751        );
1752    }
1753
1754    #[test]
1755    fn error_invalid_prerelease() {
1756        assert_snapshot!(
1757            parse_pep508_err("name==1.0.org1"),
1758            @"
1759        after parsing `1.0`, found `.org1`, which is not part of a valid version
1760        name==1.0.org1
1761            ^^^^^^^^^^
1762        "
1763        );
1764    }
1765
1766    #[test]
1767    fn error_no_version_value() {
1768        assert_snapshot!(
1769            parse_pep508_err("name=="),
1770            @"
1771        Unexpected end of version specifier, expected version
1772        name==
1773            ^^
1774        "
1775        );
1776    }
1777
1778    #[test]
1779    fn error_no_version_operator() {
1780        assert_snapshot!(
1781            parse_pep508_err("name 1.0"),
1782            @"
1783        Expected one of `@`, `(`, `<`, `=`, `>`, `~`, `!`, `;`, found `1`
1784        name 1.0
1785             ^
1786        "
1787        );
1788    }
1789
1790    #[test]
1791    fn error_random_char() {
1792        assert_snapshot!(
1793            parse_pep508_err("name >= 1.0 #"),
1794            @"
1795        Trailing `#` is not allowed
1796        name >= 1.0 #
1797             ^^^^^^^^
1798        "
1799        );
1800    }
1801
1802    #[test]
1803    #[cfg(feature = "non-pep508-extensions")]
1804    fn error_invalid_extra_unnamed_url() {
1805        assert_snapshot!(
1806            parse_unnamed_err("/foo-3.0.0-py3-none-any.whl[d,]"),
1807            @"
1808        Expected an alphanumeric character starting the extra name, found `]`
1809        /foo-3.0.0-py3-none-any.whl[d,]
1810                                      ^
1811        "
1812        );
1813    }
1814
1815    /// Check that the relative path support feature toggle works.
1816    #[test]
1817    #[cfg(feature = "non-pep508-extensions")]
1818    fn non_pep508_paths() {
1819        let requirements = &[
1820            "foo @ file://./foo",
1821            "foo @ file://foo-3.0.0-py3-none-any.whl",
1822            "foo @ file:foo-3.0.0-py3-none-any.whl",
1823            "foo @ ./foo-3.0.0-py3-none-any.whl",
1824        ];
1825        let cwd = env::current_dir().unwrap();
1826
1827        for requirement in requirements {
1828            assert_eq!(
1829                Requirement::<VerbatimUrl>::parse(requirement, &cwd).is_ok(),
1830                cfg!(feature = "non-pep508-extensions"),
1831                "{}: {:?}",
1832                requirement,
1833                Requirement::<VerbatimUrl>::parse(requirement, &cwd)
1834            );
1835        }
1836    }
1837
1838    #[test]
1839    fn no_space_after_operator() {
1840        let requirement = Requirement::<Url>::from_str("pytest;python_version<='4.0'").unwrap();
1841        assert_eq!(
1842            requirement.to_string(),
1843            "pytest ; python_full_version < '4.1'"
1844        );
1845
1846        let requirement = Requirement::<Url>::from_str("pytest;'4.0'>=python_version").unwrap();
1847        assert_eq!(
1848            requirement.to_string(),
1849            "pytest ; python_full_version < '4.1'"
1850        );
1851    }
1852
1853    #[test]
1854    #[cfg(feature = "non-pep508-extensions")]
1855    fn path_with_fragment() {
1856        let requirements = if cfg!(windows) {
1857            &[
1858                "wheel @ file:///C:/Users/ferris/wheel-0.42.0.whl#hash=somehash",
1859                "wheel @ C:/Users/ferris/wheel-0.42.0.whl#hash=somehash",
1860            ]
1861        } else {
1862            &[
1863                "wheel @ file:///Users/ferris/wheel-0.42.0.whl#hash=somehash",
1864                "wheel @ /Users/ferris/wheel-0.42.0.whl#hash=somehash",
1865            ]
1866        };
1867
1868        for requirement in requirements {
1869            // Extract the URL.
1870            let Some(VersionOrUrl::Url(url)) = Requirement::<VerbatimUrl>::from_str(requirement)
1871                .unwrap()
1872                .version_or_url
1873            else {
1874                unreachable!("Expected a URL")
1875            };
1876
1877            // Assert that the fragment and path have been separated correctly.
1878            assert_eq!(url.fragment(), Some("hash=somehash"));
1879            assert!(
1880                url.path().ends_with("/Users/ferris/wheel-0.42.0.whl"),
1881                "Expected the path to end with `/Users/ferris/wheel-0.42.0.whl`, found `{}`",
1882                url.path()
1883            );
1884        }
1885    }
1886
1887    #[test]
1888    fn add_extra_marker() -> Result<(), InvalidNameError> {
1889        let requirement = Requirement::<Url>::from_str("pytest").unwrap();
1890        let expected = Requirement::<Url>::from_str("pytest; extra == 'dotenv'").unwrap();
1891        let actual = requirement.with_extra_marker(&ExtraName::from_str("dotenv")?);
1892        assert_eq!(actual, expected);
1893
1894        let requirement = Requirement::<Url>::from_str("pytest; '4.0' >= python_version").unwrap();
1895        let expected =
1896            Requirement::from_str("pytest; '4.0' >= python_version and extra == 'dotenv'").unwrap();
1897        let actual = requirement.with_extra_marker(&ExtraName::from_str("dotenv")?);
1898        assert_eq!(actual, expected);
1899
1900        let requirement = Requirement::<Url>::from_str(
1901            "pytest; '4.0' >= python_version or sys_platform == 'win32'",
1902        )
1903        .unwrap();
1904        let expected = Requirement::from_str(
1905            "pytest; ('4.0' >= python_version or sys_platform == 'win32') and extra == 'dotenv'",
1906        )
1907        .unwrap();
1908        let actual = requirement.with_extra_marker(&ExtraName::from_str("dotenv")?);
1909        assert_eq!(actual, expected);
1910
1911        Ok(())
1912    }
1913}