diff --git a/Cargo.lock b/Cargo.lock index 8d63386..5b16c0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,6 +453,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "globset" version = "0.4.15" @@ -569,6 +575,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "json_comments" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dbbfed4e59ba9750e15ba154fdfd9329cee16ff3df539c2666b70f58cc32105" + [[package]] name = "libc" version = "0.2.159" @@ -741,6 +753,8 @@ version = "0.1.5" dependencies = [ "anyhow", "clap", + "compact_str", + "glob", "ignore", "json-strip-comments", "log", @@ -751,6 +765,8 @@ dependencies = [ "serde", "serde_json", "static_assertions", + "thiserror", + "tsconfig", ] [[package]] @@ -1539,6 +1555,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tsconfig" +version = "0.3.1" +source = "git+https://github.com/DonIsaac/tsconfig#ad1bec1d8ef725612c15bfa1ff4675ffc28b380b" +dependencies = [ + "json_comments", + "regex", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "typenum" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index 6072a91..aeb69de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" [dependencies] anyhow = { version = "1.0.89" } clap = { version = "4.5.20", features = ["cargo"] } +compact_str = { version = "0.8.0" } +glob = { version = "0.3.1" } ignore = { version = "0.4.23" } json-strip-comments = { version = "1.0.4" } log = { version = "0.4.22" } @@ -28,6 +30,9 @@ pretty_env_logger = { version = "0.5.0" } serde = { version = "1.0.210" } serde_json = { version = "1.0.128" } static_assertions = { version = "1.1.0" } +thiserror = { version = "1.0.64" } +# use our own fork +tsconfig = { git = "https://github.com/DonIsaac/tsconfig" } [lints.clippy] all = { level = "warn", priority = -1 } diff --git a/src/cli.rs b/src/cli.rs index c803d0b..04bc480 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,12 +1,11 @@ mod error; -mod root; use std::{env, num::NonZeroUsize, path::PathBuf}; use clap::{self, command, Arg, ArgMatches, ValueHint}; use miette::{Context, IntoDiagnostic, Result}; -pub(crate) use root::Root; +use crate::Root; pub fn cli() -> ArgMatches { command!() diff --git a/src/main.rs b/src/main.rs index 8b091f7..d69778a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod compiler; mod options; mod reporter; mod walk; +mod workspace; use std::{process::ExitCode, thread, time::Instant}; @@ -15,13 +16,14 @@ use crate::{ cli::{cli, CliOptions}, options::OxbuildOptions, reporter::{DiagnosticSender, Reporter}, + workspace::{Manifest, Root}, }; #[allow(clippy::print_stdout)] fn main() -> Result { pretty_env_logger::init(); - let matches = cli(); - let opts = CliOptions::new(matches).and_then(OxbuildOptions::new)?; + let cli_args = CliOptions::new(cli())?; + let opts = OxbuildOptions::new(cli_args)?; let num_threads = opts.num_threads.get(); let (mut reporter, report_sender) = Reporter::new(); diff --git a/src/options.rs b/src/options.rs index bf46ee9..2c1c6e4 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,4 +1,4 @@ -use crate::cli::{CliOptions, Root}; +use crate::{cli::CliOptions, Root}; use std::{ fs::{self}, num::NonZeroUsize, diff --git a/src/workspace/manifest.rs b/src/workspace/manifest.rs new file mode 100644 index 0000000..32e745a --- /dev/null +++ b/src/workspace/manifest.rs @@ -0,0 +1,69 @@ +use std::{fs, path::PathBuf}; + +use miette::{Context as _, IntoDiagnostic as _, Result}; +use package_json::PackageJson; +use tsconfig::TsConfig; + +use super::workspace_globs::Workspaces; + +/// A package manifest. +/// +/// May be a single package, a package in a monorepo, or the monorepo root itself. +pub struct Manifest { + /// absolute + dir: PathBuf, + package_json: PackageJson, + tsconfig: Option, + workspaces: Option, +} + +impl Manifest { + pub fn new(package_json_path: PathBuf, tsconfig: Option) -> Result { + assert!( + package_json_path.is_absolute(), + "package.json paths must be absolute" + ); + assert!( + package_json_path + .file_name() + .is_some_and(|p| p == "package.json"), + "Manifest received path to non-package.json: {}", + package_json_path.display() + ); + if !package_json_path.is_file() { + return Err(miette::Report::msg(format!( + "package.json at {} does not exist", + package_json_path.display() + ))); + } + let package_folder = package_json_path.parent().unwrap().to_path_buf(); + let package_json_raw = fs::read_to_string(&package_json_path) + .into_diagnostic() + .with_context(|| { + format!( + "Failed to read package.json at {}", + package_json_path.display() + ) + })?; + let package_json: PackageJson = serde_json::from_str(&package_json_raw) + .into_diagnostic() + .with_context(|| { + format!( + "Failed to parse package.json at {}", + package_json_path.display() + ) + })?; + + let workspaces = package_json + .workspaces + .as_ref() + .map(|workspaces| Workspaces::from_iter(workspaces)); + + Ok(Self { + dir: package_folder, + package_json, + tsconfig, + workspaces, + }) + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..f555444 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,6 @@ +mod manifest; +mod root; +mod workspace_globs; + +pub use manifest::Manifest; +pub use root::Root; diff --git a/src/cli/root.rs b/src/workspace/root.rs similarity index 99% rename from src/cli/root.rs rename to src/workspace/root.rs index 5b0b37d..73cbaf3 100644 --- a/src/cli/root.rs +++ b/src/workspace/root.rs @@ -9,7 +9,7 @@ use miette::{IntoDiagnostic, Report, Result, WrapErr}; use package_json::PackageJsonManager; #[derive(Debug)] -pub(crate) struct Root { +pub struct Root { /// Current working directory from where oxbuild was run. cwd: PathBuf, /// Path to directory containing nearest `package.json` file. diff --git a/src/workspace/workspace_globs.rs b/src/workspace/workspace_globs.rs new file mode 100644 index 0000000..39543ad --- /dev/null +++ b/src/workspace/workspace_globs.rs @@ -0,0 +1,231 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + sync::Arc, +}; + +use compact_str::CompactString; +use glob::{glob, Paths, Pattern}; +use miette::Diagnostic; +use thiserror::{self, Error}; + +/// A set of glob patterns describing where to find packages in a workspace. +/// +/// Supports exclusion patterns (starting with `!`). +#[derive(Debug, Clone)] +pub struct Workspaces { + pub(super) include_globs: Vec, + pub(super) exclude_globs: Option>, + // nohoist +} + +impl> FromIterator for Workspaces { + fn from_iter>(iter: T) -> Self { + let globs = iter.into_iter(); + let hint = globs.size_hint(); + let start_size = hint.1.unwrap_or(hint.0); + let mut include_globs: Vec = Vec::with_capacity(start_size); + // let mut exclude_globs: Option> = None; + let mut exclude_globs: Vec = Vec::new(); + + for glob in globs { + let glob = glob.as_ref().trim(); + if glob.starts_with('!') { + exclude_globs.push(glob.strip_prefix('!').unwrap().into()); + } else { + include_globs.push(glob.into()); + } + } + + include_globs.shrink_to_fit(); + let exclude = if exclude_globs.is_empty() { + None + } else { + exclude_globs.shrink_to_fit(); + Some(exclude_globs) + }; + + Self { + include_globs, + exclude_globs: exclude, + } + } +} + +impl + AsRef + ToString> Workspaces { + pub fn new(include: Vec, exclude: Option>) -> Self { + Self { + include_globs: include, + exclude_globs: exclude, + } + } + + /// Glob patterns of packages to include in the workspace + pub fn included(&self) -> &[S] { + self.include_globs.as_slice() + } + + /// Glob patterns of packages to exclude from the workspace + pub fn excluded(&self) -> Option<&[S]> { + self.exclude_globs.as_deref() + } + + /// Does this workspace glob list contain globs for included workspaces? + /// + /// Does not imply that iteration will yield any paths. + fn is_empty(&self) -> bool { + self.include_globs.is_empty() + } + + /// # Panics + /// - if `root` is not a directory. + /// - if `self` is [`empty`]. + /// + /// [`empty`]: Workspaces::is_empty + pub fn iter_paths>( + &self, + root: P, + ) -> impl Iterator> { + let root = root.as_ref(); + assert!( + root.is_dir(), + "Root path {} is not a directory.", + root.display() + ); + assert!(!self.is_empty(), "Workspaces list has no included globs."); + + let (iter, errors) = WorkspaceIter::new(root, self); + let errors_iter = errors.into_iter().map(Err); + + errors_iter.chain(iter.iter()) + } +} + +#[derive(Debug)] +struct WorkspaceIter { + include: Vec, + // needs to be Rc to get around borrow checker in iter + exclude: Option>>, +} + +impl WorkspaceIter { + fn new + AsRef + ToString>( + root: &Path, + workspaces: &Workspaces, + ) -> (Self, Vec) { + let included = workspaces.included(); + let mut include = Vec::with_capacity(included.len()); + let mut errors = Vec::with_capacity(included.len()); + for pattern in included { + let fullpath = root.join(pattern); + if let Some(fullpath_str) = fullpath.to_str() { + let resolved = glob(fullpath_str); + match resolved { + Ok(paths) => include.push(paths), + Err(e) => errors.push(BadGlobError::bad_pattern( + pattern.to_string(), + e, + GlobLocation::Include, + )), + } + } else { + errors.push(BadGlobError::not_utf8( + pattern.to_string(), + GlobLocation::Include, + )); + } + } + + let exclude = workspaces.excluded().map(|exclude| { + let mut exclude_patterns = Vec::with_capacity(exclude.len()); + for raw_pattern in exclude { + match Pattern::new(raw_pattern.as_ref()) { + Ok(pattern) => exclude_patterns.push(pattern), + Err(e) => errors.push(BadGlobError::bad_pattern( + raw_pattern.to_string(), + e, + GlobLocation::Exclude, + )), + } + } + Arc::new(exclude_patterns) + }); + + (Self { include, exclude }, errors) + } + + pub(self) fn is_excluded(exclude: Option<&Vec>, path: &Path) -> bool { + exclude.is_some_and(|exclude| exclude.iter().any(|pat| pat.matches_path(path))) + } + + // NOTE: not implementing IntoIter because... just look at the size of that type... + pub fn iter(self) -> impl Iterator> { + let exclude = self.exclude.clone(); + self.include + .into_iter() + .flat_map(move |include| { + let exclude = exclude.clone(); + include.map(move |path| { + path.map(|path| { + if Self::is_excluded(exclude.as_deref(), path.as_path()) { + None + } else { + Some(path) + } + }) + .map_err(|e| BadGlobError::bad_glob(e, GlobLocation::Include)) + }) + }) + .filter_map(|path| match path { + Err(e) => Some(Err(e)), + Ok(Some(path)) if path.join("package.json").is_file() => Some(Ok(path)), + _ => None, + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum GlobLocation { + Include, + Exclude, +} + +impl fmt::Display for GlobLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GlobLocation::Include => write!(f, "workspace pattern"), + GlobLocation::Exclude => write!(f, "workspace exclusion pattern"), + } + } +} + +#[derive(Debug, Error, Diagnostic)] +pub enum BadGlobError { + #[error("Invalid {2} '{0}': {1}")] + Pattern( + /* glob */ String, + /* inner */ #[source] glob::PatternError, + GlobLocation, + ), + #[error("{0}")] + Glob(#[source] glob::GlobError, GlobLocation), + #[error("Invalid {1} '{0}': pattern is not a UTF-8 string.")] + NotUtf8(/* glob */ String, GlobLocation), +} +impl BadGlobError { + fn bad_pattern>( + glob: S, + inner: glob::PatternError, + location: GlobLocation, + ) -> Self { + Self::Pattern(glob.into(), inner, location) + } + + fn not_utf8>(glob: S, location: GlobLocation) -> Self { + Self::NotUtf8(glob.into(), location) + } + + fn bad_glob(glob: glob::GlobError, location: GlobLocation) -> Self { + Self::Glob(glob, location) + } +}