chaste_npm/
lib.rs

1// SPDX-FileCopyrightText: 2024 The Chaste Authors
2// SPDX-License-Identifier: Apache-2.0 OR BSD-2-Clause
3
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::path::Path;
7use std::{fs, io};
8
9use chaste_types::{
10    Chastefile, ChastefileBuilder, Checksums, Dependency, DependencyBuilder, DependencyKind,
11    InstallationBuilder, Integrity, ModulePath, PackageBuilder, PackageID, PackageName,
12    PackageSource, SourceVersionSpecifier,
13};
14
15pub use crate::error::{Error, Result};
16
17use crate::types::{DependencyTreePackage, PackageLock, PeerDependencyMeta};
18
19mod error;
20#[cfg(test)]
21mod tests;
22mod types;
23
24pub static LOCKFILE_NAME: &str = "package-lock.json";
25pub static SHRINKWRAP_NAME: &str = "npm-shrinkwrap.json";
26
27struct PackageParser<'a> {
28    package_lock: &'a PackageLock<'a>,
29    chastefile_builder: ChastefileBuilder,
30    path_pid: HashMap<&'a Cow<'a, str>, PackageID>,
31}
32
33fn recognize_source(resolved: &str) -> Option<PackageSource> {
34    match resolved {
35        // XXX: The registry can be overriden via config. https://docs.npmjs.com/cli/v10/using-npm/config#registry
36        // Also per scope (see v3_scope_registry test.)
37        // npm seems to always output npmjs instead of mirrors, even if overriden.
38        r if r.starts_with("https://registry.npmjs.org/") => Some(PackageSource::Npm),
39
40        r if r.starts_with("git+") => Some(PackageSource::Git { url: r.to_string() }),
41
42        _ => None,
43    }
44}
45
46fn parse_package(
47    path: &ModulePath,
48    tree_package: &DependencyTreePackage,
49) -> Result<PackageBuilder> {
50    let mut name = tree_package
51        .name
52        .as_ref()
53        .map(|s| PackageName::new(s.to_string()))
54        .transpose()?;
55    // Most packages don't have it as it's implied by the path.
56    // So now we have to unimply it.
57    if name.is_none() {
58        name = path.implied_package_name();
59    }
60    let mut pkg = PackageBuilder::new(name, tree_package.version.as_ref().map(|s| s.to_string()));
61    if let Some(integrity) = &tree_package.integrity {
62        let inte: Integrity = integrity.parse()?;
63        if !inte.hashes.is_empty() {
64            pkg.checksums(Checksums::Tarball(inte));
65        }
66    }
67    if let Some(resolved) = &tree_package.resolved {
68        if let Some(source) = recognize_source(resolved) {
69            pkg.source(source);
70        }
71    }
72    Ok(pkg)
73}
74
75fn find_pid<'a>(
76    path: &str,
77    name: &str,
78    path_pid: &HashMap<&'a Cow<'a, str>, PackageID>,
79) -> Result<PackageID> {
80    let potential_path = match path {
81        "" => format!("node_modules/{name}"),
82        p => format!("{p}/node_modules/{name}"),
83    };
84    if let Some(pid) = path_pid.get(&Cow::Borrowed(potential_path.as_str())) {
85        return Ok(*pid);
86    }
87    if let Some((parent_path, _)) = path.rsplit_once('/') {
88        return find_pid(parent_path, name, path_pid);
89    }
90    if !path.is_empty() {
91        return find_pid("", name, path_pid);
92    }
93    Err(Error::DependencyNotFound(name.to_string()))
94}
95
96fn parse_dependencies<'a>(
97    path: &str,
98    tree_package: &'a DependencyTreePackage,
99    path_pid: &HashMap<&'a Cow<'a, str>, PackageID>,
100    self_pid: PackageID,
101) -> Result<Vec<Dependency>> {
102    let capacity = tree_package.dependencies.len() + tree_package.dev_dependencies.len();
103    let mut dependencies = Vec::with_capacity(capacity);
104    for (deps, kind_) in [
105        (&tree_package.dependencies, DependencyKind::Dependency),
106        (
107            &tree_package.dev_dependencies,
108            DependencyKind::DevDependency,
109        ),
110        (
111            &tree_package.peer_dependencies,
112            DependencyKind::PeerDependency,
113        ),
114        (
115            &tree_package.optional_dependencies,
116            DependencyKind::OptionalDependency,
117        ),
118    ] {
119        for (n, svs) in deps {
120            let kind = match kind_ {
121                DependencyKind::PeerDependency
122                    if matches!(
123                        tree_package.peer_dependencies_meta.get(n),
124                        Some(PeerDependencyMeta {
125                            optional: Some(true),
126                        })
127                    ) =>
128                {
129                    DependencyKind::OptionalPeerDependency
130                }
131                k => k,
132            };
133            match find_pid(path, n, path_pid) {
134                Ok(pid) => {
135                    let mut dep = DependencyBuilder::new(kind, self_pid, pid);
136                    let svs = SourceVersionSpecifier::new(svs.to_string())?;
137                    if svs.aliased_package_name().is_some() {
138                        dep.alias_name(PackageName::new(n.to_string())?);
139                    }
140                    dep.svs(svs);
141                    dependencies.push(dep.build());
142                }
143                // Allowed to fail. Yes, even if not marked as optional - it wasn't getting installed
144                // before npm v7, and packages can opt out with --legacy-peer-deps=true
145                // https://github.com/npm/rfcs/blob/main/implemented/0025-install-peer-deps.md
146                Err(Error::DependencyNotFound(_)) if kind.is_peer() || kind.is_optional() => {}
147
148                Err(e) => return Err(e),
149            }
150        }
151    }
152
153    debug_assert!(dependencies.len() >= capacity);
154
155    Ok(dependencies)
156}
157
158impl<'a> PackageParser<'a> {
159    fn new(package_lock: &'a PackageLock) -> Self {
160        Self {
161            package_lock,
162            chastefile_builder: ChastefileBuilder::new(),
163            path_pid: HashMap::with_capacity(package_lock.packages.len()),
164        }
165    }
166
167    fn resolve(mut self) -> Result<Chastefile> {
168        // First, go through all packages, but ignore entries that say to link to another package.
169        // We have to do that before we can resolve the links to their respective packages.
170        for (package_path, tree_package) in self
171            .package_lock
172            .packages
173            .iter()
174            .filter(|(_, tp)| tp.link != Some(true))
175        {
176            let module_path = ModulePath::new(package_path.to_string())?;
177            let mut package = parse_package(&module_path, tree_package)?;
178            if package_path.is_empty() && package.get_name().is_none() {
179                package.name(Some(PackageName::new(self.package_lock.name.to_string())?));
180            }
181            let pid = match self.chastefile_builder.add_package(package.build()?) {
182                Ok(pid) => pid,
183                // If the package is already checked in, reuse it.
184                Err(chaste_types::Error::DuplicatePackage(pid)) => pid,
185                Err(e) => return Err(Error::ChasteError(e)),
186            };
187            self.path_pid.insert(package_path, pid);
188            let installation = InstallationBuilder::new(pid, module_path).build()?;
189            self.chastefile_builder
190                .add_package_installation(installation);
191            if package_path.is_empty() {
192                self.chastefile_builder.set_root_package_id(pid)?;
193
194            // XXX: This is hacky
195            } else if !package_path.starts_with("node_modules/")
196                && !package_path.contains("/node_modules/")
197            {
198                self.chastefile_builder.set_as_workspace_member(pid)?;
199            }
200        }
201        // Resolve the links.
202        for (package_path, tree_package) in self
203            .package_lock
204            .packages
205            .iter()
206            .filter(|(_, tp)| tp.link == Some(true))
207        {
208            let Some(member_path) = &tree_package.resolved else {
209                return Err(Error::WorkspaceMemberNotFound(package_path.to_string()));
210            };
211            let pid = *self.path_pid.get(member_path).unwrap();
212            self.path_pid.insert(package_path, pid);
213            let module_path = ModulePath::new(package_path.to_string())?;
214            let installation = InstallationBuilder::new(pid, module_path).build()?;
215            self.chastefile_builder
216                .add_package_installation(installation);
217        }
218        // Now, resolve package dependencies.
219        for (package_path, tree_package) in self
220            .package_lock
221            .packages
222            .iter()
223            .filter(|(_, tp)| tp.link != Some(true))
224        {
225            let pid = *self.path_pid.get(package_path).unwrap();
226            let dependencies = parse_dependencies(package_path, tree_package, &self.path_pid, pid)?;
227            self.chastefile_builder
228                .add_dependencies(dependencies.into_iter());
229        }
230        Ok(self.chastefile_builder.build()?)
231    }
232}
233
234fn parse_lock(package_lock: &PackageLock) -> Result<Chastefile> {
235    if ![2, 3].contains(&package_lock.lockfile_version) {
236        return Err(Error::UnknownLockVersion(package_lock.lockfile_version));
237    }
238    let parser = PackageParser::new(package_lock);
239    let chastefile = parser.resolve()?;
240    Ok(chastefile)
241}
242
243pub fn parse<P>(root_dir: P) -> Result<Chastefile>
244where
245    P: AsRef<Path>,
246{
247    let lockfile_contents = match fs::read_to_string(root_dir.as_ref().join(SHRINKWRAP_NAME)) {
248        Ok(c) => c,
249        Err(e) if e.kind() == io::ErrorKind::NotFound => {
250            fs::read_to_string(root_dir.as_ref().join(LOCKFILE_NAME))?
251        }
252        Err(e) => return Err(Error::IoError(e)),
253    };
254    let package_lock: PackageLock = serde_json::from_str(&lockfile_contents)?;
255    parse_lock(&package_lock)
256}