├── .phive └── phars.xml ├── .typos.toml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── strauss ├── bootstrap.php ├── composer.json ├── phive.phar.asc └── src ├── Composer ├── ComposerPackage.php ├── Extra │ ├── ReplaceConfigInterface.php │ └── StraussConfig.php └── ProjectComposerPackage.php ├── Config ├── AliasesConfigInterface.php ├── AutoloadConfigInterface.php ├── ChangeEnumeratorConfigInterface.php ├── CleanupConfigInterface.php ├── FileCopyScannerConfigInterface.php ├── FileEnumeratorConfig.php ├── FileSymbolScannerConfigInterface.php └── PrefixerConfigInterface.php ├── Console ├── Application.php └── Commands │ ├── DependenciesCommand.php │ ├── IncludeAutoloaderCommand.php │ └── ReplaceCommand.php ├── Files ├── DiscoveredFiles.php ├── File.php ├── FileBase.php ├── FileWithDependency.php └── HasDependency.php ├── Helpers ├── FileSystem.php ├── FlysystemBackCompatInterface.php ├── FlysystemBackCompatTrait.php ├── InMemoryFilesystemAdapter.php ├── NamespaceSort.php └── ReadOnlyFileSystem.php ├── Pipeline ├── Aliases.php ├── Autoload.php ├── Autoload │ ├── ComposerAutoloadGenerator.php │ ├── DumpAutoload.php │ └── VendorComposerAutoload.php ├── ChangeEnumerator.php ├── Cleanup.php ├── Cleanup │ └── InstalledJson.php ├── Copier.php ├── DependenciesEnumerator.php ├── FileCopyScanner.php ├── FileEnumerator.php ├── FileSymbol │ └── builtinsymbols.php ├── FileSymbolScanner.php ├── Licenser.php └── Prefixer.php └── Types ├── ClassSymbol.php ├── ConstantSymbol.php ├── DiscoveredSymbol.php ├── DiscoveredSymbols.php ├── FunctionSymbol.php └── NamespaceSymbol.php /.phive/phars.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | ".git/", 4 | "tests/Issues/data/", 5 | ] 6 | ignore-hidden = false 7 | 8 | [default] 9 | extend-ignore-re = [ 10 | "ComposerAutoloaderInit[0-9a-f]+", 11 | ] 12 | 13 | [default.extend-identifiers] 14 | # Typos 15 | "Github" = "GitHub" 16 | "Wordpress" = "WordPress" 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.22.2 April 2025 4 | 5 | * Fix: `psr-0` autoloaders were no longer autoloaded because the directory structure did not match 6 | * Fix: `files` autoloaders failed when not unique (the whole point of this tool) 7 | * Fix: spelling 8 | 9 | ## 0.22.1 April 2025 10 | 11 | * Fix: jsonmapper latest version caused problems with PhpDoc 12 | 13 | ## 0.22.0 April 2025 14 | 15 | * Add: `--info`, `--debug` and `--silent` verbosity levels 16 | * Add: `--dry-run` which runs with `--debug` output but does not write files 17 | * Add: `autoload_aliases.php` file for dev dependencies to load modified classes using their original fqdn 18 | * Fix: relative namespaces 19 | * Fix: allow vendor and target directories to be in parent directory of `composer.json` 20 | * Fix: incorrectly updating call sites 21 | * Dev: major refactor to use `thephpleague/Flysystem` and `elazar/flystream` for file operations 22 | * Dev: print diff code coverage report on PRs 23 | * Dev: skip / speed-up some tests 24 | * Dev: improvements to tests' names and coverage reporting specificity 25 | * Docs: improve installation instructions in `README.md` 26 | * CI: Set up problem matcher for PHPUnit 27 | 28 | ## 0.21.1 January 2025 29 | 30 | * Fix: global functions prefixed too liberally when defined as strings 31 | * Add: include changelog in phar 32 | 33 | ## 0.21.0 January 2025 34 | 35 | * Add: prefix global functions 36 | 37 | ## 0.20.1 December 2024 38 | 39 | * Fix: `vendor-prefixed` subdirectories' permissions being copied as 0700 instead of 0755 40 | 41 | ## 0.20.0 November 2024 42 | 43 | * Fix: `Generic<\namespaced\class-type>` not prefixed 44 | * Add `strauss replace` command (e.g. if you fork a project and want to change its namespace) 45 | 46 | ## 0.19.5 October 2024 47 | 48 | * Fix: `use GlobalClass as Alias;` not prefixed 49 | * Add: `.gitattributes` file to exclude dev files from distribution 50 | * CI: Fail releases if `bin/strauss` version number is out of sync 51 | * Tests: Add first tests for `DiscoveredFiles.php` 52 | * Improve `README.md` 53 | * Fix: typos in code 54 | 55 | ## 0.19.4 October 2024 56 | 57 | * Fix: out of sync version number in `bin/strauss` 58 | 59 | ## 0.19.3 October 2024 60 | 61 | * Fix: handle `@` symbol for error suppression 62 | * Fix: handle `preg_replace...` returning `null` in `Licenser` 63 | * Fix: only search for symbols in PHP files 64 | 65 | ## 0.19.2 June 2024 66 | 67 | * Fix: available CLI arguments were overwriting extra.strauss config 68 | * Fix: updating `league/flysystem` changed the default directory permissions 69 | 70 | ## 0.19.1 April 2024 71 | 72 | * Fix: was incorrectly deleting autoload keys from installed.json 73 | 74 | ## 0.19.0 April 2024 75 | 76 | * Fix: check for array before loop 77 | * Fix: filepaths on Windows (still work to do for Windows) 78 | * Update: tidy `bin/strauss` 79 | * Run tests with project classes + with built phar 80 | * Allow `symfony/console` & `symfony/finder` `^7` for Laravel 11 compatibility 81 | * Add: `scripts/createphar.sh` 82 | * Lint: most PhpStan level 7 83 | 84 | ## 0.18.0 April 2024 85 | 86 | * Add: GitHub Action to update bin version number from CHANGELOG.md 87 | * Fix: casting a namespaced class to a string 88 | * Fix: composer dump-autoload error after delete-vendor-files/delete-vendor-packages 89 | * Fix: add missing built-in PHP interfaces to exclude rules 90 | * Fix: Undefined offset when seeing namespace 91 | * Refactoring for clarity and pending issues 92 | 93 | ## 0.14.0 07-March-2023 94 | 95 | * Merge `in-situ` branch (bugs expected) 96 | * Add: `delete_vendor_packages` option (`delete_vendor_files` is maybe deprecated now) 97 | * Add: GPG .phar signing for Phive 98 | * Breaking: Stop excluding `psr/*` from `file_patterns` prefixing 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Coen Jacobs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PHPUnit ](.github/coverage.svg)](https://brianhenryie.github.io/strauss/) [![PHPStan ](https://img.shields.io/badge/PHPStan-Level%207-2a5ea7.svg)](https://phpstan.org/) 2 | 3 | # Strauss – PHP Namespace Renamer 4 | 5 | A tool to prefix namespaces, classnames, and constants in PHP files to avoid autoloading collisions. 6 | 7 | A fork of [Mozart](https://github.com/coenjacobs/mozart/) for [Composer](https://getcomposer.org/) for PHP. 8 | 9 | Have you ever activated a WordPress plugin that has a conflict with another because the plugins use two different versions of the same PHP library? **Strauss is the solution to that problem** - it ensures that _your_ plugin's PHP dependencies are isolated and loaded from your plugin rather than loading from whichever plugin's autoloader registers & runs first. 10 | 11 | > ⚠️ **Sponsorship**: I don't want your money. [Please write a unit test to help the project](https://brianhenryie.github.io/strauss/). 12 | 13 | ## Table of Contents 14 | 15 | * [Installation](#installation) 16 | * [As a `.phar` file](#as-a-phar-file-recommended) (recommended) 17 | * [As a dev dependency via composer](#as-a-dev-dependency-via-composer-not-recommended) (not recommended) 18 | * [Edit `composer.json` scripts](#edit-composerjson-scripts) 19 | * [Usage](#usage) 20 | * [Configuration](#configuration) 21 | * [Autoloading](#autoloading) 22 | * [Motivation & Comparison to Mozart](#motivation--comparison-to-mozart) 23 | * [Alternatives](#alternatives) 24 | * [Breaking Changes](#breaking-changes) 25 | * [Acknowledgements](#acknowledgements) 26 | 27 | ## Installation 28 | 29 | ### As a `.phar` file (recommended) 30 | 31 | There are a couple of small steps to make this possible. 32 | 33 | #### Create a `bin/.gitkeep` file 34 | 35 | This ensures that there is a `bin/` directory in the root of your project. This is where the `.phar` file will go. 36 | 37 | ```bash 38 | mkdir bin 39 | touch bin/.gitkeep 40 | ``` 41 | 42 | #### `.gitignore` the `.phar` file 43 | 44 | Add the following to your `.gitignore`: 45 | 46 | ```bash 47 | bin/strauss.phar 48 | ``` 49 | 50 | #### Edit `composer.json` `scripts 51 | 52 | In your `composer.json`, add `strauss` to the `scripts` section: 53 | 54 | ```json 55 | "scripts": { 56 | "prefix-namespaces": [ 57 | "sh -c 'test -f ./bin/strauss.phar || curl -o bin/strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/latest/download/strauss.phar'", 58 | "@php bin/strauss.phar", 59 | "@composer dump-autoload" 60 | ], 61 | "post-install-cmd": [ 62 | "@prefix-namespaces" 63 | ], 64 | "post-update-cmd": [ 65 | "@prefix-namespaces" 66 | ], 67 | "post-autoload-dump": [ 68 | "@php bin/strauss.phar include-autoloader" 69 | ] 70 | } 71 | ``` 72 | 73 | This provides `composer strauss`, which does the following: 74 | 75 | 1. The `sh -c` command tests if `bin/strauss.phar` exists, and if not, downloads it from [releases](https://github.com/BrianHenryIE/strauss/releases). 76 | 2. Then `@php bin/strauss.phar` is run to prefix the namespaces. 77 | 3. Ensure that composer's autoload map is updated. 78 | 79 | ### As a dev dependency via composer (not recommended) 80 | 81 | If you prefer to include Strauss as a dev dependency, you can still do so. You mileage may vary when you include it this way. 82 | 83 | ``` 84 | composer require --dev brianhenryie/strauss 85 | ``` 86 | 87 | #### Edit `composer.json` `scripts 88 | 89 | ```json 90 | "scripts": { 91 | "prefix-namespaces": [ 92 | "strauss", 93 | "@php composer dump-autoload" 94 | ], 95 | "post-install-cmd": [ 96 | "@prefix-namespaces" 97 | ], 98 | "post-update-cmd": [ 99 | "@prefix-namespaces" 100 | ], 101 | "post-autoload-dump": [ 102 | "strauss include-autoloader" 103 | ] 104 | } 105 | ``` 106 | 107 | ## Usage 108 | 109 | If you add Strauss to your `composer.json` as indicated in [Installation](#installation), it will run when you `composer install` or `composer update`. To run Strauss directly, simply use: 110 | 111 | ```bash 112 | composer prefix-namespaces 113 | ``` 114 | 115 | To update the files that call the prefixed classes, you can use `--updateCallSites=true` which uses your autoload key, or `--updateCallSites=includes,templates` to explicitly specify the files and directories. 116 | 117 | ```bash 118 | composer -- prefix-namespaces --updateCallSites=true 119 | ``` 120 | 121 | or 122 | 123 | ```bash 124 | composer -- prefix-namespaces --updateCallSites=includes,templates 125 | ``` 126 | 127 | To try it out without making changes, you can use the `--dry-run` flag: 128 | 129 |
130 | 131 | strauss --dry-run 132 | 133 | ![](.github/strauss.mp4) 134 | 135 |
136 | 137 | Verbosity can be controlled with `--notice` (default), `--info`, `--debug` and `--silent`. 138 | 139 | ## Configuration 140 | 141 | Strauss potentially requires zero configuration, but likely you'll want to customize a little, by adding in your `composer.json` an `extra/strauss` object. The following is the default config, where the `namespace_prefix` and `classmap_prefix` are determined from your `composer.json`'s `autoload` or `name` key and `packages` is determined from the `require` key: 142 | 143 | ```json 144 | "extra": { 145 | "strauss": { 146 | "target_directory": "vendor-prefixed", 147 | "namespace_prefix": "BrianHenryIE\\My_Project\\", 148 | "classmap_prefix": "BrianHenryIE_My_Project_", 149 | "constant_prefix": "BHMP_", 150 | "packages": [ 151 | ], 152 | "update_call_sites": false, 153 | "override_autoload": { 154 | }, 155 | "exclude_from_copy": { 156 | "packages": [ 157 | ], 158 | "namespaces": [ 159 | ], 160 | "file_patterns": [ 161 | ] 162 | }, 163 | "exclude_from_prefix": { 164 | "packages": [ 165 | ], 166 | "namespaces": [ 167 | ], 168 | "file_patterns": [ 169 | ] 170 | }, 171 | "namespace_replacement_patterns" : { 172 | }, 173 | "delete_vendor_packages": false, 174 | "delete_vendor_files": false 175 | } 176 | }, 177 | ``` 178 | 179 | The following configuration is inferred: 180 | 181 | - `target_directory` defines the directory the files will be copied to, default `vendor-prefixed` 182 | - `namespace_prefix` defines the default string to prefix each namespace with 183 | - `classmap_prefix` defines the default string to prefix class names in the global namespace 184 | - `packages` is the list of packages to process. If absent, all packages in the `require` key of your `composer.json` are included 185 | - `classmap_output` is a `bool` to decide if Strauss will create `autoload-classmap.php` and `autoload.php`. If it is not set, it is `false` if `target_directory` is in your project's `autoload` key, `true` otherwise. 186 | 187 | The following configuration is default: 188 | 189 | - `delete_vendor_packages`: `false` a boolean flag to indicate if the packages' vendor directories should be deleted after being processed. It defaults to false, so any destructive change is opt-in. 190 | - `delete_vendor_files`: `false` a boolean flag to indicate if files copied from the packages' vendor directories should be deleted after being processed. It defaults to false, so any destructive change is opt-in. This is maybe deprecated! Is there any use to this that is more appropriate than `delete_vendor_packages`? 191 | - `include_modified_date` is a `bool` to decide if Strauss should include a date in the (phpdoc) header written to modified files. Defaults to `true`. 192 | - `include_author` is a `bool` to decide if Strauss should include the author name in the (phpdoc) header written to modified files. Defaults to `true`. 193 | - `update_call_sites`: `false`. This can be `true`, `false` or an `array` of directories/filepaths. When set to `true` it defaults to the directories and files in the project's `autoload` key. The PHP files and directories' PHP files will be updated where they call the prefixed classes. 194 | 195 | The remainder is empty: 196 | 197 | - `constant_prefix` is for `define( "A_CONSTANT", value );` -> `define( "MY_PREFIX_A_CONSTANT", value );`. If it is empty, constants are not prefixed (this may change to an inferred value). 198 | - `override_autoload` a dictionary, keyed with the package names, of autoload settings to replace those in the original packages' `composer.json` `autoload` property. 199 | - `exclude_from_prefix` / [`file_patterns`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/ChangeEnumerator.php#L92-L96) 200 | - `exclude_from_copy` 201 | - [`packages`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/FileEnumerator.php#L77-L79) array of package names to be skipped 202 | - [`namespaces`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/FileEnumerator.php#L95-L97) array of namespaces to skip (exact match from the package autoload keys) 203 | - [`file_patterns`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/FileEnumerator.php#L133-L137) array of regex patterns to check filenames against (including vendor relative path) where Strauss will skip that file if there is a match 204 | - `exclude_from_prefix` 205 | - [`packages`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/ChangeEnumerator.php#L86-L90) array of package names to exclude from prefixing. 206 | - [`namespaces`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/ChangeEnumerator.php#L177-L181) array of exact match namespaces to exclude (i.e. not substring/parent namespaces) 207 | - [`namespace_replacement_patterns`](https://github.com/BrianHenryIE/strauss/blob/83484b79cfaa399bba55af0bf4569c24d6eb169d/src/ChangeEnumerator.php#L183-L190) a dictionary to use in `preg_replace` instead of prefixing with `namespace_prefix`. 208 | 209 | ## Autoloading 210 | 211 | Strauss uses Composer's own tools to generate a set of autoload files in the `target_directory` and creates an `autoload.php` alongside it, so in many projects autoloading is just a matter of: 212 | 213 | ```php 214 | require_once __DIR__ . '/vendor-prefixed/autoload.php'; 215 | ``` 216 | 217 | If you plan to continue using Composer's autoloader you probably want to turn on `delete_vendor_packages` or set `target_directory` to `vendor`. 218 | 219 | You can use `strauss include-autoloader` to add a line to `vendor/autoload.php` which includes the autoloader for the new files. 220 | 221 | When `delete_vendor_packages` is enabled, `vendor/composer/autoload_aliases.php` is created to allow modified classes to be loaded with their old name during development. This file should not be included in your production code. 222 | 223 | ## Motivation & Comparison to Mozart 224 | 225 | I was happy to make PRs to Mozart to fix bugs, but they weren't being reviewed and merged. At the time of writing, somewhere approaching 50% of Mozart's code [was written by me](https://github.com/coenjacobs/mozart/graphs/contributors) with an additional [nine open PRs](https://github.com/coenjacobs/mozart/pulls?q=is%3Apr+author%3ABrianHenryIE+) and the majority of issues' solutions [provided by me](https://github.com/coenjacobs/mozart/issues?q=is%3Aissue+). This fork is a means to merge all outstanding bugfixes I've written and make some more drastic changes I see as a better approach to the problem. 226 | 227 | Benefits over Mozart: 228 | 229 | * A single output directory whose structure matches source vendor directory structure (conceptually easier than Mozart's independent `classmap_directory` and `dep_directory`) 230 | * A generated `autoload.php` to `include` in your project (analogous to Composer's `vendor/autoload.php`) 231 | * Handles `files` autoloaders – and any autoloaders that Composer itself recognises, since Strauss uses Composer's own tooling to parse the packages 232 | * Zero configuration – Strauss infers sensible defaults from your `composer.json` 233 | * No destructive defaults – `delete_vendor_files` defaults to `false`, so any destruction is explicitly opt-in 234 | * Licence files are included and PHP file headers are edited to adhere to licence requirements around modifications. My understanding is that re-distributing code that Mozart has handled is non-compliant with most open source licences – illegal! 235 | * Extensively tested – PhpUnit tests have been written to validate that many of Mozart's bugs are not present in Strauss 236 | * More configuration options – allowing exclusions in copying and editing files, and allowing specific/multiple namespace renaming 237 | * Respects `composer.json` `vendor-dir` configuration 238 | * Prefixes constants (`define`) 239 | * Handles meta-packages and virtual-packages 240 | 241 | Strauss will read the Mozart configuration from your `composer.json` to enable a seamless migration. 242 | 243 | ## Alternatives 244 | 245 | I don't have a strong opinion on these. I began using Mozart because it was easy, then I adapted it to what I felt was most natural. I've never used these. 246 | 247 | * [humbug/php-scoper](https://github.com/humbug/php-scoper) 248 | * [TypistTech/imposter-plugin](https://github.com/TypistTech/imposter-plugin) 249 | * [Automattic/jetpack-autoloader](https://github.com/Automattic/jetpack-autoloader) 250 | * [tschallacka/wordpress-composer-plugin-builder](https://github.com/tschallacka/wordpress-composer-plugin-builder) 251 | * [Interfacelab/namespacer](https://github.com/Interfacelab/namespacer) 252 | * [PHP-Prefixer](https://github.com/PHP-Prefixer) SaaS! 253 | 254 | ### Interesting 255 | 256 | * [composer-unused/composer-unused](https://github.com/composer-unused/composer-unused) 257 | * [sdrobov/autopsr4](https://github.com/sdrobov/autopsr4) 258 | * [jaem3l/unfuck](https://github.com/jaem3l/unfuck) 259 | * [bamarni/composer-bin-plugin](https://github.com/bamarni/composer-bin-plugin) 260 | * [phar-io/composer-distributor](https://github.com/phar-io/composer-distributor) 261 | 262 | ## Breaking Changes 263 | 264 | * v0.21.0 – will prefix global functions 265 | * v0.16.0 – will no longer prefix PHP built-in classes seen in polyfill packages 266 | * v0.14.0 – `psr/*` packages no longer excluded by default 267 | * v0.12.0 – default output `target_directory` changes from `strauss` to `vendor-prefixed` 268 | 269 | Please open issues to suggest possible breaking changes. I think we can probably move to 1.0.0 soon. 270 | 271 | ### Backward Compatibility Promise 272 | 273 | This project will not increase its minimum required PHP version ahead of WordPress. 274 | 275 | https://core.trac.wordpress.org/ticket/62622 276 | 277 | ## Changes before v1.0 278 | 279 | * Comprehensive attribution of code forked from Mozart – changes have been drastic and `git blame` is now useless, so I intend to add more attributions 280 | * More consistent naming. Are we prefixing or are we renaming? 281 | * Further unit tests, particularly file-system related 282 | * Regex patterns in config need to be validated 283 | * Change the name? "Renamespacer"? 284 | 285 | ## Changes before v2.0 286 | 287 | The correct approach to this problem is probably via [PHP-Parser](https://github.com/nikic/PHP-Parser/). At least all the tests will be useful. 288 | 289 | ## Acknowledgements 290 | 291 | [Coen Jacobs](https://github.com/coenjacobs/) and all the [contributors to Mozart](https://github.com/coenjacobs/mozart/graphs/contributors), particularly those who wrote nice issues. 292 | -------------------------------------------------------------------------------- /bin/strauss: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 36 | }, '0.22.2'); 37 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | =2.0.0 <=2.22.3", 24 | "league/flysystem": "^2.1|^3.0", 25 | "league/flysystem-memory": "*", 26 | "nikic/php-parser": "*", 27 | "symfony/console": "^4|^5|^6|^7", 28 | "symfony/finder": "^4|^5|^6|^7" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "BrianHenryIE\\Strauss\\": "src/" 33 | }, 34 | "files": [ 35 | "bootstrap.php" 36 | ] 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "BrianHenryIE\\Strauss\\Tests\\": "tests/", 41 | "BrianHenryIE\\Strauss\\": [ 42 | "tests/", 43 | "tests/Integration", 44 | "tests/Unit" 45 | ] 46 | } 47 | }, 48 | "require-dev": { 49 | "php": "^7.4|^8.0", 50 | "ext-json": "*", 51 | "clue/phar-composer": "^1.2", 52 | "jaschilz/php-coverage-badger": "^2.0", 53 | "mheap/phpunit-github-actions-printer": "^1.4", 54 | "mockery/mockery": "^1.6", 55 | "phpstan/phpstan": "^1.10", 56 | "phpunit/phpcov": "*", 57 | "phpunit/phpunit": "^9|^10", 58 | "squizlabs/php_codesniffer": "^3.5" 59 | }, 60 | "scripts": { 61 | "post-install-cmd": [ 62 | "@install-phive-dependencies" 63 | ], 64 | "post-update-cmd": [ 65 | "@install-phive-dependencies" 66 | ], 67 | "cs": [ 68 | "phpcs", 69 | "phpstan --memory-limit=4G" 70 | ], 71 | "install-phive-dependencies": [ 72 | "if [ -z \"$(command -v phive)\" ]; then echo \"Phive is not installed. Run 'brew install gpg phive' or see https://phar.io/.\"; exit 1; fi;", 73 | "phive install" 74 | ], 75 | "cs-fix": [ 76 | "phpcbf || true", 77 | "@cs" 78 | ], 79 | "test": [ 80 | "phpunit" 81 | ], 82 | "test-changes": [ 83 | "if [ -z \"$(command -v ./tools/php-diff-test)\" ]; then echo \"Please install 'php-diff-test' with 'phive install'.\"; exit 1; fi;", 84 | "if [ \"$XDEBUG_MODE\" != \"coverage\" ]; then echo 'Run with XDEBUG_MODE=coverage composer test-changes'; exit 1; fi;", 85 | "phpunit --filter=\"$(./tools/php-diff-test filter --input-files tests/_reports/php.cov --granularity=line)\" --coverage-text;" 86 | ], 87 | "test-changes-report": [ 88 | "if [ -z \"$(command -v ./tools/php-diff-test)\" ]; then echo \"Please install 'php-diff-test' with 'phive install'.\"; exit 1; fi;", 89 | "if [ -z \"$(command -v ./tools/phpcov)\" ]; then echo \"Please install 'phpcov' with 'phive install'.\"; exit 1; fi;", 90 | "if [ \"$XDEBUG_MODE\" != \"coverage\" ]; then echo 'Run with XDEBUG_MODE=coverage composer test-changes-report'; exit 1; fi;", 91 | "if [ -d \"tests/_reports/diff\" ]; then rm -rf tests/_reports/diff; fi;", 92 | "phpunit --filter=\"$(./tools/php-diff-test filter --input-files tests/_reports/php.cov --granularity file)\" --coverage-text --coverage-php tests/_reports/diff/php.cov -d memory_limit=-1;", 93 | "./tools/php-diff-test coverage --input-files tests/_reports/diff/php.cov --output-file tests/_reports/diff/php.cov;", 94 | "./tools/phpcov merge tests/_reports/diff --html tests/_reports/diff/html;", 95 | "open tests/_reports/diff/html/index.html" 96 | ], 97 | "test-coverage": [ 98 | "Composer\\Config::disableProcessTimeout", 99 | "if [ \"$XDEBUG_MODE\" != \"coverage\" ]; then echo \"Run with 'XDEBUG_MODE=coverage composer test-coverage'\"; exit 1; fi;", 100 | "phpunit ./tests/Unit --coverage-text --coverage-clover tests/_reports/partial/unitclover.xml --coverage-php tests/_reports/partial/unitphp.cov -d memory_limit=-1 --order-by=random", 101 | "phpcov merge --clover tests/_reports/clover.xml --html tests/_reports/html tests/_reports/partial;", 102 | "php-coverage-badger tests/_reports/clover.xml .github/coverage.svg", 103 | "if [ $(command -v ./tools/phpcov) ]; then git diff master...head > /tmp/master.diff; ./tools/phpcov patch-coverage --path-prefix $(pwd) ./tests/_reports/php.cov /tmp/master.diff || true; fi;", 104 | "# Run 'open ./tests/_reports/html/index.html' to view report." 105 | ] 106 | }, 107 | "scripts-descriptions": { 108 | "test-changes": "Run PHPUnit only on lines that have changed in master...HEAD", 109 | "test-changes-report": "Run PHPUnit only on files that have changed in master...HEAD and display the HTML report.", 110 | "test-coverage": "Run PHPUnit tests with coverage. Use 'XDEBUG_MODE=coverage composer test-coverage' to run, 'open ./tests/_reports/html/index.html' to view." 111 | }, 112 | "replace":{ 113 | "coenjacobs/mozart": "*" 114 | }, 115 | "config": { 116 | "sort-packages": true 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /phive.phar.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNATURE----- 2 | 3 | iQJBBAABCgArFiEEavclJwq4HgTXlEJUnYqYspstXXkFAmbHq+UNHHRlYW1AcGhh 4 | ci5pbwAKCRCdipiymy1deRwWD/4oVUr8uQC5Zjr0rPEkJ5BwWRIpm5PZfhSP/jLC 5 | vnL3TjtbLBy0/emJN69fUBa7oRYJX6x5Hil+P6i01COuLnvL+8ZItXT7ArYtgnJK 6 | wo9+z/jQ+F5xGsBlWECdKGxt3RULpbjyss5mgPLY41WTX7Bts7uSCD9O2ur1hfjE 7 | hJJjPnyhsX3zRS0rNe06SFovQYOItwKfucSjjOW04+XTdbol9Vayevi2M0ipaK16 8 | 8D8OquVxj4ZkXCaSQEz/2vQEb8sFJm4xAkaDNdpq2jSbDZ8Xmlklz39aBPu5TA0m 9 | sol9fkAiRBF2ITtCdO61JLCv2Llt/IYSyu/ONYzvPD9FGD5OKkF/MV7yhf2bY8lA 10 | 1mzfY/UIzmiZ9Cy2p/SJFsi1Mc1xzex4PmOuwxULaRftKtztgLQMA9LvIJMDjpYu 11 | Qr68SSIZ6pm3mzvmd7JUL0qgvDTWmKV1vIKSMMtXgqDkwuOcaLo+th7qxD5SpJhT 12 | mAgnItWmtgRZolB+E2M2V4AMVNou4ydtQxSd4qD6fheXXNmED+2jayD5rSmPlnVm 13 | oRMA1b1HIlz+zIZCYQo1XGrvkVxpfw0Zj4HfwObAnr+NE9JpH53OmZJ3vyqEwZhs 14 | nkC1gip3cK3ZoajeSktK16TqZj9Bl5RDREvyFU2I0XKjxr+5QF5Y6oNEHCD0MH4M 15 | 2mGB3A== 16 | =rU98 17 | -----END PGP SIGNATURE----- 18 | -------------------------------------------------------------------------------- /src/Composer/ComposerPackage.php: -------------------------------------------------------------------------------- 1 | ,classmap?:array,"psr-4"?:array>} 16 | */ 17 | class ComposerPackage 18 | { 19 | /** 20 | * The composer.json file as parsed by Composer. 21 | * 22 | * @see \Composer\Factory::create 23 | * 24 | * @var \Composer\Composer 25 | */ 26 | protected \Composer\Composer $composer; 27 | 28 | /** 29 | * The name of the project in composer.json. 30 | * 31 | * e.g. brianhenryie/my-project 32 | * 33 | * @var string 34 | */ 35 | protected string $packageName; 36 | 37 | /** 38 | * Virtual packages and meta packages do not have a composer.json. 39 | * Some packages are installed in a different directory name than their package name. 40 | * 41 | * @var ?string 42 | */ 43 | protected ?string $relativePath = null; 44 | 45 | /** 46 | * Packages can be symlinked from outside the current project directory. 47 | * 48 | * @var ?string 49 | */ 50 | protected ?string $packageAbsolutePath = null; 51 | 52 | /** 53 | * The discovered files, classmap, psr0 and psr4 autoload keys discovered (as parsed by Composer). 54 | * 55 | * @var AutoloadKey 56 | */ 57 | protected array $autoload = []; 58 | 59 | /** 60 | * The names in the composer.json's "requires" field (without versions). 61 | * 62 | * @var string[] 63 | */ 64 | protected array $requiresNames = []; 65 | 66 | protected string $license; 67 | 68 | /** 69 | * @param string $absolutePath The absolute path to composer.json 70 | * @param ?array{files?:array, classmap?:array, psr?:array>} $overrideAutoload Optional configuration to replace the package's own autoload definition with 71 | * another which Strauss can use. 72 | * @return ComposerPackage 73 | */ 74 | public static function fromFile(string $absolutePath, array $overrideAutoload = null): ComposerPackage 75 | { 76 | $composer = Factory::create(new NullIO(), $absolutePath, true); 77 | 78 | return new ComposerPackage($composer, $overrideAutoload); 79 | } 80 | 81 | /** 82 | * This is used for virtual packages, which don't have a composer.json. 83 | * 84 | * @param array{name?:string, license?:string, requires?:array, autoload?:AutoloadKey} $jsonArray composer.json decoded to array 85 | * @param ?AutoloadKey $overrideAutoload New autoload rules to replace the existing ones. 86 | */ 87 | public static function fromComposerJsonArray($jsonArray, array $overrideAutoload = null): ComposerPackage 88 | { 89 | $factory = new Factory(); 90 | $io = new NullIO(); 91 | $composer = $factory->createComposer($io, $jsonArray, true); 92 | 93 | return new ComposerPackage($composer, $overrideAutoload); 94 | } 95 | 96 | /** 97 | * Create a PHP object to represent a composer package. 98 | * 99 | * @param Composer $composer 100 | * @param ?AutoloadKey $overrideAutoload Optional configuration to replace the package's own autoload definition with another which Strauss can use. 101 | */ 102 | public function __construct(Composer $composer, array $overrideAutoload = null) 103 | { 104 | $this->composer = $composer; 105 | 106 | $this->packageName = $composer->getPackage()->getName(); 107 | 108 | $composerJsonFileAbsolute = $composer->getConfig()->getConfigSource()->getName(); 109 | 110 | $absolutePath = realpath(dirname($composerJsonFileAbsolute)); 111 | if (false !== $absolutePath) { 112 | $this->packageAbsolutePath = $absolutePath . '/'; 113 | } 114 | 115 | $vendorDirectory = $this->composer->getConfig()->get('vendor-dir'); 116 | if (file_exists($vendorDirectory . '/' . $this->packageName)) { 117 | $this->relativePath = $this->packageName; 118 | $this->packageAbsolutePath = realpath($vendorDirectory . '/' . $this->packageName) . '/'; 119 | // If the package is symlinked, the path will be outside the working directory. 120 | } elseif (0 !== strpos($absolutePath, getcwd()) && 1 === preg_match('/.*[\/\\\\]([^\/\\\\]*[\/\\\\][^\/\\\\]*)[\/\\\\][^\/\\\\]*/', $vendorDirectory, $output_array)) { 121 | $this->relativePath = $output_array[1]; 122 | } elseif (1 === preg_match('/.*[\/\\\\]([^\/\\\\]+[\/\\\\][^\/\\\\]+)[\/\\\\]composer.json/', $composerJsonFileAbsolute, $output_array)) { 123 | // Not every package gets installed to a folder matching its name (crewlabs/unsplash). 124 | $this->relativePath = $output_array[1]; 125 | } 126 | 127 | if (!is_null($overrideAutoload)) { 128 | $composer->getPackage()->setAutoload($overrideAutoload); 129 | } 130 | 131 | $this->autoload = $composer->getPackage()->getAutoload(); 132 | 133 | foreach ($composer->getPackage()->getRequires() as $_name => $packageLink) { 134 | $this->requiresNames[] = $packageLink->getTarget(); 135 | } 136 | 137 | // Try to get the license from the package's composer.json, assume proprietary (all rights reserved!). 138 | $this->license = !empty($composer->getPackage()->getLicense()) 139 | ? implode(',', $composer->getPackage()->getLicense()) 140 | : 'proprietary?'; 141 | } 142 | 143 | /** 144 | * Composer package project name. 145 | * 146 | * vendor/project-name 147 | * 148 | * @return string 149 | */ 150 | public function getPackageName(): string 151 | { 152 | return $this->packageName; 153 | } 154 | 155 | /** 156 | * Is this relative to vendor? 157 | */ 158 | public function getRelativePath(): ?string 159 | { 160 | return $this->relativePath . '/'; 161 | } 162 | 163 | public function getPackageAbsolutePath(): ?string 164 | { 165 | return $this->packageAbsolutePath; 166 | } 167 | 168 | /** 169 | * 170 | * e.g. ['psr-4' => [ 'BrianHenryIE\Project' => 'src' ]] 171 | * e.g. ['psr-4' => [ 'BrianHenryIE\Project' => ['src','lib] ]] 172 | * e.g. ['classmap' => [ 'src', 'lib' ]] 173 | * e.g. ['files' => [ 'lib', 'functions.php' ]] 174 | * 175 | * @return AutoloadKey 176 | */ 177 | public function getAutoload(): array 178 | { 179 | return $this->autoload; 180 | } 181 | 182 | /** 183 | * The names of the packages in the composer.json's "requires" field (without version). 184 | * 185 | * Excludes PHP, ext-*, since we won't be copying or prefixing them. 186 | * 187 | * @return string[] 188 | */ 189 | public function getRequiresNames(): array 190 | { 191 | // Unset PHP, ext-*. 192 | $removePhpExt = function ($element) { 193 | return !( 0 === strpos($element, 'ext') || 'php' === $element ); 194 | }; 195 | 196 | return array_filter($this->requiresNames, $removePhpExt); 197 | } 198 | 199 | public function getLicense():string 200 | { 201 | return $this->license; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Composer/Extra/ReplaceConfigInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function getNamespaceReplacementPatterns(): array; 17 | 18 | public function isIncludeModifiedDate(): bool; 19 | 20 | public function isIncludeAuthor(): bool; 21 | 22 | public function getUpdateCallSites(): ?array; 23 | } 24 | -------------------------------------------------------------------------------- /src/Composer/ProjectComposerPackage.php: -------------------------------------------------------------------------------- 1 | ,classmap?:array,"psr-4"?:array>} $overrideAutoload 21 | */ 22 | public function __construct(string $absolutePathFile, ?array $overrideAutoload = null) 23 | { 24 | $composer = Factory::create(new NullIO(), $absolutePathFile, true); 25 | 26 | parent::__construct($composer, $overrideAutoload); 27 | 28 | $authors = $this->composer->getPackage()->getAuthors(); 29 | if (empty($authors) || !isset($authors[0]['name'])) { 30 | $this->author = explode("/", $this->packageName, 2)[0]; 31 | } else { 32 | $this->author = $authors[0]['name']; 33 | } 34 | 35 | $this->vendorDirectory = is_string($this->composer->getConfig()->get('vendor-dir')) 36 | ? ltrim(str_replace(dirname($absolutePathFile), '', $this->composer->getConfig()->get('vendor-dir')), '\\/') 37 | : 'vendor'; 38 | } 39 | 40 | /** 41 | * @return StraussConfig 42 | * @throws \Exception 43 | */ 44 | public function getStraussConfig(): StraussConfig 45 | { 46 | $config = new StraussConfig($this->composer); 47 | $config->setVendorDirectory($this->getVendorDirectory()); 48 | return $config; 49 | } 50 | 51 | 52 | public function getAuthor(): string 53 | { 54 | return $this->author; 55 | } 56 | 57 | /** 58 | * Relative vendor directory with trailing slash. 59 | */ 60 | public function getVendorDirectory(): string 61 | { 62 | return rtrim($this->vendorDirectory, '\\/') . '/'; 63 | } 64 | 65 | /** 66 | * Get all values in the autoload key as a flattened array. 67 | * 68 | * @return string[] 69 | */ 70 | public function getFlatAutoloadKey(): array 71 | { 72 | $autoload = $this->getAutoload(); 73 | $values = []; 74 | array_walk_recursive( 75 | $autoload, 76 | function ($value, $key) use (&$values) { 77 | $values[] = $value; 78 | } 79 | ); 80 | return $values; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Config/AliasesConfigInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getNamespaceReplacementPatterns(): array; 21 | 22 | public function getNamespacePrefix(): ?string; 23 | 24 | public function getClassmapPrefix(): ?string; 25 | } 26 | -------------------------------------------------------------------------------- /src/Config/CleanupConfigInterface.php: -------------------------------------------------------------------------------- 1 | add($composeCommand); 21 | 22 | $replaceCommand = new ReplaceCommand(); 23 | $this->add($replaceCommand); 24 | 25 | $this->add(new IncludeAutoloaderCommand()); 26 | 27 | $this->setDefaultCommand('dependencies'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Console/Commands/IncludeAutoloaderCommand.php: -------------------------------------------------------------------------------- 1 | setName('include-autoloader'); 52 | $this->setDescription("Adds `require autoload_aliases.php` and `require vendor-prefixed/autoload.php` to `vendor/autoload.php`."); 53 | 54 | // TODO: permissions? 55 | $this->filesystem = new Filesystem( 56 | new \League\Flysystem\Filesystem(new LocalFilesystemAdapter('/')), 57 | getcwd() . '/' 58 | ); 59 | } 60 | 61 | /** 62 | * @param InputInterface $input 63 | * @param OutputInterface $output 64 | * 65 | * @see Command::execute() 66 | * 67 | */ 68 | protected function execute(InputInterface $input, OutputInterface $output): int 69 | { 70 | $logger = new ConsoleLogger( 71 | $output, 72 | [ LogLevel::INFO => OutputInterface::VERBOSITY_NORMAL ] 73 | ); 74 | 75 | $this->setLogger($logger); 76 | 77 | $workingDir = getcwd() . '/'; 78 | $this->workingDir = $workingDir; 79 | 80 | try { 81 | $this->loadProjectComposerPackage(); 82 | $this->loadConfigFromComposerJson(); 83 | 84 | // Pipeline 85 | 86 | // TODO: check for `--no-dev` somewhere. 87 | 88 | $vendorComposerAutoload = new VendorComposerAutoload( 89 | $this->config, 90 | $this->filesystem, 91 | $logger 92 | ); 93 | 94 | $vendorComposerAutoload->addAliasesFileToComposer(); 95 | $vendorComposerAutoload->addVendorPrefixedAutoloadToVendorAutoload(); 96 | } catch (Exception $e) { 97 | $this->logger->error($e->getMessage()); 98 | 99 | return Command::FAILURE; 100 | } 101 | 102 | return Command::SUCCESS; 103 | } 104 | 105 | /** 106 | * 1. Load the composer.json. 107 | * 108 | * @throws Exception 109 | */ 110 | protected function loadProjectComposerPackage(): void 111 | { 112 | $this->logger->notice('Loading package...'); 113 | 114 | $this->projectComposerPackage = new ProjectComposerPackage($this->workingDir . 'composer.json'); 115 | } 116 | 117 | protected function loadConfigFromComposerJson(): void 118 | { 119 | $this->logger->notice('Loading composer.json config...'); 120 | 121 | $this->config = $this->projectComposerPackage->getStraussConfig(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Console/Commands/ReplaceCommand.php: -------------------------------------------------------------------------------- 1 | setName('replace'); 64 | $this->setDescription("Rename a namespace in files."); 65 | $this->setHelp(''); 66 | 67 | $this->addOption( 68 | 'from', 69 | null, 70 | InputArgument::OPTIONAL, 71 | 'Original namespace' 72 | ); 73 | 74 | $this->addOption( 75 | 'to', 76 | null, 77 | InputArgument::OPTIONAL, 78 | 'New namespace' 79 | ); 80 | 81 | $this->addOption( 82 | 'paths', 83 | null, 84 | InputArgument::OPTIONAL, 85 | 'Comma separated list of files and directories to update. Default is the current working directory.', 86 | getcwd() 87 | ); 88 | 89 | // TODO: permissions? 90 | $this->filesystem = new Filesystem( 91 | new \League\Flysystem\Filesystem(new LocalFilesystemAdapter('/')), 92 | getcwd() . '/' 93 | ); 94 | } 95 | 96 | /** 97 | * @param InputInterface $input 98 | * @param OutputInterface $output 99 | * 100 | * @see Command::execute() 101 | * 102 | */ 103 | protected function execute(InputInterface $input, OutputInterface $output): int 104 | { 105 | $this->setLogger( 106 | new ConsoleLogger( 107 | $output, 108 | [ LogLevel::INFO => OutputInterface::VERBOSITY_NORMAL ] 109 | ) 110 | ); 111 | 112 | $workingDir = getcwd() . '/'; 113 | $this->workingDir = $workingDir; 114 | 115 | try { 116 | $config = $this->createConfig($input); 117 | $this->config = $config; 118 | 119 | // Pipeline 120 | 121 | $this->enumerateFiles($config); 122 | 123 | $this->determineChanges($config); 124 | 125 | $this->performReplacements($config); 126 | 127 | $this->performReplacementsInProjectFiles($config); 128 | 129 | $this->addLicenses($config); 130 | } catch (Exception $e) { 131 | $this->logger->error($e->getMessage()); 132 | 133 | return 1; 134 | } 135 | 136 | return Command::SUCCESS; 137 | } 138 | 139 | protected function createConfig(InputInterface $input): ReplaceConfigInterface 140 | { 141 | $config = new StraussConfig(); 142 | 143 | $from = $input->getOption('from'); 144 | $to = $input->getOption('to'); 145 | 146 | // TODO: 147 | $config->setNamespaceReplacementPatterns([$from => $to]); 148 | 149 | $paths = explode(',', $input->getOption('paths')); 150 | 151 | $config->setUpdateCallSites($paths); 152 | 153 | return $config; 154 | } 155 | 156 | 157 | protected function enumerateFiles(ReplaceConfigInterface $config): void 158 | { 159 | $this->logger->info('Enumerating files...'); 160 | $relativeUpdateCallSites = $config->getUpdateCallSites(); 161 | $updateCallSites = array_map( 162 | fn($path) => false !== strpos($path, trim($this->workingDir, '/')) ? $path : $this->workingDir . $path, 163 | $relativeUpdateCallSites 164 | ); 165 | $fileEnumerator = new FileEnumerator($config, $this->filesystem); 166 | $this->discoveredFiles = $fileEnumerator->compileFileListForPaths($updateCallSites); 167 | } 168 | 169 | // 4. Determine namespace and classname changes 170 | protected function determineChanges(ReplaceConfigInterface $config): void 171 | { 172 | $this->logger->info('Determining changes...'); 173 | 174 | $fileScanner = new FileSymbolScanner( 175 | $config, 176 | $this->filesystem 177 | ); 178 | 179 | $this->discoveredSymbols = $fileScanner->findInFiles($this->discoveredFiles); 180 | 181 | $changeEnumerator = new ChangeEnumerator( 182 | $config, 183 | $this->filesystem 184 | ); 185 | $changeEnumerator->determineReplacements($this->discoveredSymbols); 186 | } 187 | 188 | // 5. Update namespaces and class names. 189 | // Replace references to updated namespaces and classnames throughout the dependencies. 190 | protected function performReplacements(ReplaceConfigInterface $config): void 191 | { 192 | $this->logger->info('Performing replacements...'); 193 | 194 | $this->replacer = new Prefixer($config, $this->filesystem, $this->logger); 195 | 196 | $this->replacer->replaceInFiles($this->discoveredSymbols, $this->discoveredFiles->getFiles()); 197 | } 198 | 199 | protected function performReplacementsInProjectFiles(ReplaceConfigInterface $config): void 200 | { 201 | 202 | $relativeCallSitePaths = $this->config->getUpdateCallSites(); 203 | 204 | if (empty($relativeCallSitePaths)) { 205 | return; 206 | } 207 | 208 | $callSitePaths = array_map( 209 | fn($path) => false !== strpos($path, trim($this->workingDir, '/')) ? $path : $this->workingDir . $path, 210 | $relativeCallSitePaths 211 | ); 212 | 213 | $projectReplace = new Prefixer($config, $this->filesystem, $this->logger); 214 | 215 | $fileEnumerator = new FileEnumerator( 216 | $config, 217 | $this->filesystem 218 | ); 219 | 220 | $phpFilePaths = $fileEnumerator->compileFileListForPaths($callSitePaths); 221 | 222 | // TODO: Warn when a file that was specified is not found (during config validation). 223 | // $this->logger->warning('Expected file not found from project autoload: ' . $absolutePath); 224 | 225 | $phpFilesAbsolutePaths = array_map( 226 | fn($file) => $file->getSourcePath(), 227 | $phpFilePaths->getFiles() 228 | ); 229 | 230 | $projectReplace->replaceInProjectFiles($this->discoveredSymbols, $phpFilesAbsolutePaths); 231 | } 232 | 233 | 234 | protected function addLicenses(ReplaceConfigInterface $config): void 235 | { 236 | $this->logger->info('Adding licenses...'); 237 | 238 | $username = trim(shell_exec('git config user.name')); 239 | $email = trim(shell_exec('git config user.email')); 240 | 241 | if (!empty($username) && !empty($email)) { 242 | // e.g. "Brian Henry ". 243 | $author = $username . ' <' . $email . '>'; 244 | } else { 245 | // e.g. "brianhenry". 246 | $author = get_current_user(); 247 | } 248 | 249 | // TODO: Update to use DiscoveredFiles 250 | $dependencies = $this->flatDependencyTree; 251 | $licenser = new Licenser($config, $dependencies, $author, $this->filesystem, $this->logger); 252 | 253 | $licenser->copyLicenses(); 254 | 255 | $modifiedFiles = $this->replacer->getModifiedFiles(); 256 | $licenser->addInformationToUpdatedFiles($modifiedFiles); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/Files/DiscoveredFiles.php: -------------------------------------------------------------------------------- 1 | */ 10 | protected array $files = []; 11 | 12 | public function add(FileBase $file): void 13 | { 14 | $this->files[$file->getSourcePath()] = $file; 15 | } 16 | 17 | /** 18 | * @return array 19 | */ 20 | public function getFiles(): array 21 | { 22 | return $this->files; 23 | } 24 | 25 | /** 26 | * Fetch/check if a file exists in the discovered files. 27 | * 28 | * @param string $sourceAbsolutePath Full path to the file. 29 | */ 30 | public function getFile(string $sourceAbsolutePath): ?FileBase 31 | { 32 | return $this->files[$sourceAbsolutePath] ?? null; 33 | } 34 | 35 | public function sort(): void 36 | { 37 | ksort($this->files); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Files/File.php: -------------------------------------------------------------------------------- 1 | sourceAbsolutePath = $sourceAbsolutePath; 34 | } 35 | 36 | public function getSourcePath(): string 37 | { 38 | return $this->sourceAbsolutePath; 39 | } 40 | 41 | public function isPhpFile(): bool 42 | { 43 | return substr($this->sourceAbsolutePath, -4) === '.php'; 44 | } 45 | 46 | /** 47 | * Some combination of file copy exclusions and vendor-dir == target-dir 48 | * 49 | * @param bool $doCopy 50 | * 51 | * @return void 52 | */ 53 | public function setDoCopy(bool $doCopy): void 54 | { 55 | $this->doCopy = $doCopy; 56 | } 57 | public function isDoCopy(): bool 58 | { 59 | return $this->doCopy; 60 | } 61 | 62 | public function setDoPrefix(bool $doPrefix): void 63 | { 64 | } 65 | 66 | /** 67 | * Is this correct? Is there ever a time that NO changes should be made to a file? I.e. another file would have its 68 | * namespace changed and it needs to be updated throughout. 69 | * 70 | * Is this really a Symbol level function? 71 | */ 72 | public function isDoPrefix(): bool 73 | { 74 | return true; 75 | } 76 | 77 | /** 78 | * Used to mark files that are symlinked as not-to-be-deleted. 79 | * 80 | * @param bool $doDelete 81 | */ 82 | public function setDoDelete(bool $doDelete): void 83 | { 84 | $this->doDelete = $doDelete; 85 | } 86 | 87 | /** 88 | * Should file be deleted? 89 | * 90 | * NB: Also respect the "delete_vendor_files"|"delete_vendor_packages" settings. 91 | */ 92 | public function isDoDelete(): bool 93 | { 94 | return $this->doDelete; 95 | } 96 | 97 | public function setDidDelete(bool $didDelete): void 98 | { 99 | $this->didDelete = $didDelete; 100 | } 101 | public function getDidDelete(): bool 102 | { 103 | return $this->didDelete; 104 | } 105 | 106 | public function addDiscoveredSymbol(DiscoveredSymbol $symbol): void 107 | { 108 | $this->discoveredSymbols[$symbol->getOriginalSymbol()] = $symbol; 109 | } 110 | 111 | /** 112 | * @return array The discovered symbols in the file, indexed by their original string name. 113 | */ 114 | public function getDiscoveredSymbols(): array 115 | { 116 | return $this->discoveredSymbols; 117 | } 118 | 119 | public function setAbsoluteTargetPath(string $absoluteTargetPath): void 120 | { 121 | $this->absoluteTargetPath = $absoluteTargetPath; 122 | } 123 | 124 | /** 125 | * The target path to (maybe) copy the file to, and the target path to perform replacements in (which may be the 126 | * original path). 127 | */ 128 | public function getAbsoluteTargetPath(): string 129 | { 130 | // TODO: Maybe this is a mistake and should better be an exception. 131 | return isset($this->absoluteTargetPath) ? $this->absoluteTargetPath : $this->sourceAbsolutePath; 132 | } 133 | 134 | protected bool $didUpdate = false; 135 | public function setDidUpdate(): void 136 | { 137 | $this->didUpdate = true; 138 | } 139 | public function getDidUpdate(): bool 140 | { 141 | return $this->didUpdate; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Files/FileBase.php: -------------------------------------------------------------------------------- 1 | vendorRelativePath = ltrim($vendorRelativePath, '/\\'); 30 | $this->dependency = $dependency; 31 | } 32 | 33 | public function getDependency(): ComposerPackage 34 | { 35 | return $this->dependency; 36 | } 37 | 38 | /** 39 | * The target path to (maybe) copy the file to, and the target path to perform replacements in (which may be the 40 | * original path). 41 | */ 42 | 43 | /** 44 | * Record the autoloader it is found in. Which could be all of them. 45 | */ 46 | public function addAutoloader(string $autoloaderType): void 47 | { 48 | $this->autoloaderTypes = array_unique(array_merge($this->autoloaderTypes, array($autoloaderType))); 49 | } 50 | 51 | public function isFilesAutoloaderFile(): bool 52 | { 53 | return in_array('files', $this->autoloaderTypes, true); 54 | } 55 | 56 | public function getVendorRelativePath(): string 57 | { 58 | return $this->vendorRelativePath; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Files/HasDependency.php: -------------------------------------------------------------------------------- 1 | flysystem = $flysystem; 39 | $this->normalizer = new StripProtocolPathNormalizer('mem'); 40 | 41 | $this->workingDir = $workingDir; 42 | } 43 | 44 | /** 45 | * @param string[] $fileAndDirPaths 46 | * 47 | * @return string[] 48 | */ 49 | public function findAllFilesAbsolutePaths(array $fileAndDirPaths): array 50 | { 51 | $files = []; 52 | 53 | foreach ($fileAndDirPaths as $path) { 54 | if (!$this->directoryExists($path)) { 55 | $files[] = $path; 56 | continue; 57 | } 58 | 59 | $directoryListing = $this->listContents( 60 | $path, 61 | FilesystemReader::LIST_DEEP 62 | ); 63 | 64 | /** @var FileAttributes[] $files */ 65 | $fileAttributesArray = $directoryListing->toArray(); 66 | 67 | $f = array_map(fn($file) => '/'.$file->path(), $fileAttributesArray); 68 | 69 | $files = array_merge($files, $f); 70 | } 71 | 72 | return $files; 73 | } 74 | 75 | public function getAttributes(string $absolutePath): ?StorageAttributes 76 | { 77 | $fileDirectory = realpath(dirname($absolutePath)); 78 | 79 | $absolutePath = $this->normalizer->normalizePath($absolutePath); 80 | 81 | // Unsupported symbolic link encountered at location //home 82 | // \League\Flysystem\SymbolicLinkEncountered 83 | $dirList = $this->listContents($fileDirectory)->toArray(); 84 | foreach ($dirList as $file) { // TODO: use the generator. 85 | if ($file->path() === $absolutePath) { 86 | return $file; 87 | } 88 | } 89 | 90 | return null; 91 | } 92 | 93 | public function fileExists(string $location): bool 94 | { 95 | return $this->flysystem->fileExists( 96 | $this->normalizer->normalizePath($location) 97 | ); 98 | } 99 | 100 | public function read(string $location): string 101 | { 102 | return $this->flysystem->read( 103 | $this->normalizer->normalizePath($location) 104 | ); 105 | } 106 | 107 | public function readStream(string $location) 108 | { 109 | return $this->flysystem->readStream( 110 | $this->normalizer->normalizePath($location) 111 | ); 112 | } 113 | 114 | public function listContents(string $location, bool $deep = self::LIST_SHALLOW): DirectoryListing 115 | { 116 | return $this->flysystem->listContents( 117 | $this->normalizer->normalizePath($location), 118 | $deep 119 | ); 120 | } 121 | 122 | public function lastModified(string $path): int 123 | { 124 | return $this->flysystem->lastModified( 125 | $this->normalizer->normalizePath($path) 126 | ); 127 | } 128 | 129 | public function fileSize(string $path): int 130 | { 131 | return $this->flysystem->fileSize( 132 | $this->normalizer->normalizePath($path) 133 | ); 134 | } 135 | 136 | public function mimeType(string $path): string 137 | { 138 | return $this->flysystem->mimeType( 139 | $this->normalizer->normalizePath($path) 140 | ); 141 | } 142 | 143 | public function visibility(string $path): string 144 | { 145 | return $this->flysystem->visibility( 146 | $this->normalizer->normalizePath($path) 147 | ); 148 | } 149 | 150 | public function write(string $location, string $contents, array $config = []): void 151 | { 152 | $this->flysystem->write( 153 | $this->normalizer->normalizePath($location), 154 | $contents, 155 | $config 156 | ); 157 | } 158 | 159 | public function writeStream(string $location, $contents, array $config = []): void 160 | { 161 | $this->flysystem->writeStream( 162 | $this->normalizer->normalizePath($location), 163 | $contents, 164 | $config 165 | ); 166 | } 167 | 168 | public function setVisibility(string $path, string $visibility): void 169 | { 170 | $this->flysystem->setVisibility( 171 | $this->normalizer->normalizePath($path), 172 | $visibility 173 | ); 174 | } 175 | 176 | public function delete(string $location): void 177 | { 178 | $this->flysystem->delete( 179 | $this->normalizer->normalizePath($location) 180 | ); 181 | } 182 | 183 | public function deleteDirectory(string $location): void 184 | { 185 | $this->flysystem->deleteDirectory( 186 | $this->normalizer->normalizePath($location) 187 | ); 188 | } 189 | 190 | public function createDirectory(string $location, array $config = []): void 191 | { 192 | $this->flysystem->createDirectory( 193 | $this->normalizer->normalizePath($location), 194 | $config 195 | ); 196 | } 197 | 198 | public function move(string $source, string $destination, array $config = []): void 199 | { 200 | $this->flysystem->move( 201 | $this->normalizer->normalizePath($source), 202 | $this->normalizer->normalizePath($destination), 203 | $config 204 | ); 205 | } 206 | 207 | public function copy(string $source, string $destination, array $config = []): void 208 | { 209 | $this->flysystem->copy( 210 | $this->normalizer->normalizePath($source), 211 | $this->normalizer->normalizePath($destination), 212 | $config 213 | ); 214 | } 215 | 216 | /** 217 | * 218 | * /path/to/this/dir, /path/to/file.php => ../../file.php 219 | * /path/to/here, /path/to/here/dir/file.php => dir/file.php 220 | * 221 | * @param string $fromAbsoluteDirectory 222 | * @param string $toAbsolutePath 223 | * @return string 224 | */ 225 | public function getRelativePath(string $fromAbsoluteDirectory, string $toAbsolutePath): string 226 | { 227 | $fromAbsoluteDirectory = $this->normalizer->normalizePath($fromAbsoluteDirectory); 228 | $toAbsolutePath = $this->normalizer->normalizePath($toAbsolutePath); 229 | 230 | $fromDirectoryParts = array_filter(explode('/', $fromAbsoluteDirectory)); 231 | $toPathParts = array_filter(explode('/', $toAbsolutePath)); 232 | foreach ($fromDirectoryParts as $key => $part) { 233 | if ($part === $toPathParts[$key]) { 234 | unset($toPathParts[$key]); 235 | unset($fromDirectoryParts[$key]); 236 | } else { 237 | break; 238 | } 239 | if (count($fromDirectoryParts) === 0 || count($toPathParts) === 0) { 240 | break; 241 | } 242 | } 243 | 244 | $relativePath = 245 | str_repeat('../', count($fromDirectoryParts)) 246 | . implode('/', $toPathParts); 247 | 248 | if ($this->directoryExists($toAbsolutePath)) { 249 | $relativePath .= '/'; 250 | } 251 | 252 | return $relativePath; 253 | } 254 | 255 | 256 | /** 257 | * Check does the filepath point to a file outside the working directory. 258 | * If `realpath()` fails to resolve the path, assume it's a symlink. 259 | */ 260 | public function isSymlinkedFile(FileBase $file): bool 261 | { 262 | $realpath = realpath($file->getSourcePath()); 263 | 264 | return ! $realpath || ! str_starts_with($realpath, $this->workingDir); 265 | } 266 | 267 | /** 268 | * Does the subdir path start with the dir path? 269 | */ 270 | public function isSubDirOf(string $dir, string $subdir): bool 271 | { 272 | return str_starts_with( 273 | $this->normalizer->normalizePath($subdir), 274 | $this->normalizer->normalizePath($dir) 275 | ); 276 | } 277 | 278 | public function normalize(string $path) 279 | { 280 | return $this->normalizer->normalizePath($path); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/Helpers/FlysystemBackCompatInterface.php: -------------------------------------------------------------------------------- 1 | flysystem, 'directoryExists')) { 20 | return $this->flysystem->directoryExists($location); 21 | } 22 | 23 | $normalizer = new WhitespacePathNormalizer(); 24 | $normalizer = new StripProtocolPathNormalizer(['mem'], $normalizer); 25 | $location = $normalizer->normalizePath($location); 26 | 27 | $parentDirectoryContents = $this->listContents(dirname($location)); 28 | /** @var FileAttributes $entry */ 29 | foreach ($parentDirectoryContents as $entry) { 30 | if ($entry->path() == $location) { 31 | return $entry->isDir(); 32 | } 33 | } 34 | 35 | return false; 36 | } 37 | 38 | // Some version of Flysystem has: 39 | // has 40 | public function has(string $location): bool 41 | { 42 | if (method_exists($this->flysystem, 'has')) { 43 | return $this->flysystem->has($location); 44 | } 45 | return $this->fileExists($location) || $this->directoryExists($location); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Helpers/InMemoryFilesystemAdapter.php: -------------------------------------------------------------------------------- 1 | fileExists($path)) { 15 | // Assume it is a directory. 16 | 17 | // Maybe check does the directory exist. 18 | // $parentDirContents = (array) $this->listContents(dirname($path), false); 19 | // throw UnableToRetrieveMetadata::visibility($path, 'file does not exist'); 20 | 21 | return new FileAttributes($path, null, 'public'); 22 | } 23 | 24 | 25 | return parent::visibility($path); 26 | } 27 | 28 | public function lastModified(string $path): FileAttributes 29 | { 30 | if (!$this->fileExists($path)) { 31 | // Assume it is a directory 32 | return new FileAttributes($path, null, null, 0); 33 | } 34 | 35 | return parent::lastModified($path); 36 | } 37 | 38 | public function copy(string $source, string $destination, Config $config): void 39 | { 40 | $this->createDirectories($destination, $config); 41 | 42 | parent::copy($source, $destination, $config); 43 | } 44 | 45 | public function write(string $path, string $contents, Config $config): void 46 | { 47 | // Make sure there is a directory for the file to be written to. 48 | if (false === strpos($path, '______DUMMY_FILE_FOR_FORCED_LISTING_IN_FLYSYSTEM_TEST')) { 49 | $this->createDirectories($path, $config); 50 | } 51 | 52 | parent::write($path, $contents, $config); 53 | } 54 | 55 | protected function createDirectories(string $path, Config $config): void 56 | { 57 | $pathDirs = explode('/', dirname($path)); 58 | for ($level = 0; $level < count($pathDirs); $level++) { 59 | $dir = implode('/', array_slice($pathDirs, 0, $level + 1)); 60 | $this->createDirectory($dir, $config); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Helpers/NamespaceSort.php: -------------------------------------------------------------------------------- 1 | order = $order; 19 | } 20 | 21 | public function __invoke(string $a, string $b): int 22 | { 23 | $a = trim($a, '\\'); 24 | $b = trim($b, '\\'); 25 | 26 | return $this->order === self::LONGEST 27 | ? $this->sort($a, $b) 28 | : $this->sort($b, $a); 29 | } 30 | 31 | protected function sort(string $a, string $b): int 32 | { 33 | 34 | $aParts = explode('\\', $a); 35 | $bParts = explode('\\', $b); 36 | 37 | $aPartCount = count($aParts); 38 | $bPartCount = count($bParts); 39 | 40 | if ($aPartCount !== $bPartCount) { 41 | return $bPartCount - $aPartCount; 42 | } 43 | 44 | $bLastPart = array_pop($aParts); 45 | $aLastPart = array_pop($bParts); 46 | 47 | return strlen($aLastPart) - strlen($bLastPart); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Helpers/ReadOnlyFileSystem.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 34 | 35 | $this->inMemoryFiles = new InMemoryFilesystemAdapter(); 36 | $this->deletedFiles = new InMemoryFilesystemAdapter(); 37 | 38 | $this->pathNormalizer = $pathNormalizer ?? new WhitespacePathNormalizer(); 39 | } 40 | 41 | public function fileExists(string $location): bool 42 | { 43 | if ($this->deletedFiles->fileExists($location)) { 44 | return false; 45 | } 46 | return $this->inMemoryFiles->fileExists($location) 47 | || $this->filesystem->fileExists($location); 48 | } 49 | 50 | public function write(string $location, string $contents, array $config = []): void 51 | { 52 | $config = new \League\Flysystem\Config($config); 53 | $this->inMemoryFiles->write($location, $contents, $config); 54 | 55 | if ($this->deletedFiles->fileExists($location)) { 56 | $this->deletedFiles->delete($location); 57 | } 58 | } 59 | 60 | public function writeStream(string $location, $contents, $config = []): void 61 | { 62 | $config = new \League\Flysystem\Config($config); 63 | $this->rewindStream($contents); 64 | $this->inMemoryFiles->writeStream($location, $contents, $config); 65 | 66 | if ($this->deletedFiles->fileExists($location)) { 67 | $this->deletedFiles->delete($location); 68 | } 69 | } 70 | /** 71 | * @param resource $resource 72 | */ 73 | private function rewindStream($resource): void 74 | { 75 | if (ftell($resource) !== 0 && stream_get_meta_data($resource)['seekable']) { 76 | rewind($resource); 77 | } 78 | } 79 | 80 | public function read(string $location): string 81 | { 82 | if ($this->deletedFiles->fileExists($location)) { 83 | throw UnableToReadFile::fromLocation($location); 84 | } 85 | if ($this->inMemoryFiles->fileExists($location)) { 86 | return $this->inMemoryFiles->read($location); 87 | } 88 | return $this->filesystem->read($location); 89 | } 90 | 91 | public function readStream(string $location) 92 | { 93 | if ($this->deletedFiles->fileExists($location)) { 94 | throw UnableToReadFile::fromLocation($location); 95 | } 96 | if ($this->inMemoryFiles->fileExists($location)) { 97 | return $this->inMemoryFiles->readStream($location); 98 | } 99 | return $this->filesystem->readStream($location); 100 | } 101 | 102 | public function delete(string $location): void 103 | { 104 | if ($this->fileExists($location)) { 105 | $file = $this->read($location); 106 | $this->deletedFiles->write($location, $file, new Config([])); 107 | } 108 | if ($this->inMemoryFiles->fileExists($location)) { 109 | $this->inMemoryFiles->delete($location); 110 | } 111 | } 112 | 113 | public function deleteDirectory(string $location): void 114 | { 115 | $location = $this->pathNormalizer->normalizePath($location); 116 | 117 | $this->deletedFiles->createDirectory($location, new Config([])); 118 | $this->inMemoryFiles->deleteDirectory($location); 119 | } 120 | 121 | 122 | public function createDirectory(string $location, array $config = []): void 123 | { 124 | $this->inMemoryFiles->createDirectory($location, new Config($config)); 125 | 126 | $this->deletedFiles->deleteDirectory($location); 127 | } 128 | 129 | public function listContents(string $location, bool $deep = self::LIST_SHALLOW): DirectoryListing 130 | { 131 | /** @var FileAttributes[] $actual */ 132 | $actual = $this->filesystem->listContents($location, $deep)->toArray(); 133 | 134 | $inMemoryFilesGenerator = $this->inMemoryFiles->listContents($location, $deep); 135 | $inMemoryFilesArray = $inMemoryFilesGenerator instanceof Traversable 136 | ? iterator_to_array($inMemoryFilesGenerator, false) 137 | : (array) $inMemoryFilesGenerator; 138 | 139 | $inMemoryFilePaths = array_map(fn($file) => $file->path(), $inMemoryFilesArray); 140 | 141 | $deletedFilesGenerator = $this->deletedFiles->listContents($location, $deep); 142 | $deletedFilesArray = $deletedFilesGenerator instanceof Traversable 143 | ? iterator_to_array($deletedFilesGenerator, false) 144 | : (array) $deletedFilesGenerator; 145 | $deletedFilePaths = array_map(fn($file) => $file->path(), $deletedFilesArray); 146 | 147 | $actual = array_filter($actual, fn($file) => !in_array($file->path(), $inMemoryFilePaths)); 148 | $actual = array_filter($actual, fn($file) => !in_array($file->path(), $deletedFilePaths)); 149 | 150 | $good = array_merge($actual, $inMemoryFilesArray); 151 | 152 | return new DirectoryListing($good); 153 | } 154 | 155 | public function move(string $source, string $destination, array $config = []): void 156 | { 157 | throw new \BadMethodCallException('Not yet implemented'); 158 | } 159 | 160 | public function copy(string $source, string $destination, $config = null): void 161 | { 162 | $sourceFile = $this->read($source); 163 | 164 | $this->inMemoryFiles->write( 165 | $destination, 166 | $sourceFile, 167 | $config instanceof Config ? $config : new Config($config ?? []) 168 | ); 169 | 170 | $a = $this->inMemoryFiles->read($destination); 171 | if ($sourceFile !== $a) { 172 | throw new \Exception('Copy failed'); 173 | } 174 | 175 | if ($this->deletedFiles->fileExists($destination)) { 176 | $this->deletedFiles->delete($destination); 177 | } 178 | } 179 | 180 | private function getAttributes(string $path): StorageAttributes 181 | { 182 | $parentDirectoryContents = $this->listContents(dirname($path), false); 183 | /** @var FileAttributes $entry */ 184 | foreach ($parentDirectoryContents as $entry) { 185 | if ($entry->path() == $path) { 186 | return $entry; 187 | } 188 | } 189 | throw UnableToReadFile::fromLocation($path); 190 | } 191 | 192 | public function lastModified(string $path): int 193 | { 194 | $attributes = $this->getAttributes($path); 195 | return $attributes->lastModified() ?? 0; 196 | } 197 | 198 | public function fileSize(string $path): int 199 | { 200 | $filesize = 0; 201 | 202 | if ($this->inMemoryFiles->fileExists($path)) { 203 | $filesize = $this->inMemoryFiles->fileSize($path); 204 | } elseif ($this->filesystem->fileExists($path)) { 205 | $filesize = $this->filesystem->fileSize($path); 206 | } 207 | 208 | if ($filesize instanceof FileAttributes) { 209 | return $filesize->fileSize(); 210 | } 211 | 212 | return $filesize; 213 | } 214 | 215 | public function mimeType(string $path): string 216 | { 217 | throw new \BadMethodCallException('Not yet implemented'); 218 | } 219 | 220 | public function setVisibility(string $path, string $visibility): void 221 | { 222 | throw new \BadMethodCallException('Not yet implemented'); 223 | } 224 | 225 | public function visibility(string $path): string 226 | { 227 | $path = $this->pathNormalizer->normalizePath($path); 228 | 229 | if (!$this->fileExists($path) && !$this->directoryExists($path)) { 230 | throw UnableToRetrieveMetadata::visibility($path, 'file does not exist'); 231 | } 232 | 233 | if ($this->deletedFiles->fileExists($path)) { 234 | throw UnableToRetrieveMetadata::visibility($path, 'file does not exist'); 235 | } 236 | if ($this->inMemoryFiles->fileExists($path)) { 237 | $attributes = $this->inMemoryFiles->visibility($path); 238 | return $attributes->visibility(); 239 | } 240 | if ($this->filesystem->fileExists($path)) { 241 | return $this->filesystem->visibility($path); 242 | } 243 | return \League\Flysystem\Visibility::PUBLIC; 244 | } 245 | 246 | public function directoryExists(string $location): bool 247 | { 248 | $location = $this->pathNormalizer->normalizePath($location); 249 | 250 | if ($this->directoryExistsIn($location, $this->deletedFiles)) { 251 | return false; 252 | } 253 | 254 | return $this->directoryExistsIn($location, $this->inMemoryFiles) 255 | || $this->directoryExistsIn($location, $this->filesystem); 256 | } 257 | 258 | protected function directoryExistsIn(string $location, $filesystem): bool 259 | { 260 | if (method_exists($filesystem, 'directoryExists')) { 261 | return $filesystem->directoryExists($location); 262 | } 263 | 264 | $parentDirectoryContents = $filesystem->listContents(dirname($location), false); 265 | /** @var FileAttributes $entry */ 266 | foreach ($parentDirectoryContents as $entry) { 267 | if ($entry->path() == $location) { 268 | return $entry->isDir(); 269 | } 270 | } 271 | 272 | return false; 273 | } 274 | 275 | public function has(string $location): bool 276 | { 277 | throw new \BadMethodCallException('Not yet implemented'); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Pipeline/Aliases.php: -------------------------------------------------------------------------------- 1 | config = $config; 46 | $this->fileSystem = $fileSystem; 47 | $this->setLogger($logger ?? new NullLogger()); 48 | } 49 | 50 | public function writeAliasesFileForSymbols(DiscoveredSymbols $symbols): void 51 | { 52 | $outputFilepath = $this->getAliasFilepath(); 53 | 54 | $fileString = $this->buildStringOfAliases($symbols, basename($outputFilepath)); 55 | 56 | if (empty($fileString)) { 57 | // TODO: Check if no actual aliases were added (i.e. is it just an empty template). 58 | // Log? 59 | return; 60 | } 61 | 62 | $this->fileSystem->write($outputFilepath, $fileString); 63 | } 64 | 65 | /** 66 | * @return array FQDN => relative path 67 | */ 68 | protected function getVendorClassmap(): array 69 | { 70 | $paths = array_map( 71 | function ($file) { 72 | return $this->config->isDryRun() 73 | ? new \SplFileInfo('mem://'.$file->path()) 74 | : new \SplFileInfo('/'.$file->path()); 75 | }, 76 | array_filter( 77 | $this->fileSystem->listContents($this->config->getVendorDirectory(), true)->toArray(), 78 | fn(StorageAttributes $file) => $file->isFile() && in_array(substr($file->path(), -3), ['php', 'inc', '.hh']) 79 | ) 80 | ); 81 | 82 | $vendorClassmap = ClassMapGenerator::createMap($paths); 83 | 84 | $vendorClassmap = array_map(fn($path) => str_replace('mem://', '', $path), $vendorClassmap); 85 | 86 | return $vendorClassmap; 87 | } 88 | 89 | /** 90 | * @return array FQDN => absolute path 91 | */ 92 | protected function getTargetClassmap(): array 93 | { 94 | $paths = 95 | array_map( 96 | function ($file) { 97 | return $this->config->isDryRun() 98 | ? new \SplFileInfo('mem://'.$file->path()) 99 | : new \SplFileInfo('/'.$file->path()); 100 | }, 101 | array_filter( 102 | $this->fileSystem->listContents($this->config->getTargetDirectory(), \League\Flysystem\FilesystemReader::LIST_DEEP)->toArray(), 103 | fn(StorageAttributes $file) => $file->isFile() && in_array(substr($file->path(), -3), ['php', 'inc', '.hh']) 104 | ) 105 | ); 106 | 107 | $classMap = ClassMapGenerator::createMap($paths); 108 | 109 | // To make it easier when viewing in xdebug. 110 | uksort($classMap, new NamespaceSort()); 111 | 112 | $classMap = array_map(fn($path) => str_replace('mem://', '', $path), $classMap); 113 | 114 | return $classMap; 115 | } 116 | 117 | /** 118 | * We will create `vendor/composer/autoload_aliases.php` alongside other autoload files, e.g. `autoload_real.php`. 119 | */ 120 | protected function getAliasFilepath(): string 121 | { 122 | return sprintf( 123 | '%scomposer/autoload_aliases.php', 124 | $this->config->getVendorDirectory() 125 | ); 126 | } 127 | 128 | /** 129 | * @param DiscoveredSymbol[] $symbols 130 | * @return DiscoveredSymbol[] 131 | */ 132 | protected function getModifiedSymbols(array $symbols): array 133 | { 134 | $modifiedSymbols = []; 135 | foreach ($symbols as $symbol) { 136 | if ($symbol->getOriginalSymbol() !== $symbol->getReplacement()) { 137 | $modifiedSymbols[] = $symbol; 138 | } 139 | } 140 | return $modifiedSymbols; 141 | } 142 | 143 | protected function buildStringOfAliases(DiscoveredSymbols $symbols, string $outputFilename): string 144 | { 145 | 146 | $sourceDirClassmap = $this->getVendorClassmap(); 147 | 148 | $autoloadAliasesFileString = 'getModifiedSymbols($symbols->getSymbols()); 153 | 154 | $functionSymbols = array_filter($modifiedSymbols, fn(DiscoveredSymbol $symbol) => $symbol instanceof FunctionSymbol); 155 | $otherSymbols = array_filter($modifiedSymbols, fn(DiscoveredSymbol $symbol) => !($symbol instanceof FunctionSymbol)); 156 | 157 | $targetDirClassmap = $this->getTargetClassmap(); 158 | 159 | if (count($otherSymbols)>0) { 160 | $autoloadAliasesFileString .= 'function autoloadAliases( $classname ): void {' . PHP_EOL; 161 | $autoloadAliasesFileString = $this->appendAliasString($otherSymbols, $sourceDirClassmap, $targetDirClassmap, $autoloadAliasesFileString); 162 | $autoloadAliasesFileString .= '}' . PHP_EOL . PHP_EOL; 163 | $autoloadAliasesFileString .= "spl_autoload_register( 'autoloadAliases' );" . PHP_EOL . PHP_EOL; 164 | } 165 | 166 | if (count($functionSymbols)>0) { 167 | $autoloadAliasesFileString = $this->appendFunctionAliases($functionSymbols, $autoloadAliasesFileString); 168 | } 169 | 170 | return $autoloadAliasesFileString; 171 | } 172 | 173 | /** 174 | * @param array $modifiedSymbols 175 | * @param array $sourceDirClassmap 176 | * @param array $targetDirClasssmap 177 | * @param string $autoloadAliasesFileString 178 | * @return string 179 | * @throws \League\Flysystem\FilesystemException 180 | */ 181 | protected function appendAliasString(array $modifiedSymbols, array $sourceDirClassmap, array $targetDirClasssmap, string $autoloadAliasesFileString): string 182 | { 183 | $aliasesPhpString = ' switch( $classname ) {' . PHP_EOL; 184 | 185 | foreach ($modifiedSymbols as $symbol) { 186 | $originalSymbol = $symbol->getOriginalSymbol(); 187 | $replacementSymbol = $symbol->getReplacement(); 188 | 189 | // if (!$symbol->getSourceFile()->isDoDelete()) { 190 | // $this->logger->debug("Skipping {$originalSymbol} because it is not marked for deletion."); 191 | // continue; 192 | // } 193 | 194 | if ($originalSymbol === $replacementSymbol) { 195 | $this->logger->debug("Skipping {$originalSymbol} because it is not being changed."); 196 | continue; 197 | } 198 | 199 | switch (get_class($symbol)) { 200 | case NamespaceSymbol::class: 201 | // TODO: namespaced constants? 202 | $namespace = $symbol->getOriginalSymbol(); 203 | 204 | $symbolSourceFiles = $symbol->getSourceFiles(); 205 | 206 | $namespacesInOriginalClassmap = array_filter( 207 | $sourceDirClassmap, 208 | fn($filepath) => in_array($filepath, array_keys($symbolSourceFiles)) 209 | ); 210 | 211 | foreach ($namespacesInOriginalClassmap as $originalFqdnClassName => $absoluteFilePath) { 212 | if ($symbol->getOriginalSymbol() === $symbol->getReplacement()) { 213 | continue; 214 | } 215 | 216 | $localName = array_reverse(explode('\\', $originalFqdnClassName))[0]; 217 | 218 | if (0 !== strpos($originalFqdnClassName, $symbol->getReplacement())) { 219 | $newFqdnClassName = $symbol->getReplacement() . '\\' . $localName; 220 | } else { 221 | $newFqdnClassName = $originalFqdnClassName; 222 | } 223 | 224 | if (!isset($targetDirClasssmap[$newFqdnClassName]) && !isset($sourceDirClassmap[$originalFqdnClassName])) { 225 | $a = $symbol->getSourceFiles(); 226 | /** @var File $b */ 227 | $b = array_pop($a); // There's gotta be at least one. 228 | 229 | throw new \Exception("errorrrr " . ' ' . basename($b->getAbsoluteTargetPath()) . ' ' . $originalFqdnClassName . ' ' . $newFqdnClassName . PHP_EOL . PHP_EOL); 230 | } 231 | 232 | $symbolFilepath = $targetDirClasssmap[$newFqdnClassName] ?? $sourceDirClassmap[$originalFqdnClassName]; 233 | $symbolFileString = $this->fileSystem->read($symbolFilepath); 234 | 235 | // This should be improved with a check for non-class-valid characters after the name. 236 | // Eventually it should be in the File object itself. 237 | $isClass = 1 === preg_match('/class ' . $localName . '/i', $symbolFileString); 238 | $isInterface = 1 === preg_match('/interface ' . $localName . '/i', $symbolFileString); 239 | $isTrait = 1 === preg_match('/trait ' . $localName . '/i', $symbolFileString); 240 | 241 | if (!$isClass && !$isInterface && !$isTrait) { 242 | $isEnum = 1 === preg_match('/enum ' . $localName . '/', $symbolFileString); 243 | 244 | if ($isEnum) { 245 | $this->logger->warning("Skipping $newFqdnClassName – enum aliasing not yet implemented."); 246 | // TODO: enums 247 | continue; 248 | } 249 | 250 | $this->logger->error("Skipping $newFqdnClassName because it doesn't exist."); 251 | throw new \Exception("Skipping $newFqdnClassName because it doesn't exist."); 252 | } 253 | 254 | $escapedOriginalFqdnClassName = str_replace('\\', '\\\\', $originalFqdnClassName); 255 | $aliasesPhpString .= " case '$escapedOriginalFqdnClassName':" . PHP_EOL; 256 | 257 | if ($isClass) { 258 | $aliasesPhpString .= " class_alias(\\$newFqdnClassName::class, \\$originalFqdnClassName::class);" . PHP_EOL; 259 | } elseif ($isInterface) { 260 | $aliasesPhpString .= " \$includeFile = 'getOriginalSymbol(); // We want the original to continue to work, so it is the alias. 273 | $concreteClass = $symbol->getReplacement(); 274 | $aliasesPhpString .= <<getOriginalSymbol(); 307 | $replacementSymbol = $symbol->getReplacement(); 308 | 309 | // if (!$symbol->getSourceFile()->isDoDelete()) { 310 | // $this->logger->debug("Skipping {$originalSymbol} because it is not marked for deletion."); 311 | // continue; 312 | // } 313 | 314 | if ($originalSymbol === $replacementSymbol) { 315 | $this->logger->debug("Skipping {$originalSymbol} because it is not being changed."); 316 | continue; 317 | } 318 | 319 | switch (get_class($symbol)) { 320 | case FunctionSymbol::class: 321 | // TODO: Do we need to check for `void`? Or will it just be ignored? 322 | // Is it possible to inherit PHPDoc from the original function? 323 | $aliasesPhpString = <<> $discoveredFilesAutoloaders Array of packagePath => array of relativeFilePaths. 33 | */ 34 | protected array $discoveredFilesAutoloaders; 35 | 36 | protected string $absoluteTargetDirectory; 37 | 38 | /** 39 | * Autoload constructor. 40 | * 41 | * @param StraussConfig $config 42 | * @param array> $discoveredFilesAutoloaders 43 | */ 44 | public function __construct( 45 | AutoloadConfigInterface $config, 46 | array $discoveredFilesAutoloaders, 47 | Filesystem $filesystem, 48 | ?LoggerInterface $logger = null 49 | ) { 50 | $this->config = $config; 51 | $this->discoveredFilesAutoloaders = $discoveredFilesAutoloaders; 52 | $this->filesystem = $filesystem; 53 | $this->setLogger($logger ?? new NullLogger()); 54 | } 55 | 56 | public function generate(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void 57 | { 58 | if (!$this->config->isClassmapOutput()) { 59 | $this->logger->debug('Not generating autoload.php because classmap output is disabled.'); 60 | return; 61 | } 62 | 63 | $this->logger->info('Generating autoload files for ' . $this->config->getTargetDirectory()); 64 | 65 | if ($this->config->getVendorDirectory() !== $this->config->getTargetDirectory()) { 66 | $installedJson = new InstalledJson( 67 | $this->config, 68 | $this->filesystem, 69 | $this->logger 70 | ); 71 | $installedJson->createAndCleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols); 72 | } 73 | 74 | (new DumpAutoload( 75 | $this->config, 76 | $this->filesystem, 77 | $this->logger 78 | ))->generatedPrefixedAutoloader(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Pipeline/Autoload/ComposerAutoloadGenerator.php: -------------------------------------------------------------------------------- 1 | projectUniqueString = $projectUniqueString; 50 | } 51 | 52 | /** 53 | * Get a unique id for the `files` autoload entry. 54 | * 55 | * `$path` here is `PackageInterface->getTargetDir()`.`PackageInterface::getAutoload()['files'][]` 56 | * 57 | * @override 58 | * @see AutoloadGenerator::getFileIdentifier() 59 | * 60 | * @param PackageInterface $package The package to get the file identifier for. 61 | * @param string $path Relative path from `vendor`. 62 | * 63 | * @return string 64 | */ 65 | protected function getFileIdentifier(PackageInterface $package, string $path) 66 | { 67 | return hash('md5', $package->getName() . ':' . $path . ':' . $this->projectUniqueString); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Pipeline/Autoload/DumpAutoload.php: -------------------------------------------------------------------------------- 1 | config = $config; 36 | $this->filesystem = $filesystem; 37 | $this->setLogger($logger ?? new NullLogger()); 38 | } 39 | 40 | /** 41 | * Uses `vendor/composer/installed.json` to output autoload files to `vendor-prefixed/composer`. 42 | */ 43 | public function generatedPrefixedAutoloader(): void 44 | { 45 | /** 46 | * Unfortunately, `::dump()` creates the target directories if they don't exist, even though it otherwise respects `::setDryRun()`. 47 | */ 48 | if ($this->config->isDryRun()) { 49 | return; 50 | } 51 | 52 | $relativeTargetDir = $this->filesystem->getRelativePath( 53 | $this->config->getProjectDirectory(), 54 | $this->config->getTargetDirectory() 55 | ); 56 | 57 | $defaultVendorDirBefore = Config::$defaultConfig['vendor-dir']; 58 | 59 | Config::$defaultConfig['vendor-dir'] = $relativeTargetDir; 60 | 61 | $composer = Factory::create(new NullIO(), $this->config->getProjectDirectory() . 'composer.json'); 62 | $installationManager = $composer->getInstallationManager(); 63 | $package = $composer->getPackage(); 64 | 65 | $projectComposerJson = new JsonFile($this->config->getProjectDirectory() . 'composer.json'); 66 | $projectComposerJsonArray = $projectComposerJson->read(); 67 | if (isset($projectComposerJsonArray['config'], $projectComposerJsonArray['config']['vendor-dir'])) { 68 | unset($projectComposerJsonArray['config']['vendor-dir']); 69 | } 70 | 71 | /** 72 | * Cannot use `$composer->getConfig()`, need to create a new one so the vendor-dir is correct. 73 | */ 74 | $config = new \Composer\Config(false, $this->config->getProjectDirectory()); 75 | 76 | $config->merge([ 77 | 'config' => $projectComposerJsonArray['config'] ?? [] 78 | ]); 79 | 80 | $generator = new ComposerAutoloadGenerator( 81 | $this->config->getNamespacePrefix(), 82 | $composer->getEventDispatcher() 83 | ); 84 | 85 | $generator->setDryRun($this->config->isDryRun()); 86 | $generator->setClassMapAuthoritative(true); 87 | $generator->setRunScripts(false); 88 | // $generator->setApcu($apcu, $apcuPrefix); 89 | // $generator->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input)); 90 | $optimize = true; // $input->getOption('optimize') || $config->get('optimize-autoloader'); 91 | $generator->setDevMode(false); 92 | 93 | $localRepo = new InstalledFilesystemRepository(new JsonFile($this->config->getTargetDirectory() . 'composer/installed.json')); 94 | 95 | $strictAmbiguous = false; // $input->getOption('strict-ambiguous') 96 | 97 | // This will output the autoload_static.php etc. files to `vendor-prefixed/composer`. 98 | $generator->dump( 99 | $config, 100 | $localRepo, 101 | $package, 102 | $installationManager, 103 | 'composer', 104 | $optimize, 105 | $this->getSuffix(), 106 | $composer->getLocker(), 107 | $strictAmbiguous 108 | ); 109 | 110 | /** 111 | * Tests fail if this is absent. 112 | * 113 | * Arguably this should be in ::setUp() and tearDown() in the test classes, but if other tools run after Strauss 114 | * then they might expect it to be unmodified. 115 | */ 116 | Config::$defaultConfig['vendor-dir'] = $defaultVendorDirBefore; 117 | 118 | $this->prefixNewAutoloader(); 119 | } 120 | 121 | protected function prefixNewAutoloader(): void 122 | { 123 | $this->logger->debug('Prefixing the new Composer autoloader.'); 124 | 125 | $projectReplace = new Prefixer( 126 | $this->config, 127 | $this->filesystem, 128 | $this->logger 129 | ); 130 | 131 | $fileEnumerator = new FileEnumerator( 132 | $this->config, 133 | $this->filesystem 134 | ); 135 | 136 | $projectFiles = $fileEnumerator->compileFileListForPaths([ 137 | $this->config->getTargetDirectory() . 'composer', 138 | ]); 139 | 140 | $phpFiles = array_filter( 141 | $projectFiles->getFiles(), 142 | fn($file) => $file->isPhpFile() 143 | ); 144 | 145 | $phpFilesAbsolutePaths = array_map( 146 | fn($file) => $file->getSourcePath(), 147 | $phpFiles 148 | ); 149 | 150 | $sourceFile = new File(__DIR__); 151 | $composerNamespaceSymbol = new NamespaceSymbol( 152 | 'Composer\\Autoload', 153 | $sourceFile 154 | ); 155 | $composerNamespaceSymbol->setReplacement( 156 | $this->config->getNamespacePrefix() . '\\Composer\\Autoload' 157 | ); 158 | 159 | $discoveredSymbols = new DiscoveredSymbols(); 160 | $discoveredSymbols->add( 161 | $composerNamespaceSymbol 162 | ); 163 | 164 | $projectReplace->replaceInProjectFiles($discoveredSymbols, $phpFilesAbsolutePaths); 165 | } 166 | 167 | /** 168 | * If there is an existing autoloader, it will use the same suffix. If there is not, it pulls the suffix from 169 | * {Composer::getLocker()} and clashes with the existing autoloader. 170 | * 171 | * @see AutoloadGenerator::dump() 412:431 172 | * @see https://github.com/composer/composer/blob/ae208dc1e182bd45d99fcecb956501da212454a1/src/Composer/Autoload/AutoloadGenerator.php#L429 173 | */ 174 | protected function getSuffix(): ?string 175 | { 176 | return !$this->filesystem->fileExists($this->config->getTargetDirectory() . 'autoload.php') 177 | ? bin2hex(random_bytes(16)) 178 | : null; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Pipeline/Autoload/VendorComposerAutoload.php: -------------------------------------------------------------------------------- 1 | config = $config; 34 | $this->fileSystem = $filesystem; 35 | $this->setLogger($logger); 36 | } 37 | 38 | public function addVendorPrefixedAutoloadToVendorAutoload(): void 39 | { 40 | if ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) { 41 | $this->logger->info("Target dir is source dir, no autoload.php to add."); 42 | return; 43 | } 44 | 45 | $composerAutoloadPhpFilepath = $this->config->getVendorDirectory() . 'autoload.php'; 46 | 47 | if (!$this->fileSystem->fileExists($composerAutoloadPhpFilepath)) { 48 | $this->logger->info("No autoload.php found:" . $composerAutoloadPhpFilepath); 49 | return; 50 | } 51 | 52 | $newAutoloadPhpFilepath = $this->config->getTargetDirectory() . 'autoload.php'; 53 | 54 | if (!$this->fileSystem->fileExists($newAutoloadPhpFilepath)) { 55 | $this->logger->warning("No new autoload.php found: " . $newAutoloadPhpFilepath); 56 | } 57 | 58 | $this->logger->info('Modifying original autoload.php to add `' . $newAutoloadPhpFilepath); 59 | 60 | $composerAutoloadPhpFileString = $this->fileSystem->read($composerAutoloadPhpFilepath); 61 | 62 | $newComposerAutoloadPhpFileString = $this->addVendorPrefixedAutoloadToComposerAutoload($composerAutoloadPhpFileString); 63 | 64 | if ($newComposerAutoloadPhpFileString !== $composerAutoloadPhpFileString) { 65 | $this->logger->info('Writing new autoload.php'); 66 | $this->fileSystem->write($composerAutoloadPhpFilepath, $newComposerAutoloadPhpFileString); 67 | } else { 68 | $this->logger->debug('No changes to autoload.php'); 69 | } 70 | } 71 | 72 | /** 73 | * Given the PHP code string for `vendor/autoload.php`, add a `require_once autoload_aliases.php` 74 | * before require autoload_real.php. 75 | */ 76 | public function addAliasesFileToComposer(): void 77 | { 78 | if ($this->isComposerInstalled()) { 79 | $this->logger->info("Strauss installed via Composer, no need to add `autoload_aliases.php` to `vendor/autoload.php`"); 80 | return; 81 | } 82 | 83 | $composerAutoloadPhpFilepath = $this->config->getVendorDirectory() . 'autoload.php'; 84 | 85 | if (!$this->fileSystem->fileExists($composerAutoloadPhpFilepath)) { 86 | // No `vendor/autoload.php` file to add `autoload_aliases.php` to. 87 | $this->logger->error("No autoload.php found: " . $composerAutoloadPhpFilepath); 88 | // TODO: Should probably throw an exception here. 89 | return; 90 | } 91 | 92 | if ($this->isComposerNoDev()) { 93 | $this->logger->notice("Composer was run with `--no-dev`, no need to add `autoload_aliases.php` to `vendor/autoload.php`"); 94 | return; 95 | } 96 | 97 | $this->logger->info('Modifying original autoload.php to add autoload_aliases.php in ' . $this->config->getVendorDirectory()); 98 | 99 | $composerAutoloadPhpFileString = $this->fileSystem->read($composerAutoloadPhpFilepath); 100 | 101 | $newComposerAutoloadPhpFileString = $this->addAliasesFileToComposerAutoload($composerAutoloadPhpFileString); 102 | 103 | if ($newComposerAutoloadPhpFileString !== $composerAutoloadPhpFileString) { 104 | $this->logger->info('Writing new autoload.php'); 105 | $this->fileSystem->write($composerAutoloadPhpFilepath, $newComposerAutoloadPhpFileString); 106 | } else { 107 | $this->logger->debug('No changes to autoload.php'); 108 | } 109 | } 110 | 111 | /** 112 | * Determine is Strauss installed via Composer (otherwise presumably run via phar). 113 | */ 114 | protected function isComposerInstalled(): bool 115 | { 116 | if (!$this->fileSystem->fileExists($this->config->getVendorDirectory() . 'composer/installed.json')) { 117 | return false; 118 | } 119 | 120 | $installedJsonArray = json_decode($this->fileSystem->read($this->config->getVendorDirectory() . 'composer/installed.json'), true); 121 | 122 | return isset($installedJsonArray['dev-package-names']['brianhenryie/strauss']); 123 | } 124 | 125 | /** 126 | * Read `vendor/composer/installed.json` to determine if the composer was run with `--no-dev`. 127 | * 128 | * { 129 | * "packages": [], 130 | * "dev": true, 131 | * "dev-package-names": [] 132 | * } 133 | */ 134 | protected function isComposerNoDev(): bool 135 | { 136 | $installedJson = $this->fileSystem->read($this->config->getVendorDirectory() . 'composer/installed.json'); 137 | $installedJsonArray = json_decode($installedJson, true); 138 | return !$installedJsonArray['dev']; 139 | } 140 | 141 | /** 142 | * This is a very over-engineered way to do a string replace. 143 | * 144 | * `require_once __DIR__ . '/composer/autoload_aliases.php';` 145 | */ 146 | protected function addAliasesFileToComposerAutoload(string $code): string 147 | { 148 | if (false !== strpos($code, '/composer/autoload_aliases.php')) { 149 | $this->logger->info('vendor/autoload.php already includes autoload_aliases.php'); 150 | return $code; 151 | } 152 | 153 | $parser = (new ParserFactory())->createForNewestSupportedVersion(); 154 | try { 155 | $ast = $parser->parse($code); 156 | } catch (Error $error) { 157 | $this->logger->error("Parse error: {$error->getMessage()}"); 158 | return $code; 159 | } 160 | 161 | $traverser = new NodeTraverser(); 162 | $traverser->addVisitor(new class() extends NodeVisitorAbstract { 163 | 164 | public function leaveNode(Node $node) 165 | { 166 | if (get_class($node) === \PhpParser\Node\Stmt\Expression::class) { 167 | $prettyPrinter = new Standard(); 168 | $maybeRequireAutoloadReal = $prettyPrinter->prettyPrintExpr($node->expr); 169 | 170 | // Every `vendor/autoload.php` should have this line. 171 | $target = "require_once __DIR__ . '/composer/autoload_real.php'"; 172 | 173 | // If this node isn't the one we want to insert before, continue. 174 | if ($maybeRequireAutoloadReal !== $target) { 175 | return $node; 176 | } 177 | 178 | // __DIR__ . '/composer/autoload_aliases.php' 179 | $path = new \PhpParser\Node\Expr\BinaryOp\Concat( 180 | new \PhpParser\Node\Scalar\MagicConst\Dir(), 181 | new \PhpParser\Node\Scalar\String_('/composer/autoload_aliases.php') 182 | ); 183 | 184 | // require_once 185 | $requireOnceAutoloadAliases = new Node\Stmt\Expression( 186 | new \PhpParser\Node\Expr\Include_( 187 | $path, 188 | \PhpParser\Node\Expr\Include_::TYPE_REQUIRE_ONCE 189 | ) 190 | ); 191 | 192 | // if(file_exists()){} 193 | $ifFileExistsRequireOnceAutoloadAliases = new \PhpParser\Node\Stmt\If_( 194 | new \PhpParser\Node\Expr\FuncCall( 195 | new \PhpParser\Node\Name('file_exists'), 196 | [ 197 | new \PhpParser\Node\Arg($path) 198 | ], 199 | ), 200 | [ 201 | 'stmts' => [ 202 | $requireOnceAutoloadAliases 203 | ], 204 | ] 205 | ); 206 | 207 | // Add a blank line. Probably not the correct way to do this. 208 | $node->setAttribute('comments', [new \PhpParser\Comment('')]); 209 | $ifFileExistsRequireOnceAutoloadAliases->setAttribute('comments', [new \PhpParser\Comment('')]); 210 | 211 | return [ 212 | $ifFileExistsRequireOnceAutoloadAliases, 213 | $node 214 | ]; 215 | } 216 | return $node; 217 | } 218 | }); 219 | 220 | $modifiedStmts = $traverser->traverse($ast); 221 | 222 | $prettyPrinter = new Standard(); 223 | 224 | return $prettyPrinter->prettyPrintFile($modifiedStmts); 225 | } 226 | 227 | /** 228 | * `require_once __DIR__ . '/../vendor-prefixed/autoload.php';` 229 | */ 230 | protected function addVendorPrefixedAutoloadToComposerAutoload(string $code): string 231 | { 232 | if ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) { 233 | $this->logger->info('Vendor directory is target directory, no autoloader to add.'); 234 | return $code; 235 | } 236 | 237 | $targetDirAutoload = '/' . $this->fileSystem->getRelativePath($this->config->getVendorDirectory(), $this->config->getTargetDirectory()) . 'autoload.php'; 238 | 239 | if (false !== strpos($code, $targetDirAutoload)) { 240 | $this->logger->info('vendor/autoload.php already includes ' . $targetDirAutoload); 241 | return $code; 242 | } 243 | 244 | $parser = (new ParserFactory())->createForNewestSupportedVersion(); 245 | try { 246 | $ast = $parser->parse($code); 247 | } catch (Error $error) { 248 | $this->logger->error("Parse error: {$error->getMessage()}"); 249 | return $code; 250 | } 251 | 252 | $traverser = new NodeTraverser(); 253 | $traverser->addVisitor(new class($targetDirAutoload) extends NodeVisitorAbstract { 254 | 255 | protected bool $added = false; 256 | protected ?string $targetDirectoryAutoload; 257 | public function __construct(?string $targetDirectoryAutoload) 258 | { 259 | $this->targetDirectoryAutoload = $targetDirectoryAutoload; 260 | } 261 | 262 | public function leaveNode(Node $node) 263 | { 264 | if ($this->added) { 265 | return $node; 266 | } 267 | 268 | if (get_class($node) === \PhpParser\Node\Stmt\Expression::class) { 269 | $prettyPrinter = new Standard(); 270 | $nodeText = $prettyPrinter->prettyPrintExpr($node->expr); 271 | 272 | $targets = [ 273 | "require_once __DIR__ . '/composer/autoload_real.php'", 274 | ]; 275 | 276 | if (!in_array($nodeText, $targets)) { 277 | return $node; 278 | } 279 | 280 | // __DIR__ . '../vendor-prefixed/autoload.php' 281 | $path = new \PhpParser\Node\Expr\BinaryOp\Concat( 282 | new \PhpParser\Node\Scalar\MagicConst\Dir(), 283 | new Node\Scalar\String_($this->targetDirectoryAutoload) 284 | ); 285 | 286 | // require_once 287 | $requireOnceStraussAutoload = new Node\Stmt\Expression( 288 | new Node\Expr\Include_( 289 | $path, 290 | Node\Expr\Include_::TYPE_REQUIRE_ONCE 291 | ) 292 | ); 293 | 294 | // if(file_exists()){} 295 | $ifFileExistsRequireOnceStraussAutoload = new \PhpParser\Node\Stmt\If_( 296 | new \PhpParser\Node\Expr\FuncCall( 297 | new \PhpParser\Node\Name('file_exists'), 298 | [ 299 | new \PhpParser\Node\Arg($path) 300 | ], 301 | ), 302 | [ 303 | 'stmts' => [ 304 | $requireOnceStraussAutoload 305 | ], 306 | ] 307 | ); 308 | 309 | // Add a blank line. Probably not the correct way to do this. 310 | $node->setAttribute('comments', [new \PhpParser\Comment('')]); 311 | $ifFileExistsRequireOnceStraussAutoload->setAttribute('comments', [new \PhpParser\Comment('')]); 312 | 313 | $this->added = true; 314 | 315 | return [ 316 | $ifFileExistsRequireOnceStraussAutoload, 317 | $node 318 | ]; 319 | } 320 | return $node; 321 | } 322 | }); 323 | 324 | $modifiedStmts = $traverser->traverse($ast); 325 | 326 | $prettyPrinter = new Standard(); 327 | 328 | return $prettyPrinter->prettyPrintFile($modifiedStmts); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/Pipeline/ChangeEnumerator.php: -------------------------------------------------------------------------------- 1 | config = $config; 34 | $this->filesystem = $filesystem; 35 | $this->setLogger($logger ?? new NullLogger()); 36 | } 37 | 38 | public function determineReplacements(DiscoveredSymbols $discoveredSymbols): void 39 | { 40 | foreach ($discoveredSymbols->getSymbols() as $symbol) { 41 | // TODO: this is a bit of a mess. Should be reconsidered. Previously there was 1-1 relationship between symbols and files. 42 | $symbolSourceFiles = $symbol->getSourceFiles(); 43 | $symbolSourceFile = $symbolSourceFiles[array_key_first($symbolSourceFiles)]; 44 | if ($symbolSourceFile instanceof FileWithDependency) { 45 | if (in_array( 46 | $symbolSourceFile->getDependency()->getPackageName(), 47 | $this->config->getExcludePackagesFromPrefixing(), 48 | true 49 | )) { 50 | continue; 51 | } 52 | 53 | foreach ($this->config->getExcludeFilePatternsFromPrefixing() as $excludeFilePattern) { 54 | // TODO: This source relative path should be from the vendor dir. 55 | // TODO: Should the target path be used here? 56 | if (1 === preg_match($excludeFilePattern, $symbolSourceFile->getSourcePath())) { 57 | continue 2; 58 | } 59 | } 60 | } 61 | 62 | if ($symbol instanceof NamespaceSymbol) { 63 | $namespaceReplacementPatterns = $this->config->getNamespaceReplacementPatterns(); 64 | 65 | // `namespace_prefix` is just a shorthand for a replacement pattern that applies to all namespaces. 66 | 67 | // TODO: Maybe need to preg_quote and add regex delimiters to the patterns here. 68 | foreach ($namespaceReplacementPatterns as $pattern => $replacement) { 69 | if (substr($pattern, 0, 1) !== substr($pattern, -1, 1)) { 70 | unset($namespaceReplacementPatterns[$pattern]); 71 | $pattern = '~'. preg_quote($pattern, '~') . '~'; 72 | $namespaceReplacementPatterns[$pattern] = $replacement; 73 | } 74 | unset($pattern, $replacement); 75 | } 76 | 77 | if (!is_null($this->config->getNamespacePrefix())) { 78 | $stripPattern = '~^('.preg_quote($this->config->getNamespacePrefix(), '~') .'\\\\*)*(.*)~'; 79 | $strippedSymbol = preg_replace( 80 | $stripPattern, 81 | '$2', 82 | $symbol->getOriginalSymbol() 83 | ); 84 | $namespaceReplacementPatterns[ "~(" . preg_quote($this->config->getNamespacePrefix(), '~') . '\\\\*)*' . preg_quote($strippedSymbol, '~') . '~' ] 85 | = "{$this->config->getNamespacePrefix()}\\{$strippedSymbol}"; 86 | unset($stripPattern, $strippedSymbol); 87 | } 88 | 89 | // `namespace_replacement_patterns` should be ordered by priority. 90 | foreach ($namespaceReplacementPatterns as $namespaceReplacementPattern => $replacement) { 91 | $prefixed = preg_replace( 92 | $namespaceReplacementPattern, 93 | $replacement, 94 | $symbol->getOriginalSymbol() 95 | ); 96 | 97 | if ($prefixed !== $symbol->getOriginalSymbol()) { 98 | $symbol->setReplacement($prefixed); 99 | continue 2; 100 | } 101 | } 102 | $this->logger->debug("Namespace {$symbol->getOriginalSymbol()} not changed."); 103 | } 104 | 105 | if ($symbol instanceof ClassSymbol) { 106 | // Don't double-prefix classnames. 107 | if (str_starts_with($symbol->getOriginalSymbol(), $this->config->getClassmapPrefix())) { 108 | continue; 109 | } 110 | 111 | $symbol->setReplacement($this->config->getClassmapPrefix() . $symbol->getOriginalSymbol()); 112 | } 113 | 114 | if ($symbol instanceof FunctionSymbol) { 115 | // TODO: Add its own config option. 116 | $functionPrefix = strtolower($this->config->getClassmapPrefix()); 117 | if (str_starts_with($symbol->getOriginalSymbol(), $functionPrefix)) { 118 | continue; 119 | } 120 | 121 | $symbol->setReplacement($functionPrefix . $symbol->getOriginalSymbol()); 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Pipeline/Cleanup.php: -------------------------------------------------------------------------------- 1 | config = $config; 36 | $this->logger = $logger; 37 | 38 | $this->isDeleteVendorFiles = $config->isDeleteVendorFiles() && $config->getTargetDirectory() !== $config->getVendorDirectory(); 39 | $this->isDeleteVendorPackages = $config->isDeleteVendorPackages() && $config->getTargetDirectory() !== $config->getVendorDirectory(); 40 | 41 | $this->filesystem = $filesystem; 42 | } 43 | 44 | /** 45 | * Maybe delete the source files that were copied (depending on config), 46 | * then delete empty directories. 47 | * 48 | * @param File[] $files 49 | * 50 | * @throws FilesystemException 51 | */ 52 | public function cleanup(array $files): void 53 | { 54 | if (!$this->isDeleteVendorPackages && !$this->isDeleteVendorFiles) { 55 | $this->logger->info('No cleanup required.'); 56 | return; 57 | } 58 | 59 | $this->logger->info('Beginning cleanup.'); 60 | 61 | if ($this->isDeleteVendorPackages) { 62 | $this->doIsDeleteVendorPackages($files); 63 | } elseif ($this->isDeleteVendorFiles) { 64 | $this->doIsDeleteVendorFiles($files); 65 | } 66 | 67 | $this->deleteEmptyDirectories($files); 68 | } 69 | 70 | /** @param array $flatDependencyTree */ 71 | public function cleanupVendorInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void 72 | { 73 | $installedJson = new InstalledJson( 74 | $this->config, 75 | $this->filesystem, 76 | $this->logger 77 | ); 78 | 79 | if ($this->config->getTargetDirectory() !== $this->config->getVendorDirectory() 80 | && !$this->config->isDeleteVendorFiles() && !$this->config->isDeleteVendorPackages() 81 | ) { 82 | $installedJson->createAndCleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols); 83 | } elseif ($this->config->getTargetDirectory() !== $this->config->getVendorDirectory() 84 | && 85 | ($this->config->isDeleteVendorFiles() ||$this->config->isDeleteVendorPackages()) 86 | ) { 87 | $installedJson->createAndCleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols); 88 | $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols); 89 | } elseif ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) { 90 | $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols); 91 | } 92 | } 93 | 94 | /** 95 | * @throws FilesystemException 96 | */ 97 | protected function deleteEmptyDirectories(array $files) 98 | { 99 | $this->logger->info('Deleting empty directories.'); 100 | 101 | $sourceFiles = array_map( 102 | fn($file) => $file->getSourcePath(), 103 | $files 104 | ); 105 | 106 | // Get the root folders of the moved files. 107 | $rootSourceDirectories = []; 108 | foreach ($sourceFiles as $sourceFile) { 109 | $arr = explode("/", $sourceFile, 2); 110 | $dir = $arr[0]; 111 | $rootSourceDirectories[ $dir ] = $dir; 112 | } 113 | $rootSourceDirectories = array_map( 114 | function (string $path): string { 115 | return $this->config->getVendorDirectory() . $path; 116 | }, 117 | array_keys($rootSourceDirectories) 118 | ); 119 | 120 | foreach ($rootSourceDirectories as $rootSourceDirectory) { 121 | if (!$this->filesystem->directoryExists($rootSourceDirectory) || is_link($rootSourceDirectory)) { 122 | continue; 123 | } 124 | 125 | $dirList = $this->filesystem->listContents($rootSourceDirectory, true); 126 | 127 | $allFilePaths = array_map( 128 | fn($file) => $file->path(), 129 | $dirList->toArray() 130 | ); 131 | 132 | // Sort by longest path first, so subdirectories are deleted before the parent directories are checked. 133 | usort( 134 | $allFilePaths, 135 | fn($a, $b) => count(explode('/', $b)) - count(explode('/', $a)) 136 | ); 137 | 138 | foreach ($allFilePaths as $filePath) { 139 | if ($this->filesystem->directoryExists($filePath) 140 | && $this->dirIsEmpty($filePath) 141 | ) { 142 | $this->logger->debug('Deleting empty directory ' . $filePath); 143 | $this->filesystem->deleteDirectory($filePath); 144 | } 145 | } 146 | } 147 | 148 | // foreach ($this->filesystem->listContents($this->getAbsoluteVendorDir()) as $dirEntry) { 149 | // if ($dirEntry->isDir() && $this->dirIsEmpty($dirEntry->path()) && !is_link($dirEntry->path())) { 150 | // $this->logger->info('Deleting empty directory ' . $dirEntry->path()); 151 | // $this->filesystem->deleteDirectory($dirEntry->path()); 152 | // } else { 153 | // $this->logger->debug('Skipping non-empty directory ' . $dirEntry->path()); 154 | // } 155 | // } 156 | } 157 | 158 | // TODO: Move to FileSystem class. 159 | protected function dirIsEmpty(string $dir): bool 160 | { 161 | // TODO BUG this deletes directories with only symlinks inside. How does it behave with hidden files? 162 | return empty($this->filesystem->listContents($dir)->toArray()); 163 | } 164 | 165 | /** 166 | * @param array $files 167 | */ 168 | protected function doIsDeleteVendorPackages(array $files) 169 | { 170 | $this->logger->info('Deleting original vendor packages.'); 171 | 172 | $packages = []; 173 | foreach ($files as $file) { 174 | if ($file instanceof FileWithDependency) { 175 | $packages[ $file->getDependency()->getPackageName() ] = $file->getDependency(); 176 | } 177 | } 178 | 179 | /** @var ComposerPackage $package */ 180 | foreach ($packages as $package) { 181 | // Normal package. 182 | if ($this->filesystem->isSubDirOf($this->config->getVendorDirectory(), $package->getPackageAbsolutePath())) { 183 | $this->logger->info('Deleting ' . $package->getPackageAbsolutePath()); 184 | 185 | $this->filesystem->deleteDirectory($package->getPackageAbsolutePath()); 186 | } else { 187 | // TODO: log _where_ the symlink is pointing to. 188 | $this->logger->info('Deleting symlink at ' . $package->getRelativePath()); 189 | 190 | // If it's a symlink, remove the symlink in the directory 191 | $symlinkPath = 192 | rtrim( 193 | $this->config->getVendorDirectory() . $package->getRelativePath(), 194 | '/' 195 | ); 196 | 197 | if (false !== strpos('WIN', PHP_OS)) { 198 | /** 199 | * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory. 200 | * "On windows, take care that `is_link()` returns false for Junctions." 201 | * 202 | * @see https://www.php.net/manual/en/function.is-link.php#113263 203 | * @see https://stackoverflow.com/a/18262809/336146 204 | */ 205 | rmdir($symlinkPath); 206 | } else { 207 | unlink($symlinkPath); 208 | } 209 | } 210 | if ($this->dirIsEmpty(dirname($package->getPackageAbsolutePath()))) { 211 | $this->logger->info('Deleting empty directory ' . dirname($package->getPackageAbsolutePath())); 212 | $this->filesystem->deleteDirectory(dirname($package->getPackageAbsolutePath())); 213 | } 214 | } 215 | } 216 | 217 | /** 218 | * @param array $files 219 | * 220 | * @throws FilesystemException 221 | */ 222 | public function doIsDeleteVendorFiles(array $files) 223 | { 224 | $this->logger->info('Deleting original vendor files.'); 225 | 226 | foreach ($files as $file) { 227 | if (! $file->isDoDelete()) { 228 | $this->logger->debug('Skipping/preserving ' . $file->getSourcePath()); 229 | continue; 230 | } 231 | 232 | $sourceRelativePath = $file->getSourcePath(); 233 | 234 | $this->logger->info('Deleting ' . $sourceRelativePath); 235 | 236 | $this->filesystem->delete($file->getSourcePath()); 237 | 238 | $file->setDidDelete(true); 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Pipeline/Copier.php: -------------------------------------------------------------------------------- 1 | files = $files; 51 | $this->config = $config; 52 | $this->logger = $logger; 53 | $this->filesystem = $filesystem; 54 | } 55 | 56 | /** 57 | * If the target dir does not exist, create it. 58 | * If it already exists, delete any files we're about to copy. 59 | * 60 | * @return void 61 | * @throws FilesystemException 62 | */ 63 | public function prepareTarget(): void 64 | { 65 | if (! $this->filesystem->directoryExists($this->config->getTargetDirectory())) { 66 | $this->logger->info('Creating directory at ' . $this->config->getTargetDirectory()); 67 | $this->filesystem->createDirectory($this->config->getTargetDirectory()); 68 | } 69 | 70 | foreach ($this->files->getFiles() as $file) { 71 | if (!$file->isDoCopy()) { 72 | $this->logger->debug('Skipping ' . $file->getSourcePath()); 73 | continue; 74 | } 75 | 76 | $targetAbsoluteFilepath = $file->getAbsoluteTargetPath(); 77 | 78 | if ($this->filesystem->fileExists($targetAbsoluteFilepath)) { 79 | $this->logger->info('Deleting existing destination file at ' . $targetAbsoluteFilepath); 80 | $this->filesystem->delete($targetAbsoluteFilepath); 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * @throws FilesystemException 87 | */ 88 | public function copy(): void 89 | { 90 | $this->logger->notice('Copying files'); 91 | 92 | /** 93 | * @var File $file 94 | */ 95 | foreach ($this->files->getFiles() as $file) { 96 | if (!$file->isDoCopy()) { 97 | $this->logger->debug('Skipping ' . $file->getSourcePath()); 98 | continue; 99 | } 100 | 101 | $sourceAbsoluteFilepath = $file->getSourcePath(); 102 | $targetAbsolutePath = $file->getAbsoluteTargetPath(); 103 | 104 | if ($this->filesystem->directoryExists($sourceAbsoluteFilepath)) { 105 | $this->logger->info(sprintf( 106 | 'Creating directory at %s', 107 | $file->getAbsoluteTargetPath() 108 | )); 109 | $this->filesystem->createDirectory($targetAbsolutePath); 110 | } else { 111 | $this->logger->info(sprintf( 112 | 'Copying file to %s', 113 | $file->getAbsoluteTargetPath() 114 | )); 115 | $this->filesystem->copy($sourceAbsoluteFilepath, $targetAbsolutePath); 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Pipeline/DependenciesEnumerator.php: -------------------------------------------------------------------------------- 1 | */ 34 | protected array $flatDependencyTree = array(); 35 | 36 | /** 37 | * Record the files autoloaders for later use in building our own autoloader. 38 | * 39 | * Package-name: [ dir1, file1, file2, ... ]. 40 | * 41 | * @var array 42 | */ 43 | protected array $filesAutoloaders = []; 44 | 45 | /** 46 | * @var array{}|array,classmap?:array,"psr-4":array>}> $overrideAutoload 47 | */ 48 | protected array $overrideAutoload = array(); 49 | protected StraussConfig $config; 50 | 51 | /** 52 | * Constructor. 53 | * 54 | * @param StraussConfig $config 55 | */ 56 | public function __construct( 57 | StraussConfig $config, 58 | FileSystem $filesystem, 59 | ?LoggerInterface $logger = null 60 | ) { 61 | $this->overrideAutoload = $config->getOverrideAutoload(); 62 | $this->requiredPackageNames = $config->getPackages(); 63 | 64 | $this->filesystem = $filesystem; 65 | $this->config = $config; 66 | 67 | $this->setLogger($logger ?? new NullLogger()); 68 | } 69 | 70 | /** 71 | * @return array Packages indexed by package name. 72 | * @throws Exception 73 | */ 74 | public function getAllDependencies(): array 75 | { 76 | $this->recursiveGetAllDependencies($this->requiredPackageNames); 77 | 78 | return $this->flatDependencyTree; 79 | } 80 | 81 | /** 82 | * @param string[] $requiredPackageNames 83 | */ 84 | protected function recursiveGetAllDependencies(array $requiredPackageNames): void 85 | { 86 | $requiredPackageNames = array_filter($requiredPackageNames, array( $this, 'removeVirtualPackagesFilter' )); 87 | 88 | foreach ($requiredPackageNames as $requiredPackageName) { 89 | // Avoid infinite recursion. 90 | if (isset($this->flatDependencyTree[$requiredPackageName])) { 91 | continue; 92 | } 93 | 94 | $packageComposerFile = sprintf( 95 | '%s%s/composer.json', 96 | $this->config->getVendorDirectory(), 97 | $requiredPackageName 98 | ); 99 | $packageComposerFile = str_replace('mem://', '/', $packageComposerFile); 100 | 101 | $overrideAutoload = $this->overrideAutoload[ $requiredPackageName ] ?? null; 102 | 103 | if ($this->filesystem->fileExists($packageComposerFile)) { 104 | $requiredComposerPackage = ComposerPackage::fromFile($packageComposerFile, $overrideAutoload); 105 | } else { 106 | // Some packages download with NO `composer.json`! E.g. woocommerce/action-scheduler. 107 | // Some packages download to a different directory than the package name. 108 | $this->logger->debug('Could not find ' . $requiredPackageName . '\'s composer.json in vendor dir, trying composer.lock'); 109 | 110 | // TODO: These (.json, .lock) should be read once and reused. 111 | $composerJsonString = $this->filesystem->read($this->config->getProjectDirectory() . 'composer.json'); 112 | $composerJson = json_decode($composerJsonString, true); 113 | 114 | if (isset($composerJson['provide']) && in_array($requiredPackageName, array_keys($composerJson['provide']))) { 115 | $this->logger->info('Skipping ' . $requiredPackageName . ' as it is in the composer.json provide list'); 116 | continue; 117 | } 118 | 119 | $composerLockString = $this->filesystem->read($this->config->getProjectDirectory() . 'composer.lock'); 120 | $composerLock = json_decode($composerLockString, true); 121 | 122 | $requiredPackageComposerJson = null; 123 | foreach ($composerLock['packages'] as $packageJson) { 124 | if ($requiredPackageName === $packageJson['name']) { 125 | $requiredPackageComposerJson = $packageJson; 126 | break; 127 | } 128 | } 129 | 130 | if (is_null($requiredPackageComposerJson)) { 131 | // e.g. composer-plugin-api. 132 | $this->logger->info('Skipping ' . $requiredPackageName . ' as it is not in composer.lock'); 133 | continue; 134 | } 135 | 136 | if (!isset($requiredPackageComposerJson['autoload']) 137 | && empty($requiredPackageComposerJson['require']) 138 | && $requiredPackageComposerJson['type'] != 'metapackage' 139 | && ! $this->filesystem->directoryExists(dirname($packageComposerFile)) 140 | ) { 141 | // e.g. symfony/polyfill-php72 when installed on PHP 7.2 or later. 142 | $this->logger->info('Skipping ' . $requiredPackageName . ' as it is has no autoload key (possibly a polyfill unnecessary for this version of PHP).'); 143 | continue; 144 | } 145 | 146 | $requiredComposerPackage = ComposerPackage::fromComposerJsonArray($requiredPackageComposerJson, $overrideAutoload); 147 | } 148 | 149 | $this->logger->info('Analysing package ' . $requiredComposerPackage->getPackageName()); 150 | $this->flatDependencyTree[$requiredComposerPackage->getPackageName()] = $requiredComposerPackage; 151 | 152 | $nextRequiredPackageNames = $requiredComposerPackage->getRequiresNames(); 153 | 154 | if (0 !== count($nextRequiredPackageNames)) { 155 | $packageRequiresString = $requiredComposerPackage->getPackageName() . ' requires packages: '; 156 | $this->logger->debug($packageRequiresString . implode(', ', $nextRequiredPackageNames)); 157 | } else { 158 | $this->logger->debug($requiredComposerPackage->getPackageName() . ' requires no packages.'); 159 | continue; 160 | } 161 | 162 | $newPackages = array_diff($nextRequiredPackageNames, array_keys($this->flatDependencyTree)); 163 | 164 | $newPackagesString = implode(', ', $newPackages); 165 | if (!empty($newPackagesString)) { 166 | $this->logger->debug(sprintf( 167 | 'New packages: %s%s', 168 | str_repeat(' ', strlen($packageRequiresString) - strlen('New packages: ')), 169 | $newPackagesString 170 | )); 171 | } else { 172 | $this->logger->debug('No new packages.'); 173 | continue; 174 | } 175 | 176 | $this->recursiveGetAllDependencies($newPackages); 177 | } 178 | } 179 | 180 | /** 181 | * Get the recorded files autoloaders. 182 | * 183 | * @return array> 184 | */ 185 | public function getAllFilesAutoloaders(): array 186 | { 187 | $filesAutoloaders = array(); 188 | foreach ($this->flatDependencyTree as $packageName => $composerPackage) { 189 | if (isset($composerPackage->getAutoload()['files'])) { 190 | $filesAutoloaders[$packageName] = $composerPackage->getAutoload()['files']; 191 | } 192 | } 193 | return $filesAutoloaders; 194 | } 195 | 196 | /** 197 | * Unset PHP, ext-*, ... 198 | * 199 | * @param string $requiredPackageName 200 | */ 201 | protected function removeVirtualPackagesFilter(string $requiredPackageName): bool 202 | { 203 | return ! ( 204 | 0 === strpos($requiredPackageName, 'ext') 205 | // E.g. `php`, `php-64bit`. 206 | || (0 === strpos($requiredPackageName, 'php') && false === strpos($requiredPackageName, '/')) 207 | || in_array($requiredPackageName, $this->virtualPackages) 208 | ); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Pipeline/FileCopyScanner.php: -------------------------------------------------------------------------------- 1 | config = $config; 45 | $this->fileSystem = $filesystem; 46 | 47 | $this->setLogger($logger ?? new NullLogger()); 48 | } 49 | 50 | public function scanFiles(DiscoveredFiles $files): void 51 | { 52 | /** @var FileBase $file */ 53 | foreach ($files->getFiles() as $file) { 54 | $copy = true; 55 | 56 | if ($file instanceof FileWithDependency) { 57 | if (in_array($file->getDependency()->getPackageName(), $this->config->getExcludePackagesFromCopy(), true)) { 58 | $this->logger->debug("File {$file->getSourcePath()} will not be copied because {$file->getDependency()->getPackageName()} is excluded from copy."); 59 | $copy = false; 60 | } 61 | } 62 | 63 | if ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) { 64 | $this->logger->debug("The target directory is the same as the vendor directory."); // TODO: surely this should be outside the loop/class. 65 | $copy = false; 66 | } 67 | 68 | /** @var DiscoveredSymbol $symbol */ 69 | foreach ($file->getDiscoveredSymbols() as $symbol) { 70 | foreach ($this->config->getExcludeNamespacesFromCopy() as $namespace) { 71 | if (in_array($file->getSourcePath(), array_keys($symbol->getSourceFiles()), true) 72 | && $symbol instanceof NamespaceSymbol 73 | && str_starts_with($symbol->getOriginalSymbol(), $namespace) 74 | ) { 75 | $this->logger->debug("File {$file->getSourcePath()} will not be copied because namespace {$namespace} is excluded from copy."); 76 | $copy = false; 77 | } 78 | } 79 | } 80 | 81 | $filePath = $file->getSourcePath(); 82 | foreach ($this->config->getExcludeFilePatternsFromCopy() as $pattern) { 83 | if (1 == preg_match($pattern, $filePath)) { 84 | $this->logger->debug("File {$file->getSourcePath()} will not be copied because it matches pattern {$pattern}."); 85 | $copy = false; 86 | } 87 | } 88 | 89 | if ($copy) { 90 | $this->logger->debug("Marking file {$file->getSourcePath()} to be copied."); 91 | } 92 | 93 | $file->setDoCopy($copy); 94 | 95 | $target = $copy && $file instanceof FileWithDependency 96 | ? $this->config->getTargetDirectory() . $file->getVendorRelativePath() 97 | : $file->getSourcePath(); 98 | 99 | $file->setAbsoluteTargetPath($target); 100 | 101 | $shouldDelete = $this->config->isDeleteVendorFiles() && ! $this->fileSystem->isSymlinkedFile($file); 102 | $file->setDoDelete($shouldDelete); 103 | }; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Pipeline/FileEnumerator.php: -------------------------------------------------------------------------------- 1 | 46 | */ 47 | protected array $filesAutoloaders = []; 48 | 49 | protected FileEnumeratorConfig $config; 50 | 51 | /** 52 | * Copier constructor. 53 | */ 54 | public function __construct( 55 | FileEnumeratorConfig $config, 56 | FileSystem $filesystem, 57 | ?LoggerInterface $logger = null 58 | ) { 59 | $this->discoveredFiles = new DiscoveredFiles(); 60 | 61 | $this->vendorDir = $config->getVendorDirectory(); 62 | 63 | $this->config = $config; 64 | 65 | $this->excludeNamespaces = $config->getExcludeNamespacesFromCopy(); 66 | $this->excludePackageNames = $config->getExcludePackagesFromCopy(); 67 | $this->excludeFilePatterns = $config->getExcludeFilePatternsFromCopy(); 68 | 69 | $this->filesystem = $filesystem; 70 | 71 | $this->logger = $logger ?? new NullLogger(); 72 | } 73 | 74 | /** 75 | * Read the autoload keys of the dependencies and generate a list of the files referenced. 76 | * 77 | * Includes all files in the directories and subdirectories mentioned in the autoloaders. 78 | * 79 | * @param ComposerPackage[] $dependencies 80 | */ 81 | public function compileFileListForDependencies(array $dependencies): DiscoveredFiles 82 | { 83 | foreach ($dependencies as $dependency) { 84 | if (in_array($dependency->getPackageName(), $this->excludePackageNames)) { 85 | $this->logger->info("Excluding package " . $dependency->getPackageName()); 86 | continue; 87 | } 88 | $this->logger->info("Scanning for files for package " . $dependency->getPackageName()); 89 | 90 | /** 91 | * Where $dependency->autoload is ~ 92 | * 93 | * [ "psr-4" => [ "BrianHenryIE\Strauss" => "src" ] ] 94 | * Exclude "exclude-from-classmap" 95 | * @see https://getcomposer.org/doc/04-schema.md#exclude-files-from-classmaps 96 | */ 97 | $autoloaders = array_filter($dependency->getAutoload(), function ($type) { 98 | return 'exclude-from-classmap' !== $type; 99 | }, ARRAY_FILTER_USE_KEY); 100 | 101 | foreach ($autoloaders as $type => $value) { 102 | // Might have to switch/case here. 103 | 104 | if ('files' === $type) { 105 | // TODO: This is not in use. 106 | $this->filesAutoloaders[$dependency->getRelativePath()] = $value; 107 | } 108 | 109 | foreach ($value as $namespace => $namespace_relative_paths) { 110 | if (!empty($namespace) && in_array($namespace, $this->excludeNamespaces)) { 111 | $this->logger->info("Excluding namespace " . $namespace); 112 | continue; 113 | } 114 | 115 | $namespace_relative_paths = (array) $namespace_relative_paths; 116 | // if (! is_array($namespace_relative_paths)) { 117 | // $namespace_relative_paths = array( $namespace_relative_paths ); 118 | // } 119 | 120 | foreach ($namespace_relative_paths as $namespaceRelativePath) { 121 | $sourceAbsoluteDirPath = in_array($namespaceRelativePath, ['.','./']) 122 | ? $dependency->getPackageAbsolutePath() 123 | : $dependency->getPackageAbsolutePath() . $namespaceRelativePath; 124 | 125 | if ($this->filesystem->directoryExists($sourceAbsoluteDirPath)) { 126 | $fileList = $this->filesystem->listContents($sourceAbsoluteDirPath, true); 127 | $actualFileList = $fileList->toArray(); 128 | 129 | foreach ($actualFileList as $foundFile) { 130 | $sourceAbsoluteFilepath = '/'. $foundFile->path(); 131 | // No need to record the directory itself. 132 | if (!$this->filesystem->fileExists($sourceAbsoluteFilepath) 133 | || 134 | $this->filesystem->directoryExists($sourceAbsoluteFilepath) 135 | ) { 136 | continue; 137 | } 138 | 139 | $this->addFileWithDependency( 140 | $dependency, 141 | $sourceAbsoluteFilepath, 142 | $type 143 | ); 144 | } 145 | } else { 146 | $this->addFileWithDependency($dependency, $sourceAbsoluteDirPath, $type); 147 | } 148 | } 149 | } 150 | } 151 | } 152 | 153 | $this->discoveredFiles->sort(); 154 | return $this->discoveredFiles; 155 | } 156 | 157 | /** 158 | * @param ComposerPackage $dependency 159 | * @param string $sourceAbsoluteFilepath 160 | * @param string $autoloaderType 161 | * 162 | * @throws FilesystemException 163 | * @uses \BrianHenryIE\Strauss\Files\DiscoveredFiles::add() 164 | * 165 | */ 166 | protected function addFileWithDependency( 167 | ComposerPackage $dependency, 168 | string $sourceAbsoluteFilepath, 169 | string $autoloaderType 170 | ): void { 171 | $vendorRelativePath = substr( 172 | $sourceAbsoluteFilepath, 173 | strpos($sourceAbsoluteFilepath, $dependency->getRelativePath() ?: 0) 174 | ); 175 | 176 | if ($vendorRelativePath === $sourceAbsoluteFilepath) { 177 | $vendorRelativePath = $dependency->getRelativePath() . str_replace($dependency->getPackageAbsolutePath(), '', $sourceAbsoluteFilepath); 178 | } 179 | 180 | $isOutsideProjectDir = 0 !== strpos($sourceAbsoluteFilepath, $this->config->getVendorDirectory()); 181 | 182 | /** @var FileWithDependency $f */ 183 | $f = $this->discoveredFiles->getFile($sourceAbsoluteFilepath) 184 | ?? new FileWithDependency($dependency, $vendorRelativePath, $sourceAbsoluteFilepath); 185 | 186 | $f->setAbsoluteTargetPath($this->config->getVendorDirectory() . $vendorRelativePath); 187 | 188 | $f->addAutoloader($autoloaderType); 189 | $f->setDoDelete($isOutsideProjectDir); 190 | 191 | foreach ($this->excludeFilePatterns as $excludePattern) { 192 | if (1 === preg_match($excludePattern, $vendorRelativePath)) { 193 | $f->setDoCopy(false); 194 | } 195 | } 196 | 197 | $this->discoveredFiles->add($f); 198 | 199 | $this->logger->info("Found file " . $f->getAbsoluteTargetPath()); 200 | } 201 | 202 | /** 203 | * @param string[] $paths 204 | */ 205 | public function compileFileListForPaths(array $paths): DiscoveredFiles 206 | { 207 | $absoluteFilePaths = $this->filesystem->findAllFilesAbsolutePaths($paths); 208 | 209 | foreach ($absoluteFilePaths as $sourceAbsolutePath) { 210 | $f = $this->discoveredFiles->getFile($sourceAbsolutePath) 211 | ?? new File($sourceAbsolutePath); 212 | 213 | $this->discoveredFiles->add($f); 214 | } 215 | 216 | $this->discoveredFiles->sort(); 217 | return $this->discoveredFiles; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Pipeline/FileSymbolScanner.php: -------------------------------------------------------------------------------- 1 | discoveredSymbols = new DiscoveredSymbols(); 55 | $this->excludeNamespacesFromPrefixing = $config->getExcludeNamespacesFromPrefixing(); 56 | 57 | $this->filesystem = $filesystem; 58 | $this->logger = $logger ?? new NullLogger(); 59 | } 60 | 61 | protected function add(DiscoveredSymbol $symbol): void 62 | { 63 | $this->discoveredSymbols->add($symbol); 64 | 65 | $level = in_array($symbol->getOriginalSymbol(), $this->loggedSymbols) ? 'debug' : 'info'; 66 | $newText = in_array($symbol->getOriginalSymbol(), $this->loggedSymbols) ? '' : 'new '; 67 | $noNewText = in_array($symbol->getOriginalSymbol(), $this->loggedSymbols) ? ' ' : ''; 68 | 69 | $this->loggedSymbols[] = $symbol->getOriginalSymbol(); 70 | 71 | switch (get_class($symbol)) { 72 | case NamespaceSymbol::class: 73 | $this->logger->log($level, "Found {$newText}namespace: {$noNewText}" . $symbol->getOriginalSymbol()); 74 | break; 75 | case ConstantSymbol::class: 76 | $this->logger->log($level, "Found {$newText}constant: {$noNewText}" . $symbol->getOriginalSymbol()); 77 | break; 78 | case ClassSymbol::class: 79 | $this->logger->log($level, "Found {$newText}class. {$noNewText}" . $symbol->getOriginalSymbol()); 80 | break; 81 | case FunctionSymbol::class: 82 | $this->logger->log($level, "Found {$newText}function {$noNewText}" . $symbol->getOriginalSymbol()); 83 | break; 84 | default: 85 | $this->logger->log($level, "Found {$newText} " . get_class($symbol) . $noNewText . ' ' . $symbol->getOriginalSymbol()); 86 | } 87 | } 88 | 89 | /** 90 | * @param DiscoveredFiles $files 91 | */ 92 | public function findInFiles(DiscoveredFiles $files): DiscoveredSymbols 93 | { 94 | foreach ($files->getFiles() as $file) { 95 | if (!$file->isPhpFile()) { 96 | $this->logger->debug('Skipping non-PHP file: ' . $file->getSourcePath()); 97 | continue; 98 | } 99 | 100 | $this->logger->info('Scanning file: ' . $file->getSourcePath()); 101 | $this->find( 102 | $this->filesystem->read($file->getSourcePath()), 103 | $file 104 | ); 105 | } 106 | 107 | return $this->discoveredSymbols; 108 | } 109 | 110 | /** 111 | * TODO: Don't use preg_replace_callback! 112 | * 113 | * @uses self::addDiscoveredNamespaceChange() 114 | * @uses self::addDiscoveredClassChange() 115 | */ 116 | protected function find(string $contents, File $file): void 117 | { 118 | // If the entire file is under one namespace, all we want is the namespace. 119 | // If there were more than one namespace, it would appear as `namespace MyNamespace { ...`, 120 | // a file with only a single namespace will appear as `namespace MyNamespace;`. 121 | $singleNamespacePattern = '/ 122 | ([0-9A-Za-z_\x7f-\xff\\\\]+)[\s\n]*; # Match a single namespace in the file. 125 | /x'; // # x: ignore whitespace in regex. 126 | if (1 === preg_match($singleNamespacePattern, $contents, $matches)) { 127 | $this->addDiscoveredNamespaceChange($matches['namespace'], $file); 128 | 129 | return; 130 | } 131 | 132 | if (0 < preg_match_all('/\s*define\s*\(\s*["\']([^"\']*)["\']\s*,\s*["\'][^"\']*["\']\s*\)\s*;/', $contents, $constants)) { 133 | foreach ($constants[1] as $constant) { 134 | $constantObj = new ConstantSymbol($constant, $file); 135 | $this->add($constantObj); 136 | } 137 | } 138 | 139 | // TODO traits 140 | 141 | // TODO: Is the ";" in this still correct since it's being taken care of in the regex just above? 142 | // Looks like with the preceding regex, it will never match. 143 | 144 | preg_replace_callback( 145 | ' 146 | ~ # Start the pattern 147 | /\*[\s\S]*?\*/ | # Skip multiline comments 148 | \s*//.* | # Skip single line comments 149 | [\r\n]*\s*namespace\s+([a-zA-Z0-9_\x7f-\xff\\\\]+)[;{\s\n]{1}[\s\S]*?(?=namespace|$) 150 | # Look for a preceding namespace declaration, 151 | # followed by a semicolon, open curly bracket, space or new line 152 | # up until a 153 | # potential second namespace declaration or end of file. 154 | # if found, match that much before continuing the search on 155 | | # the remainder of the string. 156 | \s* # Whitespace is allowed before 157 | (?:abstract\sclass|class|interface)\s+ # Look behind for class, abstract class, interface 158 | ([a-zA-Z0-9_\x7f-\xff]+) # Match the word until the first non-classname-valid character 159 | \s? # Allow a space after 160 | (?:{|extends|implements|\n|$) # Class declaration can be followed by {, extends, implements 161 | # or a new line 162 | ~x', // # x: ignore whitespace in regex. 163 | function ($matches) use ($file) { 164 | 165 | // If we're inside a namespace other than the global namespace: 166 | if (1 === preg_match('/^\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) { 167 | $this->addDiscoveredNamespaceChange($matches[1], $file); 168 | 169 | return $matches[0]; 170 | } 171 | 172 | if (count($matches) < 3) { 173 | return $matches[0]; 174 | } 175 | 176 | // TODO: Why is this [2] and not [1] (which seems to be always empty). 177 | $this->addDiscoveredClassChange($matches[2], $file); 178 | 179 | return $matches[0]; 180 | }, 181 | $contents 182 | ); 183 | 184 | $parser = (new ParserFactory())->createForNewestSupportedVersion(); 185 | $ast = $parser->parse($contents); 186 | 187 | $traverser = new NodeTraverser(); 188 | $visitor = new class extends \PhpParser\NodeVisitorAbstract { 189 | protected array $functions = []; 190 | public function enterNode(Node $node) 191 | { 192 | if ($node instanceof Node\Stmt\Function_) { 193 | $this->functions[] = $node->name->name; 194 | } 195 | return $node; 196 | } 197 | 198 | /** 199 | * @return string[] Function names. 200 | */ 201 | public function getFunctions(): array 202 | { 203 | return $this->functions; 204 | } 205 | }; 206 | $traverser->addVisitor($visitor); 207 | 208 | /** @var Node $node */ 209 | foreach ((array) $ast as $node) { 210 | $traverser->traverse([ $node ]); 211 | } 212 | foreach ($visitor->getFunctions() as $functionName) { 213 | if (in_array($functionName, $this->getBuiltIns())) { 214 | continue; 215 | } 216 | $functionSymbol = new FunctionSymbol($functionName, $file); 217 | $this->add($functionSymbol); 218 | } 219 | } 220 | 221 | protected function addDiscoveredClassChange(string $classname, File $file): void 222 | { 223 | // TODO: This should be included but marked not to prefix. 224 | if (in_array($classname, $this->getBuiltIns())) { 225 | return; 226 | } 227 | 228 | $classSymbol = new ClassSymbol($classname, $file); 229 | $this->add($classSymbol); 230 | } 231 | 232 | protected function addDiscoveredNamespaceChange(string $namespace, File $file): void 233 | { 234 | 235 | foreach ($this->excludeNamespacesFromPrefixing as $excludeNamespace) { 236 | if (0 === strpos($namespace, $excludeNamespace)) { 237 | // TODO: Log. 238 | return; 239 | } 240 | } 241 | 242 | $namespaceObj = $this->discoveredSymbols->getNamespace($namespace); 243 | if ($namespaceObj) { 244 | $namespaceObj->addSourceFile($file); 245 | $file->addDiscoveredSymbol($namespaceObj); 246 | return; 247 | } else { 248 | $namespaceObj = new NamespaceSymbol($namespace, $file); 249 | } 250 | 251 | $this->add($namespaceObj); 252 | } 253 | 254 | /** 255 | * Get a list of PHP built-in classes etc. so they are not prefixed. 256 | * 257 | * Polyfilled classes were being prefixed, but the polyfills are only active when the PHP version is below X, 258 | * so calls to those prefixed polyfilled classnames would fail on newer PHP versions. 259 | * 260 | * NB: This list is not exhaustive. Any unloaded PHP extensions are not included. 261 | * 262 | * @see https://github.com/BrianHenryIE/strauss/issues/79 263 | * 264 | * ``` 265 | * array_filter( 266 | * get_declared_classes(), 267 | * function(string $className): bool { 268 | * $reflector = new \ReflectionClass($className); 269 | * return empty($reflector->getFileName()); 270 | * } 271 | * ); 272 | * ``` 273 | * 274 | * @return string[] 275 | */ 276 | protected function getBuiltIns(): array 277 | { 278 | if (empty($this->builtIns)) { 279 | $this->loadBuiltIns(); 280 | } 281 | 282 | return $this->builtIns; 283 | } 284 | 285 | /** 286 | * Load the file containing the built-in PHP classes etc. and flatten to a single array of strings and store. 287 | */ 288 | protected function loadBuiltIns(): void 289 | { 290 | $builtins = include __DIR__ . '/FileSymbol/builtinsymbols.php'; 291 | 292 | $flatArray = array(); 293 | array_walk_recursive( 294 | $builtins, 295 | function ($array) use (&$flatArray) { 296 | if (is_array($array)) { 297 | $flatArray = array_merge($flatArray, array_values($array)); 298 | } else { 299 | $flatArray[] = $array; 300 | } 301 | } 302 | ); 303 | 304 | $this->builtIns = $flatArray; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/Pipeline/Licenser.php: -------------------------------------------------------------------------------- 1 | dependencies = $dependencies; 71 | $this->author = $author; 72 | 73 | $this->includeModifiedDate = $config->isIncludeModifiedDate(); 74 | $this->includeAuthor = $config->isIncludeAuthor(); 75 | 76 | $this->filesystem = $filesystem; 77 | 78 | $this->config = $config; 79 | 80 | $this->setLogger($logger ?? new NullLogger()); 81 | } 82 | 83 | /** 84 | * @throws FilesystemException 85 | */ 86 | public function copyLicenses(): void 87 | { 88 | $this->findLicenseFiles(); 89 | 90 | foreach ($this->getDiscoveredLicenseFiles() as $licenseFile) { 91 | $targetLicenseFile = str_replace( 92 | $this->config->getVendorDirectory(), 93 | $this->config->getTargetDirectory(), 94 | $licenseFile 95 | ); 96 | 97 | $targetLicenseFileDir = dirname($targetLicenseFile); 98 | 99 | // Don't try copy it if it's already there. 100 | if ($this->filesystem->fileExists($targetLicenseFile)) { 101 | $this->logger->debug(sprintf( 102 | "Skipping %s because it already exists at %s", 103 | basename($licenseFile), 104 | $targetLicenseFile 105 | )); 106 | continue; 107 | } 108 | 109 | // Don't add licenses to non-existent directories – there were no files copied there! 110 | if (! $this->filesystem->directoryExists($targetLicenseFileDir)) { 111 | $this->logger->debug(sprintf( 112 | "Skipping %s because the directory %s does not exist", 113 | basename($licenseFile), 114 | $targetLicenseFileDir 115 | )); 116 | continue; 117 | } 118 | 119 | $this->logger->info( 120 | sprintf( 121 | "Copying license file from %s to %s", 122 | basename($licenseFile), 123 | $targetLicenseFile 124 | ) 125 | ); 126 | $this->filesystem->copy( 127 | $licenseFile, 128 | $targetLicenseFile 129 | ); 130 | } 131 | } 132 | 133 | /** 134 | * @see https://www.phpliveregex.com/p/A5y 135 | */ 136 | public function findLicenseFiles(): void 137 | { 138 | // Include all license files in the dependency path. 139 | 140 | /** @var ComposerPackage $dependency */ 141 | foreach ($this->dependencies as $dependency) { 142 | $packagePath = $dependency->getPackageAbsolutePath(); 143 | 144 | $files = $this->filesystem->listContents($packagePath, true) 145 | ->filter(fn (StorageAttributes $attributes) => $attributes->isFile()); 146 | foreach ($files as $file) { 147 | $filePath = '/' . $file->path(); 148 | 149 | // If packages happen to have their vendor dir, i.e. locally required packages, don't included the licenses 150 | // from their vendor dir (they should be included otherwise anyway). 151 | // I.e. in symlinked packages, the vendor dir might still exist. 152 | if (0 === strpos($packagePath . '/vendor', $filePath)) { 153 | continue; 154 | } 155 | 156 | if (!preg_match('/^.*licen.e[^\\/]*$/i', $filePath)) { 157 | continue; 158 | } 159 | 160 | $this->discoveredLicenseFiles[$filePath] = $dependency->getPackageName(); 161 | } 162 | } 163 | } 164 | /** 165 | * @return string[] 166 | */ 167 | public function getDiscoveredLicenseFiles(): array 168 | { 169 | return array_keys($this->discoveredLicenseFiles); 170 | } 171 | 172 | /** 173 | * @param array $modifiedFiles 174 | * 175 | * @throws \Exception 176 | * @throws FilesystemException 177 | */ 178 | public function addInformationToUpdatedFiles(array $modifiedFiles): void 179 | { 180 | // E.g. "25-April-2021". 181 | $date = gmdate("d-F-Y", time()); 182 | 183 | foreach ($modifiedFiles as $relativeFilePath => $package) { 184 | $filepath = $this->config->getTargetDirectory() . $relativeFilePath; 185 | 186 | if (!$this->filesystem->fileExists($filepath)) { 187 | continue; 188 | } 189 | 190 | $contents = $this->filesystem->read($filepath); 191 | 192 | $updatedContents = $this->addChangeDeclarationToPhpString( 193 | $contents, 194 | $date, 195 | $package->getPackageName(), 196 | $package->getLicense() 197 | ); 198 | 199 | if ($updatedContents !== $contents) { 200 | $this->logger->info("Adding change declaration to {$filepath}"); 201 | $this->filesystem->write($filepath, $updatedContents); 202 | } 203 | } 204 | } 205 | 206 | /** 207 | * Given a php file as a string, edit its header phpdoc, or add a header, to include: 208 | * 209 | * "Modified by {author} on {date} using Strauss. 210 | * @see https://github.com/BrianHenryIE/strauss" 211 | * 212 | * Should probably include the original license in each file since it'll often be a mix, with the parent 213 | * project often being a GPL WordPress plugin. 214 | * 215 | * Find the string between the end of php-opener and the first valid code. 216 | * First valid code will be a line whose first non-whitespace character is not / or * ?... NO! 217 | * If the first non whitespace string after php-opener is multiline-comment-opener, find the 218 | * closing multiline-comment-closer 219 | * / If there's already a comment, work within that comment 220 | * If there is no mention in the header of the license already, add it. 221 | * Add a note that changes have been made. 222 | * 223 | * @param string $phpString Code. 224 | */ 225 | public function addChangeDeclarationToPhpString( 226 | string $phpString, 227 | string $modifiedDate, 228 | string $packageName, 229 | string $packageLicense 230 | ) : string { 231 | 232 | $author = $this->author; 233 | 234 | $licenseDeclaration = "@license {$packageLicense}"; 235 | $modifiedDeclaration = 'Modified'; 236 | if ($this->includeAuthor) { 237 | $modifiedDeclaration .= " by {$author}"; 238 | } 239 | if ($this->includeModifiedDate) { 240 | $modifiedDeclaration .= " on {$modifiedDate}"; 241 | } 242 | $straussLink = 'https://github.com/BrianHenryIE/strauss'; 243 | $modifiedDeclaration .= " using {@see {$straussLink}}."; 244 | 245 | $startOfFileArray = []; 246 | $tokenizeString = token_get_all($phpString); 247 | 248 | foreach ($tokenizeString as $token) { 249 | if (is_array($token) && !in_array($token[1], ['namespace', '/*', ' /*'])) { 250 | $startOfFileArray[] = $token[1]; 251 | $token = array_shift($tokenizeString); 252 | 253 | if (is_array($token) && stristr($token[1], 'strauss')) { 254 | return $phpString; 255 | } 256 | } elseif (!is_array($token)) { 257 | $startOfFileArray[] = $token; 258 | } 259 | } 260 | // Not in use yet (because all tests are passing) but the idea of capturing the file header and only editing 261 | // that seems more reasonable than searching the whole file. 262 | $startOfFile = implode('', $startOfFileArray); 263 | 264 | // php-open followed by some whitespace and new line until the first ... 265 | $noCommentBetweenPhpOpenAndFirstCodePattern = '~<\?php[\s\n]*[\w\\\?]+~'; 266 | 267 | $multilineCommentCapturePattern = ' 268 | ~ # Start the pattern 269 | ( 270 | <\?php[\S\s]* # match the beginning of the files php-open and following whitespace 271 | ) 272 | ( 273 | \*[\S\s.]* # followed by a multiline-comment-open 274 | ) 275 | ( 276 | \*/ # Capture the multiline-comment-close separately 277 | ) 278 | ~Ux'; // U: Non-greedy matching, x: ignore whitespace in pattern. 279 | 280 | $replaceInMultilineCommentFunction = function ($matches) use ( 281 | $licenseDeclaration, 282 | $modifiedDeclaration 283 | ) { 284 | // Find the line prefix and use it, i.e. could be none, asterisk or space-asterisk. 285 | $commentLines = explode("\n", $matches[2]); 286 | 287 | if (isset($commentLines[1])&& 1 === preg_match('/^([\s\\\*]*)/', $commentLines[1], $output_array)) { 288 | $lineStart = $output_array[1]; 289 | } else { 290 | $lineStart = ' * '; 291 | } 292 | 293 | $appendString = "*\n"; 294 | 295 | // If the license is not already specified in the header, add it. 296 | if (false === strpos($matches[2], 'licen')) { 297 | $appendString .= "{$lineStart}{$licenseDeclaration}\n"; 298 | } 299 | 300 | $appendString .= "{$lineStart}{$modifiedDeclaration}\n"; 301 | 302 | $commentEnd = rtrim(rtrim($lineStart, ' '), '*').'*/'; 303 | 304 | $replaceWith = $matches[1] . $matches[2] . $appendString . $commentEnd; 305 | 306 | return $replaceWith; 307 | }; 308 | 309 | // If it's a simple case where there is no existing header, add the existing license. 310 | if (1 === preg_match($noCommentBetweenPhpOpenAndFirstCodePattern, $phpString)) { 311 | $modifiedComment = "/**\n * {$licenseDeclaration}\n *\n * {$modifiedDeclaration}\n */"; 312 | $updatedPhpString = preg_replace('~<\?php~', " $sourceFiles */ 14 | protected array $sourceFiles = []; 15 | 16 | protected string $symbol; 17 | 18 | protected string $replacement; 19 | 20 | /** 21 | * @param string $symbol The classname / namespace etc. 22 | * @param File $sourceFile The file it was discovered in. 23 | */ 24 | public function __construct(string $symbol, File $sourceFile) 25 | { 26 | $this->symbol = $symbol; 27 | 28 | $this->addSourceFile($sourceFile); 29 | $sourceFile->addDiscoveredSymbol($this); 30 | } 31 | 32 | public function getOriginalSymbol(): string 33 | { 34 | return $this->symbol; 35 | } 36 | 37 | /** 38 | * @return File[] 39 | */ 40 | public function getSourceFiles(): array 41 | { 42 | return $this->sourceFiles; 43 | } 44 | 45 | /** 46 | * @param File $sourceFile 47 | * 48 | * @see FileSymbolScanner 49 | */ 50 | public function addSourceFile(File $sourceFile): void 51 | { 52 | $this->sourceFiles[$sourceFile->getSourcePath()] = $sourceFile; 53 | } 54 | 55 | public function getReplacement(): string 56 | { 57 | return $this->replacement ?? $this->symbol; 58 | } 59 | 60 | public function setReplacement(string $replacement): void 61 | { 62 | $this->replacement = $replacement; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Types/DiscoveredSymbols.php: -------------------------------------------------------------------------------- 1 | , T_CONST:array, T_CLASS:array} 14 | */ 15 | protected array $types = []; 16 | 17 | public function __construct() 18 | { 19 | $this->types = [ 20 | T_CLASS => [], 21 | T_CONST => [], 22 | T_NAMESPACE => [], 23 | T_FUNCTION => [], 24 | ]; 25 | } 26 | 27 | /** 28 | * @param DiscoveredSymbol $symbol 29 | */ 30 | public function add(DiscoveredSymbol $symbol): void 31 | { 32 | switch (get_class($symbol)) { 33 | case NamespaceSymbol::class: 34 | $type = T_NAMESPACE; 35 | break; 36 | case ConstantSymbol::class: 37 | $type = T_CONST; 38 | break; 39 | case ClassSymbol::class: 40 | $type = T_CLASS; 41 | break; 42 | case FunctionSymbol::class: 43 | $type = T_FUNCTION; 44 | break; 45 | default: 46 | throw new \InvalidArgumentException('Unknown symbol type: ' . get_class($symbol)); 47 | } 48 | $this->types[$type][$symbol->getOriginalSymbol()] = $symbol; 49 | } 50 | 51 | /** 52 | * @return DiscoveredSymbol[] 53 | */ 54 | public function getSymbols(): array 55 | { 56 | return array_merge( 57 | array_values($this->getNamespaces()), 58 | array_values($this->getClasses()), 59 | array_values($this->getConstants()), 60 | array_values($this->getDiscoveredFunctions()), 61 | ); 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function getConstants() 68 | { 69 | return $this->types[T_CONST]; 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function getNamespaces(): array 76 | { 77 | return $this->types[T_NAMESPACE]; 78 | } 79 | 80 | public function getNamespace(string $namespace): ?NamespaceSymbol 81 | { 82 | return $this->types[T_NAMESPACE][$namespace] ?? null; 83 | } 84 | 85 | /** 86 | * @return array 87 | */ 88 | public function getClasses(): array 89 | { 90 | return $this->types[T_CLASS]; 91 | } 92 | 93 | /** 94 | * TODO: Order by longest string first. (or instead, record classnames with their namespaces) 95 | * 96 | * @return array 97 | */ 98 | public function getDiscoveredNamespaces(?string $namespacePrefix = ''): array 99 | { 100 | $discoveredNamespaceReplacements = []; 101 | 102 | // When running subsequent times, try to discover the original namespaces. 103 | // This is naive: it will not work where namespace replacement patterns have been used. 104 | foreach ($this->getNamespaces() as $key => $value) { 105 | $discoveredNamespaceReplacements[ $value->getOriginalSymbol() ] = $value; 106 | } 107 | 108 | uksort($discoveredNamespaceReplacements, function ($a, $b) { 109 | return strlen($a) <=> strlen($b); 110 | }); 111 | 112 | return $discoveredNamespaceReplacements; 113 | } 114 | 115 | /** 116 | * TODO: should be called getGlobalClasses? 117 | * 118 | * @return string[] 119 | */ 120 | public function getDiscoveredClasses(?string $classmapPrefix = ''): array 121 | { 122 | $discoveredClasses = $this->getClasses(); 123 | 124 | $discoveredClasses = array_filter( 125 | array_keys($discoveredClasses), 126 | function (string $replacement) use ($classmapPrefix) { 127 | return empty($classmapPrefix) || ! str_starts_with($replacement, $classmapPrefix); 128 | } 129 | ); 130 | 131 | return $discoveredClasses; 132 | } 133 | 134 | /** 135 | * @return string[] 136 | */ 137 | public function getDiscoveredConstants(?string $constantsPrefix = ''): array 138 | { 139 | $discoveredConstants = $this->getConstants(); 140 | $discoveredConstants = array_filter( 141 | array_keys($discoveredConstants), 142 | function (string $replacement) use ($constantsPrefix) { 143 | return empty($constantsPrefix) || ! str_starts_with($replacement, $constantsPrefix); 144 | } 145 | ); 146 | 147 | return $discoveredConstants; 148 | } 149 | 150 | public function getDiscoveredFunctions() 151 | { 152 | return $this->types[T_FUNCTION]; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Types/FunctionSymbol.php: -------------------------------------------------------------------------------- 1 |