diff --git a/.distignore b/.distignore
new file mode 100644
index 0000000..b077088
--- /dev/null
+++ b/.distignore
@@ -0,0 +1,26 @@
+# Files and folders excluded from the WordPress.org release (used by 10up/action-wordpress-plugin-deploy).
+/.git
+/.github
+/.wordpress-org
+/node_modules
+/tests
+/vendor
+/assets
+/docs
+
+.distignore
+.editorconfig
+.gitignore
+.gitattributes
+.DS_Store
+.phpunit.result.cache
+composer.json
+composer.lock
+package.json
+package-lock.json
+phpcs.xml
+phpunit.xml
+webpack.config.js
+README.md
+*.map
+*.zip
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d49702b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,36 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "(chore)"
+
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ npm:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "(chore)"
+ versioning-strategy: lockfile-only
+
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ composer:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "(chore)"
+ versioning-strategy: lockfile-only
diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml
new file mode 100644
index 0000000..194a789
--- /dev/null
+++ b/.github/workflows/dependabot-automerge.yml
@@ -0,0 +1,13 @@
+name: Dependabot Automerge
+on: pull_request
+
+jobs:
+ dependabot-automerge:
+ # Composer PRs are merged by dependabot-vendor-prefixed.yml after vendor-prefixed/ is rebuilt.
+ if: ${{ !startsWith(github.head_ref, 'dependabot/composer/') }}
+ uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main
+ # The reusable workflow requests these; grant them explicitly so a read-only default token can't block the call.
+ permissions:
+ contents: write
+ pull-requests: write
+ secrets: inherit
diff --git a/.github/workflows/dependabot-vendor-prefixed.yml b/.github/workflows/dependabot-vendor-prefixed.yml
new file mode 100644
index 0000000..9371470
--- /dev/null
+++ b/.github/workflows/dependabot-vendor-prefixed.yml
@@ -0,0 +1,77 @@
+name: Dependabot vendor-prefixed
+
+# Dependabot Composer PRs only change composer.lock. This rebuilds vendor-prefixed/ from the
+# updated lock file, pushes it to the PR branch and then merges the PR, so the shipped prefixed
+# code always matches composer.lock (the deploy workflow verifies this).
+on: pull_request
+
+# The reusable automerge workflow uses `${{ github.workflow }}-${{ github.ref }}` as its group,
+# and github.workflow keeps the caller's name there. The suffix keeps the groups apart, otherwise
+# the automerge job would cancel this run.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-rebuild
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ rebuild:
+ name: Rebuild vendor-prefixed
+ if: github.event.pull_request.user.login == 'dependabot[bot]' && startsWith(github.head_ref, 'dependabot/composer/')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out PR branch
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ coverage: none
+ tools: composer
+
+ # The post-install scripts run Strauss and then remove vendor/.
+ - name: Rebuild vendor-prefixed
+ run: |
+ rm -rf vendor-prefixed
+ composer install --no-interaction --prefer-dist
+ if [ ! -f vendor-prefixed/autoload.php ]; then
+ echo "::error::vendor-prefixed/autoload.php was not generated."
+ exit 1
+ fi
+
+ # Strauss stamps every file with the build date. Revert files where that is the only change,
+ # so the commit only contains real dependency changes. Strauss also writes the classmap
+ # entries in no fixed order, so revert it when only the order changed.
+ - name: Drop date-only changes
+ run: |
+ git diff --name-only -- vendor-prefixed | while read -r file; do
+ if git diff -I 'Modified by .* using' --quiet -- "$file"; then
+ git checkout -- "$file"
+ fi
+ done
+ CLASSMAP=vendor-prefixed/autoload-classmap.php
+ if git ls-files --error-unmatch "$CLASSMAP" >/dev/null 2>&1 \
+ && diff -q <(git show "HEAD:$CLASSMAP" | sort) <(sort "$CLASSMAP") >/dev/null; then
+ git checkout -- "$CLASSMAP"
+ fi
+
+ # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so this cannot loop.
+ - name: Commit vendor-prefixed changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: '(chore): update vendor prefixed directory'
+ file_pattern: 'vendor-prefixed'
+
+ # Composer PRs are skipped by the general automerge workflow, so they are merged here,
+ # only after vendor-prefixed/ has been pushed.
+ automerge:
+ name: Automerge
+ needs: rebuild
+ uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main
+ secrets: inherit
diff --git a/.github/workflows/format-php.yml b/.github/workflows/format-php.yml
new file mode 100644
index 0000000..18846db
--- /dev/null
+++ b/.github/workflows/format-php.yml
@@ -0,0 +1,51 @@
+name: Format PHP
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '**.php'
+ - 'phpcs.xml'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+jobs:
+ phpcbf:
+ name: phpcbf
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Check out Git repository
+ uses: actions/checkout@v5
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ coverage: none
+ tools: composer
+
+ # --no-scripts: the post-install scripts remove vendor/, which holds phpcbf.
+ - name: Install dependencies
+ run: composer install --no-interaction --prefer-dist --no-scripts
+
+ # phpcbf exits 1 when it fixed everything and 2 when unfixable violations remain;
+ # both are fine here, only 3 (processing error) should fail the job.
+ - name: Run phpcbf
+ run: composer run phpcbf || [ $? -le 2 ]
+
+ # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so this cannot loop.
+ - name: Commit formatting changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: 'style: apply phpcbf changes'
+ file_pattern: '*.php'
+ # Pass the pattern to git unexpanded so it matches PHP files in subdirectories too.
+ disable_globbing: true
diff --git a/.github/workflows/wordpress-org-deploy.yml b/.github/workflows/wordpress-org-deploy.yml
new file mode 100644
index 0000000..c4164ea
--- /dev/null
+++ b/.github/workflows/wordpress-org-deploy.yml
@@ -0,0 +1,125 @@
+name: Deploy to WordPress.org
+
+on:
+ push:
+ tags:
+ - "v*"
+ - "[0-9]*"
+ workflow_dispatch:
+ inputs:
+ dry-run:
+ description: 'Dry run: build and package, but do not commit to WordPress.org SVN'
+ type: boolean
+ default: true
+
+# One deploy at a time, whatever the ref: a tag push and a manual dispatch for the same version
+# could otherwise both pass the SVN tag check and collide. Never cancel a running publish.
+concurrency:
+ group: wordpress-org-deploy
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ name: Deploy new tag
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Check out Git repository
+ uses: actions/checkout@v5
+
+ - name: Check the version numbers
+ run: |
+ PLUGIN_VERSION=$(grep -m1 -E '^\s*\*\s*Version:' yard-deepl.php | awk '{print $NF}' | tr -d '\r')
+ STABLE_TAG=$(grep -m1 -E '^Stable tag:' readme.txt | awk '{print $NF}' | tr -d '\r')
+ README_STABLE_TAG=$(grep -m1 -E '^Stable tag:' README.md | awk '{print $NF}' | tr -d '\r')
+ echo "Plugin header: $PLUGIN_VERSION, readme.txt stable tag: $STABLE_TAG, README.md stable tag: $README_STABLE_TAG, ref: $GITHUB_REF_NAME"
+ if [ "$PLUGIN_VERSION" != "$STABLE_TAG" ]; then
+ echo "::error::Plugin header Version and readme.txt Stable tag must be equal."
+ exit 1
+ fi
+ if [ "$PLUGIN_VERSION" != "$README_STABLE_TAG" ]; then
+ echo "::error::Plugin header Version and README.md Stable tag must be equal."
+ exit 1
+ fi
+ if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "${GITHUB_REF_NAME#v}" != "$PLUGIN_VERSION" ]; then
+ echo "::error::Tag must match the plugin header Version."
+ exit 1
+ fi
+ if [ "$GITHUB_REF_TYPE" != "tag" ] && [ "${{ inputs.dry-run }}" != "true" ]; then
+ echo "::error::A real deploy must run from a tag. Pick a tag under 'Use workflow from', or enable dry run."
+ exit 1
+ fi
+ # Used by the deploy action as the SVN tag name.
+ echo "VERSION=$PLUGIN_VERSION" >> "$GITHUB_ENV"
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ coverage: none
+ tools: composer
+
+ # vendor-prefixed/ is committed and shipped as-is. Regenerate it from composer.lock and fail
+ # when it differs from the committed copy, so a Composer update (e.g. from Dependabot)
+ # can never ship without the matching prefixed code being committed first.
+ # The post-install scripts run Strauss and then remove vendor/, which the plugin never loads.
+ - name: Verify prefixed dependencies match composer.lock
+ run: |
+ COMMITTED="$RUNNER_TEMP/vendor-prefixed-committed"
+ mv vendor-prefixed "$COMMITTED"
+ composer install --no-interaction --prefer-dist
+ if [ ! -f vendor-prefixed/autoload.php ]; then
+ echo "::error::vendor-prefixed/autoload.php was not generated."
+ exit 1
+ fi
+ # Strauss stamps every file with the build date; ignore those lines.
+ # Strauss writes the classmap entries in no fixed order, so that file is compared sorted below.
+ if ! diff -r -I 'Modified by .* using' -x autoload-classmap.php "$COMMITTED" vendor-prefixed \
+ || ! diff <(sort "$COMMITTED/autoload-classmap.php") <(sort vendor-prefixed/autoload-classmap.php); then
+ echo "::error::vendor-prefixed/ is out of date with composer.lock. Run 'composer install' locally and commit vendor-prefixed/."
+ exit 1
+ fi
+ # Ship exactly the committed files.
+ rm -rf vendor-prefixed
+ mv "$COMMITTED" vendor-prefixed
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Build assets
+ run: |
+ npm ci
+ npm run build
+
+ - name: WordPress plugin deploy
+ id: deploy
+ uses: 10up/action-wordpress-plugin-deploy@stable
+ with:
+ dry-run: ${{ inputs.dry-run == true }}
+ env:
+ SLUG: yard-deepl
+ SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
+ SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
+
+ # Build the zip from this checkout instead of the action's generate-zip: when the SVN tag
+ # already exists (rerun), the action zips SVN trunk, which may hold a newer version.
+ - name: Build release zip
+ id: zip
+ run: |
+ BUILD_DIR="$RUNNER_TEMP/build"
+ mkdir -p "$BUILD_DIR/yard-deepl"
+ rsync -rc --exclude-from=.distignore ./ "$BUILD_DIR/yard-deepl/"
+ (cd "$BUILD_DIR" && zip -rq yard-deepl.zip yard-deepl)
+ echo "zip-path=$BUILD_DIR/yard-deepl.zip" >> "$GITHUB_OUTPUT"
+
+ - name: Create GitHub release
+ if: github.ref_type == 'tag' && inputs.dry-run != true
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ${{ steps.zip.outputs.zip-path }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 5676717..c3b5170 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,3 @@
node_modules
tests/coverage
vendor
-vendor-prefixed
diff --git a/README.md b/README.md
index 6a7c4b3..8bd6bc9 100644
--- a/README.md
+++ b/README.md
@@ -73,9 +73,11 @@ If you need to work with development dependencies, follow these steps:
1. Run `composer install`. This will remove the `vendor` directory and create the `vendor-prefixed` directory.
2. Run `composer install --no-scripts`. The `--no-scripts` flag prevents the automatic deletion of the `vendor` directory, allowing you to work with the development dependencies. Ensure that the `vendor-prefixed` directory is still present, as the plug-in relies on it.
+`vendor-prefixed` is committed to git. After changing Composer packages, commit `vendor-prefixed` together with `composer.lock`; the deploy stops when they don't match.
+
### Ready for releasing a new version?
-1. Run `composer install --optimize-autoloader`. This will optimize the autoloading rules by converting them into a class map, which improves performance and speeds up autoloading. It is particularly useful for production environments.
+See [docs/RELEASING.md](docs/RELEASING.md) for the release routine and how the GitHub workflows fit together.
## Usage
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..d092295
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,111 @@
+# Releasing
+
+How changes reach `main`, and how a tag on `main` becomes a release on WordPress.org and GitHub. All workflows live in [`.github/workflows`](../.github/workflows).
+
+## The whole picture
+
+```mermaid
+flowchart TD
+ subgraph composer["Dependabot: Composer"]
+ C1["Weekly PR opened
(only composer.lock changed)"] --> C2["composer-lock-diff
comments which packages changed"]
+ C2 --> C3["dependabot-vendor-prefixed
rebuilds vendor-prefixed/, pushes it, merges"]
+ end
+
+ subgraph other["Dependabot: Actions, npm"]
+ D1["Weekly PR opened"] --> D2["dependabot-automerge
approves and merges right away"]
+ end
+
+ subgraph you["You: code changes"]
+ Y1["Change code
(+ commit vendor-prefixed/ after Composer changes)"] --> Y2["Commit and push,
or merge a pull request"]
+ end
+
+ C3 --> MAIN[("main")]
+ D2 --> MAIN
+ Y2 --> MAIN
+ MAIN <-->|"push with .php changes /
style: apply phpcbf changes"| F["format-php
runs phpcbf, commits fixes"]
+
+ MAIN --> R1["Bump the version
yard-deepl.php, readme.txt, README.md"]
+
+ subgraph release["Release: you start it"]
+ R1 --> R2["Push a tag, e.g. v2.3.0"]
+ R2 --> R3["wordpress-org-deploy
checks, builds, publishes"]
+ R3 --> O1["WordPress.org
new version in the plugin directory"]
+ R3 --> O2["GitHub release
zip built from the tag"]
+ end
+```
+
+Anything can land on `main` at any time. Nothing is published until you push a tag.
+
+## Release routine
+
+1. Make sure `main` has everything you want in the release.
+2. Set the new version in:
+ - `yard-deepl.php`: `Version:`
+ - `readme.txt`: `Stable tag:`, plus a changelog entry
+ - `README.md`: `Stable tag:`, plus a changelog entry
+3. Commit (for example `release: v2.3.0`) and push.
+4. Optional: run **Deploy to WordPress.org** manually under the Actions tab. Dry run is on by default, so it runs every check and build without publishing.
+5. Push the tag:
+
+ ```sh
+ git tag v2.3.0
+ git push origin v2.3.0
+ ```
+
+6. Follow the run under the Actions tab. When it is green, the version is live on WordPress.org and GitHub.
+
+## The workflows
+
+| Workflow | Runs when | What it does |
+| --- | --- | --- |
+| `composer-lock-diff.yml` | A PR changes `composer.lock` | Comments on the PR which packages changed, from which version to which. |
+| `dependabot-vendor-prefixed.yml` | Dependabot opens a Composer PR (`dependabot/composer/...`) | Rebuilds `vendor-prefixed/` from the new `composer.lock`, drops date-only changes, pushes the result to the PR and merges it. |
+| `dependabot-automerge.yml` | Dependabot opens any other PR (Actions, npm) | Approves and merges the PR right away. Composer PRs are skipped; `dependabot-vendor-prefixed.yml` merges those after the rebuild. |
+| `format-php.yml` | A push to `main` changes a `.php` file or `phpcs.xml` | Runs `phpcbf` and commits the fixes as `style: apply phpcbf changes`. |
+| `wordpress-org-deploy.yml` | A tag is pushed, or a manual run | Checks, builds and publishes the release. See below. |
+
+### Inside wordpress-org-deploy
+
+The steps run in order. If a check fails, the run stops and nothing is published.
+
+1. **Check out the tag.** The code exactly as it was when tagged, even if `main` has moved on.
+2. **Check the version numbers.** The version in `yard-deepl.php`, the stable tag in `readme.txt` and `README.md`, and the tag name must all be the same. *Stops if they differ.*
+3. **Check `vendor-prefixed/`.** Rebuilds it from `composer.lock` and compares it with the committed copy, ignoring the Strauss date lines and the order of the classmap entries. Then puts the committed copy back, so the release ships exactly what is in git. *Stops if out of date.*
+4. **Build assets.** `npm ci` and `npm run build` produce `dist/`.
+5. **Upload to WordPress.org.** The 10up action copies the plugin to SVN, without the files in `.distignore`. *Skipped in a dry run.*
+6. **Build the zip** from the same checked-out files, without the files in `.distignore`. Because it uses the tag, a rerun always gives the zip for that version.
+7. **Create the GitHub release** for the tag and attach the zip. *Skipped in a dry run.*
+
+## vendor-prefixed/
+
+The plugin loads its Composer dependencies (PHP-DI and its packages) from `vendor-prefixed/`, never from `vendor/`. [Strauss](https://github.com/BrianHenryIE/strauss) builds it from `composer.lock` during `composer install`, and it is committed to git.
+
+- After changing Composer packages yourself, run `composer install` and commit `vendor-prefixed/` together with `composer.lock`.
+- Strauss writes the build date into every file (`Modified by ... on `), so every `composer install` touches all files. Undo only the files where the date is the only change, so real dependency changes stay:
+
+ ```sh
+ git diff --name-only -- vendor-prefixed | while read -r file; do
+ git diff -I 'Modified by .* using' --quiet -- "$file" && git checkout -- "$file"
+ done
+ ```
+
+ Don't use `git restore vendor-prefixed`: that also throws away the real changes from a dependency update.
+- The deploy checks that the committed `vendor-prefixed/` matches `composer.lock` and stops if it doesn't.
+
+## When something stops
+
+| What happened | Where | How to fix it |
+| --- | --- | --- |
+| Version numbers differ | `wordpress-org-deploy`, step 2 | Make the version the same in the three files and the tag. Delete the wrong tag, commit the fix and tag again. |
+| `vendor-prefixed/` is out of date | `wordpress-org-deploy`, step 3 | Run `composer install` locally, commit `vendor-prefixed/`, then tag again. |
+| A Dependabot Composer PR stays open | `dependabot-vendor-prefixed` | The rebuild or merge failed. Open the failed run to see why. Comment `@dependabot recreate` on the PR to start over. |
+| A `style: apply phpcbf changes` commit appears | `format-php` | Nothing to fix. Pull before you continue working, so you have the formatted code. |
+
+To delete a wrong tag before tagging again:
+
+```sh
+git tag -d v2.3.0
+git push origin :refs/tags/v2.3.0
+```
+
+Only do this when the run stopped before step 5. Once a version is on WordPress.org, release a new version instead of reusing the tag.
diff --git a/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php b/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php
new file mode 100644
index 0000000..42e80df
--- /dev/null
+++ b/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php
@@ -0,0 +1,79 @@
+
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)]
+final class Inject
+{
+ /**
+ * Entry name.
+ */
+ private ?string $name = null;
+
+ /**
+ * Parameters, indexed by the parameter number (index) or name.
+ *
+ * Used if the attribute is set on a method
+ */
+ private array $parameters = [];
+
+ /**
+ * @throws InvalidAttribute
+ */
+ public function __construct(string|array|null $name = null)
+ {
+ // #[Inject('foo')] or #[Inject(name: 'foo')]
+ if (is_string($name)) {
+ $this->name = $name;
+ }
+
+ // #[Inject([...])] on a method
+ if (is_array($name)) {
+ foreach ($name as $key => $value) {
+ if (! is_string($value)) {
+ throw new InvalidAttribute(sprintf(
+ "#[Inject(['param' => 'value'])] expects \"value\" to be a string, %s given.",
+ json_encode($value, \JSON_THROW_ON_ERROR)
+ ));
+ }
+
+ $this->parameters[$key] = $value;
+ }
+ }
+ }
+
+ /**
+ * @return string|null Name of the entry to inject
+ */
+ public function getName() : string|null
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return array Parameters, indexed by the parameter number (index) or name
+ */
+ public function getParameters() : array
+ {
+ return $this->parameters;
+ }
+}
diff --git a/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php b/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php
new file mode 100644
index 0000000..840954d
--- /dev/null
+++ b/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php
@@ -0,0 +1,39 @@
+
+ * @author Matthieu Napoli
+ */
+#[Attribute(Attribute::TARGET_CLASS)]
+final class Injectable
+{
+ /**
+ * @param bool|null $lazy Should the object be lazy-loaded.
+ */
+ public function __construct(
+ private ?bool $lazy = null,
+ ) {
+ }
+
+ public function isLazy() : bool|null
+ {
+ return $this->lazy;
+ }
+}
diff --git a/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php b/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php
new file mode 100644
index 0000000..07d2743
--- /dev/null
+++ b/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php
@@ -0,0 +1,19 @@
+
+ */
+class InvalidAttribute extends InvalidDefinition
+{
+}
diff --git a/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php b/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php
new file mode 100644
index 0000000..0d9ce1d
--- /dev/null
+++ b/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php
@@ -0,0 +1,268 @@
+
+ */
+class AttributeBasedAutowiring implements DefinitionSource, Autowiring
+{
+ /**
+ * @throws InvalidAttribute
+ */
+ public function autowire(string $name, ?ObjectDefinition $definition = null) : ObjectDefinition|null
+ {
+ $className = $definition ? $definition->getClassName() : $name;
+
+ if (!class_exists($className) && !interface_exists($className)) {
+ return $definition;
+ }
+
+ $definition = $definition ?: new ObjectDefinition($name);
+
+ $class = new ReflectionClass($className);
+
+ $this->readInjectableAttribute($class, $definition);
+
+ // Browse the class properties looking for annotated properties
+ $this->readProperties($class, $definition);
+
+ // Browse the object's methods looking for annotated methods
+ $this->readMethods($class, $definition);
+
+ return $definition;
+ }
+
+ /**
+ * @throws InvalidAttribute
+ * @throws InvalidArgumentException The class doesn't exist
+ */
+ public function getDefinition(string $name) : ObjectDefinition|null
+ {
+ return $this->autowire($name);
+ }
+
+ /**
+ * Autowiring cannot guess all existing definitions.
+ */
+ public function getDefinitions() : array
+ {
+ return [];
+ }
+
+ /**
+ * Browse the class properties looking for annotated properties.
+ */
+ private function readProperties(ReflectionClass $class, ObjectDefinition $definition) : void
+ {
+ foreach ($class->getProperties() as $property) {
+ $this->readProperty($property, $definition);
+ }
+
+ // Read also the *private* properties of the parent classes
+ /** @noinspection PhpAssignmentInConditionInspection */
+ while ($class = $class->getParentClass()) {
+ foreach ($class->getProperties(ReflectionProperty::IS_PRIVATE) as $property) {
+ $this->readProperty($property, $definition, $class->getName());
+ }
+ }
+ }
+
+ /**
+ * @throws InvalidAttribute
+ */
+ private function readProperty(ReflectionProperty $property, ObjectDefinition $definition, ?string $classname = null) : void
+ {
+ if ($property->isStatic() || $property->isPromoted()) {
+ return;
+ }
+
+ // Look for #[Inject] attribute
+ try {
+ $attribute = $property->getAttributes(Inject::class)[0] ?? null;
+ if (! $attribute) {
+ return;
+ }
+ /** @var Inject $inject */
+ $inject = $attribute->newInstance();
+ } catch (Throwable $e) {
+ throw new InvalidAttribute(sprintf(
+ '#[Inject] annotation on property %s::%s is malformed. %s',
+ $property->getDeclaringClass()->getName(),
+ $property->getName(),
+ $e->getMessage()
+ ), 0, $e);
+ }
+
+ // Try to #[Inject("name")] or look for the property type
+ $entryName = $inject->getName();
+
+ // Try using typed properties
+ $propertyType = $property->getType();
+ if ($entryName === null && $propertyType instanceof ReflectionNamedType) {
+ if (! class_exists($propertyType->getName()) && ! interface_exists($propertyType->getName())) {
+ throw new InvalidAttribute(sprintf(
+ '#[Inject] found on property %s::%s but unable to guess what to inject, the type of the property does not look like a valid class or interface name',
+ $property->getDeclaringClass()->getName(),
+ $property->getName()
+ ));
+ }
+ $entryName = $propertyType->getName();
+ }
+
+ if ($entryName === null) {
+ throw new InvalidAttribute(sprintf(
+ '#[Inject] found on property %s::%s but unable to guess what to inject, please add a type to the property',
+ $property->getDeclaringClass()->getName(),
+ $property->getName()
+ ));
+ }
+
+ $definition->addPropertyInjection(
+ new PropertyInjection($property->getName(), new Reference($entryName), $classname)
+ );
+ }
+
+ /**
+ * Browse the object's methods looking for annotated methods.
+ */
+ private function readMethods(ReflectionClass $class, ObjectDefinition $objectDefinition) : void
+ {
+ // This will look in all the methods, including those of the parent classes
+ foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->isStatic()) {
+ continue;
+ }
+
+ $methodInjection = $this->getMethodInjection($method);
+
+ if (! $methodInjection) {
+ continue;
+ }
+
+ if ($method->isConstructor()) {
+ $objectDefinition->completeConstructorInjection($methodInjection);
+ } else {
+ $objectDefinition->completeFirstMethodInjection($methodInjection);
+ }
+ }
+ }
+
+ private function getMethodInjection(ReflectionMethod $method) : ?MethodInjection
+ {
+ // Look for #[Inject] attribute
+ $attribute = $method->getAttributes(Inject::class)[0] ?? null;
+
+ if ($attribute) {
+ /** @var Inject $inject */
+ $inject = $attribute->newInstance();
+ $annotationParameters = $inject->getParameters();
+ } elseif ($method->isConstructor()) {
+ // #[Inject] on constructor is implicit, we continue
+ $annotationParameters = [];
+ } else {
+ return null;
+ }
+
+ $parameters = [];
+ foreach ($method->getParameters() as $index => $parameter) {
+ $entryName = $this->getMethodParameter($index, $parameter, $annotationParameters);
+
+ if ($entryName !== null) {
+ $parameters[$index] = new Reference($entryName);
+ }
+ }
+
+ if ($method->isConstructor()) {
+ return MethodInjection::constructor($parameters);
+ }
+
+ return new MethodInjection($method->getName(), $parameters);
+ }
+
+ /**
+ * @return string|null Entry name or null if not found.
+ */
+ private function getMethodParameter(int $parameterIndex, ReflectionParameter $parameter, array $annotationParameters) : ?string
+ {
+ // Let's check if this parameter has an #[Inject] attribute
+ $attribute = $parameter->getAttributes(Inject::class)[0] ?? null;
+ if ($attribute) {
+ /** @var Inject $inject */
+ $inject = $attribute->newInstance();
+
+ return $inject->getName();
+ }
+
+ // #[Inject] has definition for this parameter (by index, or by name)
+ if (isset($annotationParameters[$parameterIndex])) {
+ return $annotationParameters[$parameterIndex];
+ }
+ if (isset($annotationParameters[$parameter->getName()])) {
+ return $annotationParameters[$parameter->getName()];
+ }
+
+ // Skip optional parameters if not explicitly defined
+ if ($parameter->isOptional()) {
+ return null;
+ }
+
+ // Look for the property type
+ $parameterType = $parameter->getType();
+ if ($parameterType instanceof ReflectionNamedType && !$parameterType->isBuiltin()) {
+ return $parameterType->getName();
+ }
+
+ return null;
+ }
+
+ /**
+ * @throws InvalidAttribute
+ */
+ private function readInjectableAttribute(ReflectionClass $class, ObjectDefinition $definition) : void
+ {
+ try {
+ $attribute = $class->getAttributes(Injectable::class)[0] ?? null;
+ if (! $attribute) {
+ return;
+ }
+ $attribute = $attribute->newInstance();
+ } catch (Throwable $e) {
+ throw new InvalidAttribute(sprintf(
+ 'Error while reading #[Injectable] on %s: %s',
+ $class->getName(),
+ $e->getMessage()
+ ), 0, $e);
+ }
+
+ if ($attribute->isLazy() !== null) {
+ $definition->setLazy($attribute->isLazy());
+ }
+ }
+}