1use 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
50pub 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 "<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 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
145fn 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
187fn 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
214fn 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
221fn 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 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, §ion) {
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#[derive(Default)]
543struct RequirementSection {
544 extra: Option<String>,
545 marker: Option<String>,
546}
547
548impl RequirementSection {
549 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 fn scope<'a>(&'a self, default_scope: &'a str) -> &'a str {
569 self.extra.as_deref().unwrap_or(default_scope)
570 }
571
572 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 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 if !is_valid_distribution_name(name) {
938 return None;
939 }
940
941 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}