cuenv_workspaces/parsers/javascript/
yarn_classic.rs1use crate::core::traits::LockfileParser;
2use crate::core::types::{DependencyRef, DependencySource, LockfileEntry};
3use crate::error::{Error, Result};
4use std::fs;
5use std::panic;
6use std::path::{Path, PathBuf};
7
8type LockfileDetail = (Option<String>, Option<String>, Vec<DependencyRef>);
9
10#[derive(Debug, Default, Clone, Copy)]
12pub struct YarnClassicLockfileParser;
13
14impl LockfileParser for YarnClassicLockfileParser {
15 fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
16 let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
17 source,
18 path: Some(lockfile_path.to_path_buf()),
19 operation: "reading yarn.lock".to_string(),
20 })?;
21
22 let parsed_lockfile = panic::catch_unwind(panic::AssertUnwindSafe(|| {
27 yarn_lock_parser::parse_str(&contents)
28 }))
29 .ok()
30 .and_then(
31 |r: std::result::Result<
32 yarn_lock_parser::Lockfile<'_>,
33 yarn_lock_parser::YarnLockError,
34 >| r.ok(),
35 );
36
37 let entries = if let Some(lockfile) = parsed_lockfile {
38 let parsed_entries = lockfile.entries;
39 let detailed_entries = parse_lockfile_details(&contents);
40 let mut result = Vec::new();
41
42 for (i, basic_entry) in parsed_entries.iter().enumerate() {
43 let name = basic_entry.name.to_string();
44 let version = basic_entry.version.to_string();
45
46 let (resolved, integrity, dependencies) = detailed_entries.get(i).map_or_else(
47 || (None, None, Vec::new()),
48 |(r, i, d): &LockfileDetail| (r.clone(), i.clone(), d.clone()),
49 );
50
51 result.push(build_lockfile_entry(
52 name,
53 version,
54 resolved,
55 integrity,
56 dependencies,
57 ));
58 }
59
60 result
61 } else {
62 parse_yarn_lockfile_fully(&contents, lockfile_path)
64 };
65
66 Ok(entries)
67 }
68
69 fn supports_lockfile(&self, path: &Path) -> bool {
70 if !matches!(path.file_name().and_then(|n| n.to_str()), Some("yarn.lock")) {
72 return false;
73 }
74
75 if !path.exists() {
77 return true;
78 }
79
80 if let Ok(contents) = fs::read_to_string(path) {
84 if contents.contains("# yarn lockfile v1") {
86 return true;
87 }
88
89 if contents.contains("__metadata:") {
91 return false;
92 }
93
94 if contents.contains("@npm:") {
96 return false;
97 }
98
99 for line in contents.lines().take(30) {
102 if !line.starts_with(' ')
105 && !line.starts_with('\t')
106 && !line.starts_with('#')
107 && line.contains('@')
108 && line.ends_with(':')
109 && !line.starts_with('"')
110 {
112 return true;
113 }
114 }
115 }
116
117 false
119 }
120
121 fn lockfile_name(&self) -> &'static str {
122 "yarn.lock"
123 }
124}
125
126#[allow(clippy::option_if_let_else)] fn build_lockfile_entry(
129 name: String,
130 version: String,
131 resolved: Option<String>,
132 integrity: Option<String>,
133 dependencies: Vec<DependencyRef>,
134) -> LockfileEntry {
135 let source = if let Some(resolved_url) = resolved {
136 if resolved_url.starts_with("git+") || resolved_url.contains("://github.com/") {
137 DependencySource::Git(resolved_url)
138 } else if resolved_url.starts_with("file:") {
139 DependencySource::Path(PathBuf::from(resolved_url.trim_start_matches("file:")))
140 } else {
141 DependencySource::Registry(resolved_url)
142 }
143 } else {
144 DependencySource::Registry(format!("npm:{name}"))
145 };
146
147 LockfileEntry {
148 name,
149 version,
150 source,
151 checksum: integrity,
152 dependencies,
153 is_workspace_member: false,
154 }
155}
156
157fn parse_lockfile_details(contents: &str) -> Vec<LockfileDetail> {
160 let mut details = Vec::new();
161 let mut current_resolved: Option<String> = None;
162 let mut current_integrity: Option<String> = None;
163 let mut current_dependencies = Vec::new();
164 let mut in_entry = false;
165
166 for line in contents.lines() {
167 let trimmed = line.trim();
168
169 if trimmed.is_empty() || trimmed.starts_with('#') {
171 continue;
172 }
173
174 if !line.starts_with(' ') && !line.starts_with('\t') {
176 if in_entry {
178 details.push((
179 current_resolved.take(),
180 current_integrity.take(),
181 std::mem::take(&mut current_dependencies),
182 ));
183 }
184 in_entry = true;
185 } else if in_entry {
186 if let Some(resolved) = trimmed.strip_prefix("resolved ") {
188 current_resolved = Some(resolved.trim_matches('"').to_string());
189 } else if let Some(integrity) = trimmed.strip_prefix("integrity ") {
190 current_integrity = Some(integrity.trim_matches('"').to_string());
191 } else if trimmed.starts_with("dependencies:")
192 || trimmed.starts_with("optionalDependencies:")
193 {
194 } else if trimmed.contains(' ')
196 && !trimmed.starts_with('"')
197 && !trimmed.starts_with("version ")
198 {
199 let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
201 if parts.len() == 2 {
202 let dep_name = parts[0].trim();
203 let dep_version = parts[1].trim_matches('"');
204 if !dep_name.is_empty() && !dep_version.is_empty() {
205 current_dependencies.push(DependencyRef {
206 name: dep_name.to_string(),
207 version_req: dep_version.to_string(),
208 });
209 }
210 }
211 }
212 }
213 }
214
215 if in_entry {
217 details.push((current_resolved, current_integrity, current_dependencies));
218 }
219
220 details
221}
222
223#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
226#[allow(clippy::option_if_let_else)] fn parse_yarn_lockfile_fully(contents: &str, _lockfile_path: &Path) -> Vec<LockfileEntry> {
228 let mut entries = Vec::new();
229 let mut current_name: Option<String> = None;
230 let mut current_version: Option<String> = None;
231 let mut current_resolved: Option<String> = None;
232 let mut current_integrity: Option<String> = None;
233 let mut current_dependencies = Vec::new();
234 let mut in_entry = false;
235
236 for line in contents.lines() {
237 let trimmed = line.trim();
238
239 if trimmed.is_empty() || trimmed.starts_with('#') {
241 continue;
242 }
243
244 if !line.starts_with(' ') && !line.starts_with('\t') {
246 if in_entry
248 && let (Some(name), Some(version)) = (current_name.take(), current_version.take())
249 {
250 entries.push(build_lockfile_entry(
251 name,
252 version,
253 current_resolved.take(),
254 current_integrity.take(),
255 std::mem::take(&mut current_dependencies),
256 ));
257 }
258
259 let descriptor = trimmed.trim_end_matches(':').trim_matches('"');
262 let first_descriptor = descriptor.split(',').next().unwrap_or(descriptor).trim();
263
264 let name = if let Some(rest) = first_descriptor.strip_prefix('@') {
265 if let Some(second_at) = rest.find('@') {
267 format!("@{}", &rest[..second_at])
268 } else {
269 first_descriptor.to_string()
270 }
271 } else {
272 first_descriptor
274 .split('@')
275 .next()
276 .unwrap_or(first_descriptor)
277 .to_string()
278 };
279
280 current_name = Some(name);
281 in_entry = true;
282 } else if in_entry {
283 if let Some(version) = trimmed.strip_prefix("version ") {
285 current_version = Some(version.trim_matches('"').to_string());
286 } else if let Some(resolved) = trimmed.strip_prefix("resolved ") {
287 current_resolved = Some(resolved.trim_matches('"').to_string());
288 } else if let Some(integrity) = trimmed.strip_prefix("integrity ") {
289 current_integrity = Some(integrity.trim_matches('"').to_string());
290 } else if trimmed.starts_with("dependencies:")
291 || trimmed.starts_with("optionalDependencies:")
292 {
293 } else if trimmed.contains(' ') && !trimmed.starts_with('"') {
295 let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
297 if parts.len() == 2 {
298 let dep_name = parts[0].trim();
299 let dep_version = parts[1].trim_matches('"');
300 if !dep_name.is_empty() && !dep_version.is_empty() {
301 current_dependencies.push(DependencyRef {
302 name: dep_name.to_string(),
303 version_req: dep_version.to_string(),
304 });
305 }
306 }
307 }
308 }
309 }
310
311 if in_entry && let (Some(name), Some(version)) = (current_name, current_version) {
313 entries.push(build_lockfile_entry(
314 name,
315 version,
316 current_resolved,
317 current_integrity,
318 current_dependencies,
319 ));
320 }
321
322 entries
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::io::Write;
329 use tempfile::NamedTempFile;
330
331 #[test]
332 fn parses_basic_yarn_lock() {
333 let yarn_lock = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
334# yarn lockfile v1
335
336left-pad@^1.3.0:
337 version "1.3.0"
338 resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz"
339 integrity sha512-test123
340
341react@^18.0.0:
342 version "18.2.0"
343 resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz"
344 dependencies:
345 loose-envify "^1.1.0"
346"#;
347
348 let mut file = NamedTempFile::new().unwrap();
349 file.write_all(yarn_lock.as_bytes()).unwrap();
350
351 let parser = YarnClassicLockfileParser;
352 let entries = parser.parse(file.path()).unwrap();
353
354 assert!(!entries.is_empty());
355
356 let left_pad = entries.iter().find(|e| e.name == "left-pad");
357 assert!(left_pad.is_some());
358 let left_pad = left_pad.unwrap();
359 assert_eq!(left_pad.version, "1.3.0");
360 assert!(!left_pad.is_workspace_member);
361
362 let react = entries.iter().find(|e| e.name == "react");
363 assert!(react.is_some());
364 let react = react.unwrap();
365 assert_eq!(react.version, "18.2.0");
366 assert_eq!(react.dependencies.len(), 1);
367 }
368
369 #[test]
370 fn parses_scoped_packages() {
371 let yarn_lock = r#"
372"@babel/core@^7.22.0":
373 version "7.22.5"
374 resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.5.tgz"
375 integrity sha512-abc123
376"#;
377
378 let mut file = NamedTempFile::new().unwrap();
379 file.write_all(yarn_lock.as_bytes()).unwrap();
380
381 let parser = YarnClassicLockfileParser;
382 let entries = parser.parse(file.path()).unwrap();
383
384 assert!(!entries.is_empty());
385 let babel = entries.iter().find(|e| e.name == "@babel/core");
386 assert!(babel.is_some());
387 let babel = babel.unwrap();
388 assert_eq!(babel.version, "7.22.5");
389 assert_eq!(babel.checksum.as_deref(), Some("sha512-abc123"));
390 }
391
392 #[test]
393 fn parses_multiple_descriptors_same_version() {
394 let yarn_lock = r#"
395left-pad@^1.3.0, left-pad@~1.3.0:
396 version "1.3.0"
397 resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz"
398 integrity sha512-test123
399 dependencies:
400 repeat-string "^1.0.0"
401
402repeat-string@^1.0.0:
403 version "1.6.1"
404 resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz"
405"#;
406
407 let mut file = NamedTempFile::new().unwrap();
408 file.write_all(yarn_lock.as_bytes()).unwrap();
409
410 let parser = YarnClassicLockfileParser;
411 let entries = parser.parse(file.path()).unwrap();
412
413 assert_eq!(entries.len(), 2);
416
417 let left_pad = entries.iter().find(|e| e.name == "left-pad");
418 assert!(left_pad.is_some());
419 let left_pad = left_pad.unwrap();
420 assert_eq!(left_pad.version, "1.3.0");
421 assert_eq!(left_pad.dependencies.len(), 1);
422 assert_eq!(left_pad.dependencies[0].name, "repeat-string");
423
424 let repeat_string = entries.iter().find(|e| e.name == "repeat-string");
425 assert!(repeat_string.is_some());
426 assert_eq!(repeat_string.unwrap().version, "1.6.1");
427 }
428
429 #[test]
430 fn supports_expected_filename() {
431 let parser = YarnClassicLockfileParser;
432 assert!(parser.supports_lockfile(Path::new("/tmp/yarn.lock")));
433 assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
434 }
435}