deps_pypi/
types.rs

1use std::any::Any;
2use tower_lsp_server::ls_types::Range;
3
4/// Parsed dependency from pyproject.toml with position tracking.
5///
6/// Stores all information about a Python dependency declaration, including its name,
7/// version requirement, extras, environment markers, and source positions for LSP operations.
8/// Positions are critical for features like hover, completion, and inlay hints.
9///
10/// # Examples
11///
12/// ```
13/// use deps_pypi::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
14/// use tower_lsp_server::ls_types::{Position, Range};
15///
16/// let dep = PypiDependency {
17///     name: "requests".into(),
18///     name_range: Range::new(Position::new(5, 4), Position::new(5, 12)),
19///     version_req: Some(">=2.28.0,<3.0".into()),
20///     version_range: Some(Range::new(Position::new(5, 13), Position::new(5, 27))),
21///     extras: vec!["security".into()],
22///     extras_range: None,
23///     markers: Some("python_version>='3.8'".into()),
24///     markers_range: None,
25///     section: PypiDependencySection::Dependencies,
26///     source: PypiDependencySource::PyPI,
27/// };
28///
29/// assert_eq!(dep.name, "requests");
30/// assert!(matches!(dep.section, PypiDependencySection::Dependencies));
31/// ```
32#[derive(Debug, Clone, PartialEq)]
33pub struct PypiDependency {
34    /// Package name (normalized to lowercase with underscores replaced by hyphens)
35    pub name: String,
36    /// LSP range of the package name
37    pub name_range: Range,
38    /// PEP 440 version specifier (e.g., ">=2.28.0,<3.0")
39    pub version_req: Option<String>,
40    /// LSP range of the version specifier
41    pub version_range: Option<Range>,
42    /// PEP 508 extras (e.g., ["security", "socks"])
43    pub extras: Vec<String>,
44    /// LSP range of the extras specification
45    pub extras_range: Option<Range>,
46    /// PEP 508 environment markers (e.g., "python_version>='3.8'")
47    pub markers: Option<String>,
48    /// LSP range of the markers specification
49    pub markers_range: Option<Range>,
50    /// Section where this dependency is declared
51    pub section: PypiDependencySection,
52    /// Source of the dependency (PyPI, Git, Path, URL)
53    pub source: PypiDependencySource,
54}
55
56/// Section in pyproject.toml where a dependency is declared.
57///
58/// Python projects use different sections for different types of dependencies:
59/// - `[project.dependencies]`: Runtime dependencies (PEP 621)
60/// - `[project.optional-dependencies.*]`: Optional dependency groups (PEP 621)
61/// - `[tool.poetry.dependencies]`: Runtime dependencies (Poetry)
62/// - `[tool.poetry.group.*.dependencies]`: Dependency groups (Poetry)
63///
64/// # Examples
65///
66/// ```
67/// use deps_pypi::types::PypiDependencySection;
68///
69/// let section = PypiDependencySection::Dependencies;
70/// assert!(matches!(section, PypiDependencySection::Dependencies));
71/// ```
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum PypiDependencySection {
75    /// PEP 517/518 build system requires (`[build-system.requires]`)
76    BuildSystem,
77    /// PEP 621 runtime dependencies (`[project.dependencies]`)
78    Dependencies,
79    /// PEP 621 optional dependency group (`[project.optional-dependencies.{group}]`)
80    OptionalDependencies { group: String },
81    /// PEP 735 dependency group (`[dependency-groups.{group}]`)
82    DependencyGroup { group: String },
83    /// Poetry runtime dependencies (`[tool.poetry.dependencies]`)
84    PoetryDependencies,
85    /// Poetry dependency group (`[tool.poetry.group.{group}.dependencies]`)
86    PoetryGroup { group: String },
87}
88
89/// Source location of a Python dependency.
90///
91/// Python dependencies can come from PyPI, Git repositories, local paths, or direct URLs.
92/// This affects how the LSP server resolves version information and provides completions.
93///
94/// # Examples
95///
96/// ```
97/// use deps_pypi::types::PypiDependencySource;
98///
99/// let pypi = PypiDependencySource::PyPI;
100/// let git = PypiDependencySource::Git {
101///     url: "https://github.com/psf/requests.git".into(),
102///     rev: Some("v2.28.0".into()),
103/// };
104/// let path = PypiDependencySource::Path {
105///     path: "../local-package".into(),
106/// };
107/// let url = PypiDependencySource::Url {
108///     url: "https://example.com/package.whl".into(),
109/// };
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum PypiDependencySource {
113    /// Dependency from PyPI registry
114    PyPI,
115    /// Dependency from Git repository
116    Git { url: String, rev: Option<String> },
117    /// Dependency from local filesystem path
118    Path { path: String },
119    /// Dependency from direct URL (wheel or source archive)
120    Url { url: String },
121}
122
123/// Version information for a package from PyPI.
124///
125/// Retrieved from the PyPI JSON API at `https://pypi.org/pypi/{package}/json`.
126/// Contains version number, yanked status, and prerelease detection.
127///
128/// # Examples
129///
130/// ```
131/// use deps_pypi::types::PypiVersion;
132///
133/// let version = PypiVersion {
134///     version: "2.28.2".into(),
135///     yanked: false,
136/// };
137///
138/// assert!(!version.yanked);
139/// assert!(!version.is_prerelease());
140/// ```
141#[derive(Debug, Clone)]
142pub struct PypiVersion {
143    /// Version string (PEP 440 compliant)
144    pub version: String,
145    /// Whether this version has been yanked from PyPI
146    pub yanked: bool,
147}
148
149impl PypiVersion {
150    /// Check if this version is a prerelease (alpha, beta, rc).
151    ///
152    /// Uses PEP 440 version parsing for accurate prerelease detection.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use deps_pypi::types::PypiVersion;
158    ///
159    /// let stable = PypiVersion { version: "1.0.0".into(), yanked: false };
160    /// let alpha = PypiVersion { version: "1.0.0a1".into(), yanked: false };
161    /// let beta = PypiVersion { version: "1.0.0b2".into(), yanked: false };
162    /// let rc = PypiVersion { version: "1.0.0rc1".into(), yanked: false };
163    ///
164    /// assert!(!stable.is_prerelease());
165    /// assert!(alpha.is_prerelease());
166    /// assert!(beta.is_prerelease());
167    /// assert!(rc.is_prerelease());
168    /// ```
169    pub fn is_prerelease(&self) -> bool {
170        use pep440_rs::Version;
171        use std::str::FromStr;
172
173        Version::from_str(&self.version)
174            .map(|v| v.is_pre())
175            .unwrap_or(false)
176    }
177}
178
179// Use macro to implement VersionInfo and Version traits
180deps_core::impl_version!(PypiVersion {
181    version: version,
182    yanked: yanked,
183});
184
185/// Package metadata from PyPI.
186///
187/// Contains basic information about a PyPI package for display in completion
188/// suggestions. Retrieved from `https://pypi.org/pypi/{package}/json`.
189///
190/// # Examples
191///
192/// ```
193/// use deps_pypi::types::PypiPackage;
194///
195/// let pkg = PypiPackage {
196///     name: "requests".into(),
197///     summary: Some("Python HTTP for Humans.".into()),
198///     project_urls: vec![
199///         ("Homepage".into(), "https://requests.readthedocs.io".into()),
200///         ("Repository".into(), "https://github.com/psf/requests".into()),
201///     ],
202///     latest_version: "2.28.2".into(),
203/// };
204///
205/// assert_eq!(pkg.name, "requests");
206/// ```
207#[derive(Debug, Clone)]
208pub struct PypiPackage {
209    /// Package name (canonical form)
210    pub name: String,
211    /// Short package summary/description
212    pub summary: Option<String>,
213    /// Project URLs (homepage, repository, documentation, etc.)
214    pub project_urls: Vec<(String, String)>,
215    /// Latest stable version
216    pub latest_version: String,
217}
218
219// Implement deps_core traits
220
221impl deps_core::Dependency for PypiDependency {
222    fn name(&self) -> &str {
223        &self.name
224    }
225
226    fn name_range(&self) -> Range {
227        self.name_range
228    }
229
230    fn version_requirement(&self) -> Option<&str> {
231        self.version_req.as_deref()
232    }
233
234    fn version_range(&self) -> Option<Range> {
235        self.version_range
236    }
237
238    fn source(&self) -> deps_core::parser::DependencySource {
239        match &self.source {
240            PypiDependencySource::PyPI => deps_core::parser::DependencySource::Registry,
241            PypiDependencySource::Git { url, rev } => deps_core::parser::DependencySource::Git {
242                url: url.clone(),
243                rev: rev.clone(),
244            },
245            PypiDependencySource::Path { path } => {
246                deps_core::parser::DependencySource::Path { path: path.clone() }
247            }
248            PypiDependencySource::Url { .. } => deps_core::parser::DependencySource::Registry,
249        }
250    }
251
252    fn as_any(&self) -> &dyn Any {
253        self
254    }
255}
256
257impl deps_core::PackageMetadata for PypiPackage {
258    fn name(&self) -> &str {
259        &self.name
260    }
261
262    fn description(&self) -> Option<&str> {
263        self.summary.as_deref()
264    }
265
266    fn repository(&self) -> Option<&str> {
267        self.project_urls
268            .iter()
269            .find(|(key, _)| {
270                key.eq_ignore_ascii_case("repository")
271                    || key.eq_ignore_ascii_case("source")
272                    || key.eq_ignore_ascii_case("code")
273            })
274            .map(|(_, url)| url.as_str())
275    }
276
277    fn documentation(&self) -> Option<&str> {
278        self.project_urls
279            .iter()
280            .find(|(key, _)| {
281                key.eq_ignore_ascii_case("documentation")
282                    || key.eq_ignore_ascii_case("docs")
283                    || key.eq_ignore_ascii_case("homepage")
284            })
285            .map(|(_, url)| url.as_str())
286    }
287
288    fn latest_version(&self) -> &str {
289        &self.latest_version
290    }
291}
292
293impl deps_core::Metadata for PypiPackage {
294    fn name(&self) -> &str {
295        &self.name
296    }
297
298    fn description(&self) -> Option<&str> {
299        self.summary.as_deref()
300    }
301
302    fn repository(&self) -> Option<&str> {
303        self.project_urls
304            .iter()
305            .find(|(key, _)| {
306                key.eq_ignore_ascii_case("repository")
307                    || key.eq_ignore_ascii_case("source")
308                    || key.eq_ignore_ascii_case("code")
309            })
310            .map(|(_, url)| url.as_str())
311    }
312
313    fn documentation(&self) -> Option<&str> {
314        self.project_urls
315            .iter()
316            .find(|(key, _)| {
317                key.eq_ignore_ascii_case("documentation")
318                    || key.eq_ignore_ascii_case("docs")
319                    || key.eq_ignore_ascii_case("homepage")
320            })
321            .map(|(_, url)| url.as_str())
322    }
323
324    fn latest_version(&self) -> &str {
325        &self.latest_version
326    }
327
328    fn as_any(&self) -> &dyn Any {
329        self
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use deps_core::{PackageMetadata, VersionInfo};
337    use tower_lsp_server::ls_types::Position;
338
339    #[test]
340    fn test_pypi_dependency_creation() {
341        let dep = PypiDependency {
342            name: "flask".into(),
343            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
344            version_req: Some(">=3.0.0".into()),
345            version_range: Some(Range::new(Position::new(0, 6), Position::new(0, 14))),
346            extras: vec!["async".into()],
347            extras_range: None,
348            markers: Some("python_version>='3.9'".into()),
349            markers_range: None,
350            section: PypiDependencySection::Dependencies,
351            source: PypiDependencySource::PyPI,
352        };
353
354        assert_eq!(dep.name, "flask");
355        assert_eq!(dep.version_req, Some(">=3.0.0".into()));
356        assert_eq!(dep.extras, vec!["async"]);
357    }
358
359    #[test]
360    fn test_dependency_section_variants() {
361        let deps = PypiDependencySection::Dependencies;
362        let opt_deps = PypiDependencySection::OptionalDependencies {
363            group: "dev".into(),
364        };
365        let dep_group = PypiDependencySection::DependencyGroup {
366            group: "dev".into(),
367        };
368        let poetry_deps = PypiDependencySection::PoetryDependencies;
369        let poetry_group = PypiDependencySection::PoetryGroup {
370            group: "test".into(),
371        };
372
373        assert!(matches!(deps, PypiDependencySection::Dependencies));
374        assert!(matches!(
375            opt_deps,
376            PypiDependencySection::OptionalDependencies { .. }
377        ));
378        assert!(matches!(
379            dep_group,
380            PypiDependencySection::DependencyGroup { .. }
381        ));
382        assert!(matches!(
383            poetry_deps,
384            PypiDependencySection::PoetryDependencies
385        ));
386        assert!(matches!(
387            poetry_group,
388            PypiDependencySection::PoetryGroup { .. }
389        ));
390    }
391
392    #[test]
393    fn test_dependency_source_variants() {
394        let pypi = PypiDependencySource::PyPI;
395        let git = PypiDependencySource::Git {
396            url: "https://github.com/user/repo.git".into(),
397            rev: Some("main".into()),
398        };
399        let path = PypiDependencySource::Path {
400            path: "../local".into(),
401        };
402        let url = PypiDependencySource::Url {
403            url: "https://example.com/package.whl".into(),
404        };
405
406        assert!(matches!(pypi, PypiDependencySource::PyPI));
407        assert!(matches!(git, PypiDependencySource::Git { .. }));
408        assert!(matches!(path, PypiDependencySource::Path { .. }));
409        assert!(matches!(url, PypiDependencySource::Url { .. }));
410    }
411
412    #[test]
413    fn test_pypi_version_creation() {
414        let version = PypiVersion {
415            version: "1.0.0".into(),
416            yanked: false,
417        };
418
419        assert_eq!(version.version, "1.0.0");
420        assert!(!version.yanked);
421        assert!(!version.is_prerelease());
422    }
423
424    #[test]
425    fn test_pypi_version_prerelease_detection() {
426        let stable = PypiVersion {
427            version: "1.0.0".into(),
428            yanked: false,
429        };
430        let alpha = PypiVersion {
431            version: "1.0.0a1".into(),
432            yanked: false,
433        };
434        let beta = PypiVersion {
435            version: "1.0.0b2".into(),
436            yanked: false,
437        };
438        let rc = PypiVersion {
439            version: "1.0.0rc1".into(),
440            yanked: false,
441        };
442
443        assert!(!stable.is_prerelease());
444        assert!(alpha.is_prerelease());
445        assert!(beta.is_prerelease());
446        assert!(rc.is_prerelease());
447    }
448
449    #[test]
450    fn test_pypi_version_info_trait() {
451        let version = PypiVersion {
452            version: "2.28.2".into(),
453            yanked: true,
454        };
455
456        assert_eq!(version.version_string(), "2.28.2");
457        assert!(version.is_yanked());
458    }
459
460    #[test]
461    fn test_pypi_package_creation() {
462        let pkg = PypiPackage {
463            name: "requests".into(),
464            summary: Some("Python HTTP for Humans.".into()),
465            project_urls: vec![
466                ("Homepage".into(), "https://requests.readthedocs.io".into()),
467                (
468                    "Repository".into(),
469                    "https://github.com/psf/requests".into(),
470                ),
471            ],
472            latest_version: "2.28.2".into(),
473        };
474
475        assert_eq!(pkg.name, "requests");
476        assert_eq!(pkg.latest_version, "2.28.2");
477    }
478
479    #[test]
480    fn test_pypi_package_metadata_trait() {
481        let pkg = PypiPackage {
482            name: "flask".into(),
483            summary: Some("A micro web framework".into()),
484            project_urls: vec![
485                (
486                    "Documentation".into(),
487                    "https://flask.palletsprojects.com/".into(),
488                ),
489                (
490                    "Repository".into(),
491                    "https://github.com/pallets/flask".into(),
492                ),
493            ],
494            latest_version: "3.0.0".into(),
495        };
496
497        assert_eq!(pkg.name(), "flask");
498        assert_eq!(pkg.description(), Some("A micro web framework"));
499        assert_eq!(pkg.repository(), Some("https://github.com/pallets/flask"));
500        assert_eq!(
501            pkg.documentation(),
502            Some("https://flask.palletsprojects.com/")
503        );
504        assert_eq!(pkg.latest_version(), "3.0.0");
505    }
506
507    #[test]
508    fn test_package_url_fallbacks() {
509        let pkg = PypiPackage {
510            name: "test".into(),
511            summary: None,
512            project_urls: vec![
513                ("Homepage".into(), "https://example.com".into()),
514                ("Source".into(), "https://github.com/test/test".into()),
515            ],
516            latest_version: "1.0.0".into(),
517        };
518
519        // Should find "Source" as fallback for repository
520        assert_eq!(pkg.repository(), Some("https://github.com/test/test"));
521        // Should find "Homepage" as fallback for documentation
522        assert_eq!(pkg.documentation(), Some("https://example.com"));
523    }
524}