1use super::{ParseResult, PypiParser, RequirementRef};
15use crate::config::PypiIndexConfig;
16use crate::error::Result;
17use crate::types::{PypiDependencySection, PypiDependencySource};
18use deps_core::lsp_helpers::LineOffsetTable;
19use deps_core::net_policy::RegistryAccessPolicy;
20use tower_lsp_server::ls_types::{Range, Uri};
21
22const KNOWN_OPTIONS: &[&str] = &[
28 "-r",
29 "-c",
30 "-e",
31 "-i",
32 "-f",
33 "--requirement",
34 "--constraint",
35 "--editable",
36 "--index-url",
37 "--extra-index-url",
38 "--find-links",
39 "--trusted-host",
40 "--pre",
41 "--no-binary",
42 "--only-binary",
43 "--no-index",
44 "--prefer-binary",
45 "--require-hashes",
46 "--use-feature",
47 "--global-option",
48 "--config-settings",
49 "--hash",
50];
51
52const NAMELESS_URL_PREFIXES: &[&str] = &["http://", "https://", "git+", "file:"];
56
57const NAMELESS_ARCHIVE_SUFFIXES: &[&str] = &[".whl", ".tar.gz", ".tar.bz2", ".tar.xz", ".zip"];
59
60impl PypiParser {
61 pub fn parse_requirements(
117 &self,
118 content: &str,
119 uri: &Uri,
120 require_strong_signal: bool,
121 ) -> Result<ParseResult> {
122 self.parse_requirements_with_policy(
123 content,
124 uri,
125 require_strong_signal,
126 &RegistryAccessPolicy::default(),
127 )
128 }
129
130 pub fn parse_requirements_with_policy(
139 &self,
140 content: &str,
141 uri: &Uri,
142 require_strong_signal: bool,
143 policy: &RegistryAccessPolicy,
144 ) -> Result<ParseResult> {
145 let config = collect_index_config(content, policy);
150
151 let line_table = LineOffsetTable::new(content);
152 let mut dependencies = Vec::new();
153 let mut document_links = Vec::new();
154 let mut strong_signal = false;
155 let mut failed_lines: usize = 0;
156
157 let mut lines = content.lines().enumerate().peekable();
158 while let Some((line_idx, raw_line)) = lines.next() {
159 let Some(mut line_start) = line_table.line_start(line_idx) else {
160 continue;
161 };
162
163 let line = if line_idx == 0 {
167 match raw_line.strip_prefix('\u{feff}') {
168 Some(stripped) => {
169 line_start += raw_line.len() - stripped.len();
170 stripped
171 }
172 None => raw_line,
173 }
174 } else {
175 raw_line
176 };
177
178 let without_comment = strip_comment(line);
185 let trimmed = without_comment.trim();
186 if trimmed.is_empty() {
187 continue;
188 }
189
190 let leading_ws = without_comment.len() - without_comment.trim_start().len();
191 let abs_start = line_start + leading_ws;
192
193 let (text, had_continuation) = match trimmed.strip_suffix('\\') {
201 Some(stripped) => {
202 while let Some((_, next_raw)) = lines.peek() {
203 let continues = strip_comment(next_raw).trim().ends_with('\\');
204 lines.next();
205 if !continues {
206 break;
207 }
208 }
209 (stripped.trim_end(), true)
210 }
211 None => (trimmed, false),
212 };
213
214 if let Some(first_token) = text.split_whitespace().next()
219 && first_token.starts_with('-')
220 {
221 let option_name = first_token.split('=').next().unwrap_or(first_token);
222 if KNOWN_OPTIONS.contains(&option_name) {
223 strong_signal = true;
224 if matches!(option_name, "-r" | "-c" | "--requirement" | "--constraint")
225 && let Some((target, target_offset)) =
226 extract_option_target(first_token, text)
227 {
228 let target_abs_start = abs_start + target_offset;
229 let target_abs_end = target_abs_start + target.len();
230 document_links.push(RequirementRef {
231 range: Range::new(
232 line_table.byte_offset_to_position(content, target_abs_start),
233 line_table.byte_offset_to_position(content, target_abs_end),
234 ),
235 target: target.to_string(),
236 });
237 }
238 } else {
239 failed_lines += 1;
240 }
241 continue;
242 }
243
244 let (req_text, had_hash_option) = split_requirement_options(text);
247 let req_text = req_text.trim_end();
248 if req_text.is_empty() {
249 continue;
250 }
251
252 if is_nameless_requirement(req_text) {
257 continue;
258 }
259
260 let abs_end = abs_start + req_text.len();
261 match self.parse_pep508_requirement(
262 req_text,
263 Some(abs_start..abs_end),
264 content,
265 &line_table,
266 ) {
267 Ok(mut dep) => {
268 dep.section = PypiDependencySection::Requirements;
269 if had_continuation || had_hash_option {
270 dep.version_range = None;
271 }
272 if !strong_signal
279 && (dep.version_req.is_some()
280 || matches!(
281 dep.source,
282 PypiDependencySource::Git { .. } | PypiDependencySource::Url { .. }
283 ))
284 {
285 strong_signal = true;
286 }
287 if dep.source == PypiDependencySource::Registry {
292 dep.source = config.resolve_source_for(None);
293 }
294 dependencies.push(dep);
295 }
296 Err(crate::error::PypiError::RequirementTooLong { len, max }) => {
303 tracing::warn!(
304 "Requirements line too long ({len} bytes, max {max}), skipping: {}",
305 super::truncate_for_log(req_text)
306 );
307 }
308 Err(e) => {
309 tracing::debug!(
310 "Failed to parse requirements line '{}': {e}",
311 super::truncate_for_log(req_text)
312 );
313 failed_lines += 1;
314 }
315 }
316 }
317
318 let keep = strong_signal
319 || (!require_strong_signal
320 && !dependencies.is_empty()
321 && failed_lines < dependencies.len());
322
323 Ok(ParseResult {
324 dependencies: if keep { dependencies } else { Vec::new() },
325 workspace_root: None,
326 uri: uri.clone(),
327 document_links: if keep { document_links } else { Vec::new() },
328 resolved_chains: if keep {
333 config.resolved_chains()
334 } else {
335 Vec::new()
336 },
337 })
338 }
339}
340
341fn collect_index_config(content: &str, policy: &RegistryAccessPolicy) -> PypiIndexConfig {
349 let mut config = PypiIndexConfig::new();
350
351 for (line_idx, raw_line) in content.lines().enumerate() {
352 let line = if line_idx == 0 {
360 raw_line.strip_prefix('\u{feff}').unwrap_or(raw_line)
361 } else {
362 raw_line
363 };
364 let without_comment = strip_comment(line);
365 let trimmed = without_comment.trim();
366 if trimmed.is_empty() {
367 continue;
368 }
369
370 let Some(first_token) = trimmed.split_whitespace().next() else {
371 continue;
372 };
373 if !first_token.starts_with('-') {
374 continue;
375 }
376
377 let option_name = first_token.split('=').next().unwrap_or(first_token);
378 if !matches!(option_name, "--index-url" | "-i" | "--extra-index-url") {
379 continue;
380 }
381
382 let Some((target, _offset)) = extract_option_target(first_token, trimmed) else {
383 continue;
384 };
385
386 match option_name {
387 "--index-url" | "-i" => config.set_primary(target, policy),
388 "--extra-index-url" => config.add_extra(target, policy),
389 _ => unreachable!("matched above"),
390 }
391 }
392
393 config
394}
395
396fn extract_option_target<'a>(first_token: &str, text: &'a str) -> Option<(&'a str, usize)> {
403 if let Some(eq_idx) = first_token.find('=') {
404 let after_eq = &text[eq_idx + 1..];
405 let value_end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
411 let target = &after_eq[..value_end];
412 return (!target.is_empty()).then_some((target, eq_idx + 1));
413 }
414
415 let rest = &text[first_token.len()..];
416 let leading_ws = rest.len() - rest.trim_start().len();
417 let after_ws = &rest[leading_ws..];
418 let value_end = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
422 let target = &after_ws[..value_end];
423 (!target.is_empty()).then_some((target, first_token.len() + leading_ws))
424}
425
426fn strip_comment(line: &str) -> &str {
429 let bytes = line.as_bytes();
430 for (i, &b) in bytes.iter().enumerate() {
431 if b == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
432 return &line[..i];
433 }
434 }
435 line
436}
437
438fn split_requirement_options(text: &str) -> (&str, bool) {
443 let had_hash = text
444 .split_whitespace()
445 .any(|tok| tok == "--hash" || tok.starts_with("--hash="));
446
447 for token in text.split_whitespace() {
448 if token.starts_with("--") {
449 let offset = token.as_ptr() as usize - text.as_ptr() as usize;
452 return (text[..offset].trim_end(), had_hash);
453 }
454 }
455
456 (text, had_hash)
457}
458
459fn is_nameless_requirement(text: &str) -> bool {
464 if NAMELESS_URL_PREFIXES.iter().any(|p| text.starts_with(p)) {
465 return true;
466 }
467 if text == "." || text.starts_with("./") || text.starts_with("../") || text.starts_with('/') {
468 return true;
469 }
470 !text.contains('@') && NAMELESS_ARCHIVE_SUFFIXES.iter().any(|s| text.ends_with(s))
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 use std::assert_matches;
478
479 fn test_uri() -> Uri {
480 deps_core::test_util::test_uri("/test/requirements.txt")
481 }
482
483 fn parse(content: &str) -> ParseResult {
484 PypiParser::new()
485 .parse_requirements(content, &test_uri(), false)
486 .unwrap()
487 }
488
489 fn parse_strict(content: &str) -> ParseResult {
492 PypiParser::new()
493 .parse_requirements(content, &test_uri(), true)
494 .unwrap()
495 }
496
497 fn all_policy() -> RegistryAccessPolicy {
498 RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::All)
499 }
500
501 fn parse_with_policy(content: &str, policy: &RegistryAccessPolicy) -> ParseResult {
504 PypiParser::new()
505 .parse_requirements_with_policy(content, &test_uri(), false, policy)
506 .unwrap()
507 }
508
509 #[test]
512 fn test_basic_pinned() {
513 let result = parse("requests==2.31.0\n");
514 assert_eq!(result.dependencies.len(), 1);
515 assert_eq!(result.dependencies[0].name, "requests");
516 assert_eq!(
517 result.dependencies[0]
518 .version_req
519 .as_ref()
520 .map(deps_core::VersionReq::as_str),
521 Some("==2.31.0")
522 );
523 }
524
525 #[test]
526 fn test_basic_range() {
527 let result = parse("flask>=3.0,<4\n");
528 assert_eq!(result.dependencies.len(), 1);
529 assert_eq!(result.dependencies[0].name, "flask");
530 }
531
532 #[test]
533 fn test_bare_name_no_specifier() {
534 let result = parse("requests==1.0.0\nflask\n");
535 let flask = result
536 .dependencies
537 .iter()
538 .find(|d| d.name == "flask")
539 .unwrap();
540 assert_eq!(flask.version_req, None);
541 }
542
543 #[test]
544 fn test_extras() {
545 let result = parse("requests==1.0.0\nflask[async,dotenv]>=3.0\n");
546 let flask = result
547 .dependencies
548 .iter()
549 .find(|d| d.name == "flask")
550 .unwrap();
551 assert_eq!(flask.extras, vec!["async", "dotenv"]);
552 }
553
554 #[test]
555 fn test_tilde_equal() {
556 let result = parse("numpy ~= 1.24\n");
557 assert_eq!(result.dependencies.len(), 1);
558 assert_eq!(result.dependencies[0].name, "numpy");
559 }
560
561 #[test]
562 fn test_spaces_around_operator() {
563 let result = parse("requests == 2.31.0\n");
564 assert_eq!(result.dependencies.len(), 1);
565 assert_eq!(result.dependencies[0].name, "requests");
566 }
567
568 fn slice(content: &str, range: tower_lsp_server::ls_types::Range) -> String {
571 let table = LineOffsetTable::new(content);
572 let start = table.position_to_byte_offset(content, range.start);
573 let end = table.position_to_byte_offset(content, range.end);
574 content[start..end].to_string()
575 }
576
577 #[test]
578 fn test_spaced_extras_version_range() {
579 let content = "flask[async, dotenv]>=3.0\n";
580 let result = parse(content);
581 let dep = &result.dependencies[0];
582 let version_range = dep.version_range.expect("version_range should be set");
583 assert_eq!(slice(content, version_range), ">=3.0");
584 }
585
586 #[test]
587 fn test_spaced_extras_and_spaced_operator_version_range() {
588 let content = "flask [async] >= 3.0\n";
589 let result = parse(content);
590 let dep = &result.dependencies[0];
591 let version_range = dep.version_range.expect("version_range should be set");
592 assert_eq!(slice(content, version_range), ">= 3.0");
593 }
594
595 #[test]
596 fn test_dotted_source_name_version_range() {
597 let content = "my__pkg==1.0\n";
598 let result = parse(content);
599 let dep = &result.dependencies[0];
600 let version_range = dep.version_range.expect("version_range should be set");
601 assert_eq!(slice(content, version_range), "==1.0");
602 }
603
604 #[test]
607 fn test_indented_line_position() {
608 let content = " requests==2.0\n";
609 let result = parse(content);
610 let dep = &result.dependencies[0];
611 assert_eq!(dep.name_range.start.character, 2);
612 }
613
614 #[test]
615 fn test_position_after_non_ascii_comment() {
616 let content = "# héllo wörld\nrequests==2.0\n";
617 let result = parse(content);
618 let dep = &result.dependencies[0];
619 assert_eq!(dep.name_range.start.line, 1);
620 assert_eq!(dep.name_range.start.character, 0);
621 }
622
623 #[test]
624 fn test_last_line_no_trailing_newline() {
625 let content = "requests==2.0\nflask==1.0";
626 let result = parse(content);
627 assert_eq!(result.dependencies.len(), 2);
628 let flask = result
629 .dependencies
630 .iter()
631 .find(|d| d.name == "flask")
632 .unwrap();
633 assert_eq!(flask.name_range.start.line, 1);
634 }
635
636 #[test]
639 fn test_crlf_line_endings_positions_correct() {
640 let content = "requests==1.0\r\nflask==2.0\r\nnumpy==3.0\r\n";
641 let result = parse(content);
642 assert_eq!(result.dependencies.len(), 3);
643 let numpy = result
644 .dependencies
645 .iter()
646 .find(|d| d.name == "numpy")
647 .unwrap();
648 assert_eq!(numpy.name_range.start.line, 2);
649 assert_eq!(numpy.name_range.start.character, 0);
650 }
651
652 #[test]
655 fn test_bom_on_first_line() {
656 let content = "\u{feff}requests==2.0\nflask==1.0\n";
657 let result = parse(content);
658 assert_eq!(result.dependencies.len(), 2);
659 let requests = result
660 .dependencies
661 .iter()
662 .find(|d| d.name == "requests")
663 .unwrap();
664 assert_eq!(requests.name_range.start.line, 0);
665 assert_eq!(requests.name_range.start.character, 1);
668 let flask = result
669 .dependencies
670 .iter()
671 .find(|d| d.name == "flask")
672 .unwrap();
673 assert_eq!(flask.name_range.start.line, 1);
674 assert_eq!(flask.name_range.start.character, 0);
675 }
676
677 #[test]
683 fn test_full_line_comment() {
684 let result = parse("# just a comment\nrequests==1.0\n");
685 assert_eq!(result.dependencies.len(), 1);
686 }
687
688 #[test]
689 fn test_trailing_comment() {
690 let result = parse("requests==1.0 # pinned for compat\n");
691 assert_eq!(result.dependencies.len(), 1);
692 assert_eq!(result.dependencies[0].name, "requests");
693 }
694
695 #[test]
696 fn test_hash_in_url_fragment_not_cut() {
697 let content = "mylib @ https://example.com/mylib.tar.gz#egg=mylib\n";
700 let result = parse(content);
701 assert_eq!(result.dependencies.len(), 1);
702 assert_eq!(result.dependencies[0].name, "mylib");
703 }
704
705 #[test]
706 fn test_hash_with_no_preceding_whitespace_not_cut() {
707 let content = "requests==1.0#nospace\n";
708 let without_comment = strip_comment(content.trim_end());
712 assert_eq!(without_comment, "requests==1.0#nospace");
713 }
714
715 #[test]
716 fn test_quoted_marker_hash_mis_cut_matches_pip() {
717 let content = "pkg==1.0; extra == \"a #b\"\n";
721 let without_comment = strip_comment(content.trim_end());
722 assert_eq!(without_comment, "pkg==1.0; extra == \"a ");
723 }
724
725 #[test]
728 fn test_marker_on_line() {
729 let result = parse("pkg==1.0; python_version < \"3.9\"\n");
730 assert_eq!(result.dependencies.len(), 1);
731 assert!(result.dependencies[0].markers.is_some());
732 }
733
734 #[test]
735 fn test_compound_marker_on_line() {
736 let content = "pkg==1.0; sys_platform == 'win32' and python_version >= '3.8'\n";
737 let result = parse(content);
738 assert_eq!(result.dependencies.len(), 1);
739 let markers = result.dependencies[0].markers.as_ref().unwrap();
740 assert!(markers.contains("sys_platform"));
741 }
742
743 #[test]
744 fn test_oversized_marker_on_line_skips_normalization() {
745 let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
746 assert!(long_marker.len() > super::super::MAX_MARKER_LEN);
747 let content = format!("pkg==1.0; {long_marker}\n");
748 let result = parse(&content);
749 assert_eq!(result.dependencies.len(), 1);
750 assert_eq!(result.dependencies[0].markers, Some(long_marker));
751 }
752
753 #[test]
754 fn test_deeply_nested_marker_on_line_skips_normalization() {
755 let depth = 1000;
756 let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
757 assert!(nested_marker.len() < super::super::MAX_MARKER_LEN);
758 let content = format!("pkg==1.0; {nested_marker}\n");
759 let result = parse(&content);
760 assert_eq!(result.dependencies.len(), 1);
761 assert_eq!(result.dependencies[0].markers, Some(nested_marker));
762 }
763
764 #[test]
767 fn test_oversized_extras_list_rejected_fast() {
768 let huge_extras = "a,".repeat(500_000); let oversized_requirement = format!("pkg[{huge_extras}]==1.0");
775 assert!(oversized_requirement.len() > super::super::MAX_REQUIREMENT_LEN);
776 let content = format!("{oversized_requirement}\ngood-pkg==2.0\n");
777
778 let start = std::time::Instant::now();
779 let result = parse(&content);
780 let elapsed = start.elapsed();
781
782 assert!(
783 elapsed < std::time::Duration::from_secs(2),
784 "oversized extras line took too long to reject: {elapsed:?}"
785 );
786 assert_eq!(result.dependencies.len(), 1);
789 assert_eq!(result.dependencies[0].name, "good-pkg");
790 }
791
792 #[test]
793 fn test_oversized_line_rejection_does_not_count_as_failed_line() {
794 let huge_extras = "a,".repeat(500_000);
802 let oversized = format!("pkg[{huge_extras}]==1.0");
803 assert!(oversized.len() > super::super::MAX_REQUIREMENT_LEN);
804 let content = format!("{oversized}\n{oversized}\n{oversized}\nrequests\nflask\n");
805
806 let result = parse(&content);
807
808 let mut names: Vec<&str> = result
809 .dependencies
810 .iter()
811 .map(|d| d.name.as_str())
812 .collect();
813 names.sort_unstable();
814 assert_eq!(names, vec!["flask", "requests"]);
815 }
816
817 #[test]
818 fn test_requirement_length_boundary() {
819 let build = |total_len: usize| {
824 let fixed = "pkg[]==1.0".len();
825 format!("pkg[{}]==1.0", "a".repeat(total_len - fixed))
826 };
827 let max = super::super::MAX_REQUIREMENT_LEN;
828
829 let at_cap = build(max);
830 assert_eq!(at_cap.len(), max);
831 let result = parse(&format!("{at_cap}\n"));
832 assert_eq!(
833 result.dependencies.len(),
834 1,
835 "a requirement exactly at the cap must be accepted"
836 );
837
838 let over_cap = build(max + 1);
839 assert_eq!(over_cap.len(), max + 1);
840 let result = parse(&format!("{over_cap}\n"));
841 assert_eq!(
842 result.dependencies.len(),
843 0,
844 "a requirement one byte over the cap must be rejected"
845 );
846 }
847
848 #[test]
849 fn test_marker_extras_bracket_injection_rejected() {
850 let huge_extras = "a".repeat(60_000);
854 let content = format!("pkg;[{huge_extras}]==1.0\n");
855 let result = parse(&content);
856 assert_eq!(result.dependencies.len(), 1);
857 assert_eq!(result.dependencies[0].name, "pkg");
858 assert_eq!(result.dependencies[0].markers, None);
859 }
860
861 #[test]
862 fn test_marker_keyword_repeated_without_separators_rejected() {
863 let garbage = "extra".repeat(1600);
868 assert!(garbage.len() > super::super::MAX_MARKER_LEN);
869 let content = format!("pkg; {garbage}\n");
870 let result = parse(&content);
871 assert_eq!(result.dependencies.len(), 1);
872 assert_eq!(result.dependencies[0].name, "pkg");
873 assert_eq!(result.dependencies[0].markers, None);
874 }
875
876 #[test]
877 fn test_marker_keyword_padded_with_unquoted_garbage_rejected() {
878 let filler = "A".repeat(5000);
884 let raw_marker = format!("python_version <{filler}>");
885 assert!(raw_marker.len() > super::super::MAX_MARKER_LEN);
886 let content = format!("pkg; {raw_marker}\n");
887 let result = parse(&content);
888 assert_eq!(result.dependencies.len(), 1);
889 assert_eq!(result.dependencies[0].name, "pkg");
890 assert_eq!(result.dependencies[0].markers, None);
891 }
892
893 #[test]
894 fn test_marker_repeated_token_no_operator_rejected() {
895 let garbage = "python_version ".repeat(500);
901 let content = format!("pkg; {garbage}\n");
902 let result = parse(&content);
903 assert_eq!(result.dependencies.len(), 1);
904 assert_eq!(result.dependencies[0].name, "pkg");
905 assert_eq!(result.dependencies[0].markers, None);
906 }
907
908 #[test]
909 fn test_marker_and_joined_repeated_token_no_operator_rejected() {
910 let garbage = "python_version and ".repeat(400) + "python_version";
913 let content = format!("pkg; {garbage}\n");
914 let result = parse(&content);
915 assert_eq!(result.dependencies.len(), 1);
916 assert_eq!(result.dependencies[0].name, "pkg");
917 assert_eq!(result.dependencies[0].markers, None);
918 }
919
920 #[test]
921 fn test_marker_chained_comparison_rejected() {
922 let chain = "python_version==".repeat(500) + "python_version";
928 assert!(chain.len() > super::super::MAX_MARKER_LEN);
929 let content = format!("pkg; {chain}\n");
930 let result = parse(&content);
931 assert_eq!(result.dependencies.len(), 1);
932 assert_eq!(result.dependencies[0].name, "pkg");
933 assert_eq!(result.dependencies[0].markers, None);
934 }
935
936 #[test]
937 fn test_marker_chained_in_rejected() {
938 let chain = "python_version in ".repeat(500) + "python_version";
940 assert!(chain.len() > super::super::MAX_MARKER_LEN);
941 let content = format!("pkg; {chain}\n");
942 let result = parse(&content);
943 assert_eq!(result.dependencies.len(), 1);
944 assert_eq!(result.dependencies[0].name, "pkg");
945 assert_eq!(result.dependencies[0].markers, None);
946 }
947
948 #[test]
949 fn test_oversized_in_operator_marker_still_normalizes() {
950 let marker =
954 "python_version in '3.8'".to_string() + &" or python_version in '3.8'".repeat(200);
955 assert!(marker.len() > super::super::MAX_MARKER_LEN);
956 let content = format!("pkg; {marker}\n");
957 let result = parse(&content);
958 assert_eq!(result.dependencies.len(), 1);
959 assert_eq!(result.dependencies[0].name, "pkg");
960 assert_eq!(result.dependencies[0].markers, Some(marker));
961 }
962
963 #[test]
966 fn test_option_lines_skipped_not_failures() {
967 let content = "-r base.txt\n--requirement base.txt\n-c constraints.txt\n-e .\n-e git+https://example.com/pkg#egg=pkg\n--index-url https://example.com\n--extra-index-url https://example.com\n--find-links ./wheels\n--pre\nrequests==1.0\n";
968 let result = parse(content);
969 assert_eq!(result.dependencies.len(), 1);
970 assert_eq!(result.dependencies[0].name, "requests");
971 }
972
973 #[test]
974 fn test_option_lines_equals_form_recognized() {
975 let content = "--index-url=https://internal.example/simple\nrequests\n";
979 let result = parse(content);
980 assert_eq!(result.dependencies.len(), 1);
981 assert_eq!(result.dependencies[0].name, "requests");
982 }
983
984 #[test]
987 fn test_document_links_short_form() {
988 let content = "-r other-requirements.txt\n-c constraints.txt\nrequests==1.0\n";
989 let result = parse(content);
990 assert_eq!(result.document_links.len(), 2);
991 assert_eq!(result.document_links[0].target, "other-requirements.txt");
992 assert_eq!(result.document_links[1].target, "constraints.txt");
993 }
994
995 #[test]
996 fn test_document_links_long_form_space_separated() {
997 let content = "--requirement base.txt\n--constraint constraints.txt\n";
998 let result = parse(content);
999 assert_eq!(result.document_links.len(), 2);
1000 assert_eq!(result.document_links[0].target, "base.txt");
1001 assert_eq!(result.document_links[1].target, "constraints.txt");
1002 }
1003
1004 #[test]
1005 fn test_document_links_long_form_equals_separated() {
1006 let content = "--requirement=base.txt\n--constraint=constraints.txt\n";
1007 let result = parse(content);
1008 assert_eq!(result.document_links.len(), 2);
1009 assert_eq!(result.document_links[0].target, "base.txt");
1010 assert_eq!(result.document_links[1].target, "constraints.txt");
1011 }
1012
1013 #[test]
1014 fn test_document_links_range_slices_to_target_text_only() {
1015 let content = "-r other-requirements.txt\n";
1016 let result = parse(content);
1017 let link = &result.document_links[0];
1018 assert_eq!(slice(content, link.range), "other-requirements.txt");
1019 }
1020
1021 #[test]
1022 fn test_document_links_ignores_unrelated_options() {
1023 let content = "-e .\n--index-url https://example.com\n--pre\nrequests==1.0\n";
1026 let result = parse(content);
1027 assert!(result.document_links.is_empty());
1028 }
1029
1030 #[test]
1031 fn test_document_links_bare_option_with_no_target_is_skipped() {
1032 let content = "-r\nrequests==1.0\n";
1033 let result = parse(content);
1034 assert!(result.document_links.is_empty());
1035 }
1036
1037 #[test]
1040 fn test_pip_compile_continuation_with_hashes() {
1041 let content =
1042 "pkg==1.0 \\\n --hash=sha256:aaaa \\\n --hash=sha256:bbbb\nother==2.0\n";
1043 let result = parse(content);
1044 assert_eq!(result.dependencies.len(), 2);
1045 let pkg = result
1046 .dependencies
1047 .iter()
1048 .find(|d| d.name == "pkg")
1049 .unwrap();
1050 assert_eq!(pkg.version_range, None);
1051 let other = result
1052 .dependencies
1053 .iter()
1054 .find(|d| d.name == "other")
1055 .unwrap();
1056 assert!(other.version_range.is_some());
1057 }
1058
1059 #[test]
1060 fn test_inline_hash_single_line_nulls_version_range() {
1061 let content = "pkg==1.0 --hash=sha256:aaaa\n";
1062 let result = parse(content);
1063 assert_eq!(result.dependencies.len(), 1);
1064 assert_eq!(result.dependencies[0].version_range, None);
1065 }
1066
1067 #[test]
1068 fn test_non_hash_continuation_still_nulls_version_range() {
1069 let content = "pkg \\\n ==1.0\n";
1070 let result = parse(content);
1071 assert_eq!(result.dependencies.len(), 1);
1072 assert_eq!(result.dependencies[0].version_range, None);
1073 }
1074
1075 #[test]
1078 fn test_skipped_bare_url() {
1079 let result = parse("https://example.com/pkg.whl\nrequests==1.0\n");
1080 assert_eq!(result.dependencies.len(), 1);
1081 assert_eq!(result.dependencies[0].name, "requests");
1082 }
1083
1084 #[test]
1085 fn test_skipped_local_paths() {
1086 let content = "./local/pkg\n/abs/path\n../rel\n.\nrequests==1.0\n";
1087 let result = parse(content);
1088 assert_eq!(result.dependencies.len(), 1);
1089 assert_eq!(result.dependencies[0].name, "requests");
1090 }
1091
1092 #[test]
1093 fn test_named_direct_reference_kept() {
1094 let content = "mylib @ https://example.com/mylib.tar.gz\n";
1095 let result = parse(content);
1096 assert_eq!(result.dependencies.len(), 1);
1097 assert_eq!(result.dependencies[0].name, "mylib");
1098 assert_matches!(
1099 result.dependencies[0].source,
1100 PypiDependencySource::Url { .. }
1101 );
1102 assert_eq!(result.dependencies[0].version_req, None);
1103 }
1104
1105 #[test]
1108 fn test_garbage_line_skipped_surrounding_lines_parse() {
1109 let content = "requests==1.0\n>>>> merge conflict\nflask==2.0\n!!!\n[\nnumpy==3.0\n";
1110 let result = parse(content);
1111 let names: Vec<&str> = result
1112 .dependencies
1113 .iter()
1114 .map(|d| d.name.as_str())
1115 .collect();
1116 assert!(names.contains(&"requests"));
1117 assert!(names.contains(&"flask"));
1118 assert!(names.contains(&"numpy"));
1119 }
1120
1121 #[test]
1122 fn test_empty_file() {
1123 let result = parse("");
1124 assert!(result.dependencies.is_empty());
1125 }
1126
1127 #[test]
1128 fn test_file_of_only_comments() {
1129 let result = parse("# one\n# two\n");
1130 assert!(result.dependencies.is_empty());
1131 }
1132
1133 #[test]
1134 fn test_constraints_txt_parses_identically() {
1135 let uri = deps_core::test_util::test_uri("/test/constraints.txt");
1136 let result = PypiParser::new()
1137 .parse_requirements("requests==1.0\nflask==2.0\n", &uri, false)
1138 .unwrap();
1139 assert_eq!(result.dependencies.len(), 2);
1140 assert!(
1141 result
1142 .dependencies
1143 .iter()
1144 .all(|d| matches!(d.section, PypiDependencySection::Requirements))
1145 );
1146 }
1147
1148 #[test]
1151 fn test_gate_drops_prose_file() {
1152 let content = "Product requirements\n\nThe API must be fast.\n- a bullet\n";
1153 let result = parse(content);
1154 assert!(result.dependencies.is_empty());
1155 }
1156
1157 #[test]
1158 fn test_gate_keeps_unpinned_hand_written_file() {
1159 let result = parse("requests\nflask\nnumpy\n");
1162 assert_eq!(result.dependencies.len(), 3);
1163 }
1164
1165 #[test]
1166 fn test_gate_survives_mid_typing_edit() {
1167 let result = parse("requests\nflask\nnumpy\ndjango >\n");
1171 assert_eq!(result.dependencies.len(), 3);
1172 }
1173
1174 #[test]
1175 fn test_gate_bare_less_than_digit_is_strong_signal() {
1176 let result = parse("flask<4\n");
1177 assert_eq!(result.dependencies.len(), 1);
1178 }
1179
1180 #[test]
1181 fn test_gate_email_in_prose_does_not_defeat_gate() {
1182 let content = "Requirements Document\n\nAuthor: jane@example.com\nOverview\n";
1189 let result = parse(content);
1190 assert!(result.dependencies.is_empty());
1191 }
1192
1193 #[test]
1194 fn test_gate_markdown_bullets_are_failures_not_prose_survivors() {
1195 let content = "Requirements\n\n- Fast response\n- Scalable\n";
1199 let result = parse(content);
1200 assert!(result.dependencies.is_empty());
1201 }
1202
1203 #[test]
1204 fn test_gate_option_only_file_no_panic() {
1205 let result = parse("-r base.txt\n");
1206 assert!(result.dependencies.is_empty());
1207 }
1208
1209 #[test]
1212 fn test_strict_gate_drops_prose_that_would_survive_ratio_gate() {
1213 let content = "Introduction\n\nScope\n\nThis document defines the requirements.\n";
1221
1222 let lenient = parse(content);
1223 assert!(
1224 !lenient.dependencies.is_empty(),
1225 "sanity check: the ratio gate alone would keep this file"
1226 );
1227
1228 let strict = parse_strict(content);
1229 assert!(strict.dependencies.is_empty());
1230 }
1231
1232 #[test]
1233 fn test_strict_gate_still_keeps_file_with_real_pip_option() {
1234 let content = "-r base.txt\nrequests==1.0\n";
1237 let result = parse_strict(content);
1238 assert_eq!(result.dependencies.len(), 1);
1239 assert_eq!(result.document_links.len(), 1);
1240 }
1241
1242 #[test]
1243 fn test_strict_gate_still_keeps_file_with_version_specifier() {
1244 let content = "requests==2.31.0\n";
1245 let result = parse_strict(content);
1246 assert_eq!(result.dependencies.len(), 1);
1247 }
1248
1249 #[test]
1252 fn test_index_url_routes_every_dependency() {
1253 let content = "--index-url https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1254 let result = parse_with_policy(content, &all_policy());
1255 assert_eq!(result.dependencies.len(), 1);
1256 assert_matches!(
1257 result.dependencies[0].source,
1258 PypiDependencySource::AlternateRegistry { .. }
1259 );
1260 }
1261
1262 #[test]
1265 fn test_index_url_equals_spelling() {
1266 let content = "--index-url=https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1267 let result = parse_with_policy(content, &all_policy());
1268 assert_matches!(
1269 result.dependencies[0].source,
1270 PypiDependencySource::AlternateRegistry { .. }
1271 );
1272 }
1273
1274 #[test]
1276 fn test_short_dash_i_alias() {
1277 let content = "-i https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1278 let result = parse_with_policy(content, &all_policy());
1279 assert_matches!(
1280 result.dependencies[0].source,
1281 PypiDependencySource::AlternateRegistry { .. }
1282 );
1283 }
1284
1285 #[test]
1288 fn test_index_url_after_dependency_line_still_routes_it() {
1289 let content = "requests==2.31.0\n--index-url https://pypi.mycorp.example/simple\n";
1290 let result = parse_with_policy(content, &all_policy());
1291 assert_eq!(result.dependencies.len(), 1);
1292 assert!(
1293 matches!(
1294 result.dependencies[0].source,
1295 PypiDependencySource::AlternateRegistry { .. }
1296 ),
1297 "dependency declared before a late --index-url must still resolve through it, \
1298 got {:?}",
1299 result.dependencies[0].source
1300 );
1301 }
1302
1303 #[test]
1306 fn test_extra_index_url_alone_routes_through_chain() {
1307 let content = "--extra-index-url https://extra.example/simple\nrequests==2.31.0\n";
1308 let result = parse_with_policy(content, &all_policy());
1309 assert_matches!(
1310 result.dependencies[0].source,
1311 PypiDependencySource::AlternateRegistry { .. }
1312 );
1313 }
1314
1315 #[test]
1318 fn test_invalid_index_url_fails_closed() {
1319 let content = "--index-url not-a-valid-url\nrequests==2.31.0\n";
1320 let result = parse_with_policy(content, &all_policy());
1321 assert_eq!(
1322 result.dependencies[0].source,
1323 PypiDependencySource::CustomRegistry {
1324 url: "not-a-valid-url".to_string(),
1325 }
1326 );
1327 }
1328
1329 #[test]
1332 fn test_no_index_declaration_is_plain_registry() {
1333 let result = parse("requests==2.31.0\nflask>=3.0\n");
1334 assert_eq!(result.dependencies.len(), 2);
1335 for dep in &result.dependencies {
1336 assert_eq!(dep.source, PypiDependencySource::Registry);
1337 }
1338 }
1339
1340 #[test]
1344 fn test_unrelated_known_option_does_not_affect_routing() {
1345 let content = "--pre\nrequests==2.31.0\n";
1346 let result = parse_with_policy(content, &all_policy());
1347 assert_eq!(result.dependencies.len(), 1);
1348 assert_eq!(
1349 result.dependencies[0].source,
1350 PypiDependencySource::Registry
1351 );
1352 }
1353
1354 #[test]
1357 fn test_git_sourced_dependency_untouched_by_index_config() {
1358 let content = "--index-url https://pypi.mycorp.example/simple\nname @ https://example.com/name.tar.gz\n";
1359 let result = parse_with_policy(content, &all_policy());
1360 assert_eq!(result.dependencies.len(), 1);
1361 assert_matches!(
1362 result.dependencies[0].source,
1363 PypiDependencySource::Url { .. }
1364 );
1365 }
1366
1367 #[test]
1371 fn test_index_url_after_bom_on_first_line_still_captured() {
1372 let content = "\u{feff}--index-url https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1373 let result = parse_with_policy(content, &all_policy());
1374 assert_eq!(result.dependencies.len(), 1);
1375 assert!(
1376 matches!(
1377 result.dependencies[0].source,
1378 PypiDependencySource::AlternateRegistry { .. }
1379 ),
1380 "BOM must not defeat --index-url capture, got {:?}",
1381 result.dependencies[0].source
1382 );
1383 }
1384
1385 #[test]
1391 fn test_extract_option_target_space_separated_stops_at_next_option() {
1392 let text =
1393 "--index-url https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example";
1394 let (target, _offset) = extract_option_target("--index-url", text).unwrap();
1395 assert_eq!(target, "https://pypi.mycorp.example/simple");
1396 }
1397
1398 #[test]
1400 fn test_extract_option_target_equals_separated_stops_at_next_option() {
1401 let text =
1402 "--index-url=https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example";
1403 let first_token = text.split_whitespace().next().unwrap();
1404 let (target, _offset) = extract_option_target(first_token, text).unwrap();
1405 assert_eq!(target, "https://pypi.mycorp.example/simple");
1406 }
1407
1408 #[test]
1413 fn test_index_url_multi_option_line_does_not_swallow_trailing_options() {
1414 let content = "--index-url https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example\nrequests==2.31.0\n";
1415 let result = parse_with_policy(content, &all_policy());
1416 assert_eq!(result.dependencies.len(), 1);
1417 assert!(
1418 matches!(
1419 result.dependencies[0].source,
1420 PypiDependencySource::AlternateRegistry { .. }
1421 ),
1422 "a mangled URL would still validate as *some* URL but registers a different \
1423 chain than the clean one — got {:?}",
1424 result.dependencies[0].source
1425 );
1426 }
1427
1428 #[test]
1431 fn test_extra_index_url_equals_multi_option_line_does_not_swallow_trailing_options() {
1432 let content = "--extra-index-url=https://extra.example/simple --trusted-host extra.example\nrequests==2.31.0\n";
1433 let result = parse_with_policy(content, &all_policy());
1434 assert_eq!(result.dependencies.len(), 1);
1435 assert_matches!(
1436 result.dependencies[0].source,
1437 PypiDependencySource::AlternateRegistry { .. }
1438 );
1439 }
1440
1441 #[test]
1444 fn test_extra_index_url_equals_spelling() {
1445 let content = "--extra-index-url=https://extra.example/simple\nrequests==2.31.0\n";
1446 let result = parse_with_policy(content, &all_policy());
1447 assert_matches!(
1448 result.dependencies[0].source,
1449 PypiDependencySource::AlternateRegistry { .. }
1450 );
1451 }
1452}