diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-06 18:05:27 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-06 18:05:27 +0900 |
| commit | 3d128352f93c4416d087069947920e9fa864df7d (patch) | |
| tree | 52026a6ae07ad0dbc2a62e487dd4d9550992e3b8 | |
| parent | 4a9aff1af9fc74d2928fe54210d6aad5f0afd0b7 (diff) | |
| download | php-mozart-3d128352f93c4416d087069947920e9fa864df7d.tar.gz php-mozart-3d128352f93c4416d087069947920e9fa864df7d.tar.zst php-mozart-3d128352f93c4416d087069947920e9fa864df7d.zip | |
feat(core): port Factory::createComposer and AutoloadGenerator::dump
Add the Composer state-container types (LocalRepository,
RepositoryManager, InstallationManager, AutoloadGenerator,
AutoloadDumpOptions, PlatformRequirementFilter, Locker) plus the
factory wiring that builds them from composer.json and
vendor/composer/installed.json.
AutoloadGenerator::dump lives in mozart-autoload as an extension
trait so the orchestrating algorithm sits next to the classmap
scanner while the state container stays in mozart-core. Rework
dump-autoload to drive both, mirroring
$composer->getAutoloadGenerator()->dump(...).
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | crates/mozart-autoload/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/dump.rs | 340 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/lib.rs | 3 | ||||
| -rw-r--r-- | crates/mozart-core/src/composer.rs | 500 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 299 | ||||
| -rw-r--r-- | crates/mozart/src/commands/archive.rs | 3 | ||||
| -rw-r--r-- | crates/mozart/src/commands/dump_autoload.rs | 164 | ||||
| -rw-r--r-- | crates/mozart/src/commands/run_script.rs | 5 | ||||
| -rw-r--r-- | crates/mozart/tests/cli_dump_autoload.rs | 5 |
10 files changed, 1216 insertions, 107 deletions
@@ -1133,7 +1133,9 @@ dependencies = [ "indexmap", "md5", "mozart-class-map-generator", + "mozart-core", "mozart-registry", + "regex", "serde_json", "tempfile", ] diff --git a/crates/mozart-autoload/Cargo.toml b/crates/mozart-autoload/Cargo.toml index 1f1ed5a..571d70f 100644 --- a/crates/mozart-autoload/Cargo.toml +++ b/crates/mozart-autoload/Cargo.toml @@ -5,10 +5,12 @@ edition.workspace = true [dependencies] mozart-class-map-generator.workspace = true +mozart-core.workspace = true mozart-registry.workspace = true anyhow.workspace = true indexmap.workspace = true md5.workspace = true +regex.workspace = true serde_json.workspace = true [dev-dependencies] diff --git a/crates/mozart-autoload/src/dump.rs b/crates/mozart-autoload/src/dump.rs new file mode 100644 index 0000000..103c683 --- /dev/null +++ b/crates/mozart-autoload/src/dump.rs @@ -0,0 +1,340 @@ +//! `Composer\Autoload\AutoloadGenerator::dump` extension. +//! +//! [`mozart_core::composer::AutoloadGenerator`] is a state container in +//! `mozart-core`; the dumping algorithm itself sits here in +//! `mozart-autoload` because it pulls in the classmap scanner, +//! installed.json reader, and PHP-emission helpers. This module hangs +//! `dump()` off the generator via [`AutoloadGeneratorExt`] so callers +//! can still write `composer.autoload_generator().dump(...)`, matching +//! `$composer->getAutoloadGenerator()->dump(...)` in PHP. +//! +//! Bring [`AutoloadGeneratorExt`] into scope at the call site: +//! +//! ```ignore +//! use mozart_autoload::AutoloadGeneratorExt; +//! ``` +//! +//! See `Composer\Autoload\AutoloadGenerator::dump()` (the ~500-line +//! implementation in `composer/src/Composer/Autoload/AutoloadGenerator.php`) +//! for the upstream semantics. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use mozart_core::composer::{ + AutoloadDumpOptions, AutoloadGenerator, InstallationManager, LocalRepository, Locker, + PlatformRequirementFilter, +}; +use mozart_core::config::Config; +use mozart_core::package::RawPackageData; + +use crate::autoload::{AutoloadConfig, PlatformCheckMode, generate}; + +/// Mirror of `Composer\ClassMapGenerator\ClassMap` — the return value +/// of `AutoloadGenerator::dump`. PHP's class is a `Countable` carrying +/// the discovered class map plus PSR-violation and ambiguous-class +/// records; Mozart only models the slice that command handlers need to +/// branch on today (`count`, `has_psr_violations`, `has_ambiguous_classes`). +/// +/// The `map` / `psr_violations` / `ambiguous_classes` fields are +/// currently populated from the existing [`generate`]'s coarse +/// summary — once `generate` is refactored to expose the full classmap +/// these fields will hold the real entries. +pub struct ClassMap { + map: BTreeMap<String, String>, + psr_violations: Vec<String>, + ambiguous_classes: BTreeMap<String, Vec<String>>, +} + +impl ClassMap { + /// Mirror of `ClassMap::count`. + pub fn count(&self) -> usize { + self.map.len() + } + + /// Mirror of `count($classMap->getPsrViolations()) > 0`. PHP returns + /// the violation strings; commands typically only need the boolean. + pub fn has_psr_violations(&self) -> bool { + !self.psr_violations.is_empty() + } + + /// Mirror of `count($classMap->getAmbiguousClasses($filter)) > 0`. + /// `with_filter = true` applies PHP's default test/fixture/example + /// path filter; `false` skips it (the `$duplicatesFilter = false` + /// branch upstream). + pub fn has_ambiguous_classes(&self, with_filter: bool) -> bool { + if !with_filter { + return !self.ambiguous_classes.is_empty(); + } + let pattern = regex_filter_default(); + self.ambiguous_classes.values().any(|paths| { + paths + .iter() + .any(|p| !pattern.is_match(&p.replace('\\', "/"))) + }) + } + + /// Read access to the underlying map (`getMap()` upstream). + pub fn map(&self) -> &BTreeMap<String, String> { + &self.map + } + + /// Read access to the PSR-violation warnings. + pub fn psr_violations(&self) -> &[String] { + &self.psr_violations + } + + /// Read access to the ambiguous-class records. + pub fn ambiguous_classes(&self) -> &BTreeMap<String, Vec<String>> { + &self.ambiguous_classes + } +} + +fn regex_filter_default() -> regex::Regex { + use std::sync::OnceLock; + static RE: OnceLock<regex::Regex> = OnceLock::new(); + RE.get_or_init(|| { + // `{/(test|fixture|example|stub)s?/}i` from PHP's + // ClassMap::getAmbiguousClasses default. + regex::Regex::new(r"(?i)/(test|fixture|example|stub)s?/") + .expect("default ambiguous filter compiles") + }) + .clone() +} + +/// Extension trait hanging `dump()` off +/// [`mozart_core::composer::AutoloadGenerator`]. Mirrors +/// `Composer\Autoload\AutoloadGenerator::dump()`. +/// +/// Bring this trait into scope (`use mozart_autoload::AutoloadGeneratorExt;`) +/// to make the method visible. +/// +/// Diverges from PHP in one place: the per-call toggles PHP fixes via +/// `setDryRun` / `setDevMode` / … on the generator are passed in here +/// as an [`AutoloadDumpOptions`] argument, because Mozart's +/// [`AutoloadGenerator`] is stateless. +pub trait AutoloadGeneratorExt { + /// Mirror of `AutoloadGenerator::dump(Config $config, + /// InstalledRepositoryInterface $localRepo, RootPackageInterface + /// $rootPackage, InstallationManager $installationManager, string + /// $targetDir, bool $scanPsrPackages = false, ?string $suffix = null, + /// ?Locker $locker = null, bool $strictAmbiguous = false)`. + /// + /// Mozart-specific notes: + /// - `options` carries the toggles PHP fixes via setters on the + /// generator (`setDryRun`, `setDevMode`, `setApcu`, …). + /// - `target_dir` is currently unused (the underlying [`generate`] + /// always writes into `vendor_dir/composer`); the parameter is + /// kept on the signature so the call site mirrors PHP and we can + /// honour it once the writer is parameterised. + /// - `local_repo` and `root_package` are accepted to mirror the + /// PHP signature, but [`generate`] currently re-reads them from + /// `installed.json` / `composer.json`. Refactoring to consume the + /// passed-in values lives in a follow-up. + #[allow(clippy::too_many_arguments)] + fn dump( + &self, + options: &AutoloadDumpOptions, + config: &Config, + local_repo: &LocalRepository, + root_package: &RawPackageData, + installation_manager: &InstallationManager, + target_dir: &str, + scan_psr_packages: bool, + suffix: Option<&str>, + locker: &Locker, + strict_ambiguous: bool, + ) -> anyhow::Result<ClassMap>; +} + +impl AutoloadGeneratorExt for AutoloadGenerator { + fn dump( + &self, + options: &AutoloadDumpOptions, + config: &Config, + _local_repo: &LocalRepository, + _root_package: &RawPackageData, + installation_manager: &InstallationManager, + _target_dir: &str, + scan_psr_packages: bool, + suffix: Option<&str>, + locker: &Locker, + strict_ambiguous: bool, + ) -> anyhow::Result<ClassMap> { + // Mirrors PHP: classmap-authoritative implies PSR scanning so + // every class gets a fixed map entry. + let scan = scan_psr_packages || options.class_map_authoritative; + + // Mirrors PHP's `if (null === $this->devMode)` branch: read the + // `dev` flag from `vendor/composer/installed.json` when no + // explicit dev-mode has been set on the options. + let dev_mode = match options.dev_mode { + Some(m) => m, + None => read_installed_dev_flag(installation_manager.vendor_dir()), + }; + + // Mirrors PHP's suffix resolution chain in `dump()`: + // 1. explicit argument + // 2. `Config::get('autoloader-suffix')` + // 3. existing `vendor/autoload.php`'s `ComposerAutoloaderInit{X}` + // 4. `composer.lock`'s `content-hash` (when locked) + // 5. random hex + let resolved_suffix = resolve_suffix(suffix, config, installation_manager, locker)?; + + // Mirrors PHP: `$basePath = realpath(getcwd())`. We don't have + // an explicit project_dir on the generator, but `vendor_dir`'s + // parent matches the project root for the common + // `vendor-dir = "vendor"` layout. When the user points + // `vendor-dir` outside the project we fall back to `.`. + let project_dir = installation_manager + .vendor_dir() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Mirrors PHP's `$checkPlatform = $config->get('platform-check') !== + // false && !($filter instanceof IgnoreAllPlatformRequirementFilter)`. + let platform_check = if matches!( + options.platform_requirement_filter, + PlatformRequirementFilter::IgnoreAll + ) { + PlatformCheckMode::Disabled + } else { + platform_check_mode_from_config(&config.platform_check) + }; + + let cfg = AutoloadConfig { + project_dir, + vendor_dir: installation_manager.vendor_dir().to_path_buf(), + dev_mode, + suffix: resolved_suffix, + classmap_authoritative: options.class_map_authoritative, + optimize: scan, + apcu: options.apcu, + apcu_prefix: options.apcu_prefix.clone(), + // `dump()` does not surface a `--strict-psr` option (that's + // a separate command-line flag on `dump-autoload`); the + // generator only reports violations via `ClassMap`. + strict_psr: false, + strict_ambiguous, + platform_check, + ignore_platform_reqs: matches!( + options.platform_requirement_filter, + PlatformRequirementFilter::IgnoreAll + ), + }; + + if options.dry_run { + // PHP's dry-run still scans and returns the classmap but + // skips file writes. The current [`generate`] does not + // expose a dry-run hook, so we return an empty ClassMap + // for now and surface the limitation here rather than + // silently writing files. + return Ok(ClassMap { + map: BTreeMap::new(), + psr_violations: Vec::new(), + ambiguous_classes: BTreeMap::new(), + }); + } + + let result = generate(&cfg)?; + + // Mozart's `GenerateResult` only carries summary flags + // (`class_count`, `has_psr_violations`, `has_ambiguous_classes`), + // not the actual class-name / path entries that PHP's `ClassMap` + // exposes. We project the summary onto a `ClassMap` shape so + // command code that only branches on `count()` / `has_*()` works + // today; refactoring `generate` to surface the full map is + // tracked as follow-up work. + let mut map = BTreeMap::new(); + for i in 0..result.class_count { + map.insert(format!("__mozart_placeholder_{i}"), String::new()); + } + let psr_violations = if result.has_psr_violations { + vec![String::from( + "PSR-0/4 violation detected (details not yet surfaced)", + )] + } else { + Vec::new() + }; + let mut ambiguous_classes = BTreeMap::new(); + if result.has_ambiguous_classes { + ambiguous_classes.insert("__mozart_placeholder".to_string(), Vec::new()); + } + + Ok(ClassMap { + map, + psr_violations, + ambiguous_classes, + }) + } +} + +fn read_installed_dev_flag(vendor_dir: &std::path::Path) -> bool { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return false; + } + let Ok(content) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else { + return false; + }; + value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) +} + +fn resolve_suffix( + explicit: Option<&str>, + config: &Config, + installation_manager: &InstallationManager, + locker: &Locker, +) -> anyhow::Result<String> { + if let Some(s) = explicit + && !s.is_empty() + { + return Ok(s.to_string()); + } + if let Some(s) = config.autoloader_suffix.as_ref() + && !s.is_empty() + { + return Ok(s.clone()); + } + let vendor_path = installation_manager.vendor_dir(); + let autoload_path = vendor_path.join("autoload.php"); + if autoload_path.exists() + && let Ok(content) = std::fs::read_to_string(&autoload_path) + && let Some(start) = content.find("ComposerAutoloaderInit") + { + let rest = &content[start + "ComposerAutoloaderInit".len()..]; + if let Some(end) = rest.find("::") { + let candidate = &rest[..end]; + if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(candidate.to_string()); + } + } + } + if locker.is_locked() + && let Some(data) = locker.lock_data()? + && !data.content_hash.is_empty() + { + return Ok(data.content_hash); + } + // Fall back to MD5 of the current timestamp (mirrors PHP's + // `bin2hex(random_bytes(16))` — both produce a 32-char hex token + // that participates only in classloader naming). + let ts = format!("{:?}", std::time::SystemTime::now()); + Ok(format!("{:x}", md5::compute(ts.as_bytes()))) +} + +fn platform_check_mode_from_config(platform_check: &serde_json::Value) -> PlatformCheckMode { + match platform_check { + serde_json::Value::Bool(false) => PlatformCheckMode::Disabled, + serde_json::Value::Bool(true) => PlatformCheckMode::Full, + serde_json::Value::String(s) if s == "php-only" => PlatformCheckMode::PhpOnly, + // Anything else (including JSON null / unknown strings) falls + // through to `Full` — the safe default that PHP also picks + // when the value is truthy-but-not-`"php-only"`. + _ => PlatformCheckMode::Full, + } +} diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs index fc80aed..0ee48fe 100644 --- a/crates/mozart-autoload/src/lib.rs +++ b/crates/mozart-autoload/src/lib.rs @@ -1 +1,4 @@ pub mod autoload; +pub mod dump; + +pub use dump::{AutoloadGeneratorExt, ClassMap}; diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs index 2e252c6..6fe022a 100644 --- a/crates/mozart-core/src/composer.rs +++ b/crates/mozart-core/src/composer.rs @@ -1,18 +1,23 @@ -//! Composer-equivalent root state: composer.json + effective config. +//! Composer-equivalent root state: composer.json + effective config + +//! the manager objects commands look up off the root [`Composer`]. //! -//! Mirrors the role of `Composer\Composer` (PHP) to the extent that command -//! handlers need today: a single struct loaded from the project directory, -//! exposing a `config()` accessor over the merged Composer config. +//! Mirrors the role of `Composer\Composer` / `Composer\PartialComposer` +//! (PHP) — a state container with getters for the merged [`Config`], the +//! root [`RawPackageData`], the [`RepositoryManager`], and the +//! [`InstallationManager`]. Wiring lives in [`crate::factory`], the same +//! split as upstream's `Composer\Factory::createComposer`. //! //! See `Composer\Command\BaseCommand::requireComposer()` / //! `Composer\Command\BaseCommand::tryComposer()` for the upstream contract //! that [`Composer::require`] and [`Composer::try_load`] are modelled on. -use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use crate::config::{Config, resolve_references}; -use crate::factory::create_config; +use regex::Regex; + +use crate::config::Config; +use crate::factory::create_composer; +use crate::package::RawPackageData; /// Return the Composer home directory, respecting `COMPOSER_HOME` and falling /// back to the platform default using Composer-compatible logic. @@ -72,17 +77,433 @@ fn use_xdg() -> bool { || std::path::Path::new("/etc/xdg").is_dir() } -/// Project-level Composer state. Currently only carries the merged -/// [`Config`]; additional accessors (root package, locker, …) can be -/// layered on as commands need them. +/// Project-level Composer state. Mirrors `Composer\PartialComposer` / +/// `Composer\Composer` in PHP, exposing the subset of getters command +/// handlers need today: config, root package, repository manager, +/// installation manager, autoload generator, and locker. More +/// managers (download, …) can be layered on as commands need them. pub struct Composer { project_dir: PathBuf, config: Config, + package: RawPackageData, + repository_manager: RepositoryManager, + installation_manager: InstallationManager, + autoload_generator: AutoloadGenerator, + locker: Locker, +} + +/// Subset of `Composer\Package\PackageInterface` needed by the +/// installation manager. Today only the fields referenced by +/// `LibraryInstaller::getInstallPath` (`prettyName`, `targetDir`). +#[derive(Debug, Clone)] +pub struct LocalPackage { + pretty_name: String, + target_dir: Option<String>, +} + +impl LocalPackage { + pub fn new(pretty_name: String, target_dir: Option<String>) -> Self { + Self { + pretty_name, + target_dir, + } + } + + /// Original case-preserving package name (`vendor/Name`). + /// Mirrors `PackageInterface::getPrettyName`. + pub fn pretty_name(&self) -> &str { + &self.pretty_name + } + + /// Optional sub-directory inside the install path that holds the + /// package code. Mirrors `PackageInterface::getTargetDir`. + pub fn target_dir(&self) -> Option<&str> { + self.target_dir.as_deref() + } +} + +/// In-memory mirror of `Composer\Repository\InstalledFilesystemRepository` +/// (`vendor/composer/installed.json`). Carries enough information for +/// commands that walk the local install (currently: `dump-autoload`). +pub struct LocalRepository { + packages: Vec<LocalPackage>, +} + +impl LocalRepository { + pub fn new(packages: Vec<LocalPackage>) -> Self { + Self { packages } + } + + /// Mirror of `WritableRepositoryInterface::getCanonicalPackages` — + /// "at most one package of each name, with aliases unfolded". Mozart + /// does not yet model alias packages, so this is currently a straight + /// pass-through over the loaded packages. + pub fn canonical_packages(&self) -> impl Iterator<Item = &LocalPackage> { + self.packages.iter() + } +} + +/// Mirror of `Composer\Repository\RepositoryManager`. Today only the +/// local repository is wired up; remote repositories are loaded ad hoc by +/// commands and will move here as the registry layer is ported. +pub struct RepositoryManager { + local_repository: LocalRepository, +} + +impl RepositoryManager { + pub fn new(local_repository: LocalRepository) -> Self { + Self { local_repository } + } + + /// Mirror of `RepositoryManager::getLocalRepository`. + pub fn local_repository(&self) -> &LocalRepository { + &self.local_repository + } +} + +/// Mirror of `Composer\Installer\InstallationManager`. Without an +/// installer plugin chain Mozart only supports the `LibraryInstaller` +/// behaviour (`vendor-dir/<pretty-name>(/<target-dir>)`). +pub struct InstallationManager { + vendor_dir: PathBuf, +} + +impl InstallationManager { + pub fn new(vendor_dir: PathBuf) -> Self { + Self { vendor_dir } + } + + /// Resolved absolute path of the vendor directory. Not on PHP's + /// `InstallationManager`, but the autoload generator needs it + /// without the round-trip through `Config::get('vendor-dir')`. + pub fn vendor_dir(&self) -> &Path { + &self.vendor_dir + } + + /// Mirror of `InstallationManager::getInstallPath` — the absolute + /// path on disk where a package's code is expected to live. Returns + /// `None` when the package has nothing on disk (metapackages); for + /// regular library packages this matches `LibraryInstaller::getInstallPath`. + pub fn get_install_path(&self, package: &LocalPackage) -> Option<PathBuf> { + let mut path = self.vendor_dir.join(package.pretty_name()); + if let Some(td) = package.target_dir() { + path = path.join(td); + } + Some(path) + } +} + +/// Mirror of `Composer\Autoload\AutoloadGenerator`. +/// +/// PHP's class is stateful: `setDryRun`, `setDevMode`, … flip private +/// flags that `dump()` later reads. Mozart deliberately diverges here — +/// the per-call toggles live in [`AutoloadDumpOptions`] which is +/// passed into `dump()` as a parameter, and [`AutoloadGenerator`] is a +/// once-constructed handle that only holds dependencies that are +/// genuinely lifetime-shared (PHP's `EventDispatcher` / `IO` will land +/// here once they're ported). Today there are none, so the struct is +/// empty — but keeping it as a real type preserves the +/// `composer.autoload_generator().dump(...)` calling shape and gives a +/// home for those dependencies later. +pub struct AutoloadGenerator { + // Intentionally empty. EventDispatcher / IO will move here once + // ported; for now `dump()` (in `mozart-autoload`) reads everything + // it needs from its arguments. + _private: (), +} + +impl AutoloadGenerator { + pub fn new() -> Self { + Self { _private: () } + } +} + +impl Default for AutoloadGenerator { + fn default() -> Self { + Self::new() + } +} + +/// Per-invocation toggles passed to +/// `mozart_autoload::AutoloadGeneratorExt::dump`. +/// +/// Diverges from PHP, where these live on `AutoloadGenerator` itself +/// and are flipped by `setDryRun` / `setDevMode` / … . In Mozart the +/// generator carries no transient state, so commands assemble an +/// [`AutoloadDumpOptions`] and hand it to `dump()` directly. +pub struct AutoloadDumpOptions { + /// `None` mirrors PHP's `private ?bool $devMode = null` — meaning + /// "auto-detect from `installed.json`'s `dev` flag at dump time". + /// `Some(_)` corresponds to an explicit `setDevMode` call. + pub dev_mode: Option<bool>, + /// `setClassMapAuthoritative`. + pub class_map_authoritative: bool, + /// `setApcu` first arg. + pub apcu: bool, + /// `setApcu` second arg. The prefix is recorded even when `apcu` + /// is false, matching the PHP signature. + pub apcu_prefix: Option<String>, + /// `setRunScripts`. + pub run_scripts: bool, + /// `setDryRun`. + pub dry_run: bool, + /// `setPlatformRequirementFilter`. Defaults to + /// `PlatformRequirementFilterFactory::ignoreNothing()`. + pub platform_requirement_filter: PlatformRequirementFilter, +} + +impl AutoloadDumpOptions { + /// Same defaults as PHP's `AutoloadGenerator::__construct` — every + /// toggle off, dev-mode unset (auto-detect), filter set to + /// `IgnoreNothing`. + pub fn new() -> Self { + Self { + dev_mode: None, + class_map_authoritative: false, + apcu: false, + apcu_prefix: None, + run_scripts: false, + dry_run: false, + platform_requirement_filter: PlatformRequirementFilter::ignore_nothing(), + } + } +} + +impl Default for AutoloadDumpOptions { + fn default() -> Self { + Self::new() + } +} + +/// Mirror of `Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface` +/// and its three concrete implementations. +/// +/// The autoload generator and resolver consult this when deciding +/// whether to emit / enforce a `php`, `ext-*`, `lib-*`, or +/// `composer-*` requirement. For non-platform packages every variant +/// returns `false` — matching PHP's `IgnoreListPlatformRequirementFilter` +/// short-circuiting via `PlatformRepository::isPlatformPackage`. +pub enum PlatformRequirementFilter { + /// `IgnoreNothingPlatformRequirementFilter`. Default. + IgnoreNothing, + /// `IgnoreAllPlatformRequirementFilter` — every platform package is + /// ignored. + IgnoreAll, + /// `IgnoreListPlatformRequirementFilter` — match against an explicit + /// list of names (with `*` glob support). Names suffixed with `+` + /// only suppress the upper bound, mirroring the PHP constructor. + /// `None` for either regex means "no entries" (the corresponding + /// list was empty), short-circuiting to no match. + IgnoreList { + ignore_regex: Option<Regex>, + ignore_upper_bound_regex: Option<Regex>, + }, +} + +impl PlatformRequirementFilter { + /// Mirror of `PlatformRequirementFilterFactory::ignoreNothing`. + pub fn ignore_nothing() -> Self { + PlatformRequirementFilter::IgnoreNothing + } + + /// Mirror of `PlatformRequirementFilterFactory::ignoreAll`. + pub fn ignore_all() -> Self { + PlatformRequirementFilter::IgnoreAll + } + + /// Mirror of `PlatformRequirementFilterFactory::fromBoolOrList` for + /// the list branch. `reqs` accepts entries suffixed with `+` to + /// only ignore the upper bound (`IgnoreListPlatformRequirementFilter`'s + /// constructor splits on the same suffix). + pub fn from_list(reqs: &[String]) -> anyhow::Result<Self> { + let mut ignore_all: Vec<String> = Vec::new(); + let mut ignore_upper_bound: Vec<String> = Vec::new(); + for req in reqs { + if let Some(stripped) = req.strip_suffix('+') { + ignore_upper_bound.push(stripped.to_string()); + } else { + ignore_all.push(req.clone()); + } + } + Ok(PlatformRequirementFilter::IgnoreList { + ignore_regex: package_names_to_regexp(&ignore_all)?, + ignore_upper_bound_regex: package_names_to_regexp(&ignore_upper_bound)?, + }) + } + + /// Mirror of `PlatformRequirementFilterFactory::fromBoolOrList`. + pub fn from_bool_or_list(value: BoolOrList) -> anyhow::Result<Self> { + match value { + BoolOrList::Bool(true) => Ok(Self::ignore_all()), + BoolOrList::Bool(false) => Ok(Self::ignore_nothing()), + BoolOrList::List(list) => Self::from_list(&list), + } + } + + /// Mirror of `PlatformRequirementFilterInterface::isIgnored`. + pub fn is_ignored(&self, req: &str) -> bool { + match self { + PlatformRequirementFilter::IgnoreNothing => false, + PlatformRequirementFilter::IgnoreAll => is_platform_package(req), + PlatformRequirementFilter::IgnoreList { ignore_regex, .. } => { + is_platform_package(req) && ignore_regex.as_ref().is_some_and(|re| re.is_match(req)) + } + } + } + + /// Mirror of `PlatformRequirementFilterInterface::isUpperBoundIgnored`. + pub fn is_upper_bound_ignored(&self, req: &str) -> bool { + match self { + PlatformRequirementFilter::IgnoreNothing => false, + PlatformRequirementFilter::IgnoreAll => is_platform_package(req), + PlatformRequirementFilter::IgnoreList { + ignore_regex, + ignore_upper_bound_regex, + } => { + if !is_platform_package(req) { + return false; + } + ignore_regex.as_ref().is_some_and(|re| re.is_match(req)) + || ignore_upper_bound_regex + .as_ref() + .is_some_and(|re| re.is_match(req)) + } + } + } +} + +/// Helper accepted by [`PlatformRequirementFilter::from_bool_or_list`] +/// — mirrors PHP's `bool|string[]` union by replacing it with a tagged +/// enum at the boundary. Commands typically have an +/// `--ignore-platform-reqs` flag (the `Bool` arm) plus an optional +/// `--ignore-platform-req <name>` list (the `List` arm), and convert at +/// the call site. +pub enum BoolOrList { + Bool(bool), + List(Vec<String>), +} + +/// Compile a list of package names (with `*` glob support) into a +/// case-insensitive regex matching any of them. Mirrors +/// `BasePackage::packageNamesToRegexp` and its `packageNameToRegexp` +/// helper: each name is `preg_quote`'d, then `\*` becomes `.*`. +/// +/// Returns `None` when `names` is empty — Rust's `regex` crate refuses +/// regexes that never match, so we model "match nothing" as the +/// absence of a compiled regex and short-circuit at the call site. +fn package_names_to_regexp(names: &[String]) -> anyhow::Result<Option<Regex>> { + if names.is_empty() { + return Ok(None); + } + let parts: Vec<String> = names + .iter() + .map(|n| regex::escape(n).replace("\\*", ".*")) + .collect(); + let pattern = format!("(?i)^(?:{})$", parts.join("|")); + Ok(Some(Regex::new(&pattern)?)) +} + +/// Mirror of `Composer\Repository\PlatformRepository::isPlatformPackage` +/// using the same canonical regex (`PLATFORM_PACKAGE_REGEX`). +fn is_platform_package(name: &str) -> bool { + use std::sync::OnceLock; + static RE: OnceLock<Regex> = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new( + r"(?i)^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$", + ) + .expect("PLATFORM_PACKAGE_REGEX compiles") + }); + re.is_match(name) +} + +/// Mirror of `Composer\Package\Locker`. The full PHP class is a thick +/// wrapper around `composer.lock` (lock-data dump/load, freshness +/// check, dev-package tracking, …) — Mozart's port currently just +/// holds the lockfile path and exposes the slice the autoload +/// generator needs (`isLocked()` / `getLockData()['content-hash']`). +/// The richer accessors will land as more commands are ported. +pub struct Locker { + lock_file_path: PathBuf, +} + +impl Locker { + pub fn new(lock_file_path: PathBuf) -> Self { + Self { lock_file_path } + } + + /// Path to the underlying `composer.lock`. Mirrors + /// `Locker::getJsonFile()->getPath()`. + pub fn lock_file_path(&self) -> &Path { + &self.lock_file_path + } + + /// Mirror of `Locker::isLocked`. PHP additionally checks for the + /// presence of the `packages` array in a parsed lock; for now the + /// file-existence check is enough — every command that calls + /// `lock_data()` afterwards will surface a parse error if the + /// lockfile is corrupt. + pub fn is_locked(&self) -> bool { + self.lock_file_path.exists() + } + + /// Mirror of `Locker::getLockData`. Returns `Ok(None)` when the + /// lockfile is absent (PHP would throw `LogicException`; Mozart + /// commands currently treat "no lock" as "no data" so the autoload + /// suffix path stays simple). + pub fn lock_data(&self) -> anyhow::Result<Option<LockData>> { + if !self.lock_file_path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&self.lock_file_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let content_hash = value + .get("content-hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(Some(LockData { content_hash })) + } +} + +/// Subset of `composer.lock` fields the autoload generator currently +/// reads. Mirrors `Locker::getLockData()` return shape, narrowed to +/// what's load-bearing today (the `content-hash` used as the autoloader +/// suffix). More fields can be added when other ports start needing +/// them. +pub struct LockData { + pub content_hash: String, } impl Composer { + /// All-args constructor used by [`crate::factory::create_composer`]. + /// Mirrors the PHP pattern of `new Composer()` followed by + /// `setConfig` / `setPackage` / `setRepositoryManager` / + /// `setInstallationManager` / `setAutoloadGenerator` / `setLocker`, + /// collapsed into a single immutable build. + pub fn new( + project_dir: PathBuf, + config: Config, + package: RawPackageData, + repository_manager: RepositoryManager, + installation_manager: InstallationManager, + autoload_generator: AutoloadGenerator, + locker: Locker, + ) -> Self { + Self { + project_dir, + config, + package, + repository_manager, + installation_manager, + autoload_generator, + locker, + } + } + /// Load Composer state for `project_dir`, requiring a composer.json. - /// Mirrors `BaseCommand::requireComposer()`. + /// Mirrors `BaseCommand::requireComposer()`, which delegates to + /// `Factory::createComposer` after asserting the file exists. pub fn require(project_dir: impl Into<PathBuf>) -> anyhow::Result<Self> { let project_dir = project_dir.into(); let composer_json = project_dir.join("composer.json"); @@ -92,7 +513,7 @@ impl Composer { project_dir.display() ); } - Self::load(project_dir, &composer_json) + create_composer(project_dir, &composer_json) } /// Load Composer state for `project_dir`, returning `None` if no @@ -104,25 +525,7 @@ impl Composer { if !composer_json.exists() { return Ok(None); } - Self::load(project_dir, &composer_json).map(Some) - } - - fn load(project_dir: PathBuf, composer_json: &Path) -> anyhow::Result<Self> { - let content = std::fs::read_to_string(composer_json)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - let mut config = create_config()?; - if let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) { - let overrides: BTreeMap<String, serde_json::Value> = cfg_obj - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - config.merge(&overrides)?; - } - resolve_references(&mut config); - Ok(Self { - project_dir, - config, - }) + create_composer(project_dir, &composer_json).map(Some) } pub fn project_dir(&self) -> &Path { @@ -132,4 +535,39 @@ impl Composer { pub fn config(&self) -> &Config { &self.config } + + /// Root package loaded from the project's `composer.json`. Mirrors + /// `Composer::getPackage()`; ideally this would return a fully + /// resolved `RootPackageInterface` equivalent, but Mozart does not + /// yet have a `RootPackageLoader` port — for now callers see the + /// raw, pre-normalised JSON shape. + pub fn package(&self) -> &RawPackageData { + &self.package + } + + /// Mirror of `Composer::getRepositoryManager()`. + pub fn repository_manager(&self) -> &RepositoryManager { + &self.repository_manager + } + + /// Mirror of `Composer::getInstallationManager()`. + pub fn installation_manager(&self) -> &InstallationManager { + &self.installation_manager + } + + /// Mirror of `Composer::getAutoloadGenerator()`. + /// + /// Returned by shared reference because Mozart's + /// [`AutoloadGenerator`] is stateless — per-call toggles live on + /// [`AutoloadDumpOptions`] passed into `dump()`, not on the + /// generator itself. Diverges from PHP's + /// `$composer->getAutoloadGenerator()->setDryRun(...)` chain. + pub fn autoload_generator(&self) -> &AutoloadGenerator { + &self.autoload_generator + } + + /// Mirror of `Composer::getLocker()`. + pub fn locker(&self) -> &Locker { + &self.locker + } } diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs index faa98d8..92aa70e 100644 --- a/crates/mozart-core/src/factory.rs +++ b/crates/mozart-core/src/factory.rs @@ -1,14 +1,23 @@ -//! Factory helpers for constructing Composer configuration. +//! Factory helpers for constructing Composer state. //! -//! Ports the static factory methods from `Composer\Factory` that deal with -//! default and global configuration. Auth loading and htaccess creation are -//! intentionally omitted as they are out of scope for the current port. +//! Ports the static factory methods from `Composer\Factory`. Today we +//! cover [`create_config`] (effective global [`Config`]) and +//! [`create_composer`] (the project-level [`Composer`] root, built from +//! `composer.json` plus the on-disk `vendor/composer/installed.json`). +//! +//! Auth loading, htaccess creation, and the plugin/event-dispatcher +//! wiring are intentionally omitted as they are out of scope for the +//! current port. use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::composer::composer_home; -use crate::config::Config; +use crate::composer::{ + AutoloadGenerator, Composer, InstallationManager, LocalPackage, LocalRepository, Locker, + RepositoryManager, composer_home, +}; +use crate::config::{Config, resolve_references}; +use crate::package::read_from_file; /// Rust port of `Factory::getCacheDir()`. /// @@ -147,6 +156,135 @@ pub fn create_config() -> anyhow::Result<Config> { Ok(config) } +/// Rust port of `Factory::createComposer()`. +/// +/// Builds the project-level [`Composer`]: +/// 1. Read `composer.json` from `composer_json` and load it into both +/// the merged [`Config`] (overlaying [`create_config`]) and the +/// untyped [`crate::package::RawPackageData`]. +/// 2. Resolve all `{$home}` / `{$vendor-dir}` placeholders via +/// [`resolve_references`]. +/// 3. Resolve `vendor-dir` against `project_dir` if it is relative, so +/// the installation manager hands back absolute paths +/// (`Factory::createComposer` does the same via +/// `Filesystem::isAbsolutePath`). +/// 4. Wire up the [`InstallationManager`] and a [`RepositoryManager`] +/// whose local repository is populated from +/// `vendor/composer/installed.json` — the same role +/// `Factory::addLocalRepository` plays in PHP. +/// 5. Construct a fresh [`AutoloadGenerator`] with PHP defaults +/// (`new AutoloadGenerator($eventDispatcher, $io)` in PHP, minus the +/// not-yet-ported event dispatcher and IO dependencies). +/// 6. Construct a [`Locker`] pointed at `composer.lock` next to the +/// composer.json — same as `Factory::createComposer`'s +/// `new Locker($io, new JsonFile($lockFile, …), $im, $contents)`, +/// minus the IO/installation-manager/contents dependencies that +/// only matter once we port `setLockData`. +/// +/// The plugin manager, download manager, and event dispatcher that +/// `Factory::createComposer` also wires up are not yet ported. +pub fn create_composer(project_dir: PathBuf, composer_json: &Path) -> anyhow::Result<Composer> { + let content = std::fs::read_to_string(composer_json)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let mut config = create_config()?; + if let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) { + let overrides: BTreeMap<String, serde_json::Value> = cfg_obj + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + config.merge(&overrides)?; + } + resolve_references(&mut config); + + let package = read_from_file(composer_json)?; + + // Mirrors `Factory::createComposer`'s `vendorDir` handling. The + // value out of `Config::get('vendor-dir')` already had `{$...}` + // placeholders substituted, but it may still be relative — resolve + // it against the project root so install paths are absolute. + let vendor_dir = if Path::new(&config.vendor_dir).is_absolute() { + PathBuf::from(&config.vendor_dir) + } else { + project_dir.join(&config.vendor_dir) + }; + + let local_packages = read_local_packages(&vendor_dir)?; + let repository_manager = RepositoryManager::new(LocalRepository::new(local_packages)); + let installation_manager = InstallationManager::new(vendor_dir); + let autoload_generator = AutoloadGenerator::new(); + + // Mirrors `Factory::createComposer`'s lock-file path: the lockfile + // sits next to composer.json, with `.json` swapped for `.lock`. + let lock_file_path = composer_json + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| project_dir.clone()) + .join( + composer_json + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.strip_suffix(".json").unwrap_or(n)) + .map(|stem| format!("{stem}.lock")) + .unwrap_or_else(|| "composer.lock".to_string()), + ); + let locker = Locker::new(lock_file_path); + + Ok(Composer::new( + project_dir, + config, + package, + repository_manager, + installation_manager, + autoload_generator, + locker, + )) +} + +/// Read `vendor/composer/installed.json` into the minimal shape the +/// installation manager needs. Mirrors the relevant slice of +/// `Composer\Repository\FilesystemRepository::initialize`: accept both +/// the v2 object form (`{packages: [...]}`) and the legacy v1 array +/// form. Returns an empty list when the file is missing — the same +/// semantics as `FilesystemRepository::isFresh`. +/// +/// We deliberately avoid pulling the full `InstalledPackages` reader from +/// `mozart-registry` here to keep `mozart-core` at the bottom of the +/// dependency graph; the parsing that's actually load-bearing for the +/// install-path computation is just the package name + optional +/// `target-dir`. +fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<Vec<LocalPackage>> { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(&path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + let entries: &[serde_json::Value] = match &value { + serde_json::Value::Object(obj) => match obj.get("packages") { + Some(serde_json::Value::Array(arr)) => arr.as_slice(), + _ => return Ok(Vec::new()), + }, + serde_json::Value::Array(arr) => arr.as_slice(), + _ => return Ok(Vec::new()), + }; + + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let pretty_name = entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let target_dir = entry + .get("target-dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + out.push(LocalPackage::new(pretty_name, target_dir)); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +346,151 @@ mod tests { result.display() ); } + + mod create_composer { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn install_path_is_vendor_dir_plus_pretty_name() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"{"packages": [{"name": "Vendor/Pkg", "version": "1.0.0"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + + // Mirrors `LibraryInstaller::getInstallPath`: + // `vendorDir + '/' + prettyName`. `pretty-name` is preserved + // case (Composer/Repository/FilesystemRepository keeps the original). + assert_eq!(install_path, dir.path().join("vendor").join("Vendor/Pkg")); + } + + #[test] + fn install_path_appends_target_dir() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"{"packages": [{"name": "vendor/pkg", "target-dir": "src/lib"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + + assert_eq!(install_path, dir.path().join("vendor/vendor/pkg/src/lib")); + } + + #[test] + fn local_repository_is_empty_when_installed_json_missing() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + + let composer = Composer::require(dir.path()).unwrap(); + let count = composer + .repository_manager() + .local_repository() + .canonical_packages() + .count(); + assert_eq!(count, 0); + } + + #[test] + fn local_repository_accepts_v1_array_form() { + // Older Composer 1.x / fixture format: bare array of packages. + // FilesystemRepository::initialize accepts this; our minimal + // reader must too. + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"[{"name": "a/a"}, {"name": "b/b"}]"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let names: Vec<&str> = composer + .repository_manager() + .local_repository() + .canonical_packages() + .map(|p| p.pretty_name()) + .collect(); + assert_eq!(names, vec!["a/a", "b/b"]); + } + + #[test] + fn package_returns_root_composer_json() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("composer.json"), + r#"{"name": "acme/app", "require": {"vendor/pkg": "^1.0"}}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + assert_eq!(composer.package().name, "acme/app"); + assert_eq!( + composer + .package() + .require + .get("vendor/pkg") + .map(String::as_str), + Some("^1.0"), + ); + } + + #[test] + fn install_path_uses_configured_vendor_dir() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("composer.json"), + r#"{"name": "acme/app", "config": {"vendor-dir": "deps"}}"#, + ); + write( + &dir.path().join("deps/composer/installed.json"), + r#"{"packages": [{"name": "vendor/pkg"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + assert_eq!(install_path, dir.path().join("deps/vendor/pkg")); + } + } } diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index 044178f..b02e44e 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -1,4 +1,5 @@ use clap::Args; +use mozart_core::composer::Composer; use mozart_core::console_writeln; use std::path::PathBuf; @@ -99,7 +100,7 @@ pub async fn execute( // `archive` command falls back to `Factory::createConfig()` when no // composer.json is present, so we mirror that by treating a missing // file as "use defaults" (Composer::try_load returns None). - let composer = mozart_core::composer::Composer::try_load(&working_dir)?; + let composer = Composer::try_load(&working_dir)?; let composer_json_path = working_dir.join("composer.json"); let (config_archive_format, config_archive_dir) = match composer.as_ref() { Some(c) => { diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 076d72d..6f08b2a 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -1,5 +1,7 @@ use clap::Args; -use mozart_core::console_format; +use mozart_autoload::AutoloadGeneratorExt; +use mozart_core::composer::{AutoloadDumpOptions, Composer, PlatformRequirementFilter}; +use mozart_core::{console_format, console_writeln}; #[derive(Args)] pub struct DumpAutoloadArgs { @@ -54,35 +56,36 @@ pub async fn execute( console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; - let composer = mozart_core::composer::Composer::require(&working_dir)?; + let composer = Composer::require(&working_dir)?; - let composer_config = composer.config(); - - let vendor_dir = working_dir.join("vendor"); - - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; - let vendor_composer_dir = vendor_dir.join("composer"); - let mut missing_dependencies = false; - for pkg in &installed.packages { - if let Some(rel) = &pkg.install_path { - let install_path = vendor_composer_dir.join(rel); - if !install_path.exists() { - missing_dependencies = true; - console.info(&console_format!( - "<warning>Not all dependencies are installed. Make sure to run a \"composer install\" to install missing dependencies</warning>" - )); + let missing_dependencies = { + let installation_manager = composer.installation_manager(); + let local_repo = composer.repository_manager().local_repository(); + let mut missing = false; + for local_pkg in local_repo.canonical_packages() { + if let Some(install_path) = installation_manager.get_install_path(local_pkg) + && !install_path.exists() + { + missing = true; + console_writeln!( + console, + &console_format!( + r#"<warning>Not all dependencies are installed. Make sure to run a "composer install" to install missing dependencies</warning>"# + ), + ); break; } } - } + missing + }; - let optimize = args.optimize || composer_config.optimize_autoloader; - let classmap_authoritative = - args.classmap_authoritative || composer_config.classmap_authoritative; + let optimize = args.optimize || composer.config().optimize_autoloader; + let class_map_authoritative = + args.classmap_authoritative || composer.config().classmap_authoritative; let apcu_prefix = args.apcu_prefix.clone(); - let apcu = apcu_prefix.is_some() || args.apcu || composer_config.apcu_autoloader; + let apcu = apcu_prefix.is_some() || args.apcu || composer.config().apcu_autoloader; - let do_optimize = optimize || classmap_authoritative; + let do_optimize = optimize || class_map_authoritative; if args.strict_psr && !do_optimize { anyhow::bail!( "--strict-psr mode only works with optimized autoloader, use --optimize or --classmap-authoritative." @@ -94,65 +97,102 @@ pub async fn execute( ); } - if classmap_authoritative { - console.info("Generating optimized autoload files (authoritative)"); - } else if optimize { - console.info("Generating optimized autoload files"); - } else { - console.info("Generating autoload files"); - } + console_writeln!( + console, + &console_format!( + "<info>{}</info>", + if class_map_authoritative { + "Generating optimized autoload files (authoritative)" + } else if optimize { + "Generating optimized autoload files" + } else { + "Generating autoload files" + } + ), + ); + let dev_mode = if args.dev { + Some(true) + } else if args.no_dev { + Some(false) + } else { + None + }; if args.dev && args.no_dev { anyhow::bail!("You can not use both --no-dev and --dev as they conflict with each other."); } - - let suffix = mozart_autoload::autoload::determine_suffix(&working_dir, &vendor_dir)?; - - if args.dry_run { - console.info("Dry run: would generate autoload files"); - return Ok(()); - } - - let autoload_config = mozart_autoload::autoload::AutoloadConfig { - project_dir: working_dir, - vendor_dir, - dev_mode: !args.no_dev, - suffix, - classmap_authoritative, - optimize, + let options = AutoloadDumpOptions { + dev_mode, + class_map_authoritative, apcu, apcu_prefix, - strict_psr: args.strict_psr, - strict_ambiguous: args.strict_ambiguous, - platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, - ignore_platform_reqs: args.ignore_platform_reqs, + run_scripts: true, + dry_run: args.dry_run, + platform_requirement_filter: get_platform_requirement_filter(args)?, }; - let result = mozart_autoload::autoload::generate(&autoload_config)?; + let class_map = composer.autoload_generator().dump( + &options, + composer.config(), + composer.repository_manager().local_repository(), + composer.package(), + composer.installation_manager(), + "composer", + optimize, + None, + composer.locker(), + args.strict_ambiguous, + )?; + let number_of_classes = class_map.count(); - if classmap_authoritative { - console.info(&format!( - "Generated optimized autoload files (authoritative) containing {} classes", - result.class_count - )); + if class_map_authoritative { + console_writeln!( + console, + &console_format!( + "<info>Generated optimized autoload files (authoritative) containing {number_of_classes} classes</info>", + ), + ); } else if optimize { - console.info(&format!( - "Generated optimized autoload files containing {} classes", - result.class_count - )); + console_writeln!( + console, + &console_format!( + "<info>Generated optimized autoload files containing {number_of_classes} classes</info>", + ), + ); } else { - console.info("Generated autoload files"); + console_writeln!( + console, + &console_format!("<info>Generated autoload files</info>"), + ); } - if missing_dependencies { + if missing_dependencies || args.strict_psr && class_map.has_psr_violations() { return Err(mozart_core::exit_code::bail_silent( mozart_core::exit_code::GENERAL_ERROR, )); } - if args.strict_ambiguous && result.has_ambiguous_classes { + if args.strict_ambiguous && class_map.has_ambiguous_classes(false) { return Err(mozart_core::exit_code::bail_silent(2)); } Ok(()) } + +/// Mirror of `BaseCommand::getPlatformRequirementFilter` for the +/// `dump-autoload` command. Priority: +/// 1. `--ignore-platform-reqs` → ignore every platform requirement +/// 2. `--ignore-platform-req <name>...` (non-empty) → ignore the listed +/// names (with `*` glob support) +/// 3. neither → ignore nothing +fn get_platform_requirement_filter( + args: &DumpAutoloadArgs, +) -> anyhow::Result<PlatformRequirementFilter> { + if args.ignore_platform_reqs { + return Ok(PlatformRequirementFilter::ignore_all()); + } + if !args.ignore_platform_req.is_empty() { + return PlatformRequirementFilter::from_list(&args.ignore_platform_req); + } + Ok(PlatformRequirementFilter::ignore_nothing()) +} diff --git a/crates/mozart/src/commands/run_script.rs b/crates/mozart/src/commands/run_script.rs index f7ce7b9..77ed1c7 100644 --- a/crates/mozart/src/commands/run_script.rs +++ b/crates/mozart/src/commands/run_script.rs @@ -1,4 +1,5 @@ use clap::Args; +use mozart_core::composer::Composer; use mozart_core::console_writeln; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -79,7 +80,7 @@ pub async fn execute( let working_dir = cli.working_dir()?; // RunScriptCommand uses requireComposer in Composer; composer.json must exist. - let composer = mozart_core::composer::Composer::require(&working_dir)?; + let composer = Composer::require(&working_dir)?; let (scripts, descriptions) = load_scripts(&working_dir)?; @@ -444,7 +445,7 @@ fn wait_with_timeout( } } -fn resolve_bin_dir(working_dir: &Path, composer: &mozart_core::composer::Composer) -> PathBuf { +fn resolve_bin_dir(working_dir: &Path, composer: &Composer) -> PathBuf { // bin-dir's `{$vendor-dir}` placeholder is already resolved by Composer::load. working_dir.join(&composer.config().bin_dir) } diff --git a/crates/mozart/tests/cli_dump_autoload.rs b/crates/mozart/tests/cli_dump_autoload.rs index ca592a3..8b7ccbf 100644 --- a/crates/mozart/tests/cli_dump_autoload.rs +++ b/crates/mozart/tests/cli_dump_autoload.rs @@ -1,7 +1,5 @@ mod common; -use predicates::str::contains; - #[test] fn test_dump_autoload_dry_run() { let project = common::copy_fixture_to_temp("minimal"); @@ -11,8 +9,7 @@ fn test_dump_autoload_dry_run() { .arg("--working-dir") .arg(project.path()) .assert() - .success() - .stderr(contains("Dry run")); + .success(); } #[test] |
