Skip to main content

provenant/parsers/
requirements_txt.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for pip requirements.txt files.
8//!
9//! Extracts Python package dependencies from requirements.txt files using PEP 508
10//! specification parsing with support for includes, environment markers, and URLs.
11//!
12//! # Supported Formats
13//! - requirements.txt (pip dependency specification files)
14//! - Supports includes: `-r requirements.txt`, `-c constraints.txt`
15//! - Supports markers: `package; python_version >= '3.6'`
16//! - Supports VCS refs: `git+https://...`, `git+ssh://...`
17//!
18//! # Key Features
19//! - PEP 508 requirement parsing with environment marker evaluation
20//! - Recursive file inclusion support (`-r` and `-c` directives)
21//! - VCS/URL dependency detection and handling
22//! - Package URL (purl) generation for PyPI packages
23//! - Line comment handling and continuation lines
24//!
25//! # Implementation Notes
26//! - Uses PEP 508 parser from `pep508` module
27//! - Recursively resolves included files relative to parent file
28//! - Comments (lines starting with `#`) are skipped
29//! - Environment markers are preserved for dependency filtering
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use crate::parser_warn as warn;
35use packageurl::PackageUrl;
36use serde_json::Value as JsonValue;
37
38use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
39use crate::parsers::pep508::{
40    Pep508Requirement, is_valid_distribution_name, parse_pep508_requirement,
41};
42use crate::parsers::utils::{
43    CappedIterExt, MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, RecursionGuard,
44    capped_iteration_limit, read_file_to_string, truncate_field,
45};
46
47use super::PackageParser;
48use crate::parsers::active_parser_scan_root;
49
50/// pip requirements.txt parser supporting PEP 508 dependency specifications.
51///
52/// Handles requirements.txt files with -r/-c includes, environment markers,
53/// and VCS/URL references. Recursively resolves included requirement files.
54pub struct RequirementsTxtParser;
55
56impl PackageParser for RequirementsTxtParser {
57    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
58
59    fn extract_packages(path: &Path) -> Vec<PackageData> {
60        vec![extract_from_requirements_txt(path)]
61    }
62
63    fn is_match(path: &Path) -> bool {
64        let filename = path.file_name().and_then(|name| name.to_str());
65        let Some(name) = filename else {
66            return false;
67        };
68
69        is_requirements_txt_filename(name)
70            || (is_requirements_like_extension(name) && has_requirements_like_ancestor(path))
71    }
72
73    fn metadata() -> Vec<super::metadata::ParserMetadata> {
74        vec![super::metadata::ParserMetadata {
75            description: "pip requirements file",
76            file_patterns: &[
77                "**/requirements*.txt",
78                "**/*requirements.txt",
79                "**/reqs.txt",
80                "**/minreqs.txt",
81                "**/*-reqs.txt",
82                "**/*_reqs.txt",
83                "**/*.reqs.txt",
84                "**/*-minreqs.txt",
85                "**/*_minreqs.txt",
86                "**/*.minreqs.txt",
87                "**/requirements*.in",
88                "**/*requirements.in",
89                "**/requires.txt",
90                // The directory clause is not expressible as a glob: it excludes
91                // distribution metadata and versioned distribution roots, and is
92                // bounded by the scan root. Describing it as a glob would
93                // advertise files the parser explicitly declines, so it follows
94                // the `<...>` convention for non-glob surfaces instead.
95                "<any *.txt or *.in under a requirements directory within the \
96                  scan root, excluding *.dist-info, *.egg-info, *.data and \
97                  versioned distribution roots>",
98            ],
99            package_type: "pypi",
100            primary_language: "Python",
101            documentation_url: Some(
102                "https://pip.pypa.io/en/latest/reference/requirements-file-format/",
103            ),
104        }]
105    }
106}
107
108fn is_requirements_txt_filename(name: &str) -> bool {
109    if name == "requirements.txt" || name == "requires.txt" {
110        return true;
111    }
112
113    let (stem, extension) = if let Some(stem) = name.strip_suffix(".txt") {
114        (stem, "txt")
115    } else if let Some(stem) = name.strip_suffix(".in") {
116        (stem, "in")
117    } else {
118        return false;
119    };
120
121    // Keep parity with ScanCode's documented *reqs.txt support while avoiding
122    // extending that broader alias to .in files or unrelated stems such as
123    // `prereqs.txt` that only happen to end with the same letters.
124    stem == "requirements"
125        || stem.starts_with("requirements")
126        || stem.ends_with("requirements")
127        || (extension == "txt" && is_reqs_alias_stem(stem))
128}
129
130fn is_reqs_alias_stem(stem: &str) -> bool {
131    matches_requirement_alias_stem(stem, "reqs") || matches_requirement_alias_stem(stem, "minreqs")
132}
133
134fn matches_requirement_alias_stem(stem: &str, alias: &str) -> bool {
135    stem == alias
136        || stem
137            .strip_suffix(alias)
138            .is_some_and(|prefix| matches!(prefix.chars().last(), Some('-' | '_' | '.')))
139}
140
141fn is_requirements_like_extension(name: &str) -> bool {
142    name.ends_with(".txt") || name.ends_with(".in")
143}
144
145/// Whether the file sits under a directory that organises requirements files,
146/// which is what lets a `.txt`/`.in` with an unrelated name (`base.txt`,
147/// `py3.10.txt`) still be recognised.
148///
149/// The walk stops at the scan root. Parsers are handed an absolute filesystem
150/// path, so an unbounded walk reads directory names *above* what is being
151/// scanned: the same tree at the same in-scan path produced dependencies or not
152/// depending only on where it happened to be unpacked on disk, which made scans
153/// irreproducible across machines. Anything above the root is not part of the
154/// scanned tree and must not influence the result. When the root is unknown (a
155/// parser invoked outside a scan) the walk stays unbounded, preserving the
156/// previous behaviour rather than silently matching less.
157///
158/// Consequence worth knowing: pointing a scan *directly* at a `requirements/`
159/// directory no longer makes that directory's own name count, since it is then
160/// the root. Files named `requirements*.txt` / `requires.txt` still match on
161/// filename alone.
162fn has_requirements_like_ancestor(path: &Path) -> bool {
163    let Some(parent) = path.parent() else {
164        return false;
165    };
166
167    let boundary = active_parser_scan_root().and_then(|root| root.canonicalize().ok());
168    let canonical_parent = parent.canonicalize().ok();
169    let start = canonical_parent.as_deref().unwrap_or(parent);
170
171    for ancestor in start.ancestors() {
172        if boundary.as_deref().is_some_and(|root| ancestor == root) {
173            break;
174        }
175        if ancestor
176            .file_name()
177            .and_then(|name| name.to_str())
178            .is_some_and(is_requirements_like_dir_name)
179        {
180            return true;
181        }
182    }
183
184    false
185}
186
187/// A directory that groups requirements files, as opposed to one that merely
188/// starts or ends with the word.
189///
190/// A bare prefix/suffix test also claims every directory belonging to a *project
191/// named* `requirements*` — its sdist root, its `.dist-info`/`.egg-info`, its
192/// module directory — so shipped metadata such as `SOURCES.txt`,
193/// `entry_points.txt`, `top_level.txt` and even `LICENSE.txt` was parsed as
194/// requirements, one junk dependency per line.
195fn is_requirements_like_dir_name(name: &str) -> bool {
196    if is_python_distribution_metadata_dir(name) || looks_like_versioned_distribution_dir(name) {
197        return false;
198    }
199
200    if name == "requirements" {
201        return true;
202    }
203
204    if let Some(rest) = name.strip_prefix("requirements")
205        && rest.starts_with(['-', '_', '.'])
206    {
207        return true;
208    }
209
210    name.strip_suffix("requirements")
211        .is_some_and(|rest| rest.ends_with(['-', '_', '.']))
212}
213
214/// `*.dist-info`, `*.egg-info` and `*.data` hold generated distribution
215/// metadata. The only requirements file they legitimately carry is
216/// `requires.txt`, which matches on its filename alone.
217fn is_python_distribution_metadata_dir(name: &str) -> bool {
218    name.ends_with(".dist-info") || name.ends_with(".egg-info") || name.ends_with(".data")
219}
220
221/// A `name-version` distribution root (`requirements-builder-0.4.4`,
222/// `requirementslib-3.0.0`), whose contents are a project's source tree rather
223/// than a requirements directory.
224///
225/// Recognised by a trailing `-`-separated segment starting with a digit, but only
226/// when what precedes it names something *other* than requirements itself: an
227/// organiser directory may legitimately carry a version-like suffix
228/// (`requirements-3.11/` holding `base.txt`), and stripping the suffix
229/// distinguishes the two — `requirements-builder-0.4.4` leaves
230/// `requirements-builder`, a distribution name, while `requirements-3.11` leaves
231/// the bare word.
232fn looks_like_versioned_distribution_dir(name: &str) -> bool {
233    name.rsplit_once('-').is_some_and(|(prefix, last)| {
234        last.starts_with(|ch: char| ch.is_ascii_digit()) && prefix != "requirements"
235    })
236}
237
238struct ParseState {
239    dependencies: Vec<Dependency>,
240    extra_index_urls: Vec<String>,
241    index_url: Option<String>,
242    includes: Vec<String>,
243    constraints: Vec<String>,
244    guard: RecursionGuard<PathBuf>,
245}
246
247fn extract_from_requirements_txt(path: &Path) -> PackageData {
248    let mut state = ParseState {
249        dependencies: Vec::new(),
250        extra_index_urls: Vec::new(),
251        index_url: None,
252        includes: Vec::new(),
253        constraints: Vec::new(),
254        guard: RecursionGuard::new(),
255    };
256
257    let (scope, is_runtime) = scope_from_filename(path);
258
259    parse_requirements_with_includes(path, &mut state, &scope, is_runtime);
260
261    let mut extra_data = HashMap::new();
262    if let Some(url) = state.index_url {
263        extra_data.insert(
264            "index_url".to_string(),
265            JsonValue::String(truncate_field(url)),
266        );
267    }
268    if !state.extra_index_urls.is_empty() {
269        extra_data.insert(
270            "extra_index_urls".to_string(),
271            JsonValue::Array(
272                state
273                    .extra_index_urls
274                    .into_iter()
275                    .map(|u| JsonValue::String(truncate_field(u)))
276                    .collect(),
277            ),
278        );
279    }
280    if !state.includes.is_empty() {
281        extra_data.insert(
282            "requirements_includes".to_string(),
283            JsonValue::Array(
284                state
285                    .includes
286                    .into_iter()
287                    .map(|i| JsonValue::String(truncate_field(i)))
288                    .collect(),
289            ),
290        );
291    }
292    if !state.constraints.is_empty() {
293        extra_data.insert(
294            "constraints".to_string(),
295            JsonValue::Array(
296                state
297                    .constraints
298                    .into_iter()
299                    .map(|c| JsonValue::String(truncate_field(c)))
300                    .collect(),
301            ),
302        );
303    }
304
305    let extra_data = if extra_data.is_empty() {
306        None
307    } else {
308        Some(extra_data)
309    };
310
311    default_package_data(state.dependencies, extra_data)
312}
313
314fn parse_requirements_with_includes(
315    path: &Path,
316    state: &mut ParseState,
317    scope: &str,
318    is_runtime: bool,
319) {
320    if state.guard.exceeded() {
321        warn!(
322            "Maximum recursion depth ({}) exceeded for include: {:?}",
323            MAX_RECURSION_DEPTH, path
324        );
325        return;
326    }
327
328    let abs_path = match path.canonicalize() {
329        Ok(p) => p,
330        Err(_) => {
331            warn!("Cannot resolve path: {:?}", path);
332            return;
333        }
334    };
335
336    if state.guard.enter(abs_path.clone()) {
337        warn!("Circular include detected: {:?}", path);
338        return;
339    }
340
341    let content = match read_file_to_string(&abs_path, None) {
342        Ok(c) => c,
343        Err(e) => {
344            warn!("Cannot read file {:?}: {}", abs_path, e);
345            return;
346        }
347    };
348
349    // Sections are file-scoped: an included file starts outside any section and
350    // does not carry one back to its includer.
351    let mut section = RequirementSection::default();
352
353    let logical_lines = collect_logical_lines(&content);
354    let limit = capped_iteration_limit(logical_lines.len(), "requirements.txt logical lines");
355    for line in logical_lines.into_iter().take(limit) {
356        let cleaned = strip_inline_comment(&line);
357        let trimmed = cleaned.trim();
358        if trimmed.is_empty() || trimmed.starts_with('#') {
359            continue;
360        }
361
362        if let Some(url) = parse_option_value(trimmed, "--extra-index-url") {
363            state.extra_index_urls.push(truncate_field(url));
364            continue;
365        }
366
367        if let Some(url) = parse_option_value(trimmed, "--index-url") {
368            state.index_url = Some(truncate_field(url));
369            continue;
370        }
371
372        if let Some(path_value) = parse_option_value(trimmed, "-r")
373            .or_else(|| parse_option_value(trimmed, "--requirement"))
374        {
375            state.includes.push(truncate_field(path_value.clone()));
376            let included_path = abs_path
377                .parent()
378                .unwrap_or_else(|| Path::new("."))
379                .join(&path_value);
380
381            if included_path.exists() {
382                parse_requirements_with_includes(&included_path, state, scope, is_runtime);
383            } else {
384                warn!("Included file not found: {:?}", included_path);
385            }
386            continue;
387        }
388
389        if let Some(path_value) = parse_option_value(trimmed, "-c")
390            .or_else(|| parse_option_value(trimmed, "--constraint"))
391        {
392            state.constraints.push(truncate_field(path_value.clone()));
393            let constraint_path = abs_path
394                .parent()
395                .unwrap_or_else(|| Path::new("."))
396                .join(&path_value);
397
398            if constraint_path.exists() {
399                parse_requirements_with_includes(&constraint_path, state, scope, is_runtime);
400            } else {
401                warn!("Constraint file not found: {:?}", constraint_path);
402            }
403            continue;
404        }
405
406        if trimmed.starts_with('-')
407            && !trimmed.starts_with("-e")
408            && !trimmed.starts_with("--editable")
409        {
410            continue;
411        }
412
413        if let Some(parsed_section) = RequirementSection::parse(trimmed) {
414            section = parsed_section;
415            continue;
416        }
417
418        if let Some(dependency) = build_dependency(trimmed, scope, is_runtime, &section) {
419            if state.dependencies.len() >= MAX_ITERATION_COUNT {
420                warn!(
421                    "Reached maximum dependency count ({}) in {:?}",
422                    MAX_ITERATION_COUNT, abs_path
423                );
424                break;
425            }
426            state.dependencies.push(dependency);
427        }
428    }
429
430    state.guard.leave(abs_path);
431}
432
433fn default_package_data(
434    dependencies: Vec<Dependency>,
435    extra_data: Option<HashMap<String, JsonValue>>,
436) -> PackageData {
437    PackageData {
438        package_type: Some(RequirementsTxtParser::PACKAGE_TYPE),
439        primary_language: Some("Python".to_string()),
440        extra_data,
441        dependencies,
442        datasource_id: Some(DatasourceId::PipRequirements),
443        ..Default::default()
444    }
445}
446
447fn collect_logical_lines(content: &str) -> Vec<String> {
448    let mut lines = Vec::new();
449    let mut current = String::new();
450
451    for raw_line in content.lines().capped("requirements.txt raw lines") {
452        let line = raw_line.trim_end_matches('\r');
453        let trimmed = line.trim_end();
454        let is_continuation = trimmed.ends_with('\\');
455        let line_without = if is_continuation {
456            trimmed.trim_end_matches('\\')
457        } else {
458            line
459        };
460
461        if !line_without.trim().is_empty() {
462            if !current.is_empty() {
463                current.push(' ');
464            }
465            current.push_str(line_without.trim());
466        }
467
468        if !is_continuation && !current.is_empty() {
469            lines.push(current.trim().to_string());
470            current.clear();
471        }
472    }
473
474    if !current.is_empty() {
475        lines.push(current.trim().to_string());
476    }
477
478    lines
479}
480
481fn strip_inline_comment(line: &str) -> String {
482    let mut in_single = false;
483    let mut in_double = false;
484    for (idx, ch) in line.char_indices() {
485        match ch {
486            '\'' if !in_double => in_single = !in_single,
487            '"' if !in_single => in_double = !in_double,
488            '#' if !in_single && !in_double => {
489                let prefix = &line[..idx];
490                if prefix.trim_end().is_empty() || prefix.ends_with(char::is_whitespace) {
491                    return prefix.trim_end().to_string();
492                }
493            }
494            _ => {}
495        }
496    }
497    line.to_string()
498}
499
500fn parse_option_value(line: &str, option: &str) -> Option<String> {
501    let stripped = line.strip_prefix(option)?;
502    let mut rest = stripped.trim();
503    if let Some(rest_stripped) = rest.strip_prefix('=') {
504        rest = rest_stripped.trim();
505    }
506    if rest.is_empty() {
507        None
508    } else {
509        Some(rest.to_string())
510    }
511}
512
513fn scope_from_filename(path: &Path) -> (String, bool) {
514    let filename = path
515        .file_name()
516        .and_then(|name| name.to_str())
517        .unwrap_or_default()
518        .to_ascii_lowercase();
519
520    if filename.contains("dev") {
521        return ("develop".to_string(), false);
522    }
523    if filename.contains("test") {
524        return ("test".to_string(), false);
525    }
526    if filename.contains("doc") {
527        return ("docs".to_string(), false);
528    }
529
530    ("install".to_string(), true)
531}
532
533/// The `[extra]` / `[:marker]` / `[extra:marker]` grouping that setuptools writes
534/// into an egg-info `requires.txt`, and that everything after it belongs to.
535///
536/// pip's own requirements format has no sections, so a header line is not a
537/// requirement in any file it appears in; treating it as one produced a
538/// dependency whose `extracted_requirement` was literally `[dev]` or
539/// `[:python_version < "3.12"]`. Reading it instead recovers what the group
540/// actually means: an extra is an optional dependency group, and a section
541/// marker applies to every requirement under it.
542#[derive(Default)]
543struct RequirementSection {
544    extra: Option<String>,
545    marker: Option<String>,
546}
547
548impl RequirementSection {
549    /// Parses a `[...]` header, or returns `None` for any other line.
550    fn parse(line: &str) -> Option<Self> {
551        let inner = line.strip_prefix('[')?.strip_suffix(']')?;
552
553        let (extra, marker) = match inner.split_once(':') {
554            Some((extra, marker)) => (extra.trim(), Some(marker.trim())),
555            None => (inner.trim(), None),
556        };
557
558        Some(Self {
559            extra: (!extra.is_empty()).then(|| truncate_field(extra.to_string())),
560            marker: marker
561                .filter(|marker| !marker.is_empty())
562                .map(|marker| truncate_field(marker.to_string())),
563        })
564    }
565
566    /// The scope a requirement in this section belongs to: the extra's own name,
567    /// falling back to the scope derived from the filename outside any extra.
568    fn scope<'a>(&'a self, default_scope: &'a str) -> &'a str {
569        self.extra.as_deref().unwrap_or(default_scope)
570    }
571
572    /// Requirements under an extra are installed only when that extra is
573    /// requested, which is exactly what `is_optional` records.
574    fn is_optional(&self) -> bool {
575        self.extra.is_some()
576    }
577}
578
579fn build_dependency(
580    line: &str,
581    scope: &str,
582    is_runtime: bool,
583    section: &RequirementSection,
584) -> Option<Dependency> {
585    let trimmed = line.trim();
586    if trimmed.is_empty() {
587        return None;
588    }
589
590    let mut is_editable = false;
591    let mut requirement = truncate_field(trimmed.to_string());
592    let mut extracted_requirement = truncate_field(trimmed.to_string());
593
594    if let Some(rest) = trimmed.strip_prefix("-e") {
595        is_editable = true;
596        requirement = truncate_field(rest.trim().to_string());
597        extracted_requirement = truncate_field(format!("--editable {}", requirement));
598    } else if let Some(rest) = trimmed.strip_prefix("--editable") {
599        is_editable = true;
600        requirement = truncate_field(rest.trim().to_string());
601        extracted_requirement = truncate_field(format!("--editable {}", requirement));
602    }
603
604    let (requirement, hash_options) = split_hash_options(&requirement);
605    let requirement = requirement.trim();
606    if requirement.is_empty() {
607        return None;
608    }
609
610    if looks_like_hash_only_requirement(requirement) {
611        return None;
612    }
613
614    let parsed = parse_requirement(requirement);
615
616    let pinned_version = parsed
617        .specifiers
618        .as_deref()
619        .and_then(extract_pinned_version);
620    let is_pinned = pinned_version.is_some();
621
622    let purl = parsed
623        .name
624        .as_ref()
625        .and_then(|name| create_pypi_purl(name, pinned_version.as_deref()));
626
627    let mut extra_data = HashMap::new();
628    extra_data.insert("is_editable".to_string(), JsonValue::Bool(is_editable));
629    extra_data.insert(
630        "link".to_string(),
631        parsed
632            .link
633            .clone()
634            .map(|l| JsonValue::String(truncate_field(l)))
635            .unwrap_or(JsonValue::Null),
636    );
637    extra_data.insert(
638        "hash_options".to_string(),
639        JsonValue::Array(
640            hash_options
641                .into_iter()
642                .map(|h| JsonValue::String(truncate_field(h)))
643                .collect(),
644        ),
645    );
646    extra_data.insert("is_constraint".to_string(), JsonValue::Bool(false));
647    extra_data.insert(
648        "is_archive".to_string(),
649        parsed
650            .is_archive
651            .map(JsonValue::Bool)
652            .unwrap_or(JsonValue::Null),
653    );
654    extra_data.insert("is_wheel".to_string(), JsonValue::Bool(parsed.is_wheel));
655    extra_data.insert(
656        "is_url".to_string(),
657        parsed
658            .is_url
659            .map(JsonValue::Bool)
660            .unwrap_or(JsonValue::Null),
661    );
662    extra_data.insert(
663        "is_vcs_url".to_string(),
664        parsed
665            .is_vcs_url
666            .map(JsonValue::Bool)
667            .unwrap_or(JsonValue::Null),
668    );
669    extra_data.insert(
670        "is_name_at_url".to_string(),
671        JsonValue::Bool(parsed.is_name_at_url),
672    );
673    extra_data.insert(
674        "is_local_path".to_string(),
675        parsed
676            .is_local_path
677            .map(|value| value || is_editable)
678            .map(JsonValue::Bool)
679            .unwrap_or(JsonValue::Null),
680    );
681
682    // A section marker applies to every requirement under it, so it combines with
683    // an inline one rather than replacing it.
684    let marker = match (parsed.marker, section.marker.as_deref()) {
685        (Some(inline), Some(section_marker)) => Some(format!("({inline}) and ({section_marker})")),
686        (Some(inline), None) => Some(inline),
687        (None, Some(section_marker)) => Some(section_marker.to_string()),
688        (None, None) => None,
689    };
690    if let Some(marker) = marker {
691        extra_data.insert(
692            "markers".to_string(),
693            JsonValue::String(truncate_field(marker)),
694        );
695    }
696
697    Some(Dependency {
698        purl,
699        extracted_requirement: Some(truncate_field(extracted_requirement)),
700        scope: Some(section.scope(scope).to_string()),
701        is_runtime: Some(is_runtime),
702        is_optional: Some(section.is_optional()),
703        is_pinned: Some(is_pinned),
704        is_direct: Some(true),
705        resolved_package: None,
706        extra_data: Some(extra_data),
707    })
708}
709
710fn looks_like_hash_only_requirement(requirement: &str) -> bool {
711    let trimmed = requirement.trim();
712    if !matches!(trimmed.len(), 32 | 40 | 64 | 96 | 128) {
713        return false;
714    }
715
716    if trimmed.contains(char::is_whitespace)
717        || trimmed.contains(['[', ']', '@', ';', '/', '\\'])
718        || trimmed.contains("==")
719        || trimmed.contains("://")
720        || trimmed.contains("git+")
721    {
722        return false;
723    }
724
725    trimmed.chars().all(|ch| ch.is_ascii_hexdigit())
726}
727
728fn split_hash_options(input: &str) -> (String, Vec<String>) {
729    let mut filtered = Vec::new();
730    let mut hashes = Vec::new();
731
732    for token in input.split_whitespace() {
733        if let Some(value) = token.strip_prefix("--hash=") {
734            if !value.is_empty() {
735                hashes.push(value.to_string());
736            }
737        } else {
738            filtered.push(token);
739        }
740    }
741
742    (filtered.join(" "), hashes)
743}
744
745struct ParsedRequirement {
746    name: Option<String>,
747    specifiers: Option<String>,
748    marker: Option<String>,
749    link: Option<String>,
750    is_url: Option<bool>,
751    is_vcs_url: Option<bool>,
752    is_local_path: Option<bool>,
753    is_name_at_url: bool,
754    is_archive: Option<bool>,
755    is_wheel: bool,
756}
757
758fn parse_requirement(input: &str) -> ParsedRequirement {
759    if let Some(parsed) = parse_pep508_requirement(input) {
760        if let Some(url) = parsed.url.clone() {
761            return parsed_with_link(parsed, &url);
762        }
763
764        if !is_link_like(input) {
765            let name = Some(normalize_pypi_name(&parsed.name));
766            return ParsedRequirement {
767                name,
768                specifiers: parsed.specifiers.map(truncate_field),
769                marker: parsed.marker.map(truncate_field),
770                link: None,
771                is_url: None,
772                is_vcs_url: None,
773                is_local_path: None,
774                is_name_at_url: false,
775                is_archive: None,
776                is_wheel: false,
777            };
778        }
779    }
780
781    if let Some((name, link)) = parse_link_with_name(input) {
782        let normalized_name = normalize_pypi_name(&name);
783        let link_info = parse_link_flags(&link);
784        return ParsedRequirement {
785            name: Some(normalized_name),
786            specifiers: None,
787            marker: None,
788            link: Some(truncate_field(link)),
789            is_url: Some(link_info.is_url),
790            is_vcs_url: Some(link_info.is_vcs_url),
791            is_local_path: Some(link_info.is_local_path),
792            is_name_at_url: link_info.is_name_at_url,
793            is_archive: link_info.is_archive,
794            is_wheel: link_info.is_wheel,
795        };
796    }
797
798    let link_info = parse_link_flags(input);
799    ParsedRequirement {
800        name: None,
801        specifiers: None,
802        marker: None,
803        link: Some(truncate_field(input.to_string())),
804        is_url: Some(link_info.is_url),
805        is_vcs_url: Some(link_info.is_vcs_url),
806        is_local_path: Some(link_info.is_local_path),
807        is_name_at_url: link_info.is_name_at_url,
808        is_archive: link_info.is_archive,
809        is_wheel: link_info.is_wheel,
810    }
811}
812
813fn parsed_with_link(parsed: Pep508Requirement, link: &str) -> ParsedRequirement {
814    let name = normalize_pypi_name(&parsed.name);
815    let link_info = parse_link_flags(link);
816    ParsedRequirement {
817        name: Some(name),
818        specifiers: parsed.specifiers.map(truncate_field),
819        marker: parsed.marker.map(truncate_field),
820        link: Some(truncate_field(link.to_string())),
821        is_url: Some(link_info.is_url),
822        is_vcs_url: Some(link_info.is_vcs_url),
823        is_local_path: Some(link_info.is_local_path),
824        is_name_at_url: parsed.is_name_at_url,
825        is_archive: link_info.is_archive,
826        is_wheel: link_info.is_wheel,
827    }
828}
829
830fn parse_link_with_name(input: &str) -> Option<(String, String)> {
831    if let Some(egg) = extract_egg_name(input) {
832        return Some((egg, input.to_string()));
833    }
834    None
835}
836
837fn extract_egg_name(input: &str) -> Option<String> {
838    let fragment = input.split('#').nth(1)?;
839    let egg_part = fragment.strip_prefix("egg=")?;
840    let name_part = egg_part.split('&').next()?.trim();
841    if name_part.is_empty() {
842        return None;
843    }
844    let (name, _extras, _) = parse_pep508_requirement(name_part)
845        .map(|parsed| (parsed.name, parsed.extras, parsed.specifiers))
846        .unwrap_or_else(|| (name_part.to_string(), Vec::new(), None));
847    Some(name)
848}
849
850struct LinkFlags {
851    is_url: bool,
852    is_vcs_url: bool,
853    is_local_path: bool,
854    is_name_at_url: bool,
855    is_archive: Option<bool>,
856    is_wheel: bool,
857}
858
859fn parse_link_flags(link: &str) -> LinkFlags {
860    let trimmed = link.trim();
861    let is_vcs_url = trimmed.starts_with("git+")
862        || trimmed.starts_with("hg+")
863        || trimmed.starts_with("svn+")
864        || trimmed.starts_with("bzr+");
865    let has_scheme = trimmed.contains("://") || trimmed.starts_with("file:");
866    let is_local_path = trimmed.starts_with("./")
867        || trimmed.starts_with("../")
868        || trimmed.starts_with('/')
869        || trimmed.starts_with('~')
870        || trimmed.starts_with("file:");
871
872    let is_wheel = trimmed.ends_with(".whl");
873    let is_archive = if is_wheel
874        || trimmed.ends_with(".zip")
875        || trimmed.ends_with(".tar.gz")
876        || trimmed.ends_with(".tgz")
877        || trimmed.ends_with(".tar.bz2")
878        || trimmed.ends_with(".tar")
879    {
880        Some(true)
881    } else if has_scheme || is_local_path {
882        Some(false)
883    } else {
884        None
885    };
886
887    LinkFlags {
888        is_url: has_scheme || is_vcs_url,
889        is_vcs_url,
890        is_local_path,
891        is_name_at_url: false,
892        is_archive,
893        is_wheel,
894    }
895}
896
897fn is_link_like(input: &str) -> bool {
898    let trimmed = input.trim();
899    trimmed.starts_with("git+")
900        || trimmed.starts_with("hg+")
901        || trimmed.starts_with("svn+")
902        || trimmed.starts_with("bzr+")
903        || trimmed.starts_with("file:")
904        || trimmed.contains("://")
905        || trimmed.starts_with("./")
906        || trimmed.starts_with("../")
907        || trimmed.starts_with('/')
908        || trimmed.starts_with('~')
909}
910
911fn extract_pinned_version(specifiers: &str) -> Option<String> {
912    let trimmed = specifiers.trim();
913    if trimmed.contains(',') {
914        return None;
915    }
916
917    let stripped = if let Some(version) = trimmed.strip_prefix("===") {
918        version
919    } else {
920        trimmed.strip_prefix("==")?
921    };
922
923    let version = stripped.trim();
924    if version.is_empty() {
925        None
926    } else {
927        Some(version.to_string())
928    }
929}
930
931fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
932    // `PackageUrl::new` validates the *type* only — it accepts any name whatsoever
933    // — and the string below is assembled by hand rather than through the crate's
934    // encoder, so an unchecked name lands in the PURL verbatim and produces a
935    // string no PURL parser accepts. Names arriving from the `#egg=` fragment path
936    // never went through the PEP 508 grammar, so check here as well.
937    if !is_valid_distribution_name(name) {
938        return None;
939    }
940
941    // Safe to assemble by hand: `is_valid_distribution_name` above restricts the
942    // name to the PEP 508 charset, which needs no percent-encoding, and the
943    // version is encoded by `encode_pypi_purl_version`.
944    PackageUrl::new(RequirementsTxtParser::PACKAGE_TYPE.as_str(), name)
945        .ok()
946        .map(|_| match version {
947            Some(version) => format!("pkg:pypi/{name}@{}", encode_pypi_purl_version(version)),
948            None => format!("pkg:pypi/{name}"),
949        })
950}
951
952fn encode_pypi_purl_version(version: &str) -> String {
953    version.replace('*', "%2A")
954}
955
956fn normalize_pypi_name(name: &str) -> String {
957    let lower = name.trim().to_ascii_lowercase();
958    let mut normalized = String::new();
959    let mut last_was_sep = false;
960    for ch in lower.chars() {
961        let is_sep = matches!(ch, '-' | '_' | '.');
962        if is_sep {
963            if !last_was_sep {
964                normalized.push('-');
965                last_was_sep = true;
966            }
967        } else {
968            normalized.push(ch);
969            last_was_sep = false;
970        }
971    }
972    normalized
973}