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
200#[allow(clippy::option_if_let_else)] // Complex parsing with nested conditionals - imperative is clearer
201fn parse_descriptor(lockfile_path: &Path, descriptor: &str) -> Result<(String, String)> {
202    // Descriptors have format: "package-name@protocol:version"
203    // Examples: "left-pad@npm:^1.3.0", "@babel/core@npm:^7.0.0", "my-pkg@workspace:."
204
205    if let Some(rest) = descriptor.strip_prefix('@') {
206        // Scoped package: "@scope/name@protocol:version"
207        if let Some(second_at) = rest.find('@') {
208            let at_idx = second_at + 1;
209            let name = &descriptor[..at_idx];
210            let rest = &descriptor[at_idx + 1..];
211            Ok((name.to_string(), rest.to_string()))
212        } else {
213            Err(Error::LockfileParseFailed {
214                path: lockfile_path.to_path_buf(),
215                message: format!("Invalid scoped package descriptor: {descriptor}"),
216            })
217        }
218    } else {
219        // Regular package: "name@protocol:version"
220        if let Some(at_idx) = descriptor.find('@') {
221            let name = &descriptor[..at_idx];
222            let rest = &descriptor[at_idx + 1..];
223            Ok((name.to_string(), rest.to_string()))
224        } else {
225            Err(Error::LockfileParseFailed {
226                path: lockfile_path.to_path_buf(),
227                message: format!("Invalid package descriptor: {descriptor}"),
228            })
229        }
230    }
231}
232
233#[allow(clippy::option_if_let_else)] // Complex parsing with nested conditionals - imperative is clearer
234fn parse_resolution(resolution: &str, package_name: &str) -> (String, DependencySource) {
235    // Resolutions look like:
236    // - "left-pad@npm:1.3.0"
237    // - "@babel/core@npm:7.22.5"
238    // - "my-package@workspace:packages/my-package"
239    // - "some-lib@git+https://github.com/user/repo.git#commit:abc123"
240    // - "local-dep@file:../local-dep"
241
242    // Find the protocol separator
243    if let Some(colon_idx) = resolution.find(':') {
244        let before_colon = &resolution[..colon_idx];
245        let after_colon = &resolution[colon_idx + 1..];
246
247        // Extract protocol
248        let protocol = if let Some(at_idx) = before_colon.rfind('@') {
249            &before_colon[at_idx + 1..]
250        } else {
251            before_colon
252        };
253
254        match protocol {
255            "npm" | "registry" => (
256                after_colon.to_string(),
257                DependencySource::Registry(format!("npm:{package_name}@{after_colon}")),
258            ),
259            "workspace" => (
260                "0.0.0".to_string(),
261                DependencySource::Workspace(PathBuf::from(after_colon)),
262            ),
263            "git" | "git+https" | "git+ssh" => {
264                // Git resolution: extract commit hash if present
265                let (repo, commit) = if let Some(hash_idx) = after_colon.rfind('#') {
266                    (
267                        &after_colon[..hash_idx],
268                        after_colon[hash_idx + 1..].to_string(),
269                    )
270                } else {
271                    (after_colon, "HEAD".to_string())
272                };
273                (
274                    commit.clone(),
275                    DependencySource::Git(format!("{protocol}:{repo}#{commit}")),
276                )
277            }
278            "file" => (
279                "0.0.0".to_string(),
280                DependencySource::Path(PathBuf::from(after_colon)),
281            ),
282            _ => (
283                after_colon.to_string(),
284                DependencySource::Registry(resolution.to_string()),
285            ),
286        }
287    } else {
288        // No protocol separator, treat as version
289        (
290            resolution.to_string(),
291            DependencySource::Registry(format!("npm:{package_name}@{resolution}")),
292        )
293    }
294}
295
296fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
297    for (name, version_req) in deps {
298        target.push(DependencyRef {
299            name: name.clone(),
300            version_req: version_req.clone(),
301        });
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::io::Write;
309    use tempfile::NamedTempFile;
310
311    #[test]
312    fn parses_basic_yarn_modern_lock() {
313        let yaml = r#"
314"left-pad@npm:^1.3.0":
315  version: 1.3.0
316  resolution: "left-pad@npm:1.3.0"
317  checksum: sha512-test123
318  languageName: node
319  linkType: hard
320
321"react@npm:^18.0.0":
322  version: 18.2.0
323  resolution: "react@npm:18.2.0"
324  dependencies:
325    loose-envify: "npm:^1.1.0"
326  languageName: node
327  linkType: hard
328"#;
329
330        let mut file = NamedTempFile::new().unwrap();
331        file.write_all(yaml.as_bytes()).unwrap();
332
333        let parser = YarnModernLockfileParser;
334        let entries = parser.parse(file.path()).unwrap();
335
336        assert!(!entries.is_empty());
337
338        let left_pad = entries.iter().find(|e| e.name == "left-pad");
339        assert!(left_pad.is_some());
340        let left_pad = left_pad.unwrap();
341        assert_eq!(left_pad.version, "1.3.0");
342        assert!(!left_pad.is_workspace_member);
343
344        let react = entries.iter().find(|e| e.name == "react");
345        assert!(react.is_some());
346        let react = react.unwrap();
347        assert_eq!(react.version, "18.2.0");
348        assert_eq!(react.dependencies.len(), 1);
349    }
350
351    #[test]
352    fn parses_workspace_packages() {
353        let yaml = r#"
354"my-package@workspace:.":
355  version: 0.0.0
356  resolution: "my-package@workspace:."
357  linkType: soft
358  languageName: unknown
359"#;
360
361        let mut file = NamedTempFile::new().unwrap();
362        file.write_all(yaml.as_bytes()).unwrap();
363
364        let parser = YarnModernLockfileParser;
365        let entries = parser.parse(file.path()).unwrap();
366
367        assert_eq!(entries.len(), 1);
368        assert_eq!(entries[0].name, "my-package");
369        assert!(entries[0].is_workspace_member);
370    }
371
372    #[test]
373    fn parses_scoped_packages() {
374        let yaml = r#"
375"@babel/core@npm:^7.22.0":
376  version: 7.22.5
377  resolution: "@babel/core@npm:7.22.5"
378  languageName: node
379  linkType: hard
380"#;
381
382        let mut file = NamedTempFile::new().unwrap();
383        file.write_all(yaml.as_bytes()).unwrap();
384
385        let parser = YarnModernLockfileParser;
386        let entries = parser.parse(file.path()).unwrap();
387
388        assert_eq!(entries.len(), 1);
389        assert_eq!(entries[0].name, "@babel/core");
390        assert_eq!(entries[0].version, "7.22.5");
391    }
392
393    #[test]
394    fn supports_expected_filename() {
395        let parser = YarnModernLockfileParser;
396        assert!(parser.supports_lockfile(Path::new("/tmp/yarn.lock")));
397        assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
398    }
399
400    #[test]
401    fn handles_metadata_entries() {
402        let yaml = r#"
403__metadata:
404  version: 6
405  cacheKey: 8
406
407"left-pad@npm:^1.3.0":
408  version: 1.3.0
409  resolution: "left-pad@npm:1.3.0"
410  checksum: sha512-test123
411  languageName: node
412  linkType: hard
413"#;
414
415        let mut file = NamedTempFile::new().unwrap();
416        file.write_all(yaml.as_bytes()).unwrap();
417
418        let parser = YarnModernLockfileParser;
419        let entries = parser.parse(file.path()).unwrap();
420
421        // Should only have left-pad, __metadata should be excluded
422        assert_eq!(entries.len(), 1);
423        assert_eq!(entries[0].name, "left-pad");
424        assert_eq!(entries[0].version, "1.3.0");
425    }
426}