1use crate::parser::url_to_identity;
11use deps_core::error::{DepsError, Result};
12use deps_core::lockfile::{
13 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
14 locate_lockfile_for_manifest,
15};
16use serde::Deserialize;
17use std::path::{Path, PathBuf};
18use tower_lsp_server::ls_types::Uri;
19
20pub struct SwiftLockParser;
22
23impl SwiftLockParser {
24 const LOCKFILE_NAMES: &'static [&'static str] = &["Package.resolved"];
25}
26
27#[derive(Deserialize)]
28struct PackageResolved {
29 version: u32,
30 #[serde(default)]
31 object: Option<PackageResolvedV1Object>,
32 #[serde(default)]
33 pins: Option<Vec<PinV2>>,
34}
35
36#[derive(Deserialize)]
37struct PackageResolvedV1Object {
38 pins: Vec<PinV1>,
39}
40
41#[derive(Deserialize)]
42struct PinV1 {
43 package: String,
44 #[serde(rename = "repositoryURL")]
45 repository_url: String,
46 state: PinState,
47}
48
49#[derive(Deserialize)]
50struct PinV2 {
51 identity: String,
52 #[serde(default)]
53 kind: String,
54 location: String,
55 state: PinState,
56}
57
58#[derive(Deserialize)]
59struct PinState {
60 version: Option<String>,
61 revision: Option<String>,
62 #[serde(default)]
63 #[allow(dead_code)]
64 branch: Option<String>,
65}
66
67impl LockFileProvider for SwiftLockParser {
68 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
69 locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
70 }
71
72 fn parse_lockfile<'a>(
73 &'a self,
74 lockfile_path: &'a Path,
75 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
76 {
77 Box::pin(async move {
78 tracing::debug!("Parsing Package.resolved: {}", lockfile_path.display());
79
80 let content = tokio::fs::read_to_string(lockfile_path)
81 .await
82 .map_err(|e| DepsError::ParseError {
83 file_type: format!("Package.resolved at {}", lockfile_path.display()),
84 source: Box::new(e),
85 })?;
86
87 let lock_data: PackageResolved =
88 serde_json::from_str(&content).map_err(|e| DepsError::ParseError {
89 file_type: "Package.resolved".into(),
90 source: Box::new(e),
91 })?;
92
93 let mut packages = ResolvedPackages::new();
94
95 match lock_data.version {
96 1 => {
97 let Some(obj) = lock_data.object else {
98 return Ok(packages);
99 };
100 for pin in obj.pins {
101 let name =
102 url_to_identity(&pin.repository_url).unwrap_or(pin.package.clone());
103 if let Some(version) = pin.state.version {
104 let version = version.strip_prefix('v').unwrap_or(&version).to_string();
105 packages.insert(ResolvedPackage {
106 name,
107 version,
108 source: ResolvedSource::Git {
109 url: pin.repository_url,
110 rev: pin.state.revision.unwrap_or_default(),
111 },
112 dependencies: vec![],
113 });
114 }
115 }
116 }
117 2 | 3 => {
118 let Some(pins) = lock_data.pins else {
119 return Ok(packages);
120 };
121 for pin in pins {
122 let name = if pin.kind == "fileSystem" {
125 pin.identity.clone()
126 } else {
127 url_to_identity(&pin.location).unwrap_or(pin.identity.clone())
128 };
129 if let Some(version) = pin.state.version {
130 let version = version.strip_prefix('v').unwrap_or(&version).to_string();
131 let source = if pin.kind == "fileSystem" {
132 ResolvedSource::Path {
133 path: pin.location.clone(),
134 }
135 } else {
136 ResolvedSource::Git {
137 url: pin.location,
138 rev: pin.state.revision.unwrap_or_default(),
139 }
140 };
141 packages.insert(ResolvedPackage {
142 name,
143 version,
144 source,
145 dependencies: vec![],
146 });
147 }
148 }
149 }
150 v => {
151 tracing::warn!("Unknown Package.resolved version: {}", v);
152 }
153 }
154
155 tracing::info!(
156 "Parsed Package.resolved: {} packages from {}",
157 packages.len(),
158 lockfile_path.display()
159 );
160
161 Ok(packages)
162 })
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use deps_core::lockfile::LockFileProvider;
170
171 #[tokio::test]
172 async fn test_parse_v1() {
173 let content = r#"{
174 "object": {
175 "pins": [
176 {
177 "package": "SwiftNIO",
178 "repositoryURL": "https://github.com/apple/swift-nio.git",
179 "state": {
180 "branch": null,
181 "revision": "cf4e6a20",
182 "version": "2.62.0"
183 }
184 }
185 ]
186 },
187 "version": 1
188}"#;
189 let tmp = tempfile::tempdir().unwrap();
190 let path = tmp.path().join("Package.resolved");
191 tokio::fs::write(&path, content).await.unwrap();
192
193 let parser = SwiftLockParser;
194 let resolved = parser.parse_lockfile(&path).await.unwrap();
195 assert_eq!(resolved.len(), 1);
196 assert_eq!(resolved.get_version("apple/swift-nio"), Some("2.62.0"));
197 }
198
199 #[tokio::test]
200 async fn test_parse_v2() {
201 let content = r#"{
202 "pins": [
203 {
204 "identity": "swift-nio",
205 "kind": "remoteSourceControl",
206 "location": "https://github.com/apple/swift-nio.git",
207 "state": {
208 "revision": "cf4e6a20",
209 "version": "2.62.0"
210 }
211 }
212 ],
213 "version": 2
214}"#;
215 let tmp = tempfile::tempdir().unwrap();
216 let path = tmp.path().join("Package.resolved");
217 tokio::fs::write(&path, content).await.unwrap();
218
219 let parser = SwiftLockParser;
220 let resolved = parser.parse_lockfile(&path).await.unwrap();
221 assert_eq!(resolved.len(), 1);
222 assert_eq!(resolved.get_version("apple/swift-nio"), Some("2.62.0"));
223 }
224
225 #[tokio::test]
226 async fn test_parse_v3_with_origin_hash() {
227 let content = r#"{
228 "pins": [
229 {
230 "identity": "vapor",
231 "kind": "remoteSourceControl",
232 "location": "https://github.com/vapor/vapor",
233 "state": {
234 "revision": "abc123",
235 "version": "4.89.3"
236 },
237 "originHash": "sha256:abc"
238 }
239 ],
240 "version": 3
241}"#;
242 let tmp = tempfile::tempdir().unwrap();
243 let path = tmp.path().join("Package.resolved");
244 tokio::fs::write(&path, content).await.unwrap();
245
246 let parser = SwiftLockParser;
247 let resolved = parser.parse_lockfile(&path).await.unwrap();
248 assert_eq!(resolved.len(), 1);
249 assert_eq!(resolved.get_version("vapor/vapor"), Some("4.89.3"));
250 }
251
252 #[tokio::test]
253 async fn test_parse_filesystem_kind() {
254 let content = r#"{
255 "pins": [
256 {
257 "identity": "local-pkg",
258 "kind": "fileSystem",
259 "location": "/path/to/local",
260 "state": {
261 "version": "1.0.0"
262 }
263 }
264 ],
265 "version": 2
266}"#;
267 let tmp = tempfile::tempdir().unwrap();
268 let path = tmp.path().join("Package.resolved");
269 tokio::fs::write(&path, content).await.unwrap();
270
271 let parser = SwiftLockParser;
272 let resolved = parser.parse_lockfile(&path).await.unwrap();
273 assert_eq!(resolved.len(), 1);
274 let pkg = resolved.get("local-pkg").unwrap();
275 assert!(matches!(pkg.source, ResolvedSource::Path { .. }));
276 }
277
278 #[tokio::test]
279 async fn test_invalid_json_returns_error() {
280 let tmp = tempfile::tempdir().unwrap();
281 let path = tmp.path().join("Package.resolved");
282 tokio::fs::write(&path, b"not valid json").await.unwrap();
283
284 let parser = SwiftLockParser;
285 let result = parser.parse_lockfile(&path).await;
286 assert!(result.is_err());
287 }
288
289 #[tokio::test]
290 async fn test_unknown_version_returns_empty() {
291 let content = r#"{
292 "pins": [
293 {
294 "identity": "some-pkg",
295 "kind": "remoteSourceControl",
296 "location": "https://github.com/foo/bar",
297 "state": { "version": "1.0.0" }
298 }
299 ],
300 "version": 99
301}"#;
302 let tmp = tempfile::tempdir().unwrap();
303 let path = tmp.path().join("Package.resolved");
304 tokio::fs::write(&path, content).await.unwrap();
305
306 let parser = SwiftLockParser;
307 let resolved = parser.parse_lockfile(&path).await.unwrap();
308 assert_eq!(resolved.len(), 0);
309 }
310
311 #[tokio::test]
312 async fn test_v1_missing_object_returns_empty() {
313 let content = r#"{"version": 1}"#;
314 let tmp = tempfile::tempdir().unwrap();
315 let path = tmp.path().join("Package.resolved");
316 tokio::fs::write(&path, content).await.unwrap();
317
318 let parser = SwiftLockParser;
319 let resolved = parser.parse_lockfile(&path).await.unwrap();
320 assert_eq!(resolved.len(), 0);
321 }
322
323 #[tokio::test]
324 async fn test_v2_missing_pins_returns_empty() {
325 let content = r#"{"version": 2}"#;
326 let tmp = tempfile::tempdir().unwrap();
327 let path = tmp.path().join("Package.resolved");
328 tokio::fs::write(&path, content).await.unwrap();
329
330 let parser = SwiftLockParser;
331 let resolved = parser.parse_lockfile(&path).await.unwrap();
332 assert_eq!(resolved.len(), 0);
333 }
334
335 #[tokio::test]
336 async fn test_v1_strips_v_prefix() {
337 let content = r#"{
338 "object": {
339 "pins": [
340 {
341 "package": "MyPkg",
342 "repositoryURL": "https://github.com/org/mypkg.git",
343 "state": {
344 "revision": "abc",
345 "version": "v3.1.4"
346 }
347 }
348 ]
349 },
350 "version": 1
351}"#;
352 let tmp = tempfile::tempdir().unwrap();
353 let path = tmp.path().join("Package.resolved");
354 tokio::fs::write(&path, content).await.unwrap();
355
356 let parser = SwiftLockParser;
357 let resolved = parser.parse_lockfile(&path).await.unwrap();
358 assert_eq!(resolved.get_version("org/mypkg"), Some("3.1.4"));
359 }
360
361 #[tokio::test]
362 async fn test_v2_strips_v_prefix() {
363 let content = r#"{
364 "pins": [
365 {
366 "identity": "mypkg",
367 "kind": "remoteSourceControl",
368 "location": "https://github.com/org/mypkg",
369 "state": { "revision": "abc", "version": "v2.0.0" }
370 }
371 ],
372 "version": 2
373}"#;
374 let tmp = tempfile::tempdir().unwrap();
375 let path = tmp.path().join("Package.resolved");
376 tokio::fs::write(&path, content).await.unwrap();
377
378 let parser = SwiftLockParser;
379 let resolved = parser.parse_lockfile(&path).await.unwrap();
380 assert_eq!(resolved.get_version("org/mypkg"), Some("2.0.0"));
381 }
382
383 #[tokio::test]
384 async fn test_v1_fallback_to_package_name_when_url_has_no_identity() {
385 let content = r#"{
387 "object": {
388 "pins": [
389 {
390 "package": "FallbackName",
391 "repositoryURL": "https://example.com/onlyone",
392 "state": {
393 "revision": "abc",
394 "version": "1.0.0"
395 }
396 }
397 ]
398 },
399 "version": 1
400}"#;
401 let tmp = tempfile::tempdir().unwrap();
402 let path = tmp.path().join("Package.resolved");
403 tokio::fs::write(&path, content).await.unwrap();
404
405 let parser = SwiftLockParser;
406 let resolved = parser.parse_lockfile(&path).await.unwrap();
407 assert_eq!(resolved.get_version("FallbackName"), Some("1.0.0"));
408 }
409
410 #[tokio::test]
411 async fn test_skip_branch_only_pins() {
412 let content = r#"{
413 "pins": [
414 {
415 "identity": "tool",
416 "kind": "remoteSourceControl",
417 "location": "https://github.com/dev/tool",
418 "state": {
419 "branch": "main",
420 "revision": "abc123"
421 }
422 }
423 ],
424 "version": 2
425}"#;
426 let tmp = tempfile::tempdir().unwrap();
427 let path = tmp.path().join("Package.resolved");
428 tokio::fs::write(&path, content).await.unwrap();
429
430 let parser = SwiftLockParser;
431 let resolved = parser.parse_lockfile(&path).await.unwrap();
432 assert_eq!(resolved.len(), 0);
434 }
435}