Skip to main content

deps_maven/
ecosystem.rs

1//! Maven ecosystem implementation for deps-lsp.
2
3use 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/// Which half of a Maven `groupId:artifactId` coordinate a completion should insert.
27///
28/// A pom.xml `<groupId>`/`<artifactId>` tag only ever holds one half of the coordinate,
29/// so the completion inserted into it must not be the full "group:artifact" search result.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum MavenNameField {
32    GroupId,
33    ArtifactId,
34}
35
36/// Builds a completion item for one field of a Maven coordinate.
37///
38/// Reuses [`deps_core::completion::build_package_completion`] for documentation/detail
39/// formatting, then overrides the insertable text to just the requested field so it fits
40/// the single `<groupId>` or `<artifactId>` tag the cursor is inside. `replace_range` must
41/// span the entire existing tag value, not just the already-typed prefix (see
42/// [`MavenEcosystem::detect_xml_context`]) — the base builder's own range is a placeholder
43/// `(0,0)-(0,0)` that does not contain the real cursor position and would corrupt the
44/// document if used as-is.
45///
46/// Returns `None` when the requested field's value doesn't pass
47/// [`is_safe_maven_coordinate_segment`], or when the base builder itself rejects the
48/// combined `groupId:artifactId` name — a malicious/compromised search result must not
49/// reach the manifest as an unsanitized `TextEdit`, so the item is dropped rather than
50/// built with unsafe text. A known, benign edge case: two maximal-length segments
51/// (128 bytes each, [`is_safe_maven_coordinate_segment`]'s own cap) joined by `:` is
52/// 257 bytes, one over [`deps_core::is_safe_package_name`]'s 256-byte cap — an
53/// implausibly long but fully legitimate coordinate would be dropped here too,
54/// failing closed rather than insecurely.
55fn 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
87/// Builds completion items for one field of a Maven coordinate, deduped by that field's value.
88///
89/// Several search results can share the same `groupId` (or, more rarely, `artifactId`) —
90/// collapsed here to one item per distinct value, since they would otherwise insert
91/// identical text into the tag and only clutter the list. Keeps the first (highest-relevance,
92/// per the registry's own ranking) match for each value.
93fn 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    /// Detects Maven XML completion context at the given position.
158    ///
159    /// Returns `(context_type, value, value_range)` where `context_type` is "version",
160    /// "artifactId", "groupId", or empty string for no completion; `value` is the
161    /// already-typed prefix up to the cursor, used as the search query; `value_range` spans
162    /// the *entire* existing tag value (opening tag to closing tag, not just up to the
163    /// cursor) and is the range a completion's `text_edit` must replace so the whole value
164    /// is overwritten instead of leaving trailing characters behind — it is meaningless when
165    /// `context_type` is empty.
166    ///
167    /// `position.character` is a UTF-16 code unit offset (LSP spec) and is converted to a
168    /// byte offset once via [`deps_core::completion::utf16_to_byte_offset`] before any
169    /// slicing; the returned `value_range`'s `character` fields are converted back to UTF-16
170    /// units via [`deps_core::completion::byte_to_utf16_offset`]. This avoids panics on
171    /// multi-byte tag content (e.g. accented characters) and keeps the returned range valid
172    /// for LSP clients.
173    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        // Find if cursor is inside a tag value: <tag>|value|</tag>
190        // Walk back from cursor to find opening tag
191        let before_cursor = &line[..col_idx];
192
193        // Check if we're inside a known element by looking for the most recent opening tag
194        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                // Make sure there's no closing tag before cursor
199                let between = &before_cursor[value_start..];
200                if !between.contains("</") {
201                    // Check if cursor is on a dependency line (use parse_result for context)
202                    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; // 4 spaces indent
393        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        // <version>|4.13.2</version> — cursor right after '>'
404        let line = "<version>4.13.2</version>";
405        // col 0..8 is "<version", col 9 is '4'
406        let (t, v) = xml_context(line, 9); // col at value_start
407        assert_eq!(t, "version");
408        assert_eq!(v, "");
409    }
410
411    #[test]
412    fn test_detect_xml_context_version_cursor_mid() {
413        // <version>4.1|3.2</version>
414        let line = "<version>4.13.2</version>";
415        let (t, v) = xml_context(line, 12); // "4.1" = 3 chars after value_start (9)
416        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        // <version>4.13.2|</version>
423        let line = "<version>4.13.2</version>";
424        let (t, v) = xml_context(line, 15); // value_start=9, end=15
425        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        // <version>|</version>
432        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        // <artifactId>jun|it</artifactId>
441        let line = "<artifactId>junit</artifactId>";
442        let (t, v) = xml_context(line, 15); // value_start=12, cursor at 15 = "jun"
443        assert_eq!(t, "artifactId");
444        assert_eq!(v, "jun");
445    }
446
447    /// #282 S1 (second critic round) parity guard: `deps-lsp`'s `completion.rs`
448    /// (`extract_prefix`/`strip_leading_xml_tag`) must extract the identical query
449    /// string for the identical cursor position, since it's a raw-text approximation of
450    /// this function's own tag-aware extraction, and both feed the same registry
451    /// dedup/cache-key mechanism (`MavenCentralRegistry::search_typed`). This line and
452    /// cursor position are kept intentionally identical to `deps-lsp`'s
453    /// `test_fallback_completion_maven_query_matches_tag_value` — if either extractor's
454    /// logic changes, update both tests and confirm they still agree.
455    #[test]
456    fn test_detect_xml_context_compact_multi_tag_line_matches_completion_extractor() {
457        // <dependency><groupId>com.google.guava</groupId><artifactId>gua| — cursor
458        // right after "gua", with an earlier `<groupId>...</groupId>` on the same line.
459        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    /// #282 S1 (second critic round) parity guard: cursor right after a fully closed
466    /// tag (`<artifactId>guava</artifactId>|`) yields no completion context at all —
467    /// `between.contains("</")` rejects it. `deps-lsp`'s `strip_leading_xml_tag` must
468    /// agree by yielding an empty string for the same position (which `fallback_completion`'s
469    /// existing empty-prefix guard then rejects), not a markup-polluted search query.
470    #[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        // <artifactId>jun|it</artifactId> — indented by 4 spaces in xml_context_with_range
481        // The range must span the FULL existing value ("junit"), not just up to the
482        // cursor, so a completion replaces the whole tag content instead of leaving
483        // trailing characters behind (issue #218a).
484        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        // value_start = 4 (indent) + 12 ("<artifactId>") = 16; value_end = 16 + "junit".len() = 21
489        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        // <groupId>org.apache.comm|ons</groupId>
496        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        // value_start = 4 (indent) + 9 ("<groupId>") = 13; value_end = 13 + "org.apache.commons".len() = 31
501        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        // <artifactId>🎉|lib</artifactId> — 🎉 (U+1F389) is 4 UTF-8 bytes but a UTF-16
508        // surrogate pair (2 code units); cursor placed right after it via UTF-16 units.
509        let line = "<artifactId>🎉lib</artifactId>";
510        let (t, v, range) = xml_context_with_range(line, 14); // value_start=12 + 2 (🎉)
511        assert_eq!(t, "artifactId");
512        assert_eq!(v, "🎉");
513        assert_eq!(range.start, Position::new(0, 16)); // 4 (indent) + 12
514        assert_eq!(range.end, Position::new(0, 21)); // 16 + "🎉lib".len() in UTF-16 units (2+3)
515    }
516
517    #[test]
518    fn test_detect_xml_context_value_end_fallback_no_closing_tag_on_line() {
519        // <artifactId>jun|it — no closing tag anywhere on the line. After the S1 fix the
520        // range falls back to the cursor position (insert-mode) rather than swallowing the
521        // rest of the line, since there is no proof of where the value actually ends.
522        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)); // 4 (indent) + 12
527        assert_eq!(range.end, Position::new(0, 19)); // falls back to cursor: 4 + 15
528    }
529
530    #[test]
531    fn test_detect_xml_context_no_closing_tag_range_excludes_trailing_comment() {
532        // <artifactId>ju|    <!-- todo --> — regression for S1: the old `line.len()`
533        // fallback swallowed the trailing comment into the replace range. The range must
534        // stop at the cursor, not extend into unrelated trailing content.
535        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)); // 4 (indent) + 12
540        assert_eq!(range.end, Position::new(0, 18)); // 4 + 14 — does not reach the comment
541    }
542
543    #[test]
544    fn test_detect_xml_context_range_always_contains_cursor() {
545        // <artifactId>ju|</artifactId> — cursor sits between '<' and '/' of the closing
546        // tag, so `find("</")` locates a match *before* the cursor. Regression for S2:
547        // per LSP 3.17, `textEdit.range` must contain the request position, so `range.end`
548        // must never fall before the cursor.
549        let line = "<artifactId>ju</artifactId>";
550        let cursor_col = 15u32; // indented cursor position
551        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        // <version>|</version> — empty existing value produces a zero-width range at the
565        // value's start.
566        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)); // 4 (indent) + "<version>".len()
572    }
573
574    #[test]
575    fn test_detect_xml_context_cursor_at_value_start_full_replace_range() {
576        // <version>|4.13.2</version> — range must span the full existing value even
577        // though the typed prefix is empty.
578        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)); // 4 (indent) + "<version>".len()
583        assert_eq!(range.end, Position::new(0, 19)); // 13 + "4.13.2".len()
584    }
585
586    #[test]
587    fn test_detect_xml_context_multibyte_value_no_panic() {
588        // <artifactId>café|-lib</artifactId> — cursor positioned via UTF-16 units right
589        // after the multi-byte 'é' (col 16 = value_start 12 + 4 UTF-16 units into "café"),
590        // reflecting how a real LSP client reports the position (issue #217 regression:
591        // this used to panic on the byte/UTF-16 mismatch).
592        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        // value_start (UTF-16 units) = 4 (indent) + "<artifactId>".len() = 16
598        assert_eq!(range.start, Position::new(0, 16));
599        // full value "café-lib" is 8 UTF-16 units long -> end = 16 + 8 = 24
600        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        // text_edit must replace exactly the caller-supplied range (the already-typed value
648        // text), not the base builder's placeholder (0,0)-(0,0) range.
649        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        // `build_field_completion` only validates the *requested* field via
697        // `is_safe_maven_coordinate_segment` — the other half of the coordinate is
698        // left unvalidated by that check alone. Here `group_id` (the requested
699        // field) is safe on its own, but `artifact_id` contains a space, which is
700        // outside `is_safe_package_name`'s allowlist (though it would also fail
701        // `is_safe_maven_coordinate_segment`, that check never runs against the
702        // non-requested field). Realistically the registry always sets `name` to
703        // the joined `group_id:artifact_id` (see `crates/deps-maven/src/registry.rs`),
704        // so the base builder's `is_safe_package_name` gate on the combined name is
705        // what closes this — and that rejection must propagate through
706        // `build_field_completion`'s `?`, poisoning `label` otherwise (never
707        // overridden by this function).
708        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}