cuenv_workspaces/parsers/javascript/
yarn_modern.rs1use 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#[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 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 let packages = if let serde_yaml::Value::Mapping(mut map) = value {
30 map.remove(serde_yaml::Value::String("__metadata".to_string()));
32
33 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 if !matches!(path.file_name().and_then(|n| n.to_str()), Some("yarn.lock")) {
62 return false;
63 }
64
65 if !path.exists() {
67 return true;
68 }
69
70 if let Ok(contents) = fs::read_to_string(path) {
74 if contents.contains("__metadata:") {
76 return true;
77 }
78
79 if contents.contains("# yarn lockfile v1") {
81 return false;
82 }
83
84 if contents.contains("@npm:") {
86 for line in contents.lines().take(30) {
88 if line.trim().starts_with('"')
89 && line.contains("@npm:")
90 && line.trim().ends_with(':')
91 {
92 return true;
94 }
95 }
96 }
97
98 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 {
108 return false; }
110 }
111 }
112
113 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 #[serde(default)]
127 resolution: Option<String>,
128 #[serde(default)]
130 version: Option<String>,
131 #[serde(default)]
133 dependencies: BTreeMap<String, String>,
134 #[serde(default, rename = "devDependencies")]
136 dev_dependencies: BTreeMap<String, String>,
137 #[serde(default, rename = "peerDependencies")]
139 peer_dependencies: BTreeMap<String, String>,
140 #[serde(default, rename = "optionalDependencies")]
142 optional_dependencies: BTreeMap<String, String>,
143 #[serde(default)]
145 checksum: Option<String>,
146 #[serde(default, rename = "languageName")]
148 #[allow(dead_code)]
149 language_name: Option<String>,
150 #[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 let (name, _version_req) = parse_descriptor(lockfile_path, descriptor)?;
163
164 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 let is_workspace_member = package.link_type.as_deref().is_some_and(|lt| lt == "soft")
181 || matches!(&source, DependencySource::Workspace(_));
182
183 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 if let Some(rest) = descriptor.strip_prefix('@') {
205 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 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 if let Some(colon_idx) = resolution.find(':') {
242 let before_colon = &resolution[..colon_idx];
243 let after_colon = &resolution[colon_idx + 1..];
244
245 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 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 (
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 assert_eq!(entries.len(), 1);
421 assert_eq!(entries[0].name, "left-pad");
422 assert_eq!(entries[0].version, "1.3.0");
423 }
424}