Skip to main content

deps_core/
lsp_helpers.rs

1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use tower_lsp_server::ls_types::{
5    CodeAction, CodeActionKind, Diagnostic, DiagnosticSeverity, Hover, HoverContents, InlayHint,
6    InlayHintKind, InlayHintLabel, InlayHintTooltip, MarkupContent, MarkupKind, Position, Range,
7    TextEdit, Uri, WorkspaceEdit,
8};
9
10use crate::{Dependency, EcosystemConfig, ParseResult, Registry};
11
12/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
13pub fn position_in_range(pos: Position, range: Range) -> bool {
14    if pos.line < range.start.line || pos.line > range.end.line {
15        return false;
16    }
17    if pos.line == range.start.line && pos.character < range.start.character {
18        return false;
19    }
20    if pos.line == range.end.line && pos.character > range.end.character {
21        return false;
22    }
23    true
24}
25
26/// Converts byte offsets in source text to LSP `Position` values.
27///
28/// Precomputes line-start byte offsets once, then maps any byte offset to a
29/// `(line, character)` position. Characters are counted as UTF-16 code units
30/// as required by the LSP specification.
31pub struct LineOffsetTable {
32    line_starts: Vec<usize>,
33}
34
35impl LineOffsetTable {
36    /// Builds the table for `content`.
37    pub fn new(content: &str) -> Self {
38        let mut line_starts = vec![0];
39        for (i, c) in content.char_indices() {
40            if c == '\n' {
41                line_starts.push(i + 1);
42            }
43        }
44        Self { line_starts }
45    }
46
47    /// Converts a byte offset into an LSP `Position`.
48    pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
49        let offset = offset.min(content.len());
50        let line = self
51            .line_starts
52            .partition_point(|&start| start <= offset)
53            .saturating_sub(1);
54        let line_start = self.line_starts[line];
55        let character = content[line_start..offset]
56            .chars()
57            .map(|c| c.len_utf16() as u32)
58            .sum();
59        Position::new(line as u32, character)
60    }
61}
62
63/// Checks if two version strings have the same major and minor version.
64pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
65    if v1.is_empty() || v2.is_empty() {
66        return false;
67    }
68
69    let mut parts1 = v1.split('.');
70    let mut parts2 = v2.split('.');
71
72    if parts1.next() != parts2.next() {
73        return false;
74    }
75
76    match (parts1.next(), parts2.next()) {
77        (Some(m1), Some(m2)) => m1 == m2,
78        _ => true,
79    }
80}
81
82/// Ecosystem-specific formatting and comparison logic.
83pub trait EcosystemFormatter: Send + Sync {
84    /// Normalize package name for lookup (default: identity).
85    fn normalize_package_name(&self, name: &str) -> String {
86        name.to_string()
87    }
88
89    /// Format version string for code action text edit.
90    fn format_version_for_text_edit(&self, version: &str) -> String;
91
92    /// Check if a version satisfies a requirement string.
93    fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool {
94        // Handle caret (^) - allows changes that don't modify left-most non-zero
95        // ^2.0 allows 2.x.x, ^0.2 allows 0.2.x, ^0.0.3 allows only 0.0.3
96        if let Some(req) = requirement.strip_prefix('^') {
97            let req_parts: Vec<&str> = req.split('.').collect();
98            let ver_parts: Vec<&str> = version.split('.').collect();
99
100            // Must have same major version
101            if req_parts.first() != ver_parts.first() {
102                return false;
103            }
104
105            // For ^X.Y where X > 0, any X.*.* is allowed
106            if req_parts.first().is_some_and(|m| *m != "0") {
107                return true;
108            }
109
110            // For ^0.Y, must have same minor
111            if req_parts.len() >= 2 && ver_parts.len() >= 2 {
112                return req_parts[1] == ver_parts[1];
113            }
114
115            return true;
116        }
117
118        // Handle tilde (~) - allows patch-level changes
119        // ~2.0 allows 2.0.x, ~2.0.1 allows 2.0.x where x >= 1
120        if let Some(req) = requirement.strip_prefix('~') {
121            return is_same_major_minor(req, version);
122        }
123
124        // Plain version or partial version
125        let req_parts: Vec<&str> = requirement.split('.').collect();
126        let is_partial_version = req_parts.len() <= 2;
127
128        version == requirement
129            || (is_partial_version && is_same_major_minor(requirement, version))
130            || (is_partial_version && version.starts_with(requirement))
131    }
132
133    /// Get package URL for hover markdown.
134    fn package_url(&self, name: &str) -> String;
135
136    /// Message for yanked/deprecated versions in diagnostics.
137    fn yanked_message(&self) -> &'static str {
138        "This version has been yanked"
139    }
140
141    /// Label for yanked versions in hover.
142    fn yanked_label(&self) -> &'static str {
143        "*(yanked)*"
144    }
145
146    /// Detect if cursor position is on a dependency for code actions.
147    fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
148        dep.version_range()
149            .is_some_and(|r| position_in_range(position, r))
150    }
151}
152
153pub fn generate_inlay_hints(
154    parse_result: &dyn ParseResult,
155    cached_versions: &HashMap<String, String>,
156    resolved_versions: &HashMap<String, String>,
157    loading_state: crate::LoadingState,
158    config: &EcosystemConfig,
159    formatter: &dyn EcosystemFormatter,
160) -> Vec<InlayHint> {
161    let deps = parse_result.dependencies();
162    let mut hints = Vec::with_capacity(deps.len());
163
164    for dep in deps {
165        let Some(version_range) = dep.version_range() else {
166            continue;
167        };
168
169        let normalized_name = formatter.normalize_package_name(dep.name());
170        let latest_version = cached_versions
171            .get(&normalized_name)
172            .or_else(|| cached_versions.get(dep.name()));
173        let resolved_version = resolved_versions
174            .get(&normalized_name)
175            .or_else(|| resolved_versions.get(dep.name()));
176
177        // Show loading hint if loading and no cached version
178        if loading_state == crate::LoadingState::Loading
179            && config.show_loading_hints
180            && latest_version.is_none()
181        {
182            hints.push(InlayHint {
183                position: version_range.end,
184                label: InlayHintLabel::String(config.loading_text.clone()),
185                kind: Some(InlayHintKind::TYPE),
186                tooltip: Some(InlayHintTooltip::String(
187                    "Fetching latest version...".to_string(),
188                )),
189                padding_left: Some(true),
190                padding_right: None,
191                text_edits: None,
192                data: None,
193            });
194            continue;
195        }
196
197        let Some(latest) = latest_version else {
198            if let Some(resolved) = resolved_version
199                && config.show_up_to_date_hints
200            {
201                hints.push(InlayHint {
202                    position: version_range.end,
203                    label: InlayHintLabel::String(format!(
204                        "{} {}",
205                        config.up_to_date_text, resolved
206                    )),
207                    kind: Some(InlayHintKind::TYPE),
208                    padding_left: Some(true),
209                    padding_right: None,
210                    text_edits: None,
211                    tooltip: None,
212                    data: None,
213                });
214            }
215            continue;
216        };
217
218        // Two-tier check for up-to-date status:
219        // 1. If lock file has the dep, check if resolved == latest
220        // 2. If NOT in lock file, check if version requirement is satisfied by latest
221        let is_up_to_date = if let Some(resolved) = resolved_version {
222            resolved.as_str() == latest.as_str()
223        } else {
224            let version_req = dep.version_requirement().unwrap_or("");
225            formatter.version_satisfies_requirement(latest, version_req)
226        };
227
228        let label_text = if is_up_to_date {
229            if config.show_up_to_date_hints {
230                if let Some(resolved) = resolved_version {
231                    format!("{} {}", config.up_to_date_text, resolved)
232                } else {
233                    config.up_to_date_text.clone()
234                }
235            } else {
236                continue;
237            }
238        } else {
239            config.needs_update_text.replace("{}", latest)
240        };
241
242        hints.push(InlayHint {
243            position: version_range.end,
244            label: InlayHintLabel::String(label_text),
245            kind: Some(InlayHintKind::TYPE),
246            padding_left: Some(true),
247            padding_right: None,
248            text_edits: None,
249            tooltip: None,
250            data: None,
251        });
252    }
253
254    hints
255}
256
257pub async fn generate_hover<R: Registry + ?Sized>(
258    parse_result: &dyn ParseResult,
259    position: Position,
260    cached_versions: &HashMap<String, String>,
261    resolved_versions: &HashMap<String, String>,
262    registry: &R,
263    formatter: &dyn EcosystemFormatter,
264) -> Option<Hover> {
265    use std::fmt::Write;
266
267    let dep = parse_result.dependencies().into_iter().find(|d| {
268        let on_name = position_in_range(position, d.name_range());
269        let on_version = d
270            .version_range()
271            .is_some_and(|r| position_in_range(position, r));
272        on_name || on_version
273    })?;
274
275    let versions = registry.get_versions(dep.name()).await.ok()?;
276
277    let url = formatter.package_url(dep.name());
278
279    // Pre-allocate with estimated capacity to reduce allocations
280    let mut markdown = String::with_capacity(512);
281    write!(&mut markdown, "# [{}]({})\n\n", dep.name(), url).unwrap();
282
283    let normalized_name = formatter.normalize_package_name(dep.name());
284
285    let resolved = resolved_versions
286        .get(&normalized_name)
287        .or_else(|| resolved_versions.get(dep.name()));
288    if let Some(resolved_ver) = resolved {
289        write!(&mut markdown, "**Current**: `{}`\n\n", resolved_ver).unwrap();
290    } else if let Some(version_req) = dep.version_requirement() {
291        write!(&mut markdown, "**Requirement**: `{}`\n\n", version_req).unwrap();
292    }
293
294    let latest = cached_versions
295        .get(&normalized_name)
296        .or_else(|| cached_versions.get(dep.name()));
297    if let Some(latest_ver) = latest {
298        write!(&mut markdown, "**Latest**: `{}`\n\n", latest_ver).unwrap();
299    }
300
301    markdown.push_str("**Recent versions**:\n");
302    for (i, version) in versions.iter().take(8).enumerate() {
303        if i == 0 {
304            writeln!(&mut markdown, "- {} *(latest)*", version.version_string()).unwrap();
305        } else if version.is_yanked() {
306            writeln!(
307                &mut markdown,
308                "- {} {}",
309                version.version_string(),
310                formatter.yanked_label()
311            )
312            .unwrap();
313        } else {
314            writeln!(&mut markdown, "- {}", version.version_string()).unwrap();
315        }
316    }
317
318    markdown.push_str("\n---\n⌨️ **Press `Cmd+.` to update version**");
319
320    Some(Hover {
321        contents: HoverContents::Markup(MarkupContent {
322            kind: MarkupKind::Markdown,
323            value: markdown,
324        }),
325        range: Some(dep.name_range()),
326    })
327}
328
329pub async fn generate_code_actions<R: Registry + ?Sized>(
330    parse_result: &dyn ParseResult,
331    position: Position,
332    uri: &Uri,
333    registry: &R,
334    formatter: &dyn EcosystemFormatter,
335) -> Vec<CodeAction> {
336    use crate::completion::prepare_version_display_items;
337
338    let deps = parse_result.dependencies();
339    let mut actions = Vec::with_capacity(deps.len().min(5));
340
341    let Some(dep) = deps
342        .into_iter()
343        .find(|d| formatter.is_position_on_dependency(*d, position))
344    else {
345        return actions;
346    };
347
348    let Some(version_range) = dep.version_range() else {
349        return actions;
350    };
351
352    let Ok(versions) = registry.get_versions(dep.name()).await else {
353        return actions;
354    };
355
356    let display_items = prepare_version_display_items(&versions, dep.name());
357
358    for item in display_items {
359        let new_text = formatter.format_version_for_text_edit(&item.version);
360
361        let mut edits = HashMap::new();
362        edits.insert(
363            uri.clone(),
364            vec![TextEdit {
365                range: version_range,
366                new_text,
367            }],
368        );
369
370        actions.push(CodeAction {
371            title: item.label,
372            kind: Some(CodeActionKind::REFACTOR),
373            edit: Some(WorkspaceEdit {
374                changes: Some(edits),
375                ..Default::default()
376            }),
377            is_preferred: Some(item.is_latest),
378            ..Default::default()
379        });
380    }
381
382    actions
383}
384
385/// Generates diagnostics using cached versions (no network calls).
386///
387/// Uses pre-fetched version information from the lifecycle's parallel fetch.
388/// This avoids making additional network requests during diagnostic generation.
389///
390/// # Arguments
391///
392/// * `parse_result` - Parsed dependencies from manifest
393/// * `cached_versions` - Latest versions from registry (name -> latest version)
394/// * `resolved_versions` - Resolved versions from lock file (name -> installed version)
395/// * `formatter` - Ecosystem-specific formatting and comparison logic
396pub fn generate_diagnostics_from_cache(
397    parse_result: &dyn ParseResult,
398    cached_versions: &HashMap<String, String>,
399    resolved_versions: &HashMap<String, String>,
400    formatter: &dyn EcosystemFormatter,
401) -> Vec<Diagnostic> {
402    let deps = parse_result.dependencies();
403    let mut diagnostics = Vec::with_capacity(deps.len());
404
405    for dep in deps {
406        let normalized_name = formatter.normalize_package_name(dep.name());
407        let latest_version = cached_versions
408            .get(&normalized_name)
409            .or_else(|| cached_versions.get(dep.name()));
410
411        let Some(latest) = latest_version else {
412            // Skip "unknown" diagnostic if package exists in lock file
413            // (registry fetch may have failed due to rate limiting)
414            let in_lockfile = resolved_versions.contains_key(&normalized_name)
415                || resolved_versions.contains_key(dep.name());
416            if !in_lockfile {
417                diagnostics.push(Diagnostic {
418                    range: dep.name_range(),
419                    severity: Some(DiagnosticSeverity::WARNING),
420                    message: format!("Unknown package '{}'", dep.name()),
421                    source: Some("deps-lsp".into()),
422                    ..Default::default()
423                });
424            }
425            continue;
426        };
427
428        let Some(version_range) = dep.version_range() else {
429            continue;
430        };
431
432        let version_req = dep.version_requirement().unwrap_or("");
433        let requirement_allows_latest =
434            formatter.version_satisfies_requirement(latest, version_req);
435
436        if !requirement_allows_latest {
437            diagnostics.push(Diagnostic {
438                range: version_range,
439                severity: Some(DiagnosticSeverity::HINT),
440                message: format!("Newer version available: {}", latest),
441                source: Some("deps-lsp".into()),
442                ..Default::default()
443            });
444        }
445    }
446
447    diagnostics
448}
449
450/// Generates diagnostics by fetching from registry (makes network calls).
451///
452/// **Warning**: This function makes network requests for each dependency.
453/// Prefer `generate_diagnostics_from_cache` when cached versions are available.
454#[allow(dead_code)]
455pub async fn generate_diagnostics<R: Registry + ?Sized>(
456    parse_result: &dyn ParseResult,
457    registry: &R,
458    formatter: &dyn EcosystemFormatter,
459) -> Vec<Diagnostic> {
460    let deps = parse_result.dependencies();
461    let mut diagnostics = Vec::with_capacity(deps.len());
462
463    for dep in deps {
464        let versions = match registry.get_versions(dep.name()).await {
465            Ok(v) => v,
466            Err(_) => {
467                diagnostics.push(Diagnostic {
468                    range: dep.name_range(),
469                    severity: Some(DiagnosticSeverity::WARNING),
470                    message: format!("Unknown package '{}'", dep.name()),
471                    source: Some("deps-lsp".into()),
472                    ..Default::default()
473                });
474                continue;
475            }
476        };
477
478        let Some(version_req) = dep.version_requirement() else {
479            continue;
480        };
481        let Some(version_range) = dep.version_range() else {
482            continue;
483        };
484
485        let matching = registry
486            .get_latest_matching(dep.name(), version_req)
487            .await
488            .ok()
489            .flatten();
490
491        if let Some(current) = matching {
492            if current.is_yanked() {
493                diagnostics.push(Diagnostic {
494                    range: version_range,
495                    severity: Some(DiagnosticSeverity::WARNING),
496                    message: formatter.yanked_message().into(),
497                    source: Some("deps-lsp".into()),
498                    ..Default::default()
499                });
500            }
501
502            let latest = crate::registry::find_latest_stable(&versions);
503            if let Some(latest) = latest
504                && latest.version_string() != current.version_string()
505            {
506                diagnostics.push(Diagnostic {
507                    range: version_range,
508                    severity: Some(DiagnosticSeverity::HINT),
509                    message: format!("Newer version available: {}", latest.version_string()),
510                    source: Some("deps-lsp".into()),
511                    ..Default::default()
512                });
513            }
514        }
515    }
516
517    diagnostics
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use std::any::Any;
524
525    #[test]
526    fn test_position_in_range_inside() {
527        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
528        let position = Position::new(5, 15);
529        assert!(position_in_range(position, range));
530    }
531
532    #[test]
533    fn test_position_in_range_at_start() {
534        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
535        let position = Position::new(5, 10);
536        assert!(position_in_range(position, range));
537    }
538
539    #[test]
540    fn test_position_in_range_at_end() {
541        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
542        let position = Position::new(5, 20);
543        assert!(position_in_range(position, range));
544    }
545
546    #[test]
547    fn test_position_in_range_before() {
548        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
549        let position = Position::new(5, 5);
550        assert!(!position_in_range(position, range));
551    }
552
553    #[test]
554    fn test_position_in_range_after() {
555        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
556        let position = Position::new(5, 25);
557        assert!(!position_in_range(position, range));
558    }
559
560    #[test]
561    fn test_position_in_range_different_line_before() {
562        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
563        let position = Position::new(4, 15);
564        assert!(!position_in_range(position, range));
565    }
566
567    #[test]
568    fn test_position_in_range_different_line_after() {
569        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
570        let position = Position::new(6, 15);
571        assert!(!position_in_range(position, range));
572    }
573
574    #[test]
575    fn test_position_in_range_multiline() {
576        let range = Range::new(Position::new(5, 10), Position::new(7, 5));
577        let position = Position::new(6, 0);
578        assert!(position_in_range(position, range));
579    }
580
581    #[test]
582    fn test_is_same_major_minor_full_match() {
583        assert!(is_same_major_minor("1.2.3", "1.2.9"));
584    }
585
586    #[test]
587    fn test_is_same_major_minor_exact_match() {
588        assert!(is_same_major_minor("1.2.3", "1.2.3"));
589    }
590
591    #[test]
592    fn test_is_same_major_minor_major_only_match() {
593        assert!(is_same_major_minor("1", "1.2.3"));
594        assert!(is_same_major_minor("1.2.3", "1"));
595    }
596
597    #[test]
598    fn test_is_same_major_minor_no_match_different_minor() {
599        assert!(!is_same_major_minor("1.2.3", "1.3.0"));
600    }
601
602    #[test]
603    fn test_is_same_major_minor_no_match_different_major() {
604        assert!(!is_same_major_minor("1.2.3", "2.2.3"));
605    }
606
607    #[test]
608    fn test_is_same_major_minor_empty_strings() {
609        assert!(!is_same_major_minor("", ""));
610        assert!(!is_same_major_minor("1.2.3", ""));
611        assert!(!is_same_major_minor("", "1.2.3"));
612    }
613
614    #[test]
615    fn test_is_same_major_minor_partial_versions() {
616        assert!(is_same_major_minor("1.2", "1.2.3"));
617        assert!(is_same_major_minor("1.2.3", "1.2"));
618    }
619
620    struct MockFormatter;
621
622    impl EcosystemFormatter for MockFormatter {
623        fn format_version_for_text_edit(&self, version: &str) -> String {
624            format!("\"{}\"", version)
625        }
626
627        fn package_url(&self, name: &str) -> String {
628            format!("https://example.com/{}", name)
629        }
630    }
631
632    struct MockParseResult {
633        deps: Vec<MockDep>,
634        uri: Uri,
635    }
636
637    impl ParseResult for MockParseResult {
638        fn dependencies(&self) -> Vec<&dyn Dependency> {
639            self.deps.iter().map(|d| d as &dyn Dependency).collect()
640        }
641        fn workspace_root(&self) -> Option<&std::path::Path> {
642            None
643        }
644        fn uri(&self) -> &Uri {
645            &self.uri
646        }
647        fn as_any(&self) -> &dyn Any {
648            self
649        }
650    }
651
652    struct MockDep {
653        name: String,
654        version_req: String,
655        version_range: Range,
656        name_range: Range,
657    }
658
659    impl Dependency for MockDep {
660        fn name(&self) -> &str {
661            &self.name
662        }
663        fn name_range(&self) -> Range {
664            self.name_range
665        }
666        fn version_requirement(&self) -> Option<&str> {
667            Some(&self.version_req)
668        }
669        fn version_range(&self) -> Option<Range> {
670            Some(self.version_range)
671        }
672        fn source(&self) -> crate::parser::DependencySource {
673            crate::parser::DependencySource::Registry
674        }
675        fn as_any(&self) -> &dyn Any {
676            self
677        }
678    }
679
680    #[test]
681    fn test_ecosystem_formatter_defaults() {
682        let formatter = MockFormatter;
683        assert_eq!(formatter.normalize_package_name("test-pkg"), "test-pkg");
684        assert_eq!(formatter.yanked_message(), "This version has been yanked");
685        assert_eq!(formatter.yanked_label(), "*(yanked)*");
686    }
687
688    #[test]
689    fn test_ecosystem_formatter_version_satisfies() {
690        let formatter = MockFormatter;
691
692        assert!(formatter.version_satisfies_requirement("1.2.3", "1.2.3"));
693
694        assert!(formatter.version_satisfies_requirement("1.2.3", "^1.2"));
695        assert!(formatter.version_satisfies_requirement("1.2.3", "~1.2"));
696
697        assert!(formatter.version_satisfies_requirement("1.2.3", "1"));
698        assert!(formatter.version_satisfies_requirement("1.2.3", "1.2"));
699
700        assert!(!formatter.version_satisfies_requirement("1.2.3", "2.0.0"));
701        assert!(!formatter.version_satisfies_requirement("1.2.3", "1.3"));
702    }
703
704    #[test]
705    fn test_ecosystem_formatter_custom_normalize() {
706        struct PyPIFormatter;
707
708        impl EcosystemFormatter for PyPIFormatter {
709            fn normalize_package_name(&self, name: &str) -> String {
710                name.to_lowercase().replace('-', "_")
711            }
712
713            fn format_version_for_text_edit(&self, version: &str) -> String {
714                format!(
715                    ">={},<{}",
716                    version,
717                    version.split('.').next().unwrap_or("0")
718                )
719            }
720
721            fn package_url(&self, name: &str) -> String {
722                format!("https://pypi.org/project/{}", name)
723            }
724        }
725
726        let formatter = PyPIFormatter;
727        assert_eq!(
728            formatter.normalize_package_name("Test-Package"),
729            "test_package"
730        );
731        assert_eq!(
732            formatter.format_version_for_text_edit("1.2.3"),
733            ">=1.2.3,<1"
734        );
735        assert_eq!(
736            formatter.package_url("requests"),
737            "https://pypi.org/project/requests"
738        );
739    }
740
741    #[test]
742    fn test_inlay_hint_exact_version_shows_update_needed() {
743        use std::collections::HashMap;
744        use tower_lsp_server::ls_types::{Position, Range, Uri};
745
746        let formatter = MockFormatter;
747        let config = EcosystemConfig {
748            show_up_to_date_hints: true,
749            up_to_date_text: "✅".to_string(),
750            needs_update_text: "❌ {}".to_string(),
751            loading_text: "⏳".to_string(),
752            show_loading_hints: true,
753        };
754
755        let parse_result = MockParseResult {
756            deps: vec![MockDep {
757                name: "serde".to_string(),
758                version_req: "=2.0.12".to_string(),
759                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
760                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
761            }],
762            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
763        };
764
765        let mut cached_versions = HashMap::new();
766        cached_versions.insert("serde".to_string(), "2.1.1".to_string());
767
768        let mut resolved_versions = HashMap::new();
769        resolved_versions.insert("serde".to_string(), "2.0.12".to_string());
770
771        let hints = generate_inlay_hints(
772            &parse_result,
773            &cached_versions,
774            &resolved_versions,
775            crate::LoadingState::Loaded,
776            &config,
777            &formatter,
778        );
779
780        assert_eq!(hints.len(), 1);
781        match &hints[0].label {
782            InlayHintLabel::String(text) => {
783                assert_eq!(text, "❌ 2.1.1");
784            }
785            _ => panic!("Expected string label"),
786        }
787    }
788
789    #[test]
790    fn test_inlay_hint_caret_version_up_to_date() {
791        use std::collections::HashMap;
792        use tower_lsp_server::ls_types::{Position, Range, Uri};
793
794        let formatter = MockFormatter;
795        let config = EcosystemConfig {
796            show_up_to_date_hints: true,
797            up_to_date_text: "✅".to_string(),
798            needs_update_text: "❌ {}".to_string(),
799            loading_text: "⏳".to_string(),
800            show_loading_hints: true,
801        };
802
803        let parse_result = MockParseResult {
804            deps: vec![MockDep {
805                name: "serde".to_string(),
806                version_req: "^2.0".to_string(),
807                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
808                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
809            }],
810            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
811        };
812
813        let mut cached_versions = HashMap::new();
814        cached_versions.insert("serde".to_string(), "2.1.1".to_string());
815
816        let mut resolved_versions = HashMap::new();
817        resolved_versions.insert("serde".to_string(), "2.1.1".to_string());
818
819        let hints = generate_inlay_hints(
820            &parse_result,
821            &cached_versions,
822            &resolved_versions,
823            crate::LoadingState::Loaded,
824            &config,
825            &formatter,
826        );
827
828        assert_eq!(hints.len(), 1);
829        match &hints[0].label {
830            InlayHintLabel::String(text) => {
831                assert!(
832                    text.starts_with("✅"),
833                    "Expected up-to-date hint, got: {}",
834                    text
835                );
836            }
837            _ => panic!("Expected string label"),
838        }
839    }
840
841    #[test]
842    fn test_loading_hint_shows_when_no_cached_version() {
843        use std::collections::HashMap;
844        use tower_lsp_server::ls_types::{Position, Range, Uri};
845
846        let formatter = MockFormatter;
847        let config = EcosystemConfig {
848            show_up_to_date_hints: true,
849            up_to_date_text: "✅".to_string(),
850            needs_update_text: "❌ {}".to_string(),
851            loading_text: "⏳".to_string(),
852            show_loading_hints: true,
853        };
854
855        let parse_result = MockParseResult {
856            deps: vec![MockDep {
857                name: "tokio".to_string(),
858                version_req: "1.0".to_string(),
859                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
860                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
861            }],
862            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
863        };
864
865        let cached_versions = HashMap::new();
866        let resolved_versions = HashMap::new();
867
868        let hints = generate_inlay_hints(
869            &parse_result,
870            &cached_versions,
871            &resolved_versions,
872            crate::LoadingState::Loading,
873            &config,
874            &formatter,
875        );
876
877        assert_eq!(hints.len(), 1);
878        match &hints[0].label {
879            InlayHintLabel::String(text) => {
880                assert_eq!(text, "⏳", "Expected loading hint");
881            }
882            _ => panic!("Expected string label"),
883        }
884
885        if let Some(InlayHintTooltip::String(tooltip)) = &hints[0].tooltip {
886            assert_eq!(tooltip, "Fetching latest version...");
887        } else {
888            panic!("Expected tooltip");
889        }
890    }
891
892    #[test]
893    fn test_loading_hint_disabled_when_config_false() {
894        use std::collections::HashMap;
895        use tower_lsp_server::ls_types::{Position, Range, Uri};
896
897        let formatter = MockFormatter;
898        let config = EcosystemConfig {
899            show_up_to_date_hints: true,
900            up_to_date_text: "✅".to_string(),
901            needs_update_text: "❌ {}".to_string(),
902            loading_text: "⏳".to_string(),
903            show_loading_hints: false,
904        };
905
906        let parse_result = MockParseResult {
907            deps: vec![MockDep {
908                name: "tokio".to_string(),
909                version_req: "1.0".to_string(),
910                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
911                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
912            }],
913            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
914        };
915
916        let cached_versions = HashMap::new();
917        let resolved_versions = HashMap::new();
918
919        let hints = generate_inlay_hints(
920            &parse_result,
921            &cached_versions,
922            &resolved_versions,
923            crate::LoadingState::Loading,
924            &config,
925            &formatter,
926        );
927
928        assert_eq!(
929            hints.len(),
930            0,
931            "Expected no hints when loading hints disabled"
932        );
933    }
934
935    #[test]
936    fn test_caret_version_0x_edge_cases() {
937        let formatter = MockFormatter;
938
939        // ^0.2 should only allow 0.2.x
940        assert!(formatter.version_satisfies_requirement("0.2.0", "^0.2"));
941        assert!(formatter.version_satisfies_requirement("0.2.5", "^0.2"));
942        assert!(formatter.version_satisfies_requirement("0.2.99", "^0.2"));
943
944        // ^0.2 should NOT allow 0.3.x or 0.1.x
945        assert!(!formatter.version_satisfies_requirement("0.3.0", "^0.2"));
946        assert!(!formatter.version_satisfies_requirement("0.1.0", "^0.2"));
947        assert!(!formatter.version_satisfies_requirement("1.0.0", "^0.2"));
948
949        // ^0.0.3 should only allow 0.0.3 (left-most non-zero is patch)
950        assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0.3"));
951        assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0"));
952
953        // ^0 should only allow 0.x.y (major is 0)
954        assert!(formatter.version_satisfies_requirement("0.0.0", "^0"));
955        assert!(formatter.version_satisfies_requirement("0.5.0", "^0"));
956        assert!(!formatter.version_satisfies_requirement("1.0.0", "^0"));
957    }
958
959    #[test]
960    fn test_caret_version_non_zero_major() {
961        let formatter = MockFormatter;
962
963        // ^1.2 allows any 1.x.x
964        assert!(formatter.version_satisfies_requirement("1.0.0", "^1.2"));
965        assert!(formatter.version_satisfies_requirement("1.2.0", "^1.2"));
966        assert!(formatter.version_satisfies_requirement("1.9.9", "^1.2"));
967
968        // ^1.2 should NOT allow 2.x.x
969        assert!(!formatter.version_satisfies_requirement("2.0.0", "^1.2"));
970        assert!(!formatter.version_satisfies_requirement("0.9.0", "^1.2"));
971    }
972
973    #[test]
974    fn test_loading_hint_not_shown_when_cached_version_exists() {
975        use std::collections::HashMap;
976        use tower_lsp_server::ls_types::{Position, Range, Uri};
977
978        let formatter = MockFormatter;
979        let config = EcosystemConfig {
980            show_up_to_date_hints: true,
981            up_to_date_text: "✅".to_string(),
982            needs_update_text: "❌ {}".to_string(),
983            loading_text: "⏳".to_string(),
984            show_loading_hints: true,
985        };
986
987        let parse_result = MockParseResult {
988            deps: vec![MockDep {
989                name: "serde".to_string(),
990                version_req: "1.0".to_string(),
991                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
992                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
993            }],
994            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
995        };
996
997        let mut cached_versions = HashMap::new();
998        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
999
1000        // Lock file has the latest version
1001        let mut resolved_versions = HashMap::new();
1002        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
1003
1004        let hints = generate_inlay_hints(
1005            &parse_result,
1006            &cached_versions,
1007            &resolved_versions,
1008            crate::LoadingState::Loading,
1009            &config,
1010            &formatter,
1011        );
1012
1013        assert_eq!(hints.len(), 1);
1014        match &hints[0].label {
1015            InlayHintLabel::String(text) => {
1016                assert_eq!(
1017                    text, "✅ 1.0.214",
1018                    "Expected up-to-date hint, not loading hint, got: {}",
1019                    text
1020                );
1021            }
1022            _ => panic!("Expected string label"),
1023        }
1024    }
1025
1026    #[test]
1027    fn test_generate_diagnostics_from_cache_unknown_package() {
1028        use std::collections::HashMap;
1029        use tower_lsp_server::ls_types::{Position, Range, Uri};
1030
1031        let formatter = MockFormatter;
1032
1033        let parse_result = MockParseResult {
1034            deps: vec![MockDep {
1035                name: "unknown-pkg".to_string(),
1036                version_req: "1.0.0".to_string(),
1037                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1038                name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1039            }],
1040            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1041        };
1042
1043        let cached_versions = HashMap::new();
1044        let resolved_versions = HashMap::new();
1045
1046        let diagnostics = generate_diagnostics_from_cache(
1047            &parse_result,
1048            &cached_versions,
1049            &resolved_versions,
1050            &formatter,
1051        );
1052
1053        assert_eq!(diagnostics.len(), 1);
1054        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
1055        assert!(diagnostics[0].message.contains("Unknown package"));
1056        assert!(diagnostics[0].message.contains("unknown-pkg"));
1057    }
1058
1059    #[test]
1060    fn test_generate_diagnostics_from_cache_outdated_version() {
1061        use std::collections::HashMap;
1062        use tower_lsp_server::ls_types::{Position, Range, Uri};
1063
1064        let formatter = MockFormatter;
1065
1066        let parse_result = MockParseResult {
1067            deps: vec![MockDep {
1068                name: "serde".to_string(),
1069                version_req: "1.0".to_string(),
1070                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1071                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1072            }],
1073            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1074        };
1075
1076        let mut cached_versions = HashMap::new();
1077        cached_versions.insert("serde".to_string(), "2.0.0".to_string());
1078
1079        let resolved_versions = HashMap::new();
1080
1081        let diagnostics = generate_diagnostics_from_cache(
1082            &parse_result,
1083            &cached_versions,
1084            &resolved_versions,
1085            &formatter,
1086        );
1087
1088        assert_eq!(diagnostics.len(), 1);
1089        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
1090        assert!(diagnostics[0].message.contains("Newer version available"));
1091        assert!(diagnostics[0].message.contains("2.0.0"));
1092    }
1093
1094    #[test]
1095    fn test_generate_diagnostics_from_cache_up_to_date() {
1096        use std::collections::HashMap;
1097        use tower_lsp_server::ls_types::{Position, Range, Uri};
1098
1099        let formatter = MockFormatter;
1100
1101        let parse_result = MockParseResult {
1102            deps: vec![MockDep {
1103                name: "serde".to_string(),
1104                version_req: "^1.0".to_string(),
1105                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1106                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1107            }],
1108            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1109        };
1110
1111        let mut cached_versions = HashMap::new();
1112        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
1113
1114        let resolved_versions = HashMap::new();
1115
1116        let diagnostics = generate_diagnostics_from_cache(
1117            &parse_result,
1118            &cached_versions,
1119            &resolved_versions,
1120            &formatter,
1121        );
1122
1123        assert!(
1124            diagnostics.is_empty(),
1125            "Expected no diagnostics for up-to-date dependency"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_generate_diagnostics_from_cache_multiple_deps() {
1131        use std::collections::HashMap;
1132        use tower_lsp_server::ls_types::{Position, Range, Uri};
1133
1134        let formatter = MockFormatter;
1135
1136        let parse_result = MockParseResult {
1137            deps: vec![
1138                MockDep {
1139                    name: "serde".to_string(),
1140                    version_req: "^1.0".to_string(),
1141                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1142                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1143                },
1144                MockDep {
1145                    name: "tokio".to_string(),
1146                    version_req: "1.0".to_string(),
1147                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1148                    name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
1149                },
1150                MockDep {
1151                    name: "unknown".to_string(),
1152                    version_req: "1.0".to_string(),
1153                    version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
1154                    name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
1155                },
1156            ],
1157            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1158        };
1159
1160        let mut cached_versions = HashMap::new();
1161        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
1162        cached_versions.insert("tokio".to_string(), "2.0.0".to_string());
1163
1164        let resolved_versions = HashMap::new();
1165
1166        let diagnostics = generate_diagnostics_from_cache(
1167            &parse_result,
1168            &cached_versions,
1169            &resolved_versions,
1170            &formatter,
1171        );
1172
1173        assert_eq!(diagnostics.len(), 2);
1174
1175        let has_outdated = diagnostics
1176            .iter()
1177            .any(|d| d.message.contains("Newer version"));
1178        let has_unknown = diagnostics
1179            .iter()
1180            .any(|d| d.message.contains("Unknown package"));
1181
1182        assert!(has_outdated, "Expected outdated version diagnostic");
1183        assert!(has_unknown, "Expected unknown package diagnostic");
1184    }
1185
1186    #[test]
1187    fn test_inlay_hint_not_in_lockfile_but_satisfies_requirement() {
1188        use std::collections::HashMap;
1189        use tower_lsp_server::ls_types::{Position, Range, Uri};
1190
1191        let formatter = MockFormatter;
1192        let config = EcosystemConfig {
1193            show_up_to_date_hints: true,
1194            up_to_date_text: "✅".to_string(),
1195            needs_update_text: "❌ {}".to_string(),
1196            loading_text: "⏳".to_string(),
1197            show_loading_hints: true,
1198        };
1199
1200        let parse_result = MockParseResult {
1201            deps: vec![MockDep {
1202                name: "criterion".to_string(),
1203                version_req: "0.5".to_string(),
1204                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1205                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
1206            }],
1207            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1208        };
1209
1210        let mut cached_versions = HashMap::new();
1211        cached_versions.insert("criterion".to_string(), "0.5.1".to_string());
1212
1213        // Not in lock file (empty resolved_versions)
1214        let resolved_versions = HashMap::new();
1215
1216        let hints = generate_inlay_hints(
1217            &parse_result,
1218            &cached_versions,
1219            &resolved_versions,
1220            crate::LoadingState::Loaded,
1221            &config,
1222            &formatter,
1223        );
1224
1225        assert_eq!(hints.len(), 1);
1226        match &hints[0].label {
1227            InlayHintLabel::String(text) => {
1228                assert!(
1229                    text.starts_with("✅"),
1230                    "Expected up-to-date hint for satisfied requirement, got: {}",
1231                    text
1232                );
1233            }
1234            _ => panic!("Expected string label"),
1235        }
1236    }
1237
1238    #[test]
1239    fn test_inlay_hint_not_in_lockfile_and_outdated() {
1240        use std::collections::HashMap;
1241        use tower_lsp_server::ls_types::{Position, Range, Uri};
1242
1243        let formatter = MockFormatter;
1244        let config = EcosystemConfig {
1245            show_up_to_date_hints: true,
1246            up_to_date_text: "✅".to_string(),
1247            needs_update_text: "❌ {}".to_string(),
1248            loading_text: "⏳".to_string(),
1249            show_loading_hints: true,
1250        };
1251
1252        let parse_result = MockParseResult {
1253            deps: vec![MockDep {
1254                name: "criterion".to_string(),
1255                version_req: "0.4".to_string(),
1256                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1257                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
1258            }],
1259            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1260        };
1261
1262        let mut cached_versions = HashMap::new();
1263        cached_versions.insert("criterion".to_string(), "0.5.1".to_string());
1264
1265        // Not in lock file (empty resolved_versions)
1266        let resolved_versions = HashMap::new();
1267
1268        let hints = generate_inlay_hints(
1269            &parse_result,
1270            &cached_versions,
1271            &resolved_versions,
1272            crate::LoadingState::Loaded,
1273            &config,
1274            &formatter,
1275        );
1276
1277        assert_eq!(hints.len(), 1);
1278        match &hints[0].label {
1279            InlayHintLabel::String(text) => {
1280                assert!(
1281                    text.starts_with("❌"),
1282                    "Expected needs-update hint for unsatisfied requirement, got: {}",
1283                    text
1284                );
1285                assert!(text.contains("0.5.1"), "Expected latest version in hint");
1286            }
1287            _ => panic!("Expected string label"),
1288        }
1289    }
1290}