diff --git a/Cargo.lock b/Cargo.lock index 416029e897e..2cd0a3b5cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -512,6 +547,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -823,7 +889,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.1.0", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", @@ -990,6 +1056,12 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1051,12 +1123,13 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown 0.14.3", + "serde", ] [[package]] @@ -1730,6 +1803,22 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "parcel_config" +version = "0.1.0" +dependencies = [ + "derive_builder", + "glob-match", + "indexmap 2.2.6", + "mockall", + "parcel_filesystem", + "parcel_package_manager", + "pathdiff", + "serde", + "serde_json5", + "thiserror", +] + [[package]] name = "parcel_core" version = "0.1.0" @@ -1768,6 +1857,14 @@ dependencies = [ "napi", ] +[[package]] +name = "parcel_package_manager" +version = "0.1.0" +dependencies = [ + "mockall", + "thiserror", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -1809,6 +1906,51 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "pest" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.6.3" @@ -2550,6 +2692,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json5" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a6b754515e1a7bd79fc2edeaecee526fc80cb3a918607e5ca149225a3a9586" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.5" @@ -2582,6 +2735,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "simd-abstraction" version = "0.7.1" @@ -2849,7 +3013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c29e3b76a63111ef318f161bc413dfc093f21da1afca9ba5cdd6442b7069d65b" dependencies = [ "anyhow", - "indexmap 2.1.0", + "indexmap 2.2.6", "serde", "serde_json", "swc_cached", @@ -2975,7 +3139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae6cdbcd73b42e20ad33a9df635ef5fbcf5a24d775790e246ec327cb5ff60227" dependencies = [ "arrayvec", - "indexmap 2.1.0", + "indexmap 2.2.6", "is-macro", "serde", "serde_derive", @@ -3162,7 +3326,7 @@ checksum = "da9dd1ed14585df2e8e3f0bfbb8635dee2f1997d7defb7e8a28da3970bd51115" dependencies = [ "anyhow", "dashmap", - "indexmap 2.1.0", + "indexmap 2.2.6", "once_cell", "preset_env_base", "rustc-hash", @@ -3203,7 +3367,7 @@ checksum = "a491da2eaab98914d1f85bd81a35db6432ad0577ae64746bb9e5594cb0b79b47" dependencies = [ "better_scoped_tls", "bitflags 2.5.0", - "indexmap 2.1.0", + "indexmap 2.2.6", "once_cell", "phf", "rustc-hash", @@ -3239,7 +3403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de609d44d2e0dfec1968cdf3fed6faaa9e6e1b15191a25b7d70109e32a0db1c0" dependencies = [ "arrayvec", - "indexmap 2.1.0", + "indexmap 2.2.6", "is-macro", "num-bigint", "serde", @@ -3287,7 +3451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "445efca981669a08cc8bab2bd9d0420eb688e7086d6a4babc6b670473877a2c2" dependencies = [ "dashmap", - "indexmap 2.1.0", + "indexmap 2.2.6", "once_cell", "petgraph", "rustc-hash", @@ -3332,7 +3496,7 @@ checksum = "46493a5f10abf9da23e609a7cbe961f99223d2b640d80caa39ce7ede6d75eb3a" dependencies = [ "base64", "dashmap", - "indexmap 2.1.0", + "indexmap 2.2.6", "once_cell", "serde", "sha-1", @@ -3371,7 +3535,7 @@ version = "0.126.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f6edc4064cd932c6d267c05f0b161e6aaa4df4f900d5e1db8c92eda8edcc410" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.6", "num_cpus", "once_cell", "rustc-hash", @@ -3414,7 +3578,7 @@ version = "0.21.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9c9e567014e157af520f74b1a5bc151fece681136754b80b3fec6b908e26a0" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.6", "petgraph", "rustc-hash", "swc_common", @@ -3545,18 +3709,18 @@ checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" [[package]] name = "thiserror" -version = "1.0.43" +version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.43" +version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" dependencies = [ "proc-macro2", "quote", @@ -3694,7 +3858,7 @@ version = "0.22.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.6", "serde", "serde_spanned", "toml_datetime", @@ -3766,6 +3930,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + [[package]] name = "uname" version = "0.1.1" diff --git a/crates/parcel_config/Cargo.toml b/crates/parcel_config/Cargo.toml new file mode 100644 index 00000000000..73458699531 --- /dev/null +++ b/crates/parcel_config/Cargo.toml @@ -0,0 +1,19 @@ +[package] +authors = ["Monica Olejniczak ", "Devon Govett "] +name = "parcel_config" +version = "0.1.0" +edition = "2021" + +[dependencies] +derive_builder = "0.20.0" +glob-match = "0.2.1" +indexmap = { version = "2.2.6", features = ["serde", "std"] } +parcel_filesystem = { path = "../parcel_filesystem" } +parcel_package_manager = { path = "../parcel_package_manager" } +pathdiff = "0.2.1" +serde = { version = "1.0.123", features = ["derive"] } +serde_json5 = "0.1.0" +thiserror = "1.0.59" + +[dev_dependencies] +mockall = "0.12.1" diff --git a/crates/parcel_config/src/config_error.rs b/crates/parcel_config/src/config_error.rs new file mode 100644 index 00000000000..6fdd29c7e89 --- /dev/null +++ b/crates/parcel_config/src/config_error.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("{0}")] + InvalidConfig(String), + #[error("No {phase} found for {path} with pipeline {pipeline:?}")] + MissingPlugin { + path: PathBuf, + phase: String, + pipeline: Option, + }, + #[error("Unable to locate .parcelrc from {0}")] + MissingParcelRc(PathBuf), + #[error("Failed to parse {path}")] + ParseFailure { + path: PathBuf, + #[source] + source: serde_json5::Error, + }, + #[error("Failed to read {path}")] + ReadConfigFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to resolve {config_type} {specifier} from {from}")] + UnresolvedConfig { + config_type: String, + from: PathBuf, + specifier: String, + #[source] + source: Box, + }, +} diff --git a/crates/parcel_config/src/lib.rs b/crates/parcel_config/src/lib.rs new file mode 100644 index 00000000000..84574e859be --- /dev/null +++ b/crates/parcel_config/src/lib.rs @@ -0,0 +1,11 @@ +pub mod config_error; +pub mod parcel_config; +#[cfg(test)] +mod parcel_config_fixtures; +pub mod parcel_rc; +pub mod parcel_rc_config_loader; +mod partial_parcel_config; +pub mod pipeline; + +pub use parcel_config::ParcelConfig; +pub use parcel_config::PluginNode; diff --git a/crates/parcel_config/src/parcel_config.rs b/crates/parcel_config/src/parcel_config.rs new file mode 100644 index 00000000000..b075aca35b8 --- /dev/null +++ b/crates/parcel_config/src/parcel_config.rs @@ -0,0 +1,247 @@ +use std::path::Path; +use std::path::PathBuf; +use std::rc::Rc; + +use indexmap::IndexMap; + +use super::config_error::ConfigError; +use super::partial_parcel_config::PartialParcelConfig; +use super::pipeline::is_match; +use super::pipeline::PipelineMap; + +#[derive(Clone, Debug, PartialEq)] +pub struct PluginNode { + pub package_name: String, + pub resolve_from: Rc, +} + +/// Represents a fully merged and validated .parcel_rc config +#[derive(Debug, PartialEq)] +pub struct ParcelConfig { + pub(crate) bundler: PluginNode, + pub(crate) compressors: PipelineMap, + pub(crate) namers: Vec, + pub(crate) optimizers: PipelineMap, + pub(crate) packagers: IndexMap, + pub(crate) reporters: Vec, + pub(crate) resolvers: Vec, + pub(crate) runtimes: Vec, + pub(crate) transformers: PipelineMap, + pub(crate) validators: PipelineMap, +} + +impl TryFrom for ParcelConfig { + type Error = ConfigError; + + fn try_from(config: PartialParcelConfig) -> Result { + // The final stage of merging filters out any ... extensions as they are a noop + fn filter_out_extends(pipelines: Vec) -> Vec { + pipelines + .into_iter() + .filter(|p| p.package_name != "...") + .collect() + } + + fn filter_out_extends_from_map( + map: IndexMap>, + ) -> IndexMap> { + map + .into_iter() + .map(|(pattern, plugins)| (pattern, filter_out_extends(plugins))) + .collect() + } + + let mut missing_phases = Vec::new(); + + if let None = config.bundler { + missing_phases.push(String::from("bundler")); + } + + let namers = filter_out_extends(config.namers); + if namers.is_empty() { + missing_phases.push(String::from("namers")); + } + + let resolvers = filter_out_extends(config.resolvers); + if resolvers.is_empty() { + missing_phases.push(String::from("resolvers")); + } + + if !missing_phases.is_empty() { + return Err(ConfigError::InvalidConfig(format!( + "Missing plugins for the following phases: {:?}", + missing_phases + ))); + } + + Ok(ParcelConfig { + bundler: config.bundler.unwrap(), + compressors: PipelineMap::new(filter_out_extends_from_map(config.compressors)), + namers, + optimizers: PipelineMap::new(filter_out_extends_from_map(config.optimizers)), + packagers: config.packagers, + reporters: filter_out_extends(config.reporters), + resolvers, + runtimes: filter_out_extends(config.runtimes), + transformers: PipelineMap::new(filter_out_extends_from_map(config.transformers)), + validators: PipelineMap::new(filter_out_extends_from_map(config.validators)), + }) + } +} + +impl ParcelConfig { + pub fn validators(&self, path: &Path) -> Result, ConfigError> { + let pipeline: &Option<&str> = &None; + let validators = self.validators.get(path, pipeline); + + Ok(validators) + } + + pub fn transformers( + &self, + path: &Path, + pipeline: &Option>, + allow_empty: bool, + ) -> Result, ConfigError> { + let transformers = self.transformers.get(path, pipeline); + + if transformers.is_empty() { + if allow_empty { + return Ok(Vec::new()); + } + + return Err(ConfigError::MissingPlugin { + path: PathBuf::from(path), + phase: String::from("transformers"), + pipeline: pipeline.as_ref().map(|p| String::from(p.as_ref())), + }); + } + + Ok(transformers) + } + + pub fn bundler>(&self) -> Result<&PluginNode, ConfigError> { + Ok(&self.bundler) + } + + pub fn namers(&self) -> Result<&Vec, ConfigError> { + Ok(&self.namers) + } + + pub fn runtimes(&self) -> Result<&Vec, ConfigError> { + Ok(&self.runtimes) + } + + pub fn packager(&self, path: &Path) -> Result<&PluginNode, ConfigError> { + let basename = path.file_name().unwrap().to_str().unwrap(); + let path_str = path.as_os_str().to_str().unwrap(); + let packager = self + .packagers + .iter() + .find(|(pattern, _)| is_match(pattern, path_str, basename, "")); + + match packager { + None => Err(ConfigError::MissingPlugin { + path: PathBuf::from(path), + phase: String::from("packager"), + pipeline: None, + }), + Some((_, pkgr)) => Ok(pkgr), + } + } + + pub fn optimizers( + &self, + path: &Path, + pipeline: &Option>, + ) -> Result, ConfigError> { + let mut use_empty_pipeline = false; + // If a pipeline is specified, but it doesn't exist in the optimizers config, ignore it. + // Pipelines for bundles come from their entry assets, so the pipeline likely exists in transformers. + if let Some(p) = pipeline { + if !self.optimizers.contains_named_pipeline(p) { + use_empty_pipeline = true; + } + } + + let optimizers = self + .optimizers + .get(path, if use_empty_pipeline { &None } else { pipeline }); + + Ok(optimizers) + } + + pub fn compressors(&self, path: &Path) -> Result, ConfigError> { + let pipeline: &Option<&str> = &None; + let compressors = self.compressors.get(path, pipeline); + + if compressors.is_empty() { + return Err(ConfigError::MissingPlugin { + path: PathBuf::from(path), + phase: String::from("compressors"), + pipeline: None, + }); + } + + Ok(compressors) + } + + pub fn resolvers(&self) -> Result<&Vec, ConfigError> { + Ok(&self.resolvers) + } + + pub fn reporters(&self) -> Result<&Vec, ConfigError> { + Ok(&self.reporters) + } +} + +#[cfg(test)] +mod tests { + use super::*; + mod try_from { + use super::*; + use crate::partial_parcel_config::PartialParcelConfigBuilder; + + #[test] + fn returns_an_error_when_required_phases_are_optional() { + assert_eq!( + ParcelConfig::try_from(PartialParcelConfig::default()).map_err(|e| e.to_string()), + Err( + ConfigError::InvalidConfig(format!( + "Missing plugins for the following phases: {:?}", + vec!("bundler", "namers", "resolvers") + )) + .to_string() + ) + ); + } + + #[test] + fn returns_the_config() { + fn plugin() -> PluginNode { + PluginNode { + package_name: String::from("package"), + resolve_from: Rc::new(PathBuf::from("/")), + } + } + + fn extension() -> PluginNode { + PluginNode { + package_name: String::from("..."), + resolve_from: Rc::new(PathBuf::from("/")), + } + } + + let partial_config = PartialParcelConfigBuilder::default() + .bundler(Some(plugin())) + .namers(vec![plugin()]) + .resolvers(vec![extension(), plugin()]) + .build() + .unwrap(); + + let config = ParcelConfig::try_from(partial_config); + + assert!(config.is_ok_and(|c| !c.resolvers.contains(&extension()))); + } + } +} diff --git a/crates/parcel_config/src/parcel_config_fixtures.rs b/crates/parcel_config/src/parcel_config_fixtures.rs new file mode 100644 index 00000000000..ec23039a636 --- /dev/null +++ b/crates/parcel_config/src/parcel_config_fixtures.rs @@ -0,0 +1,243 @@ +use std::path::PathBuf; +use std::rc::Rc; + +use indexmap::indexmap; +use indexmap::IndexMap; + +use super::parcel_config::ParcelConfig; +use super::pipeline::PipelineMap; +use crate::parcel_config::PluginNode; + +pub struct ConfigFixture { + pub parcel_config: ParcelConfig, + pub parcel_rc: String, + pub path: PathBuf, +} + +pub struct PartialConfigFixture { + pub parcel_rc: String, + pub path: PathBuf, +} + +pub struct ExtendedConfigFixture { + pub base_config: PartialConfigFixture, + pub extended_config: PartialConfigFixture, + pub parcel_config: ParcelConfig, +} + +pub fn config(project_root: &PathBuf) -> (String, ConfigFixture) { + ( + String::from("@config/default"), + default_config(&Rc::from( + project_root + .join("node_modules") + .join("@config/default") + .join("index.json"), + )), + ) +} + +pub fn fallback_config(project_root: &PathBuf) -> (String, ConfigFixture) { + ( + String::from("@parcel/config-default"), + default_config(&Rc::from( + project_root + .join("node_modules") + .join("@parcel/config-default") + .join("index.json"), + )), + ) +} + +pub fn default_config(resolve_from: &Rc) -> ConfigFixture { + ConfigFixture { + parcel_config: ParcelConfig { + bundler: PluginNode { + package_name: String::from("@parcel/bundler-default"), + resolve_from: Rc::clone(&resolve_from), + }, + compressors: PipelineMap::new(indexmap! { + String::from("*") => vec!(PluginNode { + package_name: String::from("@parcel/compressor-raw"), + resolve_from: Rc::clone(&resolve_from), + }) + }), + namers: vec![PluginNode { + package_name: String::from("@parcel/namer-default"), + resolve_from: Rc::clone(&resolve_from), + }], + optimizers: PipelineMap::new(indexmap! { + String::from("*.{js,mjs,cjs}") => vec!(PluginNode { + package_name: String::from("@parcel/optimizer-swc"), + resolve_from: Rc::clone(&resolve_from), + }) + }), + packagers: indexmap! { + String::from("*.{js,mjs,cjs}") => PluginNode { + package_name: String::from("@parcel/packager-js"), + resolve_from: Rc::clone(&resolve_from), + } + }, + reporters: vec![PluginNode { + package_name: String::from("@parcel/reporter-dev-server"), + resolve_from: Rc::clone(&resolve_from), + }], + resolvers: vec![PluginNode { + package_name: String::from("@parcel/resolver-default"), + resolve_from: Rc::clone(&resolve_from), + }], + runtimes: vec![PluginNode { + package_name: String::from("@parcel/runtime-js"), + resolve_from: Rc::clone(&resolve_from), + }], + transformers: PipelineMap::new(indexmap! { + String::from("*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}") => vec!(PluginNode { + package_name: String::from("@parcel/transformer-js"), + resolve_from: Rc::clone(&resolve_from), + }) + }), + validators: PipelineMap::new(IndexMap::new()), + }, + parcel_rc: String::from( + r#" + { + "bundler": "@parcel/bundler-default", + "compressors": { + "*": ["@parcel/compressor-raw"] + }, + "namers": ["@parcel/namer-default"], + "optimizers": { + "*.{js,mjs,cjs}": ["@parcel/optimizer-swc"] + }, + "packagers": { + "*.{js,mjs,cjs}": "@parcel/packager-js" + }, + "reporters": ["@parcel/reporter-dev-server"], + "resolvers": ["@parcel/resolver-default"], + "runtimes": ["@parcel/runtime-js"], + "transformers": { + "*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}": [ + "@parcel/transformer-js" + ], + } + } + "#, + ), + path: PathBuf::from(resolve_from.display().to_string()), + } +} + +fn extended_config_from( + project_root: &PathBuf, + base_resolve_from: Rc, +) -> ExtendedConfigFixture { + let extended_resolve_from = Rc::from( + project_root + .join("node_modules") + .join("@parcel/config-default") + .join("index.json"), + ); + + let extended_config = default_config(&extended_resolve_from); + + ExtendedConfigFixture { + parcel_config: ParcelConfig { + bundler: PluginNode { + package_name: String::from("@parcel/bundler-default"), + resolve_from: Rc::clone(&extended_resolve_from), + }, + compressors: PipelineMap::new(indexmap! { + String::from("*") => vec!(PluginNode { + package_name: String::from("@parcel/compressor-raw"), + resolve_from: Rc::clone(&extended_resolve_from), + }) + }), + namers: vec![PluginNode { + package_name: String::from("@parcel/namer-default"), + resolve_from: Rc::clone(&extended_resolve_from), + }], + optimizers: PipelineMap::new(indexmap! { + String::from("*.{js,mjs,cjs}") => vec!(PluginNode { + package_name: String::from("@parcel/optimizer-swc"), + resolve_from: Rc::clone(&extended_resolve_from), + }) + }), + packagers: indexmap! { + String::from("*.{js,mjs,cjs}") => PluginNode { + package_name: String::from("@parcel/packager-js"), + resolve_from: Rc::clone(&extended_resolve_from), + } + }, + reporters: vec![ + PluginNode { + package_name: String::from("@parcel/reporter-dev-server"), + resolve_from: Rc::clone(&extended_resolve_from), + }, + PluginNode { + package_name: String::from("@scope/parcel-metrics-reporter"), + resolve_from: Rc::clone(&base_resolve_from), + }, + ], + resolvers: vec![PluginNode { + package_name: String::from("@parcel/resolver-default"), + resolve_from: Rc::clone(&extended_resolve_from), + }], + runtimes: vec![PluginNode { + package_name: String::from("@parcel/runtime-js"), + resolve_from: Rc::clone(&extended_resolve_from), + }], + transformers: PipelineMap::new(indexmap! { + String::from("*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}") => vec!(PluginNode { + package_name: String::from("@parcel/transformer-js"), + resolve_from: Rc::clone(&extended_resolve_from), + }), + String::from("*.{ts,tsx}") => vec!(PluginNode { + package_name: String::from("@scope/parcel-transformer-ts"), + resolve_from: Rc::clone(&base_resolve_from), + }), + }), + validators: PipelineMap::new(IndexMap::new()), + }, + base_config: PartialConfigFixture { + path: PathBuf::from(base_resolve_from.as_os_str()), + parcel_rc: String::from( + r#" + { + "extends": "@parcel/config-default", + "reporters": ["...", "@scope/parcel-metrics-reporter"], + "transformers": { + "*.{ts,tsx}": [ + "@scope/parcel-transformer-ts", + "..." + ] + } + } + "#, + ), + }, + extended_config: PartialConfigFixture { + path: extended_config.path, + parcel_rc: extended_config.parcel_rc, + }, + } +} + +pub fn default_extended_config(project_root: &PathBuf) -> ExtendedConfigFixture { + let base_resolve_from = Rc::from(project_root.join(".parcelrc")); + + extended_config_from(project_root, base_resolve_from) +} + +pub fn extended_config(project_root: &PathBuf) -> (String, ExtendedConfigFixture) { + let base_resolve_from = Rc::from( + project_root + .join("node_modules") + .join("@config/default") + .join("index.json"), + ); + + ( + String::from("@config/default"), + extended_config_from(project_root, base_resolve_from), + ) +} diff --git a/crates/parcel_config/src/parcel_rc.rs b/crates/parcel_config/src/parcel_rc.rs new file mode 100644 index 00000000000..109cd0f778e --- /dev/null +++ b/crates/parcel_config/src/parcel_rc.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use indexmap::IndexMap; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum Extends { + One(String), + Many(Vec), +} + +/// Deserialized .parcel_rc config +#[derive(Debug, Deserialize)] +pub struct ParcelRc { + pub extends: Option, + pub bundler: Option, + pub compressors: Option>>, + pub namers: Option>, + pub optimizers: Option>>, + pub packagers: Option>, + pub reporters: Option>, + pub resolvers: Option>, + pub runtimes: Option>, + pub transformers: Option>>, + pub validators: Option>>, +} + +/// Represents the .parcel_rc config file +#[derive(Debug)] +pub struct ParcelRcFile { + pub path: PathBuf, + pub contents: ParcelRc, +} diff --git a/crates/parcel_config/src/parcel_rc_config_loader.rs b/crates/parcel_config/src/parcel_rc_config_loader.rs new file mode 100644 index 00000000000..d0ab79192e8 --- /dev/null +++ b/crates/parcel_config/src/parcel_rc_config_loader.rs @@ -0,0 +1,796 @@ +use std::path::Path; +use std::path::PathBuf; + +use parcel_filesystem::search::find_ancestor_file; +use parcel_filesystem::FileSystem; +use parcel_package_manager::PackageManager; +use pathdiff::diff_paths; + +use super::config_error::ConfigError; +use super::parcel_config::ParcelConfig; +use super::parcel_config::PluginNode; +use super::parcel_rc::Extends; +use super::parcel_rc::ParcelRcFile; +use super::partial_parcel_config::PartialParcelConfig; + +#[derive(Default)] +pub struct LoadConfigOptions<'a> { + /// A list of additional reporter plugins that will be appended to the reporters config + pub additional_reporters: Vec, + /// A file path or package specifier that will be used to load the config from + pub config: Option<&'a str>, + /// A file path or package specifier that will be used to load the config from when no other + /// .parcelrc can be found + pub fallback_config: Option<&'a str>, +} + +/// Loads and validates .parcel_rc config +pub struct ParcelRcConfigLoader<'a, T, U> { + fs: &'a T, + package_manager: &'a U, +} + +impl<'a, T: FileSystem, U: PackageManager> ParcelRcConfigLoader<'a, T, U> { + pub fn new(fs: &'a T, package_manager: &'a U) -> Self { + ParcelRcConfigLoader { + fs, + package_manager, + } + } + + fn find_config(&self, project_root: &Path, path: &PathBuf) -> Result { + let from = path.parent().unwrap_or(path); + + find_ancestor_file(self.fs, vec![String::from(".parcelrc")], from, project_root) + .ok_or(ConfigError::MissingParcelRc(PathBuf::from(from))) + } + + fn resolve_from(&self, project_root: &PathBuf) -> PathBuf { + let cwd = self.fs.cwd().unwrap(); + let relative = diff_paths(cwd.clone(), project_root); + let is_cwd_inside_project_root = + relative.is_some_and(|p| !p.starts_with("..") && !p.is_absolute()); + + let dir = if is_cwd_inside_project_root { + &cwd + } else { + project_root + }; + + dir.join("index") + } + + fn load_config(&self, path: PathBuf) -> Result<(PartialParcelConfig, Vec), ConfigError> { + let parcel_rc = + self + .fs + .read_to_string(&path) + .map_err(|source| ConfigError::ReadConfigFile { + path: path.clone(), + source, + })?; + + let contents = + serde_json5::from_str(&parcel_rc).map_err(|source| ConfigError::ParseFailure { + path: path.clone(), + source, + })?; + + self.process_config(&ParcelRcFile { path, contents }) + } + + fn resolve_extends( + &self, + config_path: &PathBuf, + extend: &String, + ) -> Result { + let path = if extend.starts_with(".") { + config_path.parent().unwrap_or(config_path).join(extend) + } else { + self + .package_manager + .resolve(extend, config_path) + .map_err(|source| ConfigError::UnresolvedConfig { + config_type: String::from("extended config"), + from: PathBuf::from(config_path), + source: Box::new(source), + specifier: String::from(extend), + })? + .resolved + }; + + self + .fs + .canonicalize_base(path.clone()) + .map_err(|source| ConfigError::UnresolvedConfig { + config_type: String::from("extended config"), + from: path, + source: Box::new(source), + specifier: String::from(extend), + }) + } + + /// Processes a .parcelrc file by loading and merging "extends" configurations into a single + /// PartialParcelConfig struct + /// + /// Configuration merging will be applied to all "extends" configurations, before being merged + /// into the base config for a more natural merging order. It will replace any "..." seen in + /// plugin pipelines with the corresponding plugins from "extends" if present. + /// + fn process_config( + &self, + parcel_rc: &ParcelRcFile, + ) -> Result<(PartialParcelConfig, Vec), ConfigError> { + let mut files = vec![parcel_rc.path.clone()]; + let extends = parcel_rc.contents.extends.as_ref(); + let extends = match extends { + None => Vec::new(), + Some(extends) => match extends { + Extends::One(ext) => vec![String::from(ext)], + Extends::Many(ext) => ext.to_vec(), + }, + }; + + if extends.is_empty() { + return Ok((PartialParcelConfig::try_from(parcel_rc)?, files)); + } + + let mut merged_config: Option = None; + for extend in extends { + let extended_file_path = self.resolve_extends(&parcel_rc.path, &extend)?; + let (extended_config, mut extended_file_paths) = self.load_config(extended_file_path)?; + + merged_config = match merged_config { + None => Some(extended_config), + Some(config) => Some(PartialParcelConfig::merge(config, extended_config)), + }; + + files.append(&mut extended_file_paths); + } + + let config = PartialParcelConfig::merge( + PartialParcelConfig::try_from(parcel_rc)?, + merged_config.unwrap(), + ); + + Ok((config, files)) + } + + /// Finds and loads a .parcelrc file + /// + /// By default the nearest .parcelrc ancestor file from the current working directory will be + /// loaded, unless the config or fallback_config option are specified. In cases where the + /// current working directory does not live within the project root, the default config will be + /// loaded from the project root. + /// + pub fn load( + &self, + project_root: &PathBuf, + options: LoadConfigOptions<'a>, + ) -> Result<(ParcelConfig, Vec), ConfigError> { + let resolve_from = self.resolve_from(project_root); + let mut config_path = match options.config { + Some(config) => self + .package_manager + .resolve(&config, &resolve_from) + .map(|r| r.resolved) + .map_err(|source| ConfigError::UnresolvedConfig { + config_type: String::from("config"), + from: resolve_from.clone(), + source: Box::new(source), + specifier: String::from(config), + }), + None => self.find_config(project_root, &resolve_from), + }; + + if !config_path.is_ok() { + if let Some(fallback_config) = options.fallback_config { + config_path = self + .package_manager + .resolve(&fallback_config, &resolve_from) + .map(|r| r.resolved) + .map_err(|source| ConfigError::UnresolvedConfig { + config_type: String::from("fallback"), + from: resolve_from, + source: Box::new(source), + specifier: String::from(fallback_config), + }); + } + } + + let config_path = config_path?; + let (mut parcel_config, files) = self.load_config(config_path)?; + + if options.additional_reporters.len() > 0 { + parcel_config.reporters.extend(options.additional_reporters); + } + + let parcel_config = ParcelConfig::try_from(parcel_config)?; + + Ok((parcel_config, files)) + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate::eq; + use parcel_filesystem::in_memory_file_system::InMemoryFileSystem; + use parcel_package_manager::MockPackageManager; + use parcel_package_manager::Resolution; + use parcel_package_manager::ResolveError; + + use super::*; + + fn fail_package_manager_resolution(package_manager: &mut MockPackageManager) { + package_manager + .expect_resolve() + .return_once(|specifier, from| { + Err(ResolveError::NotFound( + String::from(specifier), + from.display().to_string(), + )) + }); + } + + struct InMemoryPackageManager<'a> { + fs: &'a InMemoryFileSystem, + } + + impl<'a> InMemoryPackageManager<'a> { + pub fn new(fs: &'a InMemoryFileSystem) -> Self { + Self { fs } + } + } + + impl<'a> PackageManager for InMemoryPackageManager<'a> { + fn resolve(&self, specifier: &str, from: &Path) -> Result { + let path = match "true" { + _s if specifier.starts_with(".") => from.join(specifier), + _s if specifier.starts_with("@") => self + .fs + .cwd() + .unwrap() + .join("node_modules") + .join(specifier) + .join("index.json"), + _ => PathBuf::from("Not found"), + }; + + if !self.fs.is_file(&path) { + return Err(ResolveError::NotFound( + String::from(specifier), + from.display().to_string(), + )); + } + + Ok(Resolution { resolved: path }) + } + } + + fn package_manager_resolution( + package_manager: &mut MockPackageManager, + specifier: String, + from: PathBuf, + ) -> PathBuf { + let resolved = PathBuf::from("/") + .join("node_modules") + .join(specifier.clone()) + .join("index.json"); + + package_manager + .expect_resolve() + .with(eq(specifier), eq(from)) + .returning(|specifier, _from| { + Ok(Resolution { + resolved: PathBuf::from("/") + .join("node_modules") + .join(specifier) + .join("index.json"), + }) + }); + + resolved + } + + mod empty_config_and_fallback { + use std::rc::Rc; + + use super::*; + use crate::parcel_config_fixtures::default_config; + use crate::parcel_config_fixtures::default_extended_config; + + #[test] + fn errors_on_missing_parcelrc_file() { + let fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let err = ParcelRcConfigLoader::new(&fs, &MockPackageManager::new()) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err(ConfigError::MissingParcelRc(project_root).to_string()) + ); + } + + #[test] + fn errors_on_failed_extended_parcelrc_resolution() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let config = default_extended_config(&project_root); + + fs.write_file( + config.base_config.path.clone(), + config.base_config.parcel_rc, + ); + + let err = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::UnresolvedConfig { + config_type: String::from("extended config"), + from: config.base_config.path, + specifier: String::from("@parcel/config-default"), + source: Box::new(ResolveError::NotFound(String::from(""), String::from(""))), + } + .to_string() + ) + ); + } + + #[test] + fn returns_default_parcel_config() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let default_config = default_config(&Rc::new(project_root.join(".parcelrc"))); + let files = vec![default_config.path.clone()]; + + fs.write_file(default_config.path, default_config.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &MockPackageManager::default()) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((default_config.parcel_config, files))); + } + + #[test] + fn returns_default_parcel_config_from_project_root() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap().join("src").join("packages").join("root"); + + let default_config = default_config(&Rc::new(project_root.join(".parcelrc"))); + let files = vec![default_config.path.clone()]; + + fs.write_file(default_config.path, default_config.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &MockPackageManager::default()) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((default_config.parcel_config, files))); + } + + #[test] + fn returns_default_parcel_config_from_project_root_when_outside_cwd() { + let project_root = PathBuf::from("/root"); + let default_config = default_config(&Rc::new(project_root.join(".parcelrc"))); + let files = vec![default_config.path.clone()]; + let mut fs = InMemoryFileSystem::default(); + + fs.set_current_working_directory(PathBuf::from("/cwd")); + fs.write_file(default_config.path, default_config.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &MockPackageManager::default()) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((default_config.parcel_config, files))); + } + + #[test] + fn returns_merged_default_parcel_config() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let default_config = default_extended_config(&project_root); + let files = vec![ + default_config.base_config.path.clone(), + default_config.extended_config.path.clone(), + ]; + + fs.write_file( + default_config.base_config.path, + default_config.base_config.parcel_rc, + ); + + fs.write_file( + default_config.extended_config.path, + default_config.extended_config.parcel_rc, + ); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load(&project_root, LoadConfigOptions::default()) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((default_config.parcel_config, files))); + } + } + + mod config { + use super::*; + use crate::parcel_config_fixtures::config; + use crate::parcel_config_fixtures::extended_config; + + #[test] + fn errors_on_failed_config_resolution() { + let fs = InMemoryFileSystem::default(); + let mut package_manager = MockPackageManager::new(); + let project_root = fs.cwd().unwrap(); + + fail_package_manager_resolution(&mut package_manager); + + let err = ParcelRcConfigLoader::new(&fs, &package_manager) + .load( + &&project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some("@scope/config"), + fallback_config: None, + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::UnresolvedConfig { + config_type: String::from("config"), + from: project_root.join("index"), + specifier: String::from("@scope/config"), + source: Box::new(ResolveError::NotFound(String::from(""), String::from(""))), + } + .to_string() + ) + ); + } + + #[test] + fn errors_on_failed_extended_config_resolution() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (specifier, config) = extended_config(&project_root); + + fs.write_file( + config.base_config.path.clone(), + config.base_config.parcel_rc, + ); + + let err = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some(&specifier), + fallback_config: None, + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::UnresolvedConfig { + config_type: String::from("extended config"), + from: config.base_config.path, + specifier: String::from("@parcel/config-default"), + source: Box::new(ResolveError::NotFound(String::from(""), String::from(""))), + } + .to_string() + ) + ); + } + + #[test] + fn errors_on_missing_config_file() { + let mut fs = InMemoryFileSystem::default(); + let mut package_manager = MockPackageManager::new(); + let project_root = fs.cwd().unwrap(); + + fs.write_file(project_root.join(".parcelrc"), String::from("{}")); + + let config_path = package_manager_resolution( + &mut package_manager, + String::from("@scope/config"), + project_root.join("index"), + ); + + let err = ParcelRcConfigLoader::new(&fs, &package_manager) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some("@scope/config"), + fallback_config: None, + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::ReadConfigFile { + path: config_path, + source: std::io::Error::new(std::io::ErrorKind::NotFound, "Not found") + } + .to_string() + ) + ); + } + + #[test] + fn returns_specified_config() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (specifier, specified_config) = config(&project_root); + let files = vec![specified_config.path.clone()]; + + fs.write_file(project_root.join(".parcelrc"), String::from("{}")); + fs.write_file(specified_config.path, specified_config.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some(&specifier), + fallback_config: None, + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((specified_config.parcel_config, files))); + } + } + + mod fallback_config { + use std::rc::Rc; + + use super::*; + use crate::parcel_config_fixtures::default_config; + use crate::parcel_config_fixtures::extended_config; + use crate::parcel_config_fixtures::fallback_config; + + #[test] + fn errors_on_failed_fallback_resolution() { + let fs = InMemoryFileSystem::default(); + let mut package_manager = MockPackageManager::new(); + let project_root = fs.cwd().unwrap(); + + fail_package_manager_resolution(&mut package_manager); + + let err = ParcelRcConfigLoader::new(&fs, &package_manager) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: None, + fallback_config: Some("@parcel/config-default"), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::UnresolvedConfig { + config_type: String::from("fallback"), + from: project_root.join("index"), + specifier: String::from("@parcel/config-default"), + source: Box::new(ResolveError::NotFound(String::from(""), String::from(""))), + } + .to_string() + ) + ); + } + + #[test] + fn errors_on_failed_extended_fallback_config_resolution() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (fallback_specifier, fallback) = extended_config(&project_root); + + fs.write_file( + fallback.base_config.path.clone(), + fallback.base_config.parcel_rc, + ); + + let err = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: None, + fallback_config: Some(&fallback_specifier), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::UnresolvedConfig { + config_type: String::from("extended config"), + from: fallback.base_config.path, + specifier: String::from("@parcel/config-default"), + source: Box::new(ResolveError::NotFound(String::from(""), String::from(""))), + } + .to_string() + ), + ); + } + + #[test] + fn errors_on_missing_fallback_config_file() { + let fs = InMemoryFileSystem::default(); + let mut package_manager = MockPackageManager::new(); + let project_root = fs.cwd().unwrap(); + + let fallback_config_path = package_manager_resolution( + &mut package_manager, + String::from("@parcel/config-default"), + project_root.join("index"), + ); + + let err = ParcelRcConfigLoader::new(&InMemoryFileSystem::default(), &package_manager) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: None, + fallback_config: Some("@parcel/config-default"), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + err, + Err( + ConfigError::ReadConfigFile { + path: fallback_config_path, + source: std::io::Error::new(std::io::ErrorKind::NotFound, "Not found") + } + .to_string() + ) + ); + } + + #[test] + fn returns_project_root_parcel_rc() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (fallback_specifier, fallback) = fallback_config(&project_root); + let project_root_config = default_config(&Rc::new(project_root.join(".parcelrc"))); + + fs.write_file( + project_root_config.path.clone(), + project_root_config.parcel_rc, + ); + + fs.write_file(fallback.path, String::from("{}")); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: None, + fallback_config: Some(&fallback_specifier), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!( + parcel_config, + Ok(( + project_root_config.parcel_config, + vec!(project_root_config.path) + )) + ); + } + + #[test] + fn returns_fallback_config_when_parcel_rc_is_missing() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (fallback_specifier, fallback) = fallback_config(&project_root); + let files = vec![fallback.path.clone()]; + + fs.write_file(fallback.path, fallback.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: None, + fallback_config: Some(&fallback_specifier), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((fallback.parcel_config, files))); + } + } + + mod fallback_with_config { + use super::*; + use crate::parcel_config_fixtures::config; + use crate::parcel_config_fixtures::fallback_config; + + #[test] + fn returns_specified_config() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (config_specifier, config) = config(&project_root); + let (fallback_config_specifier, fallback_config) = fallback_config(&project_root); + + let files = vec![config.path.clone()]; + + fs.write_file(config.path, config.parcel_rc); + fs.write_file(fallback_config.path, fallback_config.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some(&config_specifier), + fallback_config: Some(&fallback_config_specifier), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((config.parcel_config, files))); + } + + #[test] + fn returns_fallback_config_when_config_file_missing() { + let mut fs = InMemoryFileSystem::default(); + let project_root = fs.cwd().unwrap(); + + let (config_specifier, _config) = config(&project_root); + let (fallback_config_specifier, fallback) = fallback_config(&project_root); + + let files = vec![fallback.path.clone()]; + + fs.write_file(fallback.path, fallback.parcel_rc); + + let parcel_config = ParcelRcConfigLoader::new(&fs, &InMemoryPackageManager::new(&fs)) + .load( + &project_root, + LoadConfigOptions { + additional_reporters: Vec::new(), + config: Some(&config_specifier), + fallback_config: Some(&fallback_config_specifier), + }, + ) + .map_err(|e| e.to_string()); + + assert_eq!(parcel_config, Ok((fallback.parcel_config, files))); + } + } +} diff --git a/crates/parcel_config/src/partial_parcel_config.rs b/crates/parcel_config/src/partial_parcel_config.rs new file mode 100644 index 00000000000..03acb702307 --- /dev/null +++ b/crates/parcel_config/src/partial_parcel_config.rs @@ -0,0 +1,692 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use derive_builder::Builder; +use indexmap::IndexMap; + +use super::parcel_config::PluginNode; +use super::parcel_rc::ParcelRcFile; +use crate::config_error::ConfigError; + +/// An intermediate representation of the .parcelrc config +/// +/// This data structure is used to perform configuration merging, to eventually create a compelete ParcelConfig. +/// +#[derive(Clone, Debug, Default, Builder, PartialEq)] +#[builder(default)] +pub struct PartialParcelConfig { + pub bundler: Option, + pub compressors: IndexMap>, + pub namers: Vec, + pub optimizers: IndexMap>, + pub packagers: IndexMap, + pub reporters: Vec, + pub resolvers: Vec, + pub runtimes: Vec, + pub transformers: IndexMap>, + pub validators: IndexMap>, +} + +impl TryFrom<&ParcelRcFile> for PartialParcelConfig { + type Error = ConfigError; + + fn try_from(parcel_rc: &ParcelRcFile) -> Result { + // TODO Add validation here: multiple ..., plugin name format, reserved pipelines, etc + + let resolve_from = Rc::new(parcel_rc.path.clone()); + + let to_entry = |package_name: &String| PluginNode { + package_name: String::from(package_name), + resolve_from: Rc::clone(&resolve_from), + }; + + let to_vec = |maybe_plugins: Option<&Vec>| { + maybe_plugins + .map(|plugins| plugins.iter().map(to_entry).collect()) + .unwrap_or(Vec::new()) + }; + + let to_pipelines = |map: Option<&IndexMap>>| { + map + .map(|plugins| { + plugins + .iter() + .map(|(pattern, plugins)| { + ( + String::from(pattern), + plugins.iter().map(to_entry).collect(), + ) + }) + .collect() + }) + .unwrap_or(IndexMap::new()) + }; + + let to_pipeline = |map: Option<&IndexMap>| { + map + .map(|plugins| { + plugins + .iter() + .map(|(pattern, package_name)| (String::from(pattern), to_entry(package_name))) + .collect() + }) + .unwrap_or(IndexMap::new()) + }; + + Ok(PartialParcelConfig { + bundler: parcel_rc + .contents + .bundler + .as_ref() + .map(|package_name| PluginNode { + package_name: String::from(package_name), + resolve_from: Rc::clone(&resolve_from), + }), + compressors: to_pipelines(parcel_rc.contents.compressors.as_ref()), + namers: to_vec(parcel_rc.contents.namers.as_ref()), + optimizers: to_pipelines(parcel_rc.contents.optimizers.as_ref()), + packagers: to_pipeline(parcel_rc.contents.packagers.as_ref()), + reporters: to_vec(parcel_rc.contents.reporters.as_ref()), + resolvers: to_vec(parcel_rc.contents.resolvers.as_ref()), + runtimes: to_vec(parcel_rc.contents.runtimes.as_ref()), + transformers: to_pipelines(parcel_rc.contents.transformers.as_ref()), + validators: to_pipelines(parcel_rc.contents.validators.as_ref()), + }) + } +} + +impl PartialParcelConfig { + fn merge_map( + map: IndexMap, + extend_map: IndexMap, + merge: fn(map: T, extend_map: T) -> T, + ) -> IndexMap { + if extend_map.is_empty() { + return map; + } + + if map.is_empty() { + return extend_map; + } + + let mut merged_map = IndexMap::new(); + let mut used_patterns = HashSet::new(); + + // Add the extension options first so they have higher precedence in the output glob map + for (pattern, extend_pipelines) in extend_map { + let map_pipelines = map.get(&pattern); + if let Some(pipelines) = map_pipelines { + used_patterns.insert(pattern.clone()); + merged_map.insert(pattern, merge(pipelines.clone(), extend_pipelines)); + } else { + merged_map.insert(pattern, extend_pipelines); + } + } + + // Add remaining pipelines + for (pattern, value) in map { + if !used_patterns.contains(&pattern) { + merged_map.insert(String::from(pattern), value); + } + } + + merged_map + } + + fn merge_pipeline_map( + map: IndexMap, + extend_map: IndexMap, + ) -> IndexMap { + PartialParcelConfig::merge_map(map, extend_map, |map, _extend_map| map) + } + + fn merge_pipelines_map( + from_map: IndexMap>, + extend_map: IndexMap>, + ) -> IndexMap> { + PartialParcelConfig::merge_map(from_map, extend_map, PartialParcelConfig::merge_pipelines) + } + + fn merge_pipelines( + from_pipelines: Vec, + extend_pipelines: Vec, + ) -> Vec { + if extend_pipelines.is_empty() { + return from_pipelines; + } + + if from_pipelines.is_empty() { + return extend_pipelines; + } + + let spread_index = from_pipelines + .iter() + .position(|plugin| plugin.package_name == "..."); + + match spread_index { + None => from_pipelines, + Some(index) => { + let extend_pipelines = extend_pipelines.as_slice(); + + [ + &from_pipelines[..index], + extend_pipelines, + &from_pipelines[(index + 1)..], + ] + .concat() + } + } + } + + pub fn merge(from_config: PartialParcelConfig, extend_config: PartialParcelConfig) -> Self { + PartialParcelConfig { + bundler: from_config.bundler.or(extend_config.bundler), + compressors: PartialParcelConfig::merge_pipelines_map( + from_config.compressors, + extend_config.compressors, + ), + namers: PartialParcelConfig::merge_pipelines(from_config.namers, extend_config.namers), + optimizers: PartialParcelConfig::merge_pipelines_map( + from_config.optimizers, + extend_config.optimizers, + ), + packagers: PartialParcelConfig::merge_pipeline_map( + from_config.packagers, + extend_config.packagers, + ), + reporters: PartialParcelConfig::merge_pipelines( + from_config.reporters, + extend_config.reporters, + ), + resolvers: PartialParcelConfig::merge_pipelines( + from_config.resolvers, + extend_config.resolvers, + ), + runtimes: PartialParcelConfig::merge_pipelines(from_config.runtimes, extend_config.runtimes), + transformers: PartialParcelConfig::merge_pipelines_map( + from_config.transformers, + extend_config.transformers, + ), + validators: PartialParcelConfig::merge_pipelines_map( + from_config.validators, + extend_config.validators, + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod merge { + use super::*; + + mod bundler { + use std::path::PathBuf; + + use super::*; + + #[test] + fn uses_from_when_extend_missing() { + let from = PartialParcelConfigBuilder::default() + .bundler(Some(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + })) + .build() + .unwrap(); + + let extend = PartialParcelConfig::default(); + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn uses_extend_when_from_missing() { + let from = PartialParcelConfig::default(); + let extend = PartialParcelConfigBuilder::default() + .bundler(Some(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + })) + .build() + .unwrap(); + + let expected = extend.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn merges_using_from() { + let from = PartialParcelConfigBuilder::default() + .bundler(Some(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + })) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .bundler(Some(PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("/")), + })) + .build() + .unwrap(); + + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + } + + macro_rules! test_pipeline_map { + ($property: ident) => { + #[cfg(test)] + mod $property { + use std::path::PathBuf; + + use indexmap::indexmap; + + use super::*; + + #[test] + fn uses_from_when_extend_missing() { + let from = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let extend = PartialParcelConfig::default(); + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn uses_extend_when_from_missing() { + let from = PartialParcelConfig::default(); + let extend = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let expected = extend.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn merges_patterns() { + let from = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.{cjs,js,mjs}") => vec!(PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }) + }) + .build() + .unwrap(); + + assert_eq!( + PartialParcelConfig::merge(from, extend), + PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }), + String::from("*.{cjs,js,mjs}") => vec!(PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }), + }) + .build() + .unwrap() + ); + } + + #[test] + fn merges_pipelines_with_missing_dot_dot_dot() { + let from = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn merges_pipelines_with_dot_dot_dot() { + let from = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("..."), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }) + }) + .build() + .unwrap(); + + assert_eq!( + PartialParcelConfig::merge(from, extend), + PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap() + ); + } + + #[test] + fn merges_pipelines_with_dot_dot_dot_match_in_grandparent() { + let from = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("..."), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap(); + + let extend_1 = PartialParcelConfig::default(); + let extend_2 = PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }) + }) + .build() + .unwrap(); + + assert_eq!( + PartialParcelConfig::merge(PartialParcelConfig::merge(from, extend_1), extend_2), + PartialParcelConfigBuilder::default() + .$property(indexmap! { + String::from("*.js") => vec!(PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }) + }) + .build() + .unwrap() + ); + } + } + }; + } + + macro_rules! test_pipelines { + ($property: ident) => { + #[cfg(test)] + mod $property { + use std::path::PathBuf; + + use super::*; + + #[test] + fn uses_from_when_extend_missing() { + let from = PartialParcelConfigBuilder::default() + .$property(vec![PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }]) + .build() + .unwrap(); + + let extend = PartialParcelConfig::default(); + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn uses_extend_when_from_missing() { + let from = PartialParcelConfig::default(); + let extend = PartialParcelConfigBuilder::default() + .$property(vec![PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }]) + .build() + .unwrap(); + + let expected = extend.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn merges_pipelines_with_missing_dot_dot_dot() { + let from = PartialParcelConfigBuilder::default() + .$property(vec![ + PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + ]) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .$property(vec![PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }]) + .build() + .unwrap(); + + let expected = from.clone(); + + assert_eq!(PartialParcelConfig::merge(from, extend), expected); + } + + #[test] + fn merges_pipelines_with_dot_dot_dot() { + let from = PartialParcelConfigBuilder::default() + .$property(vec![ + PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("..."), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + ]) + .build() + .unwrap(); + + let extend = PartialParcelConfigBuilder::default() + .$property(vec![PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }]) + .build() + .unwrap(); + + assert_eq!( + PartialParcelConfig::merge(from, extend), + PartialParcelConfigBuilder::default() + .$property(vec!( + PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + } + )) + .build() + .unwrap() + ); + } + + #[test] + fn merges_pipelines_with_dot_dot_dot_match_in_grandparent() { + let from = PartialParcelConfigBuilder::default() + .$property(vec![ + PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("..."), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + ]) + .build() + .unwrap(); + + let extend_1 = PartialParcelConfig::default(); + let extend_2 = PartialParcelConfigBuilder::default() + .$property(vec![PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }]) + .build() + .unwrap(); + + assert_eq!( + PartialParcelConfig::merge(PartialParcelConfig::merge(from, extend_1), extend_2), + PartialParcelConfigBuilder::default() + .$property(vec!( + PluginNode { + package_name: String::from("a"), + resolve_from: Rc::new(PathBuf::from("/")), + }, + PluginNode { + package_name: String::from("b"), + resolve_from: Rc::new(PathBuf::from("~")), + }, + PluginNode { + package_name: String::from("c"), + resolve_from: Rc::new(PathBuf::from("/")), + } + )) + .build() + .unwrap() + ); + } + } + }; + } + + test_pipeline_map!(compressors); + test_pipelines!(namers); + test_pipeline_map!(optimizers); + test_pipelines!(reporters); + test_pipelines!(resolvers); + test_pipelines!(runtimes); + test_pipeline_map!(transformers); + test_pipeline_map!(validators); + } +} diff --git a/crates/parcel_config/src/pipeline.rs b/crates/parcel_config/src/pipeline.rs new file mode 100644 index 00000000000..10f095aa7db --- /dev/null +++ b/crates/parcel_config/src/pipeline.rs @@ -0,0 +1,361 @@ +use std::path::Path; + +use glob_match::glob_match; +use indexmap::IndexMap; + +use super::parcel_config::PluginNode; + +/// Represents fields in .parcelrc that use an object, mapping a pattern to a list of plugin names +/// +/// # Examples +/// +/// ``` +/// use std::path::PathBuf; +/// use std::rc::Rc; +/// +/// use indexmap::indexmap; +/// use parcel_config::pipeline::PipelineMap; +/// use parcel_config::PluginNode; +/// +/// PipelineMap::new(indexmap! { +/// String::from("*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}") => vec![PluginNode { +/// package_name: String::from("@parcel/transformer-js"), +/// resolve_from: Rc::new(PathBuf::default()), +/// }] +/// }); +/// ``` +/// +#[derive(Debug, Default, PartialEq)] +pub struct PipelineMap { + /// Maps patterns to a series of plugins, called pipelines + map: IndexMap>, +} + +impl PipelineMap { + pub fn new(map: IndexMap>) -> Self { + Self { map } + } + + /// Finds pipelines contained by a pattern that match the given file path and named pipeline + /// + /// This function will return an empty vector when a pipeline is provided and there are no exact + /// matches. Otherwise, exact pattern matches will be returned followed by any other matching + /// patterns. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use std::rc::Rc; + /// + /// use indexmap::indexmap; + /// use parcel_config::pipeline::PipelineMap; + /// use parcel_config::PluginNode; + /// + /// let pipeline_map = PipelineMap::new(indexmap! { + /// String::from("types:*.{ts,tsx}") => vec![PluginNode { + /// package_name: String::from("@parcel/transformer-typescript-types"), + /// resolve_from: Rc::new(PathBuf::default()), + /// }], + /// String::from("*.toml") => vec![PluginNode { + /// package_name: String::from("@parcel/transformer-toml"), + /// resolve_from: Rc::new(PathBuf::default()), + /// }], + /// }); + /// + /// pipeline_map.get(&PathBuf::from("component.tsx"), &Some("types")); + /// pipeline_map.get(&PathBuf::from("Cargo.toml"), &None::); + /// ``` + pub fn get(&self, path: &Path, named_pipeline: &Option>) -> Vec { + let basename = path.file_name().unwrap().to_str().unwrap(); + let path = path.as_os_str().to_str().unwrap(); + let mut matches: Vec = Vec::new(); + + // If a pipeline is requested, a the glob needs to match exactly + if let Some(pipeline) = named_pipeline { + let exact_match = self + .map + .iter() + .find(|(pattern, _)| is_match(pattern, path, basename, pipeline.as_ref())); + + if let Some((_, pipelines)) = exact_match { + matches.extend(pipelines.iter().cloned()); + } else { + return Vec::new(); + } + } + + for (pattern, pipelines) in self.map.iter() { + if is_match(&pattern, path, basename, "") { + matches.extend(pipelines.iter().cloned()); + } + } + + matches + } + + pub fn contains_named_pipeline(&self, pipeline: impl AsRef) -> bool { + let named_pipeline = format!("{}:", pipeline.as_ref()); + + self + .map + .keys() + .any(|glob| glob.starts_with(&named_pipeline)) + } + + pub fn named_pipelines(&self) -> Vec<&str> { + self + .map + .keys() + .filter_map(|glob| glob.split_once(':').map(|g| g.0)) + .collect() + } +} + +pub(crate) fn is_match(pattern: &str, path: &str, basename: &str, pipeline: &str) -> bool { + let (pattern_pipeline, glob) = pattern.split_once(':').unwrap_or(("", pattern)); + pipeline == pattern_pipeline && (glob_match(glob, basename) || glob_match(glob, path)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::rc::Rc; + + use super::*; + + fn pipelines() -> Vec { + vec![PluginNode { + package_name: String::from("@parcel/plugin-1"), + resolve_from: Rc::new(PathBuf::default()), + }] + } + + fn pipelines_two() -> Vec { + vec![PluginNode { + package_name: String::from("@parcel/plugin-2"), + resolve_from: Rc::new(PathBuf::default()), + }] + } + + fn pipelines_three() -> Vec { + vec![PluginNode { + package_name: String::from("@parcel/plugin-3"), + resolve_from: Rc::new(PathBuf::default()), + }] + } + + mod get { + use std::env; + + use indexmap::indexmap; + + use super::*; + + fn paths(filename: &str) -> Vec { + let cwd = env::current_dir().unwrap(); + vec![ + PathBuf::from(filename), + cwd.join(filename), + cwd.join("src").join(filename), + ] + } + + #[test] + fn returns_empty_vec_for_empty_map() { + let empty_map = PipelineMap::default(); + let empty_vec: Vec = Vec::new(); + + assert_eq!( + empty_map.get(&PathBuf::from("a.js"), &None::), + empty_vec + ); + + assert_eq!( + empty_map.get(&PathBuf::from("a.toml"), &None::), + empty_vec + ); + } + + #[test] + fn returns_empty_vec_when_no_matching_path() { + let empty_pipeline: Option<&str> = None; + let empty_vec: Vec = Vec::new(); + let map = PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines() + }); + + assert_eq!(map.get(&PathBuf::from("a.css"), &empty_pipeline), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.jsx"), &empty_pipeline), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.tsx"), &empty_pipeline), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.tom"), &empty_pipeline), empty_vec); + assert_eq!( + map.get(&PathBuf::from("a.tomla"), &empty_pipeline), + empty_vec + ); + } + + #[test] + fn returns_empty_vec_when_no_matching_pipeline() { + let empty_vec: Vec = Vec::new(); + let map = PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines(), + String::from("types:*.{ts,tsx}") => pipelines(), + String::from("url:*") => pipelines_two() + }); + + assert_eq!(map.get(&PathBuf::from("a.css"), &Some("css")), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.jsx"), &Some("jsx")), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.tsx"), &Some("tsx")), empty_vec); + assert_eq!(map.get(&PathBuf::from("a.ts"), &Some("typesa")), empty_vec); + assert_eq!( + map.get(&PathBuf::from("a.js"), &Some("data-url")), + empty_vec + ); + } + + #[test] + fn returns_matching_plugins_for_empty_pipeline() { + let empty_pipeline: Option<&str> = None; + let map = PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines_two() + }); + + for path in paths("a.js") { + assert_eq!(map.get(&path, &empty_pipeline), pipelines()); + } + + for path in paths("a.ts") { + assert_eq!(map.get(&path, &empty_pipeline), pipelines()); + } + + for path in paths("a.toml") { + assert_eq!(map.get(&path, &empty_pipeline), pipelines_two()); + } + } + + #[test] + fn returns_matching_plugins_for_pipeline() { + let map = PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines_three(), + String::from("*.toml") => pipelines_three(), + String::from("types:*.{ts,tsx}") => pipelines(), + String::from("url:*") => pipelines_two() + }); + + let expected_ts: Vec = [pipelines(), pipelines_three()].concat(); + for path in paths("a.ts") { + assert_eq!(map.get(&path, &Some("types")), expected_ts); + } + + for path in paths("a.tsx") { + assert_eq!(map.get(&path, &Some("types")), pipelines()); + } + + for path in paths("a.url") { + assert_eq!(map.get(&path, &Some("url")), pipelines_two()); + } + } + } + + mod contains_named_pipeline { + use indexmap::indexmap; + + use super::*; + + #[test] + fn returns_true_when_named_pipeline_exists() { + let map = PipelineMap::new(indexmap! { + String::from("data-url:*") => pipelines() + }); + + assert_eq!(map.contains_named_pipeline("data-url"), true); + } + + #[test] + fn returns_false_for_empty_map() { + let empty_map = PipelineMap::default(); + + assert_eq!(empty_map.contains_named_pipeline("data-url"), false); + assert_eq!(empty_map.contains_named_pipeline("types"), false); + } + + #[test] + fn returns_false_when_named_pipeline_does_not_exist() { + let map = PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines(), + String::from("url:*") => pipelines() + }); + + assert_eq!(map.contains_named_pipeline("*"), false); + assert_eq!(map.contains_named_pipeline("data-url"), false); + assert_eq!(map.contains_named_pipeline("types"), false); + assert_eq!(map.contains_named_pipeline("urls"), false); + } + } + + mod named_pipelines { + use indexmap::indexmap; + + use super::*; + + #[test] + fn returns_empty_vec_when_no_named_pipelines() { + let empty_vec: Vec<&str> = Vec::new(); + + assert_eq!(PipelineMap::default().named_pipelines(), empty_vec); + assert_eq!( + PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines(), + }) + .named_pipelines(), + empty_vec, + ); + } + + #[test] + fn returns_list_of_named_pipelines() { + assert_eq!( + PipelineMap::new(indexmap! { + String::from("data-url:*") => pipelines() + }) + .named_pipelines(), + vec!("data-url") + ); + + assert_eq!( + PipelineMap::new(indexmap! { + String::from("types:*.{ts,tsx}") => pipelines() + }) + .named_pipelines(), + vec!("types") + ); + + assert_eq!( + PipelineMap::new(indexmap! { + String::from("url:*") => pipelines() + }) + .named_pipelines(), + vec!("url") + ); + + assert_eq!( + PipelineMap::new(indexmap! { + String::from("*.{js,ts}") => pipelines(), + String::from("*.toml") => pipelines(), + String::from("bundle-text:*") => pipelines(), + String::from("data-url:*") => pipelines(), + String::from("types:*.{ts,tsx}") => pipelines(), + String::from("url:*") => pipelines() + }) + .named_pipelines(), + vec!("bundle-text", "data-url", "types", "url") + ); + } + } +} diff --git a/crates/parcel_filesystem/src/in_memory_file_system/mod.rs b/crates/parcel_filesystem/src/in_memory_file_system/mod.rs index eba90ad0a7f..ce10c8d313d 100644 --- a/crates/parcel_filesystem/src/in_memory_file_system/mod.rs +++ b/crates/parcel_filesystem/src/in_memory_file_system/mod.rs @@ -6,12 +6,14 @@ use std::path::PathBuf; use crate::FileSystem; /// In memory implementation of a file-system entry +#[derive(Debug)] enum InMemoryFileSystemEntry { File { contents: String }, Directory, } /// In memory implementation of the `FileSystem` trait, for testing purpouses. +#[derive(Debug)] pub struct InMemoryFileSystem { files: HashMap, current_working_directory: PathBuf, @@ -89,14 +91,14 @@ impl FileSystem for InMemoryFileSystem { || { Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "file not found", + "File not found", )) }, |entry| match entry { InMemoryFileSystemEntry::File { contents } => Ok(contents.clone()), InMemoryFileSystemEntry::Directory => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "path is a directory", + "Path is a directory", )), }, ) diff --git a/crates/parcel_filesystem/src/lib.rs b/crates/parcel_filesystem/src/lib.rs index 4248d84f662..2c430b227c4 100644 --- a/crates/parcel_filesystem/src/lib.rs +++ b/crates/parcel_filesystem/src/lib.rs @@ -10,6 +10,8 @@ pub mod js_delegate_file_system; /// In-memory file-system for testing pub mod in_memory_file_system; +pub mod search; + /// File-system implementation using std::fs and a canonicalize cache #[cfg(not(target_arch = "wasm32"))] pub mod os_file_system; diff --git a/crates/parcel_filesystem/src/search.rs b/crates/parcel_filesystem/src/search.rs new file mode 100644 index 00000000000..6f98ea3ff86 --- /dev/null +++ b/crates/parcel_filesystem/src/search.rs @@ -0,0 +1,33 @@ +use std::path::Path; +use std::path::PathBuf; + +use crate::FileSystem; + +pub fn find_ancestor_file>( + fs: &impl FileSystem, + filenames: Vec, + from: P, + root: P, +) -> Option { + for dir in from.as_ref().ancestors() { + // Break if we hit a node_modules directory + if let Some(filename) = dir.file_name() { + if filename == "node_modules" { + break; + } + } + + for name in &filenames { + let fullpath = dir.join(name); + if fs.is_file(&fullpath) { + return Some(fullpath); + } + } + + if dir == root.as_ref() { + break; + } + } + + None +} diff --git a/crates/parcel_package_manager/Cargo.toml b/crates/parcel_package_manager/Cargo.toml new file mode 100644 index 00000000000..f92edfeb488 --- /dev/null +++ b/crates/parcel_package_manager/Cargo.toml @@ -0,0 +1,9 @@ +[package] +authors = ["Monica Olejniczak "] +name = "parcel_package_manager" +version = "0.1.0" +edition = "2021" + +[dependencies] +mockall = "0.12.1" +thiserror = "1.0.59" diff --git a/crates/parcel_package_manager/src/lib.rs b/crates/parcel_package_manager/src/lib.rs new file mode 100644 index 00000000000..5c9ba02b12d --- /dev/null +++ b/crates/parcel_package_manager/src/lib.rs @@ -0,0 +1,6 @@ +pub mod package_manager; + +pub use package_manager::MockPackageManager; +pub use package_manager::PackageManager; +pub use package_manager::Resolution; +pub use package_manager::ResolveError; diff --git a/crates/parcel_package_manager/src/package_manager.rs b/crates/parcel_package_manager/src/package_manager.rs new file mode 100644 index 00000000000..039541e21cb --- /dev/null +++ b/crates/parcel_package_manager/src/package_manager.rs @@ -0,0 +1,20 @@ +use std::path::Path; +use std::path::PathBuf; + +use mockall::automock; +use thiserror::Error; + +pub struct Resolution { + pub resolved: PathBuf, +} + +#[derive(Debug, Error)] +pub enum ResolveError { + #[error("Cannot find module '{0}' from {1}")] + NotFound(String, String), +} + +#[automock] +pub trait PackageManager { + fn resolve(&self, specifier: &str, from: &Path) -> Result; +}