1use 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
24const MAX_UNRESOLVED_HOST_MESSAGE_VALUE_CHARS: usize = 128;
27
28const COMPONENT_PIN_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(5);
33
34pub struct GitlabCiEcosystem {
39 registry: Arc<GitlabCiRegistry>,
40 formatter: GitlabCiFormatter,
41 policy: Arc<RegistryAccessPolicy>,
42 instance_host: Arc<GitlabInstanceHost>,
43}
44
45impl GitlabCiEcosystem {
46 #[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 #[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 fn manifest_filenames(&self) -> &[&'static str] {
99 &[".gitlab-ci.yml"]
100 }
101
102 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 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 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 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 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 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 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
378fn 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
394fn 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 #[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 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 #[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}