1use 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
12pub 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
26pub struct LineOffsetTable {
32 line_starts: Vec<usize>,
33}
34
35impl LineOffsetTable {
36 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 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
63pub 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
82pub trait EcosystemFormatter: Send + Sync {
84 fn normalize_package_name(&self, name: &str) -> String {
86 name.to_string()
87 }
88
89 fn format_version_for_text_edit(&self, version: &str) -> String;
91
92 fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool {
94 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 if req_parts.first() != ver_parts.first() {
102 return false;
103 }
104
105 if req_parts.first().is_some_and(|m| *m != "0") {
107 return true;
108 }
109
110 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 if let Some(req) = requirement.strip_prefix('~') {
121 return is_same_major_minor(req, version);
122 }
123
124 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 fn package_url(&self, name: &str) -> String;
135
136 fn yanked_message(&self) -> &'static str {
138 "This version has been yanked"
139 }
140
141 fn yanked_label(&self) -> &'static str {
143 "*(yanked)*"
144 }
145
146 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 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 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 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
385pub 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 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#[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 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 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 assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0.3"));
951 assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0"));
952
953 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 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 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 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 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 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}