1use crate::error::{PypiError, Result};
2use crate::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
3use pep508_rs::{Requirement, VersionOrUrl};
4use std::any::Any;
5use std::str::FromStr;
6use toml_edit::{DocumentMut, Item, Table};
7use tower_lsp_server::ls_types::{Position, Range, Uri};
8
9#[derive(Debug, Clone)]
13pub struct ParseResult {
14 pub dependencies: Vec<PypiDependency>,
16 pub workspace_root: Option<std::path::PathBuf>,
18 pub uri: Uri,
20}
21
22impl deps_core::ParseResult for ParseResult {
23 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
24 self.dependencies
25 .iter()
26 .map(|d| d as &dyn deps_core::Dependency)
27 .collect()
28 }
29
30 fn workspace_root(&self) -> Option<&std::path::Path> {
31 self.workspace_root.as_deref()
32 }
33
34 fn uri(&self) -> &Uri {
35 &self.uri
36 }
37
38 fn as_any(&self) -> &dyn Any {
39 self
40 }
41}
42
43pub struct PypiParser;
65
66impl PypiParser {
67 pub const fn new() -> Self {
69 Self
70 }
71
72 pub fn parse_content(&self, content: &str, uri: &Uri) -> Result<ParseResult> {
93 let doc = content
94 .parse::<DocumentMut>()
95 .map_err(|e| PypiError::TomlParseError { source: e })?;
96
97 let mut dependencies = Vec::new();
98 let mut used_positions = std::collections::HashSet::new();
100
101 if let Some(build_system) = doc.get("build-system").and_then(|i| i.as_table()) {
103 dependencies.extend(self.parse_build_system_requires(
104 build_system,
105 content,
106 &mut used_positions,
107 )?);
108 }
109
110 if let Some(project) = doc.get("project").and_then(|i| i.as_table()) {
112 dependencies.extend(self.parse_pep621_dependencies(
113 project,
114 content,
115 &mut used_positions,
116 )?);
117 dependencies.extend(self.parse_pep621_optional_dependencies(
118 project,
119 content,
120 &mut used_positions,
121 )?);
122 }
123
124 if let Some(dep_groups) = doc.get("dependency-groups").and_then(|i| i.as_table()) {
126 dependencies.extend(self.parse_dependency_groups(
127 dep_groups,
128 content,
129 &mut used_positions,
130 )?);
131 }
132
133 if let Some(tool) = doc.get("tool").and_then(|i| i.as_table())
135 && let Some(poetry) = tool.get("poetry").and_then(|i| i.as_table())
136 {
137 dependencies.extend(self.parse_poetry_dependencies(poetry, content)?);
138 dependencies.extend(self.parse_poetry_groups(poetry, content)?);
139 }
140
141 Ok(ParseResult {
142 dependencies,
143 workspace_root: None,
144 uri: uri.clone(),
145 })
146 }
147
148 fn parse_build_system_requires(
150 &self,
151 build_system: &Table,
152 content: &str,
153 used_positions: &mut std::collections::HashSet<usize>,
154 ) -> Result<Vec<PypiDependency>> {
155 let Some(requires_item) = build_system.get("requires") else {
156 return Ok(Vec::new());
157 };
158
159 let Some(requires_array) = requires_item.as_array() else {
160 return Ok(Vec::new());
161 };
162
163 let mut dependencies = Vec::new();
164
165 for value in requires_array {
166 if let Some(dep_str) = value.as_str() {
167 let position = self
169 .find_dependency_string_position(content, dep_str, used_positions)
170 .map(|(p, _)| p);
171
172 match self.parse_pep508_requirement(dep_str, position) {
173 Ok(mut dep) => {
174 dep.section = PypiDependencySection::BuildSystem;
175 dependencies.push(dep);
176 }
177 Err(e) => {
178 tracing::warn!("Failed to parse build-system require '{}': {}", dep_str, e);
179 }
180 }
181 }
182 }
183
184 Ok(dependencies)
185 }
186
187 fn parse_pep621_dependencies(
189 &self,
190 project: &Table,
191 content: &str,
192 used_positions: &mut std::collections::HashSet<usize>,
193 ) -> Result<Vec<PypiDependency>> {
194 let Some(deps_item) = project.get("dependencies") else {
195 return Ok(Vec::new());
196 };
197
198 let Some(deps_array) = deps_item.as_array() else {
199 return Ok(Vec::new());
200 };
201
202 let mut dependencies = Vec::new();
203
204 for value in deps_array {
205 if let Some(dep_str) = value.as_str() {
206 let position = self
208 .find_dependency_string_position(content, dep_str, used_positions)
209 .map(|(p, _)| p);
210
211 match self.parse_pep508_requirement(dep_str, position) {
212 Ok(mut dep) => {
213 dep.section = PypiDependencySection::Dependencies;
214 dependencies.push(dep);
215 }
216 Err(e) => {
217 tracing::warn!("Failed to parse dependency '{}': {}", dep_str, e);
218 }
219 }
220 }
221 }
222
223 Ok(dependencies)
224 }
225
226 fn parse_pep621_optional_dependencies(
228 &self,
229 project: &Table,
230 content: &str,
231 used_positions: &mut std::collections::HashSet<usize>,
232 ) -> Result<Vec<PypiDependency>> {
233 let Some(opt_deps_item) = project.get("optional-dependencies") else {
234 return Ok(Vec::new());
235 };
236
237 let Some(opt_deps_table) = opt_deps_item.as_table() else {
238 return Ok(Vec::new());
239 };
240
241 let mut dependencies = Vec::new();
242
243 for (group_name, group_item) in opt_deps_table {
244 if let Some(group_array) = group_item.as_array() {
245 for value in group_array {
246 if let Some(dep_str) = value.as_str() {
247 let position = self
249 .find_dependency_string_position(content, dep_str, used_positions)
250 .map(|(p, _)| p);
251
252 match self.parse_pep508_requirement(dep_str, position) {
253 Ok(mut dep) => {
254 dep.section = PypiDependencySection::OptionalDependencies {
255 group: group_name.to_string(),
256 };
257 dependencies.push(dep);
258 }
259 Err(e) => {
260 tracing::warn!("Failed to parse dependency '{}': {}", dep_str, e);
261 }
262 }
263 }
264 }
265 }
266 }
267
268 Ok(dependencies)
269 }
270
271 fn parse_dependency_groups(
281 &self,
282 dep_groups: &Table,
283 content: &str,
284 used_positions: &mut std::collections::HashSet<usize>,
285 ) -> Result<Vec<PypiDependency>> {
286 let mut dependencies = Vec::new();
287
288 for (group_name, group_item) in dep_groups {
289 if let Some(group_array) = group_item.as_array() {
290 for value in group_array {
291 if let Some(dep_str) = value.as_str() {
292 let position = self
294 .find_dependency_string_position(content, dep_str, used_positions)
295 .map(|(p, _)| p);
296
297 match self.parse_pep508_requirement(dep_str, position) {
298 Ok(mut dep) => {
299 dep.section = PypiDependencySection::DependencyGroup {
300 group: group_name.to_string(),
301 };
302 dependencies.push(dep);
303 }
304 Err(e) => {
305 tracing::warn!(
306 "Failed to parse dependency group '{}' item '{}': {}",
307 group_name,
308 dep_str,
309 e
310 );
311 }
312 }
313 }
314 }
315 }
316 }
317
318 Ok(dependencies)
319 }
320
321 fn parse_poetry_dependencies(
323 &self,
324 poetry: &Table,
325 content: &str,
326 ) -> Result<Vec<PypiDependency>> {
327 let Some(deps_item) = poetry.get("dependencies") else {
328 return Ok(Vec::new());
329 };
330
331 let Some(deps_table) = deps_item.as_table() else {
332 return Ok(Vec::new());
333 };
334
335 let mut dependencies = Vec::new();
336
337 for (name, value) in deps_table {
338 if name == "python" {
340 continue;
341 }
342
343 let position = self.find_table_key_position(content, "tool.poetry.dependencies", name);
344
345 match self.parse_poetry_dependency(name, value, position) {
346 Ok(mut dep) => {
347 dep.section = PypiDependencySection::PoetryDependencies;
348 dependencies.push(dep);
349 }
350 Err(e) => {
351 tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
352 }
353 }
354 }
355
356 Ok(dependencies)
357 }
358
359 fn parse_poetry_groups(&self, poetry: &Table, content: &str) -> Result<Vec<PypiDependency>> {
361 let Some(group_item) = poetry.get("group") else {
362 return Ok(Vec::new());
363 };
364
365 let Some(groups_table) = group_item.as_table() else {
366 return Ok(Vec::new());
367 };
368
369 let mut dependencies = Vec::new();
370
371 for (group_name, group_item) in groups_table {
372 if let Some(group_table) = group_item.as_table()
373 && let Some(deps_item) = group_table.get("dependencies")
374 && let Some(deps_table) = deps_item.as_table()
375 {
376 for (name, value) in deps_table {
377 let section_path = format!("tool.poetry.group.{group_name}.dependencies");
378 let position = self.find_table_key_position(content, §ion_path, name);
379
380 match self.parse_poetry_dependency(name, value, position) {
381 Ok(mut dep) => {
382 dep.section = PypiDependencySection::PoetryGroup {
383 group: group_name.to_string(),
384 };
385 dependencies.push(dep);
386 }
387 Err(e) => {
388 tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
389 }
390 }
391 }
392 }
393 }
394
395 Ok(dependencies)
396 }
397
398 fn parse_pep508_requirement(
402 &self,
403 requirement_str: &str,
404 base_position: Option<Position>,
405 ) -> Result<PypiDependency> {
406 let requirement = Requirement::from_str(requirement_str)
407 .map_err(|e| PypiError::InvalidDependencySpec { source: e })?;
408
409 let name = requirement.name.to_string();
410 let name_range = base_position
411 .map(|pos| {
412 Range::new(
413 pos,
414 Position::new(pos.line, pos.character + name.len() as u32),
415 )
416 })
417 .unwrap_or_default();
418
419 let (version_req, version_range, source) = match requirement.version_or_url {
420 Some(VersionOrUrl::VersionSpecifier(specs)) => {
421 let version_str = specs.to_string();
422 let extras_str_len = if requirement.extras.is_empty() {
426 0
427 } else {
428 let extras_joined = requirement
430 .extras
431 .iter()
432 .map(std::string::ToString::to_string)
433 .collect::<Vec<_>>()
434 .join(",");
435 extras_joined.len() + 2 };
437 let start_offset = name.len() + extras_str_len;
438
439 let original_version_len = requirement_str.len() - start_offset;
443
444 let version_range = base_position.map(|pos| {
445 Range::new(
446 Position::new(pos.line, pos.character + start_offset as u32),
447 Position::new(
448 pos.line,
449 pos.character + start_offset as u32 + original_version_len as u32,
450 ),
451 )
452 });
453 (Some(version_str), version_range, PypiDependencySource::PyPI)
454 }
455 Some(VersionOrUrl::Url(url)) => {
456 let url_str = url.to_string();
457 if url_str.starts_with("git+") {
458 (
459 None,
460 None,
461 PypiDependencySource::Git {
462 url: url_str,
463 rev: None,
464 },
465 )
466 } else if url_str.ends_with(".whl") || url_str.ends_with(".tar.gz") {
467 (None, None, PypiDependencySource::Url { url: url_str })
468 } else {
469 (None, None, PypiDependencySource::PyPI)
470 }
471 }
472 None => (None, None, PypiDependencySource::PyPI),
473 };
474
475 let extras: Vec<String> = requirement
476 .extras
477 .into_iter()
478 .map(|e| e.to_string())
479 .collect();
480 let markers = None;
483
484 Ok(PypiDependency {
485 name,
486 name_range,
487 version_req,
488 version_range,
489 extras,
490 extras_range: None,
491 markers,
492 markers_range: None,
493 section: PypiDependencySection::Dependencies,
494 source,
495 })
496 }
497
498 fn parse_poetry_dependency(
504 &self,
505 name: &str,
506 value: &Item,
507 base_position: Option<Position>,
508 ) -> Result<PypiDependency> {
509 let name_range = base_position
510 .map(|pos| {
511 Range::new(
512 pos,
513 Position::new(pos.line, pos.character + name.len() as u32),
514 )
515 })
516 .unwrap_or_default();
517
518 if let Some(version_str) = value.as_str() {
520 let version_range = base_position.map(|pos| {
521 Range::new(
522 Position::new(pos.line, pos.character + name.len() as u32 + 3),
523 Position::new(
524 pos.line,
525 pos.character + name.len() as u32 + 3 + version_str.len() as u32,
526 ),
527 )
528 });
529
530 return Ok(PypiDependency {
531 name: name.to_string(),
532 name_range,
533 version_req: Some(version_str.to_string()),
534 version_range,
535 extras: Vec::new(),
536 extras_range: None,
537 markers: None,
538 markers_range: None,
539 section: PypiDependencySection::PoetryDependencies,
540 source: PypiDependencySource::PyPI,
541 });
542 }
543
544 if let Some(table) = value.as_table() {
546 let version_req = table
547 .get("version")
548 .and_then(|v| v.as_str())
549 .map(String::from);
550 let extras = table
551 .get("extras")
552 .and_then(|e| e.as_array())
553 .map(|arr| {
554 arr.iter()
555 .filter_map(|v| v.as_str().map(String::from))
556 .collect()
557 })
558 .unwrap_or_default();
559
560 let markers = table
561 .get("markers")
562 .and_then(|m| m.as_str())
563 .map(String::from);
564
565 let source = if table.contains_key("git") {
566 PypiDependencySource::Git {
567 url: table
568 .get("git")
569 .and_then(|g| g.as_str())
570 .unwrap_or("")
571 .to_string(),
572 rev: table.get("rev").and_then(|r| r.as_str()).map(String::from),
573 }
574 } else if table.contains_key("path") {
575 PypiDependencySource::Path {
576 path: table
577 .get("path")
578 .and_then(|p| p.as_str())
579 .unwrap_or("")
580 .to_string(),
581 }
582 } else if table.contains_key("url") {
583 PypiDependencySource::Url {
584 url: table
585 .get("url")
586 .and_then(|u| u.as_str())
587 .unwrap_or("")
588 .to_string(),
589 }
590 } else {
591 PypiDependencySource::PyPI
592 };
593
594 return Ok(PypiDependency {
595 name: name.to_string(),
596 name_range,
597 version_req,
598 version_range: None,
599 extras,
600 extras_range: None,
601 markers,
602 markers_range: None,
603 section: PypiDependencySection::PoetryDependencies,
604 source,
605 });
606 }
607
608 Err(PypiError::unsupported_format(format!(
609 "Unsupported Poetry dependency format for '{name}'"
610 )))
611 }
612
613 fn find_dependency_string_position(
626 &self,
627 content: &str,
628 dep_str: &str,
629 used_positions: &mut std::collections::HashSet<usize>,
630 ) -> Option<(Position, usize)> {
631 let bytes = content.as_bytes();
632
633 for (pos, _) in content.match_indices(dep_str) {
635 if used_positions.contains(&pos) {
636 continue;
637 }
638
639 if pos > 0 {
641 let opening_quote = bytes[pos - 1];
642 if opening_quote == b'"' || opening_quote == b'\'' {
643 let end_pos = pos + dep_str.len();
645 if end_pos < bytes.len() && bytes[end_pos] == opening_quote {
646 let before = &content[..pos];
649 let line = before.chars().filter(|&c| c == '\n').count() as u32;
650 let last_newline = before.rfind('\n').map_or(0, |p| p + 1);
651 let character = (pos - last_newline) as u32;
652
653 used_positions.insert(pos);
654 return Some((Position::new(line, character), pos));
655 }
656 }
657 }
658 }
659
660 None
661 }
662
663 fn find_table_key_position(&self, content: &str, section: &str, key: &str) -> Option<Position> {
665 let section_marker = format!("[{section}]");
667 let section_start = content.find(§ion_marker)?;
668
669 let after_section = &content[section_start..];
671 let key_pattern = format!("{key} = ");
672 let key_pos = after_section.find(&key_pattern)?;
673
674 let total_offset = section_start + key_pos;
675 let before_key = &content[..total_offset];
676 let line = before_key.chars().filter(|&c| c == '\n').count() as u32;
677 let last_newline = before_key.rfind('\n').map_or(0, |p| p + 1);
678 let character = (total_offset - last_newline) as u32;
679
680 Some(Position::new(line, character))
681 }
682}
683
684impl Default for PypiParser {
685 fn default() -> Self {
686 Self::new()
687 }
688}
689
690impl deps_core::ManifestParser for PypiParser {
693 type Dependency = PypiDependency;
694 type ParseResult = ParseResult;
695
696 fn parse(&self, content: &str, doc_uri: &Uri) -> deps_core::error::Result<Self::ParseResult> {
697 self.parse_content(content, doc_uri)
698 .map_err(|e| deps_core::error::DepsError::ParseError {
699 file_type: "pyproject.toml".to_string(),
700 source: Box::new(e),
701 })
702 }
703}
704
705impl deps_core::DependencyInfo for PypiDependency {
706 fn name(&self) -> &str {
707 &self.name
708 }
709
710 fn name_range(&self) -> Range {
711 self.name_range
712 }
713
714 fn version_requirement(&self) -> Option<&str> {
715 self.version_req.as_deref()
716 }
717
718 fn version_range(&self) -> Option<Range> {
719 self.version_range
720 }
721
722 fn source(&self) -> deps_core::DependencySource {
723 match &self.source {
724 PypiDependencySource::PyPI => deps_core::DependencySource::Registry,
725 PypiDependencySource::Git { url, rev } => deps_core::DependencySource::Git {
726 url: url.clone(),
727 rev: rev.clone(),
728 },
729 PypiDependencySource::Path { path } => {
730 deps_core::DependencySource::Path { path: path.clone() }
731 }
732 PypiDependencySource::Url { .. } => deps_core::DependencySource::Registry,
734 }
735 }
736
737 fn features(&self) -> &[String] {
738 &self.extras
739 }
740}
741
742impl deps_core::ParseResultInfo for ParseResult {
743 type Dependency = PypiDependency;
744
745 fn dependencies(&self) -> &[Self::Dependency] {
746 &self.dependencies
747 }
748
749 fn workspace_root(&self) -> Option<&std::path::Path> {
750 self.workspace_root.as_deref()
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 fn test_uri() -> Uri {
759 Uri::from_file_path("/test/pyproject.toml").unwrap()
760 }
761
762 #[test]
763 fn test_parse_pep621_dependencies() {
764 let content = r#"
765[project]
766dependencies = [
767 "requests>=2.28.0",
768 "flask[async]>=3.0",
769]
770"#;
771
772 let parser = PypiParser::new();
773 let result = parser.parse_content(content, &test_uri()).unwrap();
774 let deps = &result.dependencies;
775
776 assert_eq!(deps.len(), 2);
777 assert_eq!(deps[0].name, "requests");
778 assert_eq!(deps[0].version_req, Some(">=2.28.0".to_string()));
779 assert!(matches!(
780 deps[0].section,
781 PypiDependencySection::Dependencies
782 ));
783
784 assert_eq!(deps[1].name, "flask");
785 assert_eq!(deps[1].extras, vec!["async"]);
786 }
787
788 #[test]
789 fn test_parse_pep621_optional_dependencies() {
790 let content = r#"
791[project.optional-dependencies]
792dev = ["pytest>=7.0", "mypy>=1.0"]
793docs = ["sphinx>=5.0"]
794"#;
795
796 let parser = PypiParser::new();
797 let result = parser.parse_content(content, &test_uri()).unwrap();
798 let deps = &result.dependencies;
799
800 assert_eq!(deps.len(), 3);
801
802 let dev_deps: Vec<_> = deps.iter().filter(|d| {
803 matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "dev")
804 }).collect();
805 assert_eq!(dev_deps.len(), 2);
806
807 let docs_deps: Vec<_> = deps.iter().filter(|d| {
808 matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "docs")
809 }).collect();
810 assert_eq!(docs_deps.len(), 1);
811 }
812
813 #[test]
814 fn test_parse_poetry_dependencies() {
815 let content = r#"
816[tool.poetry.dependencies]
817python = "^3.9"
818requests = "^2.28.0"
819"#;
820
821 let parser = PypiParser::new();
822 let result = parser.parse_content(content, &test_uri()).unwrap();
823 let deps = &result.dependencies;
824
825 assert_eq!(deps.len(), 1);
827 assert_eq!(deps[0].name, "requests");
828 assert!(matches!(
829 deps[0].section,
830 PypiDependencySection::PoetryDependencies
831 ));
832 }
833
834 #[test]
835 fn test_parse_poetry_groups() {
836 let content = r#"
837[tool.poetry.group.dev.dependencies]
838pytest = "^7.0"
839mypy = "^1.0"
840
841[tool.poetry.group.docs.dependencies]
842sphinx = "^5.0"
843"#;
844
845 let parser = PypiParser::new();
846 let result = parser.parse_content(content, &test_uri()).unwrap();
847 let deps = &result.dependencies;
848
849 assert_eq!(deps.len(), 3);
850
851 let dev_deps: Vec<_> = deps.iter().filter(|d| {
852 matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "dev")
853 }).collect();
854 assert_eq!(dev_deps.len(), 2);
855
856 let docs_deps: Vec<_> = deps.iter().filter(|d| {
857 matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "docs")
858 }).collect();
859 assert_eq!(docs_deps.len(), 1);
860 }
861
862 #[test]
863 fn test_parse_pep735_dependency_groups() {
864 let content = r#"
865[dependency-groups]
866dev = ["pytest>=8.0", "mypy>=1.0", "ruff>=0.8"]
867test = ["pytest>=8.0", "pytest-cov>=4.0"]
868"#;
869
870 let parser = PypiParser::new();
871 let result = parser.parse_content(content, &test_uri()).unwrap();
872 let deps = &result.dependencies;
873
874 assert_eq!(deps.len(), 5);
875
876 let dev_deps: Vec<_> = deps
877 .iter()
878 .filter(|d| {
879 matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "dev")
880 })
881 .collect();
882 assert_eq!(dev_deps.len(), 3);
883
884 let test_deps: Vec<_> = deps
885 .iter()
886 .filter(|d| {
887 matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "test")
888 })
889 .collect();
890 assert_eq!(test_deps.len(), 2);
891
892 assert!(dev_deps.iter().any(|d| d.name == "pytest"));
894 assert!(dev_deps.iter().any(|d| d.name == "mypy"));
895 assert!(dev_deps.iter().any(|d| d.name == "ruff"));
896 }
897
898 #[test]
899 fn test_parse_pep508_with_markers() {
900 let content = r#"
901[project]
902dependencies = [
903 "numpy>=1.24; python_version>='3.9'",
904]
905"#;
906
907 let parser = PypiParser::new();
908 let result = parser.parse_content(content, &test_uri()).unwrap();
909 let deps = &result.dependencies;
910
911 assert_eq!(deps.len(), 1);
912 assert_eq!(deps[0].name, "numpy");
913 assert_eq!(deps[0].markers, None);
916 }
917
918 #[test]
919 fn test_parse_mixed_formats() {
920 let content = r#"
921[project]
922dependencies = ["requests>=2.28.0"]
923
924[tool.poetry.dependencies]
925python = "^3.9"
926flask = "^3.0"
927"#;
928
929 let parser = PypiParser::new();
930 let result = parser.parse_content(content, &test_uri()).unwrap();
931 let deps = &result.dependencies;
932
933 assert_eq!(deps.len(), 2);
934
935 let pep621_deps: Vec<_> = deps
936 .iter()
937 .filter(|d| matches!(d.section, PypiDependencySection::Dependencies))
938 .collect();
939 assert_eq!(pep621_deps.len(), 1);
940
941 let poetry_deps: Vec<_> = deps
942 .iter()
943 .filter(|d| matches!(d.section, PypiDependencySection::PoetryDependencies))
944 .collect();
945 assert_eq!(poetry_deps.len(), 1);
946 }
947
948 #[test]
949 fn test_parse_invalid_toml() {
950 let content = "invalid toml {{{";
951 let parser = PypiParser::new();
952 let result = parser.parse_content(content, &test_uri());
953
954 assert!(result.is_err());
955 assert!(matches!(
956 result.unwrap_err(),
957 PypiError::TomlParseError { .. }
958 ));
959 }
960
961 #[test]
962 fn test_parse_empty_dependencies() {
963 let content = r#"
964[project]
965name = "test"
966"#;
967
968 let parser = PypiParser::new();
969 let result = parser.parse_content(content, &test_uri()).unwrap();
970 let deps = &result.dependencies;
971
972 assert_eq!(deps.len(), 0);
973 }
974
975 #[test]
976 fn test_position_tracking_pep735() {
977 let content = r#"[dependency-groups]
979dev = ["pytest>=8.0", "mypy>=1.0"]
980"#;
981
982 let parser = PypiParser::new();
983 let result = parser.parse_content(content, &test_uri()).unwrap();
984 let deps = &result.dependencies;
985
986 assert_eq!(deps.len(), 2);
987
988 let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
990 assert_eq!(pytest.name_range.start.line, 1);
992 assert_eq!(pytest.name_range.start.character, 8);
993 assert!(pytest.version_range.is_some());
995 let version_range = pytest.version_range.unwrap();
996 assert_eq!(version_range.start.line, 1);
997 assert_eq!(version_range.start.character, 14);
999 assert_eq!(version_range.end.character, 19);
1001
1002 let mypy = deps.iter().find(|d| d.name == "mypy").unwrap();
1004 assert_eq!(mypy.name_range.start.line, 1);
1005 assert_eq!(mypy.name_range.start.character, 23);
1008 assert!(mypy.version_range.is_some());
1009 let version_range = mypy.version_range.unwrap();
1010 assert_eq!(version_range.start.character, 27);
1012 assert_eq!(version_range.end.character, 32);
1014 }
1015
1016 #[test]
1017 fn test_version_range_position_without_space() {
1018 let content = r#"[dependency-groups]
1021dev = [
1022 "maturin>=1.7,<2.0",
1023]
1024"#;
1025 let parser = PypiParser::new();
1032 let result = parser.parse_content(content, &test_uri()).unwrap();
1033 let maturin = &result.dependencies[0];
1034
1035 let version_range = maturin.version_range.unwrap();
1036 assert_eq!(version_range.start.line, 2);
1037 assert_eq!(version_range.start.character, 12); assert_eq!(version_range.end.line, 2);
1039 assert_eq!(version_range.end.character, 22); }
1041
1042 #[test]
1043 fn test_version_range_position_with_space() {
1044 let content = r#"[dependency-groups]
1046dev = [
1047 "maturin>=1.7, <2.0",
1048]
1049"#;
1050 let parser = PypiParser::new();
1053 let result = parser.parse_content(content, &test_uri()).unwrap();
1054 let maturin = &result.dependencies[0];
1055
1056 let version_range = maturin.version_range.unwrap();
1057 assert_eq!(version_range.start.character, 12);
1058 assert_eq!(version_range.end.character, 23);
1059 }
1060
1061 #[test]
1062 fn test_position_tracking_with_extras() {
1063 let content = r#"[project]
1064dependencies = ["flask[async]>=3.0"]
1065"#;
1066
1067 let parser = PypiParser::new();
1068 let result = parser.parse_content(content, &test_uri()).unwrap();
1069 let deps = &result.dependencies;
1070
1071 assert_eq!(deps.len(), 1);
1072
1073 let flask = &deps[0];
1074 assert_eq!(flask.name, "flask");
1075 assert_eq!(flask.extras, vec!["async"]);
1076
1077 assert!(flask.version_range.is_some());
1079 let version_range = flask.version_range.unwrap();
1080 assert_eq!(version_range.start.character, 29);
1083 }
1084
1085 #[test]
1086 fn test_parse_pep621_with_comments() {
1087 let toml = r#"
1088[project]
1089name = "test"
1090dependencies = [
1091 "django>=4.0", # Web framework
1092 # "old-package>=1.0", # Commented out
1093 "requests>=2.0",
1094]
1095"#;
1096 let parser = PypiParser::new();
1097 let result = parser.parse_content(toml, &test_uri()).unwrap();
1098 let deps = &result.dependencies;
1099 assert_eq!(deps.len(), 2);
1100 assert_eq!(deps[0].name, "django");
1101 assert_eq!(deps[1].name, "requests");
1102 }
1103
1104 #[test]
1105 fn test_parse_poetry_with_python_constraint() {
1106 let toml = r#"
1107[tool.poetry]
1108name = "test"
1109
1110[tool.poetry.dependencies]
1111python = "^3.9"
1112django = "^4.0"
1113"#;
1114 let parser = PypiParser::new();
1115 let result = parser.parse_content(toml, &test_uri()).unwrap();
1116 let deps = &result.dependencies;
1117 assert_eq!(deps.len(), 1);
1118 assert_eq!(deps[0].name, "django");
1119 }
1120
1121 #[test]
1122 fn test_parse_pep508_with_platform_marker() {
1123 let toml = r#"
1124[project]
1125dependencies = [
1126 "pywin32>=1.0; sys_platform == 'win32'",
1127 "django>=4.0",
1128]
1129"#;
1130 let parser = PypiParser::new();
1131 let result = parser.parse_content(toml, &test_uri()).unwrap();
1132 let deps = &result.dependencies;
1133 assert_eq!(deps.len(), 2);
1134 assert_eq!(deps[0].name, "pywin32");
1135 assert_eq!(deps[1].name, "django");
1136 }
1137
1138 #[test]
1139 fn test_parse_poetry_with_multiple_constraints() {
1140 let toml = r#"
1141[tool.poetry.dependencies]
1142django = { version = "^4.0", python = "^3.9" }
1143"#;
1144 let parser = PypiParser::new();
1145 let result = parser.parse_content(toml, &test_uri()).unwrap();
1146 let deps = &result.dependencies;
1147 if !deps.is_empty() {
1149 assert_eq!(deps[0].name, "django");
1150 assert_eq!(deps[0].version_req.as_deref(), Some("^4.0"));
1151 }
1152 }
1153
1154 #[test]
1155 fn test_parse_pep621_with_git_url() {
1156 let toml = r#"
1157[project]
1158dependencies = [
1159 "mylib @ git+https://github.com/user/mylib.git@main",
1160 "django>=4.0",
1161]
1162"#;
1163 let parser = PypiParser::new();
1164 let result = parser.parse_content(toml, &test_uri()).unwrap();
1165 let deps = &result.dependencies;
1166 assert_eq!(deps.len(), 2);
1167 assert_eq!(deps[0].name, "mylib");
1168 assert!(matches!(deps[0].source, PypiDependencySource::Git { .. }));
1169 assert_eq!(deps[1].name, "django");
1170 }
1171
1172 #[test]
1173 fn test_parse_empty_optional_dependencies_table() {
1174 let toml = r#"
1175[project]
1176dependencies = ["django>=4.0"]
1177
1178[project.optional-dependencies]
1179"#;
1180 let parser = PypiParser::new();
1181 let result = parser.parse_content(toml, &test_uri()).unwrap();
1182 let deps = &result.dependencies;
1183 assert_eq!(deps.len(), 1);
1184 assert_eq!(deps[0].name, "django");
1185 }
1186
1187 #[test]
1188 fn test_parse_whitespace_only_dependency() {
1189 let toml = r#"
1190[project]
1191dependencies = [
1192 "django>=4.0",
1193 " ",
1194 "requests>=2.0",
1195]
1196"#;
1197 let parser = PypiParser::new();
1198 let result = parser.parse_content(toml, &test_uri()).unwrap();
1199 let deps = &result.dependencies;
1200 assert_eq!(deps.len(), 2);
1201 }
1202
1203 #[test]
1204 fn test_parse_version_with_wildcard() {
1205 let toml = r#"
1206[project]
1207dependencies = [
1208 "django==4.*",
1209]
1210"#;
1211 let parser = PypiParser::new();
1212 let result = parser.parse_content(toml, &test_uri()).unwrap();
1213 let deps = &result.dependencies;
1214 assert_eq!(deps.len(), 1);
1215 assert_eq!(deps[0].version_req.as_deref(), Some("==4.*"));
1216 }
1217
1218 #[test]
1219 fn test_parse_poetry_path_dependency() {
1220 let toml = r#"
1221[tool.poetry.dependencies]
1222mylib = { path = "../mylib" }
1223django = "^4.0"
1224"#;
1225 let parser = PypiParser::new();
1226 let result = parser.parse_content(toml, &test_uri()).unwrap();
1227 let deps = &result.dependencies;
1228 let django_dep = deps.iter().find(|d| d.name == "django");
1230 assert!(django_dep.is_some());
1231 }
1232
1233 #[test]
1234 fn test_parse_pep735_with_includes() {
1235 let toml = r#"
1236[dependency-groups]
1237test = [
1238 { include-group = "dev" },
1239 "pytest>=7.0",
1240]
1241dev = [
1242 "ruff>=0.1",
1243]
1244"#;
1245 let parser = PypiParser::new();
1246 let result = parser.parse_content(toml, &test_uri()).unwrap();
1247 let deps = &result.dependencies;
1248 assert!(deps.len() >= 2);
1249 assert!(deps.iter().any(|d| d.name == "pytest"));
1250 assert!(deps.iter().any(|d| d.name == "ruff"));
1251 }
1252
1253 #[test]
1254 fn test_parse_complex_version_specifier() {
1255 let toml = r#"
1256[project]
1257dependencies = [
1258 "django>=4.0,<5.0,!=4.0.1",
1259]
1260"#;
1261 let parser = PypiParser::new();
1262 let result = parser.parse_content(toml, &test_uri()).unwrap();
1263 let deps = &result.dependencies;
1264 assert_eq!(deps.len(), 1);
1265 assert_eq!(deps[0].name, "django");
1266 assert!(deps[0].version_req.is_some());
1268 }
1269
1270 #[test]
1271 fn test_parse_no_project_section() {
1272 let toml = r#"
1273[tool.my-custom-tool]
1274config = "value"
1275"#;
1276 let parser = PypiParser::new();
1277 let result = parser.parse_content(toml, &test_uri()).unwrap();
1278 let deps = &result.dependencies;
1279 assert_eq!(deps.len(), 0);
1280 }
1281
1282 #[test]
1283 fn test_parse_build_system_requires() {
1284 let toml = r#"
1285[build-system]
1286requires = ["setuptools>=61.0", "wheel", "maturin>=1.7,<2.0"]
1287build-backend = "setuptools.build_meta"
1288"#;
1289 let parser = PypiParser::new();
1290 let result = parser.parse_content(toml, &test_uri()).unwrap();
1291 let deps = &result.dependencies;
1292
1293 assert_eq!(deps.len(), 3);
1294 assert!(
1295 deps.iter()
1296 .all(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1297 );
1298
1299 let setuptools = deps.iter().find(|d| d.name == "setuptools").unwrap();
1300 assert_eq!(setuptools.version_req, Some(">=61.0".to_string()));
1301
1302 let maturin = deps.iter().find(|d| d.name == "maturin").unwrap();
1303 assert_eq!(maturin.version_req, Some(">=1.7, <2.0".to_string()));
1304
1305 let wheel = deps.iter().find(|d| d.name == "wheel").unwrap();
1307 assert_eq!(wheel.version_req, None);
1308 }
1309
1310 #[test]
1311 fn test_parse_duplicate_dependency_positions() {
1312 let toml = r#"[build-system]
1314requires = ["maturin>=1.7,<2.0"]
1315
1316[dependency-groups]
1317dev = ["maturin>=1.7,<2.0"]
1318"#;
1319 let parser = PypiParser::new();
1320 let result = parser.parse_content(toml, &test_uri()).unwrap();
1321 let deps = &result.dependencies;
1322
1323 assert_eq!(deps.len(), 2);
1324
1325 let build_system_maturin = deps
1327 .iter()
1328 .find(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1329 .unwrap();
1330 assert_eq!(build_system_maturin.name_range.start.line, 1);
1331
1332 let dep_group_maturin = deps
1334 .iter()
1335 .find(|d| matches!(d.section, PypiDependencySection::DependencyGroup { .. }))
1336 .unwrap();
1337 assert_eq!(dep_group_maturin.name_range.start.line, 4);
1338 }
1339
1340 #[test]
1341 fn test_version_range_for_code_actions() {
1342 let toml = r#"[dependency-groups]
1344dev = ["pytest-cov>=4.0,<8.0"]
1345"#;
1346 let parser = PypiParser::new();
1353 let result = parser.parse_content(toml, &test_uri()).unwrap();
1354 let deps = &result.dependencies;
1355
1356 assert_eq!(deps.len(), 1);
1357 let dep = &deps[0];
1358
1359 assert_eq!(dep.name, "pytest-cov");
1360 assert_eq!(dep.name_range.start.line, 1);
1361 assert_eq!(dep.name_range.start.character, 8); let version_range = dep.version_range.expect("version_range should be set");
1365 assert_eq!(version_range.start.line, 1);
1366 assert_eq!(version_range.start.character, 18);
1368 assert_eq!(version_range.end.character, 28);
1370
1371 let cursor_on_version = Position::new(1, 20);
1373 assert!(
1374 cursor_on_version.character >= version_range.start.character
1375 && cursor_on_version.character < version_range.end.character,
1376 "cursor at {} should be within version_range {}..{}",
1377 cursor_on_version.character,
1378 version_range.start.character,
1379 version_range.end.character
1380 );
1381 }
1382
1383 #[test]
1384 fn test_version_range_with_space_before_specifier() {
1385 let toml = r#"[dependency-groups]
1387dev = ["pytest-cov >=4.0,<8.0"]
1388"#;
1389 let parser = PypiParser::new();
1395 let result = parser.parse_content(toml, &test_uri()).unwrap();
1396 let deps = &result.dependencies;
1397
1398 assert_eq!(deps.len(), 1);
1399 let dep = &deps[0];
1400
1401 let version_range = dep.version_range.expect("version_range should be set");
1403 assert_eq!(version_range.start.line, 1);
1404 assert_eq!(version_range.start.character, 18);
1406 assert_eq!(version_range.end.character, 29);
1408
1409 let cursor_on_version = Position::new(1, 21);
1411 assert!(
1412 cursor_on_version.character >= version_range.start.character
1413 && cursor_on_version.character < version_range.end.character,
1414 "cursor at {} should be within version_range {}..{}",
1415 cursor_on_version.character,
1416 version_range.start.character,
1417 version_range.end.character
1418 );
1419 }
1420}