1use crate::config::DepsConfig;
6use crate::document::{ServerState, ensure_document_loaded};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9use tower_lsp_server::Client;
10use tower_lsp_server::ls_types::{
11 CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse, InsertTextFormat,
12};
13
14pub async fn handle_completion(
19 state: Arc<ServerState>,
20 params: CompletionParams,
21 client: Client,
22 config: Arc<RwLock<DepsConfig>>,
23) -> Option<CompletionResponse> {
24 let uri = ¶ms.text_document_position.text_document.uri;
25 let position = params.text_document_position.position;
26
27 tracing::info!(
28 "completion request: uri={:?}, line={}, character={}",
29 uri,
30 position.line,
31 position.character
32 );
33
34 if state.get_document(uri).is_none() {
37 tracing::info!("completion: document not loaded, loading from disk");
38
39 let load_result = tokio::time::timeout(
41 std::time::Duration::from_millis(200),
42 ensure_document_loaded(uri, Arc::clone(&state), client.clone(), Arc::clone(&config)),
43 )
44 .await;
45
46 match load_result {
47 Ok(true) => {
48 tracing::debug!("completion: document loaded successfully");
50 }
51 Ok(false) | Err(_) => {
52 tracing::warn!("completion: document load failed or timed out");
54 return Some(CompletionResponse::Array(vec![]));
55 }
56 }
57 }
58
59 let doc = match state.get_document(uri) {
61 Some(d) => d,
62 None => {
63 tracing::warn!("completion: document not found: {:?}", uri);
64 return None;
65 }
66 };
67 let ecosystem_id = doc.ecosystem_id;
68 let content = doc.content.clone();
69 let has_parse_result = doc.parse_result().is_some();
70 drop(doc);
71
72 tracing::info!(
73 "completion: ecosystem={}, has_parse_result={}",
74 ecosystem_id,
75 has_parse_result
76 );
77
78 let items = if has_parse_result {
80 let doc = state.get_document(uri)?;
82 let parse_result = doc.parse_result()?;
83 let ecosystem = state.ecosystem_registry.get(ecosystem_id)?;
84 let completions = ecosystem
85 .generate_completions(parse_result, position, &content)
86 .await;
87 drop(doc);
88
89 if completions.is_empty() {
92 tracing::info!("completion: ecosystem returned empty, trying fallback");
93 fallback_completion(&state, ecosystem_id, position, &content).await
94 } else {
95 completions
96 }
97 } else {
98 fallback_completion(&state, ecosystem_id, position, &content).await
100 };
101
102 tracing::info!("completion: returning {} items", items.len());
103
104 if items.is_empty() {
105 None
106 } else {
107 Some(CompletionResponse::Array(items))
108 }
109}
110
111async fn fallback_completion(
115 state: &ServerState,
116 ecosystem_id: &str,
117 position: tower_lsp_server::ls_types::Position,
118 content: &str,
119) -> Vec<CompletionItem> {
120 tracing::info!(
121 "fallback_completion: starting for ecosystem={}",
122 ecosystem_id
123 );
124
125 let line = match content.lines().nth(position.line as usize) {
127 Some(l) => l,
128 None => {
129 tracing::info!("fallback_completion: line {} not found", position.line);
130 return vec![];
131 }
132 };
133
134 tracing::info!("fallback_completion: line content = {:?}", line);
135
136 if !is_in_dependencies_section(content, position.line as usize, ecosystem_id) {
138 tracing::info!("fallback_completion: not in dependencies section");
139 return vec![];
140 }
141
142 let prefix_end = std::cmp::min(position.character as usize, line.len());
144 let prefix = &line[..prefix_end];
145 let prefix = prefix.trim();
146
147 tracing::info!("fallback_completion: prefix = {:?}", prefix);
148
149 if prefix.is_empty() || prefix.contains('=') || prefix.len() < 2 {
151 tracing::info!("fallback_completion: prefix rejected (empty, contains =, or < 2 chars)");
152 return vec![];
153 }
154
155 let ecosystem = match state.ecosystem_registry.get(ecosystem_id) {
157 Some(e) => e,
158 None => return vec![],
159 };
160
161 let registry = ecosystem.registry();
162
163 search_packages(registry.as_ref(), ecosystem_id, prefix).await
165}
166
167fn is_in_dependencies_section(content: &str, line_number: usize, ecosystem_id: &str) -> bool {
169 match ecosystem_id {
170 "cargo" | "pypi" => is_in_toml_dependencies(content, line_number),
171 "npm" => is_in_json_dependencies(content, line_number),
172 _ => false,
173 }
174}
175
176fn is_in_toml_dependencies(content: &str, line_number: usize) -> bool {
181 let lines: Vec<_> = content.lines().enumerate().take(line_number + 1).collect();
184
185 for (_, line) in lines.iter().rev() {
186 let line = line.trim();
187
188 if line.starts_with('[') && line.ends_with(']') {
190 return line == "[dependencies]"
192 || line == "[dev-dependencies]"
193 || line == "[build-dependencies]"
194 || line == "[workspace.dependencies]"
195 || line == "[project.dependencies]"
196 || line == "[project.optional-dependencies]"
197 || line.starts_with("[target.")
198 && (line.contains(".dependencies]")
199 || line.contains(".dev-dependencies]")
200 || line.contains(".build-dependencies]"));
201 }
202 }
203
204 false
205}
206
207fn is_in_json_dependencies(content: &str, line_number: usize) -> bool {
211 let mut in_dependencies = false;
212 let mut brace_depth = 0;
213
214 for (i, line) in content.lines().enumerate() {
216 if i > line_number {
218 break;
219 }
220
221 let trimmed = line.trim();
222
223 if trimmed.starts_with('"')
225 && (trimmed.contains("\"dependencies\":")
226 || trimmed.contains("\"devDependencies\":")
227 || trimmed.contains("\"peerDependencies\":")
228 || trimmed.contains("\"optionalDependencies\":"))
229 {
230 in_dependencies = true;
231 brace_depth = 0;
232 }
233
234 if in_dependencies {
236 for ch in trimmed.chars() {
237 match ch {
238 '{' => brace_depth += 1,
239 '}' => {
240 brace_depth -= 1;
241 if brace_depth <= 0 {
243 in_dependencies = false;
244 }
245 }
246 _ => {}
247 }
248 }
249
250 if i == line_number && in_dependencies && brace_depth > 0 {
252 return true;
253 }
254 }
255 }
256
257 false
258}
259
260async fn search_packages(
262 registry: &dyn deps_core::Registry,
263 ecosystem_id: &str,
264 query: &str,
265) -> Vec<CompletionItem> {
266 tracing::info!(
267 "search_packages: query={:?}, ecosystem={}",
268 query,
269 ecosystem_id
270 );
271
272 let results = match registry.search(query, 50).await {
274 Ok(r) => {
275 tracing::info!("search_packages: found {} results", r.len());
276 r
277 }
278 Err(e) => {
279 tracing::warn!("search_packages: search failed: {}", e);
280 return vec![];
281 }
282 };
283
284 results
286 .iter()
287 .map(|metadata| create_package_completion_item(metadata.as_ref(), ecosystem_id))
288 .collect()
289}
290
291fn create_package_completion_item(
293 metadata: &dyn deps_core::Metadata,
294 ecosystem_id: &str,
295) -> CompletionItem {
296 let name = metadata.name();
297 let latest = metadata.latest_version();
298 let description = metadata.description();
299
300 let insert_text = match ecosystem_id {
302 "cargo" | "pypi" => format!("{name} = \"{latest}\""),
303 "npm" => format!("\"{name}\": \"^{latest}\""),
304 _ => format!("{name} = \"{latest}\""),
305 };
306
307 let detail = format!("Latest: {latest}");
309
310 CompletionItem {
311 label: name.to_string(),
312 kind: Some(CompletionItemKind::MODULE),
313 detail: Some(detail),
314 documentation: description
315 .map(|d| tower_lsp_server::ls_types::Documentation::String(d.into())),
316 insert_text: Some(insert_text),
317 insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
318 ..Default::default()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::document::DocumentState;
326 use crate::test_utils::test_helpers::create_test_client_and_config;
327 use tower_lsp_server::ls_types::{
328 Position, TextDocumentIdentifier, TextDocumentPositionParams, Uri,
329 };
330
331 #[tokio::test]
332 async fn test_completion_returns_empty_for_missing_document() {
333 let state = Arc::new(ServerState::new());
334 let uri = Uri::from_file_path("/test/Cargo.toml").unwrap();
335
336 let params = CompletionParams {
337 text_document_position: TextDocumentPositionParams {
338 text_document: TextDocumentIdentifier { uri },
339 position: Position::new(0, 0),
340 },
341 work_done_progress_params: Default::default(),
342 partial_result_params: Default::default(),
343 context: None,
344 };
345
346 let (client, config) = create_test_client_and_config();
347 let result = handle_completion(state, params, client, config).await;
348 assert!(matches!(result, Some(CompletionResponse::Array(items)) if items.is_empty()));
351 }
352
353 #[tokio::test]
354 async fn test_completion_delegates_to_ecosystem() {
355 let state = Arc::new(ServerState::new());
356 let uri = Uri::from_file_path("/test/Cargo.toml").unwrap();
357
358 let content = "[dependencies]\nserde = \"1.0\"".to_string();
359
360 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
362 let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
363
364 let doc = DocumentState::new_from_parse_result("cargo", content, parse_result);
365 state.update_document(uri.clone(), doc);
366
367 let params = CompletionParams {
368 text_document_position: TextDocumentPositionParams {
369 text_document: TextDocumentIdentifier { uri },
370 position: Position::new(1, 9),
371 },
372 work_done_progress_params: Default::default(),
373 partial_result_params: Default::default(),
374 context: None,
375 };
376
377 let (client, config) = create_test_client_and_config();
380 let _result = handle_completion(state, params, client, config).await;
381 }
383
384 #[test]
385 fn test_is_in_toml_dependencies_basic() {
386 let content = r#"
387[package]
388name = "test"
389
390[dependencies]
391serde
392"#;
393 assert!(is_in_toml_dependencies(content, 5));
394 assert!(!is_in_toml_dependencies(content, 1));
395 }
396
397 #[test]
398 fn test_is_in_toml_dependencies_dev_deps() {
399 let content = r"
400[dev-dependencies]
401tokio
402";
403 assert!(is_in_toml_dependencies(content, 2));
404 }
405
406 #[test]
407 fn test_is_in_toml_dependencies_build_deps() {
408 let content = r"
409[build-dependencies]
410cc
411";
412 assert!(is_in_toml_dependencies(content, 2));
413 }
414
415 #[test]
416 fn test_is_in_toml_dependencies_project_deps() {
417 let content = r"
418[project.dependencies]
419requests
420";
421 assert!(is_in_toml_dependencies(content, 2));
422 }
423
424 #[test]
425 fn test_is_in_toml_dependencies_workspace_deps() {
426 let content = r#"
427[workspace.dependencies]
428serde = "1.0"
429"#;
430 assert!(is_in_toml_dependencies(content, 2));
431 }
432
433 #[test]
434 fn test_is_in_toml_dependencies_target_specific() {
435 let content = r"
436[target.'cfg(windows)'.dependencies]
437winapi
438";
439 assert!(is_in_toml_dependencies(content, 2));
440 }
441
442 #[test]
443 fn test_is_in_toml_dependencies_wrong_section() {
444 let content = r#"
445[package]
446name = "test"
447
448[profile.release]
449opt-level = 3
450"#;
451 assert!(!is_in_toml_dependencies(content, 2));
452 assert!(!is_in_toml_dependencies(content, 5));
453 }
454
455 #[test]
456 fn test_is_in_toml_dependencies_multiple_sections() {
457 let content = r#"
458[dependencies]
459serde = "1.0"
460
461[dev-dependencies]
462tokio
463"#;
464 assert!(is_in_toml_dependencies(content, 2));
465 assert!(is_in_toml_dependencies(content, 5));
466 }
467
468 #[test]
469 fn test_is_in_json_dependencies_basic() {
470 let content = r#"{
471 "name": "test",
472 "dependencies": {
473 "express"
474 }
475}"#;
476 assert!(is_in_json_dependencies(content, 3));
477 assert!(!is_in_json_dependencies(content, 1));
478 }
479
480 #[test]
481 fn test_is_in_json_dependencies_dev_deps() {
482 let content = r#"{
483 "devDependencies": {
484 "jest": "^29.0.0"
485 }
486}"#;
487 assert!(is_in_json_dependencies(content, 2));
488 }
489
490 #[test]
491 fn test_is_in_json_dependencies_peer_deps() {
492 let content = r#"{
493 "peerDependencies": {
494 "react"
495 }
496}"#;
497 assert!(is_in_json_dependencies(content, 2));
498 }
499
500 #[test]
501 fn test_is_in_json_dependencies_optional_deps() {
502 let content = r#"{
503 "optionalDependencies": {
504 "fsevents": "^2.0.0"
505 }
506}"#;
507 assert!(is_in_json_dependencies(content, 2));
508 }
509
510 #[test]
511 fn test_is_in_json_dependencies_outside_section() {
512 let content = r#"{
513 "name": "test",
514 "dependencies": {
515 "express": "^4.0.0"
516 },
517 "scripts": {
518 "start": "node index.js"
519 }
520}"#;
521 assert!(is_in_json_dependencies(content, 3));
522 assert!(!is_in_json_dependencies(content, 6));
523 }
524
525 #[test]
526 fn test_is_in_json_dependencies_nested_braces() {
527 let content = r#"{
528 "dependencies": {
529 "package": "1.0.0"
530 }
531}"#;
532 assert!(is_in_json_dependencies(content, 2));
533 }
534
535 #[test]
536 fn test_is_in_dependencies_section_cargo() {
537 let content = r"
538[dependencies]
539serde
540";
541 assert!(is_in_dependencies_section(content, 2, "cargo"));
542 assert!(!is_in_dependencies_section(content, 0, "cargo"));
543 }
544
545 #[test]
546 fn test_is_in_dependencies_section_pypi() {
547 let content = r"
548[project.dependencies]
549requests
550";
551 assert!(is_in_dependencies_section(content, 2, "pypi"));
552 }
553
554 #[test]
555 fn test_is_in_dependencies_section_npm() {
556 let content = r#"{
557 "dependencies": {
558 "express"
559 }
560}"#;
561 assert!(is_in_dependencies_section(content, 2, "npm"));
562 }
563
564 #[test]
565 fn test_is_in_dependencies_section_unknown_ecosystem() {
566 let content = r"
567[dependencies]
568something
569";
570 assert!(!is_in_dependencies_section(content, 2, "unknown"));
571 }
572
573 #[test]
574 fn test_create_package_completion_item_cargo() {
575 struct MockMetadata;
576 impl deps_core::Metadata for MockMetadata {
577 fn name(&self) -> &'static str {
578 "serde"
579 }
580 fn description(&self) -> Option<&str> {
581 Some("A serialization framework")
582 }
583 fn repository(&self) -> Option<&str> {
584 None
585 }
586 fn documentation(&self) -> Option<&str> {
587 None
588 }
589 fn latest_version(&self) -> &'static str {
590 "1.0.214"
591 }
592 fn as_any(&self) -> &dyn std::any::Any {
593 self
594 }
595 }
596
597 let meta = MockMetadata;
598 let item = create_package_completion_item(&meta, "cargo");
599
600 assert_eq!(item.label, "serde");
601 assert_eq!(item.kind, Some(CompletionItemKind::MODULE));
602 assert_eq!(item.detail, Some("Latest: 1.0.214".to_string()));
603 assert_eq!(item.insert_text, Some("serde = \"1.0.214\"".to_string()));
604 assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT));
605 }
606
607 #[test]
608 fn test_create_package_completion_item_npm() {
609 struct MockMetadata;
610 impl deps_core::Metadata for MockMetadata {
611 fn name(&self) -> &'static str {
612 "express"
613 }
614 fn description(&self) -> Option<&str> {
615 Some("Fast web framework")
616 }
617 fn repository(&self) -> Option<&str> {
618 None
619 }
620 fn documentation(&self) -> Option<&str> {
621 None
622 }
623 fn latest_version(&self) -> &'static str {
624 "4.18.2"
625 }
626 fn as_any(&self) -> &dyn std::any::Any {
627 self
628 }
629 }
630
631 let meta = MockMetadata;
632 let item = create_package_completion_item(&meta, "npm");
633
634 assert_eq!(item.label, "express");
635 assert_eq!(
636 item.insert_text,
637 Some("\"express\": \"^4.18.2\"".to_string())
638 );
639 }
640
641 #[test]
642 fn test_create_package_completion_item_pypi() {
643 struct MockMetadata;
644 impl deps_core::Metadata for MockMetadata {
645 fn name(&self) -> &'static str {
646 "requests"
647 }
648 fn description(&self) -> Option<&str> {
649 None
650 }
651 fn repository(&self) -> Option<&str> {
652 None
653 }
654 fn documentation(&self) -> Option<&str> {
655 None
656 }
657 fn latest_version(&self) -> &'static str {
658 "2.31.0"
659 }
660 fn as_any(&self) -> &dyn std::any::Any {
661 self
662 }
663 }
664
665 let meta = MockMetadata;
666 let item = create_package_completion_item(&meta, "pypi");
667
668 assert_eq!(item.label, "requests");
669 assert_eq!(item.insert_text, Some("requests = \"2.31.0\"".to_string()));
670 }
671
672 #[tokio::test]
673 async fn test_fallback_triggered_when_parse_fails() {
674 use crate::document::Ecosystem;
675
676 let state = Arc::new(ServerState::new());
677 let uri = Uri::from_file_path("/test/Cargo.toml").unwrap();
678
679 let content = r"[dependencies]
681ser"
682 .to_string();
683
684 let doc = DocumentState::new(Ecosystem::Cargo, content.clone(), vec![]);
686 state.update_document(uri.clone(), doc);
687
688 let params = CompletionParams {
689 text_document_position: TextDocumentPositionParams {
690 text_document: TextDocumentIdentifier { uri },
691 position: Position::new(1, 3), },
693 work_done_progress_params: Default::default(),
694 partial_result_params: Default::default(),
695 context: None,
696 };
697
698 let (client, config) = create_test_client_and_config();
700 let result = handle_completion(state, params, client, config).await;
701 drop(result);
704 }
705
706 #[test]
707 fn test_fallback_rejects_single_char_prefix() {
708 let content = r"
709[dependencies]
710s
711";
712
713 let line = content.lines().nth(2).unwrap();
715 let prefix_end = std::cmp::min(1, line.len());
716 let prefix = &line[..prefix_end];
717 let prefix = prefix.trim();
718
719 assert_eq!(prefix.len(), 1);
721 assert!(prefix.len() < 2);
722 }
723
724 #[test]
725 fn test_fallback_rejects_prefix_with_equals() {
726 let content = r#"
727[dependencies]
728serde = "1.0"
729"#;
730
731 let line = content.lines().nth(2).unwrap();
733 let prefix_end = std::cmp::min(12, line.len()); let prefix = &line[..prefix_end];
735 let prefix = prefix.trim();
736
737 assert!(prefix.contains('='));
739 }
740
741 #[test]
742 fn test_prefix_extraction_cursor_beyond_line() {
743 let content = r"
744[dependencies]
745serde
746";
747
748 let line = content.lines().nth(2).unwrap();
750 assert_eq!(line, "serde");
751
752 let prefix_end = std::cmp::min(100, line.len());
754 let prefix = &line[..prefix_end];
755
756 assert_eq!(prefix, "serde");
758 assert_eq!(prefix.len(), 5); }
760}