cuenv_workspaces/parsers/javascript/
yarn_modern.rs

1use crate::core::traits::LockfileParser;
2use crate::core::types::{DependencyRef, DependencySource, LockfileEntry};
3use crate::error::{Error, Result};
4use serde::Deserialize;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Parser for Yarn Modern (v2+, Berry) `yarn.lock` files.
10#[derive(Debug, Default, Clone, Copy)]
11pub struct YarnModernLockfileParser;
12
13impl LockfileParser for YarnModernLockfileParser {
14    fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
15        let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
16            source,
17            path: Some(lockfile_path.to_path_buf()),
18            operation: "reading yarn.lock".to_string(),
19        })?;
20
21        // Parse as YAML value first to extract and separate metadata
22        let value: serde_yaml::Value =
23            serde_yaml::from_str(&contents).map_err(|source| Error::LockfileParseFailed {
24                path: lockfile_path.to_path_buf(),
25                message: source.to_string(),
26            })?;
27
28        // Extract packages, excluding __metadata
29        let packages = if let serde_yaml::Value::Mapping(mut map) = value {
30            // Remove __metadata key if present
31            map.remove(serde_yaml::Value::String("__metadata".to_string()));
32
33            // Deserialize remaining map into packages
34            serde_yaml::from_value::<std::collections::BTreeMap<String, YarnModernPackage>>(
35                serde_yaml::Value::Mapping(map),
36            )
37            .map_err(|source| Error::LockfileParseFailed {
38                path: lockfile_path.to_path_buf(),
39                message: format!("Failed to deserialize packages: {source}"),
40            })?
41        } else {
42            return Err(Error::LockfileParseFailed {
43                path: lockfile_path.to_path_buf(),
44                message: "Expected YAML mapping at root level".to_string(),
45            });
46        };
47
48        let mut entries = Vec::new();
49
50        for (descriptor, package_info) in packages {
51            if let Some(entry) = entry_from_package(lockfile_path, &descriptor, &package_info)? {
52                entries.push(entry);
53            }
54        }
55
56        Ok(entries)
57    }
58
59    fn supports_lockfile(&self, path: &Path) -> bool {
60        // Check filename first as a fast pre-filter
61        if !matches!(path.file_name().and_then(|n| n.to_str()), Some("yarn.lock")) {
62            return false;
63        }
64
65        // If the file doesn't exist yet, we can't sniff content - accept based on filename only
66        if !path.exists() {
67            return true;
68        }
69
70        // Read a small prefix of the file to distinguish Yarn v2+ from v1
71        // Yarn Modern (v2+) uses YAML with structures like "__metadata:"
72        // Yarn Classic (v1) uses a header like "# yarn lockfile v1"
73        if let Ok(contents) = fs::read_to_string(path) {
74            // Yarn Modern has __metadata entries
75            if contents.contains("__metadata:") {
76                return true;
77            }
78
79            // If it has the v1 header, it's Classic, not Modern
80            if contents.contains("# yarn lockfile v1") {
81                return false;
82            }
83
84            // Check for Yarn Modern YAML-style package descriptors with @npm: protocol
85            if contents.contains("@npm:") {
86                // Look for quoted keys with protocol specifiers (npm:, workspace:, etc.)
87                for line in contents.lines().take(30) {
88                    if line.trim().starts_with('"')
89                        && line.contains("@npm:")
90                        && line.trim().ends_with(':')
91                    {
92                        // Found a Yarn Modern-style package descriptor
93                        return true;
94                    }
95                }
96            }
97
98            // If we see unquoted v1-style descriptors, it's Classic
99            for line in contents.lines().take(30) {
100                if !line.starts_with(' ')
101                    && !line.starts_with('\t')
102                    && !line.starts_with('#')
103                    && line.contains('@')
104                    && line.ends_with(':')
105                    && !line.starts_with('"')
106                // v1 doesn't quote keys
107                {
108                    return false; // It's Classic, not Modern
109                }
110            }
111        }
112
113        // Default to false if we can't determine
114        false
115    }
116
117    fn lockfile_name(&self) -> &'static str {
118        "yarn.lock"
119    }
120}
121
122#[derive(Debug, Deserialize, Default)]
123#[serde(rename_all = "camelCase")]
124struct YarnModernPackage {
125    /// Resolution string (e.g., "left-pad@npm:1.3.0")
126    #[serde(default)]
127    resolution: Option<String>,
128    /// Version (sometimes present separately)
129    #[serde(default)]
130    version: Option<String>,
131    /// Dependencies map
132    #[serde(default)]
133    dependencies: BTreeMap<String, String>,
134    /// Dev dependencies
135    #[serde(default, rename = "devDependencies")]
136    dev_dependencies: BTreeMap<String, String>,
137    /// Peer dependencies
138    #[serde(default, rename = "peerDependencies")]
139    peer_dependencies: BTreeMap<String, String>,
140    /// Optional dependencies
141    #[serde(default, rename = "optionalDependencies")]
142    optional_dependencies: BTreeMap<String, String>,
143    /// Checksum
144    #[serde(default)]
145    checksum: Option<String>,
146    /// Language and package manager metadata
147    #[serde(default, rename = "languageName")]
148    #[allow(dead_code)]
149    language_name: Option<String>,
150    /// Link type
151    #[serde(default, rename = "linkType")]
152    link_type: Option<String>,
153}
154
155fn entry_from_package(
156    lockfile_path: &Path,
157    descriptor: &str,
158    package: &YarnModernPackage,
159) -> Result<Option<LockfileEntry>> {
160    // Parse the descriptor to get the name and version requirement
161    // Descriptors look like: "left-pad@npm:^1.3.0", "@babel/core@npm:^7.0.0"
162    let (name, _version_req) = parse_descriptor(lockfile_path, descriptor)?;
163
164    // Get the resolved version from either resolution or version field
165    let (version, source) = if let Some(resolution) = &package.resolution {
166        parse_resolution(resolution, &name)
167    } else if let Some(version) = &package.version {
168        (
169            version.clone(),
170            DependencySource::Registry(format!("npm:{name}@{version}")),
171        )
172    } else {
173        return Err(Error::LockfileParseFailed {
174            path: lockfile_path.to_path_buf(),
175            message: format!("Package {descriptor} has no resolution or version"),
176        });
177    };
178
179    // Determine if this is a workspace member
180    let is_workspace_member = package.link_type.as_deref().is_some_and(|lt| lt == "soft")
181        || matches!(&source, DependencySource::Workspace(_));
182
183    // Extract dependencies
184    let mut dependencies = Vec::new();
185    push_dependencies(&mut dependencies, &package.dependencies);
186    push_dependencies(&mut dependencies, &package.dev_dependencies);
187    push_dependencies(&mut dependencies, &package.peer_dependencies);
188    push_dependencies(&mut dependencies, &package.optional_dependencies);
189
190    Ok(Some(LockfileEntry {
191        name,
192        version,
193        source,
194        checksum: package.checksum.clone(),
195        dependencies,
196        is_workspace_member,
197    }))
198}
199
200fn parse_descriptor(lockfile_path: &Path, descriptor: &str) -> Result<(String, String)> {
201    // Descriptors have format: "package-name@protocol:version"
202    // Examples: "left-pad@npm:^1.3.0", "@babel/core@npm:^7.0.0", "my-pkg@workspace:."
203
204    if let Some(rest) = descriptor.strip_prefix('@') {
205        // Scoped package: "@scope/name@protocol:version"
206        if let Some(second_at) = rest.find('@') {
207            let at_idx = second_at + 1;
208            let name = &descriptor[..at_idx];
209            let rest = &descriptor[at_idx + 1..];
210            Ok((name.to_string(), rest.to_string()))
211        } else {
212            Err(Error::LockfileParseFailed {
213                path: lockfile_path.to_path_buf(),
214                message: format!("Invalid scoped package descriptor: {descriptor}"),
215            })
216        }
217    } else {
218        // Regular package: "name@protocol:version"
219        if let Some(at_idx) = descriptor.find('@') {
220            let name = &descriptor[..at_idx];
221            let rest = &descriptor[at_idx + 1..];
222            Ok((name.to_string(), rest.to_string()))
223        } else {
224            Err(Error::LockfileParseFailed {
225                path: lockfile_path.to_path_buf(),
226                message: format!("Invalid package descriptor: {descriptor}"),
227            })
228        }
229    }
230}
231
232fn parse_resolution(resolution: &str, package_name: &str) -> (String, DependencySource) {
233    // Resolutions look like:
234    // - "left-pad@npm:1.3.0"
235    // - "@babel/core@npm:7.22.5"
236    // - "my-package@workspace:packages/my-package"
237    // - "some-lib@git+https://github.com/user/repo.git#commit:abc123"
238    // - "local-dep@file:../local-dep"
239
240    // Find the protocol separator
241    if let Some(colon_idx) = resolution.find(':') {
242        let before_colon = &resolution[..colon_idx];
243        let after_colon = &resolution[colon_idx + 1..];
244
245        // Extract protocol
246        let protocol = if let Some(at_idx) = before_colon.rfind('@') {
247            &before_colon[at_idx + 1..]
248        } else {
249            before_colon
250        };
251
252        match protocol {
253            "npm" | "registry" => (
254                after_colon.to_string(),
255                DependencySource::Registry(format!("npm:{package_name}@{after_colon}")),
256            ),
257            "workspace" => (
258                "0.0.0".to_string(),
259                DependencySource::Workspace(PathBuf::from(after_colon)),
260            ),
261            "git" | "git+https" | "git+ssh" => {
262                // Git resolution: extract commit hash if present
263                let (repo, commit) = if let Some(hash_idx) = after_colon.rfind('#') {
264                    (
265                        &after_colon[..hash_idx],
266                        after_colon[hash_idx + 1..].to_string(),
267                    )
268                } else {
269                    (after_colon, "HEAD".to_string())
270                };
271                (
272                    commit.clone(),
273                    DependencySource::Git(format!("{protocol}:{repo}#{commit}")),
274                )
275            }
276            "file" => (
277                "0.0.0".to_string(),
278                DependencySource::Path(PathBuf::from(after_colon)),
279            ),
280            _ => (
281                after_colon.to_string(),
282                DependencySource::Registry(resolution.to_string()),
283            ),
284        }
285    } else {
286        // No protocol separator, treat as version
287        (
288            resolution.to_string(),
289            DependencySource::Registry(format!("npm:{package_name}@{resolution}")),
290        )
291    }
292}
293
294fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
295    for (name, version_req) in deps {
296        target.push(DependencyRef {
297            name: name.clone(),
298            version_req: version_req.clone(),
299        });
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::io::Write;
307    use tempfile::NamedTempFile;
308
309    #[test]
310    fn parses_basic_yarn_modern_lock() {
311        let yaml = r#"
312"left-pad@npm:^1.3.0":
313  version: 1.3.0
314  resolution: "left-pad@npm:1.3.0"
315  checksum: sha512-test123
316  languageName: node
317  linkType: hard
318
319"react@npm:^18.0.0":
320  version: 18.2.0
321  resolution: "react@npm:18.2.0"
322  dependencies:
323    loose-envify: "npm:^1.1.0"
324  languageName: node
325  linkType: hard
326"#;
327
328        let mut file = NamedTempFile::new().unwrap();
329        file.write_all(yaml.as_bytes()).unwrap();
330
331        let parser = YarnModernLockfileParser;
332        let entries = parser.parse(file.path()).unwrap();
333
334        assert!(!entries.is_empty());
335
336        let left_pad = entries.iter().find(|e| e.name == "left-pad");
337        assert!(left_pad.is_some());
338        let left_pad = left_pad.unwrap();
339        assert_eq!(left_pad.version, "1.3.0");
340        assert!(!left_pad.is_workspace_member);
341
342        let react = entries.iter().find(|e| e.name == "react");
343        assert!(react.is_some());
344        let react = react.unwrap();
345        assert_eq!(react.version, "18.2.0");
346        assert_eq!(react.dependencies.len(), 1);
347    }
348
349    #[test]
350    fn parses_workspace_packages() {
351        let yaml = r#"
352"my-package@workspace:.":
353  version: 0.0.0
354  resolution: "my-package@workspace:."
355  linkType: soft
356  languageName: unknown
357"#;
358
359        let mut file = NamedTempFile::new().unwrap();
360        file.write_all(yaml.as_bytes()).unwrap();
361
362        let parser = YarnModernLockfileParser;
363        let entries = parser.parse(file.path()).unwrap();
364
365        assert_eq!(entries.len(), 1);
366        assert_eq!(entries[0].name, "my-package");
367        assert!(entries[0].is_workspace_member);
368    }
369
370    #[test]
371    fn parses_scoped_packages() {
372        let yaml = r#"
373"@babel/core@npm:^7.22.0":
374  version: 7.22.5
375  resolution: "@babel/core@npm:7.22.5"
376  languageName: node
377  linkType: hard
378"#;
379
380        let mut file = NamedTempFile::new().unwrap();
381        file.write_all(yaml.as_bytes()).unwrap();
382
383        let parser = YarnModernLockfileParser;
384        let entries = parser.parse(file.path()).unwrap();
385
386        assert_eq!(entries.len(), 1);
387        assert_eq!(entries[0].name, "@babel/core");
388        assert_eq!(entries[0].version, "7.22.5");
389    }
390
391    #[test]
392    fn supports_expected_filename() {
393        let parser = YarnModernLockfileParser;
394        assert!(parser.supports_lockfile(Path::new("/tmp/yarn.lock")));
395        assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
396    }
397
398    #[test]
399    fn handles_metadata_entries() {
400        let yaml = r#"
401__metadata:
402  version: 6
403  cacheKey: 8
404
405"left-pad@npm:^1.3.0":
406  version: 1.3.0
407  resolution: "left-pad@npm:1.3.0"
408  checksum: sha512-test123
409  languageName: node
410  linkType: hard
411"#;
412
413        let mut file = NamedTempFile::new().unwrap();
414        file.write_all(yaml.as_bytes()).unwrap();
415
416        let parser = YarnModernLockfileParser;
417        let entries = parser.parse(file.path()).unwrap();
418
419        // Should only have left-pad, __metadata should be excluded
420        assert_eq!(entries.len(), 1);
421        assert_eq!(entries[0].name, "left-pad");
422        assert_eq!(entries[0].version, "1.3.0");
423    }
424}