Skip to main content

deps_composer/
ecosystem.rs

1//! Composer ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for PHP/Composer projects,
4//! providing LSP functionality for `composer.json` files.
5
6use std::any::Any;
7use std::sync::Arc;
8use tower_lsp_server::ls_types::{CompletionItem, Position, Range, Uri};
9
10use deps_core::{
11    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
12    lsp_helpers::EcosystemFormatter,
13};
14
15use crate::formatter::ComposerFormatter;
16use crate::registry::PackagistRegistry;
17
18/// Composer ecosystem implementation.
19///
20/// Provides LSP functionality for composer.json files, including:
21/// - Dependency parsing with position tracking
22/// - Version information from Packagist registry
23/// - Inlay hints for latest versions
24/// - Hover tooltips with package metadata
25/// - Code actions for version updates
26/// - Diagnostics for unknown/abandoned packages
27pub struct ComposerEcosystem {
28    registry: Arc<PackagistRegistry>,
29    formatter: ComposerFormatter,
30}
31
32impl ComposerEcosystem {
33    /// Creates a new Composer ecosystem with the given HTTP cache.
34    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
35        Self {
36            registry: Arc::new(PackagistRegistry::new(cache)),
37            formatter: ComposerFormatter,
38        }
39    }
40
41    async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
42        deps_core::completion::complete_package_names_generic(
43            self.registry.as_ref(),
44            prefix,
45            20,
46            range,
47        )
48        .await
49    }
50
51    async fn complete_versions(
52        &self,
53        package_name: &deps_core::PackageName,
54        prefix: &str,
55        freshness: deps_core::FreshnessSettings,
56    ) -> Vec<CompletionItem> {
57        deps_core::completion::complete_versions_generic(
58            self.registry.as_ref(),
59            package_name,
60            prefix,
61            &['^', '~', '=', '<', '>', '*'],
62            freshness,
63        )
64        .await
65    }
66}
67
68impl deps_core::ecosystem::private::Sealed for ComposerEcosystem {}
69
70impl Ecosystem for ComposerEcosystem {
71    fn id(&self) -> &'static str {
72        "composer"
73    }
74
75    fn display_name(&self) -> &'static str {
76        "Composer (PHP)"
77    }
78
79    fn manifest_filenames(&self) -> &[&'static str] {
80        &["composer.json"]
81    }
82
83    fn lockfile_filenames(&self) -> &[&'static str] {
84        &["composer.lock"]
85    }
86
87    fn parse_manifest<'a>(
88        &'a self,
89        content: &'a str,
90        uri: &'a Uri,
91    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
92        Box::pin(async move {
93            let result = crate::parser::parse_composer_json(content, uri)?;
94            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
95        })
96    }
97
98    fn registry(&self) -> Arc<dyn Registry> {
99        self.registry.clone() as Arc<dyn Registry>
100    }
101
102    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
103        Some(Arc::new(crate::lockfile::ComposerLockParser))
104    }
105
106    fn formatter(&self) -> &dyn EcosystemFormatter {
107        &self.formatter
108    }
109
110    fn generate_completions<'a>(
111        &'a self,
112        parse_result: &'a dyn ParseResultTrait,
113        position: Position,
114        content: &'a str,
115        freshness: deps_core::FreshnessSettings,
116    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
117        Box::pin(async move {
118            use deps_core::completion::{CompletionContext, detect_completion_context};
119
120            let context = detect_completion_context(parse_result, position, content);
121
122            match context {
123                CompletionContext::PackageName { prefix, range } => {
124                    self.complete_package_names(&prefix, range).await
125                }
126                CompletionContext::Version {
127                    package_name,
128                    prefix,
129                } => {
130                    self.complete_versions(&package_name, &prefix, freshness)
131                        .await
132                }
133                CompletionContext::Feature { .. } => vec![],
134                CompletionContext::None => vec![],
135            }
136            .into()
137        })
138    }
139
140    fn as_any(&self) -> &dyn Any {
141        self
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use deps_core::{EcosystemConfig, VersionData};
149    use std::collections::HashMap;
150
151    #[test]
152    fn test_ecosystem_id() {
153        let cache = Arc::new(deps_core::HttpCache::new());
154        let ecosystem = ComposerEcosystem::new(cache);
155        assert_eq!(ecosystem.id(), "composer");
156    }
157
158    #[test]
159    fn test_ecosystem_manifest_filenames() {
160        let cache = Arc::new(deps_core::HttpCache::new());
161        let ecosystem = ComposerEcosystem::new(cache);
162        assert_eq!(ecosystem.manifest_filenames(), &["composer.json"]);
163    }
164
165    #[test]
166    fn test_ecosystem_lockfile_filenames() {
167        let cache = Arc::new(deps_core::HttpCache::new());
168        let ecosystem = ComposerEcosystem::new(cache);
169        assert_eq!(ecosystem.lockfile_filenames(), &["composer.lock"]);
170    }
171
172    #[test]
173    fn test_lockfile_provider_returns_some() {
174        let cache = Arc::new(deps_core::HttpCache::new());
175        let ecosystem = ComposerEcosystem::new(cache);
176        assert!(ecosystem.lockfile_provider().is_some());
177    }
178
179    #[tokio::test]
180    async fn test_parse_manifest_valid() {
181        let cache = Arc::new(deps_core::HttpCache::new());
182        let ecosystem = ComposerEcosystem::new(cache);
183        let uri = deps_core::test_util::test_uri("/test/composer.json");
184
185        let content = r#"{"require": {"symfony/console": "^6.0"}}"#;
186        let result = ecosystem.parse_manifest(content, &uri).await;
187        assert!(result.is_ok());
188
189        let parse_result = result.unwrap();
190        assert_eq!(parse_result.dependencies().len(), 1);
191    }
192
193    #[tokio::test]
194    async fn test_parse_manifest_invalid() {
195        let cache = Arc::new(deps_core::HttpCache::new());
196        let ecosystem = ComposerEcosystem::new(cache);
197        let uri = deps_core::test_util::test_uri("/test/composer.json");
198
199        let result = ecosystem.parse_manifest("{invalid json}", &uri).await;
200        assert!(result.is_err());
201    }
202
203    #[tokio::test]
204    async fn test_package_name_completion_context_has_real_range() {
205        // Regression test for #232: the textEdit range for a package-name completion
206        // must be the real name token span, not the (0,0)-(0,0) placeholder.
207        let cache = Arc::new(deps_core::HttpCache::new());
208        let ecosystem = ComposerEcosystem::new(cache);
209        let content = "{\n  \"require\": {\n    \"symfony/console\": \"^6.0\"\n  }\n}";
210        let uri = deps_core::test_util::test_uri("/test/composer.json");
211
212        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
213        let position = Position::new(2, 9); // cursor after "symf" in "symfony/console"
214
215        let context = deps_core::completion::detect_completion_context(
216            parse_result.as_ref(),
217            position,
218            content,
219        );
220
221        match context {
222            deps_core::completion::CompletionContext::PackageName { prefix, range } => {
223                assert_eq!(prefix, "symf");
224                assert_ne!(range, Range::default());
225                assert_eq!(range, Range::new(Position::new(2, 5), Position::new(2, 20)));
226            }
227            other => panic!("Expected PackageName context, got {other:?}"),
228        }
229    }
230
231    #[tokio::test]
232    async fn test_complete_package_names_short_prefix() {
233        let cache = Arc::new(deps_core::HttpCache::new());
234        let ecosystem = ComposerEcosystem::new(cache);
235
236        let results = ecosystem
237            .complete_package_names("s", Range::default())
238            .await;
239        assert!(results.is_empty());
240    }
241
242    #[tokio::test]
243    async fn test_generate_inlay_hints_empty() {
244        let cache = Arc::new(deps_core::HttpCache::new());
245        let ecosystem = ComposerEcosystem::new(cache);
246        let uri = deps_core::test_util::test_uri("/test/composer.json");
247
248        let content = r#"{"require": {}}"#;
249        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
250
251        let hints = ecosystem
252            .generate_inlay_hints(
253                parse_result.as_ref(),
254                VersionData::new(&HashMap::new(), &HashMap::new()),
255                deps_core::LoadingState::Loaded,
256                &EcosystemConfig::default(),
257            )
258            .await;
259
260        assert!(hints.is_empty());
261    }
262
263    #[tokio::test]
264    async fn test_generate_completions_no_context() {
265        let cache = Arc::new(deps_core::HttpCache::new());
266        let ecosystem = ComposerEcosystem::new(cache);
267        let uri = deps_core::test_util::test_uri("/test/composer.json");
268
269        let content = r#"{"name": "test/project"}"#;
270        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
271        let position = Position {
272            line: 0,
273            character: 0,
274        };
275
276        let completions = ecosystem
277            .generate_completions(
278                parse_result.as_ref(),
279                position,
280                content,
281                deps_core::FreshnessSettings::default(),
282            )
283            .await;
284        assert!(completions.items.is_empty());
285    }
286}