Skip to main content

deps_gitlab_ci/
ecosystem.rs

1//! GitLab CI ecosystem implementation for deps-lsp.
2
3use std::any::Any;
4use std::sync::{Arc, RwLock};
5use std::time::Duration;
6use tower_lsp_server::ls_types::{
7    Diagnostic, DiagnosticSeverity, Hover, HoverContents, NumberOrString, Position, Uri,
8};
9
10use deps_core::net_policy::RegistryAccessPolicy;
11use deps_core::{
12    Ecosystem, HttpCache, ParseResult as ParseResultTrait, Registry, Result,
13    completion::Completions,
14    lsp_helpers::{EcosystemFormatter, markdown_code_span, truncate_for_diagnostic},
15};
16
17use crate::UNRESOLVED_HOST_DIAGNOSTIC_CODE;
18use crate::client::GitlabApiClient;
19use crate::formatter::GitlabCiFormatter;
20use crate::host::{GitlabInstanceHost, is_valid_gitlab_coordinate};
21use crate::registry::GitlabCiRegistry;
22use crate::types::{GitlabCiDependency, HostRef, IncludeKind, PinStyle};
23
24/// Maximum character count of an interpolated raw host expression before truncation —
25/// mirrors `deps_github_actions`'s `MAX_MUTABLE_REF_PIN_MESSAGE_VALUE_CHARS` precedent.
26const MAX_UNRESOLVED_HOST_MESSAGE_VALUE_CHARS: usize = 128;
27
28/// Bound on [`GitlabCiRegistry::resolve_component_pin`]'s FR-007 hover-time resolution
29/// (H1, #466 review) — mirrors `deps_core::lsp_helpers::hover`'s `HOVER_FALLBACK_TIMEOUT`
30/// precedent for a live-fetch fallback invoked from hover generation: a failure or timeout
31/// here degrades gracefully to no `**Resolved**` line, never aborting the rest of the hover.
32const COMPONENT_PIN_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(5);
33
34/// GitLab CI ecosystem implementation.
35///
36/// Provides LSP functionality for `.gitlab-ci.yml`/`.gitlab/ci/*.yml`/`*.yaml` files — see
37/// `crate` docs for the pin contract.
38pub struct GitlabCiEcosystem {
39    registry: Arc<GitlabCiRegistry>,
40    formatter: GitlabCiFormatter,
41    policy: Arc<RegistryAccessPolicy>,
42    instance_host: Arc<GitlabInstanceHost>,
43}
44
45impl GitlabCiEcosystem {
46    /// Creates a new GitLab CI ecosystem with a default (unset, process-default-policy)
47    /// context — used by simple construction paths that don't need a live
48    /// `registries.gitlab_instance_host`/`registries.workspace_registries` wiring (tests,
49    /// doctests).
50    #[must_use]
51    pub fn new(cache: Arc<HttpCache>) -> Self {
52        let policy = Arc::new(RegistryAccessPolicy::default());
53        Self::with_context(cache, policy, Arc::new(RwLock::new(None)))
54    }
55
56    /// Creates a GitLab CI ecosystem sharing live `policy`/`gitlab_instance_host` handles —
57    /// the production wiring path (`deps-lsp`'s `register_ecosystems`), mirroring
58    /// `NuGetEcosystem`/`PypiEcosystem`'s identical `with_context` precedent.
59    ///
60    /// `gitlab_instance_host_raw` is the feature-agnostic `Arc<RwLock<Option<String>>>` cell
61    /// `deps-lsp`'s `EcosystemRuntime` owns (spec §4.5's revision-3 note); this constructor
62    /// is the one place it becomes a crate-local [`GitlabInstanceHost`].
63    #[must_use]
64    pub fn with_context(
65        cache: Arc<HttpCache>,
66        policy: Arc<RegistryAccessPolicy>,
67        gitlab_instance_host_raw: Arc<RwLock<Option<String>>>,
68    ) -> Self {
69        let instance_host = Arc::new(GitlabInstanceHost::new(
70            gitlab_instance_host_raw,
71            Arc::clone(&policy),
72        ));
73        let client = Arc::new(GitlabApiClient::new(cache, Arc::clone(&instance_host)));
74        let registry = Arc::new(GitlabCiRegistry::new(client));
75        let formatter = GitlabCiFormatter::new(registry.routes(), registry.tag_index());
76        Self {
77            registry,
78            formatter,
79            policy,
80            instance_host,
81        }
82    }
83}
84
85impl deps_core::ecosystem::private::Sealed for GitlabCiEcosystem {}
86
87impl Ecosystem for GitlabCiEcosystem {
88    fn id(&self) -> &'static str {
89        "gitlab-ci"
90    }
91
92    fn display_name(&self) -> &'static str {
93        "GitLab CI/CD"
94    }
95
96    /// GitLab does not accept `.gitlab-ci.yaml` — only `.gitlab-ci.yml` is recognized
97    /// (spec FR-001).
98    fn manifest_filenames(&self) -> &[&'static str] {
99        &[".gitlab-ci.yml"]
100    }
101
102    /// The standard split-pipeline convention GitLab itself documents. This is the whole of
103    /// v1's detection: a child pipeline at a conventionless path is not detected (spec
104    /// FR-001).
105    fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
106        &[(".gitlab/ci", ".yml"), (".gitlab/ci", ".yaml")]
107    }
108
109    fn lockfile_filenames(&self) -> &[&'static str] {
110        &[]
111    }
112
113    /// Parses the manifest, then registers its routes into the shared registry and
114    /// downgrades any dependency whose route the process-wide cap refused to
115    /// `CustomRegistry` + [`HostRef::CapacityRefused`] (spec §3.2/§4.6) before returning —
116    /// the same downgrade shape (#466 review M-a) [`crate::parser`]'s per-document host cap
117    /// already produces, so the two capacity-refusal paths agree.
118    fn parse_manifest<'a>(
119        &'a self,
120        content: &'a str,
121        uri: &'a Uri,
122    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
123        Box::pin(async move {
124            let mut result = crate::parser::parse_gitlab_ci_yaml(
125                content,
126                uri,
127                &self.policy,
128                &self.instance_host,
129            )?;
130            let refused = self.registry.register_routes(&result.routes);
131            if !refused.is_empty() {
132                for dep in &mut result.dependencies {
133                    if let deps_core::parser::DependencySource::AlternateRegistry { index, .. } =
134                        &dep.source
135                        && refused.contains(index)
136                    {
137                        let origin = match &dep.host {
138                            HostRef::Literal(host) => host.origin().to_string(),
139                            HostRef::Unresolved(raw) | HostRef::CapacityRefused(raw) => raw.clone(),
140                        };
141                        dep.source = deps_core::parser::DependencySource::CustomRegistry {
142                            url: origin.clone(),
143                        };
144                        dep.host = HostRef::CapacityRefused(origin);
145                    }
146                }
147            }
148            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
149        })
150    }
151
152    fn registry(&self) -> Arc<dyn Registry> {
153        self.registry.clone() as Arc<dyn Registry>
154    }
155
156    fn formatter(&self) -> &dyn EcosystemFormatter {
157        &self.formatter
158    }
159
160    /// Version completions only, resolved through the source-aware
161    /// [`deps_core::completion::complete_versions_generic_from`] (spec §7a.1) — the
162    /// source-unaware default would return nothing, since this crate's `Registry` never
163    /// resolves an unsourced fetch. The dependency's `source` is resolved **by position**,
164    /// not by name: a `project:` and a `component:` include of the same project can share
165    /// one `PackageName` (spec §3.1's documented residual collision), and a by-name lookup
166    /// would risk picking the wrong one's source.
167    fn generate_completions<'a>(
168        &'a self,
169        parse_result: &'a dyn ParseResultTrait,
170        position: Position,
171        content: &'a str,
172        freshness: deps_core::FreshnessSettings,
173    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
174        Box::pin(async move {
175            use deps_core::completion::{
176                CompletionContext, complete_versions_generic_from, detect_completion_context,
177            };
178
179            let CompletionContext::Version { prefix, .. } =
180                detect_completion_context(parse_result, position, content)
181            else {
182                return Completions::default();
183            };
184            let Some(dep) = parse_result.dependencies().into_iter().find(|d| {
185                d.version_range()
186                    .is_some_and(|r| deps_core::position_in_range(position, r))
187            }) else {
188                return Completions::default();
189            };
190
191            complete_versions_generic_from(
192                self.registry.as_ref(),
193                dep.name(),
194                &dep.source(),
195                &prefix,
196                &[],
197                freshness,
198            )
199            .await
200            .into()
201        })
202    }
203
204    /// Appends the FR-012 informational unresolved-host diagnostic to the shared default's
205    /// output, for every dependency whose host could not be statically determined.
206    fn generate_diagnostics<'a>(
207        &'a self,
208        parse_result: &'a dyn ParseResultTrait,
209        versions: deps_core::VersionData<'a>,
210        uri: &'a Uri,
211        freshness: deps_core::FreshnessSettings,
212        severities: deps_core::lsp_helpers::DiagnosticSeverities,
213    ) -> deps_core::ecosystem::BoxFuture<'a, Vec<Diagnostic>> {
214        Box::pin(async move {
215            let mut diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
216                parse_result,
217                versions,
218                self.formatter(),
219                uri,
220                freshness,
221                severities,
222                deps_core::PublishTime::now(),
223            );
224            diagnostics.extend(unresolved_host_diagnostics(parse_result));
225            diagnostics
226        })
227    }
228
229    /// Splices a `**Resolved**` line for a SHA pin (via the shared tag index) and, for a
230    /// `component:` include only, a `**Project**` link line — the one NFR-004 hover
231    /// divergence this ecosystem has (spec §8.1/§8.2).
232    fn generate_hover<'a>(
233        &'a self,
234        parse_result: &'a dyn ParseResultTrait,
235        position: Position,
236        versions: deps_core::VersionData<'a>,
237        freshness: deps_core::FreshnessSettings,
238    ) -> deps_core::ecosystem::BoxFuture<'a, Option<Hover>> {
239        Box::pin(async move {
240            let registry = self.registry.clone();
241            let base_hover = deps_core::lsp_generate_hover(
242                parse_result,
243                position,
244                versions,
245                registry.as_ref(),
246                self.formatter(),
247                freshness,
248                deps_core::PublishTime::now(),
249            )
250            .await;
251            let mut hover = base_hover?;
252
253            let dep = parse_result.dependencies().into_iter().find(|d| {
254                deps_core::position_in_range(position, d.name_range())
255                    || d.version_range()
256                        .is_some_and(|r| deps_core::position_in_range(position, r))
257            });
258            let Some(dep) = dep else {
259                return Some(hover);
260            };
261            let Some(gl_dep) = dep.as_any().downcast_ref::<GitlabCiDependency>() else {
262                return Some(hover);
263            };
264
265            if gl_dep.kind == IncludeKind::Component
266                && let HostRef::Literal(host) = &gl_dep.host
267                && is_valid_gitlab_coordinate(&gl_dep.project_path)
268                && let HoverContents::Markup(content) = &mut hover.contents
269            {
270                let url = format!("https://{}/{}", host.host(), gl_dep.project_path);
271                content.value = splice_project_line(&content.value, &url);
272            }
273
274            if gl_dep.pin == Some(PinStyle::Sha)
275                && let Some(sha) = gl_dep
276                    .version_req
277                    .as_ref()
278                    .map(deps_core::VersionReq::as_str)
279                && let Some(resolved_tag) = self.formatter.resolved_tag_for_sha(dep.name(), sha)
280                && let HoverContents::Markup(content) = &mut hover.contents
281            {
282                content.value = splice_resolved_line(&content.value, &resolved_tag, sha);
283            }
284
285            // FR-007 (H1, #466 review): a `component:` `Latest`/`Partial` pin names no
286            // concrete version by itself — unlike `Sha` (resolved above via the tag index,
287            // no extra fetch needed) or `Tag`/`Branch` (whose text either is or isn't the
288            // version). Resolving it needs the priority ladder run against the project's
289            // published releases.
290            if gl_dep.kind == IncludeKind::Component
291                && let Some(pin @ (PinStyle::Latest | PinStyle::Partial)) = &gl_dep.pin
292                && let deps_core::parser::DependencySource::AlternateRegistry { index, .. } =
293                    dep.source()
294                && let Some(route) = registry.routes().get(&index).map(|r| r.clone())
295                && let Some(raw) = gl_dep
296                    .version_req
297                    .as_ref()
298                    .map(deps_core::VersionReq::as_str)
299            {
300                let outcome = tokio::time::timeout(
301                    COMPONENT_PIN_RESOLUTION_TIMEOUT,
302                    registry.resolve_component_pin(dep.name(), &route, pin, raw),
303                )
304                .await;
305                match outcome {
306                    Ok(Ok(Some(resolved))) => {
307                        if let HoverContents::Markup(content) = &mut hover.contents {
308                            content.value = splice_resolved_line(
309                                &content.value,
310                                resolved.version.as_str(),
311                                &resolved.sha,
312                            );
313                        }
314                    }
315                    Ok(Ok(None)) => {}
316                    Ok(Err(error)) => {
317                        tracing::warn!(package = %dep.name(), %error, "FR-007 component pin resolution failed");
318                    }
319                    Err(_) => {
320                        tracing::warn!(package = %dep.name(), "FR-007 component pin resolution timed out");
321                    }
322                }
323            }
324
325            Some(hover)
326        })
327    }
328
329    fn as_any(&self) -> &dyn Any {
330        self
331    }
332}
333
334fn unresolved_host_diagnostics(parse_result: &dyn ParseResultTrait) -> Vec<Diagnostic> {
335    parse_result
336        .dependencies()
337        .into_iter()
338        .filter_map(|dep| {
339            let gl_dep = dep.as_any().downcast_ref::<GitlabCiDependency>()?;
340            // M-a (#466 review): a capacity refusal gets its own message — the host itself
341            // was perfectly determinable, so telling the user to set
342            // `registries.gitlab_instance_host` (a fix for `Unresolved`, not this) would be
343            // actively wrong.
344            let message = match &gl_dep.host {
345                HostRef::Unresolved(raw) => {
346                    let raw = truncate_for_diagnostic(raw, MAX_UNRESOLVED_HOST_MESSAGE_VALUE_CHARS);
347                    format!(
348                        "Cannot determine the GitLab instance host for '{raw}'. Set the \
349                         `registries.gitlab_instance_host` setting to enable version resolution."
350                    )
351                }
352                HostRef::CapacityRefused(origin) => {
353                    let origin =
354                        truncate_for_diagnostic(origin, MAX_UNRESOLVED_HOST_MESSAGE_VALUE_CHARS);
355                    format!(
356                        "'{origin}' was not registered for version resolution because a \
357                         GitLab CI host/route capacity limit was reached. Reduce the number of \
358                         distinct GitLab hosts or includes referenced in this workspace \
359                         (unrelated to the `registries.gitlab_instance_host` setting)."
360                    )
361                }
362                HostRef::Literal(_) => return None,
363            };
364            Some(Diagnostic {
365                range: gl_dep.name_range,
366                severity: Some(DiagnosticSeverity::INFORMATION),
367                message,
368                code: Some(NumberOrString::String(
369                    UNRESOLVED_HOST_DIAGNOSTIC_CODE.into(),
370                )),
371                source: Some("deps-lsp".into()),
372                ..Default::default()
373            })
374        })
375        .collect()
376}
377
378/// Inserts a `**Project**: [name](url)` line immediately after the hover heading, for a
379/// `component:` include whose heading link is suppressed (spec §8.2).
380fn splice_project_line(markdown: &str, url: &str) -> String {
381    let line = format!("**Project**: [{url}]({url})\n\n");
382    if let Some(pos) = markdown.find("\n\n") {
383        let insert_at = pos + 2;
384        let mut out = String::with_capacity(markdown.len() + line.len());
385        out.push_str(&markdown[..insert_at]);
386        out.push_str(&line);
387        out.push_str(&markdown[insert_at..]);
388        out
389    } else {
390        format!("{markdown}\n\n{line}")
391    }
392}
393
394/// Inserts a `**Resolved**: `tag` (`sha…`)` line immediately after the shared hover's
395/// `**Current**`/`**Requirement**` line, mirroring
396/// `deps_github_actions::ecosystem::splice_resolved_line` exactly.
397fn splice_resolved_line(markdown: &str, resolved_tag: &str, sha: &str) -> String {
398    let short_sha = sha.get(..7).unwrap_or(sha);
399    let line = format!(
400        "**Resolved**: {} ({})\n\n",
401        markdown_code_span(resolved_tag),
402        markdown_code_span(&format!("{short_sha}…"))
403    );
404    for anchor in ["**Current**: ", "**Requirement**: "] {
405        if let Some(pos) = markdown.find(anchor)
406            && let Some(rel_end) = markdown[pos..].find("\n\n")
407        {
408            let insert_at = pos + rel_end + 2;
409            let mut out = String::with_capacity(markdown.len() + line.len());
410            out.push_str(&markdown[..insert_at]);
411            out.push_str(&line);
412            out.push_str(&markdown[insert_at..]);
413            return out;
414        }
415    }
416    format!("{markdown}{line}")
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_ecosystem_id_and_display_name() {
425        let cache = Arc::new(HttpCache::new());
426        let eco = GitlabCiEcosystem::new(cache);
427        assert_eq!(eco.id(), "gitlab-ci");
428        assert_eq!(eco.display_name(), "GitLab CI/CD");
429    }
430
431    #[test]
432    fn test_manifest_routing() {
433        let cache = Arc::new(HttpCache::new());
434        let eco = GitlabCiEcosystem::new(cache);
435        assert_eq!(eco.manifest_filenames(), &[".gitlab-ci.yml"]);
436        assert_eq!(
437            eco.manifest_directory_patterns(),
438            &[(".gitlab/ci", ".yml"), (".gitlab/ci", ".yaml")]
439        );
440        assert!(eco.manifest_patterns().is_empty());
441        assert!(eco.manifest_extensions().is_empty());
442    }
443
444    #[test]
445    fn test_as_any() {
446        let cache = Arc::new(HttpCache::new());
447        let eco = GitlabCiEcosystem::new(cache);
448        assert!(eco.as_any().is::<GitlabCiEcosystem>());
449    }
450
451    #[tokio::test]
452    async fn test_parse_manifest_valid() {
453        let cache = Arc::new(HttpCache::new());
454        let eco = GitlabCiEcosystem::new(cache);
455        let uri = deps_core::test_util::test_uri("/repo/.gitlab-ci.yml");
456        let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n";
457        let result = eco.parse_manifest(content, &uri).await.unwrap();
458        assert_eq!(result.dependencies().len(), 1);
459    }
460
461    #[test]
462    fn test_splice_project_line() {
463        let markdown = "# gitlab.com/org/proj/comp\n\n**Requirement**: `1.0.0`\n";
464        let spliced = splice_project_line(markdown, "https://gitlab.com/org/proj");
465        assert!(spliced.contains("**Project**"));
466        assert!(spliced.find("**Project**").unwrap() < spliced.find("**Requirement**").unwrap());
467    }
468
469    #[test]
470    fn test_splice_resolved_line_after_requirement() {
471        let markdown = "# org/proj\n\n**Requirement**: `v1.0.0`\n\n**Latest**: `v1.1.0`\n";
472        let spliced = splice_resolved_line(markdown, "v1.0.0", &"a".repeat(40));
473        let req_pos = spliced.find("**Requirement**").unwrap();
474        let resolved_pos = spliced.find("**Resolved**").unwrap();
475        let latest_pos = spliced.find("**Latest**").unwrap();
476        assert!(req_pos < resolved_pos);
477        assert!(resolved_pos < latest_pos);
478    }
479
480    /// M-a (#466 review): the two failure modes must produce visibly different messages —
481    /// a genuinely unresolved host still points the user at `registries.gitlab_instance_host`,
482    /// but a capacity refusal must not, since that setting cannot fix a capacity limit.
483    #[test]
484    fn test_unresolved_host_diagnostics_distinguishes_capacity_refusal_from_unresolved() {
485        let uri = deps_core::test_util::test_uri("/repo/.gitlab-ci.yml");
486        let range = tower_lsp_server::ls_types::Range::default();
487        let make_dep = |host: HostRef| crate::types::GitlabCiDependency {
488            name: "org/proj/comp".into(),
489            name_range: range,
490            version_req: Some("1.0.0".into()),
491            version_range: Some(range),
492            version_literal: None,
493            source: deps_core::parser::DependencySource::CustomRegistry { url: "x".into() },
494            is_plain_scalar: true,
495            kind: IncludeKind::Component,
496            host,
497            pin: Some(PinStyle::Tag),
498            project_path: "org/proj".to_string(),
499        };
500        let parse_result = crate::types::GitlabCiParseResult {
501            dependencies: vec![
502                make_dep(HostRef::Unresolved("$CI_SERVER_FQDN".to_string())),
503                make_dep(HostRef::CapacityRefused(
504                    "https://gitlab.other.example".to_string(),
505                )),
506            ],
507            routes: vec![],
508            uri,
509        };
510
511        let diagnostics = unresolved_host_diagnostics(&parse_result);
512
513        assert_eq!(diagnostics.len(), 2);
514        assert!(
515            diagnostics[0]
516                .message
517                .contains("Set the `registries.gitlab_instance_host`")
518        );
519        // The capacity-refusal message must never instruct the user to *set* the setting —
520        // it may still name it (to explain it's *not* the fix), but must not tell them to
521        // configure it as a remedy.
522        assert!(
523            !diagnostics[1]
524                .message
525                .contains("Set the `registries.gitlab_instance_host`")
526        );
527        assert!(diagnostics[1].message.contains("capacity"));
528    }
529
530    /// Regression for the FR-012 diagnostic: an unresolved-host dependency must get the
531    /// informational diagnostic, and no other diagnostic must compete (its source is
532    /// `CustomRegistry`, which the shared unknown-package rule's `can_resolve_source` gate
533    /// already excludes).
534    #[tokio::test]
535    async fn test_generate_diagnostics_unresolved_host() {
536        let cache = Arc::new(HttpCache::new());
537        let eco = GitlabCiEcosystem::new(cache);
538        let uri = deps_core::test_util::test_uri("/repo/.gitlab-ci.yml");
539        let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n";
540        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
541        let cached = std::collections::HashMap::new();
542        let resolved = std::collections::HashMap::new();
543
544        let diagnostics = eco
545            .generate_diagnostics(
546                parse_result.as_ref(),
547                deps_core::VersionData::new(&cached, &resolved),
548                &uri,
549                deps_core::FreshnessSettings::default(),
550                deps_core::lsp_helpers::DiagnosticSeverities::default(),
551            )
552            .await;
553
554        let found = diagnostics
555            .iter()
556            .find(|d| {
557                d.code
558                    == Some(NumberOrString::String(
559                        UNRESOLVED_HOST_DIAGNOSTIC_CODE.into(),
560                    ))
561            })
562            .expect("expected the unresolved-host diagnostic");
563        assert_eq!(found.severity, Some(DiagnosticSeverity::INFORMATION));
564        assert!(found.message.contains("gitlab_instance_host"));
565    }
566}