1use super::{ParseResult, PypiParser, normalize_marker_string, span_start, span_to_range};
5use crate::config::PypiIndexConfig;
6use crate::error::Result;
7use crate::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
8use deps_core::lsp_helpers::LineOffsetTable;
9use deps_core::net_policy::RegistryAccessPolicy;
10use std::collections::HashMap;
11use toml_span::value::{Table, Value};
12use tower_lsp_server::ls_types::Uri;
13
14struct IndexContext<'a> {
19 config: &'a PypiIndexConfig,
21 uv_named_by_dep: &'a HashMap<String, String>,
24}
25
26impl IndexContext<'_> {
27 fn resolve(&self, dep: &mut PypiDependency) {
31 if dep.source != PypiDependencySource::Registry {
32 return;
33 }
34 let named = self
35 .uv_named_by_dep
36 .get(dep.name.as_str())
37 .map(String::as_str);
38 dep.source = self.config.resolve_source_for(named);
39 }
40}
41
42impl PypiParser {
43 pub fn parse_content(&self, content: &str, uri: &Uri) -> Result<ParseResult> {
64 self.parse_content_with_policy(content, uri, &RegistryAccessPolicy::default())
65 }
66
67 pub fn parse_content_with_policy(
76 &self,
77 content: &str,
78 uri: &Uri,
79 policy: &RegistryAccessPolicy,
80 ) -> Result<ParseResult> {
81 if let Err(depth) =
82 deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH)
83 {
84 return Err(crate::error::PypiError::TomlParseError {
85 message: format!(
86 "array/table nesting depth {depth} exceeds maximum of {}",
87 deps_core::MAX_TOML_NESTING_DEPTH
88 ),
89 });
90 }
91
92 let doc =
93 toml_span::parse(content).map_err(|e| crate::error::PypiError::TomlParseError {
94 message: e.to_string(),
95 })?;
96
97 let line_table = LineOffsetTable::new(content);
98 let mut dependencies = Vec::new();
99
100 let root_table = match doc.as_table() {
101 Some(t) => t,
102 None => {
103 return Ok(ParseResult {
104 dependencies,
105 workspace_root: None,
106 uri: uri.clone(),
107 document_links: Vec::new(),
108 resolved_chains: Vec::new(),
109 });
110 }
111 };
112
113 let mut config = PypiIndexConfig::new();
119 let mut uv_named_by_dep: HashMap<String, String> = HashMap::new();
120 if let Some(tool_table) = get_table(root_table, "tool") {
121 if let Some(poetry) = get_table(tool_table, "poetry") {
122 collect_poetry_sources(poetry, &mut config, policy);
123 }
124 if let Some(uv) = get_table(tool_table, "uv") {
125 collect_uv_index(uv, &mut config, policy);
126 collect_uv_sources(uv, &mut uv_named_by_dep);
127 }
128 }
129 let ctx = IndexContext {
130 config: &config,
131 uv_named_by_dep: &uv_named_by_dep,
132 };
133
134 if let Some(build_system) = get_table(root_table, "build-system") {
136 dependencies.extend(self.parse_build_system_requires(
137 build_system,
138 content,
139 &line_table,
140 &ctx,
141 )?);
142 }
143
144 if let Some(project) = get_table(root_table, "project") {
146 dependencies.extend(self.parse_pep621_dependencies(
147 project,
148 content,
149 &line_table,
150 &ctx,
151 )?);
152 dependencies.extend(self.parse_pep621_optional_dependencies(
153 project,
154 content,
155 &line_table,
156 &ctx,
157 )?);
158 }
159
160 if let Some(dep_groups) = get_table(root_table, "dependency-groups") {
162 dependencies.extend(self.parse_dependency_groups(
163 dep_groups,
164 content,
165 &line_table,
166 &ctx,
167 )?);
168 }
169
170 if let Some(tool_table) = get_table(root_table, "tool")
172 && let Some(poetry) = get_table(tool_table, "poetry")
173 {
174 dependencies.extend(self.parse_poetry_dependencies(
175 poetry,
176 content,
177 &line_table,
178 &ctx,
179 )?);
180 dependencies.extend(self.parse_poetry_groups(poetry, content, &line_table, &ctx)?);
181 }
182
183 Ok(ParseResult {
184 dependencies,
185 workspace_root: None,
186 uri: uri.clone(),
187 document_links: Vec::new(),
188 resolved_chains: config.resolved_chains(),
189 })
190 }
191
192 fn parse_build_system_requires(
194 &self,
195 build_system: &Table<'_>,
196 content: &str,
197 line_table: &LineOffsetTable,
198 ctx: &IndexContext<'_>,
199 ) -> Result<Vec<PypiDependency>> {
200 let Some(requires_val) = build_system.get("requires") else {
201 return Ok(Vec::new());
202 };
203
204 let Some(requires_array) = requires_val.as_array() else {
205 return Ok(Vec::new());
206 };
207
208 let mut dependencies = Vec::new();
209
210 for value in requires_array {
211 if let Some(dep_str) = value.as_str() {
212 match self.parse_pep508_requirement(
213 dep_str,
214 Some(value.span.start..value.span.end),
215 content,
216 line_table,
217 ) {
218 Ok(mut dep) => {
219 dep.section = PypiDependencySection::BuildSystem;
220 ctx.resolve(&mut dep);
221 dependencies.push(dep);
222 }
223 Err(e) => {
224 tracing::warn!(
225 "Failed to parse build-system require '{}': {}",
226 super::truncate_for_log(dep_str),
227 e
228 );
229 }
230 }
231 }
232 }
233
234 Ok(dependencies)
235 }
236
237 fn parse_pep621_dependencies(
239 &self,
240 project: &Table<'_>,
241 content: &str,
242 line_table: &LineOffsetTable,
243 ctx: &IndexContext<'_>,
244 ) -> Result<Vec<PypiDependency>> {
245 let Some(deps_val) = project.get("dependencies") else {
246 return Ok(Vec::new());
247 };
248
249 let Some(deps_array) = deps_val.as_array() else {
250 return Ok(Vec::new());
251 };
252
253 let mut dependencies = Vec::new();
254
255 for value in deps_array {
256 if let Some(dep_str) = value.as_str() {
257 match self.parse_pep508_requirement(
258 dep_str,
259 Some(value.span.start..value.span.end),
260 content,
261 line_table,
262 ) {
263 Ok(mut dep) => {
264 dep.section = PypiDependencySection::Dependencies;
265 ctx.resolve(&mut dep);
266 dependencies.push(dep);
267 }
268 Err(e) => {
269 tracing::warn!(
270 "Failed to parse dependency '{}': {}",
271 super::truncate_for_log(dep_str),
272 e
273 );
274 }
275 }
276 }
277 }
278
279 Ok(dependencies)
280 }
281
282 fn parse_pep621_optional_dependencies(
284 &self,
285 project: &Table<'_>,
286 content: &str,
287 line_table: &LineOffsetTable,
288 ctx: &IndexContext<'_>,
289 ) -> Result<Vec<PypiDependency>> {
290 let Some(opt_deps_val) = project.get("optional-dependencies") else {
291 return Ok(Vec::new());
292 };
293
294 let Some(opt_deps_table) = opt_deps_val.as_table() else {
295 return Ok(Vec::new());
296 };
297
298 let mut dependencies = Vec::new();
299
300 for (group_key, group_val) in opt_deps_table {
301 if let Some(group_array) = group_val.as_array() {
302 for value in group_array {
303 if let Some(dep_str) = value.as_str() {
304 match self.parse_pep508_requirement(
305 dep_str,
306 Some(value.span.start..value.span.end),
307 content,
308 line_table,
309 ) {
310 Ok(mut dep) => {
311 dep.section = PypiDependencySection::OptionalDependencies {
312 group: group_key.name.to_string(),
313 };
314 ctx.resolve(&mut dep);
315 dependencies.push(dep);
316 }
317 Err(e) => {
318 tracing::warn!(
319 "Failed to parse dependency '{}': {}",
320 super::truncate_for_log(dep_str),
321 e
322 );
323 }
324 }
325 }
326 }
327 }
328 }
329
330 Ok(dependencies)
331 }
332
333 fn parse_dependency_groups(
343 &self,
344 dep_groups: &Table<'_>,
345 content: &str,
346 line_table: &LineOffsetTable,
347 ctx: &IndexContext<'_>,
348 ) -> Result<Vec<PypiDependency>> {
349 let mut dependencies = Vec::new();
350
351 for (group_key, group_val) in dep_groups {
352 if let Some(group_array) = group_val.as_array() {
353 for value in group_array {
354 if let Some(dep_str) = value.as_str() {
355 match self.parse_pep508_requirement(
356 dep_str,
357 Some(value.span.start..value.span.end),
358 content,
359 line_table,
360 ) {
361 Ok(mut dep) => {
362 dep.section = PypiDependencySection::DependencyGroup {
363 group: group_key.name.to_string(),
364 };
365 ctx.resolve(&mut dep);
366 dependencies.push(dep);
367 }
368 Err(e) => {
369 tracing::warn!(
370 "Failed to parse dependency group '{}' item '{}': {}",
371 group_key.name,
372 super::truncate_for_log(dep_str),
373 e
374 );
375 }
376 }
377 }
378 }
379 }
380 }
381
382 Ok(dependencies)
383 }
384
385 fn parse_poetry_dependencies(
387 &self,
388 poetry: &Table<'_>,
389 content: &str,
390 line_table: &LineOffsetTable,
391 ctx: &IndexContext<'_>,
392 ) -> Result<Vec<PypiDependency>> {
393 let Some(deps_val) = poetry.get("dependencies") else {
394 return Ok(Vec::new());
395 };
396
397 let Some(deps_table) = deps_val.as_table() else {
398 return Ok(Vec::new());
399 };
400
401 let mut dependencies = Vec::new();
402
403 for (name_key, value) in deps_table {
404 let name = &name_key.name;
405 if name == "python" {
407 continue;
408 }
409
410 let position = span_start(content, line_table, name_key.span);
411
412 match self.parse_poetry_dependency(
413 name,
414 value,
415 Some(position),
416 content,
417 line_table,
418 ctx,
419 ) {
420 Ok(mut dep) => {
421 dep.section = PypiDependencySection::PoetryDependencies;
422 dependencies.push(dep);
423 }
424 Err(e) => {
425 tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
426 }
427 }
428 }
429
430 Ok(dependencies)
431 }
432
433 fn parse_poetry_groups(
435 &self,
436 poetry: &Table<'_>,
437 content: &str,
438 line_table: &LineOffsetTable,
439 ctx: &IndexContext<'_>,
440 ) -> Result<Vec<PypiDependency>> {
441 let Some(group_val) = poetry.get("group") else {
442 return Ok(Vec::new());
443 };
444
445 let Some(groups_table) = group_val.as_table() else {
446 return Ok(Vec::new());
447 };
448
449 let mut dependencies = Vec::new();
450
451 for (group_name_key, group_val) in groups_table {
452 let group_name = &group_name_key.name;
453 if let Some(group_table) = group_val.as_table()
454 && let Some(deps_val) = group_table.get("dependencies")
455 && let Some(deps_table) = deps_val.as_table()
456 {
457 for (name_key, value) in deps_table {
458 let name = &name_key.name;
459 let position = span_start(content, line_table, name_key.span);
460
461 match self.parse_poetry_dependency(
462 name,
463 value,
464 Some(position),
465 content,
466 line_table,
467 ctx,
468 ) {
469 Ok(mut dep) => {
470 dep.section = PypiDependencySection::PoetryGroup {
471 group: group_name.to_string(),
472 };
473 dependencies.push(dep);
474 }
475 Err(e) => {
476 tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
477 }
478 }
479 }
480 }
481 }
482
483 Ok(dependencies)
484 }
485
486 fn parse_poetry_dependency(
493 &self,
494 name: &str,
495 value: &Value<'_>,
496 base_position: Option<tower_lsp_server::ls_types::Position>,
497 content: &str,
498 line_table: &LineOffsetTable,
499 ctx: &IndexContext<'_>,
500 ) -> Result<PypiDependency> {
501 use tower_lsp_server::ls_types::{Position, Range};
502
503 let name_range = base_position
504 .map(|pos| {
505 Range::new(
506 pos,
507 Position::new(pos.line, pos.character + name.len() as u32),
508 )
509 })
510 .unwrap_or_default();
511
512 if let Some(raw_value) = value.as_str() {
515 let value_span = value.span;
516 let source_slice = &content[value_span.start..value_span.end];
517
518 let (version_str, raw_marker) = match raw_value.find(';') {
519 Some(idx) => (&raw_value[..idx], Some(&raw_value[idx + 1..])),
520 None => (raw_value, None),
521 };
522
523 let source_semicolon = source_slice.find(';');
531 let version_end_byte =
532 value_span.start + source_semicolon.unwrap_or(source_slice.len());
533 let version_range = Some(span_to_range(
534 content,
535 line_table,
536 toml_span::Span::new(value_span.start, version_end_byte),
537 ));
538
539 let (markers, markers_range) = match (raw_marker, source_semicolon) {
540 (Some(marker), Some(src_idx)) => match normalize_marker_string(marker) {
541 Some(normalized) => {
542 let marker_span =
543 toml_span::Span::new(value_span.start + src_idx + 1, value_span.end);
544 (
545 Some(normalized),
546 Some(span_to_range(content, line_table, marker_span)),
547 )
548 }
549 None => (None, None),
550 },
551 _ => (None, None),
552 };
553
554 return Ok(PypiDependency {
558 name: name.into(),
559 name_range,
560 version_req: Some(version_str.trim().into()),
561 version_range,
562 extras: Vec::new(),
563 extras_range: None,
564 markers,
565 markers_range,
566 section: PypiDependencySection::PoetryDependencies,
567 source: ctx.config.resolve_source_for(None),
568 });
569 }
570
571 if let Some(table) = value.as_table() {
573 let version_req = table
574 .get("version")
575 .and_then(|v| v.as_str())
576 .map(String::from);
577 let extras = table
578 .get("extras")
579 .and_then(|e| e.as_array())
580 .map(|arr| {
581 arr.iter()
582 .filter_map(|v| v.as_str().map(String::from))
583 .collect()
584 })
585 .unwrap_or_default();
586
587 let markers_value = table.get("markers").filter(|v| v.as_str().is_some());
588 let markers = markers_value
589 .and_then(|m| m.as_str())
590 .and_then(normalize_marker_string);
591 let markers_range = markers_value
592 .filter(|_| markers.is_some())
593 .map(|v| span_to_range(content, line_table, v.span));
594
595 let source = if table.contains_key("git") {
596 PypiDependencySource::Git {
597 url: table
598 .get("git")
599 .and_then(|g| g.as_str())
600 .unwrap_or("")
601 .to_string(),
602 rev: table.get("rev").and_then(|r| r.as_str()).map(String::from),
603 }
604 } else if table.contains_key("path") {
605 PypiDependencySource::Path {
606 path: table
607 .get("path")
608 .and_then(|p| p.as_str())
609 .unwrap_or("")
610 .to_string(),
611 }
612 } else if table.contains_key("url") {
613 PypiDependencySource::Url {
614 url: table
615 .get("url")
616 .and_then(|u| u.as_str())
617 .unwrap_or("")
618 .to_string(),
619 }
620 } else {
621 let source_name = table.get("source").and_then(|s| s.as_str());
625 ctx.config.resolve_source_for(source_name)
626 };
627
628 return Ok(PypiDependency {
629 name: name.into(),
630 name_range,
631 version_req: version_req.map(Into::into),
632 version_range: None,
633 extras,
634 extras_range: None,
635 markers,
636 markers_range,
637 section: PypiDependencySection::PoetryDependencies,
638 source,
639 });
640 }
641
642 Err(crate::error::PypiError::unsupported_format(format!(
643 "Unsupported Poetry dependency format for '{name}'"
644 )))
645 }
646}
647
648fn get_table<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Table<'a>> {
650 table.get(key)?.as_table()
651}
652
653fn collect_poetry_sources(
662 poetry: &Table<'_>,
663 config: &mut PypiIndexConfig,
664 policy: &RegistryAccessPolicy,
665) {
666 let Some(sources) = poetry.get("source").and_then(Value::as_array) else {
667 return;
668 };
669
670 for entry in sources {
671 let Some(table) = entry.as_table() else {
672 continue;
673 };
674 let Some(name) = table.get("name").and_then(Value::as_str) else {
675 continue;
676 };
677 let Some(url) = table.get("url").and_then(Value::as_str) else {
678 continue;
679 };
680 let priority = table.get("priority").and_then(Value::as_str);
681
682 let resolved = crate::config::resolve_entry(url, policy);
683 config.add_named_source_resolved(name.to_string(), resolved.clone());
684
685 match priority {
686 None | Some("primary" | "default") => config.set_primary_resolved(resolved),
687 Some("supplemental" | "secondary") => config.add_extra_resolved(resolved),
688 Some(_) => {} }
690 }
691}
692
693fn collect_uv_index(uv: &Table<'_>, config: &mut PypiIndexConfig, policy: &RegistryAccessPolicy) {
704 let Some(entries) = uv.get("index").and_then(Value::as_array) else {
705 return;
706 };
707
708 for entry in entries {
709 let Some(table) = entry.as_table() else {
710 continue;
711 };
712 let Some(name) = table.get("name").and_then(Value::as_str) else {
713 continue;
714 };
715 let Some(url) = table.get("url").and_then(Value::as_str) else {
716 continue;
717 };
718 let is_default = table.get("default").and_then(Value::as_bool) == Some(true);
719 let is_explicit = table.get("explicit").and_then(Value::as_bool) == Some(true);
720
721 let resolved = crate::config::resolve_entry(url, policy);
722 config.add_named_source_resolved(crate::name::normalize(name), resolved.clone());
728
729 if is_explicit {
730 } else if is_default {
732 config.set_tail_hop_resolved(resolved);
733 } else {
734 config.add_extra_resolved(resolved);
735 }
736 }
737}
738
739fn collect_uv_sources(uv: &Table<'_>, uv_named_by_dep: &mut HashMap<String, String>) {
746 let Some(sources) = uv.get("sources").and_then(Value::as_table) else {
747 return;
748 };
749
750 for (dep_key, value) in sources {
751 if let Some(table) = value.as_table()
752 && let Some(index_name) = table.get("index").and_then(Value::as_str)
753 {
754 uv_named_by_dep.insert(
763 crate::name::normalize(&dep_key.name),
764 crate::name::normalize(index_name),
765 );
766 }
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::super::{MAX_MARKER_LEN, marker_too_deep};
773 use super::*;
774 use crate::error::PypiError;
775 use std::assert_matches;
776 use tower_lsp_server::ls_types::{Position, Range};
777
778 fn test_uri() -> Uri {
779 deps_core::test_util::test_uri("/test/pyproject.toml")
780 }
781
782 #[test]
783 fn test_parse_content_rejects_excessive_nesting() {
784 let content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
788 let parser = PypiParser::new();
789 let result = parser.parse_content(&content, &test_uri());
790 assert_matches!(result, Err(PypiError::TomlParseError { .. }));
791 }
792
793 #[test]
794 fn test_parse_pep621_dependencies() {
795 let content = r#"
796[project]
797dependencies = [
798 "requests>=2.28.0",
799 "flask[async]>=3.0",
800]
801"#;
802
803 let parser = PypiParser::new();
804 let result = parser.parse_content(content, &test_uri()).unwrap();
805 let deps = &result.dependencies;
806
807 assert_eq!(deps.len(), 2);
808 assert_eq!(deps[0].name, "requests");
809 assert_eq!(
810 deps[0]
811 .version_req
812 .as_ref()
813 .map(deps_core::VersionReq::as_str),
814 Some(">=2.28.0")
815 );
816 assert_matches!(deps[0].section, PypiDependencySection::Dependencies);
817
818 assert_eq!(deps[1].name, "flask");
819 assert_eq!(deps[1].extras, vec!["async"]);
820 }
821
822 #[test]
823 fn test_parse_pep621_optional_dependencies() {
824 let content = r#"
825[project.optional-dependencies]
826dev = ["pytest>=7.0", "mypy>=1.0"]
827docs = ["sphinx>=5.0"]
828"#;
829
830 let parser = PypiParser::new();
831 let result = parser.parse_content(content, &test_uri()).unwrap();
832 let deps = &result.dependencies;
833
834 assert_eq!(deps.len(), 3);
835
836 let dev_deps: Vec<_> = deps.iter().filter(|d| {
837 matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "dev")
838 }).collect();
839 assert_eq!(dev_deps.len(), 2);
840
841 let docs_deps: Vec<_> = deps.iter().filter(|d| {
842 matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "docs")
843 }).collect();
844 assert_eq!(docs_deps.len(), 1);
845 }
846
847 #[test]
848 fn test_parse_poetry_dependencies() {
849 let content = r#"
850[tool.poetry.dependencies]
851python = "^3.9"
852requests = "^2.28.0"
853"#;
854
855 let parser = PypiParser::new();
856 let result = parser.parse_content(content, &test_uri()).unwrap();
857 let deps = &result.dependencies;
858
859 assert_eq!(deps.len(), 1);
861 assert_eq!(deps[0].name, "requests");
862 assert_matches!(deps[0].section, PypiDependencySection::PoetryDependencies);
863 }
864
865 #[test]
866 fn test_parse_poetry_groups() {
867 let content = r#"
868[tool.poetry.group.dev.dependencies]
869pytest = "^7.0"
870mypy = "^1.0"
871
872[tool.poetry.group.docs.dependencies]
873sphinx = "^5.0"
874"#;
875
876 let parser = PypiParser::new();
877 let result = parser.parse_content(content, &test_uri()).unwrap();
878 let deps = &result.dependencies;
879
880 assert_eq!(deps.len(), 3);
881
882 let dev_deps: Vec<_> = deps.iter().filter(|d| {
883 matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "dev")
884 }).collect();
885 assert_eq!(dev_deps.len(), 2);
886
887 let docs_deps: Vec<_> = deps.iter().filter(|d| {
888 matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "docs")
889 }).collect();
890 assert_eq!(docs_deps.len(), 1);
891 }
892
893 #[test]
894 fn test_parse_pep735_dependency_groups() {
895 let content = r#"
896[dependency-groups]
897dev = ["pytest>=8.0", "mypy>=1.0", "ruff>=0.8"]
898test = ["pytest>=8.0", "pytest-cov>=4.0"]
899"#;
900
901 let parser = PypiParser::new();
902 let result = parser.parse_content(content, &test_uri()).unwrap();
903 let deps = &result.dependencies;
904
905 assert_eq!(deps.len(), 5);
906
907 let dev_deps: Vec<_> = deps
908 .iter()
909 .filter(|d| {
910 matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "dev")
911 })
912 .collect();
913 assert_eq!(dev_deps.len(), 3);
914
915 let test_deps: Vec<_> = deps
916 .iter()
917 .filter(|d| {
918 matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "test")
919 })
920 .collect();
921 assert_eq!(test_deps.len(), 2);
922
923 assert!(dev_deps.iter().any(|d| d.name == "pytest"));
925 assert!(dev_deps.iter().any(|d| d.name == "mypy"));
926 assert!(dev_deps.iter().any(|d| d.name == "ruff"));
927 }
928
929 #[test]
930 fn test_parse_pep508_with_markers() {
931 let content = r#"
932[project]
933dependencies = [
934 "numpy>=1.24; python_version>='3.9'",
935]
936"#;
937
938 let parser = PypiParser::new();
939 let result = parser.parse_content(content, &test_uri()).unwrap();
940 let deps = &result.dependencies;
941
942 assert_eq!(deps.len(), 1);
943 assert_eq!(deps[0].name, "numpy");
944 assert_eq!(
947 deps[0].markers,
948 Some("python_full_version >= '3.9'".to_string())
949 );
950 }
951
952 #[test]
953 fn test_parse_pep508_without_markers() {
954 let content = r#"
955[project]
956dependencies = [
957 "requests>=2.28.0",
958]
959"#;
960
961 let parser = PypiParser::new();
962 let result = parser.parse_content(content, &test_uri()).unwrap();
963 let deps = &result.dependencies;
964
965 assert_eq!(deps.len(), 1);
966 assert_eq!(deps[0].name, "requests");
967 assert_eq!(deps[0].markers, None);
968 }
969
970 #[test]
971 fn test_parse_pep508_with_compound_marker() {
972 let content = r#"
973[project]
974dependencies = [
975 "colorama>=0.4; sys_platform == 'win32' and python_version >= '3.8'",
976]
977"#;
978
979 let parser = PypiParser::new();
980 let result = parser.parse_content(content, &test_uri()).unwrap();
981 let deps = &result.dependencies;
982
983 assert_eq!(deps.len(), 1);
984 assert_eq!(deps[0].name, "colorama");
985 assert_eq!(
986 deps[0].markers,
987 Some("python_full_version >= '3.8' and sys_platform == 'win32'".to_string())
988 );
989 }
990
991 #[test]
992 fn test_parse_mixed_formats() {
993 let content = r#"
994[project]
995dependencies = ["requests>=2.28.0"]
996
997[tool.poetry.dependencies]
998python = "^3.9"
999flask = "^3.0"
1000"#;
1001
1002 let parser = PypiParser::new();
1003 let result = parser.parse_content(content, &test_uri()).unwrap();
1004 let deps = &result.dependencies;
1005
1006 assert_eq!(deps.len(), 2);
1007
1008 let pep621_deps: Vec<_> = deps
1009 .iter()
1010 .filter(|d| matches!(d.section, PypiDependencySection::Dependencies))
1011 .collect();
1012 assert_eq!(pep621_deps.len(), 1);
1013
1014 let poetry_deps: Vec<_> = deps
1015 .iter()
1016 .filter(|d| matches!(d.section, PypiDependencySection::PoetryDependencies))
1017 .collect();
1018 assert_eq!(poetry_deps.len(), 1);
1019 }
1020
1021 #[test]
1022 fn test_parse_invalid_toml() {
1023 let content = "invalid toml {{{";
1024 let parser = PypiParser::new();
1025 let result = parser.parse_content(content, &test_uri());
1026
1027 assert!(result.is_err());
1028 assert_matches!(result.unwrap_err(), PypiError::TomlParseError { .. });
1029 }
1030
1031 #[test]
1032 fn test_parse_empty_dependencies() {
1033 let content = r#"
1034[project]
1035name = "test"
1036"#;
1037
1038 let parser = PypiParser::new();
1039 let result = parser.parse_content(content, &test_uri()).unwrap();
1040 let deps = &result.dependencies;
1041
1042 assert_eq!(deps.len(), 0);
1043 }
1044
1045 #[test]
1046 fn test_position_tracking_pep735() {
1047 let content = r#"[dependency-groups]
1049dev = ["pytest>=8.0", "mypy>=1.0"]
1050"#;
1051
1052 let parser = PypiParser::new();
1053 let result = parser.parse_content(content, &test_uri()).unwrap();
1054 let deps = &result.dependencies;
1055
1056 assert_eq!(deps.len(), 2);
1057
1058 let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
1060 assert_eq!(pytest.name_range.start.line, 1);
1062 assert_eq!(pytest.name_range.start.character, 8);
1063 assert!(pytest.version_range.is_some());
1065 let version_range = pytest.version_range.unwrap();
1066 assert_eq!(version_range.start.line, 1);
1067 assert_eq!(version_range.start.character, 14);
1069 assert_eq!(version_range.end.character, 19);
1071
1072 let mypy = deps.iter().find(|d| d.name == "mypy").unwrap();
1074 assert_eq!(mypy.name_range.start.line, 1);
1075 assert_eq!(mypy.name_range.start.character, 23);
1078 assert!(mypy.version_range.is_some());
1079 let version_range = mypy.version_range.unwrap();
1080 assert_eq!(version_range.start.character, 27);
1082 assert_eq!(version_range.end.character, 32);
1084 }
1085
1086 #[test]
1087 fn test_version_range_position_without_space() {
1088 let content = r#"[dependency-groups]
1091dev = [
1092 "maturin>=1.7,<2.0",
1093]
1094"#;
1095 let parser = PypiParser::new();
1102 let result = parser.parse_content(content, &test_uri()).unwrap();
1103 let maturin = &result.dependencies[0];
1104
1105 let version_range = maturin.version_range.unwrap();
1106 assert_eq!(version_range.start.line, 2);
1107 assert_eq!(version_range.start.character, 12); assert_eq!(version_range.end.line, 2);
1109 assert_eq!(version_range.end.character, 22); }
1111
1112 #[test]
1113 fn test_version_range_position_with_space() {
1114 let content = r#"[dependency-groups]
1116dev = [
1117 "maturin>=1.7, <2.0",
1118]
1119"#;
1120 let parser = PypiParser::new();
1123 let result = parser.parse_content(content, &test_uri()).unwrap();
1124 let maturin = &result.dependencies[0];
1125
1126 let version_range = maturin.version_range.unwrap();
1127 assert_eq!(version_range.start.character, 12);
1128 assert_eq!(version_range.end.character, 23);
1129 }
1130
1131 #[test]
1132 fn test_position_tracking_with_extras() {
1133 let content = r#"[project]
1134dependencies = ["flask[async]>=3.0"]
1135"#;
1136
1137 let parser = PypiParser::new();
1138 let result = parser.parse_content(content, &test_uri()).unwrap();
1139 let deps = &result.dependencies;
1140
1141 assert_eq!(deps.len(), 1);
1142
1143 let flask = &deps[0];
1144 assert_eq!(flask.name, "flask");
1145 assert_eq!(flask.extras, vec!["async"]);
1146
1147 assert!(flask.version_range.is_some());
1149 let version_range = flask.version_range.unwrap();
1150 assert_eq!(version_range.start.character, 29);
1153 }
1154
1155 #[test]
1156 fn test_parse_pep621_with_comments() {
1157 let toml = r#"
1158[project]
1159name = "test"
1160dependencies = [
1161 "django>=4.0", # Web framework
1162 # "old-package>=1.0", # Commented out
1163 "requests>=2.0",
1164]
1165"#;
1166 let parser = PypiParser::new();
1167 let result = parser.parse_content(toml, &test_uri()).unwrap();
1168 let deps = &result.dependencies;
1169 assert_eq!(deps.len(), 2);
1170 assert_eq!(deps[0].name, "django");
1171 assert_eq!(deps[1].name, "requests");
1172 }
1173
1174 #[test]
1175 fn test_parse_poetry_with_python_constraint() {
1176 let toml = r#"
1177[tool.poetry]
1178name = "test"
1179
1180[tool.poetry.dependencies]
1181python = "^3.9"
1182django = "^4.0"
1183"#;
1184 let parser = PypiParser::new();
1185 let result = parser.parse_content(toml, &test_uri()).unwrap();
1186 let deps = &result.dependencies;
1187 assert_eq!(deps.len(), 1);
1188 assert_eq!(deps[0].name, "django");
1189 }
1190
1191 #[test]
1192 fn test_parse_pep508_with_platform_marker() {
1193 let toml = r#"
1194[project]
1195dependencies = [
1196 "pywin32>=1.0; sys_platform == 'win32'",
1197 "django>=4.0",
1198]
1199"#;
1200 let parser = PypiParser::new();
1201 let result = parser.parse_content(toml, &test_uri()).unwrap();
1202 let deps = &result.dependencies;
1203 assert_eq!(deps.len(), 2);
1204 assert_eq!(deps[0].name, "pywin32");
1205 assert_eq!(deps[1].name, "django");
1206 }
1207
1208 #[test]
1209 fn test_parse_poetry_with_multiple_constraints() {
1210 let toml = r#"
1211[tool.poetry.dependencies]
1212django = { version = "^4.0", python = "^3.9" }
1213"#;
1214 let parser = PypiParser::new();
1215 let result = parser.parse_content(toml, &test_uri()).unwrap();
1216 let deps = &result.dependencies;
1217 if !deps.is_empty() {
1219 assert_eq!(deps[0].name, "django");
1220 assert_eq!(
1221 deps[0]
1222 .version_req
1223 .as_ref()
1224 .map(deps_core::VersionReq::as_str),
1225 Some("^4.0")
1226 );
1227 }
1228 }
1229
1230 #[test]
1231 fn test_parse_pep621_with_git_url() {
1232 let toml = r#"
1233[project]
1234dependencies = [
1235 "mylib @ git+https://github.com/user/mylib.git@main",
1236 "django>=4.0",
1237]
1238"#;
1239 let parser = PypiParser::new();
1240 let result = parser.parse_content(toml, &test_uri()).unwrap();
1241 let deps = &result.dependencies;
1242 assert_eq!(deps.len(), 2);
1243 assert_eq!(deps[0].name, "mylib");
1244 assert_matches!(deps[0].source, PypiDependencySource::Git { .. });
1245 assert_eq!(deps[1].name, "django");
1246 }
1247
1248 #[test]
1249 fn test_parse_empty_optional_dependencies_table() {
1250 let toml = r#"
1251[project]
1252dependencies = ["django>=4.0"]
1253
1254[project.optional-dependencies]
1255"#;
1256 let parser = PypiParser::new();
1257 let result = parser.parse_content(toml, &test_uri()).unwrap();
1258 let deps = &result.dependencies;
1259 assert_eq!(deps.len(), 1);
1260 assert_eq!(deps[0].name, "django");
1261 }
1262
1263 #[test]
1264 fn test_parse_whitespace_only_dependency() {
1265 let toml = r#"
1266[project]
1267dependencies = [
1268 "django>=4.0",
1269 " ",
1270 "requests>=2.0",
1271]
1272"#;
1273 let parser = PypiParser::new();
1274 let result = parser.parse_content(toml, &test_uri()).unwrap();
1275 let deps = &result.dependencies;
1276 assert_eq!(deps.len(), 2);
1277 }
1278
1279 #[test]
1280 fn test_parse_version_with_wildcard() {
1281 let toml = r#"
1282[project]
1283dependencies = [
1284 "django==4.*",
1285]
1286"#;
1287 let parser = PypiParser::new();
1288 let result = parser.parse_content(toml, &test_uri()).unwrap();
1289 let deps = &result.dependencies;
1290 assert_eq!(deps.len(), 1);
1291 assert_eq!(
1292 deps[0]
1293 .version_req
1294 .as_ref()
1295 .map(deps_core::VersionReq::as_str),
1296 Some("==4.*")
1297 );
1298 }
1299
1300 #[test]
1301 fn test_parse_poetry_path_dependency() {
1302 let toml = r#"
1303[tool.poetry.dependencies]
1304mylib = { path = "../mylib" }
1305django = "^4.0"
1306"#;
1307 let parser = PypiParser::new();
1308 let result = parser.parse_content(toml, &test_uri()).unwrap();
1309 let deps = &result.dependencies;
1310 let django_dep = deps.iter().find(|d| d.name == "django");
1312 assert!(django_dep.is_some());
1313 }
1314
1315 #[test]
1316 fn test_parse_pep735_with_includes() {
1317 let toml = r#"
1318[dependency-groups]
1319test = [
1320 { include-group = "dev" },
1321 "pytest>=7.0",
1322]
1323dev = [
1324 "ruff>=0.1",
1325]
1326"#;
1327 let parser = PypiParser::new();
1328 let result = parser.parse_content(toml, &test_uri()).unwrap();
1329 let deps = &result.dependencies;
1330 assert!(deps.len() >= 2);
1331 assert!(deps.iter().any(|d| d.name == "pytest"));
1332 assert!(deps.iter().any(|d| d.name == "ruff"));
1333 }
1334
1335 #[test]
1336 fn test_parse_complex_version_specifier() {
1337 let toml = r#"
1338[project]
1339dependencies = [
1340 "django>=4.0,<5.0,!=4.0.1",
1341]
1342"#;
1343 let parser = PypiParser::new();
1344 let result = parser.parse_content(toml, &test_uri()).unwrap();
1345 let deps = &result.dependencies;
1346 assert_eq!(deps.len(), 1);
1347 assert_eq!(deps[0].name, "django");
1348 assert!(deps[0].version_req.is_some());
1350 }
1351
1352 #[test]
1353 fn test_parse_no_project_section() {
1354 let toml = r#"
1355[tool.my-custom-tool]
1356config = "value"
1357"#;
1358 let parser = PypiParser::new();
1359 let result = parser.parse_content(toml, &test_uri()).unwrap();
1360 let deps = &result.dependencies;
1361 assert_eq!(deps.len(), 0);
1362 }
1363
1364 #[test]
1365 fn test_parse_build_system_requires() {
1366 let toml = r#"
1367[build-system]
1368requires = ["setuptools>=61.0", "wheel", "maturin>=1.7,<2.0"]
1369build-backend = "setuptools.build_meta"
1370"#;
1371 let parser = PypiParser::new();
1372 let result = parser.parse_content(toml, &test_uri()).unwrap();
1373 let deps = &result.dependencies;
1374
1375 assert_eq!(deps.len(), 3);
1376 assert!(
1377 deps.iter()
1378 .all(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1379 );
1380
1381 let setuptools = deps.iter().find(|d| d.name == "setuptools").unwrap();
1382 assert_eq!(
1383 setuptools
1384 .version_req
1385 .as_ref()
1386 .map(deps_core::VersionReq::as_str),
1387 Some(">=61.0")
1388 );
1389
1390 let maturin = deps.iter().find(|d| d.name == "maturin").unwrap();
1391 assert_eq!(
1392 maturin
1393 .version_req
1394 .as_ref()
1395 .map(deps_core::VersionReq::as_str),
1396 Some(">=1.7, <2.0")
1397 );
1398
1399 let wheel = deps.iter().find(|d| d.name == "wheel").unwrap();
1401 assert_eq!(wheel.version_req, None);
1402 }
1403
1404 #[test]
1405 fn test_parse_duplicate_dependency_positions() {
1406 let toml = r#"[build-system]
1408requires = ["maturin>=1.7,<2.0"]
1409
1410[dependency-groups]
1411dev = ["maturin>=1.7,<2.0"]
1412"#;
1413 let parser = PypiParser::new();
1414 let result = parser.parse_content(toml, &test_uri()).unwrap();
1415 let deps = &result.dependencies;
1416
1417 assert_eq!(deps.len(), 2);
1418
1419 let build_system_maturin = deps
1421 .iter()
1422 .find(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1423 .unwrap();
1424 assert_eq!(build_system_maturin.name_range.start.line, 1);
1425
1426 let dep_group_maturin = deps
1428 .iter()
1429 .find(|d| matches!(d.section, PypiDependencySection::DependencyGroup { .. }))
1430 .unwrap();
1431 assert_eq!(dep_group_maturin.name_range.start.line, 4);
1432 }
1433
1434 #[test]
1435 fn test_version_range_for_code_actions() {
1436 let toml = r#"[dependency-groups]
1438dev = ["pytest-cov>=4.0,<8.0"]
1439"#;
1440 let parser = PypiParser::new();
1447 let result = parser.parse_content(toml, &test_uri()).unwrap();
1448 let deps = &result.dependencies;
1449
1450 assert_eq!(deps.len(), 1);
1451 let dep = &deps[0];
1452
1453 assert_eq!(dep.name, "pytest-cov");
1454 assert_eq!(dep.name_range.start.line, 1);
1455 assert_eq!(dep.name_range.start.character, 8); let version_range = dep.version_range.expect("version_range should be set");
1459 assert_eq!(version_range.start.line, 1);
1460 assert_eq!(version_range.start.character, 18);
1462 assert_eq!(version_range.end.character, 28);
1464
1465 let cursor_on_version = Position::new(1, 20);
1467 assert!(
1468 cursor_on_version.character >= version_range.start.character
1469 && cursor_on_version.character < version_range.end.character,
1470 "cursor at {} should be within version_range {}..{}",
1471 cursor_on_version.character,
1472 version_range.start.character,
1473 version_range.end.character
1474 );
1475 }
1476
1477 #[test]
1478 fn test_version_range_with_space_before_specifier() {
1479 let toml = r#"[dependency-groups]
1481dev = ["pytest-cov >=4.0,<8.0"]
1482"#;
1483 let parser = PypiParser::new();
1489 let result = parser.parse_content(toml, &test_uri()).unwrap();
1490 let deps = &result.dependencies;
1491
1492 assert_eq!(deps.len(), 1);
1493 let dep = &deps[0];
1494
1495 let version_range = dep.version_range.expect("version_range should be set");
1499 assert_eq!(version_range.start.line, 1);
1500 assert_eq!(version_range.start.character, 19);
1502 assert_eq!(version_range.end.character, 29);
1504
1505 let cursor_on_version = Position::new(1, 21);
1507 assert!(
1508 cursor_on_version.character >= version_range.start.character
1509 && cursor_on_version.character < version_range.end.character,
1510 "cursor at {} should be within version_range {}..{}",
1511 cursor_on_version.character,
1512 version_range.start.character,
1513 version_range.end.character
1514 );
1515 }
1516
1517 fn utf16_offset_to_byte(line: &str, utf16_offset: u32) -> usize {
1519 let mut utf16_count = 0u32;
1520 for (byte_idx, ch) in line.char_indices() {
1521 if utf16_count >= utf16_offset {
1522 return byte_idx;
1523 }
1524 utf16_count += ch.len_utf16() as u32;
1525 }
1526 line.len()
1527 }
1528
1529 fn slice_range(content: &str, range: Range) -> String {
1535 assert_eq!(
1536 range.start.line, range.end.line,
1537 "helper only supports single-line ranges"
1538 );
1539 let line = content.lines().nth(range.start.line as usize).unwrap();
1540 let start = utf16_offset_to_byte(line, range.start.character);
1541 let end = utf16_offset_to_byte(line, range.end.character);
1542 line[start..end].to_string()
1543 }
1544
1545 #[test]
1546 fn test_pep621_markers_range_covers_marker_text() {
1547 let toml = r#"[project]
1548dependencies = [
1549 "numpy>=1.24; python_version>='3.9'",
1550]
1551"#;
1552 let parser = PypiParser::new();
1553 let result = parser.parse_content(toml, &test_uri()).unwrap();
1554 let deps = &result.dependencies;
1555
1556 assert_eq!(deps.len(), 1);
1557 let dep = &deps[0];
1558 assert_eq!(
1559 dep.markers,
1560 Some("python_full_version >= '3.9'".to_string())
1561 );
1562
1563 let markers_range = dep.markers_range.expect("markers_range should be set");
1565 assert_eq!(slice_range(toml, markers_range), " python_version>='3.9'");
1566 }
1567
1568 #[test]
1569 fn test_pep621_without_markers_has_no_markers_range() {
1570 let toml = r#"[project]
1571dependencies = ["requests>=2.28.0"]
1572"#;
1573 let parser = PypiParser::new();
1574 let result = parser.parse_content(toml, &test_uri()).unwrap();
1575 let deps = &result.dependencies;
1576
1577 assert_eq!(deps.len(), 1);
1578 assert_eq!(deps[0].markers, None);
1579 assert_eq!(deps[0].markers_range, None);
1580 }
1581
1582 #[test]
1583 fn test_pep621_version_range_excludes_marker_text() {
1584 let toml = r#"[project]
1588dependencies = [
1589 "numpy>=1.24; python_version>='3.9'",
1590]
1591"#;
1592 let parser = PypiParser::new();
1593 let result = parser.parse_content(toml, &test_uri()).unwrap();
1594 let dep = &result.dependencies[0];
1595
1596 let version_range = dep.version_range.expect("version_range should be set");
1597 assert_eq!(slice_range(toml, version_range), ">=1.24");
1598
1599 let markers_range = dep.markers_range.expect("markers_range should be set");
1600 assert!(version_range.end.character <= markers_range.start.character);
1601 }
1602
1603 #[test]
1604 fn test_poetry_table_form_markers_normalized() {
1605 let toml = r#"[tool.poetry.dependencies]
1606django = { version = "^4.0", markers = "python_version >= \"3.8\"" }
1607"#;
1608 let parser = PypiParser::new();
1609 let result = parser.parse_content(toml, &test_uri()).unwrap();
1610 let deps = &result.dependencies;
1611
1612 assert_eq!(deps.len(), 1);
1613 let dep = &deps[0];
1614 assert_eq!(dep.name, "django");
1615 assert_eq!(
1617 dep.markers,
1618 Some("python_full_version >= '3.8'".to_string())
1619 );
1620
1621 let markers_range = dep.markers_range.expect("markers_range should be set");
1622 assert_eq!(
1623 slice_range(toml, markers_range),
1624 "python_version >= \\\"3.8\\\""
1625 );
1626 }
1627
1628 #[test]
1629 fn test_poetry_table_form_invalid_markers_falls_back_to_raw() {
1630 let toml = r#"[tool.poetry.dependencies]
1631django = { version = "^4.0", markers = "not a valid marker (((" }
1632"#;
1633 let parser = PypiParser::new();
1634 let result = parser.parse_content(toml, &test_uri()).unwrap();
1635 let deps = &result.dependencies;
1636
1637 assert_eq!(deps.len(), 1);
1638 let dep = &deps[0];
1639 assert_eq!(dep.markers, None);
1642 assert_eq!(dep.markers_range, None);
1643 }
1644
1645 #[test]
1646 fn test_poetry_table_form_unbalanced_parens_rejected() {
1647 let toml = r#"[tool.poetry.dependencies]
1648django = { version = "^4.0", markers = "os_name == 'a' (((" }
1649"#;
1650 let parser = PypiParser::new();
1651 let result = parser.parse_content(toml, &test_uri()).unwrap();
1652 let dep = &result.dependencies[0];
1653
1654 assert_eq!(dep.markers, None);
1661 assert_eq!(dep.markers_range, None);
1662 }
1663
1664 #[test]
1665 fn test_poetry_table_form_trivially_true_marker_becomes_none() {
1666 let toml = r#"[tool.poetry.dependencies]
1669django = { version = "^4.0", markers = "os_name == 'a' or os_name != 'a'" }
1670"#;
1671 let parser = PypiParser::new();
1672 let result = parser.parse_content(toml, &test_uri()).unwrap();
1673 let dep = &result.dependencies[0];
1674
1675 assert_eq!(dep.markers, None);
1676 assert_eq!(dep.markers_range, None);
1677 }
1678
1679 #[test]
1680 fn test_poetry_table_form_empty_markers_becomes_none() {
1681 let toml = r#"[tool.poetry.dependencies]
1682django = { version = "^4.0", markers = " " }
1683"#;
1684 let parser = PypiParser::new();
1685 let result = parser.parse_content(toml, &test_uri()).unwrap();
1686 let dep = &result.dependencies[0];
1687
1688 assert_eq!(dep.markers, None);
1689 assert_eq!(dep.markers_range, None);
1690 }
1691
1692 #[test]
1693 fn test_poetry_table_form_oversized_marker_skips_normalization() {
1694 let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1695 assert!(long_marker.len() > MAX_MARKER_LEN);
1696 let toml = format!(
1697 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{long_marker}\" }}\n"
1698 );
1699 let parser = PypiParser::new();
1700 let result = parser.parse_content(&toml, &test_uri()).unwrap();
1701 let dep = &result.dependencies[0];
1702
1703 assert_eq!(dep.markers, Some(long_marker));
1706 assert!(dep.markers_range.is_some());
1707 }
1708
1709 #[test]
1710 fn test_poetry_table_form_without_markers_key_has_no_markers() {
1711 let toml = r#"[tool.poetry.dependencies]
1712django = { version = "^4.0" }
1713"#;
1714 let parser = PypiParser::new();
1715 let result = parser.parse_content(toml, &test_uri()).unwrap();
1716 let deps = &result.dependencies;
1717
1718 assert_eq!(deps.len(), 1);
1719 assert_eq!(deps[0].markers, None);
1720 assert_eq!(deps[0].markers_range, None);
1721 }
1722
1723 #[test]
1724 fn test_poetry_string_form_without_marker_stays_none() {
1725 let toml = r#"[tool.poetry.dependencies]
1726requests = "^2.28.0"
1727"#;
1728 let parser = PypiParser::new();
1729 let result = parser.parse_content(toml, &test_uri()).unwrap();
1730 let deps = &result.dependencies;
1731
1732 assert_eq!(deps.len(), 1);
1733 let dep = &deps[0];
1734 assert_eq!(
1735 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1736 Some("^2.28.0")
1737 );
1738 assert_eq!(dep.markers, None);
1739 assert_eq!(dep.markers_range, None);
1740 }
1741
1742 #[test]
1743 fn test_poetry_string_form_with_marker_suffix_normalized() {
1744 let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; python_version >= '3.8'\"\n";
1745 let parser = PypiParser::new();
1746 let result = parser.parse_content(toml, &test_uri()).unwrap();
1747 let deps = &result.dependencies;
1748
1749 assert_eq!(deps.len(), 1);
1750 let dep = &deps[0];
1751 assert_eq!(dep.name, "requests");
1752 assert_eq!(
1754 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1755 Some("^2.28.0")
1756 );
1757 assert_eq!(
1758 dep.markers,
1759 Some("python_full_version >= '3.8'".to_string())
1760 );
1761
1762 let version_range = dep.version_range.expect("version_range should be set");
1763 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1764
1765 let markers_range = dep.markers_range.expect("markers_range should be set");
1767 assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1768 }
1769
1770 #[test]
1771 fn test_poetry_string_form_with_invalid_marker_suffix_falls_back_to_raw() {
1772 let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; not a valid marker (((\"\n";
1773 let parser = PypiParser::new();
1774 let result = parser.parse_content(toml, &test_uri()).unwrap();
1775 let deps = &result.dependencies;
1776
1777 assert_eq!(deps.len(), 1);
1778 let dep = &deps[0];
1779 assert_eq!(
1780 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1781 Some("^2.28.0")
1782 );
1783 assert_eq!(dep.markers, None);
1786 assert_eq!(dep.markers_range, None);
1787 }
1788
1789 #[test]
1790 fn test_poetry_string_form_version_range_without_marker() {
1791 let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0\"\n";
1792 let parser = PypiParser::new();
1793 let result = parser.parse_content(toml, &test_uri()).unwrap();
1794 let dep = &result.dependencies[0];
1795
1796 let version_range = dep.version_range.expect("version_range should be set");
1797 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1798 }
1799
1800 #[test]
1801 fn test_poetry_string_form_empty_marker_after_semicolon() {
1802 let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0;\"\n";
1803 let parser = PypiParser::new();
1804 let result = parser.parse_content(toml, &test_uri()).unwrap();
1805 let dep = &result.dependencies[0];
1806
1807 assert_eq!(
1808 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1809 Some("^2.28.0")
1810 );
1811 assert_eq!(dep.markers, None);
1812 assert_eq!(dep.markers_range, None);
1813 }
1814
1815 #[test]
1816 fn test_poetry_string_form_no_space_around_equals() {
1817 let toml = "[tool.poetry.dependencies]\nrequests=\"^2.28.0; python_version >= '3.8'\"\n";
1820 let parser = PypiParser::new();
1821 let result = parser.parse_content(toml, &test_uri()).unwrap();
1822 let dep = &result.dependencies[0];
1823
1824 assert_eq!(dep.name, "requests");
1825 assert_eq!(
1826 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1827 Some("^2.28.0")
1828 );
1829 assert_eq!(
1830 dep.markers,
1831 Some("python_full_version >= '3.8'".to_string())
1832 );
1833
1834 let version_range = dep.version_range.expect("version_range should be set");
1835 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1836 let markers_range = dep.markers_range.expect("markers_range should be set");
1837 assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1838 }
1839
1840 #[test]
1841 fn test_poetry_string_form_quoted_key() {
1842 let toml =
1843 "[tool.poetry.dependencies]\n\"requests\" = \"^2.28.0; python_version >= '3.8'\"\n";
1844 let parser = PypiParser::new();
1845 let result = parser.parse_content(toml, &test_uri()).unwrap();
1846 let dep = &result.dependencies[0];
1847
1848 assert_eq!(dep.name, "requests");
1849 assert_eq!(
1850 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1851 Some("^2.28.0")
1852 );
1853
1854 let version_range = dep.version_range.expect("version_range should be set");
1855 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1856 let markers_range = dep.markers_range.expect("markers_range should be set");
1857 assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1858 }
1859
1860 #[test]
1861 fn test_poetry_string_form_marker_with_escaped_quotes() {
1862 let toml =
1865 "[tool.poetry.dependencies]\nrequests = \"^2.28.0; python_version >= \\\"3.8\\\"\"\n";
1866 let parser = PypiParser::new();
1867 let result = parser.parse_content(toml, &test_uri()).unwrap();
1868 let dep = &result.dependencies[0];
1869
1870 assert_eq!(
1871 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1872 Some("^2.28.0")
1873 );
1874 assert_eq!(
1875 dep.markers,
1876 Some("python_full_version >= '3.8'".to_string())
1877 );
1878
1879 let version_range = dep.version_range.expect("version_range should be set");
1880 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1881 }
1882
1883 #[test]
1884 fn test_poetry_string_form_marker_with_non_ascii() {
1885 let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; os_name == 'ПРИВЕТ🚀'\"\n";
1889 let parser = PypiParser::new();
1890 let result = parser.parse_content(toml, &test_uri()).unwrap();
1891 let dep = &result.dependencies[0];
1892
1893 assert_eq!(
1894 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1895 Some("^2.28.0")
1896 );
1897
1898 let version_range = dep.version_range.expect("version_range should be set");
1899 assert_eq!(slice_range(toml, version_range), "^2.28.0");
1900
1901 let line = toml.lines().nth(1).unwrap();
1902 let line_utf16_len = line.encode_utf16().count() as u32;
1903 let markers_range = dep.markers_range.expect("markers_range should be set");
1904 assert!(markers_range.end.character <= line_utf16_len);
1905 assert_eq!(slice_range(toml, markers_range), " os_name == 'ПРИВЕТ🚀'");
1906 }
1907
1908 #[test]
1909 fn test_poetry_string_form_oversized_marker_skips_normalization() {
1910 let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1911 assert!(long_marker.len() > MAX_MARKER_LEN);
1912 let toml = format!("[tool.poetry.dependencies]\nrequests = \"^2.28.0; {long_marker}\"\n");
1913 let parser = PypiParser::new();
1914 let result = parser.parse_content(&toml, &test_uri()).unwrap();
1915 let dep = &result.dependencies[0];
1916
1917 assert_eq!(
1918 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1919 Some("^2.28.0")
1920 );
1921 assert_eq!(dep.markers, Some(long_marker));
1922 assert!(dep.markers_range.is_some());
1923 }
1924
1925 #[test]
1926 fn test_pep621_oversized_marker_skips_normalization() {
1927 let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1928 assert!(long_marker.len() > MAX_MARKER_LEN);
1929 let toml = format!("[project]\ndependencies = [\n \"numpy>=1.24; {long_marker}\",\n]\n");
1930 let parser = PypiParser::new();
1931 let result = parser.parse_content(&toml, &test_uri()).unwrap();
1932 let dep = &result.dependencies[0];
1933
1934 assert_eq!(dep.name, "numpy");
1935 assert_eq!(
1936 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1937 Some(">=1.24")
1938 );
1939 assert_eq!(dep.markers, Some(long_marker));
1942 assert!(dep.markers_range.is_some());
1943 }
1944
1945 #[test]
1946 fn test_pep621_oversized_extras_list_rejected_fast() {
1947 let huge_extras = "a,".repeat(500_000); let requirement = format!("pkg[{huge_extras}]==1.0");
1954 assert!(requirement.len() > super::super::MAX_REQUIREMENT_LEN);
1955 let toml = format!(
1956 "[project]\ndependencies = [\n \"{requirement}\",\n \"good-pkg==2.0\",\n]\n"
1957 );
1958 let parser = PypiParser::new();
1959
1960 let start = std::time::Instant::now();
1961 let result = parser.parse_content(&toml, &test_uri()).unwrap();
1962 let elapsed = start.elapsed();
1963
1964 assert!(
1965 elapsed < std::time::Duration::from_secs(2),
1966 "oversized extras dependency took too long to reject: {elapsed:?}"
1967 );
1968 assert_eq!(result.dependencies.len(), 1);
1969 assert_eq!(result.dependencies[0].name, "good-pkg");
1970 }
1971
1972 #[test]
1973 fn test_pep621_oversized_marker_beyond_total_cap_still_survives() {
1974 let long_marker: String = "os_name == 'a' or ".repeat(230) + "os_name == 'a'";
1982 assert!(long_marker.len() > MAX_MARKER_LEN);
1983 let toml = format!("[project]\ndependencies = [\n \"numpy>=1.24; {long_marker}\",\n]\n");
1984 assert!(
1985 "numpy>=1.24".len() < super::super::MAX_REQUIREMENT_LEN,
1986 "pre-marker portion must stay under the cap"
1987 );
1988 assert!(
1989 format!("numpy>=1.24; {long_marker}").len() > super::super::MAX_REQUIREMENT_LEN,
1990 "total requirement (incl. marker) must exceed the cap for this test to be meaningful"
1991 );
1992 let parser = PypiParser::new();
1993 let result = parser.parse_content(&toml, &test_uri()).unwrap();
1994
1995 assert_eq!(result.dependencies.len(), 1);
1996 let dep = &result.dependencies[0];
1997 assert_eq!(dep.name, "numpy");
1998 assert_eq!(
1999 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2000 Some(">=1.24")
2001 );
2002 assert_eq!(dep.markers, Some(long_marker));
2003 }
2004
2005 #[test]
2006 fn test_pep621_deeply_nested_marker_under_length_cap_skips_normalization() {
2007 let depth = 1000;
2012 let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
2013 assert!(nested_marker.len() < MAX_MARKER_LEN);
2014 let toml =
2015 format!("[project]\ndependencies = [\n \"numpy>=1.24; {nested_marker}\",\n]\n");
2016 let parser = PypiParser::new();
2017 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2018 let dep = &result.dependencies[0];
2019
2020 assert_eq!(dep.name, "numpy");
2021 assert_eq!(
2022 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2023 Some(">=1.24")
2024 );
2025 assert_eq!(dep.markers, Some(nested_marker));
2026 assert!(dep.markers_range.is_some());
2027 }
2028
2029 #[test]
2030 fn test_poetry_table_form_deeply_nested_marker_skips_normalization() {
2031 let depth = 1000;
2034 let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
2035 assert!(nested_marker.len() < MAX_MARKER_LEN);
2036 let toml = format!(
2037 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{nested_marker}\" }}\n"
2038 );
2039 let parser = PypiParser::new();
2040 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2041 let dep = &result.dependencies[0];
2042
2043 assert_eq!(dep.markers, Some(nested_marker));
2044 assert!(dep.markers_range.is_some());
2045 }
2046
2047 #[test]
2048 fn test_pep621_marker_depth_bypass_via_quoted_parens_falls_back() {
2049 let levels = 60;
2059 let mut marker = String::new();
2060 for _ in 0..levels {
2061 marker.push_str("(extra==')'and ");
2062 }
2063 marker.push_str("extra=='a'");
2064 for _ in 0..levels {
2065 marker.push(')');
2066 }
2067 assert!(marker.len() < MAX_MARKER_LEN);
2068 assert!(marker_too_deep(&marker));
2069
2070 let toml = format!("[project]\ndependencies = [\n \"numpy>=1.24; {marker}\",\n]\n");
2071 let parser = PypiParser::new();
2072 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2073 let dep = &result.dependencies[0];
2074
2075 assert_eq!(dep.name, "numpy");
2076 assert_eq!(
2077 dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2078 Some(">=1.24")
2079 );
2080 assert_eq!(dep.markers, Some(marker));
2082 assert!(dep.markers_range.is_some());
2083 }
2084
2085 #[test]
2086 fn test_pep621_reasonably_nested_marker_still_normalizes() {
2087 let toml = r#"[project]
2090dependencies = [
2091 "numpy>=1.24; (os_name == 'a' and sys_platform == 'b') or os_name == 'c'",
2092]
2093"#;
2094 let parser = PypiParser::new();
2095 let result = parser.parse_content(toml, &test_uri()).unwrap();
2096 let dep = &result.dependencies[0];
2097
2098 assert_eq!(dep.name, "numpy");
2099 let markers = dep.markers.as_ref().expect("marker should normalize");
2100 assert!(markers.contains("os_name"));
2101 assert!(markers.contains("sys_platform"));
2102 }
2103
2104 #[test]
2105 fn test_pep621_marker_extras_bracket_injection_rejected() {
2106 let huge_extras = "a".repeat(60_000);
2112 let toml = format!("[project]\ndependencies = [\n \"pkg;[{huge_extras}]==1.0\",\n]\n");
2113 let parser = PypiParser::new();
2114 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2115 let dep = &result.dependencies[0];
2116
2117 assert_eq!(dep.name, "pkg");
2118 assert_eq!(dep.version_req, None);
2119 assert_eq!(dep.markers, None);
2120 assert_eq!(dep.markers_range, None);
2121 }
2122
2123 #[test]
2124 fn test_poetry_table_form_oversized_non_marker_text_rejected() {
2125 let garbage = "[".to_string() + &"x".repeat(60_000) + "]";
2129 assert!(garbage.len() > MAX_MARKER_LEN);
2130 let toml = format!(
2131 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2132 );
2133 let parser = PypiParser::new();
2134 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2135 let dep = &result.dependencies[0];
2136
2137 assert_eq!(dep.markers, None);
2138 assert_eq!(dep.markers_range, None);
2139 }
2140
2141 #[test]
2142 fn test_pep621_marker_keyword_repeated_without_separators_rejected() {
2143 let garbage = "extra".repeat(1600);
2148 assert!(garbage.len() > MAX_MARKER_LEN);
2149 let toml = format!("[project]\ndependencies = [\n \"pkg; {garbage}\",\n]\n");
2150 let parser = PypiParser::new();
2151 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2152 let dep = &result.dependencies[0];
2153
2154 assert_eq!(dep.name, "pkg");
2155 assert_eq!(dep.markers, None);
2156 assert_eq!(dep.markers_range, None);
2157 }
2158
2159 #[test]
2160 fn test_pep621_marker_keyword_padded_with_unquoted_garbage_rejected() {
2161 let filler = "A".repeat(5000);
2167 let raw_marker = format!("python_version <{filler}>");
2168 assert!(raw_marker.len() > MAX_MARKER_LEN);
2169 let toml = format!("[project]\ndependencies = [\n \"pkg; {raw_marker}\",\n]\n");
2170 let parser = PypiParser::new();
2171 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2172 let dep = &result.dependencies[0];
2173
2174 assert_eq!(dep.name, "pkg");
2175 assert_eq!(dep.markers, None);
2176 assert_eq!(dep.markers_range, None);
2177 }
2178
2179 #[test]
2180 fn test_poetry_table_form_oversized_garbage_under_length_cap_rejected() {
2181 let garbage = format!("[{}]==1.0", "a".repeat(1980));
2187 assert!(garbage.len() < MAX_MARKER_LEN);
2188 let toml = format!(
2189 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2190 );
2191 let parser = PypiParser::new();
2192 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2193 let dep = &result.dependencies[0];
2194
2195 assert_eq!(dep.markers, None);
2196 assert_eq!(dep.markers_range, None);
2197 }
2198
2199 #[test]
2200 fn test_pep621_marker_repeated_token_no_operator_rejected() {
2201 let garbage = "python_version ".repeat(500);
2207 assert!(garbage.len() > MAX_MARKER_LEN);
2208 let toml = format!("[project]\ndependencies = [\n \"pkg; {garbage}\",\n]\n");
2209 let parser = PypiParser::new();
2210 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2211 let dep = &result.dependencies[0];
2212
2213 assert_eq!(dep.name, "pkg");
2214 assert_eq!(dep.markers, None);
2215 assert_eq!(dep.markers_range, None);
2216 }
2217
2218 #[test]
2219 fn test_pep621_marker_and_joined_repeated_token_no_operator_rejected() {
2220 let garbage = "python_version and ".repeat(400) + "python_version";
2223 assert!(garbage.len() > MAX_MARKER_LEN);
2224 let toml = format!("[project]\ndependencies = [\n \"pkg; {garbage}\",\n]\n");
2225 let parser = PypiParser::new();
2226 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2227 let dep = &result.dependencies[0];
2228
2229 assert_eq!(dep.name, "pkg");
2230 assert_eq!(dep.markers, None);
2231 assert_eq!(dep.markers_range, None);
2232 }
2233
2234 #[test]
2235 fn test_poetry_table_form_repeated_token_no_operator_rejected() {
2236 let garbage = "python_version ".repeat(500);
2239 assert!(garbage.len() > MAX_MARKER_LEN);
2240 let toml = format!(
2241 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2242 );
2243 let parser = PypiParser::new();
2244 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2245 let dep = &result.dependencies[0];
2246
2247 assert_eq!(dep.markers, None);
2248 assert_eq!(dep.markers_range, None);
2249 }
2250
2251 #[test]
2252 fn test_pep621_marker_chained_comparison_rejected() {
2253 let chain = "python_version==".repeat(500) + "python_version";
2262 assert!(chain.len() > MAX_MARKER_LEN);
2263 let toml = format!("[project]\ndependencies = [\n \"pkg; {chain}\",\n]\n");
2264 let parser = PypiParser::new();
2265 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2266 let dep = &result.dependencies[0];
2267
2268 assert_eq!(dep.name, "pkg");
2269 assert_eq!(dep.markers, None);
2270 assert_eq!(dep.markers_range, None);
2271 }
2272
2273 #[test]
2274 fn test_pep621_marker_chained_in_rejected() {
2275 let chain = "python_version in ".repeat(500) + "python_version";
2277 assert!(chain.len() > MAX_MARKER_LEN);
2278 let toml = format!("[project]\ndependencies = [\n \"pkg; {chain}\",\n]\n");
2279 let parser = PypiParser::new();
2280 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2281 let dep = &result.dependencies[0];
2282
2283 assert_eq!(dep.name, "pkg");
2284 assert_eq!(dep.markers, None);
2285 assert_eq!(dep.markers_range, None);
2286 }
2287
2288 #[test]
2289 fn test_poetry_table_form_chained_comparison_rejected() {
2290 let chain = "python_version==".repeat(500) + "python_version";
2292 assert!(chain.len() > MAX_MARKER_LEN);
2293 let toml = format!(
2294 "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{chain}\" }}\n"
2295 );
2296 let parser = PypiParser::new();
2297 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2298 let dep = &result.dependencies[0];
2299
2300 assert_eq!(dep.markers, None);
2301 assert_eq!(dep.markers_range, None);
2302 }
2303
2304 #[test]
2305 fn test_pep621_oversized_in_operator_marker_still_normalizes() {
2306 let marker =
2310 "python_version in '3.8'".to_string() + &" or python_version in '3.8'".repeat(200);
2311 assert!(marker.len() > MAX_MARKER_LEN);
2312 let toml = format!("[project]\ndependencies = [\n \"pkg; {marker}\",\n]\n");
2313 let parser = PypiParser::new();
2314 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2315 let dep = &result.dependencies[0];
2316
2317 assert_eq!(dep.name, "pkg");
2318 assert_eq!(dep.markers, Some(marker));
2319 assert!(dep.markers_range.is_some());
2320 }
2321
2322 #[test]
2323 fn test_pep621_oversized_not_in_operator_marker_still_normalizes() {
2324 let marker = "python_version not in '3.8'".to_string()
2326 + &" or python_version not in '3.8'".repeat(200);
2327 assert!(marker.len() > MAX_MARKER_LEN);
2328 let toml = format!("[project]\ndependencies = [\n \"pkg; {marker}\",\n]\n");
2329 let parser = PypiParser::new();
2330 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2331 let dep = &result.dependencies[0];
2332
2333 assert_eq!(dep.name, "pkg");
2334 assert_eq!(dep.markers, Some(marker));
2335 assert!(dep.markers_range.is_some());
2336 }
2337
2338 #[test]
2339 fn test_pep621_non_ascii_marker_literal_still_normalizes() {
2340 let filler = "é".repeat(1500);
2345 let raw_marker = format!("platform_release == '{filler}' or python_version >= '3.8'");
2346 assert!(raw_marker.len() > MAX_MARKER_LEN);
2347 let toml = format!("[project]\ndependencies = [\n \"pkg; {raw_marker}\",\n]\n");
2348 let parser = PypiParser::new();
2349 let result = parser.parse_content(&toml, &test_uri()).unwrap();
2350 let dep = &result.dependencies[0];
2351
2352 assert_eq!(dep.name, "pkg");
2353 assert_eq!(dep.markers, Some(raw_marker));
2354 assert!(dep.markers_range.is_some());
2355 }
2356
2357 fn parse_with_all_policy(content: &str) -> ParseResult {
2360 let policy = deps_core::net_policy::RegistryAccessPolicy::new(
2361 deps_core::net_policy::WorkspaceRegistryAccess::All,
2362 );
2363 PypiParser::new()
2364 .parse_content_with_policy(content, &test_uri(), &policy)
2365 .unwrap()
2366 }
2367
2368 #[test]
2369 fn test_poetry_unlabeled_source_is_primary() {
2370 let content = r#"
2373[[tool.poetry.source]]
2374name = "internal"
2375url = "https://pypi.mycorp.example/simple"
2376
2377[tool.poetry.dependencies]
2378requests = "^2.28.0"
2379"#;
2380 let result = parse_with_all_policy(content);
2381 let dep = result
2382 .dependencies
2383 .iter()
2384 .find(|d| d.name == "requests")
2385 .unwrap();
2386 assert_matches!(dep.source, PypiDependencySource::AlternateRegistry { .. });
2387 }
2388
2389 #[test]
2393 fn test_poetry_multiple_primary_sources_keeps_first() {
2394 let content = r#"
2395[[tool.poetry.source]]
2396name = "first"
2397url = "https://first.example/simple"
2398
2399[[tool.poetry.source]]
2400name = "second"
2401url = "https://second.example/simple"
2402priority = "primary"
2403
2404[tool.poetry.dependencies]
2405requests = "^2.28.0"
2406"#;
2407 let result = parse_with_all_policy(content);
2408 let dep = result
2409 .dependencies
2410 .iter()
2411 .find(|d| d.name == "requests")
2412 .unwrap();
2413 let PypiDependencySource::AlternateRegistry { index, .. } = &dep.source else {
2414 panic!("expected AlternateRegistry, got {:?}", dep.source);
2415 };
2416
2417 let chain = result
2418 .resolved_chains
2419 .iter()
2420 .find(|c| &c.key == index)
2421 .expect("chain must be registered");
2422 assert_eq!(chain.hops.len(), 1);
2423 assert_eq!(chain.hops[0].as_str(), "https://first.example/simple");
2424 }
2425
2426 #[test]
2427 fn test_poetry_explicit_source_via_dependency_key() {
2428 let content = r#"
2429[[tool.poetry.source]]
2430name = "internal"
2431url = "https://pypi.mycorp.example/simple"
2432priority = "explicit"
2433
2434[tool.poetry.dependencies]
2435flask = { version = "^3.0", source = "internal" }
2436requests = "^2.28.0"
2437"#;
2438 let result = parse_with_all_policy(content);
2439 let flask = result
2440 .dependencies
2441 .iter()
2442 .find(|d| d.name == "flask")
2443 .unwrap();
2444 assert_eq!(
2445 flask.source,
2446 PypiDependencySource::AlternateRegistry {
2447 index: "https://pypi.mycorp.example/simple".to_string(),
2448 mirrors_crates_io: false,
2449 }
2450 );
2451
2452 let requests = result
2455 .dependencies
2456 .iter()
2457 .find(|d| d.name == "requests")
2458 .unwrap();
2459 assert_eq!(requests.source, PypiDependencySource::Registry);
2460 }
2461
2462 #[test]
2463 fn test_poetry_source_reference_with_no_matching_entry_fails_closed() {
2464 let content = r#"
2465[tool.poetry.dependencies]
2466flask = { version = "^3.0", source = "does-not-exist" }
2467"#;
2468 let result = parse_with_all_policy(content);
2469 assert_eq!(
2470 result.dependencies[0].source,
2471 PypiDependencySource::CustomRegistry {
2472 url: "does-not-exist".to_string(),
2473 }
2474 );
2475 }
2476
2477 #[test]
2478 fn test_poetry_supplemental_priority_is_extra_not_primary() {
2479 let content = r#"
2480[[tool.poetry.source]]
2481name = "extra"
2482url = "https://extra.example/simple"
2483priority = "supplemental"
2484
2485[tool.poetry.dependencies]
2486requests = "^2.28.0"
2487"#;
2488 let result = parse_with_all_policy(content);
2489 assert_matches!(
2492 result.dependencies[0].source,
2493 PypiDependencySource::AlternateRegistry { .. }
2494 );
2495 }
2496
2497 #[test]
2500 fn test_uv_plain_entry_is_automatic_extra() {
2501 let content = r#"
2502[[tool.uv.index]]
2503name = "extra"
2504url = "https://extra.example/simple"
2505
2506[project]
2507dependencies = ["requests>=2.28.0"]
2508"#;
2509 let result = parse_with_all_policy(content);
2510 assert_matches!(
2511 result.dependencies[0].source,
2512 PypiDependencySource::AlternateRegistry { .. }
2513 );
2514 }
2515
2516 #[test]
2523 fn test_uv_default_entry_is_last_resort_not_primary() {
2524 let content = r#"
2525[[tool.uv.index]]
2526name = "non-default"
2527url = "https://non-default.example/simple"
2528
2529[[tool.uv.index]]
2530name = "default-index"
2531url = "https://default.example/simple"
2532default = true
2533
2534[project]
2535dependencies = ["requests>=2.28.0"]
2536"#;
2537 let result = parse_with_all_policy(content);
2538 assert_matches!(
2542 result.dependencies[0].source,
2543 PypiDependencySource::AlternateRegistry { .. }
2544 );
2545 }
2546
2547 #[test]
2548 fn test_uv_explicit_entry_named_source_only() {
2549 let content = r#"
2550[[tool.uv.index]]
2551name = "internal"
2552url = "https://pypi.mycorp.example/simple"
2553explicit = true
2554
2555[tool.uv.sources]
2556mypkg = { index = "internal" }
2557
2558[project]
2559dependencies = ["mypkg>=1.0.0", "requests>=2.28.0"]
2560"#;
2561 let result = parse_with_all_policy(content);
2562 let mypkg = result
2563 .dependencies
2564 .iter()
2565 .find(|d| d.name == "mypkg")
2566 .unwrap();
2567 assert_eq!(
2568 mypkg.source,
2569 PypiDependencySource::AlternateRegistry {
2570 index: "https://pypi.mycorp.example/simple".to_string(),
2571 mirrors_crates_io: false,
2572 }
2573 );
2574
2575 let requests = result
2578 .dependencies
2579 .iter()
2580 .find(|d| d.name == "requests")
2581 .unwrap();
2582 assert_eq!(requests.source, PypiDependencySource::Registry);
2583 }
2584
2585 #[test]
2592 fn test_uv_sources_index_binding_normalizes_name_mismatch() {
2593 let content = r#"
2594[[tool.uv.index]]
2595name = "Internal-Index"
2596url = "https://pypi.mycorp.example/simple"
2597explicit = true
2598
2599[tool.uv.sources]
2600mypkg = { index = "internal_index" }
2601
2602[project]
2603dependencies = ["mypkg>=1.0.0"]
2604"#;
2605 let result = parse_with_all_policy(content);
2606 let mypkg = result
2607 .dependencies
2608 .iter()
2609 .find(|d| d.name == "mypkg")
2610 .unwrap();
2611 assert_eq!(
2612 mypkg.source,
2613 PypiDependencySource::AlternateRegistry {
2614 index: "https://pypi.mycorp.example/simple".to_string(),
2615 mirrors_crates_io: false,
2616 }
2617 );
2618 }
2619
2620 #[test]
2624 fn test_uv_sources_dependency_key_normalizes_name_mismatch() {
2625 let content = r#"
2626[[tool.uv.index]]
2627name = "internal"
2628url = "https://pypi.mycorp.example/simple"
2629explicit = true
2630
2631[tool.uv.sources]
2632"Flask-SQLAlchemy" = { index = "internal" }
2633
2634[project]
2635dependencies = ["flask-sqlalchemy>=1.0.0"]
2636"#;
2637 let result = parse_with_all_policy(content);
2638 let dep = result
2639 .dependencies
2640 .iter()
2641 .find(|d| d.name == "flask-sqlalchemy")
2642 .unwrap();
2643 assert_eq!(
2644 dep.source,
2645 PypiDependencySource::AlternateRegistry {
2646 index: "https://pypi.mycorp.example/simple".to_string(),
2647 mirrors_crates_io: false,
2648 }
2649 );
2650 }
2651
2652 #[test]
2655 fn test_uv_sources_index_binding_works_for_non_explicit_entry() {
2656 let content = r#"
2657[[tool.uv.index]]
2658name = "extra"
2659url = "https://extra.example/simple"
2660
2661[tool.uv.sources]
2662mypkg = { index = "extra" }
2663
2664[project]
2665dependencies = ["mypkg>=1.0.0"]
2666"#;
2667 let result = parse_with_all_policy(content);
2668 assert_eq!(
2669 result.dependencies[0].source,
2670 PypiDependencySource::AlternateRegistry {
2671 index: "https://extra.example/simple".to_string(),
2672 mirrors_crates_io: false,
2673 }
2674 );
2675 }
2676
2677 #[test]
2683 fn test_uv_sources_non_index_shapes_have_no_routing_effect() {
2684 let content = r#"
2685[tool.uv.sources]
2686git-pkg = { git = "https://github.com/example/git-pkg" }
2687path-pkg = { path = "../path-pkg" }
2688workspace-pkg = { workspace = true }
2689
2690[project]
2691dependencies = ["git-pkg>=1.0.0", "path-pkg>=1.0.0", "workspace-pkg>=1.0.0"]
2692"#;
2693 let result = parse_with_all_policy(content);
2694 for dep in &result.dependencies {
2695 assert_eq!(
2696 dep.source,
2697 PypiDependencySource::Registry,
2698 "{} should be unaffected by a non-index uv.sources shape",
2699 dep.name
2700 );
2701 }
2702 }
2703
2704 #[test]
2707 fn test_no_index_declaration_pyproject_is_plain_registry() {
2708 let content = r#"
2709[project]
2710dependencies = ["requests>=2.28.0"]
2711
2712[tool.poetry.dependencies]
2713flask = "^3.0"
2714"#;
2715 let result = PypiParser::new()
2716 .parse_content(content, &test_uri())
2717 .unwrap();
2718 for dep in &result.dependencies {
2719 assert_eq!(dep.source, PypiDependencySource::Registry);
2720 }
2721 }
2722}