Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix JavaScript workspace lockfile generation #1237

Merged
merged 8 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]

### Fixed
- Cargo manifest generation for workspaces
- Workspace lockfile generation for cargo, npm, yarn, and pnpm

## [5.7.1] - 2023-09-08

Expand Down
17 changes: 13 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions lockfile_generator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ rust-version = "1.68.0"
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
anyhow = "1.0.75"
glob = "0.3.1"
thiserror = "1.0.49"

[dev-dependencies]
tempfile = "3.3.0"
97 changes: 22 additions & 75 deletions lockfile_generator/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
use std::error::Error as StdError;
use std::ffi::OsString;
use std::fmt::{self, Display, Formatter};
use std::path::{Path, PathBuf};
use std::path::{Path, PathBuf, StripPrefixError};
use std::process::{Command, Stdio};
use std::string::FromUtf8Error;
use std::{fs, io};
Expand Down Expand Up @@ -135,81 +133,30 @@ impl FileRelocator {
pub type Result<T> = std::result::Result<T, Error>;

/// Lockfile generation error.
#[derive(Debug)]
#[derive(thiserror::Error, Debug)]
pub enum Error {
InvalidUtf8(FromUtf8Error),
Json(JsonError),
Io(io::Error),
ProcessCreation(String, String, io::Error),
NonZeroExit(Option<i32>, String),
#[error("{0}")]
Anyhow(#[from] anyhow::Error),
#[error("invalid manifest path: {0:?}")]
InvalidManifest(PathBuf),
#[error("utf8 parsing error")]
InvalidUtf8(#[from] FromUtf8Error),
#[error("I/O error")]
Io(#[from] io::Error),
#[error("json parsing error")]
Json(#[from] JsonError),
#[error("package manager quit unexpectedly (code: {0:?}):\n\n{1}")]
NonZeroExit(Option<i32>, String),
#[error("unsupported pip report version {1:?}, expected {0:?}")]
PipReportVersionMismatch(&'static str, String),
#[error("failed to spawn command {0}: Is {1} installed?")]
ProcessCreation(String, String, io::Error),
#[error("could not strip path prefix")]
StripPrefix(#[from] StripPrefixError),
#[error("unexpected output for {0:?}: {1}")]
UnexpectedOutput(&'static str, String),
#[error("unsupported {0:?} version {2:?}, expected {1:?}")]
UnsupportedCommandVersion(&'static str, &'static str, String),
Anyhow(anyhow::Error),
#[error("no lockfile was generated")]
NoLockfileGenerated,
}

impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::InvalidUtf8(err) => Some(err),
Self::Json(err) => Some(err),
Self::Io(err) => Some(err),
Self::ProcessCreation(_, _, err) => Some(err),
Self::Anyhow(err) => err.source(),
_ => None,
}
}
}

impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUtf8(_) => write!(f, "utf8 parsing error"),
Self::Json(_) => write!(f, "json parsing error"),
Self::Io(_) => write!(f, "I/O error"),
Self::InvalidManifest(path) => write!(f, "invalid manifest path: {path:?}"),
Self::ProcessCreation(program, tool, _) => {
write!(f, "failed to spawn command {program}: Is {tool} installed?")
},
Self::NonZeroExit(Some(code), stderr) => {
write!(f, "package manager exited with error code {code}:\n\n{stderr}")
},
Self::NonZeroExit(None, stderr) => {
write!(f, "package manager quit unexpectedly:\n\n{stderr}")
},
Self::PipReportVersionMismatch(expected, received) => {
write!(f, "unsupported pip report version {received:?}, expected {expected:?}")
},
Self::UnsupportedCommandVersion(command, expected, received) => {
write!(f, "unsupported {command:?} version {received:?}, expected {expected:?}")
},
Self::NoLockfileGenerated => write!(f, "no lockfile was generated"),
Self::Anyhow(err) => write!(f, "{err}"),
}
}
}

impl From<io::Error> for Error {
fn from(err: io::Error) -> Self {
Self::Io(err)
}
}

impl From<FromUtf8Error> for Error {
fn from(err: FromUtf8Error) -> Self {
Self::InvalidUtf8(err)
}
}

impl From<JsonError> for Error {
fn from(err: JsonError) -> Self {
Self::Json(err)
}
}

impl From<anyhow::Error> for Error {
fn from(err: anyhow::Error) -> Self {
Self::Anyhow(err)
}
}
118 changes: 111 additions & 7 deletions lockfile_generator/src/npm.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
//! JavaScript npm ecosystem.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use glob::Pattern;
use serde::Deserialize;

use crate::{Error, Generator, Result};

/// Maximum upwards travelsal when searching for a workspace root.
const WORKSPACE_ROOT_RECURSION_LIMIT: usize = 16;

pub struct Npm;

impl Generator for Npm {
fn lockfile_path(&self, manifest_path: &Path) -> Result<PathBuf> {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("package-lock.json"))
let workspace_root = find_workspace_root(manifest_path)?;
Ok(workspace_root.join("package-lock.json"))
}

fn conflicting_files(&self, manifest_path: &Path) -> Result<Vec<PathBuf>> {
let workspace_root = find_workspace_root(manifest_path)?;
Ok(vec![
self.lockfile_path(manifest_path)?,
PathBuf::from("npm-shrinkwrap.json"),
PathBuf::from("yarn.lock"),
workspace_root.join("package-lock.json"),
workspace_root.join("npm-shrinkwrap.json"),
workspace_root.join("yarn.lock"),
])
}

Expand All @@ -33,3 +39,101 @@ impl Generator for Npm {
"npm"
}
}

/// Find the workspace root of an npm project.
pub(crate) fn find_workspace_root(manifest_path: impl AsRef<Path>) -> Result<PathBuf> {
let manifest_path = manifest_path.as_ref();
let original_root = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?
.canonicalize()?;

// Search parent directories for workspace manifests.
for path in original_root.ancestors().skip(1).take(WORKSPACE_ROOT_RECURSION_LIMIT) {
// Check if directory has an NPM manifest.
let manifest_path = path.join("package.json");
if !manifest_path.exists() {
continue;
}

// Parse manifest.
let content = fs::read_to_string(&manifest_path)?;
let manifest: PackageJson = serde_json::from_str(&content)?;

// Ignore non-workspace manifests.
let workspaces = match manifest.workspaces {
Some(workspaces) => workspaces,
None => continue,
};

// Get original manifest's location relative to this manifest.
let relative_path = original_root.strip_prefix(path)?;

// Check if original manifest location matches any workspace glob.
let is_root = workspaces.iter().any(|glob| {
let glob = glob.strip_prefix("./").unwrap_or(glob);
Pattern::new(glob).map_or(false, |pattern| pattern.matches_path(relative_path))
});

if is_root {
return Ok(path.into());
} else {
return Ok(original_root);
}
}

Ok(original_root)
}

/// Package JSON subset.
#[derive(Deserialize, Debug)]
struct PackageJson {
workspaces: Option<Vec<String>>,
}

#[cfg(test)]
mod tests {
use super::*;

const NON_WORKSPACE_MANIFEST: &str = r#"{ "name": "test" }"#;

#[test]
fn root_with_workspace() {
const WORKSPACE_MANIFEST: &str =
r#"{ "name": "parent", "workspaces": ["./packages/sub/*"] }"#;

// Write root workspace manifest.
let tempdir = tempfile::tempdir().unwrap();
let workspace_manifest = tempdir.path().join("package.json");
fs::write(workspace_manifest, WORKSPACE_MANIFEST).unwrap();

// Create irrelevant non-manifest directory.
let nothing_dir = tempdir.path().join("packages");
fs::create_dir_all(&nothing_dir).unwrap();

// Write irrelevant intermediate manifest.
let sub_dir = nothing_dir.join("sub");
fs::create_dir_all(&sub_dir).unwrap();
let sub_manifest = sub_dir.join("package.json");
fs::write(sub_manifest, NON_WORKSPACE_MANIFEST).unwrap();

// Write target project manifest.
let project_dir = sub_dir.join("project");
fs::create_dir_all(&project_dir).unwrap();
let project_manifest = project_dir.join("package.json");
fs::write(&project_manifest, NON_WORKSPACE_MANIFEST).unwrap();

let root = find_workspace_root(&project_manifest).unwrap();
assert_eq!(root, tempdir.path().to_path_buf().canonicalize().unwrap());
}

#[test]
fn root_without_workspace() {
let tempdir = tempfile::tempdir().unwrap();
let manifest_path = tempdir.path().join("package.json");
fs::write(&manifest_path, NON_WORKSPACE_MANIFEST).unwrap();

let root = find_workspace_root(&manifest_path).unwrap();
assert_eq!(root, tempdir.path().to_path_buf().canonicalize().unwrap());
}
}
35 changes: 34 additions & 1 deletion lockfile_generator/src/pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,35 @@

use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

use crate::{Error, Generator, Result};

const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml";
const WORKSPACE_DIR_ENV_VAR: &str = "NPM_CONFIG_WORKSPACE_DIR";

pub struct Pnpm;

impl Generator for Pnpm {
// Based on PNPM's implementation:
// https://github.com/pnpm/pnpm/blob/98377afd3452d92183e4b643a8b122887c0406c3/workspace/find-workspace-dir/src/index.ts
fn lockfile_path(&self, manifest_path: &Path) -> Result<PathBuf> {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("pnpm-lock.yaml"))

// Get project root from env variable.
let workspace_dir_env = env::var_os(WORKSPACE_DIR_ENV_VAR)
.or_else(|| env::var_os(WORKSPACE_DIR_ENV_VAR.to_lowercase()))
.map(PathBuf::from);

// Fallback to recursive search for `WORKSPACE_MANIFEST_FILENAME`.
let workspace_root = workspace_dir_env.or_else(|| find_workspace_root(project_path));

// Fallback to non-workspace location.
let root = workspace_root.unwrap_or_default();

Ok(root.join("pnpm-lock.yaml"))
}

fn command(&self, _manifest_path: &Path) -> Command {
Expand All @@ -25,3 +43,18 @@ impl Generator for Pnpm {
"pnpm"
}
}

/// Find PNPM workspace root.
fn find_workspace_root(path: &Path) -> Option<PathBuf> {
for path in path.ancestors() {
let dir = fs::read_dir(path).ok()?;

for dir_entry in dir.into_iter().flatten().map(|entry| entry.path()) {
if dir_entry.file_name().map_or(false, |name| name == WORKSPACE_MANIFEST_FILENAME) {
return Some(path.into());
}
}
}

None
}
8 changes: 3 additions & 5 deletions lockfile_generator/src/yarn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::{Error, Generator, Result};
use crate::{npm, Error, Generator, Result};

pub struct Yarn;

impl Generator for Yarn {
fn lockfile_path(&self, manifest_path: &Path) -> Result<PathBuf> {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("yarn.lock"))
let workspace_root = npm::find_workspace_root(manifest_path)?;
Ok(workspace_root.join("yarn.lock"))
}

fn command(&self, _manifest_path: &Path) -> Command {
Expand Down