deps_pypi/
parser.rs

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/// Parse result containing all dependencies from pyproject.toml.
10///
11/// Stores dependencies and optional workspace information for LSP operations.
12#[derive(Debug, Clone)]
13pub struct ParseResult {
14    /// All dependencies found in the manifest
15    pub dependencies: Vec<PypiDependency>,
16    /// Workspace root path (None for Python - no workspace concept like Cargo)
17    pub workspace_root: Option<std::path::PathBuf>,
18    /// URI of the parsed file
19    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
43/// Parser for Python pyproject.toml files.
44///
45/// Supports both PEP 621 standard format and Poetry format.
46/// Uses `toml_edit` to preserve source positions for LSP operations.
47///
48/// # Examples
49///
50/// ```no_run
51/// use deps_pypi::parser::PypiParser;
52/// use tower_lsp_server::ls_types::Uri;
53///
54/// let content = r#"
55/// [project]
56/// dependencies = ["requests>=2.28.0", "flask[async]>=3.0"]
57/// "#;
58///
59/// let parser = PypiParser::new();
60/// let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
61/// let result = parser.parse_content(content, &uri).unwrap();
62/// assert_eq!(result.dependencies.len(), 2);
63/// ```
64pub struct PypiParser;
65
66impl PypiParser {
67    /// Create a new PyPI parser.
68    pub const fn new() -> Self {
69        Self
70    }
71
72    /// Parse pyproject.toml content and extract all dependencies.
73    ///
74    /// Parses both PEP 621 and Poetry formats in a single pass.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if:
79    /// - TOML is malformed
80    /// - PEP 508 dependency specifications are invalid
81    ///
82    /// # Examples
83    ///
84    /// ```no_run
85    /// # use deps_pypi::parser::PypiParser;
86    /// # use tower_lsp_server::ls_types::Uri;
87    /// let parser = PypiParser::new();
88    /// let content = std::fs::read_to_string("pyproject.toml").unwrap();
89    /// let uri = Uri::from_file_path("/project/pyproject.toml").unwrap();
90    /// let result = parser.parse_content(&content, &uri).unwrap();
91    /// ```
92    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        // Track used positions to handle duplicate dependency strings across sections
99        let mut used_positions = std::collections::HashSet::new();
100
101        // Parse build-system requires (PEP 517/518)
102        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        // Parse PEP 621 format
111        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        // Parse PEP 735 dependency-groups format
125        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        // Parse Poetry format
134        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    /// Parse PEP 517/518 `[build-system]` requires array.
149    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                // Find exact position of this dependency string in content
168                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    /// Parse PEP 621 `[project.dependencies]` array.
188    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                // Find exact position of this dependency string in content
207                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    /// Parse PEP 621 `[project.optional-dependencies]` tables.
227    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                        // Find exact position of this dependency string in content
248                        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    /// Parse PEP 735 `[dependency-groups]` tables.
272    ///
273    /// Format: `[dependency-groups]` with named groups containing arrays of PEP 508 requirements.
274    /// Example:
275    /// ```toml
276    /// [dependency-groups]
277    /// dev = ["pytest>=8.0", "mypy>=1.0"]
278    /// test = ["pytest>=8.0", "pytest-cov>=4.0"]
279    /// ```
280    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                        // Find exact position of this dependency string in content
293                        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    /// Parse Poetry `[tool.poetry.dependencies]` table.
322    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            // Skip Python version constraint
339            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    /// Parse Poetry `[tool.poetry.group.*.dependencies]` tables.
360    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, &section_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    /// Parse a PEP 508 requirement string.
399    ///
400    /// Example: `requests[security,socks]>=2.28.0,<3.0; python_version>='3.8'`
401    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                // Calculate offset from name start to version specifier
423                // For "package>=1.0": offset = len("package") = 7
424                // For "package[extra]>=1.0": offset = len("package[extra]") = 14
425                let extras_str_len = if requirement.extras.is_empty() {
426                    0
427                } else {
428                    // Format: "[extra1,extra2]"
429                    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 // +2 for [ and ]
436                };
437                let start_offset = name.len() + extras_str_len;
438
439                // Calculate original version length from requirement_str
440                // pep508 normalizes version specifiers (e.g., ">=1.7,<2.0" -> ">=1.7, <2.0")
441                // We need the original length for correct position tracking
442                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        // For now, skip markers - we'll implement proper MarkerTree serialization later
481        // TODO: Implement proper marker serialization
482        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    /// Parse a Poetry dependency (can be string or table).
499    ///
500    /// Examples:
501    /// - String: `requests = "^2.28.0"`
502    /// - Table: `flask = { version = "^3.0", extras = ["async"] }`
503    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        // Simple string version
519        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        // Table format
545        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    /// Find the exact position of a dependency string in the content.
614    /// Returns the position at the START of the package name (for name_range)
615    /// and can be used to calculate version_range.
616    ///
617    /// `used_positions` tracks byte offsets that have already been used,
618    /// allowing us to find duplicate strings at different positions.
619    /// Returns `(position, byte_offset)` where `byte_offset` is added to
620    /// `used_positions` to track this occurrence.
621    ///
622    /// Performance optimization: Single-pass search without allocations.
623    /// Instead of formatting quoted strings and searching twice, we search
624    /// for the dependency string once and validate quotes in place.
625    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        // Single pass: search for the dependency string and validate quotes
634        for (pos, _) in content.match_indices(dep_str) {
635            if used_positions.contains(&pos) {
636                continue;
637            }
638
639            // Check if preceded by opening quote (either " or ')
640            if pos > 0 {
641                let opening_quote = bytes[pos - 1];
642                if opening_quote == b'"' || opening_quote == b'\'' {
643                    // Validate matching closing quote exists
644                    let end_pos = pos + dep_str.len();
645                    if end_pos < bytes.len() && bytes[end_pos] == opening_quote {
646                        // Calculate LSP position (line and character)
647                        // pos is now at the start of dep_str (after opening quote)
648                        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    /// Find position of table key in source content.
664    fn find_table_key_position(&self, content: &str, section: &str, key: &str) -> Option<Position> {
665        // Find section first
666        let section_marker = format!("[{section}]");
667        let section_start = content.find(&section_marker)?;
668
669        // Find the key after the section
670        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
690// Implement deps_core traits for interoperability with LSP server
691
692impl 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            // URL dependencies are treated as Registry since they're still remote packages
733            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        // Should skip "python"
826        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        // Verify package names
893        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        // TODO: Implement proper marker serialization from MarkerTree
914        // assert_eq!(deps[0].markers, Some("python_version >= '3.9'".to_string()));
915        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        // Test that position tracking works correctly for PEP 735 dependency-groups
978        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        // Check pytest>=8.0 position
989        let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
990        // Line 1 (0-indexed), character should be at 'p' (position 8 after `dev = ["`)
991        assert_eq!(pytest.name_range.start.line, 1);
992        assert_eq!(pytest.name_range.start.character, 8);
993        // Version range should point to >=8.0
994        assert!(pytest.version_range.is_some());
995        let version_range = pytest.version_range.unwrap();
996        assert_eq!(version_range.start.line, 1);
997        // pytest is 6 chars, so version starts at 8 + 6 = 14
998        assert_eq!(version_range.start.character, 14);
999        // >=8.0 is 5 chars, so version ends at 14 + 5 = 19
1000        assert_eq!(version_range.end.character, 19);
1001
1002        // Check mypy>=1.0 position
1003        let mypy = deps.iter().find(|d| d.name == "mypy").unwrap();
1004        assert_eq!(mypy.name_range.start.line, 1);
1005        // mypy starts after `dev = ["pytest>=8.0", "` = position 23
1006        // dev = ["pytest>=8.0", " = 22 chars, then position 22 is ", position 23 is m
1007        assert_eq!(mypy.name_range.start.character, 23);
1008        assert!(mypy.version_range.is_some());
1009        let version_range = mypy.version_range.unwrap();
1010        // mypy is 4 chars, so version starts at 23 + 4 = 27
1011        assert_eq!(version_range.start.character, 27);
1012        // >=1.0 is 5 chars, so version ends at 27 + 5 = 32
1013        assert_eq!(version_range.end.character, 32);
1014    }
1015
1016    #[test]
1017    fn test_version_range_position_without_space() {
1018        // Bug: pep508 normalizes ">=1.7,<2.0" to ">=1.7, <2.0" (adds space)
1019        // Version range end must use original string length, not normalized
1020        let content = r#"[dependency-groups]
1021dev = [
1022    "maturin>=1.7,<2.0",
1023]
1024"#;
1025        // Line 0: [dependency-groups]
1026        // Line 1: dev = [
1027        // Line 2:     "maturin>=1.7,<2.0",
1028        //             ^    ^         ^
1029        //             5    12        22 (end of version, before closing quote)
1030
1031        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); // after "maturin"
1038        assert_eq!(version_range.end.line, 2);
1039        assert_eq!(version_range.end.character, 22); // ">=1.7,<2.0" = 10 chars
1040    }
1041
1042    #[test]
1043    fn test_version_range_position_with_space() {
1044        // With space in original - should also work correctly
1045        let content = r#"[dependency-groups]
1046dev = [
1047    "maturin>=1.7, <2.0",
1048]
1049"#;
1050        // ">=1.7, <2.0" = 11 chars, end at 12 + 11 = 23
1051
1052        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        // Version range should account for extras
1078        assert!(flask.version_range.is_some());
1079        let version_range = flask.version_range.unwrap();
1080        // dependencies = [" is 17 chars, flask starts at char 17
1081        // flask is 5 chars, [async] is 7 chars, so version starts at 17 + 5 + 7 = 29
1082        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        // Poetry table-style with python constraints may not be fully parsed yet
1148        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        // Poetry path dependencies may not be fully parsed yet
1229        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        // Version specifier should be preserved
1267        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        // wheel has no version constraint
1306        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        // Test that duplicate dependency strings get correct positions
1313        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        // First maturin in [build-system] should be on line 1
1326        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        // Second maturin in [dependency-groups] should be on line 4
1333        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        // Test that version_range correctly covers the version specifier for code actions
1343        let toml = r#"[dependency-groups]
1344dev = ["pytest-cov>=4.0,<8.0"]
1345"#;
1346        // Line 0: [dependency-groups]
1347        // Line 1: dev = ["pytest-cov>=4.0,<8.0"]
1348        //               ^          ^         ^
1349        //               8          18        28 (positions)
1350        //               name_start version_start version_end
1351
1352        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); // after `dev = ["`
1362
1363        // Version range should cover >=4.0,<8.0
1364        let version_range = dep.version_range.expect("version_range should be set");
1365        assert_eq!(version_range.start.line, 1);
1366        // pytest-cov is 10 chars, so version starts at 8 + 10 = 18
1367        assert_eq!(version_range.start.character, 18);
1368        // >=4.0,<8.0 is 10 chars, so version ends at 18 + 10 = 28
1369        assert_eq!(version_range.end.character, 28);
1370
1371        // Verify that cursor at position 20 (on '4') is within version_range
1372        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        // Test version_range when there's a space between name and version specifier
1386        let toml = r#"[dependency-groups]
1387dev = ["pytest-cov >=4.0,<8.0"]
1388"#;
1389        // Line 1: dev = ["pytest-cov >=4.0,<8.0"]
1390        //               ^          ^          ^
1391        //               8          18         29 (positions)
1392        //               name_start space+ver  version_end
1393
1394        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        // Version range should cover " >=4.0,<8.0" (with leading space)
1402        let version_range = dep.version_range.expect("version_range should be set");
1403        assert_eq!(version_range.start.line, 1);
1404        // pytest-cov is 10 chars, so version_range starts at 8 + 10 = 18 (the space)
1405        assert_eq!(version_range.start.character, 18);
1406        // " >=4.0,<8.0" is 11 chars, so version ends at 18 + 11 = 29
1407        assert_eq!(version_range.end.character, 29);
1408
1409        // Verify that cursor at position 21 (on '>') is within version_range
1410        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}