Skip to main content

deps_gitlab_ci/
formatter.rs

1//! GitLab CI ecosystem formatter.
2
3use dashmap::DashMap;
4use deps_core::lsp_helpers::{
5    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
6    RequirementResolution, RequirementStatus, SourcePolicy, match_v_prefix_style,
7    warn_rejected_value,
8};
9use deps_core::parser::DependencySource;
10use deps_core::{ConcreteVersion, Dependency, InvalidPackageName, PackageName, VersionReq};
11use std::sync::Arc;
12
13use crate::host::is_valid_gitlab_coordinate;
14use crate::types::{EndpointKind, GitlabCiDependency, GitlabRoute, PinStyle};
15
16/// Formatter for GitLab CI ecosystem LSP responses.
17pub struct GitlabCiFormatter {
18    /// Shared handle to [`crate::registry::GitlabCiRegistry`]'s route table, so
19    /// [`Self::suppress_package_url`] can distinguish a `project:` (Tags) route from a
20    /// `component:` (Releases) route — the one NFR-004 carve-out this ecosystem has (spec
21    /// §8.2): a `component:`'s heading link is suppressed, since its name ends in the
22    /// component segment rather than the project path.
23    pub(crate) routes: Arc<DashMap<String, GitlabRoute>>,
24    /// Shared handle to [`crate::registry::GitlabCiRegistry`]'s tag/SHA cross-reference.
25    pub(crate) tag_index: Arc<DashMap<PackageName, Arc<crate::registry::TagIndex>>>,
26}
27
28impl GitlabCiFormatter {
29    /// Creates a new formatter over the given shared registry handles.
30    #[must_use]
31    pub fn new(
32        routes: Arc<DashMap<String, GitlabRoute>>,
33        tag_index: Arc<DashMap<PackageName, Arc<crate::registry::TagIndex>>>,
34    ) -> Self {
35        Self { routes, tag_index }
36    }
37
38    /// Looks up `pin`'s commit SHA for `name` in the shared tag index, mirroring
39    /// `deps_github_actions::GithubActionsFormatter::sha_pin_replacement_for`'s lookup
40    /// shape (used by hover's `**Resolved**` splice, `crate::ecosystem`).
41    #[must_use]
42    pub(crate) fn resolved_tag_for_sha(&self, name: &PackageName, sha: &str) -> Option<String> {
43        self.tag_index
44            .get(name)
45            .and_then(|index| index.sha_to_tag.get(sha).cloned())
46    }
47}
48
49impl PackageNaming for GitlabCiFormatter {
50    fn normalize_package_name(&self, name: &PackageName) -> String {
51        name.as_str().to_lowercase()
52    }
53
54    /// Accepts both the bare (`org/proj[/comp]`) and host-qualified
55    /// (`host/org/proj[/comp]`) coordinate shapes — [`is_valid_gitlab_coordinate`] is a
56    /// syntactic gate only, not a semantic classifier (see that function's doc).
57    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
58        if is_valid_gitlab_coordinate(name) {
59            Ok(())
60        } else {
61            Err(InvalidPackageName::new(
62                "name must be a GitLab project/component coordinate",
63            ))
64        }
65    }
66}
67
68impl PackageRendering for GitlabCiFormatter {
69    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
70        version.as_str().to_string()
71    }
72
73    /// Preserves `current`'s `v`-prefix style for a normal (Sha/Tag/Branch/unpinned)
74    /// update; `Partial`/`Latest` pins are returned unchanged — bumping `1.2` to `1.3.0`
75    /// changes the pin's *kind*, not just its value, so the shared no-op guard correctly
76    /// suppresses the code action instead of writing a value-changing-but-kind-wrong edit.
77    fn format_version_replacing_for(
78        &self,
79        dep: &dyn Dependency,
80        version: &ConcreteVersion,
81        current: &str,
82    ) -> String {
83        let Some(gl_dep) = dep.as_any().downcast_ref::<GitlabCiDependency>() else {
84            return self.format_version_for_text_edit(version);
85        };
86        match &gl_dep.pin {
87            Some(PinStyle::Partial | PinStyle::Latest) => current.to_string(),
88            _ => match_v_prefix_style(current, version.as_str()),
89        }
90    }
91
92    fn package_url(&self, name: &PackageName) -> String {
93        if is_valid_gitlab_coordinate(name.as_str()) {
94            format!("https://{name}")
95        } else {
96            warn_rejected_value(
97                "is_valid_gitlab_coordinate",
98                "gitlab-ci package display formatting",
99                name.as_str(),
100            );
101            String::new()
102        }
103    }
104
105    /// `component:` includes only (spec §8.2/NFR-004 carve-out) — a component's name ends
106    /// in the component segment, so `https://{name}` is not the project's URL; the real
107    /// project link is spliced into the hover body instead (`crate::ecosystem`'s
108    /// `generate_hover` override). A `project:` include's name is exactly
109    /// `{host}/{project_path}`, so its standard heading link is correct and unsuppressed.
110    fn suppress_package_url(&self, source: &DependencySource) -> bool {
111        match source {
112            DependencySource::AlternateRegistry { index, .. } => {
113                match self.routes.get(index).map(|r| r.endpoint) {
114                    Some(EndpointKind::Tags) => false,
115                    // `Releases`, or an index absent from the route table (unreachable
116                    // after `GitlabCiEcosystem::parse_manifest`'s downgrade pass — fail
117                    // closed rather than guess a link).
118                    Some(EndpointKind::Releases) | None => true,
119                }
120            }
121            // `CustomRegistry` (unresolved host, FR-012) — the name carries no host at all.
122            _ => true,
123        }
124    }
125}
126
127impl RequirementResolution for GitlabCiFormatter {
128    /// Whether `requirement`'s pin — classified purely from its own text, mirroring
129    /// [`crate::component::classify_component_pin_style`]'s shape-only rule — could not be
130    /// resolved to a concrete version constraint: a SHA or branch-shaped ref.
131    ///
132    /// Text-only, so ambiguous for a shape shared between grammars (#466 review M-c) — a
133    /// caller that already has the dependency in hand should call
134    /// [`Self::requirement_status_for`] instead, which consults its authoritative
135    /// [`crate::types::PinStyle`] rather than re-guessing from text.
136    fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
137        matches!(
138            crate::component::classify_component_pin_style(requirement.as_str()),
139            PinStyle::Sha | PinStyle::Branch
140        )
141    }
142
143    /// `~latest` is always up to date (it dynamically tracks the newest release, like an
144    /// existence wildcard). A `Partial` pin (`1.2`, `1`) is up to date while `latest` falls
145    /// within its GitLab tilde-range semantics. A `Tag` pin is compared by normalized
146    /// exact-string equality. A SHA/branch pin returns `true` unconditionally — never a
147    /// false "outdated" (the diagnostic itself is separately gated by
148    /// [`Self::requirement_is_unresolved`]; this is the boolean fallback for a caller that
149    /// does not consult that first, e.g. the "Update N outdated" code lens).
150    ///
151    /// Text-only, so ambiguous for a shape shared between grammars — see
152    /// [`Self::requirement_status_for`]'s doc for the dependency-aware alternative a caller
153    /// holding the dependency should prefer.
154    fn is_requirement_up_to_date(
155        &self,
156        requirement: &VersionReq,
157        latest: &ConcreteVersion,
158    ) -> bool {
159        let pin = crate::component::classify_component_pin_style(requirement.as_str());
160        !matches!(
161            status_for_pin(&pin, requirement.as_str(), latest.as_str()),
162            RequirementStatus::Outdated
163        )
164    }
165
166    /// #466 review M-c: consults `dep`'s own parse-time [`PinStyle`] (authoritative — set
167    /// once, at parse time, from the correct project-vs-component grammar) instead of
168    /// re-classifying `requirement`'s raw text, which is ambiguous between the two:
169    /// `"1.2"` is [`PinStyle::Partial`] under the `component:` pin grammar
170    /// ([`crate::component::classify_component_pin_style`]) but [`PinStyle::Branch`] under
171    /// the simpler `project:` ref grammar (`crate::parser`'s `classify_project_pin`) —
172    /// indistinguishable from the text alone. This is the same source of truth
173    /// [`PackageRendering::format_version_replacing_for`] already consults, so the two can
174    /// no longer disagree about the same dependency (previously: the outdated diagnostic
175    /// text-reclassified a `project:` `ref: "1.2"` as `Partial` and silently suppressed
176    /// itself, while the code action offered by `format_version_replacing_for`'s correct
177    /// `Branch` classification still treated it as a normal, bumpable pin).
178    fn requirement_status_for(
179        &self,
180        dep: &dyn Dependency,
181        requirement: &VersionReq,
182        latest: &ConcreteVersion,
183    ) -> RequirementStatus {
184        let Some(pin) = dep
185            .as_any()
186            .downcast_ref::<GitlabCiDependency>()
187            .and_then(|gl_dep| gl_dep.pin.as_ref())
188        else {
189            return self.requirement_status(requirement, latest);
190        };
191        status_for_pin(pin, requirement.as_str(), latest.as_str())
192    }
193}
194
195/// The shared classification -> status rule every [`RequirementResolution`] method on
196/// [`GitlabCiFormatter`] reduces to, whether `pin` came from a fresh text-only guess
197/// ([`crate::component::classify_component_pin_style`]) or the dependency's own
198/// authoritative parse-time field (`GitlabCiDependency::pin`) — the single place this
199/// mapping is defined, so the two call paths cannot drift apart (#466 review M-c).
200fn status_for_pin(pin: &PinStyle, requirement: &str, latest: &str) -> RequirementStatus {
201    match pin {
202        PinStyle::Sha | PinStyle::Branch => RequirementStatus::Unresolved,
203        PinStyle::Latest => RequirementStatus::UpToDate,
204        PinStyle::Tag => {
205            if deps_core::github::normalize_tag(requirement)
206                == deps_core::github::normalize_tag(latest)
207            {
208                RequirementStatus::UpToDate
209            } else {
210                RequirementStatus::Outdated
211            }
212        }
213        PinStyle::Partial => {
214            if partial_leading_components_match(requirement, latest) {
215                RequirementStatus::UpToDate
216            } else {
217                RequirementStatus::Outdated
218            }
219        }
220    }
221}
222
223/// Whether `latest` falls within `req`'s (already partial-semver-shaped) GitLab tilde-range
224/// semantics — the up-to-date rule for a `Partial` component pin. Delegates to
225/// [`crate::component::gitlab_version_req`] (#466 review M-b), the same partial-pin parsing
226/// `component::resolve_component_pin` and `registry::GitlabCiRegistry::select_latest_matching`
227/// use, rather than a third, independently-maintained implementation.
228fn partial_leading_components_match(req: &str, latest: &str) -> bool {
229    crate::component::gitlab_version_req(req).is_some_and(|range| {
230        semver::Version::parse(deps_core::github::normalize_tag(latest))
231            .is_ok_and(|version| range.matches(&version))
232    })
233}
234
235impl DiagnosticMessages for GitlabCiFormatter {}
236
237impl DiagnosticPolicy for GitlabCiFormatter {}
238
239impl SourcePolicy for GitlabCiFormatter {
240    /// Only a source this crate's registry actually routes — mirrors every other
241    /// per-source-routing ecosystem's override (`deps-npm`, `deps-pypi`, `deps-go`,
242    /// `deps-nuget`). Pure function of `source`; must never read live configuration (spec
243    /// §4.5 — a live-reading predicate here would replace the correct FR-012 informational
244    /// diagnostic with a false "Unknown package" the moment a config change flips it, while
245    /// the background fetch it would then imply has not actually run).
246    fn can_resolve_source(&self, source: &DependencySource) -> bool {
247        matches!(source, DependencySource::AlternateRegistry { .. })
248    }
249}
250
251impl OsvNaming for GitlabCiFormatter {
252    /// Unprefixed — mirrors `deps-github-actions`'s identical rationale, kept for
253    /// cross-ecosystem consistency even though it is largely unreachable here (a git-tag
254    /// pin has no OSV coordinate by name).
255    fn osv_version(&self, version: &str) -> String {
256        deps_core::github::normalize_tag(version).to_string()
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use tower_lsp_server::ls_types::{Position, Range};
264
265    fn formatter() -> GitlabCiFormatter {
266        GitlabCiFormatter::new(Arc::new(DashMap::new()), Arc::new(DashMap::new()))
267    }
268
269    fn range() -> Range {
270        Range::new(Position::new(0, 0), Position::new(0, 1))
271    }
272
273    fn dep(pin: Option<PinStyle>, name: &str, source: DependencySource) -> GitlabCiDependency {
274        GitlabCiDependency {
275            name: name.into(),
276            name_range: range(),
277            version_req: Some("1.0.0".into()),
278            version_range: Some(range()),
279            version_literal: None,
280            source,
281            is_plain_scalar: true,
282            kind: crate::types::IncludeKind::Project,
283            host: crate::types::HostRef::Unresolved("$CI_SERVER_FQDN".into()),
284            pin,
285            project_path: "org/proj".to_string(),
286        }
287    }
288
289    #[test]
290    fn test_validate_package_name_accepts_bare_and_host_qualified() {
291        let fmt = formatter();
292        assert!(fmt.validate_package_name("org/proj").is_ok());
293        assert!(
294            fmt.validate_package_name("gitlab.com/org/proj/comp")
295                .is_ok()
296        );
297        assert!(fmt.validate_package_name("no-slash").is_err());
298    }
299
300    #[test]
301    fn test_package_url() {
302        let fmt = formatter();
303        assert_eq!(
304            fmt.package_url(&PackageName::new("gitlab.com/org/proj")),
305            "https://gitlab.com/org/proj"
306        );
307        assert_eq!(fmt.package_url(&PackageName::new("no-slash")), "");
308    }
309
310    #[test]
311    fn test_suppress_package_url_custom_registry_always_suppressed() {
312        let fmt = formatter();
313        assert!(fmt.suppress_package_url(&DependencySource::CustomRegistry {
314            url: "$CI_SERVER_FQDN".into()
315        }));
316    }
317
318    #[test]
319    fn test_suppress_package_url_tags_route_not_suppressed() {
320        let fmt = formatter();
321        fmt.routes.insert(
322            "gitlab:abc".to_string(),
323            GitlabRoute {
324                origin: "https://gitlab.com".into(),
325                endpoint: EndpointKind::Tags,
326            },
327        );
328        assert!(
329            !fmt.suppress_package_url(&DependencySource::AlternateRegistry {
330                index: "gitlab:abc".into(),
331                mirrors_crates_io: false,
332            })
333        );
334    }
335
336    #[test]
337    fn test_suppress_package_url_releases_route_suppressed() {
338        let fmt = formatter();
339        fmt.routes.insert(
340            "gitlab:abc".to_string(),
341            GitlabRoute {
342                origin: "https://gitlab.com".into(),
343                endpoint: EndpointKind::Releases,
344            },
345        );
346        assert!(
347            fmt.suppress_package_url(&DependencySource::AlternateRegistry {
348                index: "gitlab:abc".into(),
349                mirrors_crates_io: false,
350            })
351        );
352    }
353
354    #[test]
355    fn test_suppress_package_url_unregistered_index_fails_closed() {
356        let fmt = formatter();
357        assert!(
358            fmt.suppress_package_url(&DependencySource::AlternateRegistry {
359                index: "gitlab:missing".into(),
360                mirrors_crates_io: false,
361            })
362        );
363    }
364
365    #[test]
366    fn test_can_resolve_source() {
367        let fmt = formatter();
368        assert!(
369            fmt.can_resolve_source(&DependencySource::AlternateRegistry {
370                index: "x".into(),
371                mirrors_crates_io: false,
372            })
373        );
374        assert!(!fmt.can_resolve_source(&DependencySource::CustomRegistry { url: "x".into() }));
375        assert!(!fmt.can_resolve_source(&DependencySource::Registry));
376    }
377
378    #[test]
379    fn test_requirement_is_unresolved_sha_and_branch() {
380        let fmt = formatter();
381        assert!(fmt.requirement_is_unresolved(&VersionReq::new("a".repeat(40))));
382        assert!(fmt.requirement_is_unresolved(&VersionReq::new("some-branch")));
383    }
384
385    #[test]
386    fn test_requirement_is_unresolved_tag_latest_partial_are_resolved() {
387        let fmt = formatter();
388        assert!(!fmt.requirement_is_unresolved(&VersionReq::new("1.0.0")));
389        assert!(!fmt.requirement_is_unresolved(&VersionReq::new("~latest")));
390        assert!(!fmt.requirement_is_unresolved(&VersionReq::new("1.2")));
391    }
392
393    #[test]
394    fn test_is_requirement_up_to_date_latest_always_true() {
395        let fmt = formatter();
396        assert!(fmt.is_requirement_up_to_date(
397            &VersionReq::new("~latest"),
398            &ConcreteVersion::new("9.9.9")
399        ));
400    }
401
402    #[test]
403    fn test_is_requirement_up_to_date_partial_leading_components() {
404        let fmt = formatter();
405        assert!(
406            fmt.is_requirement_up_to_date(&VersionReq::new("1.2"), &ConcreteVersion::new("1.2.5"))
407        );
408        assert!(
409            !fmt.is_requirement_up_to_date(&VersionReq::new("1.2"), &ConcreteVersion::new("1.3.0"))
410        );
411        assert!(
412            fmt.is_requirement_up_to_date(&VersionReq::new("1"), &ConcreteVersion::new("1.9.0"))
413        );
414    }
415
416    #[test]
417    fn test_is_requirement_up_to_date_tag_exact_match() {
418        let fmt = formatter();
419        assert!(
420            fmt.is_requirement_up_to_date(
421                &VersionReq::new("v1.0.0"),
422                &ConcreteVersion::new("1.0.0")
423            )
424        );
425        assert!(
426            !fmt.is_requirement_up_to_date(
427                &VersionReq::new("1.0.0"),
428                &ConcreteVersion::new("1.1.0")
429            )
430        );
431    }
432
433    #[test]
434    fn test_is_requirement_up_to_date_sha_never_false_positive() {
435        let fmt = formatter();
436        assert!(fmt.is_requirement_up_to_date(
437            &VersionReq::new("a".repeat(40)),
438            &ConcreteVersion::new("1.0.0")
439        ));
440    }
441
442    /// M-c (#466 review) regression: `requirement_status_for` must side with the
443    /// dependency's own `dep.pin` (here `Branch`, as a `project:` ref's simpler grammar
444    /// would classify it — see `crate::parser::classify_project_pin`), not the blanket
445    /// component-grammar text reclassification `is_requirement_up_to_date`/
446    /// `requirement_is_unresolved` fall back to, which would misjudge `"1.2"` as `Partial`.
447    #[test]
448    fn test_requirement_status_for_consults_dep_pin_not_text_reclassification() {
449        let fmt = formatter();
450        let d = dep(
451            Some(PinStyle::Branch),
452            "org/proj",
453            DependencySource::Registry,
454        );
455        let requirement = VersionReq::new("1.2");
456        // Text-only reclassification (what the two boolean methods still fall back to
457        // without a dependency) disagrees — it would call this `Partial`, not `Branch`,
458        // and (falling within `~1.2`'s range) report it up to date.
459        assert_eq!(
460            fmt.requirement_status(&requirement, &ConcreteVersion::new("1.2.9")),
461            RequirementStatus::UpToDate,
462            "sanity: bare text reclassification treats this as an up-to-date Partial pin"
463        );
464        // The dep-aware path must instead honor the authoritative `Branch` classification:
465        // honest-unknown, never a false "up to date" nor a false "outdated".
466        assert_eq!(
467            fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.2.9")),
468            RequirementStatus::Unresolved
469        );
470    }
471
472    #[test]
473    fn test_requirement_status_for_partial_pin_matches_boolean_method() {
474        let fmt = formatter();
475        let d = dep(
476            Some(PinStyle::Partial),
477            "org/proj",
478            DependencySource::Registry,
479        );
480        let requirement = VersionReq::new("1.2");
481        assert_eq!(
482            fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.2.9")),
483            RequirementStatus::UpToDate
484        );
485        assert_eq!(
486            fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.3.0")),
487            RequirementStatus::Outdated
488        );
489    }
490
491    #[test]
492    fn test_requirement_status_for_non_gitlab_dependency_falls_back_to_text() {
493        // A `dep` this formatter can't downcast (or whose `pin` is `None`) must fall back
494        // to the ordinary text-based `requirement_status`, not panic or misbehave.
495        struct OtherDep;
496        impl Dependency for OtherDep {
497            fn name(&self) -> &PackageName {
498                unimplemented!()
499            }
500            fn name_range(&self) -> tower_lsp_server::ls_types::Range {
501                Range::default()
502            }
503            fn version_requirement(&self) -> Option<&VersionReq> {
504                None
505            }
506            fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
507                None
508            }
509            fn source(&self) -> DependencySource {
510                DependencySource::Registry
511            }
512            fn as_any(&self) -> &dyn std::any::Any {
513                self
514            }
515        }
516        let fmt = formatter();
517        let requirement = VersionReq::new("^1.0");
518        assert_eq!(
519            fmt.requirement_status_for(&OtherDep, &requirement, &ConcreteVersion::new("1.5.0")),
520            fmt.requirement_status(&requirement, &ConcreteVersion::new("1.5.0"))
521        );
522    }
523
524    #[test]
525    fn test_format_version_replacing_for_partial_returns_current_unchanged() {
526        let fmt = formatter();
527        let d = dep(
528            Some(PinStyle::Partial),
529            "org/proj",
530            DependencySource::Registry,
531        );
532        assert_eq!(
533            fmt.format_version_replacing_for(&d, &ConcreteVersion::new("1.3.0"), "1.2"),
534            "1.2"
535        );
536    }
537
538    #[test]
539    fn test_format_version_replacing_for_latest_returns_current_unchanged() {
540        let fmt = formatter();
541        let d = dep(
542            Some(PinStyle::Latest),
543            "org/proj",
544            DependencySource::Registry,
545        );
546        assert_eq!(
547            fmt.format_version_replacing_for(&d, &ConcreteVersion::new("2.0.0"), "~latest"),
548            "~latest"
549        );
550    }
551
552    #[test]
553    fn test_format_version_replacing_for_tag_preserves_v_style() {
554        let fmt = formatter();
555        let d = dep(Some(PinStyle::Tag), "org/proj", DependencySource::Registry);
556        assert_eq!(
557            fmt.format_version_replacing_for(&d, &ConcreteVersion::new("2.0.0"), "v1.0.0"),
558            "v2.0.0"
559        );
560    }
561
562    #[test]
563    fn test_osv_version_strips_v_prefix() {
564        let fmt = formatter();
565        assert_eq!(fmt.osv_version("v1.2.3"), "1.2.3");
566    }
567}