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..a95ecbc
--- /dev/null
+++ b/.github/workflows/dependabot-automerge.yml
@@ -0,0 +1,9 @@
+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
+ secrets: inherit
diff --git a/.github/workflows/dependabot-vendor-prefixed.yml b/.github/workflows/dependabot-vendor-prefixed.yml
new file mode 100644
index 0000000..46b1eea
--- /dev/null
+++ b/.github/workflows/dependabot-vendor-prefixed.yml
@@ -0,0 +1,71 @@
+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.
+ - 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
+
+ # 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..8ec10f9
--- /dev/null
+++ b/.github/workflows/wordpress-org-deploy.yml
@@ -0,0 +1,117 @@
+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
+
+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.
+ if ! diff -r -I 'Modified by .* using' "$COMMITTED" vendor-prefixed; 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 98bfaa3..e7d46c9 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..b69cc93
--- /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. 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());
+ }
+ }
+}