Skip to main content

deps_composer/
lockfile.rs

1//! composer.lock parser.
2//!
3//! Parses composer.lock files to extract resolved dependency versions.
4//! Supports both production (`packages`) and development (`packages-dev`) sections.
5
6use deps_core::error::{DepsError, Result};
7use deps_core::lockfile::{
8    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
9    locate_lockfile_for_manifest,
10};
11use serde::Deserialize;
12use std::path::{Path, PathBuf};
13use tower_lsp_server::ls_types::Uri;
14
15/// composer.lock file parser.
16///
17/// Implements lock file parsing for the Composer package manager.
18/// Supports project-level and workspace-level lock files.
19///
20/// # Lock File Location
21///
22/// The parser searches for composer.lock in the following order:
23/// 1. Same directory as composer.json
24/// 2. Parent directories (up to 5 levels) for workspace root
25pub struct ComposerLockParser;
26
27impl ComposerLockParser {
28    const LOCKFILE_NAMES: &'static [&'static str] = &["composer.lock"];
29}
30
31/// composer.lock structure (partial).
32#[derive(Debug, Deserialize)]
33struct ComposerLock {
34    #[serde(default)]
35    packages: Vec<LockPackage>,
36    #[serde(rename = "packages-dev", default)]
37    packages_dev: Vec<LockPackage>,
38}
39
40/// Individual package entry in composer.lock.
41#[derive(Debug, Deserialize)]
42struct LockPackage {
43    name: String,
44    version: String,
45    #[serde(default)]
46    source: Option<LockSource>,
47}
48
49/// Source entry in composer.lock package.
50#[derive(Debug, Deserialize)]
51struct LockSource {
52    #[serde(rename = "type")]
53    source_type: String,
54    url: String,
55    reference: Option<String>,
56}
57
58impl LockFileProvider for ComposerLockParser {
59    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
60        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
61    }
62
63    fn parse_lockfile<'a>(
64        &'a self,
65        lockfile_path: &'a Path,
66    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
67    {
68        Box::pin(async move {
69            tracing::debug!("Parsing composer.lock: {}", lockfile_path.display());
70
71            let content = tokio::fs::read_to_string(lockfile_path)
72                .await
73                .map_err(|e| DepsError::ParseError {
74                    file_type: "composer.lock".into(),
75                    source: Box::new(e),
76                })?;
77
78            let lock_data: ComposerLock =
79                serde_json::from_str(&content).map_err(|e| DepsError::ParseError {
80                    file_type: "composer.lock".into(),
81                    source: Box::new(e),
82                })?;
83
84            let mut packages = ResolvedPackages::new();
85
86            for pkg in lock_data.packages.into_iter().chain(lock_data.packages_dev) {
87                let source = pkg.source.map_or(
88                    ResolvedSource::Registry {
89                        url: String::new(),
90                        checksum: String::new(),
91                    },
92                    |s| match s.source_type.as_str() {
93                        "git" => ResolvedSource::Git {
94                            url: s.url,
95                            rev: s.reference.unwrap_or_default(),
96                        },
97                        "path" => ResolvedSource::Path { path: s.url },
98                        _ => ResolvedSource::Registry {
99                            url: s.url,
100                            checksum: String::new(),
101                        },
102                    },
103                );
104
105                packages.insert(ResolvedPackage {
106                    name: pkg.name.to_lowercase(),
107                    version: pkg.version,
108                    source,
109                    dependencies: Vec::new(),
110                });
111            }
112
113            tracing::info!(
114                "Parsed composer.lock: {} packages from {}",
115                packages.len(),
116                lockfile_path.display()
117            );
118
119            Ok(packages)
120        })
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[tokio::test]
129    async fn test_parse_composer_lock() {
130        let content = r#"{
131  "packages": [
132    {
133      "name": "symfony/console",
134      "version": "6.0.0",
135      "source": {
136        "type": "git",
137        "url": "https://github.com/symfony/console.git",
138        "reference": "abc123"
139      }
140    }
141  ],
142  "packages-dev": [
143    {
144      "name": "phpunit/phpunit",
145      "version": "10.0.0"
146    }
147  ]
148}"#;
149
150        let temp_dir = tempfile::tempdir().unwrap();
151        let lock_path = temp_dir.path().join("composer.lock");
152        tokio::fs::write(&lock_path, content).await.unwrap();
153
154        let parser = ComposerLockParser;
155        let resolved = parser.parse_lockfile(&lock_path).await.unwrap();
156
157        assert_eq!(resolved.len(), 2);
158        assert_eq!(resolved.get_version("symfony/console"), Some("6.0.0"));
159        assert_eq!(resolved.get_version("phpunit/phpunit"), Some("10.0.0"));
160    }
161
162    #[tokio::test]
163    async fn test_parse_git_source() {
164        let content = r#"{
165  "packages": [
166    {
167      "name": "vendor/package",
168      "version": "1.0.0",
169      "source": {
170        "type": "git",
171        "url": "https://github.com/vendor/package.git",
172        "reference": "deadbeef"
173      }
174    }
175  ],
176  "packages-dev": []
177}"#;
178
179        let temp_dir = tempfile::tempdir().unwrap();
180        let lock_path = temp_dir.path().join("composer.lock");
181        tokio::fs::write(&lock_path, content).await.unwrap();
182
183        let parser = ComposerLockParser;
184        let resolved = parser.parse_lockfile(&lock_path).await.unwrap();
185
186        let pkg = resolved.get("vendor/package").unwrap();
187        match &pkg.source {
188            ResolvedSource::Git { url, rev } => {
189                assert_eq!(url, "https://github.com/vendor/package.git");
190                assert_eq!(rev, "deadbeef");
191            }
192            _ => panic!("Expected Git source"),
193        }
194    }
195
196    #[tokio::test]
197    async fn test_parse_malformed_lock() {
198        let temp_dir = tempfile::tempdir().unwrap();
199        let lock_path = temp_dir.path().join("composer.lock");
200        tokio::fs::write(&lock_path, "not json").await.unwrap();
201
202        let parser = ComposerLockParser;
203        let result = parser.parse_lockfile(&lock_path).await;
204        assert!(result.is_err());
205    }
206
207    #[test]
208    fn test_locate_lockfile() {
209        let temp_dir = tempfile::tempdir().unwrap();
210        let manifest_path = temp_dir.path().join("composer.json");
211        let lock_path = temp_dir.path().join("composer.lock");
212
213        std::fs::write(&manifest_path, r#"{"name": "test/project"}"#).unwrap();
214        std::fs::write(&lock_path, r#"{"packages": [], "packages-dev": []}"#).unwrap();
215
216        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
217        let parser = ComposerLockParser;
218
219        let located = parser.locate_lockfile(&manifest_uri);
220        assert!(located.is_some());
221        assert_eq!(located.unwrap(), lock_path);
222    }
223}