cuenv_workspaces/parsers/javascript/
yarn_classic.rs

1use 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/// Parser for Yarn Classic (v1.x) `yarn.lock` files.
11#[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        // Try to use yarn_lock_parser for name and version extraction.
23        // If it fails (which can happen on some valid yarn.lock files), fall back to manual parsing.
24        // Note: yarn_lock_parser only provides name and version, not resolved/integrity/dependencies.
25        // We use catch_unwind because yarn_lock_parser can panic on some valid lockfiles.
26        let entries = 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        .map(|lockfile| {
37            let parsed_entries = lockfile.entries;
38            let detailed_entries = parse_lockfile_details(&contents);
39            let mut result = Vec::new();
40
41            for (i, basic_entry) in parsed_entries.iter().enumerate() {
42                let name = basic_entry.name.to_string();
43                let version = basic_entry.version.to_string();
44
45                let (resolved, integrity, dependencies) = detailed_entries.get(i).map_or_else(
46                    || (None, None, Vec::new()),
47                    |(r, i, d): &LockfileDetail| (r.clone(), i.clone(), d.clone()),
48                );
49
50                result.push(build_lockfile_entry(
51                    name,
52                    version,
53                    resolved,
54                    integrity,
55                    dependencies,
56                ));
57            }
58
59            result
60        })
61        .map_or_else(
62            || {
63                // Fall back to fully manual parsing if yarn_lock_parser fails or panics
64                parse_yarn_lockfile_fully(&contents, lockfile_path)
65            },
66            |entries| entries,
67        );
68
69        Ok(entries)
70    }
71
72    fn supports_lockfile(&self, path: &Path) -> bool {
73        // Check filename first as a fast pre-filter
74        if !matches!(path.file_name().and_then(|n| n.to_str()), Some("yarn.lock")) {
75            return false;
76        }
77
78        // If the file doesn't exist yet, we can't sniff content - accept based on filename only
79        if !path.exists() {
80            return true;
81        }
82
83        // Read a small prefix of the file to distinguish Yarn v1 from v2+
84        // Yarn Classic (v1) uses a header like "# yarn lockfile v1"
85        // Yarn Modern (v2+) uses YAML with structures like "__metadata:"
86        if let Ok(contents) = fs::read_to_string(path) {
87            // Yarn Classic v1 has a specific header comment
88            if contents.contains("# yarn lockfile v1") {
89                return true;
90            }
91
92            // If it looks like YAML with __metadata, it's Modern (not Classic)
93            if contents.contains("__metadata:") {
94                return false;
95            }
96
97            // If it contains "@npm:" it's Yarn Modern format (not Classic)
98            if contents.contains("@npm:") {
99                return false;
100            }
101
102            // If we see v1-style unquoted package descriptors without Modern indicators,
103            // it's likely Classic
104            for line in contents.lines().take(30) {
105                // v1 has descriptors like: lodash@^4.17.21:
106                // Modern has descriptors like: "lodash@npm:^4.17.21":
107                if !line.starts_with(' ')
108                    && !line.starts_with('\t')
109                    && !line.starts_with('#')
110                    && line.contains('@')
111                    && line.ends_with(':')
112                    && !line.starts_with('"')
113                // Modern uses quoted keys
114                {
115                    return true;
116                }
117            }
118        }
119
120        // Default to false if we can't determine - let Yarn Modern try
121        false
122    }
123
124    fn lockfile_name(&self) -> &'static str {
125        "yarn.lock"
126    }
127}
128
129/// Build a `LockfileEntry` from parsed components
130fn build_lockfile_entry(
131    name: String,
132    version: String,
133    resolved: Option<String>,
134    integrity: Option<String>,
135    dependencies: Vec<DependencyRef>,
136) -> LockfileEntry {
137    let source = if let Some(resolved_url) = resolved {
138        if resolved_url.starts_with("git+") || resolved_url.contains("://github.com/") {
139            DependencySource::Git(resolved_url)
140        } else if resolved_url.starts_with("file:") {
141            DependencySource::Path(PathBuf::from(resolved_url.trim_start_matches("file:")))
142        } else {
143            DependencySource::Registry(resolved_url)
144        }
145    } else {
146        DependencySource::Registry(format!("npm:{name}"))
147    };
148
149    LockfileEntry {
150        name,
151        version,
152        source,
153        checksum: integrity,
154        dependencies,
155        is_workspace_member: false,
156    }
157}
158
159/// Parse additional details from Yarn Classic lockfile that `yarn_lock_parser` doesn't provide.
160/// Returns a vector of (`resolved_url`, `integrity`, `dependencies`) in the same order as entries appear.
161fn parse_lockfile_details(contents: &str) -> Vec<LockfileDetail> {
162    let mut details = Vec::new();
163    let mut current_resolved: Option<String> = None;
164    let mut current_integrity: Option<String> = None;
165    let mut current_dependencies = Vec::new();
166    let mut in_entry = false;
167
168    for line in contents.lines() {
169        let trimmed = line.trim();
170
171        // Skip comments and empty lines
172        if trimmed.is_empty() || trimmed.starts_with('#') {
173            continue;
174        }
175
176        // Check if this is a package header (no leading whitespace)
177        if !line.starts_with(' ') && !line.starts_with('\t') {
178            // Save the previous entry if exists
179            if in_entry {
180                details.push((
181                    current_resolved.take(),
182                    current_integrity.take(),
183                    std::mem::take(&mut current_dependencies),
184                ));
185            }
186            in_entry = true;
187        } else if in_entry {
188            // Parse entry properties
189            if let Some(resolved) = trimmed.strip_prefix("resolved ") {
190                current_resolved = Some(resolved.trim_matches('"').to_string());
191            } else if let Some(integrity) = trimmed.strip_prefix("integrity ") {
192                current_integrity = Some(integrity.trim_matches('"').to_string());
193            } else if trimmed.starts_with("dependencies:")
194                || trimmed.starts_with("optionalDependencies:")
195            {
196                // Dependencies section marker - will be parsed in next lines
197            } else if trimmed.contains(' ')
198                && !trimmed.starts_with('"')
199                && !trimmed.starts_with("version ")
200            {
201                // Dependency line: name "version"
202                let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
203                if parts.len() == 2 {
204                    let dep_name = parts[0].trim();
205                    let dep_version = parts[1].trim_matches('"');
206                    if !dep_name.is_empty() && !dep_version.is_empty() {
207                        current_dependencies.push(DependencyRef {
208                            name: dep_name.to_string(),
209                            version_req: dep_version.to_string(),
210                        });
211                    }
212                }
213            }
214        }
215    }
216
217    // Save the last entry
218    if in_entry {
219        details.push((current_resolved, current_integrity, current_dependencies));
220    }
221
222    details
223}
224
225/// Fully manual parser for Yarn Classic lockfiles (used as fallback when `yarn_lock_parser` fails).
226/// This parses everything including name, version, resolved, integrity, and dependencies.
227fn 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        // Skip comments and empty lines
240        if trimmed.is_empty() || trimmed.starts_with('#') {
241            continue;
242        }
243
244        // Check if this is a package header (no leading whitespace)
245        if !line.starts_with(' ') && !line.starts_with('\t') {
246            // Save the previous entry if exists
247            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            // Parse the package name from the descriptor
260            // Format: "package-name@^1.0.0" or package-name@^1.0.0:
261            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                // Scoped package: @scope/name@version
266                if let Some(second_at) = rest.find('@') {
267                    format!("@{}", &rest[..second_at])
268                } else {
269                    first_descriptor.to_string()
270                }
271            } else {
272                // Regular package: name@version
273                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            // Parse entry properties
284            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                // Dependencies section marker
294            } else if trimmed.contains(' ') && !trimmed.starts_with('"') {
295                // Dependency line: name "version"
296                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    // Save the last entry
312    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        // Should have 2 entries (left-pad and repeat-string)
414        // Multiple descriptors should result in a single entry
415        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}