deps_lsp/handlers/
completion.rs

1//! Completion handler implementation.
2//!
3//! Delegates to ecosystem-specific completion logic.
4
5use 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
14/// Handles completion requests.
15///
16/// Delegates to the appropriate ecosystem implementation based on the document type.
17/// Falls back to text-based completion when TOML parsing fails (user is still typing).
18pub 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 = &params.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    // Check if document is loaded, if not try to load with short timeout
35    // Completion is latency-critical, so we use a 200ms timeout
36    if state.get_document(uri).is_none() {
37        tracing::info!("completion: document not loaded, loading from disk");
38
39        // Try to load with short timeout (200ms)
40        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                // Document loaded successfully, continue with completion
49                tracing::debug!("completion: document loaded successfully");
50            }
51            Ok(false) | Err(_) => {
52                // Load failed or timed out, return empty completions
53                tracing::warn!("completion: document load failed or timed out");
54                return Some(CompletionResponse::Array(vec![]));
55            }
56        }
57    }
58
59    // Get document and extract needed data
60    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    // Try parse_result first, fallback to text-based detection
79    let items = if has_parse_result {
80        // Re-acquire document to get parse_result
81        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 ecosystem returned no completions, try fallback
90        // This handles the case where user is typing a NEW package name
91        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: detect context from raw text
99        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
111/// Fallback completion when document parsing fails.
112///
113/// Detects dependencies sections from raw text and provides package name suggestions.
114async 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    // Get the current line
126    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    // Check if we're in a dependencies section
137    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    // Extract what user has typed (from start of line to cursor)
143    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 it looks like a package name (letters, no = sign, at least 2 chars)
150    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    // Get ecosystem and search for packages
156    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 for packages matching the prefix
164    search_packages(registry.as_ref(), ecosystem_id, prefix).await
165}
166
167/// Checks if a line is inside a dependencies section.
168fn 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
176/// Checks if a line is inside a TOML dependencies section.
177///
178/// Looks for `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` sections
179/// in Cargo.toml or `[project.dependencies]` in pyproject.toml.
180fn is_in_toml_dependencies(content: &str, line_number: usize) -> bool {
181    // Walk backwards from current line to find the most recent section header
182    // Collect lines up to target, then iterate backwards
183    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        // Check if this is a section header
189        if line.starts_with('[') && line.ends_with(']') {
190            // Check if it's a dependencies section
191            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
207/// Checks if a line is inside a JSON dependencies section.
208///
209/// Looks for `"dependencies": {`, `"devDependencies": {`, etc. in package.json.
210fn is_in_json_dependencies(content: &str, line_number: usize) -> bool {
211    let mut in_dependencies = false;
212    let mut brace_depth = 0;
213
214    // Use iterator directly without collecting to avoid allocation
215    for (i, line) in content.lines().enumerate() {
216        // Early exit: stop if we've passed the target line
217        if i > line_number {
218            break;
219        }
220
221        let trimmed = line.trim();
222
223        // Check if we're entering a dependencies section
224        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        // Track brace depth when in dependencies section
235        if in_dependencies {
236            for ch in trimmed.chars() {
237                match ch {
238                    '{' => brace_depth += 1,
239                    '}' => {
240                        brace_depth -= 1;
241                        // If we've closed the dependencies section
242                        if brace_depth <= 0 {
243                            in_dependencies = false;
244                        }
245                    }
246                    _ => {}
247                }
248            }
249
250            // If we're at the target line and inside dependencies section with depth > 0
251            if i == line_number && in_dependencies && brace_depth > 0 {
252                return true;
253            }
254        }
255    }
256
257    false
258}
259
260/// Searches for packages and returns completion items.
261async 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    // Search for up to 50 packages
273    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    // Convert search results to completion items
285    results
286        .iter()
287        .map(|metadata| create_package_completion_item(metadata.as_ref(), ecosystem_id))
288        .collect()
289}
290
291/// Creates a completion item for a package.
292fn 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    // Create insert text based on ecosystem
301    let insert_text = match ecosystem_id {
302        "cargo" | "pypi" => format!("{name} = \"{latest}\""),
303        "npm" => format!("\"{name}\": \"^{latest}\""),
304        _ => format!("{name} = \"{latest}\""),
305    };
306
307    // Build detail text
308    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        // With cold start support, missing documents trigger background load
349        // and return empty completions for the first request
350        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        // Parse the manifest to get a proper parse result
361        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        // Should return Some or None based on ecosystem implementation
378        // We don't test the actual completions here as that's ecosystem-specific
379        let (client, config) = create_test_client_and_config();
380        let _result = handle_completion(state, params, client, config).await;
381        // Just verify it doesn't panic - actual completion logic is in ecosystem
382    }
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        // Malformed content that will fail to parse
680        let content = r"[dependencies]
681ser"
682        .to_string();
683
684        // Create document without parse result (simulating parse failure)
685        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), // After "ser"
692            },
693            work_done_progress_params: Default::default(),
694            partial_result_params: Default::default(),
695            context: None,
696        };
697
698        // Should use fallback completion (won't panic, may return empty if search fails)
699        let (client, config) = create_test_client_and_config();
700        let result = handle_completion(state, params, client, config).await;
701        // Just verify it doesn't panic - actual results depend on registry availability
702        // In a real scenario with mocked registry, we'd verify it returns search results
703        drop(result);
704    }
705
706    #[test]
707    fn test_fallback_rejects_single_char_prefix() {
708        let content = r"
709[dependencies]
710s
711";
712
713        // Extract prefix at position (1 char)
714        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        // Should reject single char (< 2 chars requirement)
720        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        // Extract prefix at position (contains '=')
732        let line = content.lines().nth(2).unwrap();
733        let prefix_end = std::cmp::min(12, line.len()); // "serde = "
734        let prefix = &line[..prefix_end];
735        let prefix = prefix.trim();
736
737        // Should reject prefix containing '='
738        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        // Try to extract prefix with cursor beyond line length
749        let line = content.lines().nth(2).unwrap();
750        assert_eq!(line, "serde");
751
752        // Cursor at position 100 (beyond line)
753        let prefix_end = std::cmp::min(100, line.len());
754        let prefix = &line[..prefix_end];
755
756        // Should clamp to line length
757        assert_eq!(prefix, "serde");
758        assert_eq!(prefix.len(), 5); // Not 100
759    }
760}