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 } => {
31//!             // Search registry and build completions
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::{Metadata, ParseResult, Version};
44use tower_lsp_server::ls_types::{
45    CompletionItem, CompletionItemKind, CompletionItemTag, CompletionTextEdit, Documentation,
46    MarkupContent, MarkupKind, Position, Range, TextEdit,
47};
48
49/// Context for completion request based on cursor position.
50///
51/// This enum represents what type of completion is appropriate at the
52/// current cursor location within a manifest file.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum CompletionContext {
55    /// Cursor is within or after a package name.
56    ///
57    /// Example: `serd|` or `tokio|` where | represents cursor position.
58    PackageName {
59        /// Partial package name typed so far (may be empty).
60        prefix: String,
61    },
62
63    /// Cursor is within a version string.
64    ///
65    /// Example: `"1.0|"` or `"^2.|"` where | represents cursor position.
66    Version {
67        /// Package name this version belongs to.
68        package_name: String,
69        /// Partial version typed so far (may include operators like ^, ~).
70        prefix: String,
71    },
72
73    /// Cursor is within a feature array.
74    ///
75    /// Example: `features = ["deri|"]` where | represents cursor position.
76    Feature {
77        /// Package name whose features are being completed.
78        package_name: String,
79        /// Partial feature name typed so far (may be empty).
80        prefix: String,
81    },
82
83    /// Cursor is not in a valid completion position.
84    None,
85}
86
87/// Detects the completion context based on cursor position.
88///
89/// This function analyzes the cursor position relative to parsed dependencies
90/// to determine what type of completion should be offered.
91///
92/// # Arguments
93///
94/// * `parse_result` - Parsed manifest with dependency information
95/// * `position` - Cursor position in the document (LSP Position, 0-based line, 0-based character)
96/// * `content` - Full document content for prefix extraction
97///
98/// # Returns
99///
100/// A `CompletionContext` indicating what type of completion is appropriate,
101/// or `CompletionContext::None` if the cursor is not in a valid position.
102///
103/// # Examples
104///
105/// ```no_run
106/// use deps_core::completion::detect_completion_context;
107/// use tower_lsp_server::ls_types::Position;
108///
109/// # async fn example(parse_result: &dyn deps_core::ParseResult, content: &str) {
110/// // Cursor at position after "ser" in "serde"
111/// let position = Position { line: 5, character: 3 };
112/// let context = detect_completion_context(parse_result, position, content);
113/// # }
114/// ```
115pub fn detect_completion_context(
116    parse_result: &dyn ParseResult,
117    position: Position,
118    content: &str,
119) -> CompletionContext {
120    let dependencies = parse_result.dependencies();
121
122    for dep in dependencies {
123        // Check if position is within the dependency name range
124        let name_range = dep.name_range();
125        if position_in_range(position, name_range) {
126            let prefix = extract_prefix(content, position, name_range);
127            return CompletionContext::PackageName { prefix };
128        }
129
130        // Check if position is within the version range
131        if let Some(version_range) = dep.version_range()
132            && position_in_range(position, version_range)
133        {
134            let prefix = extract_prefix(content, position, version_range);
135            return CompletionContext::Version {
136                package_name: dep.name().to_string(),
137                prefix,
138            };
139        }
140
141        // TODO: Feature detection - ecosystem-specific, requires more context
142    }
143
144    CompletionContext::None
145}
146
147/// Checks if a position is within or at the end of a range.
148///
149/// LSP ranges are inclusive of start, exclusive of end.
150/// We also consider the position to be "in range" if it's immediately
151/// after the range end (for completion after typing).
152fn position_in_range(position: Position, range: Range) -> bool {
153    // Before range start
154    if position.line < range.start.line {
155        return false;
156    }
157
158    if position.line == range.start.line && position.character < range.start.character {
159        return false;
160    }
161
162    // After range end (allow one position past for completion)
163    if position.line > range.end.line {
164        return false;
165    }
166
167    if position.line == range.end.line && position.character > range.end.character + 1 {
168        return false;
169    }
170
171    true
172}
173
174/// Converts UTF-16 offset to byte offset in a string.
175///
176/// LSP uses UTF-16 code units for character positions (for compatibility with
177/// JavaScript and other languages). This function converts from UTF-16 offset
178/// to byte offset for Rust string indexing.
179///
180/// # Arguments
181///
182/// * `s` - The string to index into
183/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
184///
185/// # Returns
186///
187/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
188///
189/// # Examples
190///
191/// ```
192/// # use deps_core::completion::utf16_to_byte_offset;
193/// // ASCII: UTF-16 offset equals byte offset
194/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
195///
196/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
197/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
198/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
199/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
200///
201/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
202/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
203/// ```
204pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
205    let mut utf16_count = 0u32;
206    for (byte_idx, ch) in s.char_indices() {
207        if utf16_count >= utf16_offset {
208            return Some(byte_idx);
209        }
210        utf16_count += ch.len_utf16() as u32;
211    }
212    if utf16_count == utf16_offset {
213        return Some(s.len());
214    }
215    None
216}
217
218/// Extracts the prefix text from content at a position within a range.
219///
220/// This function finds the text from the start of the range up to the
221/// cursor position, excluding any quote characters.
222///
223/// # Arguments
224///
225/// * `content` - Full document content
226/// * `position` - Cursor position (LSP Position, 0-based line, UTF-16 character offset)
227/// * `range` - Range containing the token (name, version, etc.)
228///
229/// # Returns
230///
231/// The prefix string typed so far, with quotes and extra whitespace removed.
232///
233/// # Examples
234///
235/// ```no_run
236/// use deps_core::completion::extract_prefix;
237/// use tower_lsp_server::ls_types::{Position, Range};
238///
239/// let content = r#"serde = "1.0""#;
240/// let position = Position { line: 0, character: 11 }; // After "1."
241/// let range = Range {
242///     start: Position { line: 0, character: 9 },
243///     end: Position { line: 0, character: 13 },
244/// };
245///
246/// let prefix = extract_prefix(content, position, range);
247/// assert_eq!(prefix, "1.");
248/// ```
249pub fn extract_prefix(content: &str, position: Position, range: Range) -> String {
250    // Get the line at the position - use nth() instead of collecting all lines
251    let line = match content.lines().nth(position.line as usize) {
252        Some(l) => l,
253        None => return String::new(),
254    };
255
256    // Convert UTF-16 positions to byte offsets
257    let start_byte = if position.line == range.start.line {
258        match utf16_to_byte_offset(line, range.start.character) {
259            Some(offset) => offset,
260            None => return String::new(),
261        }
262    } else {
263        0
264    };
265
266    let cursor_byte = match utf16_to_byte_offset(line, position.character) {
267        Some(offset) => offset,
268        None => return String::new(),
269    };
270
271    // Safety: ensure byte offsets are within bounds
272    if start_byte > line.len() || cursor_byte > line.len() || start_byte > cursor_byte {
273        return String::new();
274    }
275
276    // Extract substring
277    let prefix = &line[start_byte..cursor_byte];
278
279    // Remove quotes and trim whitespace
280    prefix
281        .trim()
282        .trim_matches('"')
283        .trim_matches('\'')
284        .trim()
285        .to_string()
286}
287
288/// Builds a completion item for a package name.
289///
290/// Creates a properly formatted LSP CompletionItem with documentation,
291/// version information, and links to repository/docs.
292///
293/// # Arguments
294///
295/// * `metadata` - Package metadata from registry search
296/// * `insert_range` - LSP range where the completion should be inserted
297///
298/// # Returns
299///
300/// A complete `CompletionItem` ready to send to the LSP client.
301///
302/// # Examples
303///
304/// ```no_run
305/// use deps_core::completion::build_package_completion;
306/// use tower_lsp_server::ls_types::Range;
307///
308/// # async fn example(metadata: &dyn deps_core::Metadata) {
309/// let range = Range::default(); // Use actual range from context
310/// let item = build_package_completion(metadata, range);
311/// assert_eq!(item.label, metadata.name());
312/// # }
313/// ```
314pub fn build_package_completion(metadata: &dyn Metadata, insert_range: Range) -> CompletionItem {
315    let name = metadata.name();
316    let latest = metadata.latest_version();
317
318    // Build markdown documentation
319    let mut doc_parts = vec![format!("**{}** v{}", name, latest)];
320
321    if let Some(desc) = metadata.description() {
322        doc_parts.push(String::new()); // Empty line
323        let truncated = if desc.len() > 200 {
324            let mut end = 200;
325            while end > 0 && !desc.is_char_boundary(end) {
326                end -= 1;
327            }
328            format!("{}...", &desc[..end])
329        } else {
330            desc.to_string()
331        };
332        doc_parts.push(truncated);
333    }
334
335    // Add links section if we have any links
336    let mut links = Vec::new();
337    if let Some(repo) = metadata.repository() {
338        links.push(format!("[Repository]({})", repo));
339    }
340    if let Some(docs) = metadata.documentation() {
341        links.push(format!("[Documentation]({})", docs));
342    }
343
344    if !links.is_empty() {
345        doc_parts.push(String::new()); // Empty line
346        doc_parts.push(links.join(" | "));
347    }
348
349    CompletionItem {
350        label: name.to_string(),
351        kind: Some(CompletionItemKind::MODULE),
352        detail: Some(format!("v{}", latest)),
353        documentation: Some(Documentation::MarkupContent(MarkupContent {
354            kind: MarkupKind::Markdown,
355            value: doc_parts.join("\n"),
356        })),
357        insert_text: Some(name.to_string()),
358        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
359            range: insert_range,
360            new_text: name.to_string(),
361        })),
362        sort_text: Some(name.to_string()),
363        filter_text: Some(name.to_string()),
364        ..Default::default()
365    }
366}
367
368/// Builds a completion item for a version string.
369///
370/// Creates a properly formatted LSP CompletionItem with version metadata
371/// including yanked status, pre-release indicators, and appropriate sorting.
372///
373/// # Arguments
374///
375/// * `version` - Version information from registry
376/// * `package_name` - Name of the package this version belongs to
377/// * `insert_range` - LSP range where the completion should be inserted
378///
379/// # Returns
380///
381/// A complete `CompletionItem` with proper sorting and deprecation tags.
382///
383/// # Sorting
384///
385/// Versions are sorted in this priority order:
386/// 1. Stable versions (not yanked, not pre-release) - newest first
387/// 2. Pre-release versions - newest first
388/// 3. Yanked versions - newest first
389///
390/// # Examples
391///
392/// ```no_run
393/// use deps_core::completion::build_version_completion;
394/// use tower_lsp_server::ls_types::Range;
395///
396/// # async fn example(version: &dyn deps_core::Version) {
397/// let range = Range::default();
398/// let item = build_version_completion(version, "serde", range);
399/// assert_eq!(item.label, version.version_string());
400/// # }
401/// ```
402pub fn build_version_completion(
403    version: &dyn Version,
404    package_name: &str,
405    insert_range: Range,
406) -> CompletionItem {
407    let version_str = version.version_string();
408
409    // Build detail text with status indicators
410    let mut detail_parts = vec![format!("v{}", version_str)];
411
412    if version.is_yanked() {
413        detail_parts.push("(yanked)".to_string());
414    }
415
416    if version.is_prerelease() {
417        detail_parts.push("(pre-release)".to_string());
418    }
419
420    let detail = detail_parts.join(" ");
421
422    // Tags for deprecated/yanked versions
423    let tags = if version.is_yanked() {
424        Some(vec![CompletionItemTag::DEPRECATED])
425    } else {
426        None
427    };
428
429    // Sort key: stable first, then pre-release, then yanked
430    // Within each group, sort by version (descending)
431    let sort_prefix = if version.is_yanked() {
432        "3_"
433    } else if version.is_prerelease() {
434        "2_"
435    } else {
436        "1_"
437    };
438
439    let sort_text = format!("{}{}", sort_prefix, version_str);
440
441    CompletionItem {
442        label: version_str.to_string(),
443        kind: Some(CompletionItemKind::VALUE),
444        detail: Some(detail),
445        documentation: Some(Documentation::String(format!(
446            "Version {} of {}",
447            version_str, package_name
448        ))),
449        insert_text: Some(version_str.to_string()),
450        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
451            range: insert_range,
452            new_text: version_str.to_string(),
453        })),
454        sort_text: Some(sort_text),
455        deprecated: Some(version.is_yanked()),
456        tags,
457        ..Default::default()
458    }
459}
460
461/// Builds a completion item for a feature flag.
462///
463/// Creates a properly formatted LSP CompletionItem for feature flag names.
464/// Only applicable to ecosystems that support features (e.g., Cargo).
465///
466/// # Arguments
467///
468/// * `feature_name` - Name of the feature flag
469/// * `package_name` - Name of the package this feature belongs to
470/// * `insert_range` - LSP range where the completion should be inserted
471///
472/// # Returns
473///
474/// A complete `CompletionItem` for the feature flag.
475///
476/// # Examples
477///
478/// ```no_run
479/// use deps_core::completion::build_feature_completion;
480/// use tower_lsp_server::ls_types::Range;
481///
482/// let range = Range::default();
483/// let item = build_feature_completion("derive", "serde", range);
484/// assert_eq!(item.label, "derive");
485/// ```
486pub fn build_feature_completion(
487    feature_name: &str,
488    package_name: &str,
489    insert_range: Range,
490) -> CompletionItem {
491    CompletionItem {
492        label: feature_name.to_string(),
493        kind: Some(CompletionItemKind::PROPERTY),
494        detail: Some(format!("Feature of {}", package_name)),
495        documentation: None,
496        insert_text: Some(feature_name.to_string()),
497        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
498            range: insert_range,
499            new_text: feature_name.to_string(),
500        })),
501        sort_text: Some(feature_name.to_string()),
502        ..Default::default()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::any::Any;
510
511    // Mock implementations for testing
512
513    struct MockDependency {
514        name: String,
515        name_range: Range,
516        version_range: Option<Range>,
517    }
518
519    impl crate::ecosystem::Dependency for MockDependency {
520        fn name(&self) -> &str {
521            &self.name
522        }
523
524        fn name_range(&self) -> Range {
525            self.name_range
526        }
527
528        fn version_requirement(&self) -> Option<&str> {
529            Some("1.0")
530        }
531
532        fn version_range(&self) -> Option<Range> {
533            self.version_range
534        }
535
536        fn source(&self) -> crate::parser::DependencySource {
537            crate::parser::DependencySource::Registry
538        }
539
540        fn as_any(&self) -> &dyn Any {
541            self
542        }
543    }
544
545    struct MockParseResult {
546        dependencies: Vec<MockDependency>,
547    }
548
549    impl ParseResult for MockParseResult {
550        fn dependencies(&self) -> Vec<&dyn crate::ecosystem::Dependency> {
551            self.dependencies
552                .iter()
553                .map(|d| d as &dyn crate::ecosystem::Dependency)
554                .collect()
555        }
556
557        fn workspace_root(&self) -> Option<&std::path::Path> {
558            None
559        }
560
561        fn uri(&self) -> &tower_lsp_server::ls_types::Uri {
562            // Create a dummy URL for testing
563            static URL_STR: &str = "file:///test/Cargo.toml";
564            static URL: once_cell::sync::Lazy<tower_lsp_server::ls_types::Uri> =
565                once_cell::sync::Lazy::new(|| URL_STR.parse().unwrap());
566            &URL
567        }
568
569        fn as_any(&self) -> &dyn Any {
570            self
571        }
572    }
573
574    struct MockVersion {
575        version: String,
576        yanked: bool,
577        prerelease: bool,
578    }
579
580    impl crate::registry::Version for MockVersion {
581        fn version_string(&self) -> &str {
582            &self.version
583        }
584
585        fn is_yanked(&self) -> bool {
586            self.yanked
587        }
588
589        fn is_prerelease(&self) -> bool {
590            self.prerelease
591        }
592
593        fn as_any(&self) -> &dyn Any {
594            self
595        }
596    }
597
598    struct MockMetadata {
599        name: String,
600        description: Option<String>,
601        repository: Option<String>,
602        documentation: Option<String>,
603        latest_version: String,
604    }
605
606    impl crate::registry::Metadata for MockMetadata {
607        fn name(&self) -> &str {
608            &self.name
609        }
610
611        fn description(&self) -> Option<&str> {
612            self.description.as_deref()
613        }
614
615        fn repository(&self) -> Option<&str> {
616            self.repository.as_deref()
617        }
618
619        fn documentation(&self) -> Option<&str> {
620            self.documentation.as_deref()
621        }
622
623        fn latest_version(&self) -> &str {
624            &self.latest_version
625        }
626
627        fn as_any(&self) -> &dyn Any {
628            self
629        }
630    }
631
632    // Context detection tests
633
634    #[test]
635    fn test_detect_package_name_context_at_start() {
636        let parse_result = MockParseResult {
637            dependencies: vec![MockDependency {
638                name: "serde".to_string(),
639                name_range: Range {
640                    start: Position {
641                        line: 0,
642                        character: 0,
643                    },
644                    end: Position {
645                        line: 0,
646                        character: 5,
647                    },
648                },
649                version_range: None,
650            }],
651        };
652
653        let content = "serde";
654        let position = Position {
655            line: 0,
656            character: 0,
657        };
658
659        let context = detect_completion_context(&parse_result, position, content);
660
661        match context {
662            CompletionContext::PackageName { prefix } => {
663                assert_eq!(prefix, "");
664            }
665            _ => panic!("Expected PackageName context, got {:?}", context),
666        }
667    }
668
669    #[test]
670    fn test_detect_package_name_context_partial() {
671        let parse_result = MockParseResult {
672            dependencies: vec![MockDependency {
673                name: "serde".to_string(),
674                name_range: Range {
675                    start: Position {
676                        line: 0,
677                        character: 0,
678                    },
679                    end: Position {
680                        line: 0,
681                        character: 5,
682                    },
683                },
684                version_range: None,
685            }],
686        };
687
688        let content = "serde";
689        let position = Position {
690            line: 0,
691            character: 3,
692        };
693
694        let context = detect_completion_context(&parse_result, position, content);
695
696        match context {
697            CompletionContext::PackageName { prefix } => {
698                assert_eq!(prefix, "ser");
699            }
700            _ => panic!("Expected PackageName context, got {:?}", context),
701        }
702    }
703
704    #[test]
705    fn test_detect_version_context() {
706        let parse_result = MockParseResult {
707            dependencies: vec![MockDependency {
708                name: "serde".to_string(),
709                name_range: Range {
710                    start: Position {
711                        line: 0,
712                        character: 0,
713                    },
714                    end: Position {
715                        line: 0,
716                        character: 5,
717                    },
718                },
719                version_range: Some(Range {
720                    start: Position {
721                        line: 0,
722                        character: 9,
723                    },
724                    end: Position {
725                        line: 0,
726                        character: 14,
727                    },
728                }),
729            }],
730        };
731
732        let content = r#"serde = "1.0.1""#;
733        let position = Position {
734            line: 0,
735            character: 11,
736        };
737
738        let context = detect_completion_context(&parse_result, position, content);
739
740        match context {
741            CompletionContext::Version {
742                package_name,
743                prefix,
744            } => {
745                assert_eq!(package_name, "serde");
746                assert_eq!(prefix, "1.");
747            }
748            _ => panic!("Expected Version context, got {:?}", context),
749        }
750    }
751
752    #[test]
753    fn test_detect_no_context_before_dependencies() {
754        let parse_result = MockParseResult {
755            dependencies: vec![MockDependency {
756                name: "serde".to_string(),
757                name_range: Range {
758                    start: Position {
759                        line: 5,
760                        character: 0,
761                    },
762                    end: Position {
763                        line: 5,
764                        character: 5,
765                    },
766                },
767                version_range: None,
768            }],
769        };
770
771        let content = "[dependencies]\nserde";
772        let position = Position {
773            line: 0,
774            character: 10,
775        };
776
777        let context = detect_completion_context(&parse_result, position, content);
778
779        assert_eq!(context, CompletionContext::None);
780    }
781
782    #[test]
783    fn test_detect_no_context_invalid_position() {
784        let parse_result = MockParseResult {
785            dependencies: vec![],
786        };
787
788        let content = "";
789        let position = Position {
790            line: 100,
791            character: 100,
792        };
793
794        let context = detect_completion_context(&parse_result, position, content);
795
796        assert_eq!(context, CompletionContext::None);
797    }
798
799    // Prefix extraction tests
800
801    #[test]
802    fn test_extract_prefix_at_start() {
803        let content = "serde";
804        let position = Position {
805            line: 0,
806            character: 0,
807        };
808        let range = Range {
809            start: Position {
810                line: 0,
811                character: 0,
812            },
813            end: Position {
814                line: 0,
815                character: 5,
816            },
817        };
818
819        let prefix = extract_prefix(content, position, range);
820        assert_eq!(prefix, "");
821    }
822
823    #[test]
824    fn test_extract_prefix_partial() {
825        let content = "serde";
826        let position = Position {
827            line: 0,
828            character: 3,
829        };
830        let range = Range {
831            start: Position {
832                line: 0,
833                character: 0,
834            },
835            end: Position {
836                line: 0,
837                character: 5,
838            },
839        };
840
841        let prefix = extract_prefix(content, position, range);
842        assert_eq!(prefix, "ser");
843    }
844
845    #[test]
846    fn test_extract_prefix_with_quotes() {
847        let content = r#"serde = "1.0""#;
848        let position = Position {
849            line: 0,
850            character: 11,
851        };
852        let range = Range {
853            start: Position {
854                line: 0,
855                character: 9,
856            },
857            end: Position {
858                line: 0,
859                character: 13,
860            },
861        };
862
863        let prefix = extract_prefix(content, position, range);
864        assert_eq!(prefix, "1.");
865    }
866
867    #[test]
868    fn test_extract_prefix_empty() {
869        let content = r#"serde = """#;
870        let position = Position {
871            line: 0,
872            character: 9,
873        };
874        let range = Range {
875            start: Position {
876                line: 0,
877                character: 9,
878            },
879            end: Position {
880                line: 0,
881                character: 11,
882            },
883        };
884
885        let prefix = extract_prefix(content, position, range);
886        assert_eq!(prefix, "");
887    }
888
889    #[test]
890    fn test_extract_prefix_version_with_operator() {
891        let content = r#"serde = "^1.0""#;
892        let position = Position {
893            line: 0,
894            character: 12,
895        };
896        let range = Range {
897            start: Position {
898                line: 0,
899                character: 9,
900            },
901            end: Position {
902                line: 0,
903                character: 14,
904            },
905        };
906
907        let prefix = extract_prefix(content, position, range);
908        assert_eq!(prefix, "^1.");
909    }
910
911    // CompletionItem builder tests
912
913    #[test]
914    fn test_build_package_completion_full() {
915        let metadata = MockMetadata {
916            name: "serde".to_string(),
917            description: Some("Serialization framework".to_string()),
918            repository: Some("https://github.com/serde-rs/serde".to_string()),
919            documentation: Some("https://docs.rs/serde".to_string()),
920            latest_version: "1.0.214".to_string(),
921        };
922
923        let range = Range::default();
924        let item = build_package_completion(&metadata, range);
925
926        assert_eq!(item.label, "serde");
927        assert_eq!(item.kind, Some(CompletionItemKind::MODULE));
928        assert_eq!(item.detail, Some("v1.0.214".to_string()));
929        assert!(matches!(
930            item.documentation,
931            Some(Documentation::MarkupContent(_))
932        ));
933
934        if let Some(Documentation::MarkupContent(content)) = item.documentation {
935            assert!(content.value.contains("**serde** v1.0.214"));
936            assert!(content.value.contains("Serialization framework"));
937            assert!(content.value.contains("Repository"));
938            assert!(content.value.contains("Documentation"));
939        }
940    }
941
942    #[test]
943    fn test_build_package_completion_minimal() {
944        let metadata = MockMetadata {
945            name: "test-pkg".to_string(),
946            description: None,
947            repository: None,
948            documentation: None,
949            latest_version: "0.1.0".to_string(),
950        };
951
952        let range = Range::default();
953        let item = build_package_completion(&metadata, range);
954
955        assert_eq!(item.label, "test-pkg");
956        assert_eq!(item.detail, Some("v0.1.0".to_string()));
957
958        if let Some(Documentation::MarkupContent(content)) = item.documentation {
959            assert!(content.value.contains("**test-pkg** v0.1.0"));
960            assert!(!content.value.contains("Repository"));
961        }
962    }
963
964    #[test]
965    fn test_build_version_completion_stable() {
966        let version = MockVersion {
967            version: "1.0.0".to_string(),
968            yanked: false,
969            prerelease: false,
970        };
971
972        let range = Range::default();
973        let item = build_version_completion(&version, "serde", range);
974
975        assert_eq!(item.label, "1.0.0");
976        assert_eq!(item.kind, Some(CompletionItemKind::VALUE));
977        assert_eq!(item.detail, Some("v1.0.0".to_string()));
978        assert_eq!(item.deprecated, Some(false));
979        assert!(item.tags.is_none());
980        assert!(item.sort_text.as_ref().unwrap().starts_with("1_"));
981    }
982
983    #[test]
984    fn test_build_version_completion_yanked() {
985        let version = MockVersion {
986            version: "1.0.0".to_string(),
987            yanked: true,
988            prerelease: false,
989        };
990
991        let range = Range::default();
992        let item = build_version_completion(&version, "serde", range);
993
994        assert_eq!(item.detail, Some("v1.0.0 (yanked)".to_string()));
995        assert_eq!(item.deprecated, Some(true));
996        assert_eq!(item.tags, Some(vec![CompletionItemTag::DEPRECATED]));
997        assert!(item.sort_text.as_ref().unwrap().starts_with("3_"));
998    }
999
1000    #[test]
1001    fn test_build_version_completion_prerelease() {
1002        let version = MockVersion {
1003            version: "2.0.0-alpha.1".to_string(),
1004            yanked: false,
1005            prerelease: true,
1006        };
1007
1008        let range = Range::default();
1009        let item = build_version_completion(&version, "tokio", range);
1010
1011        assert_eq!(
1012            item.detail,
1013            Some("v2.0.0-alpha.1 (pre-release)".to_string())
1014        );
1015        assert_eq!(item.deprecated, Some(false));
1016        assert!(item.tags.is_none());
1017        assert!(item.sort_text.as_ref().unwrap().starts_with("2_"));
1018    }
1019
1020    #[test]
1021    fn test_build_version_completion_sort_order() {
1022        let stable = MockVersion {
1023            version: "1.0.0".to_string(),
1024            yanked: false,
1025            prerelease: false,
1026        };
1027        let prerelease = MockVersion {
1028            version: "2.0.0-beta".to_string(),
1029            yanked: false,
1030            prerelease: true,
1031        };
1032        let yanked = MockVersion {
1033            version: "0.9.0".to_string(),
1034            yanked: true,
1035            prerelease: false,
1036        };
1037
1038        let range = Range::default();
1039        let stable_item = build_version_completion(&stable, "test", range);
1040        let prerelease_item = build_version_completion(&prerelease, "test", range);
1041        let yanked_item = build_version_completion(&yanked, "test", range);
1042
1043        // Stable should sort first (1_)
1044        assert!(stable_item.sort_text.as_ref().unwrap().starts_with("1_"));
1045        // Pre-release should sort second (2_)
1046        assert!(
1047            prerelease_item
1048                .sort_text
1049                .as_ref()
1050                .unwrap()
1051                .starts_with("2_")
1052        );
1053        // Yanked should sort last (3_)
1054        assert!(yanked_item.sort_text.as_ref().unwrap().starts_with("3_"));
1055    }
1056
1057    #[test]
1058    fn test_build_feature_completion() {
1059        let range = Range::default();
1060        let item = build_feature_completion("derive", "serde", range);
1061
1062        assert_eq!(item.label, "derive");
1063        assert_eq!(item.kind, Some(CompletionItemKind::PROPERTY));
1064        assert_eq!(item.detail, Some("Feature of serde".to_string()));
1065        assert!(item.documentation.is_none());
1066        assert_eq!(item.sort_text, Some("derive".to_string()));
1067    }
1068
1069    #[test]
1070    fn test_position_in_range_within() {
1071        let range = Range {
1072            start: Position {
1073                line: 0,
1074                character: 5,
1075            },
1076            end: Position {
1077                line: 0,
1078                character: 10,
1079            },
1080        };
1081
1082        let position = Position {
1083            line: 0,
1084            character: 7,
1085        };
1086
1087        assert!(position_in_range(position, range));
1088    }
1089
1090    #[test]
1091    fn test_position_in_range_at_start() {
1092        let range = Range {
1093            start: Position {
1094                line: 0,
1095                character: 5,
1096            },
1097            end: Position {
1098                line: 0,
1099                character: 10,
1100            },
1101        };
1102
1103        let position = Position {
1104            line: 0,
1105            character: 5,
1106        };
1107
1108        assert!(position_in_range(position, range));
1109    }
1110
1111    #[test]
1112    fn test_position_in_range_at_end() {
1113        let range = Range {
1114            start: Position {
1115                line: 0,
1116                character: 5,
1117            },
1118            end: Position {
1119                line: 0,
1120                character: 10,
1121            },
1122        };
1123
1124        let position = Position {
1125            line: 0,
1126            character: 10,
1127        };
1128
1129        assert!(position_in_range(position, range));
1130    }
1131
1132    #[test]
1133    fn test_position_in_range_one_past_end() {
1134        let range = Range {
1135            start: Position {
1136                line: 0,
1137                character: 5,
1138            },
1139            end: Position {
1140                line: 0,
1141                character: 10,
1142            },
1143        };
1144
1145        // Allow one character past end for completion
1146        let position = Position {
1147            line: 0,
1148            character: 11,
1149        };
1150
1151        assert!(position_in_range(position, range));
1152    }
1153
1154    #[test]
1155    fn test_position_in_range_before() {
1156        let range = Range {
1157            start: Position {
1158                line: 0,
1159                character: 5,
1160            },
1161            end: Position {
1162                line: 0,
1163                character: 10,
1164            },
1165        };
1166
1167        let position = Position {
1168            line: 0,
1169            character: 4,
1170        };
1171
1172        assert!(!position_in_range(position, range));
1173    }
1174
1175    #[test]
1176    fn test_position_in_range_after() {
1177        let range = Range {
1178            start: Position {
1179                line: 0,
1180                character: 5,
1181            },
1182            end: Position {
1183                line: 0,
1184                character: 10,
1185            },
1186        };
1187
1188        let position = Position {
1189            line: 0,
1190            character: 12,
1191        };
1192
1193        assert!(!position_in_range(position, range));
1194    }
1195
1196    // UTF-16 to byte offset conversion tests
1197
1198    #[test]
1199    fn test_utf16_to_byte_offset_ascii() {
1200        let s = "hello";
1201        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
1202        assert_eq!(utf16_to_byte_offset(s, 2), Some(2));
1203        assert_eq!(utf16_to_byte_offset(s, 5), Some(5));
1204    }
1205
1206    #[test]
1207    fn test_utf16_to_byte_offset_multibyte() {
1208        // "日本語" - each character is 3 bytes, 1 UTF-16 code unit
1209        let s = "日本語";
1210        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
1211        assert_eq!(utf16_to_byte_offset(s, 1), Some(3));
1212        assert_eq!(utf16_to_byte_offset(s, 2), Some(6));
1213        assert_eq!(utf16_to_byte_offset(s, 3), Some(9));
1214    }
1215
1216    #[test]
1217    fn test_utf16_to_byte_offset_emoji() {
1218        // "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
1219        let s = "😀test";
1220        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
1221        assert_eq!(utf16_to_byte_offset(s, 2), Some(4)); // After emoji
1222        assert_eq!(utf16_to_byte_offset(s, 3), Some(5)); // After 't'
1223    }
1224
1225    #[test]
1226    fn test_utf16_to_byte_offset_mixed() {
1227        // Mix of ASCII, multi-byte, and emoji
1228        let s = "hello 世界 😀!";
1229        assert_eq!(utf16_to_byte_offset(s, 0), Some(0)); // 'h'
1230        assert_eq!(utf16_to_byte_offset(s, 6), Some(6)); // '世'
1231        assert_eq!(utf16_to_byte_offset(s, 7), Some(9)); // '界'
1232        assert_eq!(utf16_to_byte_offset(s, 9), Some(13)); // '😀' (2 UTF-16 units)
1233        assert_eq!(utf16_to_byte_offset(s, 11), Some(17)); // '!'
1234    }
1235
1236    #[test]
1237    fn test_utf16_to_byte_offset_out_of_bounds() {
1238        let s = "hello";
1239        assert_eq!(utf16_to_byte_offset(s, 100), None);
1240    }
1241
1242    #[test]
1243    fn test_utf16_to_byte_offset_empty() {
1244        let s = "";
1245        assert_eq!(utf16_to_byte_offset(s, 0), Some(0));
1246        assert_eq!(utf16_to_byte_offset(s, 1), None);
1247    }
1248
1249    // Unicode truncation tests
1250
1251    #[test]
1252    fn test_build_package_completion_long_description_ascii() {
1253        let long_desc = "a".repeat(250);
1254        let metadata = MockMetadata {
1255            name: "test-pkg".to_string(),
1256            description: Some(long_desc),
1257            repository: None,
1258            documentation: None,
1259            latest_version: "1.0.0".to_string(),
1260        };
1261
1262        let range = Range::default();
1263        let item = build_package_completion(&metadata, range);
1264
1265        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1266            // Should be truncated to 200 chars + "..."
1267            let lines: Vec<_> = content.value.lines().collect();
1268            assert!(lines[2].ends_with("..."));
1269            assert!(lines[2].len() <= 203); // 200 + "..."
1270        } else {
1271            panic!("Expected MarkupContent documentation");
1272        }
1273    }
1274
1275    #[test]
1276    fn test_build_package_completion_long_description_unicode() {
1277        // Create description with Unicode chars at the boundary
1278        // Each '日' is 3 bytes, so 67 chars = 201 bytes
1279        let mut long_desc = String::new();
1280        for _ in 0..67 {
1281            long_desc.push('日');
1282        }
1283
1284        let metadata = MockMetadata {
1285            name: "test-pkg".to_string(),
1286            description: Some(long_desc),
1287            repository: None,
1288            documentation: None,
1289            latest_version: "1.0.0".to_string(),
1290        };
1291
1292        let range = Range::default();
1293        let item = build_package_completion(&metadata, range);
1294
1295        // Should not panic on truncation
1296        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1297            let lines: Vec<_> = content.value.lines().collect();
1298            assert!(lines[2].ends_with("..."));
1299            // Truncation should happen at a char boundary
1300            assert!(lines[2].is_char_boundary(lines[2].len()));
1301        } else {
1302            panic!("Expected MarkupContent documentation");
1303        }
1304    }
1305
1306    #[test]
1307    fn test_build_package_completion_long_description_emoji() {
1308        // Emoji "😀" is 4 bytes each
1309        // 51 emoji = 204 bytes
1310        let long_desc = "😀".repeat(51);
1311
1312        let metadata = MockMetadata {
1313            name: "test-pkg".to_string(),
1314            description: Some(long_desc),
1315            repository: None,
1316            documentation: None,
1317            latest_version: "1.0.0".to_string(),
1318        };
1319
1320        let range = Range::default();
1321        let item = build_package_completion(&metadata, range);
1322
1323        // Should not panic on truncation
1324        if let Some(Documentation::MarkupContent(content)) = item.documentation {
1325            let lines: Vec<_> = content.value.lines().collect();
1326            assert!(lines[2].ends_with("..."));
1327            // Truncation should happen at a char boundary
1328            assert!(lines[2].is_char_boundary(lines[2].len()));
1329        } else {
1330            panic!("Expected MarkupContent documentation");
1331        }
1332    }
1333
1334    #[test]
1335    fn test_extract_prefix_unicode_package_name() {
1336        // Package name with Unicode characters
1337        let content = "日本語-crate = \"1.0\"";
1338        let position = Position {
1339            line: 0,
1340            character: 3, // UTF-16 offset after "日本語"
1341        };
1342        let range = Range {
1343            start: Position {
1344                line: 0,
1345                character: 0,
1346            },
1347            end: Position {
1348                line: 0,
1349                character: 10,
1350            },
1351        };
1352
1353        let prefix = extract_prefix(content, position, range);
1354        assert_eq!(prefix, "日本語");
1355    }
1356
1357    #[test]
1358    fn test_extract_prefix_emoji_in_content() {
1359        // Content with emoji (rare but should handle gracefully)
1360        let content = "emoji-😀-crate = \"1.0\"";
1361        let position = Position {
1362            line: 0,
1363            character: 8, // UTF-16 offset after "emoji-😀"
1364        };
1365        let range = Range {
1366            start: Position {
1367                line: 0,
1368                character: 0,
1369            },
1370            end: Position {
1371                line: 0,
1372                character: 14,
1373            },
1374        };
1375
1376        let prefix = extract_prefix(content, position, range);
1377        assert_eq!(prefix, "emoji-😀");
1378    }
1379}