deps_composer/
lockfile.rs1use 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
15pub struct ComposerLockParser;
26
27impl ComposerLockParser {
28 const LOCKFILE_NAMES: &'static [&'static str] = &["composer.lock"];
29}
30
31#[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#[derive(Debug, Deserialize)]
42struct LockPackage {
43 name: String,
44 version: String,
45 #[serde(default)]
46 source: Option<LockSource>,
47}
48
49#[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}