1use std::any::Any;
4use std::sync::Arc;
5use tower_lsp_server::ls_types::{
6 CompletionItem, CompletionTextEdit, Position, Range as LspRange, TextEdit, Uri,
7};
8
9use deps_core::{
10 Ecosystem, ParseResult as ParseResultTrait, Registry, Result,
11 completion::Completions,
12 is_safe_maven_coordinate_segment,
13 lsp_helpers::{EcosystemFormatter, warn_rejected_value},
14 position_in_range,
15};
16
17use crate::formatter::MavenFormatter;
18use crate::registry::MavenCentralRegistry;
19use crate::types::ArtifactInfo;
20
21pub struct MavenEcosystem {
22 registry: Arc<MavenCentralRegistry>,
23 formatter: MavenFormatter,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum MavenNameField {
32 GroupId,
33 ArtifactId,
34}
35
36fn build_field_completion(
56 artifact: &ArtifactInfo,
57 field: MavenNameField,
58 replace_range: LspRange,
59) -> Option<CompletionItem> {
60 let value = match field {
61 MavenNameField::GroupId => artifact.group_id.clone(),
62 MavenNameField::ArtifactId => artifact.artifact_id.clone(),
63 };
64
65 if !is_safe_maven_coordinate_segment(&value) {
66 warn_rejected_value(
67 "is_safe_maven_coordinate_segment",
68 "maven coordinate field completion",
69 &value,
70 );
71 return None;
72 }
73
74 let mut item = deps_core::completion::build_package_completion(artifact, LspRange::default())?;
75
76 item.insert_text = Some(value.clone());
77 item.filter_text = Some(value.clone());
78 item.sort_text = Some(value.clone());
79 item.text_edit = Some(CompletionTextEdit::Edit(TextEdit {
80 range: replace_range,
81 new_text: value,
82 }));
83
84 Some(item)
85}
86
87fn build_deduped_field_completions(
94 results: &[ArtifactInfo],
95 field: MavenNameField,
96 replace_range: LspRange,
97) -> Vec<CompletionItem> {
98 let mut seen = std::collections::HashSet::new();
99 results
100 .iter()
101 .filter(|artifact| {
102 let value = match field {
103 MavenNameField::GroupId => &artifact.group_id,
104 MavenNameField::ArtifactId => &artifact.artifact_id,
105 };
106 seen.insert(value.clone())
107 })
108 .filter_map(|artifact| build_field_completion(artifact, field, replace_range))
109 .collect()
110}
111
112impl MavenEcosystem {
113 pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
114 Self {
115 registry: Arc::new(MavenCentralRegistry::new(cache)),
116 formatter: MavenFormatter,
117 }
118 }
119
120 async fn complete_package_names_for_field(
121 &self,
122 prefix: &str,
123 field: MavenNameField,
124 replace_range: LspRange,
125 ) -> Vec<CompletionItem> {
126 if !deps_core::completion::is_valid_completion_prefix_len(prefix) {
127 return vec![];
128 }
129
130 let results = match self.registry.search_typed(prefix, 20).await {
131 Ok(r) => r,
132 Err(e) => {
133 tracing::warn!("Maven registry search failed for '{}': {}", prefix, e);
134 return vec![];
135 }
136 };
137
138 build_deduped_field_completions(&results, field, replace_range)
139 }
140
141 async fn complete_versions(
142 &self,
143 package_name: &deps_core::PackageName,
144 prefix: &str,
145 freshness: deps_core::FreshnessSettings,
146 ) -> Vec<CompletionItem> {
147 deps_core::completion::complete_versions_generic(
148 self.registry.as_ref(),
149 package_name,
150 prefix,
151 &[],
152 freshness,
153 )
154 .await
155 }
156
157 fn detect_xml_context<'a>(
174 content: &'a str,
175 position: Position,
176 parse_result: &dyn ParseResultTrait,
177 ) -> (&'static str, &'a str, LspRange) {
178 let lines: Vec<&str> = content.lines().collect();
179 let line_idx = position.line as usize;
180
181 if line_idx >= lines.len() {
182 return ("", "", LspRange::default());
183 }
184
185 let line = lines[line_idx];
186 let col_idx = deps_core::completion::utf16_to_byte_offset(line, position.character)
187 .unwrap_or(line.len());
188
189 let before_cursor = &line[..col_idx];
192
193 for tag in &["version", "artifactId", "groupId"] {
195 let open = format!("<{tag}>");
196 if let Some(start) = before_cursor.rfind(&open) {
197 let value_start = start + open.len();
198 let between = &before_cursor[value_start..];
200 if !between.contains("</") {
201 let _ = parse_result;
203 let value = &line[value_start..col_idx];
204 let value_end = line[value_start..]
205 .find("</")
206 .map_or(col_idx, |rel| value_start + rel)
207 .max(col_idx);
208 let value_range = LspRange {
209 start: Position {
210 line: position.line,
211 character: deps_core::completion::byte_to_utf16_offset(
212 line,
213 value_start,
214 ),
215 },
216 end: Position {
217 line: position.line,
218 character: deps_core::completion::byte_to_utf16_offset(line, value_end),
219 },
220 };
221 return (tag, value, value_range);
222 }
223 }
224 }
225
226 ("", "", LspRange::default())
227 }
228}
229
230impl deps_core::ecosystem::private::Sealed for MavenEcosystem {}
231
232impl Ecosystem for MavenEcosystem {
233 fn id(&self) -> &'static str {
234 "maven"
235 }
236
237 fn display_name(&self) -> &'static str {
238 "Maven (JVM)"
239 }
240
241 fn manifest_filenames(&self) -> &[&'static str] {
242 &["pom.xml"]
243 }
244
245 fn lockfile_filenames(&self) -> &[&'static str] {
246 &[]
247 }
248
249 fn parse_manifest<'a>(
250 &'a self,
251 content: &'a str,
252 uri: &'a Uri,
253 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
254 Box::pin(async move {
255 let result = crate::parser::parse_pom_xml(content, uri)?;
256 Ok(Box::new(result) as Box<dyn ParseResultTrait>)
257 })
258 }
259
260 fn registry(&self) -> Arc<dyn Registry> {
261 self.registry.clone() as Arc<dyn Registry>
262 }
263
264 fn formatter(&self) -> &dyn EcosystemFormatter {
265 &self.formatter
266 }
267
268 fn generate_completions<'a>(
269 &'a self,
270 parse_result: &'a dyn ParseResultTrait,
271 position: Position,
272 content: &'a str,
273 freshness: deps_core::FreshnessSettings,
274 ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
275 Box::pin(async move {
276 let (ctx_type, value, value_range) =
277 Self::detect_xml_context(content, position, parse_result);
278
279 match ctx_type {
280 "version" => {
281 let dep = parse_result.dependencies().into_iter().find(|d| {
282 d.version_range()
283 .is_some_and(|r| position_in_range(position, r))
284 || d.name_range().start.line == position.line
285 });
286 if let Some(dep) = dep {
287 self.complete_versions(dep.name(), value, freshness).await
288 } else {
289 vec![]
290 }
291 }
292 "artifactId" => {
293 self.complete_package_names_for_field(
294 value,
295 MavenNameField::ArtifactId,
296 value_range,
297 )
298 .await
299 }
300 "groupId" => {
301 self.complete_package_names_for_field(
302 value,
303 MavenNameField::GroupId,
304 value_range,
305 )
306 .await
307 }
308 _ => vec![],
309 }
310 .into()
311 })
312 }
313
314 fn as_any(&self) -> &dyn Any {
315 self
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_ecosystem_id() {
325 let cache = Arc::new(deps_core::HttpCache::new());
326 let eco = MavenEcosystem::new(cache);
327 assert_eq!(eco.id(), "maven");
328 }
329
330 #[test]
331 fn test_ecosystem_display_name() {
332 let cache = Arc::new(deps_core::HttpCache::new());
333 let eco = MavenEcosystem::new(cache);
334 assert_eq!(eco.display_name(), "Maven (JVM)");
335 }
336
337 #[test]
338 fn test_manifest_filenames() {
339 let cache = Arc::new(deps_core::HttpCache::new());
340 let eco = MavenEcosystem::new(cache);
341 assert_eq!(eco.manifest_filenames(), &["pom.xml"]);
342 }
343
344 #[test]
345 fn test_lockfile_filenames() {
346 let cache = Arc::new(deps_core::HttpCache::new());
347 let eco = MavenEcosystem::new(cache);
348 assert!(eco.lockfile_filenames().is_empty());
349 }
350
351 #[test]
352 fn test_lockfile_provider_none() {
353 let cache = Arc::new(deps_core::HttpCache::new());
354 let eco = MavenEcosystem::new(cache);
355 assert!(eco.lockfile_provider().is_none());
356 }
357
358 #[test]
359 fn test_as_any() {
360 let cache = Arc::new(deps_core::HttpCache::new());
361 let eco = MavenEcosystem::new(cache);
362 assert!(eco.as_any().is::<MavenEcosystem>());
363 }
364
365 struct NoopParseResult;
366 impl deps_core::ParseResult for NoopParseResult {
367 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
368 vec![]
369 }
370 fn workspace_root(&self) -> Option<&std::path::Path> {
371 None
372 }
373 fn uri(&self) -> &tower_lsp_server::ls_types::Uri {
374 unimplemented!()
375 }
376 fn as_any(&self) -> &dyn std::any::Any {
377 self
378 }
379 }
380
381 fn make_position(line: u32, character: u32) -> Position {
382 Position { line, character }
383 }
384
385 fn xml_context(line_content: &str, col: u32) -> (&'static str, String) {
386 let (t, v, _range) = xml_context_with_range(line_content, col);
387 (t, v)
388 }
389
390 fn xml_context_with_range(line_content: &str, col: u32) -> (&'static str, String, LspRange) {
391 let content = format!(" {line_content}\n");
392 let col_in_content = col + 4; let (t, v, range) = MavenEcosystem::detect_xml_context(
394 &content,
395 make_position(0, col_in_content),
396 &NoopParseResult,
397 );
398 (t, v.to_owned(), range)
399 }
400
401 #[test]
402 fn test_detect_xml_context_version_cursor_at_start() {
403 let line = "<version>4.13.2</version>";
405 let (t, v) = xml_context(line, 9); assert_eq!(t, "version");
408 assert_eq!(v, "");
409 }
410
411 #[test]
412 fn test_detect_xml_context_version_cursor_mid() {
413 let line = "<version>4.13.2</version>";
415 let (t, v) = xml_context(line, 12); assert_eq!(t, "version");
417 assert_eq!(v, "4.1");
418 }
419
420 #[test]
421 fn test_detect_xml_context_version_cursor_at_end() {
422 let line = "<version>4.13.2</version>";
424 let (t, v) = xml_context(line, 15); assert_eq!(t, "version");
426 assert_eq!(v, "4.13.2");
427 }
428
429 #[test]
430 fn test_detect_xml_context_version_empty_value() {
431 let line = "<version></version>";
433 let (t, v) = xml_context(line, 9);
434 assert_eq!(t, "version");
435 assert_eq!(v, "");
436 }
437
438 #[test]
439 fn test_detect_xml_context_artifact_id_prefix() {
440 let line = "<artifactId>junit</artifactId>";
442 let (t, v) = xml_context(line, 15); assert_eq!(t, "artifactId");
444 assert_eq!(v, "jun");
445 }
446
447 #[test]
456 fn test_detect_xml_context_compact_multi_tag_line_matches_completion_extractor() {
457 let line = "<dependency><groupId>com.google.guava</groupId><artifactId>gua";
460 let (t, v) = xml_context(line, u32::try_from(line.len()).unwrap());
461 assert_eq!(t, "artifactId");
462 assert_eq!(v, "gua");
463 }
464
465 #[test]
471 fn test_detect_xml_context_after_closed_tag_yields_no_context() {
472 let line = "<artifactId>guava</artifactId>";
473 let (t, v) = xml_context(line, u32::try_from(line.len()).unwrap());
474 assert_eq!(t, "");
475 assert_eq!(v, "");
476 }
477
478 #[test]
479 fn test_detect_xml_context_artifact_id_range_spans_full_value() {
480 let line = "<artifactId>junit</artifactId>";
485 let (t, v, range) = xml_context_with_range(line, 15);
486 assert_eq!(t, "artifactId");
487 assert_eq!(v, "jun");
488 assert_eq!(range.start, Position::new(0, 16));
490 assert_eq!(range.end, Position::new(0, 21));
491 }
492
493 #[test]
494 fn test_detect_xml_context_group_id_range_spans_full_value() {
495 let line = "<groupId>org.apache.commons</groupId>";
497 let (t, v, range) = xml_context_with_range(line, 24);
498 assert_eq!(t, "groupId");
499 assert_eq!(v, "org.apache.comm");
500 assert_eq!(range.start, Position::new(0, 13));
502 assert_eq!(range.end, Position::new(0, 31));
503 }
504
505 #[test]
506 fn test_detect_xml_context_surrogate_pair_value_no_panic() {
507 let line = "<artifactId>🎉lib</artifactId>";
510 let (t, v, range) = xml_context_with_range(line, 14); assert_eq!(t, "artifactId");
512 assert_eq!(v, "🎉");
513 assert_eq!(range.start, Position::new(0, 16)); assert_eq!(range.end, Position::new(0, 21)); }
516
517 #[test]
518 fn test_detect_xml_context_value_end_fallback_no_closing_tag_on_line() {
519 let line = "<artifactId>junit";
523 let (t, v, range) = xml_context_with_range(line, 15);
524 assert_eq!(t, "artifactId");
525 assert_eq!(v, "jun");
526 assert_eq!(range.start, Position::new(0, 16)); assert_eq!(range.end, Position::new(0, 19)); }
529
530 #[test]
531 fn test_detect_xml_context_no_closing_tag_range_excludes_trailing_comment() {
532 let line = "<artifactId>ju <!-- todo -->";
536 let (t, v, range) = xml_context_with_range(line, 14);
537 assert_eq!(t, "artifactId");
538 assert_eq!(v, "ju");
539 assert_eq!(range.start, Position::new(0, 16)); assert_eq!(range.end, Position::new(0, 18)); }
542
543 #[test]
544 fn test_detect_xml_context_range_always_contains_cursor() {
545 let line = "<artifactId>ju</artifactId>";
550 let cursor_col = 15u32; let (t, v, range) = xml_context_with_range(line, 15);
552 assert_eq!(t, "artifactId");
553 assert_eq!(v, "ju<");
554 let cursor = Position::new(0, cursor_col + 4);
555 assert!(
556 range.end >= cursor,
557 "range {range:?} must contain cursor {cursor:?}"
558 );
559 assert_eq!(range.end, Position::new(0, 19));
560 }
561
562 #[test]
563 fn test_detect_xml_context_empty_value_zero_width_range() {
564 let line = "<version></version>";
567 let (t, v, range) = xml_context_with_range(line, 9);
568 assert_eq!(t, "version");
569 assert_eq!(v, "");
570 assert_eq!(range.start, range.end);
571 assert_eq!(range.start, Position::new(0, 13)); }
573
574 #[test]
575 fn test_detect_xml_context_cursor_at_value_start_full_replace_range() {
576 let line = "<version>4.13.2</version>";
579 let (t, v, range) = xml_context_with_range(line, 9);
580 assert_eq!(t, "version");
581 assert_eq!(v, "");
582 assert_eq!(range.start, Position::new(0, 13)); assert_eq!(range.end, Position::new(0, 19)); }
585
586 #[test]
587 fn test_detect_xml_context_multibyte_value_no_panic() {
588 let line = "<artifactId>café-lib</artifactId>";
593 let (t, v, range) = xml_context_with_range(line, 16);
594 assert_eq!(t, "artifactId");
595 assert_eq!(v, "café");
596
597 assert_eq!(range.start, Position::new(0, 16));
599 assert_eq!(range.end, Position::new(0, 24));
601 }
602
603 #[tokio::test]
604 async fn test_complete_package_names_for_field_min_prefix() {
605 let cache = Arc::new(deps_core::HttpCache::new());
606 let eco = MavenEcosystem::new(cache);
607 let range = LspRange::default();
608 assert!(
609 eco.complete_package_names_for_field("a", MavenNameField::ArtifactId, range)
610 .await
611 .is_empty()
612 );
613 assert!(
614 eco.complete_package_names_for_field("", MavenNameField::GroupId, range)
615 .await
616 .is_empty()
617 );
618 }
619
620 fn test_artifact() -> ArtifactInfo {
621 ArtifactInfo {
622 group_id: "org.apache.commons".to_string(),
623 artifact_id: "commons-lang3".to_string(),
624 name: "org.apache.commons:commons-lang3".to_string().into(),
625 description: Some("Apache Commons Lang".to_string()),
626 latest_version: "3.14.0".into(),
627 repository: None,
628 }
629 }
630
631 fn test_range() -> LspRange {
632 LspRange {
633 start: Position::new(3, 12),
634 end: Position::new(3, 15),
635 }
636 }
637
638 #[test]
639 fn test_build_field_completion_artifact_id() {
640 let artifact = test_artifact();
641 let range = test_range();
642 let item = build_field_completion(&artifact, MavenNameField::ArtifactId, range).unwrap();
643
644 assert_eq!(item.insert_text, Some("commons-lang3".to_string()));
645 assert_eq!(item.filter_text, Some("commons-lang3".to_string()));
646 assert_eq!(item.label, "org.apache.commons:commons-lang3");
647 assert_eq!(
650 item.text_edit,
651 Some(CompletionTextEdit::Edit(TextEdit {
652 range,
653 new_text: "commons-lang3".to_string(),
654 }))
655 );
656 }
657
658 #[test]
659 fn test_build_field_completion_group_id() {
660 let artifact = test_artifact();
661 let range = test_range();
662 let item = build_field_completion(&artifact, MavenNameField::GroupId, range).unwrap();
663
664 assert_eq!(item.insert_text, Some("org.apache.commons".to_string()));
665 assert_eq!(item.filter_text, Some("org.apache.commons".to_string()));
666 assert_eq!(item.label, "org.apache.commons:commons-lang3");
667 assert_eq!(
668 item.text_edit,
669 Some(CompletionTextEdit::Edit(TextEdit {
670 range,
671 new_text: "org.apache.commons".to_string(),
672 }))
673 );
674 }
675
676 #[test]
677 fn test_build_field_completion_rejects_xml_breakout_artifact_id() {
678 let mut artifact = test_artifact();
679 artifact.artifact_id = "commons</artifactId><parent><groupId>evil".to_string();
680 let range = test_range();
681
682 assert!(build_field_completion(&artifact, MavenNameField::ArtifactId, range).is_none());
683 }
684
685 #[test]
686 fn test_build_field_completion_rejects_control_character_group_id() {
687 let mut artifact = test_artifact();
688 artifact.group_id = "org.apache\ncommons".to_string();
689 let range = test_range();
690
691 assert!(build_field_completion(&artifact, MavenNameField::GroupId, range).is_none());
692 }
693
694 #[test]
695 fn test_build_field_completion_rejects_when_base_builder_rejects_name() {
696 let mut artifact = test_artifact();
709 artifact.artifact_id = "commons lang3".to_string();
710 artifact.name = format!("{}:{}", artifact.group_id, artifact.artifact_id).into();
711 let range = test_range();
712
713 assert!(is_safe_maven_coordinate_segment(&artifact.group_id));
714 assert!(build_field_completion(&artifact, MavenNameField::GroupId, range).is_none());
715 }
716
717 #[test]
718 fn test_build_deduped_field_completions_drops_unsafe_results() {
719 let results = vec![
720 ArtifactInfo {
721 group_id: "org.apache.commons".to_string(),
722 artifact_id: "commons-lang3".to_string(),
723 name: "org.apache.commons:commons-lang3".to_string().into(),
724 description: None,
725 latest_version: "3.14.0".into(),
726 repository: None,
727 },
728 ArtifactInfo {
729 group_id: "org.evil</groupId><parent>".to_string(),
730 artifact_id: "payload".to_string(),
731 name: "org.evil:payload".to_string().into(),
732 description: None,
733 latest_version: "1.0.0".into(),
734 repository: None,
735 },
736 ];
737
738 let items =
739 build_deduped_field_completions(&results, MavenNameField::GroupId, test_range());
740
741 assert_eq!(items.len(), 1);
742 assert_eq!(items[0].insert_text, Some("org.apache.commons".to_string()));
743 }
744
745 #[test]
746 fn test_build_deduped_field_completions_dedupes_shared_group_id() {
747 let results = vec![
748 ArtifactInfo {
749 group_id: "org.apache.commons".to_string(),
750 artifact_id: "commons-lang3".to_string(),
751 name: "org.apache.commons:commons-lang3".to_string().into(),
752 description: None,
753 latest_version: "3.14.0".into(),
754 repository: None,
755 },
756 ArtifactInfo {
757 group_id: "org.apache.commons".to_string(),
758 artifact_id: "commons-io".to_string(),
759 name: "org.apache.commons:commons-io".to_string().into(),
760 description: None,
761 latest_version: "2.16.1".into(),
762 repository: None,
763 },
764 ArtifactInfo {
765 group_id: "org.apache.commons".to_string(),
766 artifact_id: "commons-collections4".to_string(),
767 name: "org.apache.commons:commons-collections4".to_string().into(),
768 description: None,
769 latest_version: "4.4".into(),
770 repository: None,
771 },
772 ];
773
774 let items =
775 build_deduped_field_completions(&results, MavenNameField::GroupId, test_range());
776
777 assert_eq!(items.len(), 1);
778 assert_eq!(items[0].insert_text, Some("org.apache.commons".to_string()));
779 }
780
781 #[test]
782 fn test_build_deduped_field_completions_keeps_distinct_group_ids() {
783 let results = vec![
784 ArtifactInfo {
785 group_id: "org.apache.commons".to_string(),
786 artifact_id: "commons-lang3".to_string(),
787 name: "org.apache.commons:commons-lang3".to_string().into(),
788 description: None,
789 latest_version: "3.14.0".into(),
790 repository: None,
791 },
792 ArtifactInfo {
793 group_id: "com.google.guava".to_string(),
794 artifact_id: "guava".to_string(),
795 name: "com.google.guava:guava".to_string().into(),
796 description: None,
797 latest_version: "33.2.1-jre".into(),
798 repository: None,
799 },
800 ];
801
802 let items =
803 build_deduped_field_completions(&results, MavenNameField::GroupId, test_range());
804
805 assert_eq!(items.len(), 2);
806 }
807
808 #[test]
809 fn test_build_deduped_field_completions_dedupes_shared_artifact_id() {
810 let results = vec![
811 ArtifactInfo {
812 group_id: "org.foo".to_string(),
813 artifact_id: "commons".to_string(),
814 name: "org.foo:commons".to_string().into(),
815 description: None,
816 latest_version: "1.0.0".into(),
817 repository: None,
818 },
819 ArtifactInfo {
820 group_id: "org.bar".to_string(),
821 artifact_id: "commons".to_string(),
822 name: "org.bar:commons".to_string().into(),
823 description: None,
824 latest_version: "2.0.0".into(),
825 repository: None,
826 },
827 ];
828
829 let items =
830 build_deduped_field_completions(&results, MavenNameField::ArtifactId, test_range());
831
832 assert_eq!(items.len(), 1);
833 assert_eq!(items[0].insert_text, Some("commons".to_string()));
834 }
835
836 #[tokio::test]
837 async fn test_parse_manifest() {
838 let cache = Arc::new(deps_core::HttpCache::new());
839 let eco = MavenEcosystem::new(cache);
840
841 let xml = r"<project>
842 <dependencies>
843 <dependency>
844 <groupId>junit</groupId>
845 <artifactId>junit</artifactId>
846 <version>4.13.2</version>
847 </dependency>
848 </dependencies>
849</project>";
850
851 #[cfg(windows)]
852 let path = "C:/test/pom.xml";
853 #[cfg(not(windows))]
854 let path = "/test/pom.xml";
855 let uri = Uri::from_file_path(path).unwrap();
856
857 let result = eco.parse_manifest(xml, &uri).await.unwrap();
858 assert_eq!(result.dependencies().len(), 1);
859 }
860}