deps_npm/
ecosystem.rs

1//! npm ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for npm/JavaScript projects,
4//! providing LSP functionality for `package.json` files.
5
6use async_trait::async_trait;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{
11    CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri,
12};
13
14use deps_core::{
15    Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers,
16};
17
18use crate::formatter::NpmFormatter;
19use crate::registry::NpmRegistry;
20
21/// npm ecosystem implementation.
22///
23/// Provides LSP functionality for package.json files, including:
24/// - Dependency parsing with position tracking
25/// - Version information from npm registry
26/// - Inlay hints for latest versions
27/// - Hover tooltips with package metadata
28/// - Code actions for version updates
29/// - Diagnostics for unknown/deprecated packages
30pub struct NpmEcosystem {
31    registry: Arc<NpmRegistry>,
32    formatter: NpmFormatter,
33}
34
35impl NpmEcosystem {
36    /// Creates a new npm ecosystem with the given HTTP cache.
37    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
38        Self {
39            registry: Arc::new(NpmRegistry::new(cache)),
40            formatter: NpmFormatter,
41        }
42    }
43
44    /// Completes package names by searching the npm registry.
45    ///
46    /// Requires at least 2 characters for search. Returns up to 20 results.
47    async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
48        use deps_core::completion::build_package_completion;
49
50        // Security: reject too short or too long prefixes
51        if prefix.len() < 2 || prefix.len() > 100 {
52            return vec![];
53        }
54
55        // Search registry (limit to 20 results)
56        let results = match self.registry.search(prefix, 20).await {
57            Ok(r) => r,
58            Err(e) => {
59                tracing::warn!("Package search failed for '{}': {}", prefix, e);
60                return vec![];
61            }
62        };
63
64        // Use dummy range - completion will be inserted at cursor position
65        let insert_range = tower_lsp_server::ls_types::Range::default();
66
67        results
68            .into_iter()
69            .map(|metadata| {
70                let boxed: Box<dyn deps_core::Metadata> = Box::new(metadata);
71                build_package_completion(boxed.as_ref(), insert_range)
72            })
73            .collect()
74    }
75
76    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
77        deps_core::completion::complete_versions_generic(
78            self.registry.as_ref(),
79            package_name,
80            prefix,
81            &['^', '~', '=', '<', '>', '*'],
82        )
83        .await
84    }
85}
86
87#[async_trait]
88impl Ecosystem for NpmEcosystem {
89    fn id(&self) -> &'static str {
90        "npm"
91    }
92
93    fn display_name(&self) -> &'static str {
94        "npm (JavaScript)"
95    }
96
97    fn manifest_filenames(&self) -> &[&'static str] {
98        &["package.json"]
99    }
100
101    fn lockfile_filenames(&self) -> &[&'static str] {
102        &["package-lock.json"]
103    }
104
105    async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result<Box<dyn ParseResultTrait>> {
106        let result = crate::parser::parse_package_json(content, uri)?;
107        Ok(Box::new(result))
108    }
109
110    fn registry(&self) -> Arc<dyn Registry> {
111        self.registry.clone() as Arc<dyn Registry>
112    }
113
114    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
115        Some(Arc::new(crate::lockfile::NpmLockParser))
116    }
117
118    async fn generate_inlay_hints(
119        &self,
120        parse_result: &dyn ParseResultTrait,
121        cached_versions: &HashMap<String, String>,
122        resolved_versions: &HashMap<String, String>,
123        loading_state: deps_core::LoadingState,
124        config: &EcosystemConfig,
125    ) -> Vec<InlayHint> {
126        lsp_helpers::generate_inlay_hints(
127            parse_result,
128            cached_versions,
129            resolved_versions,
130            loading_state,
131            config,
132            &self.formatter,
133        )
134    }
135
136    async fn generate_hover(
137        &self,
138        parse_result: &dyn ParseResultTrait,
139        position: Position,
140        cached_versions: &HashMap<String, String>,
141        resolved_versions: &HashMap<String, String>,
142    ) -> Option<Hover> {
143        lsp_helpers::generate_hover(
144            parse_result,
145            position,
146            cached_versions,
147            resolved_versions,
148            self.registry.as_ref(),
149            &self.formatter,
150        )
151        .await
152    }
153
154    async fn generate_code_actions(
155        &self,
156        parse_result: &dyn ParseResultTrait,
157        position: Position,
158        _cached_versions: &HashMap<String, String>,
159        uri: &Uri,
160    ) -> Vec<CodeAction> {
161        lsp_helpers::generate_code_actions(
162            parse_result,
163            position,
164            uri,
165            self.registry.as_ref(),
166            &self.formatter,
167        )
168        .await
169    }
170
171    async fn generate_diagnostics(
172        &self,
173        parse_result: &dyn ParseResultTrait,
174        cached_versions: &HashMap<String, String>,
175        resolved_versions: &HashMap<String, String>,
176        _uri: &Uri,
177    ) -> Vec<Diagnostic> {
178        lsp_helpers::generate_diagnostics_from_cache(
179            parse_result,
180            cached_versions,
181            resolved_versions,
182            &self.formatter,
183        )
184    }
185
186    async fn generate_completions(
187        &self,
188        parse_result: &dyn ParseResultTrait,
189        position: Position,
190        content: &str,
191    ) -> Vec<CompletionItem> {
192        use deps_core::completion::{CompletionContext, detect_completion_context};
193
194        let context = detect_completion_context(parse_result, position, content);
195
196        match context {
197            CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await,
198            CompletionContext::Version {
199                package_name,
200                prefix,
201            } => self.complete_versions(&package_name, &prefix).await,
202            // npm doesn't have features like Cargo
203            CompletionContext::Feature { .. } => vec![],
204            CompletionContext::None => vec![],
205        }
206    }
207
208    fn as_any(&self) -> &dyn Any {
209        self
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_ecosystem_id() {
219        let cache = Arc::new(deps_core::HttpCache::new());
220        let ecosystem = NpmEcosystem::new(cache);
221        assert_eq!(ecosystem.id(), "npm");
222    }
223
224    #[test]
225    fn test_ecosystem_display_name() {
226        let cache = Arc::new(deps_core::HttpCache::new());
227        let ecosystem = NpmEcosystem::new(cache);
228        assert_eq!(ecosystem.display_name(), "npm (JavaScript)");
229    }
230
231    #[test]
232    fn test_ecosystem_manifest_filenames() {
233        let cache = Arc::new(deps_core::HttpCache::new());
234        let ecosystem = NpmEcosystem::new(cache);
235        assert_eq!(ecosystem.manifest_filenames(), &["package.json"]);
236    }
237
238    #[test]
239    fn test_ecosystem_lockfile_filenames() {
240        let cache = Arc::new(deps_core::HttpCache::new());
241        let ecosystem = NpmEcosystem::new(cache);
242        assert_eq!(ecosystem.lockfile_filenames(), &["package-lock.json"]);
243    }
244
245    #[test]
246    fn test_as_any() {
247        let cache = Arc::new(deps_core::HttpCache::new());
248        let ecosystem = NpmEcosystem::new(cache);
249
250        let any = ecosystem.as_any();
251        assert!(any.is::<NpmEcosystem>());
252    }
253
254    #[tokio::test]
255    async fn test_complete_package_names_minimum_prefix() {
256        let cache = Arc::new(deps_core::HttpCache::new());
257        let ecosystem = NpmEcosystem::new(cache);
258
259        // Less than 2 characters should return empty
260        let results = ecosystem.complete_package_names("e").await;
261        assert!(results.is_empty());
262
263        // Empty prefix should return empty
264        let results = ecosystem.complete_package_names("").await;
265        assert!(results.is_empty());
266    }
267
268    #[tokio::test]
269    #[ignore] // Requires network access
270    async fn test_complete_package_names_real_search() {
271        let cache = Arc::new(deps_core::HttpCache::new());
272        let ecosystem = NpmEcosystem::new(cache);
273
274        let results = ecosystem.complete_package_names("expre").await;
275        assert!(!results.is_empty());
276        assert!(results.iter().any(|r| r.label == "express"));
277    }
278
279    #[tokio::test]
280    #[ignore] // Requires network access
281    async fn test_complete_versions_real() {
282        let cache = Arc::new(deps_core::HttpCache::new());
283        let ecosystem = NpmEcosystem::new(cache);
284
285        let results = ecosystem.complete_versions("express", "4.").await;
286        assert!(!results.is_empty());
287        assert!(results.iter().all(|r| r.label.starts_with("4.")));
288    }
289
290    #[tokio::test]
291    #[ignore] // Requires network access
292    async fn test_complete_versions_with_operator() {
293        let cache = Arc::new(deps_core::HttpCache::new());
294        let ecosystem = NpmEcosystem::new(cache);
295
296        let results = ecosystem.complete_versions("express", "^4.").await;
297        assert!(!results.is_empty());
298        assert!(results.iter().all(|r| r.label.starts_with("4.")));
299    }
300
301    #[tokio::test]
302    async fn test_complete_versions_unknown_package() {
303        let cache = Arc::new(deps_core::HttpCache::new());
304        let ecosystem = NpmEcosystem::new(cache);
305
306        // Unknown package should return empty (graceful degradation)
307        let results = ecosystem
308            .complete_versions("this-package-does-not-exist-12345", "1.0")
309            .await;
310        assert!(results.is_empty());
311    }
312
313    #[tokio::test]
314    async fn test_complete_package_names_special_characters() {
315        let cache = Arc::new(deps_core::HttpCache::new());
316        let ecosystem = NpmEcosystem::new(cache);
317
318        // Package names with special characters (@scope/package) should work
319        let results = ecosystem.complete_package_names("@type").await;
320        // Should not panic or error
321        assert!(results.is_empty() || !results.is_empty());
322    }
323
324    #[tokio::test]
325    async fn test_complete_package_names_max_length() {
326        let cache = Arc::new(deps_core::HttpCache::new());
327        let ecosystem = NpmEcosystem::new(cache);
328
329        // Prefix longer than 100 chars should return empty (security)
330        let long_prefix = "a".repeat(101);
331        let results = ecosystem.complete_package_names(&long_prefix).await;
332        assert!(results.is_empty());
333
334        // Exactly 100 chars should work
335        let max_prefix = "a".repeat(100);
336        let results = ecosystem.complete_package_names(&max_prefix).await;
337        // Should not panic, but may return empty (no matches)
338        assert!(results.is_empty() || !results.is_empty());
339    }
340
341    #[tokio::test]
342    #[ignore] // Requires network access
343    async fn test_complete_versions_limit_20() {
344        let cache = Arc::new(deps_core::HttpCache::new());
345        let ecosystem = NpmEcosystem::new(cache);
346
347        // Test that we respect the 20 result limit
348        let results = ecosystem.complete_versions("express", "4").await;
349        assert!(results.len() <= 20);
350    }
351
352    #[tokio::test]
353    #[ignore] // Requires network access
354    async fn test_complete_package_names_scoped() {
355        let cache = Arc::new(deps_core::HttpCache::new());
356        let ecosystem = NpmEcosystem::new(cache);
357
358        // Scoped packages (@types/node, etc.)
359        let results = ecosystem.complete_package_names("@types").await;
360        assert!(!results.is_empty() || results.is_empty()); // May not have results but shouldn't panic
361    }
362
363    #[tokio::test]
364    async fn test_parse_manifest_valid_json() {
365        let cache = Arc::new(deps_core::HttpCache::new());
366        let ecosystem = NpmEcosystem::new(cache);
367        let uri = Uri::from_file_path("/test/package.json").unwrap();
368
369        let content = r#"{"dependencies": {"express": "^4.18.0"}}"#;
370
371        let result = ecosystem.parse_manifest(content, &uri).await;
372        assert!(result.is_ok());
373
374        let parse_result = result.unwrap();
375        assert!(!parse_result.dependencies().is_empty());
376    }
377
378    #[tokio::test]
379    async fn test_parse_manifest_invalid_json() {
380        let cache = Arc::new(deps_core::HttpCache::new());
381        let ecosystem = NpmEcosystem::new(cache);
382        let uri = Uri::from_file_path("/test/package.json").unwrap();
383
384        let invalid_content = r#"{"dependencies": invalid json"#;
385
386        let result = ecosystem.parse_manifest(invalid_content, &uri).await;
387        assert!(result.is_err());
388    }
389
390    #[tokio::test]
391    async fn test_parse_manifest_empty_dependencies() {
392        let cache = Arc::new(deps_core::HttpCache::new());
393        let ecosystem = NpmEcosystem::new(cache);
394        let uri = Uri::from_file_path("/test/package.json").unwrap();
395
396        let content = r#"{"dependencies": {}}"#;
397
398        let result = ecosystem.parse_manifest(content, &uri).await;
399        assert!(result.is_ok());
400
401        let parse_result = result.unwrap();
402        assert!(parse_result.dependencies().is_empty());
403    }
404
405    #[tokio::test]
406    async fn test_registry_returns_arc() {
407        let cache = Arc::new(deps_core::HttpCache::new());
408        let ecosystem = NpmEcosystem::new(cache);
409
410        let registry = ecosystem.registry();
411        assert!(Arc::strong_count(&registry) >= 1);
412    }
413
414    #[tokio::test]
415    async fn test_lockfile_provider_returns_some() {
416        let cache = Arc::new(deps_core::HttpCache::new());
417        let ecosystem = NpmEcosystem::new(cache);
418
419        let provider = ecosystem.lockfile_provider();
420        assert!(provider.is_some());
421    }
422
423    #[tokio::test]
424    async fn test_generate_inlay_hints_empty_dependencies() {
425        let cache = Arc::new(deps_core::HttpCache::new());
426        let ecosystem = NpmEcosystem::new(cache);
427        let uri = Uri::from_file_path("/test/package.json").unwrap();
428
429        let content = r#"{"dependencies": {}}"#;
430
431        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
432        let cached_versions = HashMap::new();
433        let resolved_versions = HashMap::new();
434        let config = EcosystemConfig::default();
435
436        let hints = ecosystem
437            .generate_inlay_hints(
438                parse_result.as_ref(),
439                &cached_versions,
440                &resolved_versions,
441                deps_core::LoadingState::Loaded,
442                &config,
443            )
444            .await;
445
446        assert!(hints.is_empty());
447    }
448
449    #[tokio::test]
450    async fn test_generate_completions_no_context() {
451        let cache = Arc::new(deps_core::HttpCache::new());
452        let ecosystem = NpmEcosystem::new(cache);
453        let uri = Uri::from_file_path("/test/package.json").unwrap();
454
455        let content = r#"{"name": "test"}"#;
456
457        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
458        let position = Position {
459            line: 0,
460            character: 0,
461        };
462
463        let completions = ecosystem
464            .generate_completions(parse_result.as_ref(), position, content)
465            .await;
466
467        assert!(completions.is_empty());
468    }
469
470    #[tokio::test]
471    async fn test_generate_completions_feature_context_returns_empty() {
472        let cache = Arc::new(deps_core::HttpCache::new());
473        let ecosystem = NpmEcosystem::new(cache);
474
475        // npm doesn't have features, so this should always return empty
476        let content = r#"{"dependencies": {"express": "4.0.0"}}"#;
477        let uri = Uri::from_file_path("/test/package.json").unwrap();
478        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
479
480        let position = Position {
481            line: 0,
482            character: 30,
483        };
484
485        let completions = ecosystem
486            .generate_completions(parse_result.as_ref(), position, content)
487            .await;
488
489        // Should not crash, returns empty or package/version completions
490        assert!(completions.is_empty() || !completions.is_empty());
491    }
492
493    #[tokio::test]
494    async fn test_generate_hover_no_dependency_at_position() {
495        let cache = Arc::new(deps_core::HttpCache::new());
496        let ecosystem = NpmEcosystem::new(cache);
497        let uri = Uri::from_file_path("/test/package.json").unwrap();
498
499        let content = r#"{"name": "test"}"#;
500
501        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
502        let position = Position {
503            line: 0,
504            character: 0,
505        };
506        let cached_versions = HashMap::new();
507        let resolved_versions = HashMap::new();
508
509        let hover = ecosystem
510            .generate_hover(
511                parse_result.as_ref(),
512                position,
513                &cached_versions,
514                &resolved_versions,
515            )
516            .await;
517
518        assert!(hover.is_none());
519    }
520
521    #[tokio::test]
522    async fn test_generate_code_actions_no_actions() {
523        let cache = Arc::new(deps_core::HttpCache::new());
524        let ecosystem = NpmEcosystem::new(cache);
525        let uri = Uri::from_file_path("/test/package.json").unwrap();
526
527        let content = r#"{"name": "test"}"#;
528
529        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
530        let position = Position {
531            line: 0,
532            character: 0,
533        };
534        let cached_versions = HashMap::new();
535
536        let actions = ecosystem
537            .generate_code_actions(parse_result.as_ref(), position, &cached_versions, &uri)
538            .await;
539
540        assert!(actions.is_empty());
541    }
542
543    #[tokio::test]
544    async fn test_generate_diagnostics_no_dependencies() {
545        let cache = Arc::new(deps_core::HttpCache::new());
546        let ecosystem = NpmEcosystem::new(cache);
547        let uri = Uri::from_file_path("/test/package.json").unwrap();
548
549        let content = r#"{"dependencies": {}}"#;
550
551        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
552        let cached_versions = HashMap::new();
553        let resolved_versions = HashMap::new();
554
555        let diagnostics = ecosystem
556            .generate_diagnostics(
557                parse_result.as_ref(),
558                &cached_versions,
559                &resolved_versions,
560                &uri,
561            )
562            .await;
563
564        assert!(diagnostics.is_empty());
565    }
566
567    #[tokio::test]
568    async fn test_complete_versions_empty_prefix() {
569        let cache = Arc::new(deps_core::HttpCache::new());
570        let ecosystem = NpmEcosystem::new(cache);
571
572        // Empty prefix should show non-deprecated versions (up to 20)
573        let results = ecosystem.complete_versions("nonexistent-package", "").await;
574        // Should not panic, returns empty for unknown package
575        assert!(results.is_empty());
576    }
577
578    #[tokio::test]
579    async fn test_complete_versions_with_tilde_operator() {
580        let cache = Arc::new(deps_core::HttpCache::new());
581        let ecosystem = NpmEcosystem::new(cache);
582
583        // Test ~ operator stripping
584        let results = ecosystem.complete_versions("nonexistent-pkg", "~4.0").await;
585        assert!(results.is_empty());
586    }
587
588    #[tokio::test]
589    async fn test_complete_versions_with_wildcard() {
590        let cache = Arc::new(deps_core::HttpCache::new());
591        let ecosystem = NpmEcosystem::new(cache);
592
593        // Test * wildcard stripping
594        let results = ecosystem.complete_versions("nonexistent-pkg", "*").await;
595        assert!(results.is_empty());
596    }
597
598    #[tokio::test]
599    async fn test_complete_versions_with_less_than_operator() {
600        let cache = Arc::new(deps_core::HttpCache::new());
601        let ecosystem = NpmEcosystem::new(cache);
602
603        // Test < and > operator stripping
604        let results = ecosystem.complete_versions("nonexistent-pkg", "<2.0").await;
605        assert!(results.is_empty());
606    }
607}