Skip to main content

deps_swift/
lockfile.rs

1//! Package.resolved lockfile parser.
2//!
3//! Supports Package.resolved format versions 1, 2, and 3.
4//!
5//! # Format Differences
6//!
7//! - v1: `object.pins[].package` + `repositoryURL`
8//! - v2/v3: `pins[].identity` + `location` (v3 adds optional `originHash`)
9
10use 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
20/// Package.resolved file parser.
21pub 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                        // For fileSystem pins, location is a local path — use identity as name.
123                        // For remote pins, derive owner/repo from the URL.
124                        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        // URL with single path segment → url_to_identity returns None → fallback to package field
386        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        // No version, should be skipped
433        assert_eq!(resolved.len(), 0);
434    }
435}