Skip to main content

deps_pypi/
formatter.rs

1use deps_core::ConcreteVersion;
2use deps_core::Dependency;
3use deps_core::InvalidPackageName;
4use deps_core::PackageName;
5use deps_core::VersionReq;
6use deps_core::lsp_helpers::{
7    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
8    RequirementMatcher, RequirementResolution, SourcePolicy,
9};
10use pep440_rs::{Version, VersionSpecifiers};
11use std::str::FromStr;
12use tower_lsp_server::ls_types::Position;
13
14/// Precise PEP 440 specifier-set matcher, compiled once per dependency by
15/// [`PypiFormatter::compile_requirement`].
16struct Pep440Matcher(VersionSpecifiers);
17
18impl RequirementMatcher for Pep440Matcher {
19    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
20        let version = version.as_str();
21        Version::from_str(version).ok().map(|v| self.0.contains(&v))
22    }
23}
24
25pub struct PypiFormatter;
26
27impl PackageNaming for PypiFormatter {
28    fn normalize_package_name(&self, name: &PackageName) -> String {
29        crate::name::normalize(name.as_str())
30    }
31
32    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
33        // PEP 508: ^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$
34        let valid = !name.is_empty()
35            && name
36                .chars()
37                .next()
38                .is_some_and(|c| c.is_ascii_alphanumeric())
39            && name
40                .chars()
41                .last()
42                .is_some_and(|c| c.is_ascii_alphanumeric())
43            && name
44                .chars()
45                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
46
47        if valid {
48            Ok(())
49        } else {
50            Err(InvalidPackageName::new(
51                "must match PEP 508 name pattern ^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$",
52            ))
53        }
54    }
55}
56
57impl PackageRendering for PypiFormatter {
58    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
59        let version = version.as_str();
60        let next_major = version
61            .split('.')
62            .next()
63            .and_then(|s| s.parse::<u32>().ok())
64            .and_then(|v| v.checked_add(1))
65            .unwrap_or(1);
66
67        format!(">={version},<{next_major}")
68    }
69
70    fn format_version_replacing(&self, version: &ConcreteVersion, current: &str) -> String {
71        let version = version.as_str();
72        let terms: Vec<&str> = current.trim().split(',').map(str::trim).collect();
73
74        if terms.iter().any(|t| t.starts_with("===")) {
75            return format!("==={version}");
76        }
77
78        if let Some(term) = terms.iter().find(|t| t.starts_with("==")) {
79            return match term.strip_circumfix("==", ".*") {
80                Some(base) => {
81                    let candidate = truncate_release_to_match(base, version)
82                        .map(|truncated| format!("=={truncated}.*"))
83                        .unwrap_or_else(|| format!("=={version}"));
84                    // Truncating `version` back down to the wildcard's own
85                    // precision can reproduce `current` byte-for-byte (e.g.
86                    // `==1.0.*` stays `==1.0.*` for a 1.0.2 fix) even though
87                    // the wildcard still admits the vulnerable range it
88                    // started from — fall back to the untruncated exact pin
89                    // rather than silently no-oping a live finding.
90                    if candidate == *term {
91                        format!("=={version}")
92                    } else {
93                        candidate
94                    }
95                }
96                None => format!("=={version}"),
97            };
98        }
99
100        if let Some(term) = terms.iter().find(|t| t.starts_with("~=")) {
101            let rest = term.strip_prefix("~=").unwrap_or_default().trim();
102            let release_len = Version::from_str(rest)
103                .map(|v| v.release().len())
104                .unwrap_or(0);
105            return if release_len >= 2 {
106                let candidate = truncate_release_to_match(rest, version)
107                    .map(|truncated| format!("~={truncated}"))
108                    .unwrap_or_else(|| format!("~={version}"));
109                // Same no-op fallback as the `==` wildcard case above.
110                if candidate == *term {
111                    format!("~={version}")
112                } else {
113                    candidate
114                }
115            } else {
116                // `~=3` has a single release segment, which is not valid PEP 440
117                // on its own — don't emit another invalid pin.
118                self.format_version_for_text_edit(&ConcreteVersion::new(version))
119            };
120        }
121
122        self.format_version_for_text_edit(&ConcreteVersion::new(version))
123    }
124
125    fn package_url(&self, name: &PackageName) -> String {
126        crate::registry::package_url(name.as_str())
127    }
128
129    fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
130        let name_range = dep.name_range();
131
132        if position.line != name_range.start.line {
133            return false;
134        }
135
136        let end_char = dep
137            .version_range()
138            .map_or(name_range.end.character, |r| r.end.character);
139
140        let start_char = name_range.start.character.saturating_sub(2);
141        let end_char = end_char.saturating_add(2);
142
143        position.character >= start_char && position.character <= end_char
144    }
145
146    /// FR-009/validator finding #2: suppresses the hover heading's `pypi.org` project link
147    /// for anything but plain public-registry content. Without this override (the trait
148    /// default is unconditionally `false`), a private-index dependency's hover would render
149    /// a `pypi.org` link right next to its actual private-index version data — once live
150    /// data renders alongside it, an unrelated `pypi.org` link reads as false confirmation
151    /// the link is real. Mirrors `NpmFormatter`'s/`CargoFormatter`'s identical override,
152    /// reusing `SourcePolicy::source_is_public_registry_content`'s default (`Registry` only
153    /// — PyPI has no crates.io-style verified-mirror concept for `AlternateRegistry` to
154    /// except).
155    fn suppress_package_url(&self, source: &deps_core::DependencySource) -> bool {
156        !self.source_is_public_registry_content(source)
157    }
158}
159
160impl RequirementResolution for PypiFormatter {
161    fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
162        let version = version.as_str();
163        let Ok(ver) = Version::from_str(version) else {
164            return false;
165        };
166
167        let Ok(specs) = VersionSpecifiers::from_str(requirement) else {
168            return false;
169        };
170
171        specs.contains(&ver)
172    }
173
174    /// Compiles `requirement` via `pep440_rs::VersionSpecifiers` — the same crate and the
175    /// same precise contains-check `version_satisfies_requirement` above already uses, so
176    /// this is not a new comparator, just its result cached across candidates instead of
177    /// reparsed per call.
178    ///
179    /// Returns `None` when any specifier pins a PEP 440 [local version identifier]
180    /// (e.g. `torch==2.0.1+cu118`). Local versions are conventionally published only on
181    /// custom/alternate indexes (e.g. the PyTorch wheel index), not the default registry
182    /// this matcher checks candidates against — so an empty match set there does not mean
183    /// the requirement is unsatisfiable everywhere, and the diagnostic would be a false
184    /// positive.
185    ///
186    /// [local version identifier]: https://peps.python.org/pep-0440/#local-version-identifiers
187    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
188        let specs = VersionSpecifiers::from_str(requirement.as_str()).ok()?;
189        if specs.iter().any(|spec| spec.version().is_local()) {
190            return None;
191        }
192        Some(Box::new(Pep440Matcher(specs)))
193    }
194}
195
196impl DiagnosticMessages for PypiFormatter {}
197
198impl DiagnosticPolicy for PypiFormatter {}
199
200impl SourcePolicy for PypiFormatter {
201    /// FR-009: gates hover/diagnostics/code-actions on a resolved `AlternateRegistry`
202    /// (private-index) source, in addition to the plain public `Registry` default —
203    /// mirrors `NpmFormatter::can_resolve_source` exactly. `CustomRegistry` (an unresolved
204    /// or invalid explicit index — FR-006) is deliberately not accepted here: it falls
205    /// through to the default `is_version_resolvable() == false`, keeping the existing
206    /// fail-closed gate intact.
207    ///
208    /// Known cosmetic limitation (M1, not fixed): a plain dependency in an extras-only file
209    /// (FR-005(b)) is classified `AlternateRegistry` at parse time, before the winning hop
210    /// is known — if it actually resolves via the implicit public fallback, its `pypi.org`
211    /// hover link is still suppressed by `PackageRendering::suppress_package_url` (correct
212    /// for the private-index case this feature exists for, cosmetically over-cautious only
213    /// for this one edge case). Accepted for phase 1; documented in `ECOSYSTEM_GUIDE.md`.
214    fn can_resolve_source(&self, source: &deps_core::DependencySource) -> bool {
215        matches!(
216            source,
217            deps_core::DependencySource::Registry
218                | deps_core::DependencySource::AlternateRegistry { .. }
219        )
220    }
221}
222
223impl OsvNaming for PypiFormatter {}
224
225/// Truncates `latest`'s PEP 440 release segments to the same segment count
226/// as `source_version`'s release, joined with `.`. Returns `None` if either
227/// fails to parse, or `latest` has fewer release segments than
228/// `source_version` (in which case the caller falls back to the untruncated
229/// version rather than losing precision).
230fn truncate_release_to_match(source_version: &str, latest: &str) -> Option<String> {
231    let source_release_len = Version::from_str(source_version).ok()?.release().len();
232    let latest_release = Version::from_str(latest).ok()?;
233    let latest_release = latest_release.release();
234    if latest_release.len() < source_release_len {
235        return None;
236    }
237    Some(
238        latest_release[..source_release_len]
239            .iter()
240            .map(u64::to_string)
241            .collect::<Vec<_>>()
242            .join("."),
243    )
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// FR-009: `Registry` and `AlternateRegistry` both resolve; `CustomRegistry` and every
251    /// non-registry source stay fail-closed via the default `is_version_resolvable()`.
252    #[test]
253    fn test_can_resolve_source() {
254        let formatter = PypiFormatter;
255        assert!(formatter.can_resolve_source(&deps_core::DependencySource::Registry));
256        assert!(
257            formatter.can_resolve_source(&deps_core::DependencySource::AlternateRegistry {
258                index: "pypi-chain:deadbeef".to_string(),
259                mirrors_crates_io: false,
260            })
261        );
262        assert!(
263            !formatter.can_resolve_source(&deps_core::DependencySource::CustomRegistry {
264                url: "https://pypi.mycorp.example/simple".to_string(),
265            })
266        );
267    }
268
269    /// Validator finding #2 (security H2): a private-index (`AlternateRegistry`) dependency's
270    /// hover must suppress the `pypi.org` project link; a plain public-registry dependency
271    /// must not.
272    #[test]
273    fn test_suppress_package_url() {
274        let formatter = PypiFormatter;
275        assert!(!formatter.suppress_package_url(&deps_core::DependencySource::Registry));
276        assert!(
277            formatter.suppress_package_url(&deps_core::DependencySource::AlternateRegistry {
278                index: "pypi-chain:deadbeef".to_string(),
279                mirrors_crates_io: false,
280            })
281        );
282        assert!(
283            formatter.suppress_package_url(&deps_core::DependencySource::CustomRegistry {
284                url: "https://pypi.mycorp.example/simple".to_string(),
285            })
286        );
287    }
288
289    #[test]
290    fn test_normalize_package_name() {
291        let formatter = PypiFormatter;
292        assert_eq!(
293            formatter.normalize_package_name(&PackageName::new("requests")),
294            "requests"
295        );
296        assert_eq!(
297            formatter.normalize_package_name(&PackageName::new("Django-REST-Framework")),
298            "django-rest-framework"
299        );
300        assert_eq!(
301            formatter.normalize_package_name(&PackageName::new("My-Package")),
302            "my-package"
303        );
304        assert_eq!(
305            formatter.normalize_package_name(&PackageName::new("zope.interface")),
306            "zope-interface"
307        );
308    }
309
310    #[test]
311    fn test_format_version() {
312        let formatter = PypiFormatter;
313        assert_eq!(
314            formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
315            ">=1.2.3,<2"
316        );
317        assert_eq!(
318            formatter.format_version_for_text_edit(&ConcreteVersion::new("2.28.0")),
319            ">=2.28.0,<3"
320        );
321        assert_eq!(
322            formatter.format_version_for_text_edit(&ConcreteVersion::new("0.1.0")),
323            ">=0.1.0,<1"
324        );
325    }
326
327    #[test]
328    fn test_format_version_overflow_protection() {
329        let formatter = PypiFormatter;
330        // u32::MAX should not overflow, checked_add returns None
331        assert_eq!(
332            formatter.format_version_for_text_edit(&ConcreteVersion::new("4294967295.0.0")),
333            ">=4294967295.0.0,<1"
334        );
335    }
336
337    #[test]
338    fn test_package_url() {
339        let formatter = PypiFormatter;
340        assert_eq!(
341            formatter.package_url(&PackageName::new("requests")),
342            "https://pypi.org/project/requests"
343        );
344        assert_eq!(
345            formatter.package_url(&PackageName::new("django")),
346            "https://pypi.org/project/django"
347        );
348    }
349
350    #[test]
351    fn test_version_satisfies_pep440() {
352        let formatter = PypiFormatter;
353
354        assert!(
355            formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), ">=1.0,<2")
356        );
357        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("2.28.0"), ">=2.0"));
358        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "==1.0.0"));
359        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.0"), "~=1.2.0"));
360
361        assert!(
362            !formatter.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), ">=1.0,<2")
363        );
364        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("0.9.0"), ">=1.0"));
365    }
366
367    #[test]
368    fn test_version_satisfies_invalid_version() {
369        let formatter = PypiFormatter;
370        assert!(
371            !formatter
372                .version_satisfies_requirement(&ConcreteVersion::new("not-a-version"), ">=1.0")
373        );
374    }
375
376    #[test]
377    fn test_version_satisfies_invalid_specifier() {
378        let formatter = PypiFormatter;
379        assert!(
380            !formatter
381                .version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "not-a-specifier")
382        );
383    }
384
385    #[test]
386    fn test_compile_requirement_satisfiable() {
387        let formatter = PypiFormatter;
388        let matcher = formatter
389            .compile_requirement(&VersionReq::new(">=1.0,<2.0"))
390            .expect("valid PEP 440 specifier must compile");
391        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
392        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
393    }
394
395    #[test]
396    fn test_compile_requirement_unparseable_requirement_returns_none() {
397        let formatter = PypiFormatter;
398        assert!(
399            formatter
400                .compile_requirement(&VersionReq::new("not-a-specifier"))
401                .is_none()
402        );
403    }
404
405    #[test]
406    fn test_compile_requirement_local_version_returns_none() {
407        let formatter = PypiFormatter;
408        assert!(
409            formatter
410                .compile_requirement(&VersionReq::new("==2.0.1+cu118"))
411                .is_none()
412        );
413    }
414
415    #[test]
416    fn test_compile_requirement_unparseable_candidate_is_skipped() {
417        let formatter = PypiFormatter;
418        let matcher = formatter
419            .compile_requirement(&VersionReq::new(">=1.0"))
420            .unwrap();
421        assert_eq!(matcher.matches(&ConcreteVersion::new("2011k")), None);
422    }
423
424    #[test]
425    fn test_default_yanked_message() {
426        let formatter = PypiFormatter;
427        assert_eq!(formatter.yanked_message(), "This version has been yanked");
428        assert_eq!(formatter.yanked_label(), "*(yanked)*");
429    }
430
431    #[test]
432    fn test_osv_version_to_native_round_trips_through_own_parser() {
433        // Critic S2 gate: `osv_version_to_native` is identity for PyPI (OSV
434        // records use PEP 440 verbatim), so the version it hands to
435        // `format_version_for_text_edit` — which expands it into a
436        // `>=v,<next-major` range, unlike the identity edit most other
437        // ecosystems use — must itself satisfy the requirement text that
438        // edit produces.
439        let formatter = PypiFormatter;
440        let osv_version = "2.28.0";
441        let native = formatter.osv_version_to_native(osv_version);
442        assert_eq!(native, osv_version);
443        let native = ConcreteVersion::new(native);
444        let edit_text = formatter.format_version_for_text_edit(&native);
445        assert!(formatter.version_satisfies_requirement(&native, &edit_text));
446    }
447
448    #[test]
449    fn test_osv_version_to_native_round_trips_through_format_version_replacing() {
450        // Critic M2: the vulnerability-fix `TextEdit` is now built via
451        // `format_version_replacing`, not `format_version_for_text_edit` —
452        // the round-trip gate above no longer guards the code it was
453        // written for. Same property, retargeted at the method the fix
454        // path actually calls, across every `current` shape it recognizes.
455        let formatter = PypiFormatter;
456        let osv_version = "2.28.0";
457        let native = formatter.osv_version_to_native(osv_version);
458        assert_eq!(native, osv_version);
459        let native = ConcreteVersion::new(native);
460
461        for current in ["==2.20.0", "==2.20.*", "~=2.20", "~=2.20.0", ">=2.20,<2.21"] {
462            let edit_text = formatter.format_version_replacing(&native, current);
463            assert!(
464                formatter.version_satisfies_requirement(&native, &edit_text),
465                "current={current:?} produced edit_text={edit_text:?}, which does not admit {native:?}"
466            );
467        }
468    }
469
470    #[test]
471    fn test_normalize_fast_path() {
472        let formatter = PypiFormatter;
473        // Already lowercase, no hyphens - should hit fast path
474        assert_eq!(
475            formatter.normalize_package_name(&PackageName::new("requests")),
476            "requests"
477        );
478        assert_eq!(
479            formatter.normalize_package_name(&PackageName::new("flask")),
480            "flask"
481        );
482        assert_eq!(
483            formatter.normalize_package_name(&PackageName::new("numpy")),
484            "numpy"
485        );
486    }
487
488    #[test]
489    fn test_validate_package_name_accepts_valid_names() {
490        let formatter = PypiFormatter;
491        assert!(formatter.validate_package_name("zope.interface").is_ok());
492        assert!(formatter.validate_package_name("Django").is_ok());
493        assert!(formatter.validate_package_name("a").is_ok());
494        assert!(formatter.validate_package_name("my-package_1.0").is_ok());
495    }
496
497    #[test]
498    fn test_validate_package_name_rejects_invalid_names() {
499        let formatter = PypiFormatter;
500        assert!(formatter.validate_package_name("---").is_err());
501        assert!(formatter.validate_package_name("-x").is_err());
502        assert!(formatter.validate_package_name("x-").is_err());
503        assert!(formatter.validate_package_name("a b").is_err());
504        assert!(formatter.validate_package_name("").is_err());
505    }
506
507    #[test]
508    fn test_format_version_replacing_table() {
509        let formatter = PypiFormatter;
510
511        // starts `===`
512        assert_eq!(
513            formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "===1.0"),
514            "===1.2"
515        );
516
517        // starts `==`, no wildcard
518        assert_eq!(
519            formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "==1.0"),
520            "==1.2"
521        );
522
523        // starts `==`, wildcard, latest has enough segments
524        assert_eq!(
525            formatter.format_version_replacing(&ConcreteVersion::new("1.6.2"), "==1.4.*"),
526            "==1.6.*"
527        );
528
529        // `~=` with >=2 release segments truncates, never over-specifies
530        assert_eq!(
531            formatter.format_version_replacing(&ConcreteVersion::new("1.26.4"), "~=1.24"),
532            "~=1.26"
533        );
534
535        // `~=` with a single release segment is invalid PEP 440 on its own; default
536        assert_eq!(
537            formatter.format_version_replacing(&ConcreteVersion::new("4.0.0"), "~=3"),
538            ">=4.0.0,<5"
539        );
540
541        // multi-specifier collapse: any comma-separated term starting with `==` wins,
542        // regardless of position (N1 fix — pep440_rs sorts specifiers by version, so
543        // `!=0.9,==1.0` in source may render sorted either way)
544        assert_eq!(
545            formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "==1.0, !=1.0.1"),
546            "==1.2"
547        );
548        assert_eq!(
549            formatter.format_version_replacing(&ConcreteVersion::new("1.2"), "!=0.9, ==1.0"),
550            "==1.2"
551        );
552
553        // comma-separated, no `==`/`===`/`~=` term -> default range
554        assert_eq!(
555            formatter
556                .format_version_replacing(&ConcreteVersion::new("2.0.0"), ">=1.0, !=1.5, <2.0"),
557            ">=2.0.0,<3"
558        );
559
560        // anything else -> default
561        assert_eq!(
562            formatter.format_version_replacing(&ConcreteVersion::new("2.0.0"), ">=1.0"),
563            ">=2.0.0,<3"
564        );
565    }
566
567    #[test]
568    fn test_format_version_replacing_wildcard_pin_no_op_falls_back_to_exact_fix() {
569        // Critic S1: truncating the fix version back down to the pin's own
570        // precision can reproduce `current` byte-for-byte even though the
571        // pin still admits the vulnerable range it started from (`~=1.0`
572        // and `==1.0.*` both still match 1.0.0/1.0.1). If that happens, the
573        // untruncated exact version must be emitted instead, or the N1
574        // no-op guard in `deps-core` silently drops the vulnerability
575        // quickfix entirely.
576        let formatter = PypiFormatter;
577
578        assert_eq!(
579            formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "~=1.0"),
580            "~=1.0.2"
581        );
582        assert_eq!(
583            formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "==1.0.*"),
584            "==1.0.2"
585        );
586
587        // `~=`'s floor equals the fix version's own precision: the
588        // untruncated fallback text is identical to the truncated one, so
589        // this is a genuine no-op either way (unaffected by the fallback).
590        assert_eq!(
591            formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "~=1.0.2"),
592            "~=1.0.2"
593        );
594        // `==V.*` has no floor semantics (it is a release-prefix match, not
595        // a lower bound), so there is no textually-identical "genuine
596        // no-op" fallback for it — the wildcard is narrowed to an exact pin
597        // instead, which is always at least as safe as leaving it alone.
598        assert_eq!(
599            formatter.format_version_replacing(&ConcreteVersion::new("1.0.2"), "==1.0.2.*"),
600            "==1.0.2"
601        );
602    }
603
604    mod is_position_on_dependency_tests {
605        use super::*;
606        use deps_core::parser::DependencySource;
607        use std::any::Any;
608        use tower_lsp_server::ls_types::Range;
609
610        struct MockDep {
611            name_range: Range,
612            version_range: Option<Range>,
613        }
614
615        impl deps_core::Dependency for MockDep {
616            fn name(&self) -> &deps_core::PackageName {
617                static NAME: std::sync::LazyLock<deps_core::PackageName> =
618                    std::sync::LazyLock::new(|| deps_core::PackageName::new("test-package"));
619                &NAME
620            }
621            fn name_range(&self) -> Range {
622                self.name_range
623            }
624            fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
625                static VERSION_REQ: std::sync::LazyLock<deps_core::VersionReq> =
626                    std::sync::LazyLock::new(|| deps_core::VersionReq::new(">=1.0"));
627                Some(&VERSION_REQ)
628            }
629            fn version_range(&self) -> Option<Range> {
630                self.version_range
631            }
632            fn source(&self) -> DependencySource {
633                DependencySource::Registry
634            }
635            fn as_any(&self) -> &dyn Any {
636                self
637            }
638        }
639
640        #[test]
641        fn test_position_on_name() {
642            let formatter = PypiFormatter;
643            let dep = MockDep {
644                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
645                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
646            };
647            // Position on package name
648            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 15)));
649        }
650
651        #[test]
652        fn test_position_in_padding_before() {
653            let formatter = PypiFormatter;
654            let dep = MockDep {
655                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
656                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
657            };
658            // Position in padding before name (character - 2)
659            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 8)));
660        }
661
662        #[test]
663        fn test_position_after_version_padding() {
664            let formatter = PypiFormatter;
665            let dep = MockDep {
666                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
667                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
668            };
669            // Position after version range (character + 2)
670            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 37)));
671        }
672
673        #[test]
674        fn test_position_too_far_before() {
675            let formatter = PypiFormatter;
676            let dep = MockDep {
677                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
678                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
679            };
680            // Position too far before (outside padding)
681            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 5)));
682        }
683
684        #[test]
685        fn test_position_too_far_after() {
686            let formatter = PypiFormatter;
687            let dep = MockDep {
688                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
689                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
690            };
691            // Position too far after (outside padding)
692            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 40)));
693        }
694
695        #[test]
696        fn test_position_different_line() {
697            let formatter = PypiFormatter;
698            let dep = MockDep {
699                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
700                version_range: Some(Range::new(Position::new(5, 25), Position::new(5, 35))),
701            };
702            // Different line
703            assert!(!formatter.is_position_on_dependency(&dep, Position::new(4, 15)));
704            assert!(!formatter.is_position_on_dependency(&dep, Position::new(6, 15)));
705        }
706
707        #[test]
708        fn test_position_without_version_range() {
709            let formatter = PypiFormatter;
710            let dep = MockDep {
711                name_range: Range::new(Position::new(5, 10), Position::new(5, 20)),
712                version_range: None,
713            };
714            // Should use name_range.end for calculation
715            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 22)));
716            assert!(!formatter.is_position_on_dependency(&dep, Position::new(5, 25)));
717        }
718
719        #[test]
720        fn test_saturating_sub_at_column_zero() {
721            let formatter = PypiFormatter;
722            // Edge case: character 0 with saturating_sub(2)
723            let dep = MockDep {
724                name_range: Range::new(Position::new(5, 0), Position::new(5, 10)),
725                version_range: None,
726            };
727            // saturating_sub(2) should give 0, not underflow
728            assert!(formatter.is_position_on_dependency(&dep, Position::new(5, 0)));
729        }
730    }
731}