Skip to main content

deps_core/
completion.rs

1//! Core completion infrastructure for deps-lsp.
2//!
3//! This module provides generic completion functionality that works across
4//! all package ecosystems (Cargo, npm, PyPI, etc.). It handles:
5//!
6//! - Context detection - determining what type of completion is appropriate
7//! - Prefix extraction - getting the text typed so far
8//! - CompletionItem builders - creating LSP completion responses
9//!
10//! # Architecture
11//!
12//! The completion system uses trait objects (`dyn Dependency`, `dyn ParseResult`,
13//! `dyn Version`, `dyn Metadata`) to work generically across ecosystems.
14//!
15//! # Examples
16//!
17//! ```no_run
18//! use deps_core::completion::{detect_completion_context, CompletionContext};
19//! use tower_lsp_server::ls_types::Position;
20//!
21//! // In your ecosystem's generate_completions implementation:
22//! async fn generate_completions(
23//!     parse_result: &dyn deps_core::ParseResult,
24//!     position: Position,
25//!     content: &str,
26//! ) -> Vec<tower_lsp_server::ls_types::CompletionItem> {
27//!     let context = detect_completion_context(parse_result, position, content);
28//!
29//!     match context {
30//!         CompletionContext::PackageName { prefix, range } => {
31//!             // Search registry and build completions, replacing `range`
32//!             vec![]
33//!         }
34//!         CompletionContext::Version { package_name, prefix } => {
35//!             // Fetch versions and build completions
36//!             vec![]
37//!         }
38//!         _ => vec![],
39//!     }
40//! }
41//! ```
42
43use crate::lsp_helpers::{escape_markdown, is_safe_version_string, warn_rejected_value};
44use crate::{
45    ConcreteVersion, FreshnessSettings, Metadata, PackageName, ParseResult, PublishTime, Version,
46    format_relative_age,
47};
48use std::time::Duration;
49use tower_lsp_server::ls_types::{
50    CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionTextEdit,
51    Documentation, MarkupContent, MarkupKind, Position, Range, TextEdit,
52};
53
54/// Wall-clock budget `deps-lsp`'s completion handler gives an ecosystem's
55/// `generate_completions` before treating it as a timeout.
56///
57/// Past this, the handler skips the fallback search rather than treating a
58/// fast-but-empty result as "genuinely no results"
59/// (`crates/deps-lsp/src/handlers/completion.rs`).
60///
61/// A registry-backed completion path that retries internally on failure (e.g.
62/// `deps-maven`'s `search_typed`, #274) must size its own total retry budget to
63/// exceed this constant: finishing sooner with an empty/error result is
64/// indistinguishable, at the call site, from a query that legitimately has no
65/// matches, and triggers a wasted (and, for a struggling registry, likely to also
66/// fail) fallback search rather than the handler's existing skip-on-timeout path.
67pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
68
69/// Result of [`Ecosystem::generate_completions`](crate::Ecosystem::generate_completions).
70///
71/// Carries the completion items produced for this specific call, plus whether they are
72/// a possibly-truncated view of a larger candidate set that the LSP client should
73/// re-query for as the user keeps typing.
74///
75/// Supersedes the ecosystem-wide `Ecosystem::completions_are_incomplete()` flag (#419):
76/// `is_incomplete` is computed per call from the actual completion context and result
77/// set, so only the specific context that is genuinely truncated (e.g. PyPI's unranked,
78/// index-backed package-name search) is flagged — a version completion, a comment
79/// position, or any other exhaustive context in the same manifest correctly reports
80/// `is_incomplete: false` instead of inheriting the worst case across the whole
81/// ecosystem (#427).
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct Completions {
84    /// The completion items for this call.
85    pub items: Vec<CompletionItem>,
86    /// Whether `items` is a possibly-truncated view of a larger candidate set.
87    pub is_incomplete: bool,
88}
89
90impl From<Vec<CompletionItem>> for Completions {
91    /// Wraps an always-exhaustive result set, i.e. `is_incomplete: false`.
92    fn from(items: Vec<CompletionItem>) -> Self {
93        Self {
94            items,
95            is_incomplete: false,
96        }
97    }
98}
99
100/// Context for completion request based on cursor position.
101///
102/// This enum represents what type of completion is appropriate at the
103/// current cursor location within a manifest file.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum CompletionContext {
106    /// Cursor is within or after a package name.
107    ///
108    /// Example: `serd|` or `tokio|` where | represents cursor position.
109    PackageName {
110        /// Partial package name typed so far (may be empty).
111        prefix: String,
112        /// Range of the full package-name token, to be replaced by the completion's
113        /// `textEdit` (not just the already-typed prefix up to the cursor).
114        range: Range,
115    },
116
117    /// Cursor is within a version string.
118    ///
119    /// Example: `"1.0|"` or `"^2.|"` where | represents cursor position.
120    Version {
121        /// Package name this version belongs to.
122        package_name: PackageName,
123        /// Partial version typed so far (may include operators like ^, ~).
124        prefix: String,
125    },
126
127    /// Cursor is within a feature array.
128    ///
129    /// Example: `features = ["deri|"]` where | represents cursor position.
130    Feature {
131        /// Package name whose features are being completed.
132        package_name: PackageName,
133        /// Partial feature name typed so far (may be empty).
134        prefix: String,
135    },
136
137    /// Cursor is not in a valid completion position.
138    None,
139}
140
141/// Detects the completion context based on cursor position.
142///
143/// This function analyzes the cursor position relative to parsed dependencies
144/// to determine what type of completion should be offered.
145///
146/// # Arguments
147///
148/// * `parse_result` - Parsed manifest with dependency information
149/// * `position` - Cursor position in the document (LSP Position, 0-based line, 0-based character)
150/// * `content` - Full document content for prefix extraction
151///
152/// # Returns
153///
154/// A `CompletionContext` indicating what type of completion is appropriate,
155/// or `CompletionContext::None` if the cursor is not in a valid position.
156///
157/// # Examples
158///
159/// ```no_run
160/// use deps_core::completion::detect_completion_context;
161/// use tower_lsp_server::ls_types::Position;
162///
163/// # async fn example(parse_result: &dyn deps_core::ParseResult, content: &str) {
164/// // Cursor at position after "ser" in "serde"
165/// let position = Position { line: 5, character: 3 };
166/// let context = detect_completion_context(parse_result, position, content);
167/// # }
168/// ```
169pub fn detect_completion_context(
170    parse_result: &dyn ParseResult,
171    position: Position,
172    content: &str,
173) -> CompletionContext {
174    let dependencies = parse_result.dependencies();
175
176    for dep in dependencies {
177        // Check if position is within the dependency name range
178        let name_range = dep.name_range();
179        // `position_in_range` tolerates a request position one column past
180        // `name_range.end` (a convenience for firing completion right after the
181        // last typed character), but the manifest text immediately following the
182        // name is often structurally significant (a closing quote, the space
183        // before `=`, ...). Widening the returned range to reach that far would
184        // consume it once the client applies the edit. So this branch requires
185        // *strict* containment (`position.character <= name_range.end.character`
186        // on the end line) rather than reusing that tolerance — the boundary
187        // case (cursor exactly at `name_range.end`) is already covered without
188        // it, and a position one further past falls through to the checks below
189        // instead of matching here.
190        if position_in_range(position, name_range)
191            && (name_range.end.line != position.line
192                || position.character <= name_range.end.character)
193        {
194            let prefix = extract_prefix(content, position, name_range);
195            return CompletionContext::PackageName {
196                prefix,
197                range: name_range,
198            };
199        }
200
201        // Check if position is within the version range
202        if let Some(version_range) = dep.version_range()
203            && position_in_range(position, version_range)
204        {
205            let prefix = extract_prefix(content, position, version_range);
206            return CompletionContext::Version {
207                package_name: dep.name().clone(),
208                prefix,
209            };
210        }
211
212        // Check if position is within the features array range
213        if let Some(features_range) = dep.features_range()
214            && position_in_range(position, features_range)
215        {
216            let prefix = extract_feature_prefix(content, position);
217            return CompletionContext::Feature {
218                package_name: dep.name().clone(),
219                prefix,
220            };
221        }
222    }
223
224    CompletionContext::None
225}
226
227/// Checks if a position is within or at the end of a range.
228///
229/// LSP ranges are inclusive of start, exclusive of end.
230/// We also consider the position to be "in range" if it's immediately
231/// after the range end (for completion after typing).
232const fn position_in_range(position: Position, range: Range) -> bool {
233    // Before range start
234    if position.line < range.start.line {
235        return false;
236    }
237
238    if position.line == range.start.line && position.character < range.start.character {
239        return false;
240    }
241
242    // After range end (allow one position past for completion)
243    if position.line > range.end.line {
244        return false;
245    }
246
247    if position.line == range.end.line && position.character > range.end.character + 1 {
248        return false;
249    }
250
251    true
252}
253
254/// Converts UTF-16 offset to byte offset in a string.
255///
256/// LSP uses UTF-16 code units for character positions (for compatibility with
257/// JavaScript and other languages). This function converts from UTF-16 offset
258/// to byte offset for Rust string indexing.
259///
260/// # Arguments
261///
262/// * `s` - The string to index into
263/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
264///
265/// # Returns
266///
267/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
268///
269/// # Examples
270///
271/// ```
272/// # use deps_core::completion::utf16_to_byte_offset;
273/// // ASCII: UTF-16 offset equals byte offset
274/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
275///
276/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
277/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
278/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
279/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
280///
281/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
282/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
283/// ```
284pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
285    let mut utf16_count = 0u32;
286    for (byte_idx, ch) in s.char_indices() {
287        if utf16_count >= utf16_offset {
288            return Some(byte_idx);
289        }
290        utf16_count += ch.len_utf16() as u32;
291    }
292    if utf16_count == utf16_offset {
293        return Some(s.len());
294    }
295    None
296}
297
298/// Converts a byte offset within `s` to a UTF-16 code unit offset (LSP `Position.character`).
299///
300/// `byte_offset` must fall on a UTF-8 char boundary of `s` (e.g. one produced by
301/// `str::find`/`rfind`/slicing, never an arbitrary user-controlled value).
302///
303/// # Panics
304///
305/// Panics if `byte_offset` is out of bounds or does not fall on a UTF-8 char boundary of `s`.
306///
307/// # Examples
308///
309/// ```
310/// # use deps_core::completion::byte_to_utf16_offset;
311/// // ASCII: byte offset equals UTF-16 offset
312/// assert_eq!(byte_to_utf16_offset("hello", 2), 2);
313///
314/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
315/// assert_eq!(byte_to_utf16_offset("日本語", 0), 0);
316/// assert_eq!(byte_to_utf16_offset("日本語", 3), 1);
317/// assert_eq!(byte_to_utf16_offset("日本語", 6), 2);
318///
319/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
320/// assert_eq!(byte_to_utf16_offset("😀test", 4), 2);
321/// ```
322pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
323    s[..byte_offset].encode_utf16().count() as u32
324}
325
326/// Extracts the prefix text from content at a position within a range.
327///
328/// This function finds the text from the start of the range up to the
329/// cursor position, excluding any quote characters.
330///
331/// # Arguments
332///
333/// * `content` - Full document content
334/// * `position` - Cursor position (LSP Position, 0-based line, UTF-16 character offset)
335/// * `range` - Range containing the token (name, version, etc.)
336///
337/// # Returns
338///
339/// The prefix string typed so far, with quotes and extra whitespace removed.
340///
341/// # Examples
342///
343/// ```no_run
344/// use deps_core::completion::extract_prefix;
345/// use tower_lsp_server::ls_types::{Position, Range};
346///
347/// let content = r#"serde = "1.0""#;
348/// let position = Position { line: 0, character: 11 }; // After "1."
349/// let range = Range {
350///     start: Position { line: 0, character: 9 },
351///     end: Position { line: 0, character: 13 },
352/// };
353///
354/// let prefix = extract_prefix(content, position, range);
355/// assert_eq!(prefix, "1.");
356/// ```
357pub fn extract_prefix(content: &str, position: Position, range: Range) -> String {
358    // Get the line at the position - use nth() instead of collecting all lines
359    let line = match content.lines().nth(position.line as usize) {
360        Some(l) => l,
361        None => return String::new(),
362    };
363
364    // Convert UTF-16 positions to byte offsets
365    let start_byte = if position.line == range.start.line {
366        match utf16_to_byte_offset(line, range.start.character) {
367            Some(offset) => offset,
368            None => return String::new(),
369        }
370    } else {
371        0
372    };
373
374    let cursor_byte = match utf16_to_byte_offset(line, position.character) {
375        Some(offset) => offset,
376        None => return String::new(),
377    };
378
379    // Safety: ensure byte offsets are within bounds
380    if start_byte > line.len() || cursor_byte > line.len() || start_byte > cursor_byte {
381        return String::new();
382    }
383
384    // Extract substring
385    let prefix = &line[start_byte..cursor_byte];
386
387    // Remove quotes and trim whitespace
388    prefix
389        .trim()
390        .trim_matches('"')
391        .trim_matches('\'')
392        .trim()
393        .to_string()
394}
395
396/// Extracts the partial feature name typed at the cursor position.
397///
398/// Scans backwards from the cursor on the current line to find the start of
399/// the feature string being typed. Handles both inline and multi-line arrays.
400///
401/// Returns an empty string when the cursor is not inside a quoted string
402/// (e.g. right after `[` or between `, ` and the next `"`).
403///
404/// # Examples
405///
406/// ```no_run
407/// # use deps_core::completion::extract_feature_prefix;
408/// # use tower_lsp_server::ls_types::Position;
409/// // Cursor inside: features = ["derive", "std", "ser|"]
410/// let content = r#"serde = { version = "1", features = ["derive", "std", "ser"] }"#;
411/// // cursor_char = index after "ser" inside the last quoted element
412/// let ser_start = content.find(r#""ser""#).unwrap() + 1; // skip opening quote
413/// let pos = Position { line: 0, character: (ser_start + "ser".len()) as u32 };
414/// let prefix = extract_feature_prefix(content, pos);
415/// assert_eq!(prefix, "ser");
416/// ```
417pub fn extract_feature_prefix(content: &str, position: Position) -> String {
418    let line = match content.lines().nth(position.line as usize) {
419        Some(l) => l,
420        None => return String::new(),
421    };
422
423    let cursor_byte = match utf16_to_byte_offset(line, position.character) {
424        Some(offset) => offset.min(line.len()),
425        None => return String::new(),
426    };
427
428    let before_cursor = &line[..cursor_byte];
429
430    // Use the text after the last '[' on this line as the relevant segment
431    // (handles inline arrays; for multi-line arrays there is no '[' and we
432    // use the whole line up to the cursor).
433    let segment_start = before_cursor.rfind('[').map_or(0, |i| i + 1);
434    let segment = &before_cursor[segment_start..];
435
436    // Count '"' characters to determine whether the cursor is inside a string.
437    // An odd count means the cursor is inside an open string literal.
438    let quote_count = segment.chars().filter(|&c| c == '"').count();
439    if quote_count % 2 == 0 {
440        return String::new();
441    }
442
443    // Find the last opening quote and return the text after it.
444    match segment.rfind('"') {
445        Some(pos) => segment[pos + 1..].to_string(),
446        None => String::new(),
447    }
448}
449
450/// Builds a completion item for a package name.
451///
452/// Creates a properly formatted LSP CompletionItem with documentation,
453/// version information, and links to repository/docs.
454///
455/// # Arguments
456///
457/// * `metadata` - Package metadata from registry search
458/// * `insert_range` - LSP range where the completion should be inserted
459///
460/// # Returns
461///
462/// `Some(CompletionItem)` ready to send to the LSP client, or `None` when
463/// `metadata.name()` fails [`crate::is_safe_package_name`] — a malicious/compromised
464/// registry search result must not reach the manifest as an unsanitized `label`,
465/// `insert_text`, `text_edit`, `sort_text`, or `filter_text`, so the item is dropped
466/// rather than built with unsafe text.
467///
468/// # Examples
469///
470/// ```no_run
471/// use deps_core::completion::build_package_completion;
472/// use tower_lsp_server::ls_types::Range;
473///
474/// # async fn example(metadata: &dyn deps_core::Metadata) {
475/// let range = Range::default(); // Use actual range from context
476/// let item = build_package_completion(metadata, range).unwrap();
477/// assert_eq!(item.label, metadata.name().as_str());
478/// # }
479/// ```
480pub fn build_package_completion(
481    metadata: &dyn Metadata,
482    insert_range: Range,
483) -> Option<CompletionItem> {
484    let name = metadata.name();
485    if !crate::is_safe_package_name(name.as_str()) {
486        warn_rejected_value(
487            "is_safe_package_name",
488            "primary completion path package name",
489            name.as_str(),
490        );
491        return None;
492    }
493    let latest = metadata.latest_version().as_str();
494
495    // Build markdown documentation
496    let header = if latest.is_empty() {
497        format!("**{}**", escape_markdown(name.as_str()))
498    } else {
499        format!(
500            "**{}** v{}",
501            escape_markdown(name.as_str()),
502            escape_markdown(latest)
503        )
504    };
505    let mut doc_parts = vec![header];
506
507    if let Some(desc) = metadata.description() {
508        doc_parts.push(String::new()); // Empty line
509        // Truncate the raw description first, then escape — escaping first could
510        // cut a `\`-escape sequence in half at the byte boundary.
511        let truncated = if desc.len() > 200 {
512            let end = desc.floor_char_boundary(200);
513            format!("{}...", escape_markdown(&desc[..end]))
514        } else {
515            escape_markdown(desc)
516        };
517        doc_parts.push(truncated);
518    }
519
520    // Add links section if we have any links
521    let mut links = Vec::new();
522    if let Some(repo) = metadata.repository() {
523        links.push(format!("[Repository]({})", escape_markdown(repo)));
524    }
525    if let Some(docs) = metadata.documentation() {
526        links.push(format!("[Documentation]({})", escape_markdown(docs)));
527    }
528
529    if !links.is_empty() {
530        doc_parts.push(String::new()); // Empty line
531        doc_parts.push(links.join(" | "));
532    }
533
534    Some(CompletionItem {
535        label: name.to_string(),
536        kind: Some(CompletionItemKind::MODULE),
537        detail: if latest.is_empty() {
538            None
539        } else {
540            Some(format!("v{}", latest))
541        },
542        documentation: Some(Documentation::MarkupContent(MarkupContent {
543            kind: MarkupKind::Markdown,
544            value: doc_parts.join("\n"),
545        })),
546        insert_text: Some(name.to_string()),
547        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
548            range: insert_range,
549            new_text: name.to_string(),
550        })),
551        sort_text: Some(name.to_string()),
552        filter_text: Some(name.to_string()),
553        ..Default::default()
554    })
555}
556
557/// Builds a completion item for a version string.
558///
559/// Creates a properly formatted LSP CompletionItem with version metadata
560/// in a simplified format matching Code Actions (Cmd+.) style.
561///
562/// # Arguments
563///
564/// * `display_item` - Version display metadata with label, description, and flags
565/// * `insert_range` - Optional LSP range where the completion should replace text.
566///   If `None`, the completion will insert at cursor position without replacing.
567/// * `now` - Current instant, injected explicitly rather than read internally, so every
568///   item in the same completion response has its age computed against one consistent
569///   instant instead of drifting mid-request.
570///
571/// # Returns
572///
573/// A complete `CompletionItem` with simple index-based sorting and preselect.
574///
575/// # Format
576///
577/// - Label: `"version"` or `"version (latest)"` for the latest version
578/// - Detail: `"Update package_name to version"`
579/// - Label details: a greyed-out relative age (e.g. `"2 hours ago"`) when
580///   `display_item.published_at` is known and `freshness_enabled` is `true`; omitted
581///   entirely otherwise
582/// - Preselect: `true` for latest version, `false` otherwise
583/// - Sort: Index-based (00000, 00001, etc.)
584///
585/// # Examples
586///
587/// ```no_run
588/// use deps_core::completion::{build_version_completion, VersionDisplayItem};
589/// use deps_core::PackageName;
590/// use tower_lsp_server::ls_types::Range;
591///
592/// # async fn example(version: &dyn deps_core::Version) {
593/// let now = deps_core::PublishTime::now();
594///
595/// // Without range - insert at cursor
596/// let display_item = VersionDisplayItem::new(version, &PackageName::new("serde"), 0, true);
597/// let item = build_version_completion(&display_item, None, now, true);
598/// assert_eq!(item.label, display_item.label);
599///
600/// // With range - replace existing text
601/// let range = Range::default();
602/// let item = build_version_completion(&display_item, Some(range), now, true);
603/// # }
604/// ```
605pub fn build_version_completion(
606    display_item: &VersionDisplayItem,
607    insert_range: Option<Range>,
608    now: PublishTime,
609    freshness_enabled: bool,
610) -> CompletionItem {
611    // Simple index-based sorting (00000, 00001, etc.)
612    let sort_text = format!("{:05}", display_item.index);
613
614    // Greyed-out label suffix; unlike `label`, it never participates in filter matching,
615    // so adding it cannot change which items match a typed prefix (FR-006).
616    let label_details = freshness_enabled
617        .then_some(display_item.published_at)
618        .flatten()
619        .map(|published_at| CompletionItemLabelDetails {
620            detail: Some(format!(
621                "  {}",
622                format_relative_age(published_at.age_secs_from(now))
623            )),
624            description: None,
625        });
626
627    CompletionItem {
628        label: display_item.label.clone(),
629        kind: Some(CompletionItemKind::VALUE),
630        detail: Some(display_item.description.clone()),
631        documentation: None,
632        insert_text: Some(display_item.version.to_string()),
633        text_edit: insert_range.map(|range| {
634            CompletionTextEdit::Edit(TextEdit {
635                range,
636                new_text: display_item.version.to_string(),
637            })
638        }),
639        sort_text: Some(sort_text),
640        preselect: Some(display_item.is_latest),
641        label_details,
642        ..Default::default()
643    }
644}
645
646/// Display metadata for a single version in LSP responses.
647///
648/// Captures common formatting logic shared between completion items and code actions.
649#[derive(Debug, Clone)]
650pub struct VersionDisplayItem {
651    /// Raw version string (e.g., "1.0.0")
652    pub version: ConcreteVersion,
653    /// Display label with "(latest)" suffix for first item
654    pub label: String,
655    /// Action description (e.g., "Update serde to 1.0.0")
656    pub description: String,
657    /// Zero-based index for sorting
658    pub index: usize,
659    /// True if this is the latest non-yanked version
660    pub is_latest: bool,
661    /// When this version was published, if the registry exposes it.
662    ///
663    /// `None` for ecosystems without publish metadata (see
664    /// [`Version::published_at`]) — callers must degrade gracefully rather than
665    /// rendering a placeholder age.
666    pub published_at: Option<PublishTime>,
667}
668
669impl VersionDisplayItem {
670    /// Creates a display item from version metadata.
671    pub fn new(
672        version: &dyn Version,
673        package_name: &PackageName,
674        index: usize,
675        is_latest: bool,
676    ) -> Self {
677        let version_str = version.version_string();
678        let label = if is_latest {
679            format!("{} (latest)", version_str)
680        } else {
681            version_str.to_string()
682        };
683        let description = format!("Update {} to {}", package_name, version_str);
684
685        Self {
686            version: version_str.clone(),
687            label,
688            description,
689            index,
690            is_latest,
691            published_at: version.published_at(),
692        }
693    }
694}
695
696/// Filters and formats versions for LSP display.
697///
698/// Returns up to 5 non-yanked versions with display metadata. An
699/// advisory-deprecated version (e.g. an abandoned Composer package, a
700/// deprecated npm package) is not excluded here — only a hard yank is
701/// (#347): excluding advisory-only flags would leave a deprecated-but-
702/// installable package with zero version completions.
703pub fn prepare_version_display_items<V: AsRef<dyn Version>>(
704    versions: &[V],
705    package_name: &PackageName,
706) -> Vec<VersionDisplayItem> {
707    versions
708        .iter()
709        .map(|v| v.as_ref())
710        .filter(|v| !v.removal_status().blocks_resolution())
711        .take(MAX_COMPLETION_VERSIONS)
712        .enumerate()
713        .map(|(index, version)| VersionDisplayItem::new(version, package_name, index, index == 0))
714        .collect()
715}
716
717/// Builds a completion item for a feature flag.
718///
719/// Creates a properly formatted LSP CompletionItem for feature flag names.
720/// Only applicable to ecosystems that support features (e.g., Cargo).
721///
722/// # Arguments
723///
724/// * `feature_name` - Name of the feature flag
725/// * `package_name` - Name of the package this feature belongs to
726/// * `insert_range` - LSP range where the completion should be inserted, or `None` to omit
727///   `textEdit` and let the client insert at cursor position via `insertText`
728///
729/// # Returns
730///
731/// A complete `CompletionItem` for the feature flag.
732///
733/// # Examples
734///
735/// ```no_run
736/// use deps_core::completion::build_feature_completion;
737///
738/// let item = build_feature_completion("derive", &deps_core::PackageName::new("serde"), None);
739/// assert_eq!(item.label, "derive");
740/// ```
741pub fn build_feature_completion(
742    feature_name: &str,
743    package_name: &PackageName,
744    insert_range: Option<Range>,
745) -> CompletionItem {
746    CompletionItem {
747        label: feature_name.to_string(),
748        kind: Some(CompletionItemKind::PROPERTY),
749        detail: Some(format!("Feature of {}", package_name)),
750        documentation: None,
751        insert_text: Some(feature_name.to_string()),
752        text_edit: insert_range.map(|range| {
753            CompletionTextEdit::Edit(TextEdit {
754                range,
755                new_text: feature_name.to_string(),
756            })
757        }),
758        sort_text: Some(feature_name.to_string()),
759        ..Default::default()
760    }
761}
762
763/// Maximum number of version completions to show (matches Code Actions limit).
764const MAX_COMPLETION_VERSIONS: usize = 5;
765
766/// Checks whether `prefix` has an acceptable length (2 to 200 characters, inclusive) for
767/// triggering a package-name completion search.
768///
769/// Length is measured in Unicode scalar values (`chars().count()`), not bytes, so a
770/// multi-byte prefix (e.g. CJK) is bounded by how many characters the user typed rather
771/// than how many bytes those characters happen to occupy.
772///
773/// # Examples
774///
775/// ```
776/// # use deps_core::completion::is_valid_completion_prefix_len;
777/// assert!(!is_valid_completion_prefix_len("a")); // 1 char, too short
778/// assert!(is_valid_completion_prefix_len("ab")); // 2 chars, accepted
779///
780/// // "日" is 1 char / 3 bytes: rejected despite being >= 2 bytes.
781/// assert!(!is_valid_completion_prefix_len("日"));
782/// // "日本" is 2 chars / 6 bytes: accepted.
783/// assert!(is_valid_completion_prefix_len("日本"));
784/// ```
785#[must_use]
786pub fn is_valid_completion_prefix_len(prefix: &str) -> bool {
787    (2..=200).contains(&prefix.chars().count())
788}
789
790/// Generic package name completion using any `Registry` implementation.
791///
792/// Searches the registry for packages matching `prefix` and returns up to `limit`
793/// completion items, each with its `textEdit` set to replace `insert_range`. Returns
794/// empty vec if `prefix` is shorter than 2 characters or longer than 200 characters.
795/// A result whose name fails [`build_package_completion`]'s [`crate::is_safe_package_name`]
796/// gate is silently dropped rather than surfaced as an error, matching the fallback-search
797/// completion builder's convention (`create_package_completion_item` in `deps-lsp`).
798pub async fn complete_package_names_generic(
799    registry: &dyn crate::Registry,
800    prefix: &str,
801    limit: usize,
802    insert_range: Range,
803) -> Vec<CompletionItem> {
804    if !is_valid_completion_prefix_len(prefix) {
805        return vec![];
806    }
807
808    let results = match registry.search(prefix, limit).await {
809        Ok(r) => r,
810        Err(e) => {
811            tracing::warn!("Registry search failed for '{}': {}", prefix, e);
812            return vec![];
813        }
814    };
815
816    results
817        .into_iter()
818        .filter_map(|metadata| build_package_completion(metadata.as_ref(), insert_range))
819        .collect()
820}
821
822/// Generic version completion logic used by all ecosystems.
823///
824/// Filters versions by prefix (stripping ecosystem-specific operators),
825/// hides yanked/deprecated versions, returns up to 5 completion items.
826///
827/// # Arguments
828///
829/// * `registry` - Package registry to fetch versions from
830/// * `package_name` - Name of the package
831/// * `prefix` - Partial version string typed by user (may include operators)
832/// * `operator_chars` - Ecosystem-specific version operators to strip (e.g., `&['^', '~']`)
833///
834/// # Returns
835///
836/// Up to 5 completion items for non-yanked versions, filtered by prefix.
837/// If no versions match the prefix, returns up to 5 non-yanked versions.
838/// The first item (latest version) is marked with "(latest)" suffix and preselected.
839///
840/// # Examples
841///
842/// ```no_run
843/// use deps_core::completion::complete_versions_generic;
844/// use deps_core::PackageName;
845///
846/// # async fn example(registry: &dyn deps_core::Registry) {
847/// let freshness = deps_core::FreshnessSettings::default();
848///
849/// // Cargo: strip ^, ~, =, <, > operators
850/// let items = complete_versions_generic(
851///     registry,
852///     &PackageName::new("serde"),
853///     "^1.0",
854///     &['^', '~', '=', '<', '>'],
855///     freshness,
856/// ).await;
857///
858/// // Go: no operators to strip
859/// let items = complete_versions_generic(
860///     registry,
861///     &PackageName::new("github.com/gin-gonic/gin"),
862///     "v1.9",
863///     &[],
864///     freshness,
865/// ).await;
866/// # }
867/// ```
868pub async fn complete_versions_generic(
869    registry: &dyn crate::Registry,
870    package_name: &PackageName,
871    prefix: &str,
872    operator_chars: &[char],
873    freshness: FreshnessSettings,
874) -> Vec<CompletionItem> {
875    complete_versions_generic_from(
876        registry,
877        package_name,
878        &crate::parser::DependencySource::Registry,
879        prefix,
880        operator_chars,
881        freshness,
882    )
883    .await
884}
885
886/// Like [`complete_versions_generic`], but resolves through [`crate::Registry::get_versions_from`].
887///
888/// Routes on `source`, so a registry that routes a dependency's fetch across more than one
889/// underlying index (e.g. a per-instance-host `AlternateRegistry`, GitLab CI's
890/// `deps-gitlab-ci`) completes against the correct index instead of falling back to a
891/// source-unaware default.
892///
893/// [`complete_versions_generic`] delegates to this with
894/// [`crate::parser::DependencySource::Registry`] — behavior-preserving for every one of its
895/// 18 existing call sites, all of which pass a plain registry-resolved dependency: this is
896/// exactly the source [`crate::Registry::get_versions_from`]'s default implementation
897/// forwards to [`crate::Registry::get_versions_with`] for, so nothing observable changes for
898/// them.
899///
900/// # Examples
901///
902/// ```no_run
903/// use deps_core::completion::complete_versions_generic_from;
904/// use deps_core::parser::DependencySource;
905/// use deps_core::PackageName;
906///
907/// # async fn example(registry: &dyn deps_core::Registry) {
908/// let freshness = deps_core::FreshnessSettings::default();
909/// let items = complete_versions_generic_from(
910///     registry,
911///     &PackageName::new("gitlab.com/org/proj"),
912///     &DependencySource::Registry,
913///     "1.",
914///     &[],
915///     freshness,
916/// ).await;
917/// # }
918/// ```
919pub async fn complete_versions_generic_from(
920    registry: &dyn crate::Registry,
921    package_name: &PackageName,
922    source: &crate::parser::DependencySource,
923    prefix: &str,
924    operator_chars: &[char],
925    freshness: FreshnessSettings,
926) -> Vec<CompletionItem> {
927    let versions = match registry
928        .get_versions_from(package_name, source, freshness)
929        .await
930    {
931        Ok(v) => v,
932        Err(e) => {
933            tracing::warn!("Failed to fetch versions for '{}': {}", package_name, e);
934            return vec![];
935        }
936    };
937
938    let clean_prefix = prefix.trim_start_matches(operator_chars).trim();
939
940    // Filter versions by prefix first
941    let filtered_versions: Vec<_> = versions
942        .iter()
943        .filter(|v| v.version_string().as_str().starts_with(clean_prefix))
944        .collect();
945
946    // Use filtered or all versions, prepare_version_display_items will handle yanked filtering
947    let display_items = if filtered_versions.is_empty() {
948        prepare_version_display_items(&versions, package_name)
949    } else {
950        prepare_version_display_items(&filtered_versions, package_name)
951    };
952
953    // Don't provide text_edit range - let LSP client insert at cursor position
954    let now = PublishTime::now();
955    display_items
956        .iter()
957        // A registry-reported version is exactly as untrusted as the one fed into
958        // `format_version_replacing`/`format_version_for_text_edit` (see
959        // `is_safe_version_string`'s doc comment) — a completion item's
960        // `insert_text`/`text_edit` is a manifest-write sink too, and fires on
961        // ordinary typing rather than a quickfix click.
962        .filter(|item| {
963            let safe = is_safe_version_string(item.version.as_str());
964            if !safe {
965                warn_rejected_value(
966                    "is_safe_version_string",
967                    "version completion item",
968                    item.version.as_str(),
969                );
970            }
971            safe
972        })
973        .map(|item| build_version_completion(item, None, now, freshness.enabled))
974        .collect()
975}
976
977/// Version completion resolved by **cursor position**, not by package name (issue #593).
978///
979/// Finds the dependency in `parse_result` whose `version_range` contains `position` — the
980/// same containment check [`detect_completion_context`] used to decide this is a `Version`
981/// context in the first place — then completes through [`complete_versions_generic_from`]
982/// against that dependency's own [`crate::Dependency::source`]. Unlike a name join (the old
983/// per-ecosystem `resolve_completion_source` pattern this replaces), two dependencies sharing
984/// one [`PackageName`] but resolving to different sources never collide: the cursor position
985/// unambiguously identifies which occurrence the user is editing, so each completes against
986/// its own source independently instead of both offering nothing.
987///
988/// Deliberately reuses this module's own (lenient) `position_in_range` rather than
989/// [`crate::lsp_helpers::position_in_range`] (the two differ at the one-past-`range.end`
990/// boundary) — using a different predicate here than the one [`detect_completion_context`]
991/// used to decide `position` is even inside a `Version` context would let this lookup silently
992/// disagree with its own caller and return empty at a boundary the caller already committed to.
993///
994/// # Source-resolvability gate
995///
996/// [`crate::Registry::get_versions_from`]'s default/documented contract does not itself fail
997/// closed for a source an ecosystem's registry does not specifically route (see that method's
998/// docs) — several concrete `Registry` implementations forward an unrecognized source (e.g.
999/// `Git`, `Path`, an unresolved `CustomRegistry`) to their default public-registry client
1000/// rather than erroring. Gating on `formatter`'s
1001/// [`SourcePolicy::can_resolve_source`](crate::lsp_helpers::SourcePolicy::can_resolve_source)
1002/// first — the same check [`crate::lsp_helpers::generate_hover`] and diagnostics/code-actions
1003/// already use — is what keeps that permissive default from leaking a private/non-registry
1004/// dependency's name to a public registry on every keystroke.
1005///
1006/// # Examples
1007///
1008/// ```no_run
1009/// use deps_core::completion::complete_versions_at_position;
1010/// use deps_core::lsp_helpers::SourcePolicy;
1011/// use tower_lsp_server::ls_types::Position;
1012///
1013/// struct DefaultFormatter;
1014/// impl SourcePolicy for DefaultFormatter {}
1015///
1016/// # async fn example(registry: &dyn deps_core::Registry, parse_result: &dyn deps_core::ParseResult) {
1017/// let freshness = deps_core::FreshnessSettings::default();
1018/// let position = Position { line: 3, character: 12 };
1019/// let items = complete_versions_at_position(
1020///     registry,
1021///     &DefaultFormatter,
1022///     parse_result,
1023///     position,
1024///     "1.",
1025///     &['^', '~'],
1026///     freshness,
1027/// ).await;
1028/// # }
1029/// ```
1030pub async fn complete_versions_at_position(
1031    registry: &dyn crate::Registry,
1032    formatter: &dyn crate::lsp_helpers::SourcePolicy,
1033    parse_result: &dyn ParseResult,
1034    position: Position,
1035    prefix: &str,
1036    operator_chars: &[char],
1037    freshness: FreshnessSettings,
1038) -> Vec<CompletionItem> {
1039    let Some(dep) = parse_result.dependencies().into_iter().find(|d| {
1040        d.version_range()
1041            .is_some_and(|r| position_in_range(position, r))
1042    }) else {
1043        return vec![];
1044    };
1045
1046    let source = dep.source();
1047    if !formatter.can_resolve_source(&source) {
1048        return vec![];
1049    }
1050
1051    complete_versions_generic_from(
1052        registry,
1053        dep.name(),
1054        &source,
1055        prefix,
1056        operator_chars,
1057        freshness,
1058    )
1059    .await
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use std::any::Any;
1066    use std::assert_matches;
1067
1068    fn pkg(s: &str) -> PackageName {
1069        PackageName::new(s)
1070    }
1071
1072    // Mock implementations for testing
1073
1074    struct MockDependency {
1075        name: crate::PackageName,
1076        name_range: Range,
1077        version_range: Option<Range>,
1078        features_range: Option<Range>,
1079    }
1080
1081    impl crate::ecosystem::Dependency for MockDependency {
1082        fn name(&self) -> &crate::PackageName {
1083            &self.name
1084        }
1085
1086        fn name_range(&self) -> Range {
1087            self.name_range
1088        }
1089
1090        fn version_requirement(&self) -> Option<&crate::VersionReq> {
1091            static VERSION_REQ: std::sync::LazyLock<crate::VersionReq> =
1092                std::sync::LazyLock::new(|| crate::VersionReq::new("1.0"));
1093            Some(&VERSION_REQ)
1094        }
1095
1096        fn version_range(&self) -> Option<Range> {
1097            self.version_range
1098        }
1099
1100        fn features_range(&self) -> Option<Range> {
1101            self.features_range
1102        }
1103
1104        fn source(&self) -> crate::parser::DependencySource {
1105            crate::parser::DependencySource::Registry
1106        }
1107
1108        fn as_any(&self) -> &dyn Any {
1109            self
1110        }
1111    }
1112
1113    struct MockParseResult {
1114        dependencies: Vec<MockDependency>,
1115    }
1116
1117    impl ParseResult for MockParseResult {
1118        fn dependencies(&self) -> Vec<&dyn crate::ecosystem::Dependency> {
1119            self.dependencies
1120                .iter()
1121                .map(|d| d as &dyn crate::ecosystem::Dependency)
1122                .collect()
1123        }
1124
1125        fn workspace_root(&self) -> Option<&std::path::Path> {
1126            None
1127        }
1128
1129        fn uri(&self) -> &tower_lsp_server::ls_types::Uri {
1130            static URL: std::sync::LazyLock<tower_lsp_server::ls_types::Uri> =
1131                std::sync::LazyLock::new(|| "file:///test/Cargo.toml".parse().unwrap());
1132            &URL
1133        }
1134
1135        fn as_any(&self) -> &dyn Any {
1136            self
1137        }
1138    }
1139
1140    struct MockVersion {
1141        version: ConcreteVersion,
1142        yanked: bool,
1143        prerelease: bool,
1144    }
1145
1146    impl crate::registry::Version for MockVersion {
1147        fn version_string(&self) -> &ConcreteVersion {
1148            &self.version
1149        }
1150
1151        fn removal_status(&self) -> crate::RemovalStatus {
1152            crate::RemovalStatus::from_yanked(self.yanked)
1153        }
1154
1155        fn is_prerelease(&self) -> bool {
1156            self.prerelease
1157        }
1158
1159        fn as_any(&self) -> &dyn Any {
1160            self
1161        }
1162    }
1163
1164    /// A [`MockVersion`] variant that reports a `published_at`, used only by the
1165    /// freshness-specific tests below — kept separate so the many pre-existing
1166    /// `MockVersion` literals do not need a new field added to every call site.
1167    struct MockVersionWithAge {
1168        version: ConcreteVersion,
1169        published_at: Option<PublishTime>,
1170    }
1171
1172    impl crate::registry::Version for MockVersionWithAge {
1173        fn version_string(&self) -> &ConcreteVersion {
1174            &self.version
1175        }
1176
1177        fn published_at(&self) -> Option<PublishTime> {
1178            self.published_at
1179        }
1180
1181        fn as_any(&self) -> &dyn Any {
1182            self
1183        }
1184    }
1185
1186    #[derive(Clone)]
1187    struct MockMetadata {
1188        name: crate::PackageName,
1189        description: Option<String>,
1190        repository: Option<String>,
1191        documentation: Option<String>,
1192        latest_version: ConcreteVersion,
1193    }
1194
1195    impl crate::registry::Metadata for MockMetadata {
1196        fn name(&self) -> &crate::PackageName {
1197            &self.name
1198        }
1199
1200        fn description(&self) -> Option<&str> {
1201            self.description.as_deref()
1202        }
1203
1204        fn repository(&self) -> Option<&str> {
1205            self.repository.as_deref()
1206        }
1207
1208        fn documentation(&self) -> Option<&str> {
1209            self.documentation.as_deref()
1210        }
1211
1212        fn latest_version(&self) -> &ConcreteVersion {
1213            &self.latest_version
1214        }
1215
1216        fn as_any(&self) -> &dyn Any {
1217            self
1218        }
1219    }
1220
1221    struct MockRegistry {
1222        versions: Vec<MockVersion>,
1223    }
1224
1225    impl crate::Registry for MockRegistry {
1226        fn get_versions<'a>(
1227            &'a self,
1228            _package_name: &'a crate::PackageName,
1229        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
1230        {
1231            let versions: Vec<Box<dyn crate::Version>> = self
1232                .versions
1233                .iter()
1234                .map(|v| {
1235                    Box::new(MockVersion {
1236                        version: v.version.clone(),
1237                        yanked: v.yanked,
1238                        prerelease: v.prerelease,
1239                    }) as Box<dyn crate::Version>
1240                })
1241                .collect();
1242            Box::pin(async move { Ok(versions) })
1243        }
1244
1245        fn get_latest_matching<'a>(
1246            &'a self,
1247            _name: &'a crate::PackageName,
1248            _req: &'a crate::VersionReq,
1249        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Option<Box<dyn crate::Version>>>>
1250        {
1251            Box::pin(async move { Ok(None) })
1252        }
1253
1254        fn search<'a>(
1255            &'a self,
1256            _query: &'a str,
1257            _limit: usize,
1258        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
1259        {
1260            Box::pin(async move { Ok(vec![]) })
1261        }
1262
1263        fn as_any(&self) -> &dyn Any {
1264            self
1265        }
1266    }
1267
1268    /// Registry stub whose `search` returns preconfigured metadata, used to verify
1269    /// [`complete_package_names_generic`] threads its `insert_range` into every
1270    /// returned item's `text_edit` instead of defaulting to a placeholder range.
1271    struct MockSearchRegistry {
1272        results: Vec<MockMetadata>,
1273    }
1274
1275    impl crate::Registry for MockSearchRegistry {
1276        fn get_versions<'a>(
1277            &'a self,
1278            _package_name: &'a crate::PackageName,
1279        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
1280        {
1281            Box::pin(async move { Ok(vec![]) })
1282        }
1283
1284        fn get_latest_matching<'a>(
1285            &'a self,
1286            _name: &'a crate::PackageName,
1287            _req: &'a crate::VersionReq,
1288        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Option<Box<dyn crate::Version>>>>
1289        {
1290            Box::pin(async move { Ok(None) })
1291        }
1292
1293        fn search<'a>(
1294            &'a self,
1295            _query: &'a str,
1296            _limit: usize,
1297        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
1298        {
1299            let results: Vec<Box<dyn crate::Metadata>> = self
1300                .results
1301                .iter()
1302                .cloned()
1303                .map(|m| Box::new(m) as Box<dyn crate::Metadata>)
1304                .collect();
1305            Box::pin(async move { Ok(results) })
1306        }
1307
1308        fn as_any(&self) -> &dyn Any {
1309            self
1310        }
1311    }
1312
1313    #[tokio::test]
1314    async fn test_complete_package_names_generic_uses_insert_range() {
1315        let registry = MockSearchRegistry {
1316            results: vec![MockMetadata {
1317                name: pkg("serde"),
1318                description: None,
1319                repository: None,
1320                documentation: None,
1321                latest_version: "1.0.0".into(),
1322            }],
1323        };
1324
1325        let insert_range = Range {
1326            start: Position {
1327                line: 3,
1328                character: 4,
1329            },
1330            end: Position {
1331                line: 3,
1332                character: 7,
1333            },
1334        };
1335
1336        let items = complete_package_names_generic(&registry, "ser", 5, insert_range).await;
1337
1338        assert_eq!(items.len(), 1);
1339        assert_ne!(insert_range, Range::default());
1340        match &items[0].text_edit {
1341            Some(CompletionTextEdit::Edit(edit)) => {
1342                assert_eq!(edit.range, insert_range);
1343                assert_eq!(edit.new_text, "serde");
1344            }
1345            other => panic!("Expected a textEdit::Edit, got {other:?}"),
1346        }
1347    }
1348
1349    #[tokio::test]
1350    async fn test_complete_package_names_generic_short_prefix_empty() {
1351        let registry = MockSearchRegistry {
1352            results: vec![MockMetadata {
1353                name: pkg("serde"),
1354                description: None,
1355                repository: None,
1356                documentation: None,
1357                latest_version: "1.0.0".into(),
1358            }],
1359        };
1360
1361        let items = complete_package_names_generic(&registry, "s", 5, Range::default()).await;
1362        assert!(items.is_empty());
1363    }
1364
1365    #[tokio::test]
1366    async fn test_complete_package_names_generic_drops_unsafe_names() {
1367        // A registry search can return a mix of safe and malicious/compromised
1368        // results (e.g. a Gradle Groovy breakout name); only the safe ones should
1369        // survive into the returned completion list.
1370        let registry = MockSearchRegistry {
1371            results: vec![
1372                MockMetadata {
1373                    name: pkg("serde"),
1374                    description: None,
1375                    repository: None,
1376                    documentation: None,
1377                    latest_version: "1.0.0".into(),
1378                },
1379                MockMetadata {
1380                    name: pkg("guava'); System.exit(1); //"),
1381                    description: None,
1382                    repository: None,
1383                    documentation: None,
1384                    latest_version: "1.0.0".into(),
1385                },
1386            ],
1387        };
1388
1389        let items = complete_package_names_generic(&registry, "gua", 5, Range::default()).await;
1390
1391        assert_eq!(items.len(), 1);
1392        assert_eq!(items[0].label, "serde");
1393    }
1394
1395    #[test]
1396    fn test_is_valid_completion_prefix_len_ascii_short_rejected() {
1397        assert!(!is_valid_completion_prefix_len("a"));
1398    }
1399
1400    #[test]
1401    fn test_is_valid_completion_prefix_len_one_char_cjk_rejected() {
1402        // "日" is 1 char but 3 bytes — a byte-length guard would wrongly accept it.
1403        assert!(!is_valid_completion_prefix_len("日"));
1404    }
1405
1406    #[test]
1407    fn test_is_valid_completion_prefix_len_two_char_cjk_accepted() {
1408        // "日本" is 2 chars but 6 bytes — must be accepted under char-count semantics.
1409        assert!(is_valid_completion_prefix_len("日本"));
1410    }
1411
1412    #[tokio::test]
1413    async fn test_complete_package_names_generic_one_char_cjk_prefix_empty() {
1414        // "日" is 1 char but 3 bytes — a byte-length guard would wrongly accept it.
1415        let registry = MockSearchRegistry {
1416            results: vec![MockMetadata {
1417                name: pkg("serde"),
1418                description: None,
1419                repository: None,
1420                documentation: None,
1421                latest_version: "1.0.0".into(),
1422            }],
1423        };
1424
1425        let items = complete_package_names_generic(&registry, "日", 5, Range::default()).await;
1426        assert!(items.is_empty());
1427    }
1428
1429    #[tokio::test]
1430    async fn test_complete_package_names_generic_two_char_cjk_prefix_accepted() {
1431        // "日本" is 2 chars / 6 bytes — must pass the guard and reach the registry search.
1432        let registry = MockSearchRegistry {
1433            results: vec![MockMetadata {
1434                name: pkg("serde"),
1435                description: None,
1436                repository: None,
1437                documentation: None,
1438                latest_version: "1.0.0".into(),
1439            }],
1440        };
1441
1442        let items = complete_package_names_generic(&registry, "日本", 5, Range::default()).await;
1443        assert_eq!(items.len(), 1);
1444    }
1445
1446    // Context detection tests
1447
1448    #[test]
1449    fn test_detect_package_name_context_at_start() {
1450        let parse_result = MockParseResult {
1451            dependencies: vec![MockDependency {
1452                name: "serde".into(),
1453                name_range: Range {
1454                    start: Position {
1455                        line: 0,
1456                        character: 0,
1457                    },
1458                    end: Position {
1459                        line: 0,
1460                        character: 5,
1461                    },
1462                },
1463                version_range: None,
1464                features_range: None,
1465            }],
1466        };
1467
1468        let content = "serde";
1469        let position = Position {
1470            line: 0,
1471            character: 0,
1472        };
1473
1474        let context = detect_completion_context(&parse_result, position, content);
1475
1476        match context {
1477            CompletionContext::PackageName { prefix, range } => {
1478                assert_eq!(prefix, "");
1479                assert_eq!(
1480                    range,
1481                    Range {
1482                        start: Position {
1483                            line: 0,
1484                            character: 0
1485                        },
1486                        end: Position {
1487                            line: 0,
1488                            character: 5
1489                        },
1490                    }
1491                );
1492            }
1493            _ => panic!("Expected PackageName context, got {:?}", context),
1494        }
1495    }
1496
1497    #[test]
1498    fn test_detect_package_name_context_partial() {
1499        let parse_result = MockParseResult {
1500            dependencies: vec![MockDependency {
1501                name: "serde".into(),
1502                name_range: Range {
1503                    start: Position {
1504                        line: 0,
1505                        character: 0,
1506                    },
1507                    end: Position {
1508                        line: 0,
1509                        character: 5,
1510                    },
1511                },
1512                version_range: None,
1513                features_range: None,
1514            }],
1515        };
1516
1517        let content = "serde";
1518        let position = Position {
1519            line: 0,
1520            character: 3,
1521        };
1522
1523        let context = detect_completion_context(&parse_result, position, content);
1524
1525        match context {
1526            CompletionContext::PackageName { prefix, range } => {
1527                assert_eq!(prefix, "ser");
1528                assert_eq!(
1529                    range,
1530                    Range {
1531                        start: Position {
1532                            line: 0,
1533                            character: 0
1534                        },
1535                        end: Position {
1536                            line: 0,
1537                            character: 5
1538                        },
1539                    }
1540                );
1541            }
1542            _ => panic!("Expected PackageName context, got {:?}", context),
1543        }
1544    }
1545
1546    #[test]
1547    fn test_detect_package_name_context_one_past_end_does_not_widen_range() {
1548        // `position_in_range` tolerates a request one column past `name_range.end`,
1549        // but the text right after a name is often structurally significant (a
1550        // closing quote, the space before `=`, ...) — widening the range to reach
1551        // a position past the name would consume that character once a client
1552        // applies the edit, corrupting the manifest. So a one-past-end position
1553        // must NOT produce a PackageName context (there is nothing else on this
1554        // line for it to match either, so it falls through to `None`).
1555        let parse_result = MockParseResult {
1556            dependencies: vec![MockDependency {
1557                name: "serde".into(),
1558                name_range: Range {
1559                    start: Position {
1560                        line: 0,
1561                        character: 0,
1562                    },
1563                    end: Position {
1564                        line: 0,
1565                        character: 5,
1566                    },
1567                },
1568                version_range: None,
1569                features_range: None,
1570            }],
1571        };
1572
1573        let content = "serde";
1574        let position = Position {
1575            line: 0,
1576            character: 6,
1577        };
1578
1579        let context = detect_completion_context(&parse_result, position, content);
1580
1581        assert_eq!(context, CompletionContext::None);
1582    }
1583
1584    #[test]
1585    fn test_detect_package_name_context_exactly_at_end_matches_unwidened_range() {
1586        // The cursor sitting exactly at `name_range.end` (right after the last
1587        // typed character, no tolerance needed) must still fire PackageName with
1588        // the name's own unwidened range.
1589        let parse_result = MockParseResult {
1590            dependencies: vec![MockDependency {
1591                name: "serde".into(),
1592                name_range: Range {
1593                    start: Position {
1594                        line: 0,
1595                        character: 0,
1596                    },
1597                    end: Position {
1598                        line: 0,
1599                        character: 5,
1600                    },
1601                },
1602                version_range: None,
1603                features_range: None,
1604            }],
1605        };
1606
1607        let content = "serde";
1608        let position = Position {
1609            line: 0,
1610            character: 5,
1611        };
1612
1613        let context = detect_completion_context(&parse_result, position, content);
1614
1615        match context {
1616            CompletionContext::PackageName { prefix, range } => {
1617                assert_eq!(prefix, "serde");
1618                assert_eq!(
1619                    range,
1620                    Range {
1621                        start: Position {
1622                            line: 0,
1623                            character: 0
1624                        },
1625                        end: Position {
1626                            line: 0,
1627                            character: 5
1628                        },
1629                    }
1630                );
1631            }
1632            _ => panic!("Expected PackageName context, got {:?}", context),
1633        }
1634    }
1635
1636    #[test]
1637    fn test_detect_package_name_context_fires_for_ecosystem_supplied_partial_name() {
1638        // #310 (deps-deno): a scheme-prefixed but structurally incomplete specifier value
1639        // ("jsr:", "jsr:@", "jsr:@std", "jsr:@std/") has no complete name to parse, yet
1640        // completion must still fire while the user is mid-keystroke. This is deliberately
1641        // NOT solved by adding jsr:/npm:-specific logic here — `detect_completion_context`
1642        // stays ecosystem-agnostic. Instead, `deps-deno`'s parser (mirroring `deps-npm`,
1643        // which always builds a `Dependency` from a `dependencies` object key regardless of
1644        // scope completeness) supplies a `Dependency` whose `name`/`name_range` cover the
1645        // partial text directly; the existing range-containment check below requires no
1646        // change to handle it. This test documents and locks in that consistency.
1647        let parse_result = MockParseResult {
1648            dependencies: vec![MockDependency {
1649                name: "jsr:@std/".into(),
1650                name_range: Range {
1651                    start: Position {
1652                        line: 0,
1653                        character: 0,
1654                    },
1655                    end: Position {
1656                        line: 0,
1657                        character: 9,
1658                    },
1659                },
1660                version_range: None,
1661                features_range: None,
1662            }],
1663        };
1664
1665        let content = "jsr:@std/";
1666        let position = Position {
1667            line: 0,
1668            character: 9,
1669        };
1670
1671        let context = detect_completion_context(&parse_result, position, content);
1672
1673        match context {
1674            CompletionContext::PackageName { prefix, range } => {
1675                assert_eq!(prefix, "jsr:@std/");
1676                assert_eq!(range, parse_result.dependencies[0].name_range);
1677            }
1678            other => panic!("Expected PackageName context, got {other:?}"),
1679        }
1680    }
1681
1682    #[test]
1683    fn test_detect_version_context() {
1684        let parse_result = MockParseResult {
1685            dependencies: vec![MockDependency {
1686                name: "serde".into(),
1687                name_range: Range {
1688                    start: Position {
1689                        line: 0,
1690                        character: 0,
1691                    },
1692                    end: Position {
1693                        line: 0,
1694                        character: 5,
1695                    },
1696                },
1697                version_range: Some(Range {
1698                    start: Position {
1699                        line: 0,
1700                        character: 9,
1701                    },
1702                    end: Position {
1703                        line: 0,
1704                        character: 14,
1705                    },
1706                }),
1707                features_range: None,
1708            }],
1709        };
1710
1711        let content = r#"serde = "1.0.1""#;
1712        let position = Position {
1713            line: 0,
1714            character: 11,
1715        };
1716
1717        let context = detect_completion_context(&parse_result, position, content);
1718
1719        match context {
1720            CompletionContext::Version {
1721                package_name,
1722                prefix,
1723            } => {
1724                assert_eq!(package_name, "serde");
1725                assert_eq!(prefix, "1.");
1726            }
1727            _ => panic!("Expected Version context, got {:?}", context),
1728        }
1729    }
1730
1731    #[test]
1732    fn test_detect_no_context_before_dependencies() {
1733        let parse_result = MockParseResult {
1734            dependencies: vec![MockDependency {
1735                name: "serde".into(),
1736                name_range: Range {
1737                    start: Position {
1738                        line: 5,
1739                        character: 0,
1740                    },
1741                    end: Position {
1742                        line: 5,
1743                        character: 5,
1744                    },
1745                },
1746                version_range: None,
1747                features_range: None,
1748            }],
1749        };
1750
1751        let content = "[dependencies]\nserde";
1752        let position = Position {
1753            line: 0,
1754            character: 10,
1755        };
1756
1757        let context = detect_completion_context(&parse_result, position, content);
1758
1759        assert_eq!(context, CompletionContext::None);
1760    }
1761
1762    #[test]
1763    fn test_detect_no_context_invalid_position() {
1764        let parse_result = MockParseResult {
1765            dependencies: vec![],
1766        };
1767
1768        let content = "";
1769        let position = Position {
1770            line: 100,
1771            character: 100,
1772        };
1773
1774        let context = detect_completion_context(&parse_result, position, content);
1775
1776        assert_eq!(context, CompletionContext::None);
1777    }
1778
1779    // Prefix extraction tests
1780
1781    #[test]
1782    fn test_extract_prefix_at_start() {
1783        let content = "serde";
1784        let position = Position {
1785            line: 0,
1786            character: 0,
1787        };
1788        let range = Range {
1789            start: Position {
1790                line: 0,
1791                character: 0,
1792            },
1793            end: Position {
1794                line: 0,
1795                character: 5,
1796            },
1797        };
1798
1799        let prefix = extract_prefix(content, position, range);
1800        assert_eq!(prefix, "");
1801    }
1802
1803    #[test]
1804    fn test_extract_prefix_partial() {
1805        let content = "serde";
1806        let position = Position {
1807            line: 0,
1808            character: 3,
1809        };
1810        let range = Range {
1811            start: Position {
1812                line: 0,
1813                character: 0,
1814            },
1815            end: Position {
1816                line: 0,
1817                character: 5,
1818            },
1819        };
1820
1821        let prefix = extract_prefix(content, position, range);
1822        assert_eq!(prefix, "ser");
1823    }
1824
1825    #[test]
1826    fn test_extract_prefix_with_quotes() {
1827        let content = r#"serde = "1.0""#;
1828        let position = Position {
1829            line: 0,
1830            character: 11,
1831        };
1832        let range = Range {
1833            start: Position {
1834                line: 0,
1835                character: 9,
1836            },
1837            end: Position {
1838                line: 0,
1839                character: 13,
1840            },
1841        };
1842
1843        let prefix = extract_prefix(content, position, range);
1844        assert_eq!(prefix, "1.");
1845    }
1846
1847    #[test]
1848    fn test_extract_prefix_empty() {
1849        let content = r#"serde = """#;
1850        let position = Position {
1851            line: 0,
1852            character: 9,
1853        };
1854        let range = Range {
1855            start: Position {
1856                line: 0,
1857                character: 9,
1858            },
1859            end: Position {
1860                line: 0,
1861                character: 11,
1862            },
1863        };
1864
1865        let prefix = extract_prefix(content, position, range);
1866        assert_eq!(prefix, "");
1867    }
1868
1869    #[test]
1870    fn test_extract_prefix_version_with_operator() {
1871        let content = r#"serde = "^1.0""#;
1872        let position = Position {
1873            line: 0,
1874            character: 12,
1875        };
1876        let range = Range {
1877            start: Position {
1878                line: 0,
1879                character: 9,
1880            },
1881            end: Position {
1882                line: 0,
1883                character: 14,
1884            },
1885        };
1886
1887        let prefix = extract_prefix(content, position, range);
1888        assert_eq!(prefix, "^1.");
1889    }
1890
1891    // CompletionItem builder tests
1892
1893    #[test]
1894    fn test_build_package_completion_full() {
1895        let metadata = MockMetadata {
1896            name: "serde".into(),
1897            description: Some("Serialization framework".to_string()),
1898            repository: Some("https://github.com/serde-rs/serde".to_string()),
1899            documentation: Some("https://docs.rs/serde".to_string()),
1900            latest_version: "1.0.214".into(),
1901        };
1902
1903        let range = Range::default();
1904        let item = build_package_completion(&metadata, range).unwrap();
1905
1906        assert_eq!(item.label, "serde");
1907        assert_eq!(item.kind, Some(CompletionItemKind::MODULE));
1908        assert_eq!(item.detail, Some("v1.0.214".to_string()));
1909        assert_matches!(item.documentation, Some(Documentation::MarkupContent(_)));
1910
1911        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1912            assert!(content.value.contains("**serde** v1\\.0\\.214"));
1913            assert!(content.value.contains("Serialization framework"));
1914            assert!(content.value.contains("Repository"));
1915            assert!(content.value.contains("Documentation"));
1916        }
1917    }
1918
1919    #[test]
1920    fn test_build_package_completion_minimal() {
1921        let metadata = MockMetadata {
1922            name: "test-pkg".into(),
1923            description: None,
1924            repository: None,
1925            documentation: None,
1926            latest_version: "0.1.0".into(),
1927        };
1928
1929        let range = Range::default();
1930        let item = build_package_completion(&metadata, range).unwrap();
1931
1932        assert_eq!(item.label, "test-pkg");
1933        assert_eq!(item.detail, Some("v0.1.0".to_string()));
1934
1935        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1936            assert!(content.value.contains("**test\\-pkg** v0\\.1\\.0"));
1937            assert!(!content.value.contains("Repository"));
1938        }
1939    }
1940
1941    #[test]
1942    fn test_build_package_completion_empty_latest_version() {
1943        let metadata = MockMetadata {
1944            name: "swift-nio".into(),
1945            description: None,
1946            repository: None,
1947            documentation: None,
1948            latest_version: String::new().into(),
1949        };
1950
1951        let range = Range::default();
1952        let item = build_package_completion(&metadata, range).unwrap();
1953
1954        assert_eq!(item.detail, None);
1955
1956        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1957            assert!(content.value.contains("**swift\\-nio**"));
1958            assert!(!content.value.trim_end().ends_with('v'));
1959        }
1960    }
1961
1962    #[test]
1963    fn test_build_package_completion_escapes_description_markdown() {
1964        let metadata = MockMetadata {
1965            name: "test-pkg".into(),
1966            description: Some("Fast *bold* _italic_ [link](evil) `code`".to_string()),
1967            repository: None,
1968            documentation: None,
1969            latest_version: "1.0.0".into(),
1970        };
1971
1972        let range = Range::default();
1973        let item = build_package_completion(&metadata, range).unwrap();
1974
1975        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1976            assert!(!content.value.contains("*bold*"));
1977            assert!(!content.value.contains("_italic_"));
1978            assert!(!content.value.contains("[link](evil)"));
1979            assert!(content.value.contains(r"\*bold\*"));
1980            assert!(content.value.contains(r"\[link\]\(evil\)"));
1981        } else {
1982            panic!("Expected MarkupContent documentation");
1983        }
1984    }
1985
1986    #[test]
1987    fn test_build_package_completion_escapes_repository_link_breakout() {
1988        // A malicious repository URL that attempts to close the `[Repository](...)`
1989        // link early and splice in a new, attacker-controlled markdown link.
1990        let malicious_repo = "https://legit.example)[Click here](https://evil.example";
1991        let metadata = MockMetadata {
1992            name: "test-pkg".into(),
1993            description: None,
1994            repository: Some(malicious_repo.to_string()),
1995            documentation: None,
1996            latest_version: "1.0.0".into(),
1997        };
1998
1999        let range = Range::default();
2000        let item = build_package_completion(&metadata, range).unwrap();
2001
2002        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2003            assert!(!content.value.contains(")[Click here]("));
2004            assert!(content.value.contains(r"\)\[Click here\]\("));
2005        } else {
2006            panic!("Expected MarkupContent documentation");
2007        }
2008    }
2009
2010    #[test]
2011    fn test_build_package_completion_escapes_documentation_link_breakout() {
2012        let malicious_docs = "https://legit.example)[Click here](https://evil.example";
2013        let metadata = MockMetadata {
2014            name: "test-pkg".into(),
2015            description: None,
2016            repository: None,
2017            documentation: Some(malicious_docs.to_string()),
2018            latest_version: "1.0.0".into(),
2019        };
2020
2021        let range = Range::default();
2022        let item = build_package_completion(&metadata, range).unwrap();
2023
2024        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2025            assert!(!content.value.contains(")[Click here]("));
2026            assert!(content.value.contains(r"\)\[Click here\]\("));
2027        } else {
2028            panic!("Expected MarkupContent documentation");
2029        }
2030    }
2031
2032    #[test]
2033    fn test_build_package_completion_truncate_then_escape_no_dangling_backslash() {
2034        // A special character sitting right at the 200-char truncation boundary:
2035        // truncating BEFORE escaping (correct order) keeps the escape sequence
2036        // whole; escaping before truncating would risk cutting between the
2037        // backslash and the character it escapes, leaving a dangling `\`.
2038        let mut desc = "a".repeat(199);
2039        desc.push('*');
2040        desc.push_str(&"b".repeat(50));
2041
2042        let metadata = MockMetadata {
2043            name: "test-pkg".into(),
2044            description: Some(desc),
2045            repository: None,
2046            documentation: None,
2047            latest_version: "1.0.0".into(),
2048        };
2049
2050        let range = Range::default();
2051        let item = build_package_completion(&metadata, range).unwrap();
2052
2053        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2054            let lines: Vec<_> = content.value.lines().collect();
2055            let desc_line = lines[2];
2056            assert!(desc_line.ends_with(r"\*..."), "got: {desc_line}");
2057            assert!(
2058                !desc_line.ends_with(r"\..."),
2059                "dangling backslash: {desc_line}"
2060            );
2061        } else {
2062            panic!("Expected MarkupContent documentation");
2063        }
2064    }
2065
2066    #[test]
2067    fn test_build_package_completion_escapes_malicious_version_link_breakout() {
2068        // A crafted latest-version string attempting to close the leading bold span
2069        // and splice in a live, attacker-controlled markdown link — same injection
2070        // class as description/repository/documentation. `latest_version` isn't
2071        // gated by `is_safe_package_name` (that only guards `name`), so it must
2072        // still be escaped in the rendered documentation.
2073        let malicious_latest = "1.0.0)[click](https://evil.example";
2074        let metadata = MockMetadata {
2075            name: "test-pkg".into(),
2076            description: None,
2077            repository: None,
2078            documentation: None,
2079            latest_version: malicious_latest.into(),
2080        };
2081
2082        let range = Range::default();
2083        let item = build_package_completion(&metadata, range).unwrap();
2084
2085        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2086            assert!(!content.value.contains(")[click]("));
2087            assert!(
2088                content
2089                    .value
2090                    .contains(r"1\.0\.0\)\[click\]\(https\:\/\/evil\.example")
2091            );
2092        } else {
2093            panic!("Expected MarkupContent documentation");
2094        }
2095    }
2096
2097    #[test]
2098    fn test_build_package_completion_rejects_unsafe_name() {
2099        // A crafted package name attempting to close the leading bold span and
2100        // splice in a live, attacker-controlled markdown link — reachable simply
2101        // by typing a package-name prefix (no malicious manifest required). Such
2102        // a name fails `is_safe_package_name` (structural characters like `[`,
2103        // `]`, `(`, `)`, `*`, and space are not in its allowlist), so the whole
2104        // completion item must be dropped rather than built with unsafe text.
2105        let malicious_name = "a** [Official Download](https://evil.example) **b";
2106        let metadata = MockMetadata {
2107            name: malicious_name.into(),
2108            description: None,
2109            repository: None,
2110            documentation: None,
2111            latest_version: "1.0.0".into(),
2112        };
2113
2114        let range = Range::default();
2115        assert!(build_package_completion(&metadata, range).is_none());
2116    }
2117
2118    #[test]
2119    fn test_build_package_completion_benign_repository_url_round_trips() {
2120        // Backslash-escaping ASCII punctuation is visually inert on render (CommonMark
2121        // strips the backslash for the literal character), so a normal URL must still
2122        // render as the same, unmangled link once those escapes are stripped.
2123        let metadata = MockMetadata {
2124            name: "test-pkg".into(),
2125            description: None,
2126            repository: Some("https://github.com/owner/repo".to_string()),
2127            documentation: None,
2128            latest_version: "1.0.0".into(),
2129        };
2130
2131        let range = Range::default();
2132        let item = build_package_completion(&metadata, range).unwrap();
2133
2134        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2135            let unescaped: String = content.value.chars().filter(|&c| c != '\\').collect();
2136            assert!(unescaped.contains("[Repository](https://github.com/owner/repo)"));
2137        } else {
2138            panic!("Expected MarkupContent documentation");
2139        }
2140    }
2141
2142    #[test]
2143    fn test_build_package_completion_escapes_html_in_description() {
2144        let metadata = MockMetadata {
2145            name: "test-pkg".into(),
2146            description: Some("<img src=x onerror=alert(1)>".to_string()),
2147            repository: None,
2148            documentation: None,
2149            latest_version: "1.0.0".into(),
2150        };
2151
2152        let range = Range::default();
2153        let item = build_package_completion(&metadata, range).unwrap();
2154
2155        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2156            assert!(!content.value.contains("<img src=x onerror=alert(1)>"));
2157            assert!(
2158                content
2159                    .value
2160                    .contains(r"\<img src\=x onerror\=alert\(1\)\>")
2161            );
2162        } else {
2163            panic!("Expected MarkupContent documentation");
2164        }
2165    }
2166
2167    #[test]
2168    fn test_build_package_completion_empty_description() {
2169        let metadata = MockMetadata {
2170            name: "test-pkg".into(),
2171            description: Some(String::new()),
2172            repository: None,
2173            documentation: None,
2174            latest_version: "1.0.0".into(),
2175        };
2176
2177        let range = Range::default();
2178        let item = build_package_completion(&metadata, range).unwrap();
2179
2180        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2181            assert!(content.value.starts_with(r"**test\-pkg** v1\.0\.0"));
2182        } else {
2183            panic!("Expected MarkupContent documentation");
2184        }
2185    }
2186
2187    #[test]
2188    fn test_build_package_completion_truncate_snaps_multibyte_boundary() {
2189        // A 3-byte character straddling the 200-byte truncation boundary: truncation
2190        // must snap back to a valid char boundary rather than panicking mid-codepoint,
2191        // proving `floor_char_boundary` is exercised as a genuine non-identity op
2192        // (unlike an all-ASCII description, where every byte offset is already a
2193        // char boundary).
2194        let mut desc = "a".repeat(199);
2195        desc.push('日'); // 3 bytes, occupies byte offsets 199..202 — straddles byte 200
2196        desc.push('*');
2197        desc.push_str(&"b".repeat(50));
2198
2199        let metadata = MockMetadata {
2200            name: "test-pkg".into(),
2201            description: Some(desc),
2202            repository: None,
2203            documentation: None,
2204            latest_version: "1.0.0".into(),
2205        };
2206
2207        let range = Range::default();
2208        let item = build_package_completion(&metadata, range).unwrap();
2209
2210        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2211            let lines: Vec<_> = content.value.lines().collect();
2212            let desc_line = lines[2];
2213            assert!(!desc_line.contains('日'));
2214            assert!(desc_line.ends_with("..."));
2215        } else {
2216            panic!("Expected MarkupContent documentation");
2217        }
2218    }
2219
2220    #[test]
2221    fn test_build_version_completion_stable() {
2222        let version = MockVersion {
2223            version: "1.0.0".into(),
2224            yanked: false,
2225            prerelease: false,
2226        };
2227
2228        let now = PublishTime::now();
2229        let display_item = VersionDisplayItem::new(&version, &pkg("serde"), 0, false);
2230        let item = build_version_completion(&display_item, None, now, true);
2231
2232        assert_eq!(item.label, "1.0.0");
2233        assert_eq!(item.kind, Some(CompletionItemKind::VALUE));
2234        assert_eq!(item.detail, Some("Update serde to 1.0.0".to_string()));
2235        assert_eq!(item.documentation, None);
2236        assert_eq!(item.preselect, Some(false));
2237        assert_eq!(item.sort_text, Some("00000".to_string()));
2238        assert_eq!(item.text_edit, None); // No text_edit when range is None
2239    }
2240
2241    #[test]
2242    fn test_build_version_completion_latest() {
2243        let version = MockVersion {
2244            version: "1.0.0".into(),
2245            yanked: false,
2246            prerelease: false,
2247        };
2248
2249        let now = PublishTime::now();
2250        let display_item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
2251        let item = build_version_completion(&display_item, None, now, true);
2252
2253        assert_eq!(item.label, "1.0.0 (latest)");
2254        assert_eq!(item.kind, Some(CompletionItemKind::VALUE));
2255        assert_eq!(item.detail, Some("Update serde to 1.0.0".to_string()));
2256        assert_eq!(item.documentation, None);
2257        assert_eq!(item.preselect, Some(true));
2258        assert_eq!(item.sort_text, Some("00000".to_string()));
2259        assert_eq!(item.text_edit, None); // No text_edit when range is None
2260    }
2261
2262    #[test]
2263    fn test_build_version_completion_not_latest() {
2264        let version = MockVersion {
2265            version: "0.9.0".into(),
2266            yanked: false,
2267            prerelease: false,
2268        };
2269
2270        let now = PublishTime::now();
2271        let display_item = VersionDisplayItem::new(&version, &pkg("tokio"), 1, false);
2272        let item = build_version_completion(&display_item, None, now, true);
2273
2274        assert_eq!(item.label, "0.9.0");
2275        assert_eq!(item.detail, Some("Update tokio to 0.9.0".to_string()));
2276        assert_eq!(item.documentation, None);
2277        assert_eq!(item.preselect, Some(false));
2278        assert_eq!(item.sort_text, Some("00001".to_string()));
2279        assert_eq!(item.text_edit, None); // No text_edit when range is None
2280    }
2281
2282    #[test]
2283    fn test_build_version_completion_sort_order() {
2284        let v1 = MockVersion {
2285            version: "1.0.0".into(),
2286            yanked: false,
2287            prerelease: false,
2288        };
2289        let v2 = MockVersion {
2290            version: "0.9.0".into(),
2291            yanked: false,
2292            prerelease: false,
2293        };
2294        let v3 = MockVersion {
2295            version: "0.8.0".into(),
2296            yanked: false,
2297            prerelease: false,
2298        };
2299
2300        let display_item1 = VersionDisplayItem::new(&v1, &pkg("test"), 0, true);
2301        let display_item2 = VersionDisplayItem::new(&v2, &pkg("test"), 1, false);
2302        let display_item3 = VersionDisplayItem::new(&v3, &pkg("test"), 2, false);
2303        let now = PublishTime::now();
2304        let item1 = build_version_completion(&display_item1, None, now, true);
2305        let item2 = build_version_completion(&display_item2, None, now, true);
2306        let item3 = build_version_completion(&display_item3, None, now, true);
2307
2308        // Simple index-based sorting
2309        assert_eq!(item1.sort_text.as_ref().unwrap(), "00000");
2310        assert_eq!(item2.sort_text.as_ref().unwrap(), "00001");
2311        assert_eq!(item3.sort_text.as_ref().unwrap(), "00002");
2312
2313        // First item should be preselected
2314        assert_eq!(item1.preselect, Some(true));
2315        assert_eq!(item2.preselect, Some(false));
2316        assert_eq!(item3.preselect, Some(false));
2317    }
2318
2319    #[test]
2320    fn test_version_completion_semantic_ordering() {
2321        let versions = [
2322            MockVersion {
2323                version: "0.14.0".into(),
2324                yanked: false,
2325                prerelease: false,
2326            },
2327            MockVersion {
2328                version: "0.8.0".into(),
2329                yanked: false,
2330                prerelease: false,
2331            },
2332            MockVersion {
2333                version: "0.2.0".into(),
2334                yanked: false,
2335                prerelease: false,
2336            },
2337        ];
2338
2339        let now = PublishTime::now();
2340        let items: Vec<_> = versions
2341            .iter()
2342            .enumerate()
2343            .map(|(idx, v)| {
2344                let display_item = VersionDisplayItem::new(v, &pkg("test"), idx, idx == 0);
2345                build_version_completion(&display_item, None, now, true)
2346            })
2347            .collect();
2348
2349        assert_eq!(items[0].sort_text.as_ref().unwrap(), "00000");
2350        assert_eq!(items[1].sort_text.as_ref().unwrap(), "00001");
2351        assert_eq!(items[2].sort_text.as_ref().unwrap(), "00002");
2352
2353        let mut sorted_items = items;
2354        sorted_items.sort_by(|a, b| {
2355            a.sort_text
2356                .as_ref()
2357                .unwrap()
2358                .cmp(b.sort_text.as_ref().unwrap())
2359        });
2360
2361        assert_eq!(sorted_items[0].label, "0.14.0 (latest)");
2362        assert_eq!(sorted_items[1].label, "0.8.0");
2363        assert_eq!(sorted_items[2].label, "0.2.0");
2364    }
2365
2366    #[test]
2367    fn test_version_completion_index_ordering() {
2368        let versions = ["1.20.0", "1.9.0", "1.2.0", "0.99.0", "0.50.0"];
2369
2370        let now = PublishTime::now();
2371        let items: Vec<_> = versions
2372            .iter()
2373            .enumerate()
2374            .map(|(idx, ver)| {
2375                let v = MockVersion {
2376                    version: (*ver).into(),
2377                    yanked: false,
2378                    prerelease: false,
2379                };
2380                let display_item = VersionDisplayItem::new(&v, &pkg("test"), idx, idx == 0);
2381                build_version_completion(&display_item, None, now, true)
2382            })
2383            .collect();
2384
2385        assert_eq!(items[0].sort_text.as_ref().unwrap(), "00000");
2386        assert_eq!(items[1].sort_text.as_ref().unwrap(), "00001");
2387        assert_eq!(items[2].sort_text.as_ref().unwrap(), "00002");
2388        assert_eq!(items[3].sort_text.as_ref().unwrap(), "00003");
2389        assert_eq!(items[4].sort_text.as_ref().unwrap(), "00004");
2390
2391        let mut sorted_items = items;
2392        sorted_items.sort_by(|a, b| {
2393            a.sort_text
2394                .as_ref()
2395                .unwrap()
2396                .cmp(b.sort_text.as_ref().unwrap())
2397        });
2398
2399        assert_eq!(sorted_items[0].label, "1.20.0 (latest)");
2400        assert_eq!(sorted_items[1].label, "1.9.0");
2401        assert_eq!(sorted_items[2].label, "1.2.0");
2402        assert_eq!(sorted_items[3].label, "0.99.0");
2403        assert_eq!(sorted_items[4].label, "0.50.0");
2404    }
2405
2406    #[test]
2407    fn test_version_display_item_latest() {
2408        let version = MockVersion {
2409            version: "1.0.0".into(),
2410            yanked: false,
2411            prerelease: false,
2412        };
2413
2414        let item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
2415
2416        assert_eq!(item.version, "1.0.0");
2417        assert_eq!(item.label, "1.0.0 (latest)");
2418        assert_eq!(item.description, "Update serde to 1.0.0");
2419        assert_eq!(item.index, 0);
2420        assert!(item.is_latest);
2421    }
2422
2423    #[test]
2424    fn test_version_display_item_not_latest() {
2425        let version = MockVersion {
2426            version: "0.9.0".into(),
2427            yanked: false,
2428            prerelease: false,
2429        };
2430
2431        let item = VersionDisplayItem::new(&version, &pkg("tokio"), 1, false);
2432
2433        assert_eq!(item.version, "0.9.0");
2434        assert_eq!(item.label, "0.9.0");
2435        assert_eq!(item.description, "Update tokio to 0.9.0");
2436        assert_eq!(item.index, 1);
2437        assert!(!item.is_latest);
2438    }
2439
2440    #[test]
2441    fn test_prepare_version_display_items_filters_yanked() {
2442        let versions: Vec<std::sync::Arc<dyn crate::Version>> = vec![
2443            std::sync::Arc::new(MockVersion {
2444                version: "1.0.0".into(),
2445                yanked: false,
2446                prerelease: false,
2447            }),
2448            std::sync::Arc::new(MockVersion {
2449                version: "0.9.0".into(),
2450                yanked: true,
2451                prerelease: false,
2452            }),
2453            std::sync::Arc::new(MockVersion {
2454                version: "0.8.0".into(),
2455                yanked: false,
2456                prerelease: false,
2457            }),
2458        ];
2459
2460        let items = prepare_version_display_items(&versions, &pkg("test"));
2461
2462        assert_eq!(items.len(), 2);
2463        assert_eq!(items[0].version, "1.0.0");
2464        assert_eq!(items[0].label, "1.0.0 (latest)");
2465        assert!(items[0].is_latest);
2466        assert_eq!(items[1].version, "0.8.0");
2467        assert_eq!(items[1].label, "0.8.0");
2468        assert!(!items[1].is_latest);
2469    }
2470
2471    #[test]
2472    fn test_prepare_version_display_items_limits_to_5() {
2473        let versions: Vec<std::sync::Arc<dyn crate::Version>> = (0..10)
2474            .map(|i| {
2475                std::sync::Arc::new(MockVersion {
2476                    version: format!("1.0.{}", i).into(),
2477                    yanked: false,
2478                    prerelease: false,
2479                }) as std::sync::Arc<dyn crate::Version>
2480            })
2481            .collect();
2482
2483        let items = prepare_version_display_items(&versions, &pkg("test"));
2484
2485        assert_eq!(items.len(), 5);
2486        assert_eq!(items[0].version, "1.0.0");
2487        assert_eq!(items[0].label, "1.0.0 (latest)");
2488        assert_eq!(items[4].version, "1.0.4");
2489        assert_eq!(items[4].label, "1.0.4");
2490    }
2491
2492    #[test]
2493    fn test_prepare_version_display_items_empty() {
2494        let versions: Vec<std::sync::Arc<dyn crate::Version>> = vec![];
2495
2496        let items = prepare_version_display_items(&versions, &pkg("test"));
2497
2498        assert_eq!(items.len(), 0);
2499    }
2500
2501    #[test]
2502    fn test_prepare_version_display_items_all_yanked() {
2503        let versions: Vec<std::sync::Arc<dyn crate::Version>> = vec![
2504            std::sync::Arc::new(MockVersion {
2505                version: "1.0.0".into(),
2506                yanked: true,
2507                prerelease: false,
2508            }),
2509            std::sync::Arc::new(MockVersion {
2510                version: "0.9.0".into(),
2511                yanked: true,
2512                prerelease: false,
2513            }),
2514        ];
2515
2516        let items = prepare_version_display_items(&versions, &pkg("test"));
2517
2518        assert_eq!(items.len(), 0);
2519    }
2520
2521    #[test]
2522    fn test_build_feature_completion() {
2523        let item = build_feature_completion("derive", &pkg("serde"), None);
2524
2525        assert_eq!(item.label, "derive");
2526        assert_eq!(item.kind, Some(CompletionItemKind::PROPERTY));
2527        assert_eq!(item.detail, Some("Feature of serde".to_string()));
2528        assert!(item.documentation.is_none());
2529        assert!(item.text_edit.is_none());
2530        assert_eq!(item.sort_text, Some("derive".to_string()));
2531    }
2532
2533    #[test]
2534    fn test_build_feature_completion_with_range() {
2535        let range = Range::default();
2536        let item = build_feature_completion("derive", &pkg("serde"), Some(range));
2537
2538        assert_eq!(item.label, "derive");
2539        assert!(item.text_edit.is_some());
2540    }
2541
2542    #[test]
2543    fn test_position_in_range_within() {
2544        let range = Range {
2545            start: Position {
2546                line: 0,
2547                character: 5,
2548            },
2549            end: Position {
2550                line: 0,
2551                character: 10,
2552            },
2553        };
2554
2555        let position = Position {
2556            line: 0,
2557            character: 7,
2558        };
2559
2560        assert!(position_in_range(position, range));
2561    }
2562
2563    #[test]
2564    fn test_position_in_range_at_start() {
2565        let range = Range {
2566            start: Position {
2567                line: 0,
2568                character: 5,
2569            },
2570            end: Position {
2571                line: 0,
2572                character: 10,
2573            },
2574        };
2575
2576        let position = Position {
2577            line: 0,
2578            character: 5,
2579        };
2580
2581        assert!(position_in_range(position, range));
2582    }
2583
2584    #[test]
2585    fn test_position_in_range_at_end() {
2586        let range = Range {
2587            start: Position {
2588                line: 0,
2589                character: 5,
2590            },
2591            end: Position {
2592                line: 0,
2593                character: 10,
2594            },
2595        };
2596
2597        let position = Position {
2598            line: 0,
2599            character: 10,
2600        };
2601
2602        assert!(position_in_range(position, range));
2603    }
2604
2605    #[test]
2606    fn test_position_in_range_one_past_end() {
2607        let range = Range {
2608            start: Position {
2609                line: 0,
2610                character: 5,
2611            },
2612            end: Position {
2613                line: 0,
2614                character: 10,
2615            },
2616        };
2617
2618        // Allow one character past end for completion
2619        let position = Position {
2620            line: 0,
2621            character: 11,
2622        };
2623
2624        assert!(position_in_range(position, range));
2625    }
2626
2627    #[test]
2628    fn test_position_in_range_before() {
2629        let range = Range {
2630            start: Position {
2631                line: 0,
2632                character: 5,
2633            },
2634            end: Position {
2635                line: 0,
2636                character: 10,
2637            },
2638        };
2639
2640        let position = Position {
2641            line: 0,
2642            character: 4,
2643        };
2644
2645        assert!(!position_in_range(position, range));
2646    }
2647
2648    #[test]
2649    fn test_position_in_range_after() {
2650        let range = Range {
2651            start: Position {
2652                line: 0,
2653                character: 5,
2654            },
2655            end: Position {
2656                line: 0,
2657                character: 10,
2658            },
2659        };
2660
2661        let position = Position {
2662            line: 0,
2663            character: 12,
2664        };
2665
2666        assert!(!position_in_range(position, range));
2667    }
2668
2669    // UTF-16 to byte offset conversion tests
2670
2671    #[test]
2672    fn test_utf16_to_byte_offset_ascii() {
2673        let s = "hello";
2674        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
2675        assert_eq!(utf16_to_byte_offset(s, 2), Some(2));
2676        assert_eq!(utf16_to_byte_offset(s, 5), Some(5));
2677    }
2678
2679    #[test]
2680    fn test_utf16_to_byte_offset_multibyte() {
2681        // "日本語" - each character is 3 bytes, 1 UTF-16 code unit
2682        let s = "日本語";
2683        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
2684        assert_eq!(utf16_to_byte_offset(s, 1), Some(3));
2685        assert_eq!(utf16_to_byte_offset(s, 2), Some(6));
2686        assert_eq!(utf16_to_byte_offset(s, 3), Some(9));
2687    }
2688
2689    #[test]
2690    fn test_utf16_to_byte_offset_emoji() {
2691        // "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
2692        let s = "😀test";
2693        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
2694        assert_eq!(utf16_to_byte_offset(s, 2), Some(4)); // After emoji
2695        assert_eq!(utf16_to_byte_offset(s, 3), Some(5)); // After 't'
2696    }
2697
2698    #[test]
2699    fn test_utf16_to_byte_offset_mixed() {
2700        // Mix of ASCII, multi-byte, and emoji
2701        let s = "hello 世界 😀!";
2702        assert_eq!(utf16_to_byte_offset(s, 0), Some(0)); // 'h'
2703        assert_eq!(utf16_to_byte_offset(s, 6), Some(6)); // '世'
2704        assert_eq!(utf16_to_byte_offset(s, 7), Some(9)); // '界'
2705        assert_eq!(utf16_to_byte_offset(s, 9), Some(13)); // '😀' (2 UTF-16 units)
2706        assert_eq!(utf16_to_byte_offset(s, 11), Some(17)); // '!'
2707    }
2708
2709    #[test]
2710    fn test_utf16_to_byte_offset_out_of_bounds() {
2711        let s = "hello";
2712        assert_eq!(utf16_to_byte_offset(s, 100), None);
2713    }
2714
2715    #[test]
2716    fn test_utf16_to_byte_offset_empty() {
2717        let s = "";
2718        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
2719        assert_eq!(utf16_to_byte_offset(s, 1), None);
2720    }
2721
2722    // Byte to UTF-16 offset conversion tests
2723
2724    #[test]
2725    fn test_byte_to_utf16_offset_ascii() {
2726        let s = "hello";
2727        assert_eq!(byte_to_utf16_offset(s, 0), 0);
2728        assert_eq!(byte_to_utf16_offset(s, 2), 2);
2729        assert_eq!(byte_to_utf16_offset(s, 5), 5);
2730    }
2731
2732    #[test]
2733    fn test_byte_to_utf16_offset_multibyte() {
2734        // "日本語" - each character is 3 bytes, 1 UTF-16 code unit
2735        let s = "日本語";
2736        assert_eq!(byte_to_utf16_offset(s, 0), 0);
2737        assert_eq!(byte_to_utf16_offset(s, 3), 1);
2738        assert_eq!(byte_to_utf16_offset(s, 6), 2);
2739        assert_eq!(byte_to_utf16_offset(s, 9), 3);
2740    }
2741
2742    #[test]
2743    fn test_byte_to_utf16_offset_emoji() {
2744        // "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
2745        let s = "😀test";
2746        assert_eq!(byte_to_utf16_offset(s, 0), 0);
2747        assert_eq!(byte_to_utf16_offset(s, 4), 2); // After emoji
2748        assert_eq!(byte_to_utf16_offset(s, 5), 3); // After 't'
2749    }
2750
2751    #[test]
2752    fn test_byte_to_utf16_offset_mixed() {
2753        // Mix of ASCII, multi-byte, and emoji
2754        let s = "hello 世界 😀!";
2755        assert_eq!(byte_to_utf16_offset(s, 0), 0); // 'h'
2756        assert_eq!(byte_to_utf16_offset(s, 6), 6); // '世'
2757        assert_eq!(byte_to_utf16_offset(s, 9), 7); // '界'
2758        assert_eq!(byte_to_utf16_offset(s, 13), 9); // '😀' (2 UTF-16 units)
2759        assert_eq!(byte_to_utf16_offset(s, 17), 11); // '!'
2760    }
2761
2762    #[test]
2763    fn test_byte_to_utf16_offset_empty() {
2764        let s = "";
2765        assert_eq!(byte_to_utf16_offset(s, 0), 0);
2766    }
2767
2768    // Unicode truncation tests
2769
2770    #[test]
2771    fn test_build_package_completion_long_description_ascii() {
2772        let long_desc = "a".repeat(250);
2773        let metadata = MockMetadata {
2774            name: "test-pkg".into(),
2775            description: Some(long_desc),
2776            repository: None,
2777            documentation: None,
2778            latest_version: "1.0.0".into(),
2779        };
2780
2781        let range = Range::default();
2782        let item = build_package_completion(&metadata, range).unwrap();
2783
2784        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2785            // Should be truncated to 200 chars + "..."
2786            let lines: Vec<_> = content.value.lines().collect();
2787            assert!(lines[2].ends_with("..."));
2788            assert!(lines[2].len() <= 203); // 200 + "..."
2789        } else {
2790            panic!("Expected MarkupContent documentation");
2791        }
2792    }
2793
2794    #[test]
2795    fn test_build_package_completion_long_description_unicode() {
2796        // Create description with Unicode chars at the boundary
2797        // Each '日' is 3 bytes, so 67 chars = 201 bytes
2798        let mut long_desc = String::new();
2799        for _ in 0..67 {
2800            long_desc.push('日');
2801        }
2802
2803        let metadata = MockMetadata {
2804            name: "test-pkg".into(),
2805            description: Some(long_desc),
2806            repository: None,
2807            documentation: None,
2808            latest_version: "1.0.0".into(),
2809        };
2810
2811        let range = Range::default();
2812        let item = build_package_completion(&metadata, range).unwrap();
2813
2814        // Should not panic on truncation
2815        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2816            let lines: Vec<_> = content.value.lines().collect();
2817            assert!(lines[2].ends_with("..."));
2818            // Truncation should happen at a char boundary
2819            assert!(lines[2].is_char_boundary(lines[2].len()));
2820        } else {
2821            panic!("Expected MarkupContent documentation");
2822        }
2823    }
2824
2825    #[test]
2826    fn test_build_package_completion_long_description_emoji() {
2827        // Emoji "😀" is 4 bytes each
2828        // 51 emoji = 204 bytes
2829        let long_desc = "😀".repeat(51);
2830
2831        let metadata = MockMetadata {
2832            name: "test-pkg".into(),
2833            description: Some(long_desc),
2834            repository: None,
2835            documentation: None,
2836            latest_version: "1.0.0".into(),
2837        };
2838
2839        let range = Range::default();
2840        let item = build_package_completion(&metadata, range).unwrap();
2841
2842        // Should not panic on truncation
2843        if let Some(Documentation::MarkupContent(content)) = item.documentation {
2844            let lines: Vec<_> = content.value.lines().collect();
2845            assert!(lines[2].ends_with("..."));
2846            // Truncation should happen at a char boundary
2847            assert!(lines[2].is_char_boundary(lines[2].len()));
2848        } else {
2849            panic!("Expected MarkupContent documentation");
2850        }
2851    }
2852
2853    #[test]
2854    fn test_extract_prefix_unicode_package_name() {
2855        // Package name with Unicode characters
2856        let content = "日本語-crate = \"1.0\"";
2857        let position = Position {
2858            line: 0,
2859            character: 3, // UTF-16 offset after "日本語"
2860        };
2861        let range = Range {
2862            start: Position {
2863                line: 0,
2864                character: 0,
2865            },
2866            end: Position {
2867                line: 0,
2868                character: 10,
2869            },
2870        };
2871
2872        let prefix = extract_prefix(content, position, range);
2873        assert_eq!(prefix, "日本語");
2874    }
2875
2876    #[test]
2877    fn test_extract_prefix_emoji_in_content() {
2878        // Content with emoji (rare but should handle gracefully)
2879        let content = "emoji-😀-crate = \"1.0\"";
2880        let position = Position {
2881            line: 0,
2882            character: 8, // UTF-16 offset after "emoji-😀"
2883        };
2884        let range = Range {
2885            start: Position {
2886                line: 0,
2887                character: 0,
2888            },
2889            end: Position {
2890                line: 0,
2891                character: 14,
2892            },
2893        };
2894
2895        let prefix = extract_prefix(content, position, range);
2896        assert_eq!(prefix, "emoji-😀");
2897    }
2898
2899    // Generic version completion tests
2900
2901    #[tokio::test]
2902    async fn test_complete_versions_generic_operator_stripping() {
2903        let registry = MockRegistry {
2904            versions: vec![
2905                MockVersion {
2906                    version: "1.0.0".into(),
2907                    yanked: false,
2908                    prerelease: false,
2909                },
2910                MockVersion {
2911                    version: "1.0.1".into(),
2912                    yanked: false,
2913                    prerelease: false,
2914                },
2915                MockVersion {
2916                    version: "1.1.0".into(),
2917                    yanked: false,
2918                    prerelease: false,
2919                },
2920                MockVersion {
2921                    version: "2.0.0".into(),
2922                    yanked: false,
2923                    prerelease: false,
2924                },
2925            ],
2926        };
2927
2928        // Test with Cargo-style operators (^, ~, =, <, >)
2929        let items = complete_versions_generic(
2930            &registry,
2931            &pkg("test-pkg"),
2932            "^1.0",
2933            &['^', '~', '=', '<', '>'],
2934            FreshnessSettings::default(),
2935        )
2936        .await;
2937
2938        // Should return versions starting with "1.0" (after stripping ^)
2939        assert_eq!(items.len(), 2);
2940        assert_eq!(items[0].label, "1.0.0 (latest)");
2941        assert_eq!(items[1].label, "1.0.1");
2942
2943        // Test with tilde operator
2944        let items = complete_versions_generic(
2945            &registry,
2946            &pkg("test-pkg"),
2947            "~1.1",
2948            &['^', '~', '=', '<', '>'],
2949            FreshnessSettings::default(),
2950        )
2951        .await;
2952
2953        assert_eq!(items.len(), 1);
2954        assert_eq!(items[0].label, "1.1.0 (latest)");
2955
2956        // Test with equals operator
2957        let items = complete_versions_generic(
2958            &registry,
2959            &pkg("test-pkg"),
2960            "=2.0",
2961            &['^', '~', '=', '<', '>'],
2962            FreshnessSettings::default(),
2963        )
2964        .await;
2965
2966        assert_eq!(items.len(), 1);
2967        assert_eq!(items[0].label, "2.0.0 (latest)");
2968
2969        // Test with no operator (should work the same)
2970        let items = complete_versions_generic(
2971            &registry,
2972            &pkg("test-pkg"),
2973            "1.0",
2974            &['^', '~', '=', '<', '>'],
2975            FreshnessSettings::default(),
2976        )
2977        .await;
2978
2979        assert_eq!(items.len(), 2);
2980        assert_eq!(items[0].label, "1.0.0 (latest)");
2981        assert_eq!(items[1].label, "1.0.1");
2982    }
2983
2984    /// GitLab CI ecosystem plan §7a.1: `complete_versions_generic` must produce
2985    /// byte-identical items before and after becoming a thin delegation to
2986    /// [`complete_versions_generic_from`] with a plain [`crate::parser::DependencySource::Registry`].
2987    #[tokio::test]
2988    async fn test_complete_versions_generic_delegates_to_from_byte_identical() {
2989        let registry = MockRegistry {
2990            versions: vec![MockVersion {
2991                version: "1.0.0".into(),
2992                yanked: false,
2993                prerelease: false,
2994            }],
2995        };
2996
2997        let via_generic = complete_versions_generic(
2998            &registry,
2999            &pkg("test-pkg"),
3000            "1.0",
3001            &[],
3002            FreshnessSettings::default(),
3003        )
3004        .await;
3005        let via_from = complete_versions_generic_from(
3006            &registry,
3007            &pkg("test-pkg"),
3008            &crate::parser::DependencySource::Registry,
3009            "1.0",
3010            &[],
3011            FreshnessSettings::default(),
3012        )
3013        .await;
3014
3015        assert_eq!(via_generic.len(), via_from.len());
3016        assert_eq!(via_generic[0].label, via_from[0].label);
3017    }
3018
3019    /// A registry stub whose `get_versions_from` override returns a distinct version list
3020    /// per [`crate::parser::DependencySource`] — proves
3021    /// [`complete_versions_generic_from`] genuinely threads `source` through to
3022    /// `Registry::get_versions_from` rather than silently dropping it to the
3023    /// source-unaware `get_versions_with` default (the class of bug M10 flagged: a stub
3024    /// that ignores `source`, like [`MockRegistry`] above, would never catch this).
3025    struct RoutingMockRegistry;
3026
3027    impl crate::Registry for RoutingMockRegistry {
3028        fn get_versions<'a>(
3029            &'a self,
3030            _name: &'a crate::PackageName,
3031        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
3032        {
3033            Box::pin(async move {
3034                Ok(vec![Box::new(MockVersion {
3035                    version: "9.9.9".into(),
3036                    yanked: false,
3037                    prerelease: false,
3038                }) as Box<dyn crate::Version>])
3039            })
3040        }
3041
3042        fn get_versions_from<'a>(
3043            &'a self,
3044            _name: &'a crate::PackageName,
3045            source: &'a crate::parser::DependencySource,
3046            _freshness: FreshnessSettings,
3047        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
3048        {
3049            let version = if matches!(
3050                source,
3051                crate::parser::DependencySource::AlternateRegistry { .. }
3052            ) {
3053                "2.0.0"
3054            } else {
3055                "1.0.0"
3056            };
3057            Box::pin(async move {
3058                Ok(vec![Box::new(MockVersion {
3059                    version: version.into(),
3060                    yanked: false,
3061                    prerelease: false,
3062                }) as Box<dyn crate::Version>])
3063            })
3064        }
3065
3066        fn get_latest_matching<'a>(
3067            &'a self,
3068            _name: &'a crate::PackageName,
3069            _req: &'a crate::VersionReq,
3070        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Option<Box<dyn crate::Version>>>>
3071        {
3072            Box::pin(async move { Ok(None) })
3073        }
3074
3075        fn search<'a>(
3076            &'a self,
3077            _query: &'a str,
3078            _limit: usize,
3079        ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
3080        {
3081            Box::pin(async move { Ok(vec![]) })
3082        }
3083
3084        fn as_any(&self) -> &dyn Any {
3085            self
3086        }
3087    }
3088
3089    #[tokio::test]
3090    async fn test_complete_versions_generic_from_routes_source_to_get_versions_from() {
3091        let registry = RoutingMockRegistry;
3092        let alternate = crate::parser::DependencySource::AlternateRegistry {
3093            index: "gitlab-ci:deadbeef".into(),
3094            mirrors_crates_io: false,
3095        };
3096
3097        let items = complete_versions_generic_from(
3098            &registry,
3099            &pkg("test-pkg"),
3100            &alternate,
3101            "",
3102            &[],
3103            FreshnessSettings::default(),
3104        )
3105        .await;
3106        assert_eq!(items[0].label, "2.0.0 (latest)");
3107
3108        // The source-unaware `get_versions` override (returning "9.9.9") must never be
3109        // reached by the source-aware helper.
3110        assert_ne!(items[0].label, "9.9.9 (latest)");
3111    }
3112
3113    #[tokio::test]
3114    async fn test_complete_versions_generic_fallback_when_no_prefix_match() {
3115        let registry = MockRegistry {
3116            versions: vec![
3117                MockVersion {
3118                    version: "1.0.0".into(),
3119                    yanked: false,
3120                    prerelease: false,
3121                },
3122                MockVersion {
3123                    version: "1.1.0".into(),
3124                    yanked: false,
3125                    prerelease: false,
3126                },
3127                MockVersion {
3128                    version: "2.0.0".into(),
3129                    yanked: false,
3130                    prerelease: false,
3131                },
3132                MockVersion {
3133                    version: "2.1.0".into(),
3134                    yanked: true, // Yanked version
3135                    prerelease: false,
3136                },
3137            ],
3138        };
3139
3140        // Test with prefix that doesn't match any version
3141        let items = complete_versions_generic(
3142            &registry,
3143            &pkg("test-pkg"),
3144            "3.0",
3145            &['^', '~', '=', '<', '>'],
3146            FreshnessSettings::default(),
3147        )
3148        .await;
3149
3150        // Should fallback to showing all non-yanked versions
3151        assert_eq!(items.len(), 3);
3152        assert_eq!(items[0].label, "1.0.0 (latest)");
3153        assert_eq!(items[1].label, "1.1.0");
3154        assert_eq!(items[2].label, "2.0.0");
3155
3156        // Yanked version should not be included in fallback
3157        assert!(!items.iter().any(|item| item.label == "2.1.0"));
3158
3159        // Test with empty prefix (should show all non-yanked)
3160        let items = complete_versions_generic(
3161            &registry,
3162            &pkg("test-pkg"),
3163            "",
3164            &[],
3165            FreshnessSettings::default(),
3166        )
3167        .await;
3168
3169        assert_eq!(items.len(), 3);
3170        assert_eq!(items[0].label, "1.0.0 (latest)");
3171        assert_eq!(items[1].label, "1.1.0");
3172        assert_eq!(items[2].label, "2.0.0");
3173    }
3174
3175    #[tokio::test]
3176    async fn test_complete_versions_generic_filters_yanked_in_prefix_match() {
3177        let registry = MockRegistry {
3178            versions: vec![
3179                MockVersion {
3180                    version: "1.0.0".into(),
3181                    yanked: false,
3182                    prerelease: false,
3183                },
3184                MockVersion {
3185                    version: "1.0.1".into(),
3186                    yanked: true, // Yanked version
3187                    prerelease: false,
3188                },
3189                MockVersion {
3190                    version: "1.0.2".into(),
3191                    yanked: false,
3192                    prerelease: false,
3193                },
3194            ],
3195        };
3196
3197        // Test that yanked versions are filtered out even when prefix matches
3198        let items = complete_versions_generic(
3199            &registry,
3200            &pkg("test-pkg"),
3201            "1.0",
3202            &[],
3203            FreshnessSettings::default(),
3204        )
3205        .await;
3206
3207        // Should only include non-yanked versions
3208        assert_eq!(items.len(), 2);
3209        assert_eq!(items[0].label, "1.0.0 (latest)");
3210        assert_eq!(items[1].label, "1.0.2");
3211
3212        // Yanked version 1.0.1 should not be included
3213        assert!(!items.iter().any(|item| item.label == "1.0.1"));
3214    }
3215
3216    #[tokio::test]
3217    async fn test_complete_versions_generic_filters_unsafe_version_string() {
3218        // Regression (critic S3): `build_version_completion` writes `insert_text`/
3219        // `text_edit.new_text` straight from a registry-reported version, the same
3220        // untrusted data source as the REFACTOR code-action loop. An unsafe
3221        // registry version must never surface as a completion item, while an
3222        // ordinary safe version alongside it must still be offered.
3223        let registry = MockRegistry {
3224            versions: vec![
3225                MockVersion {
3226                    version: "1.0.0".into(),
3227                    yanked: false,
3228                    prerelease: false,
3229                },
3230                MockVersion {
3231                    version: "1.0.1\", \"evil\": \"true".into(),
3232                    yanked: false,
3233                    prerelease: false,
3234                },
3235            ],
3236        };
3237
3238        let items = complete_versions_generic(
3239            &registry,
3240            &pkg("test-pkg"),
3241            "1.0",
3242            &[],
3243            FreshnessSettings::default(),
3244        )
3245        .await;
3246
3247        assert!(
3248            !items.iter().any(|item| item.label.contains("evil")),
3249            "an unsafe version string must never be offered as a completion item: {items:?}"
3250        );
3251        assert!(
3252            items.iter().any(|item| item.label.starts_with("1.0.0")),
3253            "a safe version must still be offered: {items:?}"
3254        );
3255    }
3256
3257    #[tokio::test]
3258    async fn test_complete_versions_generic_limit_5() {
3259        // Create more than 5 versions
3260        let versions: Vec<_> = (0..10)
3261            .map(|i| MockVersion {
3262                version: format!("1.0.{}", i).into(),
3263                yanked: false,
3264                prerelease: false,
3265            })
3266            .collect();
3267
3268        let registry = MockRegistry { versions };
3269
3270        // Test that we only return 5 items
3271        let items = complete_versions_generic(
3272            &registry,
3273            &pkg("test-pkg"),
3274            "1.0",
3275            &[],
3276            FreshnessSettings::default(),
3277        )
3278        .await;
3279
3280        assert_eq!(items.len(), 5);
3281        assert_eq!(items[0].label, "1.0.0 (latest)");
3282        assert_eq!(items[4].label, "1.0.4");
3283    }
3284
3285    #[tokio::test]
3286    async fn test_complete_versions_generic_go_no_operators() {
3287        let registry = MockRegistry {
3288            versions: vec![
3289                MockVersion {
3290                    version: "v1.9.0".into(),
3291                    yanked: false,
3292                    prerelease: false,
3293                },
3294                MockVersion {
3295                    version: "v1.9.1".into(),
3296                    yanked: false,
3297                    prerelease: false,
3298                },
3299                MockVersion {
3300                    version: "v1.10.0".into(),
3301                    yanked: false,
3302                    prerelease: false,
3303                },
3304            ],
3305        };
3306
3307        // Go has no operators, so empty array
3308        let items = complete_versions_generic(
3309            &registry,
3310            &pkg("github.com/gin-gonic/gin"),
3311            "v1.9",
3312            &[],
3313            FreshnessSettings::default(),
3314        )
3315        .await;
3316
3317        assert_eq!(items.len(), 2);
3318        assert_eq!(items[0].label, "v1.9.0 (latest)");
3319        assert_eq!(items[1].label, "v1.9.1");
3320    }
3321
3322    // --- Feature completion detection tests ---
3323
3324    fn make_dep_with_features_range(
3325        name: &str,
3326        name_range: Range,
3327        features_range: Range,
3328    ) -> MockDependency {
3329        MockDependency {
3330            name: name.into(),
3331            name_range,
3332            version_range: None,
3333            features_range: Some(features_range),
3334        }
3335    }
3336
3337    #[test]
3338    fn test_detect_feature_context_inline() {
3339        // serde = { version = "1", features = ["derive", "std"] }
3340        // col:                                 36              52
3341        let features_range = Range {
3342            start: Position {
3343                line: 0,
3344                character: 36,
3345            },
3346            end: Position {
3347                line: 0,
3348                character: 52,
3349            },
3350        };
3351        let dep = make_dep_with_features_range(
3352            "serde",
3353            Range {
3354                start: Position {
3355                    line: 0,
3356                    character: 0,
3357                },
3358                end: Position {
3359                    line: 0,
3360                    character: 5,
3361                },
3362            },
3363            features_range,
3364        );
3365        let parse_result = MockParseResult {
3366            dependencies: vec![dep],
3367        };
3368
3369        let content = r#"serde = { version = "1", features = ["derive", "std"] }"#;
3370
3371        // Content: ...["derive",...  => '"' is at char 37, 'd'=38, 'e'=39, 'r'=40
3372        // Cursor after 'r' (insertion point) = character 41
3373        let position = Position {
3374            line: 0,
3375            character: 41,
3376        };
3377        let context = detect_completion_context(&parse_result, position, content);
3378        assert!(
3379            matches!(context, CompletionContext::Feature { ref package_name, ref prefix }
3380                if package_name == "serde" && prefix == "der"),
3381            "Expected Feature context with prefix 'der', got {context:?}"
3382        );
3383    }
3384
3385    #[test]
3386    fn test_detect_feature_context_empty_prefix() {
3387        // Cursor right after opening quote: features = ["|"]
3388        let features_range = Range {
3389            start: Position {
3390                line: 0,
3391                character: 11,
3392            },
3393            end: Position {
3394                line: 0,
3395                character: 15,
3396            },
3397        };
3398        let dep = make_dep_with_features_range(
3399            "tokio",
3400            Range {
3401                start: Position {
3402                    line: 0,
3403                    character: 0,
3404                },
3405                end: Position {
3406                    line: 0,
3407                    character: 5,
3408                },
3409            },
3410            features_range,
3411        );
3412        let parse_result = MockParseResult {
3413            dependencies: vec![dep],
3414        };
3415
3416        let content = r#"features = [""]"#;
3417        // Cursor between the two quotes: position character 13
3418        let position = Position {
3419            line: 0,
3420            character: 13,
3421        };
3422        let context = detect_completion_context(&parse_result, position, content);
3423        assert!(
3424            matches!(context, CompletionContext::Feature { ref package_name, ref prefix }
3425                if package_name == "tokio" && prefix.is_empty()),
3426            "Expected Feature context with empty prefix, got {context:?}"
3427        );
3428    }
3429
3430    #[test]
3431    fn test_detect_feature_context_second_item() {
3432        // features = ["full", "rt-|"]
3433        let features_range = Range {
3434            start: Position {
3435                line: 0,
3436                character: 11,
3437            },
3438            end: Position {
3439                line: 0,
3440                character: 28,
3441            },
3442        };
3443        let dep = make_dep_with_features_range(
3444            "tokio",
3445            Range {
3446                start: Position {
3447                    line: 0,
3448                    character: 0,
3449                },
3450                end: Position {
3451                    line: 0,
3452                    character: 5,
3453                },
3454            },
3455            features_range,
3456        );
3457        let parse_result = MockParseResult {
3458            dependencies: vec![dep],
3459        };
3460
3461        let content = r#"features = ["full", "rt-"]"#;
3462        // Cursor after "rt-": character 24
3463        let position = Position {
3464            line: 0,
3465            character: 24,
3466        };
3467        let context = detect_completion_context(&parse_result, position, content);
3468        assert!(
3469            matches!(context, CompletionContext::Feature { ref package_name, ref prefix }
3470                if package_name == "tokio" && prefix == "rt-"),
3471            "Expected Feature context with prefix 'rt-', got {context:?}"
3472        );
3473    }
3474
3475    #[test]
3476    fn test_detect_no_feature_context_outside_range() {
3477        let features_range = Range {
3478            start: Position {
3479                line: 2,
3480                character: 11,
3481            },
3482            end: Position {
3483                line: 2,
3484                character: 20,
3485            },
3486        };
3487        let dep = make_dep_with_features_range(
3488            "serde",
3489            Range {
3490                start: Position {
3491                    line: 2,
3492                    character: 0,
3493                },
3494                end: Position {
3495                    line: 2,
3496                    character: 5,
3497                },
3498            },
3499            features_range,
3500        );
3501        let parse_result = MockParseResult {
3502            dependencies: vec![dep],
3503        };
3504
3505        // Cursor is on line 0, not line 2 where features are
3506        let content = "[package]\nname = \"test\"\nfeatures = [\"full\"]";
3507        let position = Position {
3508            line: 0,
3509            character: 5,
3510        };
3511        let context = detect_completion_context(&parse_result, position, content);
3512        assert_eq!(context, CompletionContext::None);
3513    }
3514
3515    #[test]
3516    fn test_extract_feature_prefix_basic() {
3517        let content = r#"serde = { features = ["derive"] }"#;
3518        // '"' is at char 22, 'd'=23, 'e'=24, 'r'=25, 'i'=26
3519        // Cursor after 'i' (insertion point) = character 27
3520        let position = Position {
3521            line: 0,
3522            character: 27,
3523        };
3524        let prefix = extract_feature_prefix(content, position);
3525        assert_eq!(prefix, "deri");
3526    }
3527
3528    #[test]
3529    fn test_extract_feature_prefix_empty() {
3530        let content = r#"features = [""]"#;
3531        // Cursor between opening and closing quote at character 13
3532        let position = Position {
3533            line: 0,
3534            character: 13,
3535        };
3536        let prefix = extract_feature_prefix(content, position);
3537        assert_eq!(prefix, "");
3538    }
3539
3540    #[test]
3541    fn test_extract_feature_prefix_multiline() {
3542        let content = "features = [\n    \"rt-multi-thread\",\n    \"mac\"\n]";
3543        // Line 2: `    "mac"` — '"' at char 4, 'm'=5, 'a'=6, 'c'=7
3544        // Cursor after 'c' (insertion point) = character 8
3545        let position = Position {
3546            line: 2,
3547            character: 8,
3548        };
3549        let prefix = extract_feature_prefix(content, position);
3550        assert_eq!(prefix, "mac");
3551    }
3552
3553    #[test]
3554    fn test_extract_feature_prefix_no_quote() {
3555        let content = "features = [\n    \n]";
3556        // Cursor on blank line inside array
3557        let position = Position {
3558            line: 1,
3559            character: 4,
3560        };
3561        let prefix = extract_feature_prefix(content, position);
3562        assert_eq!(prefix, "");
3563    }
3564
3565    #[test]
3566    fn test_extract_feature_prefix_between_items_no_quote() {
3567        // Cursor between a comma and the next opening quote: ["full", |]
3568        // After "full" the quote count is 2 (even) → not inside a string → empty prefix
3569        let content = r#"features = ["full", ]"#;
3570        // Cursor after ", " at character 19 (before `]`)
3571        let position = Position {
3572            line: 0,
3573            character: 19,
3574        };
3575        let prefix = extract_feature_prefix(content, position);
3576        assert_eq!(prefix, "");
3577    }
3578
3579    #[test]
3580    fn test_extract_feature_prefix_cursor_after_opening_bracket() {
3581        // Cursor right after `[`, before any quote: features = [|]
3582        let content = "features = []";
3583        let position = Position {
3584            line: 0,
3585            character: 12,
3586        };
3587        let prefix = extract_feature_prefix(content, position);
3588        assert_eq!(prefix, "");
3589    }
3590
3591    // --- Release-freshness signal (issue #145): VersionDisplayItem.published_at,
3592    // build_version_completion's label_details ---
3593
3594    #[test]
3595    fn test_version_display_item_captures_published_at() {
3596        let version = MockVersionWithAge {
3597            version: "1.0.0".into(),
3598            published_at: Some(PublishTime::from_unix_secs(1_000)),
3599        };
3600
3601        let item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
3602
3603        assert_eq!(item.published_at, Some(PublishTime::from_unix_secs(1_000)));
3604    }
3605
3606    #[test]
3607    fn test_version_display_item_published_at_none_when_unavailable() {
3608        // Plain `MockVersion` doesn't override `published_at`, so it falls back to
3609        // the `Version` trait's default `None` — the ecosystems-without-metadata case.
3610        let version = MockVersion {
3611            version: "1.0.0".into(),
3612            yanked: false,
3613            prerelease: false,
3614        };
3615
3616        let item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
3617
3618        assert_eq!(item.published_at, None);
3619    }
3620
3621    #[test]
3622    fn test_build_version_completion_label_details_present_when_published_at_known() {
3623        let now = PublishTime::from_unix_secs(10_000);
3624        let published_two_hours_ago = PublishTime::from_unix_secs(10_000 - 2 * 3600);
3625        let version = MockVersionWithAge {
3626            version: "1.2.3".into(),
3627            published_at: Some(published_two_hours_ago),
3628        };
3629        let display_item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
3630
3631        let item = build_version_completion(&display_item, None, now, true);
3632
3633        let details = item
3634            .label_details
3635            .expect("label_details must be set when published_at is known");
3636        assert_eq!(details.detail, Some("  2 hours ago".to_string()));
3637        assert_eq!(details.description, None);
3638    }
3639
3640    #[test]
3641    fn test_build_version_completion_label_details_absent_when_freshness_disabled() {
3642        // `freshness.enabled: false` must suppress label_details even when
3643        // published_at is known — the escape hatch must be all-or-nothing.
3644        let now = PublishTime::from_unix_secs(10_000);
3645        let published_two_hours_ago = PublishTime::from_unix_secs(10_000 - 2 * 3600);
3646        let version = MockVersionWithAge {
3647            version: "1.2.3".into(),
3648            published_at: Some(published_two_hours_ago),
3649        };
3650        let display_item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
3651
3652        let item = build_version_completion(&display_item, None, now, false);
3653
3654        assert!(item.label_details.is_none());
3655    }
3656
3657    #[test]
3658    fn test_build_version_completion_label_details_absent_when_published_at_unknown() {
3659        let version = MockVersion {
3660            version: "1.2.3".into(),
3661            yanked: false,
3662            prerelease: false,
3663        };
3664        let display_item = VersionDisplayItem::new(&version, &pkg("serde"), 0, true);
3665
3666        let item = build_version_completion(&display_item, None, PublishTime::now(), true);
3667
3668        assert!(item.label_details.is_none());
3669    }
3670
3671    /// FR-006 regression guard: when freshness data is absent (the pre-feature and
3672    /// 5-deferred-ecosystem case), `label`, `sort_text`, `preselect`, and the item
3673    /// count/order out of `prepare_version_display_items` must stay byte-identical to
3674    /// what this suite asserted before `published_at`/`label_details` existed.
3675    #[test]
3676    fn test_build_version_completion_byte_identical_output_without_freshness_data() {
3677        let versions: Vec<std::sync::Arc<dyn crate::Version>> = vec![
3678            std::sync::Arc::new(MockVersion {
3679                version: "1.0.0".into(),
3680                yanked: false,
3681                prerelease: false,
3682            }),
3683            std::sync::Arc::new(MockVersion {
3684                version: "0.9.0".into(),
3685                yanked: true,
3686                prerelease: false,
3687            }),
3688            std::sync::Arc::new(MockVersion {
3689                version: "0.8.0".into(),
3690                yanked: false,
3691                prerelease: false,
3692            }),
3693        ];
3694
3695        let display_items = prepare_version_display_items(&versions, &pkg("test"));
3696        assert_eq!(display_items.len(), 2, "yanked filtering must be unchanged");
3697
3698        let now = PublishTime::now();
3699        let items: Vec<_> = display_items
3700            .iter()
3701            .map(|item| build_version_completion(item, None, now, true))
3702            .collect();
3703
3704        assert_eq!(items[0].label, "1.0.0 (latest)");
3705        assert_eq!(items[0].sort_text, Some("00000".to_string()));
3706        assert_eq!(items[0].preselect, Some(true));
3707        assert_eq!(items[0].label_details, None);
3708
3709        assert_eq!(items[1].label, "0.8.0");
3710        assert_eq!(items[1].sort_text, Some("00001".to_string()));
3711        assert_eq!(items[1].preselect, Some(false));
3712        assert_eq!(items[1].label_details, None);
3713    }
3714}