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
200#[allow(clippy::option_if_let_else)] fn parse_descriptor(lockfile_path: &Path, descriptor: &str) -> Result<(String, String)> {
202 if let Some(rest) = descriptor.strip_prefix('@') {
206 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 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)] fn parse_resolution(resolution: &str, package_name: &str) -> (String, DependencySource) {
235 if let Some(colon_idx) = resolution.find(':') {
244 let before_colon = &resolution[..colon_idx];
245 let after_colon = &resolution[colon_idx + 1..];
246
247 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 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 (
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 assert_eq!(entries.len(), 1);
423 assert_eq!(entries[0].name, "left-pad");
424 assert_eq!(entries[0].version, "1.3.0");
425 }
426}