Skip to main content

uv_requirements_txt/
lib.rs

1//! Parses a subset of requirement.txt syntax
2//!
3//! <https://pip.pypa.io/en/stable/reference/requirements-file-format/>
4//!
5//! Supported:
6//!  * [PEP 508 requirements](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
7//!  * `-r`
8//!  * `-c`
9//!  * `--hash` (postfix)
10//!  * `-e`
11//!
12//! Unsupported:
13//!  * `<path>`. TBD
14//!  * `<archive_url>`. TBD
15//!  * Options without a requirement, such as `--find-links` or `--index-url`
16//!
17//! Grammar as implemented:
18//!
19//! ```text
20//! file = (statement | empty ('#' any*)? '\n')*
21//! empty = whitespace*
22//! statement = constraint_include | requirements_include | editable_requirement | requirement
23//! constraint_include = '-c' ('=' | wrappable_whitespaces) filepath
24//! requirements_include = '-r' ('=' | wrappable_whitespaces) filepath
25//! editable_requirement = '-e' ('=' | wrappable_whitespaces) requirement
26//! # We check whether the line starts with a letter or a number, in that case we assume it's a
27//! # PEP 508 requirement
28//! # https://packaging.python.org/en/latest/specifications/name-normalization/#valid-non-normalized-names
29//! # This does not (yet?) support plain files or urls, we use a letter or a number as first
30//! # character to assume a PEP 508 requirement
31//! requirement = [a-zA-Z0-9] pep508_grammar_tail wrappable_whitespaces hashes
32//! hashes = ('--hash' ('=' | wrappable_whitespaces) [a-zA-Z0-9-_]+ ':' [a-zA-Z0-9-_] wrappable_whitespaces+)*
33//! # This should indicate a single backslash before a newline
34//! wrappable_whitespaces = whitespace ('\\\n' | whitespace)*
35//! ```
36
37use std::borrow::Cow;
38use std::fmt::{Display, Formatter};
39use std::io;
40use std::path::{Path, PathBuf};
41use std::str::FromStr;
42
43use rustc_hash::{FxHashMap, FxHashSet};
44use tracing::instrument;
45use unscanny::{Pattern, Scanner};
46use url::Url;
47
48#[cfg(feature = "http")]
49use uv_client::{BaseClient, ClientBuildError};
50use uv_client::{BaseClientBuilder, Connectivity};
51use uv_configuration::{NoBinary, NoBuild, PackageNameSpecifier};
52use uv_distribution_types::{
53    Requirement, UnresolvedRequirement, UnresolvedRequirementSpecification,
54};
55use uv_fs::{Simplified, normalize_path};
56use uv_pep508::{Pep508Error, RequirementOrigin, VerbatimUrl, expand_env_vars};
57use uv_pypi_types::VerbatimParsedUrl;
58#[cfg(feature = "http")]
59use uv_redacted::DisplaySafeUrl;
60use uv_redacted::DisplaySafeUrlError;
61
62use crate::requirement::EditableError;
63pub use crate::requirement::RequirementsTxtRequirement;
64use crate::shquote::unquote;
65
66mod requirement;
67mod shquote;
68
69/// A cache of file contents, keyed by path, to avoid re-reading files from disk.
70pub type SourceCache = FxHashMap<PathBuf, String>;
71
72/// We emit one of those for each `requirements.txt` entry.
73enum RequirementsTxtStatement {
74    /// `-r` inclusion filename
75    Requirements {
76        filename: String,
77        start: usize,
78        end: usize,
79    },
80    /// `-c` inclusion filename
81    Constraint {
82        filename: String,
83        start: usize,
84        end: usize,
85    },
86    /// PEP 508 requirement plus metadata
87    RequirementEntry(RequirementEntry),
88    /// `-e`
89    EditableRequirementEntry(RequirementEntry),
90    /// `--index-url`
91    IndexUrl(VerbatimUrl),
92    /// `--extra-index-url`
93    ExtraIndexUrl(VerbatimUrl),
94    /// `--find-links`
95    FindLinks(VerbatimUrl),
96    /// `--no-index`
97    NoIndex,
98    /// `--no-binary`
99    NoBinary(NoBinary),
100    /// `--only-binary`
101    OnlyBinary(NoBuild),
102    /// An unsupported option (e.g., `--trusted-host`).
103    UnsupportedOption(UnsupportedOption),
104}
105
106/// A [Requirement] with additional metadata from the `requirements.txt`, currently only hashes but in
107/// the future also editable and similar information.
108#[derive(Debug, Clone, Eq, PartialEq, Hash)]
109pub struct RequirementEntry {
110    /// The actual PEP 508 requirement.
111    pub requirement: RequirementsTxtRequirement,
112    /// Hashes of the downloadable packages.
113    pub hashes: Vec<String>,
114}
115
116// We place the impl here instead of next to `UnresolvedRequirementSpecification` because
117// `UnresolvedRequirementSpecification` is defined in `distribution-types` and `requirements-txt`
118// depends on `distribution-types`.
119impl From<RequirementEntry> for UnresolvedRequirementSpecification {
120    fn from(value: RequirementEntry) -> Self {
121        Self {
122            requirement: match value.requirement {
123                RequirementsTxtRequirement::Named(named) => {
124                    UnresolvedRequirement::Named(Requirement::from(named))
125                }
126                RequirementsTxtRequirement::Unnamed(unnamed) => {
127                    UnresolvedRequirement::Unnamed(unnamed)
128                }
129            },
130            hashes: value.hashes,
131        }
132    }
133}
134
135impl From<RequirementsTxtRequirement> for UnresolvedRequirementSpecification {
136    fn from(value: RequirementsTxtRequirement) -> Self {
137        Self::from(RequirementEntry {
138            requirement: value,
139            hashes: vec![],
140        })
141    }
142}
143
144/// Parsed and flattened requirements.txt with requirements and constraints
145#[derive(Debug, Default, Clone, PartialEq, Eq)]
146pub struct RequirementsTxt {
147    /// The actual requirements with the hashes.
148    pub requirements: Vec<RequirementEntry>,
149    /// Constraints included with `-c`.
150    pub constraints: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
151    /// Editables with `-e`.
152    pub editables: Vec<RequirementEntry>,
153    /// The index URL, specified with `--index-url`.
154    pub index_url: Option<VerbatimUrl>,
155    /// The extra index URLs, specified with `--extra-index-url`.
156    pub extra_index_urls: Vec<VerbatimUrl>,
157    /// The find links locations, specified with `--find-links`.
158    pub find_links: Vec<VerbatimUrl>,
159    /// Whether to ignore the index, specified with `--no-index`.
160    pub no_index: bool,
161    /// Whether to disallow wheels, specified with `--no-binary`.
162    pub no_binary: NoBinary,
163    /// Whether to allow only wheels, specified with `--only-binary`.
164    pub only_binary: NoBuild,
165}
166
167impl RequirementsTxt {
168    /// See module level documentation.
169    #[instrument(
170        skip_all,
171        fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
172    )]
173    pub async fn parse(
174        requirements_txt: impl AsRef<Path>,
175        working_dir: impl AsRef<Path>,
176    ) -> Result<Self, RequirementsTxtFileError> {
177        Self::parse_with_cache(
178            requirements_txt,
179            working_dir,
180            &BaseClientBuilder::default().connectivity(Connectivity::Offline),
181            &mut SourceCache::default(),
182        )
183        .await
184    }
185
186    /// Parse a `requirements.txt` file, using the given cache to avoid re-reading files from disk.
187    #[instrument(
188        skip_all,
189        fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
190    )]
191    pub async fn parse_with_cache(
192        requirements_txt: impl AsRef<Path>,
193        working_dir: impl AsRef<Path>,
194        client_builder: &BaseClientBuilder<'_>,
195        cache: &mut SourceCache,
196    ) -> Result<Self, RequirementsTxtFileError> {
197        let mut visited = VisitedFiles::Requirements {
198            requirements: &mut FxHashSet::default(),
199            constraints: &mut FxHashSet::default(),
200        };
201        Self::parse_impl(
202            requirements_txt,
203            working_dir,
204            client_builder,
205            &mut visited,
206            cache,
207        )
208        .await
209    }
210
211    /// Parse requirements from a string, using the given path for error messages and resolving
212    /// relative paths.
213    pub async fn parse_str(
214        content: &str,
215        requirements_txt: impl AsRef<Path>,
216        working_dir: impl AsRef<Path>,
217        client_builder: &BaseClientBuilder<'_>,
218        source_contents: &mut SourceCache,
219    ) -> Result<Self, RequirementsTxtFileError> {
220        let requirements_txt = requirements_txt.as_ref();
221        let working_dir = working_dir.as_ref();
222        let requirements_dir = requirements_txt.parent().unwrap_or(working_dir);
223
224        let mut visited = VisitedFiles::Requirements {
225            requirements: &mut FxHashSet::default(),
226            constraints: &mut FxHashSet::default(),
227        };
228
229        Self::parse_inner(
230            content,
231            working_dir,
232            requirements_dir,
233            client_builder,
234            requirements_txt,
235            &mut visited,
236            source_contents,
237        )
238        .await
239        .map_err(|err| RequirementsTxtFileError {
240            file: requirements_txt.to_path_buf(),
241            error: err,
242        })
243    }
244
245    /// See module level documentation
246    #[instrument(
247        skip_all,
248        fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
249    )]
250    async fn parse_impl(
251        requirements_txt: impl AsRef<Path>,
252        working_dir: impl AsRef<Path>,
253        client_builder: &BaseClientBuilder<'_>,
254        visited: &mut VisitedFiles<'_>,
255        cache: &mut SourceCache,
256    ) -> Result<Self, RequirementsTxtFileError> {
257        let requirements_txt = requirements_txt.as_ref();
258        let working_dir = working_dir.as_ref();
259
260        let content = if let Some(content) = cache.get(requirements_txt) {
261            // Use cached content if available.
262            content.clone()
263        } else if requirements_txt.starts_with("http://") | requirements_txt.starts_with("https://")
264        {
265            #[cfg(not(feature = "http"))]
266            {
267                return Err(RequirementsTxtFileError {
268                    file: requirements_txt.to_path_buf(),
269                    error: RequirementsTxtParserError::Io(io::Error::new(
270                        io::ErrorKind::InvalidInput,
271                        "Remote file not supported without `http` feature",
272                    )),
273                });
274            }
275
276            #[cfg(feature = "http")]
277            {
278                let url = requirements_txt.display().to_string();
279                let url = DisplaySafeUrl::parse(&url).map_err(|err| RequirementsTxtFileError {
280                    file: requirements_txt.to_path_buf(),
281                    error: RequirementsTxtParserError::InvalidUrl(
282                        requirements_txt.display().to_string(),
283                        err,
284                    ),
285                })?;
286
287                // Avoid constructing a client if network is disabled already
288                if client_builder.is_offline() {
289                    return Err(RequirementsTxtFileError {
290                        file: requirements_txt.to_path_buf(),
291                        error: RequirementsTxtParserError::Io(io::Error::new(
292                            io::ErrorKind::InvalidInput,
293                            format!(
294                                "Network connectivity is disabled, but a remote requirements file was requested: {url}"
295                            ),
296                        )),
297                    });
298                }
299                let client = client_builder
300                    .build()
301                    .map_err(|err| RequirementsTxtFileError {
302                        file: requirements_txt.to_path_buf(),
303                        error: RequirementsTxtParserError::ClientBuild(url.clone(), Box::new(err)),
304                    })?;
305                let content = read_url_to_string(&requirements_txt, client)
306                    .await
307                    .map_err(|err| RequirementsTxtFileError {
308                        file: requirements_txt.to_path_buf(),
309                        error: err,
310                    })?;
311                cache.insert(requirements_txt.to_path_buf(), content.clone());
312                content
313            }
314        } else {
315            // Ex) `file:///home/ferris/project/requirements.txt`
316            let content = uv_fs::read_to_string_transcode(&requirements_txt)
317                .await
318                .map_err(|err| RequirementsTxtFileError {
319                    file: requirements_txt.to_path_buf(),
320                    error: RequirementsTxtParserError::Io(err),
321                })?;
322            cache.insert(requirements_txt.to_path_buf(), content.clone());
323            content
324        };
325
326        let requirements_dir = requirements_txt.parent().unwrap_or(working_dir);
327        let data = Self::parse_inner(
328            &content,
329            working_dir,
330            requirements_dir,
331            client_builder,
332            requirements_txt,
333            visited,
334            cache,
335        )
336        .await
337        .map_err(|err| RequirementsTxtFileError {
338            file: requirements_txt.to_path_buf(),
339            error: err,
340        })?;
341
342        Ok(data)
343    }
344
345    /// See module level documentation.
346    ///
347    /// When parsing, relative paths to requirements (e.g., `-e ../editable/`) are resolved against
348    /// the current working directory. However, relative paths to sub-files (e.g., `-r ../requirements.txt`)
349    /// are resolved against the directory of the containing `requirements.txt` file, to match
350    /// `pip`'s behavior.
351    async fn parse_inner(
352        content: &str,
353        working_dir: &Path,
354        requirements_dir: &Path,
355        client_builder: &BaseClientBuilder<'_>,
356        requirements_txt: &Path,
357        visited: &mut VisitedFiles<'_>,
358        cache: &mut SourceCache,
359    ) -> Result<Self, RequirementsTxtParserError> {
360        let mut s = Scanner::new(content);
361
362        let mut data = Self::default();
363        while let Some(statement) = parse_entry(&mut s, content, working_dir, requirements_txt)? {
364            match statement {
365                RequirementsTxtStatement::Requirements {
366                    filename,
367                    start,
368                    end,
369                } => {
370                    let filename = expand_env_vars(&filename);
371                    let sub_file =
372                        if filename.starts_with("http://") || filename.starts_with("https://") {
373                            PathBuf::from(filename.as_ref())
374                        } else if filename.starts_with("file://") {
375                            requirements_txt.join(
376                                Url::parse(filename.as_ref())
377                                    .map_err(|err| RequirementsTxtParserError::Url {
378                                        source: DisplaySafeUrlError::Url(err).into(),
379                                        url: filename.to_string(),
380                                        start,
381                                        end,
382                                    })?
383                                    .to_file_path()
384                                    .map_err(|()| RequirementsTxtParserError::FileUrl {
385                                        url: filename.to_string(),
386                                        start,
387                                        end,
388                                    })?,
389                            )
390                        } else {
391                            requirements_dir.join(filename.as_ref())
392                        };
393                    match visited {
394                        VisitedFiles::Requirements { requirements, .. } => {
395                            if !requirements.insert(visited_file(&sub_file)) {
396                                continue;
397                            }
398                        }
399                        // Treat any nested requirements or constraints as constraints. This differs
400                        // from `pip`, which seems to treat `-r` requirements in constraints files as
401                        // _requirements_, but we don't want to support that.
402                        VisitedFiles::Constraints { constraints } => {
403                            if !constraints.insert(visited_file(&sub_file)) {
404                                continue;
405                            }
406                        }
407                    }
408                    let sub_requirements = Box::pin(Self::parse_impl(
409                        &sub_file,
410                        working_dir,
411                        client_builder,
412                        visited,
413                        cache,
414                    ))
415                    .await
416                    .map_err(|err| RequirementsTxtParserError::Subfile {
417                        source: Box::new(err),
418                        start,
419                        end,
420                    })?;
421
422                    // Disallow conflicting `--index-url` in nested `requirements` files.
423                    if sub_requirements.index_url.is_some()
424                        && data.index_url.is_some()
425                        && sub_requirements.index_url != data.index_url
426                    {
427                        let (line, column) = calculate_row_column(content, s.cursor());
428                        return Err(RequirementsTxtParserError::Parser {
429                            message:
430                                "Nested `requirements` file contains conflicting `--index-url`"
431                                    .to_string(),
432                            line,
433                            column,
434                        });
435                    }
436
437                    // Add each to the correct category.
438                    data.update_from(sub_requirements);
439                }
440                RequirementsTxtStatement::Constraint {
441                    filename,
442                    start,
443                    end,
444                } => {
445                    let filename = expand_env_vars(&filename);
446                    let sub_file =
447                        if filename.starts_with("http://") || filename.starts_with("https://") {
448                            PathBuf::from(filename.as_ref())
449                        } else if filename.starts_with("file://") {
450                            requirements_txt.join(
451                                Url::parse(filename.as_ref())
452                                    .map_err(|err| RequirementsTxtParserError::Url {
453                                        source: DisplaySafeUrlError::Url(err).into(),
454                                        url: filename.to_string(),
455                                        start,
456                                        end,
457                                    })?
458                                    .to_file_path()
459                                    .map_err(|()| RequirementsTxtParserError::FileUrl {
460                                        url: filename.to_string(),
461                                        start,
462                                        end,
463                                    })?,
464                            )
465                        } else {
466                            requirements_dir.join(filename.as_ref())
467                        };
468
469                    // Switch to constraints mode, if we aren't in it already.
470                    let mut visited = match visited {
471                        VisitedFiles::Requirements { constraints, .. } => {
472                            if !constraints.insert(visited_file(&sub_file)) {
473                                continue;
474                            }
475                            VisitedFiles::Constraints { constraints }
476                        }
477                        VisitedFiles::Constraints { constraints } => {
478                            if !constraints.insert(visited_file(&sub_file)) {
479                                continue;
480                            }
481                            VisitedFiles::Constraints { constraints }
482                        }
483                    };
484
485                    let sub_constraints = Box::pin(Self::parse_impl(
486                        &sub_file,
487                        working_dir,
488                        client_builder,
489                        &mut visited,
490                        cache,
491                    ))
492                    .await
493                    .map_err(|err| RequirementsTxtParserError::Subfile {
494                        source: Box::new(err),
495                        start,
496                        end,
497                    })?;
498
499                    // Treat any nested requirements or constraints as constraints. This differs
500                    // from `pip`, which seems to treat `-r` requirements in constraints files as
501                    // _requirements_, but we don't want to support that.
502                    for entry in sub_constraints.requirements {
503                        match entry.requirement {
504                            RequirementsTxtRequirement::Named(requirement) => {
505                                data.constraints.push(requirement);
506                            }
507                            RequirementsTxtRequirement::Unnamed(_) => {
508                                return Err(RequirementsTxtParserError::UnnamedConstraint {
509                                    start,
510                                    end,
511                                });
512                            }
513                        }
514                    }
515                    for constraint in sub_constraints.constraints {
516                        data.constraints.push(constraint);
517                    }
518                }
519                RequirementsTxtStatement::RequirementEntry(requirement_entry) => {
520                    data.requirements.push(requirement_entry);
521                }
522                RequirementsTxtStatement::EditableRequirementEntry(editable) => {
523                    data.editables.push(editable);
524                }
525                RequirementsTxtStatement::IndexUrl(url) => {
526                    if data.index_url.is_some() {
527                        let (line, column) = calculate_row_column(content, s.cursor());
528                        return Err(RequirementsTxtParserError::Parser {
529                            message: "Multiple `--index-url` values provided".to_string(),
530                            line,
531                            column,
532                        });
533                    }
534                    data.index_url = Some(url);
535                }
536                RequirementsTxtStatement::ExtraIndexUrl(url) => {
537                    data.extra_index_urls.push(url);
538                }
539                RequirementsTxtStatement::FindLinks(url) => {
540                    data.find_links.push(url);
541                }
542                RequirementsTxtStatement::NoIndex => {
543                    data.no_index = true;
544                }
545                RequirementsTxtStatement::NoBinary(no_binary) => {
546                    data.no_binary.extend(no_binary);
547                }
548                RequirementsTxtStatement::OnlyBinary(only_binary) => {
549                    data.only_binary.extend(only_binary);
550                }
551                RequirementsTxtStatement::UnsupportedOption(flag) => {
552                    if requirements_txt == Path::new("-") {
553                        if flag.cli() {
554                            uv_warnings::warn_user!(
555                                "Ignoring unsupported option from stdin: `{flag}` (hint: pass `{flag}` on the command line instead)",
556                                flag = flag.green()
557                            );
558                        } else {
559                            uv_warnings::warn_user!(
560                                "Ignoring unsupported option from stdin: `{flag}`",
561                                flag = flag.green()
562                            );
563                        }
564                    } else {
565                        if flag.cli() {
566                            uv_warnings::warn_user!(
567                                "Ignoring unsupported option in `{path}`: `{flag}` (hint: pass `{flag}` on the command line instead)",
568                                path = requirements_txt.user_display().cyan(),
569                                flag = flag.green()
570                            );
571                        } else {
572                            uv_warnings::warn_user!(
573                                "Ignoring unsupported option in `{path}`: `{flag}`",
574                                path = requirements_txt.user_display().cyan(),
575                                flag = flag.green()
576                            );
577                        }
578                    }
579                }
580            }
581        }
582        Ok(data)
583    }
584
585    /// Merge the data from a nested `requirements` file (`other`) into this one.
586    fn update_from(&mut self, other: Self) {
587        let Self {
588            requirements,
589            constraints,
590            editables,
591            index_url,
592            extra_index_urls,
593            find_links,
594            no_index,
595            no_binary,
596            only_binary,
597        } = other;
598        self.requirements.extend(requirements);
599        self.constraints.extend(constraints);
600        self.editables.extend(editables);
601        if self.index_url.is_none() {
602            self.index_url = index_url;
603        }
604        self.extra_index_urls.extend(extra_index_urls);
605        self.find_links.extend(find_links);
606        self.no_index = self.no_index || no_index;
607        self.no_binary.extend(no_binary);
608        self.only_binary.extend(only_binary);
609    }
610}
611
612/// An unsupported option (e.g., `--trusted-host`).
613///
614/// See: <https://pip.pypa.io/en/stable/reference/requirements-file-format/#global-options>
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616enum UnsupportedOption {
617    PreferBinary,
618    RequireHashes,
619    Pre,
620    TrustedHost,
621    UseFeature,
622}
623
624impl UnsupportedOption {
625    /// The name of the unsupported option.
626    fn name(self) -> &'static str {
627        match self {
628            Self::PreferBinary => "--prefer-binary",
629            Self::RequireHashes => "--require-hashes",
630            Self::Pre => "--pre",
631            Self::TrustedHost => "--trusted-host",
632            Self::UseFeature => "--use-feature",
633        }
634    }
635
636    /// Returns `true` if the option is supported on the CLI.
637    fn cli(self) -> bool {
638        match self {
639            Self::PreferBinary => false,
640            Self::RequireHashes => true,
641            Self::Pre => true,
642            Self::TrustedHost => true,
643            Self::UseFeature => false,
644        }
645    }
646
647    /// Returns an iterator over all unsupported options.
648    fn iter() -> impl Iterator<Item = Self> {
649        [
650            Self::PreferBinary,
651            Self::RequireHashes,
652            Self::Pre,
653            Self::TrustedHost,
654            Self::UseFeature,
655        ]
656        .iter()
657        .copied()
658    }
659}
660
661impl Display for UnsupportedOption {
662    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
663        write!(f, "{}", self.name())
664    }
665}
666
667/// Returns `true` if the character is a newline or a comment character.
668const fn is_terminal(c: char) -> bool {
669    matches!(c, '\n' | '\r' | '#')
670}
671
672/// Parse a single entry, that is a requirement, an inclusion or a comment line.
673///
674/// Consumes all preceding trivia (whitespace and comments). If it returns `None`, we've reached
675/// the end of file.
676fn parse_entry(
677    s: &mut Scanner,
678    content: &str,
679    working_dir: &Path,
680    requirements_txt: &Path,
681) -> Result<Option<RequirementsTxtStatement>, RequirementsTxtParserError> {
682    // Eat all preceding whitespace, this may run us to the end of file
683    eat_wrappable_whitespace(s);
684    while s.at(['\n', '\r', '#']) {
685        // skip comments
686        eat_trailing_line(content, s)?;
687        eat_wrappable_whitespace(s);
688    }
689
690    let start = s.cursor();
691    Ok(Some(if s.eat_if("-r") || s.eat_if("--requirement") {
692        let filename = parse_value("--requirement", content, s, |c: char| !is_terminal(c))?;
693        let filename = unquote(filename)
694            .ok()
695            .flatten()
696            .unwrap_or_else(|| filename.to_string());
697        let end = s.cursor();
698        RequirementsTxtStatement::Requirements {
699            filename,
700            start,
701            end,
702        }
703    } else if s.eat_if("-c") || s.eat_if("--constraint") {
704        let filename = parse_value("--constraint", content, s, |c: char| !is_terminal(c))?;
705        let filename = unquote(filename)
706            .ok()
707            .flatten()
708            .unwrap_or_else(|| filename.to_string());
709        let end = s.cursor();
710        RequirementsTxtStatement::Constraint {
711            filename,
712            start,
713            end,
714        }
715    } else if s.eat_if("-e") || s.eat_if("--editable") {
716        if s.eat_if('=') {
717            // Explicit equals sign.
718        } else if s.eat_if(char::is_whitespace) {
719            // Key and value are separated by whitespace instead.
720            s.eat_whitespace();
721        } else {
722            let (line, column) = calculate_row_column(content, s.cursor());
723            return Err(RequirementsTxtParserError::Parser {
724                message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
725                line,
726                column,
727            });
728        }
729
730        let source = if requirements_txt == Path::new("-") {
731            None
732        } else {
733            Some(requirements_txt)
734        };
735
736        let (requirement, hashes) =
737            parse_requirement_and_hashes(s, content, source, working_dir, true)?;
738        let requirement =
739            requirement
740                .into_editable()
741                .map_err(|err| RequirementsTxtParserError::NonEditable {
742                    source: err,
743                    start,
744                    end: s.cursor(),
745                })?;
746        RequirementsTxtStatement::EditableRequirementEntry(RequirementEntry {
747            requirement,
748            hashes,
749        })
750    } else if s.eat_if("-i") || s.eat_if("--index-url") {
751        let given = parse_value("--index-url", content, s, |c: char| !is_terminal(c))?;
752        let given = unquote(given)
753            .ok()
754            .flatten()
755            .map(Cow::Owned)
756            .unwrap_or(Cow::Borrowed(given));
757        let expanded = expand_env_vars(given.as_ref());
758        let url = if let Some(path) = std::path::absolute(expanded.as_ref())
759            .ok()
760            .filter(|path| path.exists())
761        {
762            VerbatimUrl::from_absolute_path(path).map_err(|err| {
763                RequirementsTxtParserError::VerbatimUrl {
764                    source: err,
765                    url: given.to_string(),
766                    start,
767                    end: s.cursor(),
768                }
769            })?
770        } else {
771            VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
772                RequirementsTxtParserError::Url {
773                    source: err,
774                    url: given.to_string(),
775                    start,
776                    end: s.cursor(),
777                }
778            })?
779        };
780        RequirementsTxtStatement::IndexUrl(url.with_given(given))
781    } else if s.eat_if("--extra-index-url") {
782        let given = parse_value("--extra-index-url", content, s, |c: char| !is_terminal(c))?;
783        let given = unquote(given)
784            .ok()
785            .flatten()
786            .map(Cow::Owned)
787            .unwrap_or(Cow::Borrowed(given));
788        let expanded = expand_env_vars(given.as_ref());
789        let url = if let Some(path) = std::path::absolute(expanded.as_ref())
790            .ok()
791            .filter(|path| path.exists())
792        {
793            VerbatimUrl::from_absolute_path(path).map_err(|err| {
794                RequirementsTxtParserError::VerbatimUrl {
795                    source: err,
796                    url: given.to_string(),
797                    start,
798                    end: s.cursor(),
799                }
800            })?
801        } else {
802            VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
803                RequirementsTxtParserError::Url {
804                    source: err,
805                    url: given.to_string(),
806                    start,
807                    end: s.cursor(),
808                }
809            })?
810        };
811        RequirementsTxtStatement::ExtraIndexUrl(url.with_given(given))
812    } else if s.eat_if("--no-index") {
813        RequirementsTxtStatement::NoIndex
814    } else if s.eat_if("--find-links") || s.eat_if("-f") {
815        let given = parse_value("--find-links", content, s, |c: char| !is_terminal(c))?;
816        let given = unquote(given)
817            .ok()
818            .flatten()
819            .map(Cow::Owned)
820            .unwrap_or(Cow::Borrowed(given));
821        let expanded = expand_env_vars(given.as_ref());
822        let url = if let Some(path) = std::path::absolute(expanded.as_ref())
823            .ok()
824            .filter(|path| path.exists())
825        {
826            VerbatimUrl::from_absolute_path(path).map_err(|err| {
827                RequirementsTxtParserError::VerbatimUrl {
828                    source: err,
829                    url: given.to_string(),
830                    start,
831                    end: s.cursor(),
832                }
833            })?
834        } else {
835            VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
836                RequirementsTxtParserError::Url {
837                    source: err,
838                    url: given.to_string(),
839                    start,
840                    end: s.cursor(),
841                }
842            })?
843        };
844        RequirementsTxtStatement::FindLinks(url.with_given(given))
845    } else if s.eat_if("--no-binary") {
846        let given = parse_value("--no-binary", content, s, |c: char| !is_terminal(c))?;
847        let given = unquote(given)
848            .ok()
849            .flatten()
850            .map(Cow::Owned)
851            .unwrap_or(Cow::Borrowed(given));
852        let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
853            RequirementsTxtParserError::NoBinary {
854                source: err,
855                specifier: given.to_string(),
856                start,
857                end: s.cursor(),
858            }
859        })?;
860        RequirementsTxtStatement::NoBinary(NoBinary::from_pip_arg(specifier))
861    } else if s.eat_if("--only-binary") {
862        let given = parse_value("--only-binary", content, s, |c: char| !is_terminal(c))?;
863        let given = unquote(given)
864            .ok()
865            .flatten()
866            .map(Cow::Owned)
867            .unwrap_or(Cow::Borrowed(given));
868        let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
869            RequirementsTxtParserError::NoBinary {
870                source: err,
871                specifier: given.to_string(),
872                start,
873                end: s.cursor(),
874            }
875        })?;
876        RequirementsTxtStatement::OnlyBinary(NoBuild::from_pip_arg(specifier))
877    } else if s.at(char::is_ascii_alphanumeric) || s.at(|char| matches!(char, '.' | '/' | '$')) {
878        let source = if requirements_txt == Path::new("-") {
879            None
880        } else {
881            Some(requirements_txt)
882        };
883
884        let (requirement, hashes) =
885            parse_requirement_and_hashes(s, content, source, working_dir, false)?;
886        RequirementsTxtStatement::RequirementEntry(RequirementEntry {
887            requirement,
888            hashes,
889        })
890    } else if let Some(char) = s.peek() {
891        // Identify an unsupported option, like `--trusted-host`.
892        if let Some(option) = UnsupportedOption::iter().find(|option| s.eat_if(option.name())) {
893            s.eat_while(|c: char| !is_terminal(c));
894            RequirementsTxtStatement::UnsupportedOption(option)
895        } else {
896            let (line, column) = calculate_row_column(content, s.cursor());
897            return Err(RequirementsTxtParserError::Parser {
898                message: format!(
899                    "Unexpected '{char}', expected '-c', '-e', '-r' or the start of a requirement"
900                ),
901                line,
902                column,
903            });
904        }
905    } else {
906        // EOF
907        return Ok(None);
908    }))
909}
910
911/// Eat whitespace and ignore newlines escaped with a backslash
912fn eat_wrappable_whitespace<'a>(s: &mut Scanner<'a>) -> &'a str {
913    let start = s.cursor();
914    s.eat_while([' ', '\t']);
915    // Allow multiple escaped line breaks
916    // With the order we support `\n`, `\r`, `\r\n` without accidentally eating a `\n\r`
917    while s.eat_if("\\\n") || s.eat_if("\\\r\n") || s.eat_if("\\\r") {
918        s.eat_while([' ', '\t']);
919    }
920    s.from(start)
921}
922
923/// Eats the end of line or a potential trailing comma
924fn eat_trailing_line(content: &str, s: &mut Scanner) -> Result<(), RequirementsTxtParserError> {
925    s.eat_while([' ', '\t']);
926    match s.eat() {
927        None | Some('\n') => {} // End of file or end of line, nothing to do
928        Some('\r') => {
929            s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
930        }
931        Some('#') => {
932            s.eat_until(['\r', '\n']);
933            if s.at('\r') {
934                s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
935            }
936        }
937        Some(other) => {
938            let (line, column) = calculate_row_column(content, s.cursor());
939            return Err(RequirementsTxtParserError::Parser {
940                message: format!("Expected comment or end-of-line, found `{other}`"),
941                line,
942                column,
943            });
944        }
945    }
946    Ok(())
947}
948
949/// Parse a PEP 508 requirement with optional trailing hashes
950fn parse_requirement_and_hashes(
951    s: &mut Scanner,
952    content: &str,
953    source: Option<&Path>,
954    working_dir: &Path,
955    editable: bool,
956) -> Result<(RequirementsTxtRequirement, Vec<String>), RequirementsTxtParserError> {
957    // PEP 508 requirement
958    let start = s.cursor();
959    // Termination: s.eat() eventually becomes None
960    let (end, has_hashes) = loop {
961        let end = s.cursor();
962
963        //  We look for the end of the line ...
964        if s.eat_if('\n') {
965            break (end, false);
966        }
967        if s.eat_if('\r') {
968            s.eat_if('\n'); // Support `\r\n` but also accept stray `\r`
969            break (end, false);
970        }
971        // ... or `--hash`, an escaped newline or a comment separated by whitespace ...
972        if !eat_wrappable_whitespace(s).is_empty() {
973            if s.after().starts_with("--") {
974                break (end, true);
975            } else if s.eat_if('#') {
976                s.eat_until(['\r', '\n']);
977                if s.at('\r') {
978                    s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
979                }
980                break (end, false);
981            }
982            continue;
983        }
984        // ... or the end of the file, which works like the end of line
985        if s.eat().is_none() {
986            break (end, false);
987        }
988    };
989
990    let requirement = &content[start..end];
991
992    // If the requirement looks like a `requirements.txt` file (with a missing `-r`), raise an
993    // error.
994    //
995    // While `requirements.txt` is a valid package name (per the spec), PyPI disallows
996    // `requirements.txt` and some other variants anyway.
997    #[expect(clippy::case_sensitive_file_extension_comparisons)]
998    if requirement.ends_with(".txt") || requirement.ends_with(".in") {
999        let path = Path::new(requirement);
1000        let path = if path.is_absolute() {
1001            Cow::Borrowed(path)
1002        } else {
1003            Cow::Owned(working_dir.join(path))
1004        };
1005        if path.is_file() {
1006            return Err(RequirementsTxtParserError::MissingRequirementPrefix(
1007                requirement.to_string(),
1008            ));
1009        }
1010    }
1011
1012    let requirement = RequirementsTxtRequirement::parse(requirement, working_dir, editable)
1013        .map(|requirement| {
1014            if let Some(source) = source {
1015                requirement.with_origin(RequirementOrigin::File(source.to_path_buf()))
1016            } else {
1017                requirement
1018            }
1019        })
1020        .map_err(|err| RequirementsTxtParserError::Pep508 {
1021            source: err,
1022            start,
1023            end,
1024        })?;
1025
1026    let hashes = if has_hashes {
1027        parse_hashes(content, s)?
1028    } else {
1029        Vec::new()
1030    };
1031    Ok((requirement, hashes))
1032}
1033
1034/// Parse `--hash=... --hash ...` after a requirement
1035fn parse_hashes(content: &str, s: &mut Scanner) -> Result<Vec<String>, RequirementsTxtParserError> {
1036    let mut hashes = Vec::new();
1037    if !s.eat_if("--hash") {
1038        let (line, column) = calculate_row_column(content, s.cursor());
1039        return Err(RequirementsTxtParserError::Parser {
1040            message: format!(
1041                "Expected `--hash`, found `{:?}`",
1042                s.eat_while(|c: char| !c.is_whitespace())
1043            ),
1044            line,
1045            column,
1046        });
1047    }
1048    let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
1049    hashes.push(hash.to_string());
1050    loop {
1051        eat_wrappable_whitespace(s);
1052        if !s.eat_if("--hash") {
1053            break;
1054        }
1055        let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
1056        hashes.push(hash.to_string());
1057    }
1058    Ok(hashes)
1059}
1060
1061/// In `-<key>=<value>` or `-<key> value`, this parses the part after the key
1062fn parse_value<'a, T>(
1063    option: &str,
1064    content: &str,
1065    s: &mut Scanner<'a>,
1066    while_pattern: impl Pattern<T>,
1067) -> Result<&'a str, RequirementsTxtParserError> {
1068    let value = if s.eat_if('=') {
1069        // Explicit equals sign.
1070        s.eat_while(while_pattern).trim_end()
1071    } else if s.eat_if(char::is_whitespace) {
1072        // Key and value are separated by whitespace instead.
1073        s.eat_whitespace();
1074        s.eat_while(while_pattern).trim_end()
1075    } else {
1076        let (line, column) = calculate_row_column(content, s.cursor());
1077        return Err(RequirementsTxtParserError::Parser {
1078            message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
1079            line,
1080            column,
1081        });
1082    };
1083
1084    if value.is_empty() {
1085        let (line, column) = calculate_row_column(content, s.cursor());
1086        return Err(RequirementsTxtParserError::Parser {
1087            message: format!("`{option}` must be followed by an argument"),
1088            line,
1089            column,
1090        });
1091    }
1092
1093    Ok(value)
1094}
1095
1096/// Fetch the contents of a URL and return them as a string.
1097#[cfg(feature = "http")]
1098async fn read_url_to_string(
1099    path: impl AsRef<Path>,
1100    client: BaseClient,
1101) -> Result<String, RequirementsTxtParserError> {
1102    // pip would URL-encode the non-UTF-8 bytes of the string; we just don't support them.
1103    let path_utf8 =
1104        path.as_ref()
1105            .to_str()
1106            .ok_or_else(|| RequirementsTxtParserError::NonUnicodeUrl {
1107                url: path.as_ref().to_owned(),
1108            })?;
1109
1110    let url = DisplaySafeUrl::from_str(path_utf8)
1111        .map_err(|err| RequirementsTxtParserError::InvalidUrl(path_utf8.to_string(), err))?;
1112    let response = client
1113        .for_host(&url)
1114        .get(Url::from(url.clone()))
1115        .send()
1116        .await
1117        .map_err(|err| RequirementsTxtParserError::from_reqwest_middleware(url.clone(), err))?;
1118    let text = response
1119        .error_for_status()
1120        .map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?
1121        .text()
1122        .await
1123        .map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?;
1124    Ok(text)
1125}
1126
1127/// Error parsing requirements.txt, wrapper with filename
1128#[derive(Debug)]
1129pub struct RequirementsTxtFileError {
1130    file: PathBuf,
1131    error: RequirementsTxtParserError,
1132}
1133
1134/// Error parsing requirements.txt, error disambiguation
1135#[derive(Debug)]
1136pub enum RequirementsTxtParserError {
1137    Io(io::Error),
1138    Url {
1139        source: uv_pep508::VerbatimUrlError,
1140        url: String,
1141        start: usize,
1142        end: usize,
1143    },
1144    FileUrl {
1145        url: String,
1146        start: usize,
1147        end: usize,
1148    },
1149    VerbatimUrl {
1150        source: uv_pep508::VerbatimUrlError,
1151        url: String,
1152        start: usize,
1153        end: usize,
1154    },
1155    UrlConversion(String),
1156    UnsupportedUrl(String),
1157    MissingRequirementPrefix(String),
1158    NonEditable {
1159        source: EditableError,
1160        start: usize,
1161        end: usize,
1162    },
1163    NoBinary {
1164        source: uv_normalize::InvalidNameError,
1165        specifier: String,
1166        start: usize,
1167        end: usize,
1168    },
1169    OnlyBinary {
1170        source: uv_normalize::InvalidNameError,
1171        specifier: String,
1172        start: usize,
1173        end: usize,
1174    },
1175    UnnamedConstraint {
1176        start: usize,
1177        end: usize,
1178    },
1179    Parser {
1180        message: String,
1181        line: usize,
1182        column: usize,
1183    },
1184    UnsupportedRequirement {
1185        source: Box<Pep508Error<VerbatimParsedUrl>>,
1186        start: usize,
1187        end: usize,
1188    },
1189    Pep508 {
1190        source: Box<Pep508Error<VerbatimParsedUrl>>,
1191        start: usize,
1192        end: usize,
1193    },
1194    ParsedUrl {
1195        source: Box<Pep508Error<VerbatimParsedUrl>>,
1196        start: usize,
1197        end: usize,
1198    },
1199    Subfile {
1200        source: Box<RequirementsTxtFileError>,
1201        start: usize,
1202        end: usize,
1203    },
1204    NonUnicodeUrl {
1205        url: PathBuf,
1206    },
1207    #[cfg(feature = "http")]
1208    Reqwest(DisplaySafeUrl, reqwest_middleware::Error),
1209    #[cfg(feature = "http")]
1210    ClientBuild(DisplaySafeUrl, Box<ClientBuildError>),
1211    #[cfg(feature = "http")]
1212    InvalidUrl(String, DisplaySafeUrlError),
1213}
1214
1215impl Display for RequirementsTxtParserError {
1216    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1217        match self {
1218            Self::Io(err) => err.fmt(f),
1219            Self::Url { url, start, .. } => {
1220                write!(f, "Invalid URL at position {start}: `{url}`")
1221            }
1222            Self::FileUrl { url, start, .. } => {
1223                write!(f, "Invalid file URL at position {start}: `{url}`")
1224            }
1225            Self::VerbatimUrl { url, start, .. } => {
1226                write!(f, "Invalid URL at position {start}: `{url}`")
1227            }
1228            Self::UrlConversion(given) => {
1229                write!(f, "Unable to convert URL to path: {given}")
1230            }
1231            Self::UnsupportedUrl(url) => {
1232                write!(f, "Unsupported URL (expected a `file://` scheme): `{url}`")
1233            }
1234            Self::NonEditable { .. } => {
1235                write!(f, "Unsupported editable requirement")
1236            }
1237            Self::MissingRequirementPrefix(given) => {
1238                write!(
1239                    f,
1240                    "Requirement `{given}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?"
1241                )
1242            }
1243            Self::NoBinary { specifier, .. } => {
1244                write!(f, "Invalid specifier for `--no-binary`: {specifier}")
1245            }
1246            Self::OnlyBinary { specifier, .. } => {
1247                write!(f, "Invalid specifier for `--only-binary`: {specifier}")
1248            }
1249            Self::UnnamedConstraint { .. } => {
1250                write!(f, "Unnamed requirements are not allowed as constraints")
1251            }
1252            Self::Parser {
1253                message,
1254                line,
1255                column,
1256            } => {
1257                write!(f, "{message} at {line}:{column}")
1258            }
1259            Self::UnsupportedRequirement { start, end, .. } => {
1260                write!(f, "Unsupported requirement in position {start} to {end}")
1261            }
1262            Self::Pep508 { start, .. } => {
1263                write!(f, "Couldn't parse requirement at position {start}")
1264            }
1265            Self::ParsedUrl { start, .. } => {
1266                write!(f, "Couldn't URL at position {start}")
1267            }
1268            Self::Subfile { start, .. } => {
1269                write!(f, "Error parsing included file at position {start}")
1270            }
1271            Self::NonUnicodeUrl { url } => {
1272                write!(
1273                    f,
1274                    "Remote requirements URL contains non-unicode characters: {}",
1275                    url.display(),
1276                )
1277            }
1278            #[cfg(feature = "http")]
1279            Self::Reqwest(url, _err) => {
1280                write!(f, "Error while accessing remote requirements file: `{url}`")
1281            }
1282            #[cfg(feature = "http")]
1283            Self::ClientBuild(url, _err) => {
1284                write!(f, "Error while accessing remote requirements file: `{url}`")
1285            }
1286            #[cfg(feature = "http")]
1287            Self::InvalidUrl(url, err) => {
1288                match err {
1289                    DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
1290                    DisplaySafeUrlError::AmbiguousAuthority(_) => {
1291                        // Intentionally avoid leaking the URL here, since we suspect that the user
1292                        // has given us an ambiguous URL that contains sensitive information.
1293                        // The error's own Display will provide a redacted version of the URL.
1294                        write!(f, "Invalid URL: {err}")
1295                    }
1296                }
1297            }
1298        }
1299    }
1300}
1301
1302impl std::error::Error for RequirementsTxtParserError {
1303    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1304        match self {
1305            Self::Io(err) => err.source(),
1306            Self::Url { source, .. } => Some(source),
1307            Self::FileUrl { .. } => None,
1308            Self::VerbatimUrl { source, .. } => Some(source),
1309            Self::UrlConversion(_) => None,
1310            Self::UnsupportedUrl(_) => None,
1311            Self::NonEditable { source, .. } => Some(source),
1312            Self::MissingRequirementPrefix(_) => None,
1313            Self::NoBinary { source, .. } => Some(source),
1314            Self::OnlyBinary { source, .. } => Some(source),
1315            Self::UnnamedConstraint { .. } => None,
1316            Self::UnsupportedRequirement { source, .. } => Some(source),
1317            Self::Pep508 { source, .. } => Some(source),
1318            Self::ParsedUrl { source, .. } => Some(source),
1319            Self::Subfile { source, .. } => Some(source.as_ref()),
1320            Self::Parser { .. } => None,
1321            Self::NonUnicodeUrl { .. } => None,
1322            #[cfg(feature = "http")]
1323            Self::Reqwest(_, err) => err.source(),
1324            #[cfg(feature = "http")]
1325            Self::ClientBuild(_, err) => Some(err.as_ref()),
1326            #[cfg(feature = "http")]
1327            Self::InvalidUrl(_, err) => err.source(),
1328        }
1329    }
1330}
1331
1332impl Display for RequirementsTxtFileError {
1333    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1334        match &self.error {
1335            RequirementsTxtParserError::Io(err) => err.fmt(f),
1336            RequirementsTxtParserError::Url { url, start, .. } => {
1337                write!(
1338                    f,
1339                    "Invalid URL in `{}` at position {start}: `{url}`",
1340                    self.file.user_display(),
1341                )
1342            }
1343            RequirementsTxtParserError::FileUrl { url, start, .. } => {
1344                write!(
1345                    f,
1346                    "Invalid file URL in `{}` at position {start}: `{url}`",
1347                    self.file.user_display(),
1348                )
1349            }
1350            RequirementsTxtParserError::VerbatimUrl { url, start, .. } => {
1351                write!(
1352                    f,
1353                    "Invalid URL in `{}` at position {start}: `{url}`",
1354                    self.file.user_display(),
1355                )
1356            }
1357            RequirementsTxtParserError::UrlConversion(given) => {
1358                write!(
1359                    f,
1360                    "Unable to convert URL to path `{}`: {given}",
1361                    self.file.user_display()
1362                )
1363            }
1364            RequirementsTxtParserError::UnsupportedUrl(url) => {
1365                write!(
1366                    f,
1367                    "Unsupported URL (expected a `file://` scheme) in `{}`: `{url}`",
1368                    self.file.user_display(),
1369                )
1370            }
1371            RequirementsTxtParserError::NonEditable { .. } => {
1372                write!(
1373                    f,
1374                    "Unsupported editable requirement in `{}`",
1375                    self.file.user_display(),
1376                )
1377            }
1378            RequirementsTxtParserError::MissingRequirementPrefix(given) => {
1379                write!(
1380                    f,
1381                    "Requirement `{given}` in `{}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?",
1382                    self.file.user_display(),
1383                )
1384            }
1385            RequirementsTxtParserError::NoBinary { specifier, .. } => {
1386                write!(
1387                    f,
1388                    "Invalid specifier for `--no-binary` in `{}`: {specifier}",
1389                    self.file.user_display(),
1390                )
1391            }
1392            RequirementsTxtParserError::OnlyBinary { specifier, .. } => {
1393                write!(
1394                    f,
1395                    "Invalid specifier for `--only-binary` in `{}`: {specifier}",
1396                    self.file.user_display(),
1397                )
1398            }
1399            RequirementsTxtParserError::UnnamedConstraint { .. } => {
1400                write!(
1401                    f,
1402                    "Unnamed requirements are not allowed as constraints in `{}`",
1403                    self.file.user_display(),
1404                )
1405            }
1406            RequirementsTxtParserError::Parser {
1407                message,
1408                line,
1409                column,
1410            } => {
1411                write!(
1412                    f,
1413                    "{message} at {}:{line}:{column}",
1414                    self.file.user_display(),
1415                )
1416            }
1417            RequirementsTxtParserError::UnsupportedRequirement { start, .. } => {
1418                write!(
1419                    f,
1420                    "Unsupported requirement in {} at position {start}",
1421                    self.file.user_display(),
1422                )
1423            }
1424            RequirementsTxtParserError::Pep508 { start, .. } => {
1425                write!(
1426                    f,
1427                    "Couldn't parse requirement in `{}` at position {start}",
1428                    self.file.user_display(),
1429                )
1430            }
1431            RequirementsTxtParserError::ParsedUrl { start, .. } => {
1432                write!(
1433                    f,
1434                    "Couldn't parse URL in `{}` at position {start}",
1435                    self.file.user_display(),
1436                )
1437            }
1438            RequirementsTxtParserError::Subfile { start, .. } => {
1439                write!(
1440                    f,
1441                    "Error parsing included file in `{}` at position {start}",
1442                    self.file.user_display(),
1443                )
1444            }
1445            RequirementsTxtParserError::NonUnicodeUrl { url } => {
1446                write!(
1447                    f,
1448                    "Remote requirements URL contains non-unicode characters: {}",
1449                    url.display(),
1450                )
1451            }
1452            #[cfg(feature = "http")]
1453            RequirementsTxtParserError::Reqwest(url, _err) => {
1454                write!(f, "Error while accessing remote requirements file: `{url}`")
1455            }
1456            #[cfg(feature = "http")]
1457            RequirementsTxtParserError::ClientBuild(url, _err) => {
1458                write!(f, "Error while accessing remote requirements file: `{url}`")
1459            }
1460            #[cfg(feature = "http")]
1461            RequirementsTxtParserError::InvalidUrl(url, err) => match err {
1462                DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
1463                DisplaySafeUrlError::AmbiguousAuthority(_) => {
1464                    // Intentionally avoid leaking the URL here, since we suspect that the user
1465                    // has given us an ambiguous URL that contains sensitive information.
1466                    // The error's own Display will provide a redacted version of the URL.
1467                    write!(f, "Invalid URL: {err}")
1468                }
1469            },
1470        }
1471    }
1472}
1473
1474impl std::error::Error for RequirementsTxtFileError {
1475    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1476        self.error.source()
1477    }
1478}
1479
1480impl From<io::Error> for RequirementsTxtParserError {
1481    fn from(err: io::Error) -> Self {
1482        Self::Io(err)
1483    }
1484}
1485
1486#[cfg(feature = "http")]
1487impl RequirementsTxtParserError {
1488    fn from_reqwest(url: DisplaySafeUrl, err: reqwest::Error) -> Self {
1489        Self::Reqwest(url, reqwest_middleware::Error::Reqwest(err))
1490    }
1491
1492    fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1493        Self::Reqwest(url, err)
1494    }
1495}
1496
1497/// Avoid infinite recursion through recursive inclusions, while also being mindful of nested
1498/// requirements and constraint inclusions.
1499#[derive(Debug)]
1500enum VisitedFiles<'a> {
1501    /// The requirements are included as regular requirements, and can recursively include both
1502    /// requirements and constraints.
1503    Requirements {
1504        requirements: &'a mut FxHashSet<PathBuf>,
1505        constraints: &'a mut FxHashSet<PathBuf>,
1506    },
1507    /// The requirements are included as constraints, all recursive inclusions are considered
1508    /// constraints.
1509    Constraints {
1510        constraints: &'a mut FxHashSet<PathBuf>,
1511    },
1512}
1513
1514/// Return a stable identity for a requirements file without changing the path used to read it.
1515fn visited_file(path: &Path) -> PathBuf {
1516    if path.starts_with("http://") || path.starts_with("https://") {
1517        path.to_path_buf()
1518    } else {
1519        normalize_path(path).into_owned()
1520    }
1521}
1522
1523/// Calculates the column and line offset of a given cursor based on the
1524/// number of Unicode codepoints.
1525fn calculate_row_column(content: &str, position: usize) -> (usize, usize) {
1526    let mut line = 1;
1527    let mut column = 1;
1528
1529    let mut chars = content.char_indices().peekable();
1530    while let Some((index, char)) = chars.next() {
1531        if index >= position {
1532            break;
1533        }
1534        match char {
1535            '\r' => {
1536                // If the next character is a newline, skip it.
1537                if chars
1538                    .peek()
1539                    .is_some_and(|&(_, next_char)| next_char == '\n')
1540                {
1541                    chars.next();
1542                }
1543
1544                // Reset.
1545                line += 1;
1546                column = 1;
1547            }
1548            '\n' => {
1549                //
1550                line += 1;
1551                column = 1;
1552            }
1553            // Increment column by Unicode codepoint. We don't use visual width
1554            // (e.g., `UnicodeWidthChar::width(char).unwrap_or(0)`), since that's
1555            // not what editors typically count.
1556            _ => column += 1,
1557        }
1558    }
1559
1560    (line, column)
1561}
1562
1563#[cfg(test)]
1564mod test {
1565    use std::collections::BTreeSet;
1566    use std::path::{Path, PathBuf};
1567
1568    use anyhow::Result;
1569    use assert_fs::prelude::*;
1570    use fs_err as fs;
1571    use indoc::indoc;
1572    use insta::assert_debug_snapshot;
1573    use itertools::Itertools;
1574    use tempfile::tempdir;
1575    use test_case::test_case;
1576    use unscanny::Scanner;
1577
1578    use uv_fs::Simplified;
1579
1580    use crate::{RequirementsTxt, calculate_row_column};
1581
1582    fn workspace_test_data_dir() -> PathBuf {
1583        Path::new("./test-data").simple_canonicalize().unwrap()
1584    }
1585
1586    /// Filter a path for use in snapshots; in particular, match the Windows debug representation
1587    /// of a path.
1588    ///
1589    /// We replace backslashes to match the debug representation for paths, and match _either_
1590    /// backslashes or forward slashes as the latter appear when constructing a path from a URL.
1591    fn path_filter(path: &Path) -> String {
1592        regex::escape(&path.simplified_display().to_string()).replace(r"\\", r"(\\\\|/)")
1593    }
1594
1595    /// Return the insta filters for a given path.
1596    fn path_filters(filter: &str) -> Vec<(&str, &str)> {
1597        vec![(filter, "<REQUIREMENTS_DIR>"), (r"\\\\", "/")]
1598    }
1599
1600    #[test_case(Path::new("basic.txt"))]
1601    #[test_case(Path::new("constraints-a.txt"))]
1602    #[test_case(Path::new("constraints-b.txt"))]
1603    #[test_case(Path::new("empty.txt"))]
1604    #[test_case(Path::new("for-poetry.txt"))]
1605    #[test_case(Path::new("include-a.txt"))]
1606    #[test_case(Path::new("include-b.txt"))]
1607    #[test_case(Path::new("poetry-with-hashes.txt"))]
1608    #[test_case(Path::new("small.txt"))]
1609    #[test_case(Path::new("whitespace.txt"))]
1610    #[tokio::test]
1611    async fn parse(path: &Path) {
1612        let working_dir = workspace_test_data_dir().join("requirements-txt");
1613        let requirements_txt = working_dir.join(path);
1614
1615        let actual = RequirementsTxt::parse(requirements_txt.clone(), &working_dir)
1616            .await
1617            .unwrap();
1618
1619        let snapshot = format!("parse-{}", path.to_string_lossy());
1620
1621        insta::with_settings!({
1622            filters => path_filters(&path_filter(&working_dir)),
1623        }, {
1624            insta::assert_debug_snapshot!(snapshot, actual);
1625        });
1626    }
1627
1628    #[test_case(Path::new("basic.txt"))]
1629    #[test_case(Path::new("constraints-a.txt"))]
1630    #[test_case(Path::new("constraints-b.txt"))]
1631    #[test_case(Path::new("empty.txt"))]
1632    #[test_case(Path::new("for-poetry.txt"))]
1633    #[test_case(Path::new("include-a.txt"))]
1634    #[test_case(Path::new("include-b.txt"))]
1635    #[test_case(Path::new("poetry-with-hashes.txt"))]
1636    #[test_case(Path::new("small.txt"))]
1637    #[test_case(Path::new("whitespace.txt"))]
1638    #[tokio::test]
1639    async fn line_endings(path: &Path) {
1640        let working_dir = workspace_test_data_dir().join("requirements-txt");
1641        let requirements_txt = working_dir.join(path);
1642
1643        // Copy the existing files over to a temporary directory.
1644        let temp_dir = tempdir().unwrap();
1645        for entry in fs::read_dir(&working_dir).unwrap() {
1646            let entry = entry.unwrap();
1647            let path = entry.path();
1648            let dest = temp_dir.path().join(path.file_name().unwrap());
1649            fs::copy(&path, &dest).unwrap();
1650        }
1651
1652        // Replace line endings with the other choice. This works even if you use git with LF
1653        // only on windows.
1654        let contents = fs::read_to_string(requirements_txt).unwrap();
1655        let contents = if contents.contains("\r\n") {
1656            contents.replace("\r\n", "\n")
1657        } else {
1658            contents.replace('\n', "\r\n")
1659        };
1660        let requirements_txt = temp_dir.path().join(path);
1661        fs::write(&requirements_txt, contents).unwrap();
1662
1663        let actual = RequirementsTxt::parse(&requirements_txt, &working_dir)
1664            .await
1665            .unwrap();
1666
1667        let snapshot = format!("line-endings-{}", path.to_string_lossy());
1668
1669        insta::with_settings!({
1670            filters => path_filters(&path_filter(temp_dir.path())),
1671        }, {
1672            insta::assert_debug_snapshot!(snapshot, actual);
1673        });
1674    }
1675
1676    #[cfg(unix)]
1677    #[test_case(Path::new("bare-url.txt"))]
1678    #[test_case(Path::new("editable.txt"))]
1679    #[tokio::test]
1680    async fn parse_unix(path: &Path) {
1681        let working_dir = workspace_test_data_dir().join("requirements-txt");
1682        let requirements_txt = working_dir.join(path);
1683
1684        let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1685            .await
1686            .unwrap();
1687
1688        let snapshot = format!("parse-unix-{}", path.to_string_lossy());
1689
1690        insta::with_settings!({
1691            filters => path_filters(&path_filter(&working_dir)),
1692        }, {
1693            insta::assert_debug_snapshot!(snapshot, actual);
1694        });
1695    }
1696
1697    #[cfg(unix)]
1698    #[test_case(Path::new("semicolon.txt"))]
1699    #[test_case(Path::new("hash.txt"))]
1700    #[tokio::test]
1701    async fn parse_err(path: &Path) {
1702        let working_dir = workspace_test_data_dir().join("requirements-txt");
1703        let requirements_txt = working_dir.join(path);
1704
1705        let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1706            .await
1707            .unwrap_err();
1708
1709        let snapshot = format!("parse-unix-{}", path.to_string_lossy());
1710
1711        insta::with_settings!({
1712            filters => path_filters(&path_filter(&working_dir)),
1713        }, {
1714            insta::assert_debug_snapshot!(snapshot, actual);
1715        });
1716    }
1717
1718    #[cfg(windows)]
1719    #[test_case(Path::new("bare-url.txt"))]
1720    #[test_case(Path::new("editable.txt"))]
1721    #[tokio::test]
1722    async fn parse_windows(path: &Path) {
1723        let working_dir = workspace_test_data_dir().join("requirements-txt");
1724        let requirements_txt = working_dir.join(path);
1725
1726        let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1727            .await
1728            .unwrap();
1729
1730        let snapshot = format!("parse-windows-{}", path.to_string_lossy());
1731
1732        insta::with_settings!({
1733            filters => path_filters(&path_filter(&working_dir)),
1734        }, {
1735            insta::assert_debug_snapshot!(snapshot, actual);
1736        });
1737    }
1738
1739    #[tokio::test]
1740    async fn invalid_include_missing_file() -> Result<()> {
1741        let temp_dir = assert_fs::TempDir::new()?;
1742        let missing_txt = temp_dir.child("missing.txt");
1743        let requirements_txt = temp_dir.child("requirements.txt");
1744        requirements_txt.write_str(indoc! {"
1745            -r missing.txt
1746        "})?;
1747
1748        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1749            .await
1750            .unwrap_err();
1751        let errors = anyhow::Error::new(error)
1752            .chain()
1753            // The last error is operating-system specific.
1754            .take(2)
1755            .join("\n");
1756
1757        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1758        let missing_txt = regex::escape(&missing_txt.path().user_display().to_string());
1759        let filters = vec![
1760            (requirement_txt.as_str(), "<REQUIREMENTS_TXT>"),
1761            (missing_txt.as_str(), "<MISSING_TXT>"),
1762            // Windows translates error messages, for example i get:
1763            // "Das System kann den angegebenen Pfad nicht finden. (os error 3)"
1764            (
1765                r": .* \(os error 2\)",
1766                ": The system cannot find the path specified. (os error 2)",
1767            ),
1768        ];
1769        insta::with_settings!({
1770            filters => filters,
1771        }, {
1772            insta::assert_snapshot!(errors, @"
1773            Error parsing included file in `<REQUIREMENTS_TXT>` at position 0
1774            failed to read from file `<MISSING_TXT>`: The system cannot find the path specified. (os error 2)
1775            ");
1776        });
1777
1778        Ok(())
1779    }
1780
1781    #[tokio::test]
1782    async fn invalid_requirement_version() -> Result<()> {
1783        let temp_dir = assert_fs::TempDir::new()?;
1784        let requirements_txt = temp_dir.child("requirements.txt");
1785        requirements_txt.write_str(indoc! {"
1786            numpy[ö]==1.29
1787        "})?;
1788
1789        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1790            .await
1791            .unwrap_err();
1792        let errors = anyhow::Error::new(error).chain().join("\n");
1793
1794        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1795        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1796        insta::with_settings!({
1797            filters => filters
1798        }, {
1799            insta::assert_snapshot!(errors, @"
1800            Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
1801            Expected an alphanumeric character starting the extra name, found `ö`
1802            numpy[ö]==1.29
1803                  ^
1804            ");
1805        });
1806
1807        Ok(())
1808    }
1809
1810    #[tokio::test]
1811    async fn invalid_requirement_url() -> Result<()> {
1812        let temp_dir = assert_fs::TempDir::new()?;
1813        let requirements_txt = temp_dir.child("requirements.txt");
1814        requirements_txt.write_str(indoc! {"
1815            numpy @ https:///
1816        "})?;
1817
1818        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1819            .await
1820            .unwrap_err();
1821        let errors = anyhow::Error::new(error).chain().join("\n");
1822
1823        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1824        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1825        insta::with_settings!({
1826            filters => filters
1827        }, {
1828            insta::assert_snapshot!(errors, @"
1829            Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
1830            empty host
1831            numpy @ https:///
1832                    ^^^^^^^^^
1833            ");
1834        });
1835
1836        Ok(())
1837    }
1838
1839    #[tokio::test]
1840    async fn unsupported_editable() -> Result<()> {
1841        let temp_dir = assert_fs::TempDir::new()?;
1842        let requirements_txt = temp_dir.child("requirements.txt");
1843        requirements_txt.write_str(indoc! {"
1844            -e https://localhost:8080/
1845        "})?;
1846
1847        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1848            .await
1849            .unwrap_err();
1850        let errors = anyhow::Error::new(error).chain().join("\n");
1851
1852        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1853        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1854        insta::with_settings!({
1855            filters => filters
1856        }, {
1857            insta::assert_snapshot!(errors, @"
1858            Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
1859            Expected direct URL (`https://localhost:8080/`) to end in a supported file extension: `.whl`, `.tar.gz`, `.zip`, `.tar.bz2`, `.tar.lz`, `.tar.lzma`, `.tar.xz`, `.tar.zst`, `.tar`, `.tbz`, `.tgz`, `.tlz`, or `.txz`
1860            https://localhost:8080/
1861            ^^^^^^^^^^^^^^^^^^^^^^^
1862            ");
1863        });
1864
1865        Ok(())
1866    }
1867
1868    #[tokio::test]
1869    async fn unsupported_editable_extension() -> Result<()> {
1870        let temp_dir = assert_fs::TempDir::new()?;
1871        let requirements_txt = temp_dir.child("requirements.txt");
1872        requirements_txt.write_str(indoc! {"
1873            -e https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz
1874        "})?;
1875
1876        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1877            .await
1878            .unwrap_err();
1879        let errors = anyhow::Error::new(error).chain().join("\n");
1880
1881        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1882        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1883        insta::with_settings!({
1884            filters => filters
1885        }, {
1886            insta::assert_snapshot!(errors, @"
1887            Unsupported editable requirement in `<REQUIREMENTS_TXT>`
1888            Editable must refer to a local directory, not an HTTPS URL: `https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz`
1889            ");
1890        });
1891
1892        Ok(())
1893    }
1894
1895    #[tokio::test]
1896    async fn invalid_editable_extra() -> Result<()> {
1897        let temp_dir = assert_fs::TempDir::new()?;
1898        let requirements_txt = temp_dir.child("requirements.txt");
1899        requirements_txt.write_str(indoc! {"
1900            -e black[,abcdef]
1901        "})?;
1902
1903        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1904            .await
1905            .unwrap_err();
1906        let errors = anyhow::Error::new(error).chain().join("\n");
1907
1908        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1909        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1910        insta::with_settings!({
1911            filters => filters
1912        }, {
1913            insta::assert_snapshot!(errors, @"
1914            Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
1915            Expected either alphanumerical character (starting the extra name) or `]` (ending the extras section), found `,`
1916            black[,abcdef]
1917                  ^
1918            ");
1919        });
1920
1921        Ok(())
1922    }
1923
1924    #[tokio::test]
1925    async fn relative_index_url() -> Result<()> {
1926        let temp_dir = assert_fs::TempDir::new()?;
1927        let requirements_txt = temp_dir.child("requirements.txt");
1928        requirements_txt.write_str(indoc! {"
1929            --index-url 123
1930        "})?;
1931
1932        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1933            .await
1934            .unwrap_err();
1935        let errors = anyhow::Error::new(error).chain().join("\n");
1936
1937        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1938        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1939        insta::with_settings!({
1940            filters => filters
1941        }, {
1942            insta::assert_snapshot!(errors, @"
1943            Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `123`
1944            relative URL without a base
1945            ");
1946        });
1947
1948        Ok(())
1949    }
1950
1951    #[tokio::test]
1952    async fn invalid_index_url() -> Result<()> {
1953        let temp_dir = assert_fs::TempDir::new()?;
1954        let requirements_txt = temp_dir.child("requirements.txt");
1955        requirements_txt.write_str(indoc! {"
1956            --index-url https:////
1957        "})?;
1958
1959        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1960            .await
1961            .unwrap_err();
1962        let errors = anyhow::Error::new(error).chain().join("\n");
1963
1964        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1965        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1966        insta::with_settings!({
1967            filters => filters
1968        }, {
1969            insta::assert_snapshot!(errors, @"
1970            Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `https:////`
1971            empty host
1972            ");
1973        });
1974
1975        Ok(())
1976    }
1977
1978    #[tokio::test]
1979    async fn missing_value() -> Result<()> {
1980        let temp_dir = assert_fs::TempDir::new()?;
1981        let requirements_txt = temp_dir.child("requirements.txt");
1982        requirements_txt.write_str(indoc! {"
1983            flask
1984            --no-binary
1985        "})?;
1986
1987        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1988            .await
1989            .unwrap_err();
1990        let errors = anyhow::Error::new(error).chain().join("\n");
1991
1992        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1993        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1994        insta::with_settings!({
1995            filters => filters
1996        }, {
1997            insta::assert_snapshot!(errors, @"`--no-binary` must be followed by an argument at <REQUIREMENTS_TXT>:3:1");
1998        });
1999
2000        Ok(())
2001    }
2002
2003    #[tokio::test]
2004    async fn missing_r() -> Result<()> {
2005        let temp_dir = assert_fs::TempDir::new()?;
2006
2007        let file_txt = temp_dir.child("file.txt");
2008        file_txt.touch()?;
2009
2010        let requirements_txt = temp_dir.child("requirements.txt");
2011        requirements_txt.write_str(indoc! {"
2012            flask
2013            file.txt
2014        "})?;
2015
2016        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2017            .await
2018            .unwrap_err();
2019        let errors = anyhow::Error::new(error).chain().join("\n");
2020
2021        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2022        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2023        insta::with_settings!({
2024            filters => filters
2025        }, {
2026            insta::assert_snapshot!(errors, @"Requirement `file.txt` in `<REQUIREMENTS_TXT>` looks like a requirements file but was passed as a package name. Did you mean `-r file.txt`?");
2027        });
2028
2029        Ok(())
2030    }
2031
2032    #[tokio::test]
2033    async fn relative_requirement() -> Result<()> {
2034        let temp_dir = assert_fs::TempDir::new()?;
2035
2036        // Create a requirements file with a relative entry, in a subdirectory.
2037        let sub_dir = temp_dir.child("subdir");
2038
2039        let sibling_txt = sub_dir.child("sibling.txt");
2040        sibling_txt.write_str(indoc! {"
2041            flask
2042        "})?;
2043
2044        let child_txt = sub_dir.child("child.txt");
2045        child_txt.write_str(indoc! {"
2046            -r sibling.txt
2047        "})?;
2048
2049        // Create a requirements file that points at `requirements.txt`.
2050        let parent_txt = temp_dir.child("parent.txt");
2051        parent_txt.write_str(indoc! {"
2052            -r subdir/child.txt
2053        "})?;
2054
2055        let requirements = RequirementsTxt::parse(parent_txt.path(), temp_dir.path())
2056            .await
2057            .unwrap();
2058
2059        insta::with_settings!({
2060            filters => path_filters(&path_filter(temp_dir.path())),
2061        }, {
2062            insta::assert_debug_snapshot!(requirements, @r#"
2063            RequirementsTxt {
2064                requirements: [
2065                    RequirementEntry {
2066                        requirement: Named(
2067                            Requirement {
2068                                name: PackageName(
2069                                    "flask",
2070                                ),
2071                                extras: [],
2072                                version_or_url: None,
2073                                marker: true,
2074                                origin: Some(
2075                                    File(
2076                                        "<REQUIREMENTS_DIR>/subdir/sibling.txt",
2077                                    ),
2078                                ),
2079                            },
2080                        ),
2081                        hashes: [],
2082                    },
2083                ],
2084                constraints: [],
2085                editables: [],
2086                index_url: None,
2087                extra_index_urls: [],
2088                find_links: [],
2089                no_index: false,
2090                no_binary: None,
2091                only_binary: None,
2092            }
2093            "#);
2094        });
2095
2096        Ok(())
2097    }
2098
2099    #[tokio::test]
2100    async fn nested_no_binary() -> Result<()> {
2101        let temp_dir = assert_fs::TempDir::new()?;
2102
2103        let requirements_txt = temp_dir.child("requirements.txt");
2104        requirements_txt.write_str(indoc! {"
2105            flask
2106            --no-binary :none:
2107            -r child.txt
2108        "})?;
2109
2110        let child = temp_dir.child("child.txt");
2111        child.write_str(indoc! {"
2112            --no-binary flask
2113        "})?;
2114
2115        let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2116            .await
2117            .unwrap();
2118
2119        insta::with_settings!({
2120            filters => path_filters(&path_filter(temp_dir.path())),
2121        }, {
2122            insta::assert_debug_snapshot!(requirements, @r#"
2123            RequirementsTxt {
2124                requirements: [
2125                    RequirementEntry {
2126                        requirement: Named(
2127                            Requirement {
2128                                name: PackageName(
2129                                    "flask",
2130                                ),
2131                                extras: [],
2132                                version_or_url: None,
2133                                marker: true,
2134                                origin: Some(
2135                                    File(
2136                                        "<REQUIREMENTS_DIR>/requirements.txt",
2137                                    ),
2138                                ),
2139                            },
2140                        ),
2141                        hashes: [],
2142                    },
2143                ],
2144                constraints: [],
2145                editables: [],
2146                index_url: None,
2147                extra_index_urls: [],
2148                find_links: [],
2149                no_index: false,
2150                no_binary: Packages(
2151                    [
2152                        PackageName(
2153                            "flask",
2154                        ),
2155                    ],
2156                ),
2157                only_binary: None,
2158            }
2159            "#);
2160        });
2161
2162        Ok(())
2163    }
2164
2165    #[tokio::test]
2166    #[cfg(not(windows))]
2167    async fn nested_editable() -> Result<()> {
2168        let temp_dir = assert_fs::TempDir::new()?;
2169
2170        let requirements_txt = temp_dir.child("requirements.txt");
2171        requirements_txt.write_str(indoc! {"
2172            -r child.txt
2173        "})?;
2174
2175        let child = temp_dir.child("child.txt");
2176        child.write_str(indoc! {"
2177            -r grandchild.txt
2178        "})?;
2179
2180        let grandchild = temp_dir.child("grandchild.txt");
2181        grandchild.write_str(indoc! {"
2182            -e /foo/bar
2183            --no-index
2184        "})?;
2185
2186        let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2187            .await
2188            .unwrap();
2189
2190        insta::with_settings!({
2191            filters => path_filters(&path_filter(temp_dir.path())),
2192        }, {
2193            insta::assert_debug_snapshot!(requirements, @r#"
2194            RequirementsTxt {
2195                requirements: [],
2196                constraints: [],
2197                editables: [
2198                    RequirementEntry {
2199                        requirement: Unnamed(
2200                            UnnamedRequirement {
2201                                url: VerbatimParsedUrl {
2202                                    parsed_url: Directory(
2203                                        ParsedDirectoryUrl {
2204                                            url: DisplaySafeUrl {
2205                                                scheme: "file",
2206                                                cannot_be_a_base: false,
2207                                                username: "",
2208                                                password: None,
2209                                                host: None,
2210                                                port: None,
2211                                                path: "/foo/bar",
2212                                                query: None,
2213                                                fragment: None,
2214                                            },
2215                                            install_path: "/foo/bar",
2216                                            editable: Some(
2217                                                true,
2218                                            ),
2219                                            virtual: None,
2220                                        },
2221                                    ),
2222                                    verbatim: VerbatimUrl {
2223                                        url: DisplaySafeUrl {
2224                                            scheme: "file",
2225                                            cannot_be_a_base: false,
2226                                            username: "",
2227                                            password: None,
2228                                            host: None,
2229                                            port: None,
2230                                            path: "/foo/bar",
2231                                            query: None,
2232                                            fragment: None,
2233                                        },
2234                                        given: Some(
2235                                            "/foo/bar",
2236                                        ),
2237                                        expanded: false,
2238                                    },
2239                                },
2240                                extras: [],
2241                                marker: true,
2242                                origin: Some(
2243                                    File(
2244                                        "<REQUIREMENTS_DIR>/grandchild.txt",
2245                                    ),
2246                                ),
2247                            },
2248                        ),
2249                        hashes: [],
2250                    },
2251                ],
2252                index_url: None,
2253                extra_index_urls: [],
2254                find_links: [],
2255                no_index: true,
2256                no_binary: None,
2257                only_binary: None,
2258            }
2259            "#);
2260        });
2261
2262        Ok(())
2263    }
2264
2265    #[tokio::test]
2266    async fn nested_conflicting_index_url() -> Result<()> {
2267        let temp_dir = assert_fs::TempDir::new()?;
2268
2269        let requirements_txt = temp_dir.child("requirements.txt");
2270        requirements_txt.write_str(indoc! {"
2271            --index-url https://test.pypi.org/simple
2272            -r child.txt
2273        "})?;
2274
2275        let child = temp_dir.child("child.txt");
2276        child.write_str(indoc! {"
2277            -r grandchild.txt
2278        "})?;
2279
2280        let grandchild = temp_dir.child("grandchild.txt");
2281        grandchild.write_str(indoc! {"
2282            --index-url https://fake.pypi.org/simple
2283        "})?;
2284
2285        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2286            .await
2287            .unwrap_err();
2288        let errors = anyhow::Error::new(error).chain().join("\n");
2289
2290        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2291        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2292        insta::with_settings!({
2293            filters => filters
2294        }, {
2295            insta::assert_snapshot!(errors, @"Nested `requirements` file contains conflicting `--index-url` at <REQUIREMENTS_TXT>:2:13");
2296        });
2297
2298        Ok(())
2299    }
2300
2301    #[tokio::test]
2302    async fn comments() -> Result<()> {
2303        let temp_dir = assert_fs::TempDir::new()?;
2304
2305        let requirements_txt = temp_dir.child("requirements.txt");
2306        requirements_txt.write_str(indoc! {r"
2307            -r ./sibling.txt  # comment
2308            --index-url https://test.pypi.org/simple/  # comment
2309            --no-binary :all:  # comment
2310
2311            flask==3.0.0 \
2312                --hash=sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef \
2313                # comment
2314
2315            requests==2.26.0 \
2316                --hash=sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 # comment
2317
2318            black==21.12b0 # comment
2319
2320            mypy==0.910 \
2321              # comment
2322        "})?;
2323
2324        let sibling_txt = temp_dir.child("sibling.txt");
2325        sibling_txt.write_str(indoc! {"
2326            httpx # comment
2327        "})?;
2328
2329        let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2330            .await
2331            .unwrap();
2332
2333        insta::with_settings!({
2334            filters => path_filters(&path_filter(temp_dir.path())),
2335        }, {
2336            insta::assert_debug_snapshot!(requirements, @r#"
2337            RequirementsTxt {
2338                requirements: [
2339                    RequirementEntry {
2340                        requirement: Named(
2341                            Requirement {
2342                                name: PackageName(
2343                                    "httpx",
2344                                ),
2345                                extras: [],
2346                                version_or_url: None,
2347                                marker: true,
2348                                origin: Some(
2349                                    File(
2350                                        "<REQUIREMENTS_DIR>/./sibling.txt",
2351                                    ),
2352                                ),
2353                            },
2354                        ),
2355                        hashes: [],
2356                    },
2357                    RequirementEntry {
2358                        requirement: Named(
2359                            Requirement {
2360                                name: PackageName(
2361                                    "flask",
2362                                ),
2363                                extras: [],
2364                                version_or_url: Some(
2365                                    VersionSpecifier(
2366                                        VersionSpecifiers(
2367                                            [
2368                                                VersionSpecifier {
2369                                                    operator: Equal,
2370                                                    version: "3.0.0",
2371                                                },
2372                                            ],
2373                                        ),
2374                                    ),
2375                                ),
2376                                marker: true,
2377                                origin: Some(
2378                                    File(
2379                                        "<REQUIREMENTS_DIR>/requirements.txt",
2380                                    ),
2381                                ),
2382                            },
2383                        ),
2384                        hashes: [
2385                            "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
2386                        ],
2387                    },
2388                    RequirementEntry {
2389                        requirement: Named(
2390                            Requirement {
2391                                name: PackageName(
2392                                    "requests",
2393                                ),
2394                                extras: [],
2395                                version_or_url: Some(
2396                                    VersionSpecifier(
2397                                        VersionSpecifiers(
2398                                            [
2399                                                VersionSpecifier {
2400                                                    operator: Equal,
2401                                                    version: "2.26.0",
2402                                                },
2403                                            ],
2404                                        ),
2405                                    ),
2406                                ),
2407                                marker: true,
2408                                origin: Some(
2409                                    File(
2410                                        "<REQUIREMENTS_DIR>/requirements.txt",
2411                                    ),
2412                                ),
2413                            },
2414                        ),
2415                        hashes: [
2416                            "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
2417                        ],
2418                    },
2419                    RequirementEntry {
2420                        requirement: Named(
2421                            Requirement {
2422                                name: PackageName(
2423                                    "black",
2424                                ),
2425                                extras: [],
2426                                version_or_url: Some(
2427                                    VersionSpecifier(
2428                                        VersionSpecifiers(
2429                                            [
2430                                                VersionSpecifier {
2431                                                    operator: Equal,
2432                                                    version: "21.12b0",
2433                                                },
2434                                            ],
2435                                        ),
2436                                    ),
2437                                ),
2438                                marker: true,
2439                                origin: Some(
2440                                    File(
2441                                        "<REQUIREMENTS_DIR>/requirements.txt",
2442                                    ),
2443                                ),
2444                            },
2445                        ),
2446                        hashes: [],
2447                    },
2448                    RequirementEntry {
2449                        requirement: Named(
2450                            Requirement {
2451                                name: PackageName(
2452                                    "mypy",
2453                                ),
2454                                extras: [],
2455                                version_or_url: Some(
2456                                    VersionSpecifier(
2457                                        VersionSpecifiers(
2458                                            [
2459                                                VersionSpecifier {
2460                                                    operator: Equal,
2461                                                    version: "0.910",
2462                                                },
2463                                            ],
2464                                        ),
2465                                    ),
2466                                ),
2467                                marker: true,
2468                                origin: Some(
2469                                    File(
2470                                        "<REQUIREMENTS_DIR>/requirements.txt",
2471                                    ),
2472                                ),
2473                            },
2474                        ),
2475                        hashes: [],
2476                    },
2477                ],
2478                constraints: [],
2479                editables: [],
2480                index_url: Some(
2481                    VerbatimUrl {
2482                        url: DisplaySafeUrl {
2483                            scheme: "https",
2484                            cannot_be_a_base: false,
2485                            username: "",
2486                            password: None,
2487                            host: Some(
2488                                Domain(
2489                                    "test.pypi.org",
2490                                ),
2491                            ),
2492                            port: None,
2493                            path: "/simple/",
2494                            query: None,
2495                            fragment: None,
2496                        },
2497                        given: Some(
2498                            "https://test.pypi.org/simple/",
2499                        ),
2500                        expanded: false,
2501                    },
2502                ),
2503                extra_index_urls: [],
2504                find_links: [],
2505                no_index: false,
2506                no_binary: All,
2507                only_binary: None,
2508            }
2509            "#);
2510        });
2511
2512        Ok(())
2513    }
2514
2515    #[tokio::test]
2516    #[cfg(not(windows))]
2517    async fn archive_requirement() -> Result<()> {
2518        let temp_dir = assert_fs::TempDir::new()?;
2519
2520        let requirements_txt = temp_dir.child("requirements.txt");
2521        requirements_txt.write_str(indoc! {r"
2522            # Archive name that's also a valid Python package name.
2523            importlib_metadata-8.3.0-py3-none-any.whl
2524
2525            # Archive name that's also a valid Python package name, with markers.
2526            importlib_metadata-8.2.0-py3-none-any.whl ; sys_platform == 'win32'
2527
2528            # Archive name that's also a valid Python package name, with extras.
2529            importlib_metadata-8.2.0-py3-none-any.whl[extra]
2530
2531            # Archive name that's not a valid Python package name.
2532            importlib_metadata-8.2.0+local-py3-none-any.whl
2533
2534            # Archive name that's not a valid Python package name, with markers.
2535            importlib_metadata-8.2.0+local-py3-none-any.whl ; sys_platform == 'win32'
2536
2537            # Archive name that's not a valid Python package name, with extras.
2538            importlib_metadata-8.2.0+local-py3-none-any.whl[extra]
2539        "})?;
2540
2541        let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2542            .await
2543            .unwrap();
2544
2545        insta::with_settings!({
2546            filters => path_filters(&path_filter(temp_dir.path())),
2547        }, {
2548            insta::assert_debug_snapshot!(requirements, @r#"
2549            RequirementsTxt {
2550                requirements: [
2551                    RequirementEntry {
2552                        requirement: Unnamed(
2553                            UnnamedRequirement {
2554                                url: VerbatimParsedUrl {
2555                                    parsed_url: Path(
2556                                        ParsedPathUrl {
2557                                            url: DisplaySafeUrl {
2558                                                scheme: "file",
2559                                                cannot_be_a_base: false,
2560                                                username: "",
2561                                                password: None,
2562                                                host: None,
2563                                                port: None,
2564                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2565                                                query: None,
2566                                                fragment: None,
2567                                            },
2568                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2569                                            ext: Wheel,
2570                                        },
2571                                    ),
2572                                    verbatim: VerbatimUrl {
2573                                        url: DisplaySafeUrl {
2574                                            scheme: "file",
2575                                            cannot_be_a_base: false,
2576                                            username: "",
2577                                            password: None,
2578                                            host: None,
2579                                            port: None,
2580                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2581                                            query: None,
2582                                            fragment: None,
2583                                        },
2584                                        given: Some(
2585                                            "importlib_metadata-8.3.0-py3-none-any.whl",
2586                                        ),
2587                                        expanded: false,
2588                                    },
2589                                },
2590                                extras: [],
2591                                marker: true,
2592                                origin: Some(
2593                                    File(
2594                                        "<REQUIREMENTS_DIR>/requirements.txt",
2595                                    ),
2596                                ),
2597                            },
2598                        ),
2599                        hashes: [],
2600                    },
2601                    RequirementEntry {
2602                        requirement: Unnamed(
2603                            UnnamedRequirement {
2604                                url: VerbatimParsedUrl {
2605                                    parsed_url: Path(
2606                                        ParsedPathUrl {
2607                                            url: DisplaySafeUrl {
2608                                                scheme: "file",
2609                                                cannot_be_a_base: false,
2610                                                username: "",
2611                                                password: None,
2612                                                host: None,
2613                                                port: None,
2614                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2615                                                query: None,
2616                                                fragment: None,
2617                                            },
2618                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2619                                            ext: Wheel,
2620                                        },
2621                                    ),
2622                                    verbatim: VerbatimUrl {
2623                                        url: DisplaySafeUrl {
2624                                            scheme: "file",
2625                                            cannot_be_a_base: false,
2626                                            username: "",
2627                                            password: None,
2628                                            host: None,
2629                                            port: None,
2630                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2631                                            query: None,
2632                                            fragment: None,
2633                                        },
2634                                        given: Some(
2635                                            "importlib_metadata-8.2.0-py3-none-any.whl",
2636                                        ),
2637                                        expanded: false,
2638                                    },
2639                                },
2640                                extras: [],
2641                                marker: sys_platform == 'win32',
2642                                origin: Some(
2643                                    File(
2644                                        "<REQUIREMENTS_DIR>/requirements.txt",
2645                                    ),
2646                                ),
2647                            },
2648                        ),
2649                        hashes: [],
2650                    },
2651                    RequirementEntry {
2652                        requirement: Unnamed(
2653                            UnnamedRequirement {
2654                                url: VerbatimParsedUrl {
2655                                    parsed_url: Path(
2656                                        ParsedPathUrl {
2657                                            url: DisplaySafeUrl {
2658                                                scheme: "file",
2659                                                cannot_be_a_base: false,
2660                                                username: "",
2661                                                password: None,
2662                                                host: None,
2663                                                port: None,
2664                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2665                                                query: None,
2666                                                fragment: None,
2667                                            },
2668                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2669                                            ext: Wheel,
2670                                        },
2671                                    ),
2672                                    verbatim: VerbatimUrl {
2673                                        url: DisplaySafeUrl {
2674                                            scheme: "file",
2675                                            cannot_be_a_base: false,
2676                                            username: "",
2677                                            password: None,
2678                                            host: None,
2679                                            port: None,
2680                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2681                                            query: None,
2682                                            fragment: None,
2683                                        },
2684                                        given: Some(
2685                                            "importlib_metadata-8.2.0-py3-none-any.whl",
2686                                        ),
2687                                        expanded: false,
2688                                    },
2689                                },
2690                                extras: [
2691                                    ExtraName(
2692                                        "extra",
2693                                    ),
2694                                ],
2695                                marker: true,
2696                                origin: Some(
2697                                    File(
2698                                        "<REQUIREMENTS_DIR>/requirements.txt",
2699                                    ),
2700                                ),
2701                            },
2702                        ),
2703                        hashes: [],
2704                    },
2705                    RequirementEntry {
2706                        requirement: Unnamed(
2707                            UnnamedRequirement {
2708                                url: VerbatimParsedUrl {
2709                                    parsed_url: Path(
2710                                        ParsedPathUrl {
2711                                            url: DisplaySafeUrl {
2712                                                scheme: "file",
2713                                                cannot_be_a_base: false,
2714                                                username: "",
2715                                                password: None,
2716                                                host: None,
2717                                                port: None,
2718                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2719                                                query: None,
2720                                                fragment: None,
2721                                            },
2722                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2723                                            ext: Wheel,
2724                                        },
2725                                    ),
2726                                    verbatim: VerbatimUrl {
2727                                        url: DisplaySafeUrl {
2728                                            scheme: "file",
2729                                            cannot_be_a_base: false,
2730                                            username: "",
2731                                            password: None,
2732                                            host: None,
2733                                            port: None,
2734                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2735                                            query: None,
2736                                            fragment: None,
2737                                        },
2738                                        given: Some(
2739                                            "importlib_metadata-8.2.0+local-py3-none-any.whl",
2740                                        ),
2741                                        expanded: false,
2742                                    },
2743                                },
2744                                extras: [],
2745                                marker: true,
2746                                origin: Some(
2747                                    File(
2748                                        "<REQUIREMENTS_DIR>/requirements.txt",
2749                                    ),
2750                                ),
2751                            },
2752                        ),
2753                        hashes: [],
2754                    },
2755                    RequirementEntry {
2756                        requirement: Unnamed(
2757                            UnnamedRequirement {
2758                                url: VerbatimParsedUrl {
2759                                    parsed_url: Path(
2760                                        ParsedPathUrl {
2761                                            url: DisplaySafeUrl {
2762                                                scheme: "file",
2763                                                cannot_be_a_base: false,
2764                                                username: "",
2765                                                password: None,
2766                                                host: None,
2767                                                port: None,
2768                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2769                                                query: None,
2770                                                fragment: None,
2771                                            },
2772                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2773                                            ext: Wheel,
2774                                        },
2775                                    ),
2776                                    verbatim: VerbatimUrl {
2777                                        url: DisplaySafeUrl {
2778                                            scheme: "file",
2779                                            cannot_be_a_base: false,
2780                                            username: "",
2781                                            password: None,
2782                                            host: None,
2783                                            port: None,
2784                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2785                                            query: None,
2786                                            fragment: None,
2787                                        },
2788                                        given: Some(
2789                                            "importlib_metadata-8.2.0+local-py3-none-any.whl",
2790                                        ),
2791                                        expanded: false,
2792                                    },
2793                                },
2794                                extras: [],
2795                                marker: sys_platform == 'win32',
2796                                origin: Some(
2797                                    File(
2798                                        "<REQUIREMENTS_DIR>/requirements.txt",
2799                                    ),
2800                                ),
2801                            },
2802                        ),
2803                        hashes: [],
2804                    },
2805                    RequirementEntry {
2806                        requirement: Unnamed(
2807                            UnnamedRequirement {
2808                                url: VerbatimParsedUrl {
2809                                    parsed_url: Path(
2810                                        ParsedPathUrl {
2811                                            url: DisplaySafeUrl {
2812                                                scheme: "file",
2813                                                cannot_be_a_base: false,
2814                                                username: "",
2815                                                password: None,
2816                                                host: None,
2817                                                port: None,
2818                                                path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2819                                                query: None,
2820                                                fragment: None,
2821                                            },
2822                                            install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2823                                            ext: Wheel,
2824                                        },
2825                                    ),
2826                                    verbatim: VerbatimUrl {
2827                                        url: DisplaySafeUrl {
2828                                            scheme: "file",
2829                                            cannot_be_a_base: false,
2830                                            username: "",
2831                                            password: None,
2832                                            host: None,
2833                                            port: None,
2834                                            path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2835                                            query: None,
2836                                            fragment: None,
2837                                        },
2838                                        given: Some(
2839                                            "importlib_metadata-8.2.0+local-py3-none-any.whl",
2840                                        ),
2841                                        expanded: false,
2842                                    },
2843                                },
2844                                extras: [
2845                                    ExtraName(
2846                                        "extra",
2847                                    ),
2848                                ],
2849                                marker: true,
2850                                origin: Some(
2851                                    File(
2852                                        "<REQUIREMENTS_DIR>/requirements.txt",
2853                                    ),
2854                                ),
2855                            },
2856                        ),
2857                        hashes: [],
2858                    },
2859                ],
2860                constraints: [],
2861                editables: [],
2862                index_url: None,
2863                extra_index_urls: [],
2864                find_links: [],
2865                no_index: false,
2866                no_binary: None,
2867                only_binary: None,
2868            }
2869            "#);
2870        });
2871
2872        Ok(())
2873    }
2874
2875    #[tokio::test]
2876    async fn parser_error_line_and_column() -> Result<()> {
2877        let temp_dir = assert_fs::TempDir::new()?;
2878        let requirements_txt = temp_dir.child("requirements.txt");
2879        requirements_txt.write_str(indoc! {"
2880            numpy>=1,<2
2881              --broken
2882            tqdm
2883        "})?;
2884
2885        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2886            .await
2887            .unwrap_err();
2888        let errors = anyhow::Error::new(error).chain().join("\n");
2889
2890        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2891        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2892        insta::with_settings!({
2893            filters => filters
2894        }, {
2895            insta::assert_snapshot!(errors, @"Unexpected '-', expected '-c', '-e', '-r' or the start of a requirement at <REQUIREMENTS_TXT>:2:3");
2896        });
2897
2898        Ok(())
2899    }
2900
2901    #[tokio::test]
2902    async fn malformed_hash_option() -> Result<()> {
2903        let temp_dir = assert_fs::TempDir::new()?;
2904        let requirements_txt = temp_dir.child("requirements.txt");
2905        requirements_txt.write_str("flask==3.0.0 --hash--hash=sha256:deadbeef")?;
2906
2907        let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2908            .await
2909            .unwrap_err();
2910        let errors = anyhow::Error::new(error).chain().join("\n");
2911
2912        let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2913        let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2914        insta::with_settings!({
2915            filters => filters
2916        }, {
2917            insta::assert_snapshot!(errors, @"Expected '=' or whitespace, found Some('-') at <REQUIREMENTS_TXT>:1:20");
2918        });
2919
2920        Ok(())
2921    }
2922
2923    #[test_case("numpy>=1,<2\n  @-broken\ntqdm", "2:4"; "ASCII Character with LF")]
2924    #[test_case("numpy>=1,<2\r\n  #-broken\ntqdm", "2:4"; "ASCII Character with CRLF")]
2925    #[test_case("numpy>=1,<2\n  \n-broken\ntqdm", "3:1"; "ASCII Character LF then LF")]
2926    #[test_case("numpy>=1,<2\n  \r-broken\ntqdm", "3:1"; "ASCII Character LF then CR but no LF")]
2927    #[test_case("numpy>=1,<2\n  \r\n-broken\ntqdm", "3:1"; "ASCII Character LF then CRLF")]
2928    #[test_case("numpy>=1,<2\n  🚀-broken\ntqdm", "2:4"; "Emoji (Wide) Character")]
2929    #[test_case("numpy>=1,<2\n  中-broken\ntqdm", "2:4"; "Fullwidth character")]
2930    #[test_case("numpy>=1,<2\n  e\u{0301}-broken\ntqdm", "2:5"; "Two codepoints")]
2931    #[test_case("numpy>=1,<2\n  a\u{0300}\u{0316}-broken\ntqdm", "2:6"; "Three codepoints")]
2932    fn test_calculate_line_column_pair(input: &str, expected: &str) {
2933        let mut s = Scanner::new(input);
2934        // Place cursor right after the character we want to test
2935        s.eat_until('-');
2936
2937        // Compute line/column
2938        let (line, column) = calculate_row_column(input, s.cursor());
2939        let line_column = format!("{line}:{column}");
2940
2941        // Assert line and columns are expected
2942        assert_eq!(line_column, expected, "Issues with input: {input}");
2943    }
2944
2945    /// Test different kinds of recursive inclusions with requirements and constraints
2946    #[tokio::test]
2947    async fn recursive_circular_inclusion() -> Result<()> {
2948        let temp_dir = assert_fs::TempDir::new()?;
2949        let both = temp_dir.child("both.txt");
2950        both.write_str(indoc! {"
2951            pkg-both
2952        "})?;
2953        let both = temp_dir.child("both-recursive.txt");
2954        both.write_str(indoc! {"
2955            pkg-both-recursive
2956            -r both-recursive.txt
2957            -c both-recursive.txt
2958        "})?;
2959        let requirements_only = temp_dir.child("requirements-only.txt");
2960        requirements_only.write_str(indoc! {"
2961            pkg-requirements-only
2962            -r requirements-only.txt
2963        "})?;
2964        let requirements_only = temp_dir.child("requirements-only-recursive.txt");
2965        requirements_only.write_str(indoc! {"
2966            pkg-requirements-only-recursive
2967            -r requirements-only-recursive.txt
2968        "})?;
2969        let constraints_only = temp_dir.child("requirements-in-constraints.txt");
2970        constraints_only.write_str(indoc! {"
2971            pkg-requirements-in-constraints
2972            # Some nested recursion for good measure
2973            -c constraints-only.txt
2974        "})?;
2975        let constraints_only = temp_dir.child("constraints-only.txt");
2976        constraints_only.write_str(indoc! {"
2977            pkg-constraints-only
2978            -c constraints-only.txt
2979            # Using `-r` inside `-c`
2980            -r requirements-in-constraints.txt
2981        "})?;
2982        let constraints_only = temp_dir.child("constraints-only-recursive.txt");
2983        constraints_only.write_str(indoc! {"
2984            pkg-constraints-only-recursive
2985            -r constraints-only-recursive.txt
2986        "})?;
2987
2988        let requirements = temp_dir.child("requirements.txt");
2989        requirements.write_str(indoc! {"
2990            # Even if a package was already included as a constraint, it is also included as
2991            # requirement
2992            -c both.txt
2993            -r both.txt
2994            -c both-recursive.txt
2995            -r both-recursive.txt
2996
2997            -r requirements-only.txt
2998            -r requirements-only-recursive.txt
2999            -c constraints-only.txt
3000            -c constraints-only-recursive.txt
3001        "})?;
3002
3003        let parsed = RequirementsTxt::parse(&requirements, temp_dir.path()).await?;
3004
3005        let requirements: BTreeSet<String> = parsed
3006            .requirements
3007            .iter()
3008            .map(|entry| entry.requirement.to_string())
3009            .collect();
3010        let constraints: BTreeSet<String> =
3011            parsed.constraints.iter().map(ToString::to_string).collect();
3012
3013        assert_debug_snapshot!(requirements, @r#"
3014        {
3015            "pkg-both",
3016            "pkg-both-recursive",
3017            "pkg-requirements-only",
3018            "pkg-requirements-only-recursive",
3019        }
3020        "#);
3021        assert_debug_snapshot!(constraints, @r#"
3022        {
3023            "pkg-both",
3024            "pkg-both-recursive",
3025            "pkg-constraints-only",
3026            "pkg-constraints-only-recursive",
3027            "pkg-requirements-in-constraints",
3028        }
3029        "#);
3030
3031        Ok(())
3032    }
3033}