aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
AgeCommit message (Collapse)Author
2026-08-30build(nix): add a package output producing the binarynsfisis
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24fix(require-command): report an unwritable composer.jsonnsfisis
The write-back probe discarded the file_put_contents result and returned a hard-coded false, so the comparison against false always held and the error branch was taken for every path is_writable() rejected. Compare the write result instead, matching the PHP original. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24refactor(silencer): stop guarding work that stays inside Rustnsfisis
Silencer only lowers the PHP error_reporting() level and re-throws whatever the guarded work raises. A region that never reaches the PHP runtime has no level to lower and emits no diagnostic on failure, so wrapping it is indistinguishable from running it unguarded. The pair kept in Application::hint_common_errors brackets a getComposer() call, which loads installed plugins and dispatches PluginEvents::INIT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23fix(curl-downloader): flush a downloaded file before it is read backnsfisis
tokio's File returns from write_all once the blocking write is queued, so the tail of the response body was still in flight when the download future resolved. The caller then renamed the file, stat'd it and copied it into the cache, so a package could land in the file cache truncated. The first run still extracted the complete file and only a later run reading that cache entry failed with "End-of-central-directory signature not found". Flushing also surfaces the last write's error, which used to be dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23docs(plugin): bring boundary text in line with the implementationnsfisis
Comments that pointed at design notes kept outside the repository are dead ends for anyone reading only the tree, so what each of them explained now lives in a tagged TODO at the site it applies to. Several of those sites also stated something the implementation does not do, and the TODOs record the actual gap instead: the two halves of the codec recognize handle descriptors by different rules, the scripts Command path drops the exception class and collects output in a BufferedOutput that cannot carry an interactive command, find_shortest_path panics where PHP throws, and the package dispatch hand-rolls the variant selection AnyPackage should own. The classifier document likewise described rust-snapshot, plugin-constructible and several of the open questions as designed rather than as built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23fix(fs): carry file_get_contents results as bytesnsfisis
file_get_contents() and file_get_contents_with_max_length() return Vec<u8> instead of a from_utf8_lossy'd String. Call sites whose consumer takes a &str still convert lossily and are marked TODO(bytes). file_get_contents_with_max_length() now reads at most the requested number of bytes instead of the whole file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23fix(config-command): print config values PHP would stringifynsfisis
`config --list` and `config <key>` rendered a value with `as_string()`, which yields None for everything but a string, so every int-valued setting printed as an empty string: `cache-ttl`, `cache-files-ttl` and the resolved value of `cache-files-maxsize`. PHP builds that output by concatenating the value into the message, which casts it, so the ports now go through `php_to_string`. Two more differences in the same rendering path: The `[a, b]` branch flattened the array through `as_list()`, which sees only `PhpMixed::List`, so a keyed array reaching it rendered as `[]`. PHP takes that branch whenever the first key is numeric, so the values now come from `array_values_mixed`, which covers both representations. The raw-vs-resolved comparison deciding between `raw (value)` and `value` compared the two as strings. PHP compares with `!==`, which also compares the type: with a `cache-ttl` of `"15552000"` in composer.json, the raw string and the resolved int are not identical and Composer prints `15552000 (15552000)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23perf(filesystem): delete directories without spawning rm -rfnsfisis
Composer shells out to `rm -rf` because PHP has no recursive directory removal. Rust has one, and every package installed from a dist archive pays for a removal: the extraction pipeline drops its temporary directory once per package, and uninstalling a package removes its whole tree. The asynchronous path goes through `tokio::fs` rather than `std::fs`, so the walk runs on a blocking thread and the sibling installs the reactor is driving keep making progress, the way they did while the subprocess was working. Installing laravel/laravel (109 packages) from a warm cache drops from 3.85 to 3.19 CPU seconds. The removals run concurrently, so on an idle 16-core machine they never reach the critical path and wall time is unchanged at 1.65 s; pinned to two cores it falls from 2.33 s to 2.20 s. Pruning the 33 dev packages with `install --no-dev`, where whole package trees are removed rather than empty temporary directories, drops from 844 ms to 806 ms even on 16 cores. Windows keeps the `rmdir /S /Q` subprocess, and both platforms keep falling back to `remove_directory_php` when the fast path does not clear the directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22chore(todo): tag or drop the TODO markers that carry no tagnsfisis
`fund_command.rs` claimed `CompleteAliasPackage` still had to be handled, but `as_complete()` resolves through `is_complete()`, which already covers `CompleteAliasPackage` and `RootAliasPackage` — the same set PHP's `instanceof CompletePackageInterface` matches. The preceding alias guard rules those out anyway, so both markers went away; the first condition is now a single chain, matching the shape of the PHP original. `Application::find()` skips commands it cannot borrow, where Symfony registers the aliases of every command unconditionally. That is a real divergence, so it gets `TODO(port)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22feat(path-downloader): implement safe_junctions as always truensfisis
Composer requires Windows 7 or later with proc_open available before it will use junctions, because a PHP bug (bugs.php.net #77552) makes junction detection fragile and can lose the target content when a package is removed. Junctions are created and removed here without going through PHP, so that bug does not apply and the check always passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22fix(auditor): keep newlines when sanitizing audit table rowsnsfisis
Auditor.php calls ConsoleIO::sanitize() with a single argument, so $allowNewlines is the default true. The port passed false, stripping newlines out of advisory titles, links and ignore reasons before they reach the table renderer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22test(show-command): run test_self againnsfisis
date() renders in the system default timezone, so the mismatch the ignore described is gone; the test passes with the local date ahead of or behind the UTC date. test_show_outdated_deps_sorting_by_age still fails that way, because ShowCommand::get_relative_time formats the release date in UTC while taking "today" from date(). Its ignore now says so on its own.
2026-08-22fix(package): dispatch get_links_for_type on Link::TYPE_* valuesnsfisis
PHP resolves a link kind through `$package->{'get'.ucfirst($linkType)}()`, so the argument is a `Link::$TYPES` value ("requires", "devRequires", ...). The helper matched the composer.json key names ("require", "require-dev", ...) instead, so every caller passing a `Link::TYPE_*` constant got an empty map back: `show <package>` printed none of its requires/provides/conflicts/ replaces sections, and `--format=json` carried none of those keys. The two callers that were passing composer.json keys now pass the matching `method`, as PHP does.
2026-08-22refactor(plugin): decode RPC values through conversion traitsnsfisis
The R-table dispatchers hand-rolled argument decoding and return encoding per method, spread over 24 helpers. `FromPluginArg` and `ToPluginValue` replace them: blanket impls for `Option<T>`, `Vec<T>` and `IndexMap<String, T>` compose over a handful of leaf types, so a composite like `Vec<IndexMap<String, String>>` needs no helper of its own, and the lossy UTF-8 conversion of a PHP string lives in one impl instead of a dozen call sites. Argument errors now read `{method} expects {expected} at position {position}` rather than a message written per call site. `arg_or` takes its default for an explicit null too, not only for an omitted argument; every parameter it serves is declared non-nullable in the proxy stubs, so PHP never passes one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22fix(event-dispatcher): run COMPOSER_BINARY without a PHP interpreternsfisis
`@composer <args>` and a bare `composer <args>` script both re-enter the binary running the script, taken from COMPOSER_BINARY. That path used to be prefixed with the PHP interpreter command, which produced `php <shirabe path> install` and could not run: Shirabe ships as a native executable, not a phar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22feat(cli): tell the user to run `shirabe`, not `composer`nsfisis
Messages that instruct the user to run a command named the Composer binary. Shirabe ships as its own binary, so the hints now name it. File names (composer.json, composer.lock), the "composer" repository type and prose about Composer itself are untouched. The installer and functional integration fixtures live in the Composer submodule and cannot be edited, so their expected output is normalized on load.
2026-08-22ci: setup GitHub Actionsnsfisis
2026-08-20fix(event-dispatcher): let a PHP script reach the Composer object graphnsfisis
A PHP script listener that walks the event it receives (Laravel's Illuminate\Foundation\ComposerScripts::postAutoloadDump asks for $event->getComposer()->getConfig()->get('vendor-dir')) aborted the run with `unknown Rust handle 2`. The event's getComposer/getIO answers register an entity in the R table and hand back its rhandle, but ScriptRpcDispatcher resolved only rhandle 0 and the one event handle of the call in flight, so it could not serve a method on a handle it had just minted itself. The R-table lookup PluginRpcDispatcher already does is now dispatch_r_table_method, shared by both. The stubs that graph hands out extend and implement the real Composer contracts (Composer\Package\PackageInterface and the rest), which live in the Composer PHP runtime and not among the generated stubs or guards, so execute_event_php_script loads that runtime the way the plugin and command-class paths do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20perf(semver): build CompilingMatcher's cache key without allocatingnsfisis
CompilingMatcher::match formats `<operator><constraint>;<version>` into a fresh String on every call, including the cache hits the key exists to serve. Profiling put the formatting at 2.9 % of self time, more than the match it guards. Build the key into a reused thread-local buffer and copy it only when there is a miss to record. Taking the version as `&str` rather than `String` removes the copy the callers made for the same reason: `optimize_by_identical_dependencies` allocated one per candidate package in its innermost loop. laravel/framework require --no-install (warm cache, network disabled): instructions:u 9142799608 -> 8895247586 (-2.7 %) cycles:u 4626440169 -> 4496152041 (-2.8 %) wall (hyperfine, 25 runs) 1.239 s +- 0.014 s -> 1.227 s +- 0.016 s (-1.0 %) monolog/monolog is unchanged (102.1 ms -> 101.6 ms, within noise). composer.lock is byte-identical for both packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20perf(semver): memoize Intervals::isSubsetOf on its operand pairnsfisis
isSubsetOf builds a throwaway `MultiConstraint([candidate, constraint])` and hands it to `Intervals::get`, whose cache key is the constraint's string form. That MultiConstraint is fresh on every call, so its memoized string form is always cold and the whole intersection has to be stringified recursively -- profiling put `AnyConstraint: Display::fmt` at 3.6 % of self time, more than the interval computation the cache exists to skip. Cache the answer on the pair of operand strings instead. Both operands are long-lived, so each one's own string memo stays warm and the throwaway intersection is never built on a hit. laravel/framework require --no-install (warm cache, network disabled): instructions:u 9885142257 -> 9142874710 (-7.5 %) cycles:u 4858265879 -> 4444135669 (-8.5 %) wall (hyperfine, 20 runs) 1.322 s +- 0.018 s -> 1.224 s +- 0.011 s (-7.4 %) monolog/monolog is unchanged (101.6 ms -> 100.3 ms, within noise). composer.lock is byte-identical for both packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20perf(repository): take ownership of the decoded provider JSONnsfisis
startCachedAsyncDownload's cached branch decoded the provider JSON into an owned value and then rebuilt it entry by entry, recursively cloning every package definition only to drop the original. Match the decoded value by value instead. PHP hands the decoded array to the closure by COW, so no copy happens there either. laravel/framework require --no-install (warm cache, network disabled): instructions:u 10276126917 -> 9884102191 (-3.8 %) cycles:u 5048262624 -> 4777881212 (-5.4 %) wall (hyperfine, 20 runs) 1.345 s +- 0.015 s -> 1.316 s +- 0.018 s (-2.2 %) monolog/monolog is unchanged (100.7 ms -> 101.7 ms, within noise). composer.lock is byte-identical for both packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19perf(package): hand out link maps behind Rcnsfisis
PackageInterface::getRequires() and friends return the array of Link objects; in PHP that is a copy-on-write array of object references, so a caller pays nothing to look at it. The port returned IndexMap<String, Link> by value, so every call deep-cloned the whole map, keys and constraints included. Pool building calls these accessors once per package per candidate, which put IndexMap::clone at 13.5% of `require laravel/laravel`. Store the maps as Rc<IndexMap<String, Link>> and return a handle. Callers that mutate the map clone it explicitly at the point of mutation, matching where PHP would separate the array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19perf(semver): return the memoized interval collection by handlensfisis
Intervals::get() memoizes generateIntervals() results, but the port deep-cloned the IntervalCollection out of the cache on every hit, where PHP hands the array back by copy-on-write. On `require laravel/laravel` that is 282k calls, 99% of them hits. Store and return Arc<IntervalCollection> so a hit costs a refcount bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19perf(semver): memoize MultiConstraint's string formnsfisis
composer/semver memoizes MultiConstraint::__toString() into $this->string, but the port recomputed it on every call. On `require laravel/laravel` that meant 837k stringifications visiting 1.7M child constraints, all of them feeding the CompilingMatcher and Intervals cache keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19perf(php-src): stop collecting version tokens into vectorsnsfisis
php_version_compare canonicalized both operands into a Vec<&str> and copied the canonicalized bytes a second time through from_utf8_lossy().into_owned(). Walk the split iterators directly and move the Vec<u8> into the String, which takes the per-call allocation count from six down to two. Splits are only ever inserted at ASCII digit boundaries, so the buffer is always valid UTF-8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19fix(input): thread a typed InputValue through the input layernsfisis
Options and arguments were stored and passed as PhpMixed even though Symfony only ever puts a string, a bool, a list of strings or null in one. get_option already narrowed to InputOptionValue at the boundary; this widens that enum into InputValue and pushes it through InputInterface, InputOption/InputArgument defaults, the Input storage, ArgvInput/ArrayInput/StringInput/CompletionInput, Command::add_option and add_argument, and the Composer-side wrappers. Two neighbouring string|int unions get types of their own: InputDefinition::{get_argument,has_argument} take an ArgumentName, and ArrayInput keys its parameters by ParameterName. has_parameter_option and get_parameter_option take the values they look for as &[&str], which is what PHP's `(array) $values` cast produced anyway. Two behaviours change along the way. Input::set_option on a negated option now negates with PHP's loose bool cast rather than treating a non-bool as false, matching `!$value`. ArrayInput::parse now resolves an integer key to an argument position instead of looking up an argument literally named "0". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19perf(composer-repository): pass packages into the loader by valuensfisis
create_packages handed ArrayLoader::load_packages a clone of the package array it had just built and then dropped its own copy, so every package definition in the response was deep-copied once for nothing. Measured on `require laravel/laravel` (77 packages) against a warm cache with the network disabled: the create_packages span drops from 420 ms to 275 ms, and the run from 2.333 s to 2.192 s. Peak RSS is unchanged -- the copy was transient. `composer.lock` is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19perf(curl-downloader): reuse one reqwest client per tokio runtimensfisis
CurlDownloader built a fresh reqwest Client, and with it a fresh connection pool, on every construction. A `require` run constructs two: RequireCommand::doUpdate discards the Composer instance that BaseCommand::initialize built and rebuilds it against the rewritten composer.json, so the second one opened a second TCP+TLS connection to the same repository and paid another CA bundle parse. Nothing in the constructor varies the Client -- `options` and `disable_tls` are not applied to it -- so it can be shared. The cache is keyed by tokio runtime rather than by process: a pooled connection is driven by a task on the runtime that opened it and hangs if it is later handed to another one, and `sync_executor::block_on` builds a disposable runtime per call outside `main`. Measured on `require monolog/monolog` in a git-managed project against a warm cache: TLS connections to the repository drop from 2 to 1, taking the run from 646 ms to 529 ms with the network and from 104 ms to 101 ms offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19perf(shirabe): use mimalloc as the global allocatornsfisis
Profiling `create-project` showed glibc malloc/free accounting for about 41% of samples: the work is dominated by a very large number of small, short-lived allocations, mostly from decoding repository JSON into PhpMixed. `create-project laravel/laravel`, offline against a warmed cache, all three variants measured in one hyperfine run (8 runs each, identical composer.lock): glibc 3.633 s +- 0.023 (user 3.580 s, sys 2.255 s) mimalloc 3.096 s +- 0.054 (user 3.004 s, sys 2.315 s) -14.8% jemalloc 3.177 s +- 0.028 (user 3.136 s, sys 2.285 s) -12.5% The gain is entirely in user time; system time is unchanged, so the extraction and file-writing half of the run is unaffected. Peak RSS drops slightly, from 267 MB to 257 MB. mimalloc wins over jemalloc and, unlike jemalloc, needs no C build of its own at every profile. The allocator is declared in the library rather than in `main.rs` so that the test and benchmark binaries, which link this crate but not `main.rs`, run against the allocator the binary ships with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18perf(metadata-minifier): defer copying versions out of expandnsfisis
MetadataMinifier::expand now returns ExpandedVersions, which holds the minified input plus, for each expanded version, a table of references to where its fields live. A version is copied only when materialize() asks for it. ComposerRepository::load_async_packages runs its constraint and stability filters straight off that view through the new VersionFields trait, so the versions it rejects are never copied at all. Benchmarks are new under crates/shirabe/benches. load_packages against real packagist p2 metadata, before -> after: symfony/console (768 versions) 0 accepted 9.01 ms -> 4.40 ms -51% 50 accepted 10.70 ms -> 6.30 ms -41% 147 accepted 13.17 ms -> 9.62 ms -27% 329 accepted 18.58 ms -> 16.89 ms -9% 663 accepted 27.27 ms -> 27.89 ms +2% laravel/framework (1277 versions) 0 accepted 39.44 ms -> 11.22 ms -72% 81 accepted 44.76 ms -> 18.25 ms -59% 840 accepted 125.44 ms -> 120.12 ms -4% 1266 accepted 163.18 ms -> 182.01 ms +12% The crossover sits near 80% acceptance. Past it the view loses, because materialize rebuilds a map where the old code cloned one, and the minified input stays alive alongside the copies; the 1266-of-1277 case measured between +5% and +12% across runs. Loads with a real constraint sit far below the crossover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18fix(input): return a bool|string|string[]|null enum from get_optionnsfisis
`InputInterface::get_option` returned `PhpMixed`, so the negation branch of `Input::get_option` reproduced PHP's `return !$value;` as `!value.as_bool().unwrap_or(false)`, which inverts the result for a string value instead of leaving it `false`. It now returns `InputOptionValue`, whose `to_bool` is PHP's truthiness cast. The narrowing also removes the `Vec<PhpMixed>` element handling the commands carried for array options: `as_array` hands back `&[String]`, so the `filter_map(|v| v.as_string())` chains at eight call sites collapse. Its other accessors keep `PhpMixed`'s names and meanings (`is_null`, `as_bool`, `as_string`, `to_bool`), and `From<InputOptionValue> for PhpMixed` covers the callers that feed the value back into an `IndexMap<String, PhpMixed>` or a `PhpMixed` parameter. `Input` keeps its parsed options and `InputOption` its defaults as `PhpMixed`, so `Input::get_option` is where the narrowing happens and where a value outside the domain panics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(check-platform-reqs-command): model the JSON output as structsnsfisis
Replace the hand-built `IndexMap<String, PhpMixed>` rows behind `check-platform-reqs --format=json` with structs deriving `serde::Serialize`, so key order and the `null` values PHP keeps for a missing failed requirement or provider live in the type rather than being rebuilt at every insertion site. Test the provider against its raw value the way PHP's `$provider === ''` does, instead of against the `strip_tags` result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(diagnose-command): model check results as dedicated enumsnsfisis
The `check_*` methods returned `PhpMixed`, and `output_result` reproduced PHP's truthiness over it incorrectly: only `PhpMixed::Bool(false)` took the falsey path, so an empty string or an empty list printed a blank line instead of reporting `FAIL` and raising the exit code. `CheckResult` and `GithubRateLimit` make the shapes PHP returns explicit, and `check_platform` stores the detail of an error or warning as `Option<String>` rather than `true`-or-string. `check_http` and `check_composer_repo` also built the exception label with `std::any::type_name_of_val`, printing the Rust type path where PHP prints `get_class($e)`; they now go through `AnyThrowable::php_class_name` like the rest of the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(show-command): model the JSON output as dedicated structsnsfisis
Replace the `IndexMap<String, PhpMixed>` maps behind `show`'s three output shapes with structs deriving `serde::Serialize`, so field order, key omission and PHP's rule that an empty array encodes as `[]` live in the type instead of being rebuilt at every insertion site. Typing the fields after PHP's own signatures corrects three divergences: `description`, `source.url`/`source.reference` and `dist.url`/`dist.reference` now encode as `null` rather than `""` where the getter returns null, and the package list follows PHP's `??` chain for the homepage fallback instead of also skipping empty strings. Record with TODO(port) that `get_links_for_type` matches composer.json key names while `show` passes `Link::TYPE_*`, so its link sections never print. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(zip): return a ZipEntryStat struct from stat_indexnsfisis
The stat array was modelled as IndexMap<String, PhpMixed>, forcing callers through untyped lookups with an unwrap_or(0) fallback PHP has no counterpart for. Only `size` and `comp_size` are kept: statIndex has a single call site in Composer and it reads nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix: propagate ported exceptions instead of flattening themnsfisis
`ProcessExecutor::execute_args` existed only to turn `execute`'s `anyhow::Result` into an exit code of 1, so every one of its ~87 call sites silently took the "command failed" branch on an error PHP would have thrown. It is gone; callers use `execute` and propagate with `?`. Where the enclosing function had no `Result` to propagate into, its signature grew one, up to and including `Git::get_version`, `Svn::binary_version`, `GitHub`/`GitLab`/`Bitbucket::authorize_oauth`, `InitCommand::get_git_config` and `DiagnoseCommand::check_git`. The VCS drivers had the same problem in the other direction: their `get_contents` returned `Result<Response, Box<TransportException>>`, a type too narrow for the PHP method, which lets any Throwable out of the `catch (TransportException $e)` block. Every non-transport error was therefore rewritten into a `TransportException` with code 0, which the callers switch on. They now return `anyhow::Result<Result<Response, Box<TransportException>>>`: the outer `Result` carries what PHP does not catch, the inner one the exception the drivers handle. That signature also restores `GitLabDriver::getContents`: the 400/401 `TransportException`s it raises to force authentication are thrown inside its own `try` block and handled by its own `catch`, but the port returned them straight to the caller, so the authentication flow behind them never ran. `impl_php_exception!` gains `From<Box<$ty>> for anyhow::Error` so a caught exception can be re-propagated with `?`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(process-executor): model commands as a CommandLine enumnsfisis
Commands were carried as `PhpMixed`, whose `String`/`List` variants do not tell a shell command line apart from an argv list at the type level. `Perforce::execute_command` and `Git::run_command` therefore funnelled string commands through `execute_args`, spawning `p4 set` or `git command` as a single argument instead of running it through a shell as PHP does; their tests were written against that shape. Introduce `CommandLine::{Shell, Args}` and use it for every `ProcessExecutor` entry point, the mock expectation queue and the `Git::run_command` callables. The unreachable "Invalid command type" branches disappear with it, and the affected tests go back to the string expectations the PHP suite uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(function-exists): drop checks for always-present capabilitiesnsfisis
function_exists() returns false in PHP when a function is blocked by disable_functions, when its extension is not compiled in, or when the PHP version predates it. None of those apply to a native binary.
2026-08-18test(process-executor): benchmark execute_async with criterionnsfisis
The `single` group compares the argv form against the string form, which goes through an extra `/bin/sh -c`; the `concurrent` group varies the job count around `max_jobs` so the semaphore throttle is visible as a throughput knee. `execute_async`'s futures are `!Send`, so they run on a current-thread runtime and overlap only through `join_all` within a single task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(cache): allow underscores in metadata cache keysnsfisis
Cache::read()/write() rewrite every character outside the allowlist to "-", so a package name containing "_" was stored and looked up under a file name Composer never writes: the repository metadata cache was never hit for those packages, and "vendor/foo_bar" collided with "vendor/foo-bar". With no cached copy there is no last-modified date to send either, so a degraded repository resolved such packages as not found instead of serving the cached metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): make preg_match_all yield matches per occurrencensfisis
PHP's PREG_PATTERN_ORDER is column-oriented, but 7 of the 10 call sites read it row-wise, rebuilding each occurrence by indexing every column at the same offset. Return an iterator of PregMatches instead, which is also what the set-order and offset-capture variants were carrying, so the three functions collapse into one and PregMatchesAll, PregMatchesAllWithOffsets, CaptureKey and preg_match_map! all go away. The offset-capture call sites are served by the new PregMatches get_offset/name_offset accessors. The search stays eager: regex::Captures borrows only the subject, so the matches outlive the pattern resolved for the call, and PHP's preg_match_all is eager too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): add preg_is_match for existence-only call sitesnsfisis
The capture groups were discarded at 162 of the preg_match call sites, which only tested the Option. They now call preg_is_match, which lets the regex engine skip capture tracking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): drop the offset argument from preg_matchnsfisis
Every call site but one passed offset 0. The remaining one, the UTF-8 chunking loop in Application, slices the subject instead: its pattern has no anchor or lookaround, so matching a suffix is equivalent to starting the search at that offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(class-map-generator): anchor the type keyword searchnsfisis
PhpFileCleaner::clean scans for the single class/interface/trait keyword of a one-declaration file with a pattern PHP runs anchored (`A`) one char before the keyword. The port dropped the anchor and relied on the leftmost match of an offset search instead, on the grounds that the keyword sits exactly one char past the offset. That holds for the keyword but not for the guards around it: where the preceding char makes PCRE fail (`Foo::class`, `$class`, `->class`), the unanchored search does not fail, it matches the next declaration further down the file and returns the cleaned prefix cut short there. Given <?php namespace A { $x = Foo::class; } namespace B { class Bar {} } PhpFileParser::findClasses reported A\Bar where PHP reports B\Bar, because the `namespace B {` the cleaner skipped past never reached the parser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): anchor A patterns at the call sitensfisis
The shim carried the PCRE A (anchored) modifier alongside every compiled pattern so preg_match2 could honour it by searching the sub-slice at the offset. Only two call sites ever passed such a pattern, and each can cut that slice itself, so the flag is gone from the cache, ResolvedPattern, PregPattern and the php_regex! macro, and a pattern still carrying A is now rejected rather than silently searched unanchored. StringInput::tokenize and PhpFileCleaner::match search from their cursor with a `^`-prefixed pattern instead. The rule is written down in docs/dev/regex-porting.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): drop the Vec-returning preg_match_allnsfisis
The two preg_match_all variants took the same arguments and differed only in what they returned: a Vec of columns, or the named-and-numbered PregMatchesAll. The latter is the one all but two call sites already used, so preg_match_all2 takes over the plain PHP name and the Vec variant goes away. Its remaining readers only ever wanted group 0's column, which they now take through CaptureKey::ByIndex(0); in the formatter this replaces the array_shift that popped that column off the PREG_PATTERN_ORDER array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): fold preg_match into preg_match2nsfisis
preg_match copied every group into a Vec<Option<String>> while preg_match2 handed back the borrowed captures. They now differ only in the offset argument, so preg_match delegates with offset 0 and its callers read groups through PregMatches::get. Going through preg_match2 also makes preg_match honour the PCRE A modifier, which it used to ignore; no caller passes such a pattern. VersionParser::manipulate_version_string takes an index accessor instead of a slice, and VersionParser::normalize matches against a copy of the subject because the captures outlive the assignments to $version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(pcre): inline Preg into its call sites and drop the cratensfisis
Preg had shed everything it owned: after the last few rounds its methods were one-line forwards to the shim's preg_*(), differing only in a default argument or a wrapper the caller unwrapped anyway. The 460 call sites now name the shim function, and shirabe-pcre is gone from the workspace along with its LICENSE entry. The forwards expand as they read: isMatch becomes preg_match2(.., 0).is_some() (is_none() where PHP negates it), isMatch3 and match3 drop the .is_some(), matchAll counts through preg_match_all2(..).occurrence_count(), and replace4/replace5 spell out the limit and count arguments preg_replace2 takes. Callbacks are the one place the shapes differ: preg_replace_callback carries an error out of the callback, so the fourteen infallible closures wrap their result in Ok() and expect() it back. Config::process() is the fifteenth, and it drops the `error` cell it captured to smuggle a failure past a closure that could only return a String. The `?` in the closure now carries it, which is what the PHP does -- a throw from the callback leaves preg_replace_callback at the failing match rather than running the remaining replacements and reporting the last error. The module doc that explained why composer/pcre's exceptions and *StrictGroups() variants have no counterpart moves to the shim's preg module, where the functions it describes live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): split PregMatches reads into get() and name()nsfisis
PregMatches keyed both forms of a capture group through CaptureKey, so every read built one: a usize wrapped in an enum, or worse, a String allocated to name a group that regex::Captures can look up from a &str. It now mirrors regex::Captures instead -- get() takes the group number, name() the group name -- and the enum drops out of the type entirely. That is 285 call sites across 59 files, and the named ones carry most of the win: `matches.get(&CaptureKey::ByName("host".to_string()))` reads as `matches.name("host")`. ProcessExecutor loses a `user_key` binding that existed only to build the key once. CaptureKey stays as the key type of PregMatchesAll and PregMatchesAllWithOffsets, where numbered and named entries share one IndexMap and a key type is the point. Five files still name it. Also retargets the two preg_match_all comments that described the occurrence count through `matches[&CaptureKey::ByIndex(0)].len()`, an Index impl these types no longer carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(pcre): drop the two bespoke isMatch variantsnsfisis
is_match_named and is_match_with_indexed_captures reshaped a match into a name-keyed map or a number-positioned vec, each allocating a String per group up front for callers that then read one or two of them. Every one of the eleven call sites ports a plain Preg::isMatch in PHP, so they now call is_match3 and reach for the group they want through get(&CaptureKey::ByIndex(N)) / get(&CaptureKey::ByName(..)), the same way the rest of the tree already reads a match. Falling out of that: PregNamedGroups existed only to type the first variant; PregMatches::iter() only to build both; and PregMatches::pattern only to give iter() the capture names. PregMatches is now a plain wrapper over regex::Captures, so preg_replace_callback no longer clones the resolved pattern for every match, and preg_match_map! is internal to the shim again. SvnDriver::get_file_content and get_change_date recover the flat `isMatch(..) && $match[2] !== null` condition the PHP has, which the vec shape had forced into a nested if. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>