diff --git a/.github/workflows/ci-lsp.yml b/.github/workflows/ci-lsp.yml index 5d92a27..dcc3bd5 100644 --- a/.github/workflows/ci-lsp.yml +++ b/.github/workflows/ci-lsp.yml @@ -41,6 +41,35 @@ jobs: - name: Run PHPUnit (LSP) run: make test/unit + behat-lsp: + name: Behat (LSP acceptance) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + # Root deps first -- the isolated Behat install autoloads the root + # vendor/autoload at runtime (see tools/behat/composer.json). + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + + # Behat lives in its own composer.json (tools/behat) because Behat 3.x + # caps symfony/console at ^7 while the root pins ^8 via xphp-lang/xphp. + - name: Install Behat tooling dependencies + run: composer install --working-dir=tools/behat --no-interaction + + # Drives the real LSP dispatcher end-to-end, fully in-memory; @todo + # scenarios are skipped via the gherkin tag filter in behat.dist.yml. + - name: Run Behat acceptance suite + run: make test/behat + infection-lsp: name: Mutation testing (LSP) # Gate: only run when explicitly requested via the Actions tab. The diff --git a/.gitignore b/.gitignore index 6b953b0..b0c890a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /vendor/ /var/ +/tools/behat/vendor/ .phpunit.result.cache .infection.json5.tmp.log /docker-compose.override.yml diff --git a/Makefile b/Makefile index 01380c9..2be45dd 100644 --- a/Makefile +++ b/Makefile @@ -87,3 +87,38 @@ build/phar: $(BOX_PHAR) php -d phar.readonly=0 var/box.phar compile --no-interaction composer install --quiet --no-interaction @echo "==> Built $$(ls -lh var/xphp-lsp.phar | awk '{print $$5, $$9}')" + +# Behat lives in an isolated tooling install (tools/behat) because Behat 3.x +# caps symfony/console at ^7 while the root project pins ^8 via xphp-lang/xphp. +# Its files-autoload pulls in the root autoloader so the harness resolves +# XPHP\Lsp\* classes. Scenarios drive the REAL language server end-to-end via +# phpactor's LanguageServerTester (the production LspDispatcherFactory + full +# middleware stack); deferred behavior is tagged @todo and skipped. This target +# is deliberately NOT part of the `test/unit` gate. +# The warmer's stderr chatter is silenced from tools/behat/bootstrap.php +# (putenv XPHP_LSP_QUIET=1), since shell env-prefixes don't propagate through +# this project's containerized `php` proxy. +# memory_limit=-1: the first scenario builds the worse-reflection stub map +# (peaks ~512M, same as the PHPUnit handler tests), which OOMs under the default +# CLI limit on a cold cache (e.g. in CI). +BEHAT_BIN := tools/behat/vendor/bin/behat +BEHAT := php -d error_reporting='E_ALL & ~E_DEPRECATED' -d memory_limit=-1 $(BEHAT_BIN) +BEHAT_FLAGS := -c behat.dist.yml --colors + +$(BEHAT_BIN): + composer install --working-dir=tools/behat --quiet + +.PHONY: test/behat +test/behat: $(BEHAT_BIN) + $(BEHAT) $(BEHAT_FLAGS) + +# Each feature file runs in its own process. Safe because every scenario builds +# its own in-memory workspace -- no shared files/DB/ports. The one shared +# resource is the read-only PHP-stubs cache; the pre-warm run below populates it +# once (sequentially) so the parallel fan-out only ever reads it. +.PHONY: test/behat/parallel +test/behat/parallel: $(BEHAT_BIN) + @echo "==> warming shared stub cache" + @$(BEHAT) $(BEHAT_FLAGS) features/navigate/definition.feature >/dev/null 2>&1 || true + find features -name '*.feature' | xargs -P 4 -I{} \ + php -d error_reporting='E_ALL & ~E_DEPRECATED' -d memory_limit=-1 $(BEHAT_BIN) $(BEHAT_FLAGS) {} diff --git a/behat.dist.yml b/behat.dist.yml new file mode 100644 index 0000000..a6b99b4 --- /dev/null +++ b/behat.dist.yml @@ -0,0 +1,25 @@ +default: + # Deferred behavior is written as @todo scenarios that document the desired + # outcome but are skipped, so the suite stays green on what's expected to work. + gherkin: + filters: + tags: '~@todo' + # Registers WorldArgumentResolver so the per-scenario World is + # constructor-injected into every context (and reset before each scenario). + extensions: + XPHP\Lsp\Test\Behat\WorldExtension: ~ + suites: + default: + paths: + - '%paths.base%/features' + # Contexts live under test/ and are resolved by the root project's + # PSR-4 autoload-dev (XPHP\Lsp\Test\ -> test/), which the isolated + # Behat install pulls in via its files-autoload. ServerContext holds + # the cross-theme Givens + generic dispatchers; the rest are per-theme. + contexts: + - 'XPHP\Lsp\Test\Behat\ServerContext' + - 'XPHP\Lsp\Test\Behat\NavigateContext' + - 'XPHP\Lsp\Test\Behat\EditContext' + - 'XPHP\Lsp\Test\Behat\UnderstandContext' + - 'XPHP\Lsp\Test\Behat\ValidateContext' + - 'XPHP\Lsp\Test\Behat\FindContext' diff --git a/features/README.md b/features/README.md new file mode 100644 index 0000000..ff7d564 --- /dev/null +++ b/features/README.md @@ -0,0 +1,64 @@ +# Behavior specifications (Gherkin) + +Executable acceptance specs for the xphp language server, organized by theme. +Each scenario drives the **real language server end-to-end** — phpactor's +`LanguageServerTester` builds the production `LspDispatcherFactory`, runs the +initialize/ServerCapabilities handshake, and routes real JSON-RPC requests +through the full middleware stack to the handlers. Everything is **in-memory** +(fixtures are opened via `textDocument/didOpen`; the transmitter is an array +buffer — no stdio, sockets, or files), so the suite is isolated and +parallel-safe. + +``` +features/ +├── navigate/ definition, type-definition, references, implementation, +│ document & workspace symbols, document highlight, +│ call hierarchy, type hierarchy +├── edit/ rename, code actions, code lens, willRenameFiles +├── understand/ hover, signature help, inlay hints, folding ranges, semantic tokens +├── validate/ diagnostics (parse, undefined-name, bound, ctor-arg, duplicate) +└── find/ completion, completion-item resolve +``` + +Each scenario is arranged **Given** (fixture file contents + warmed FQN index) / +**When** (one LSP request) / **Then** (assert the response). Fixtures use +leading-slash URIs (`/Foo.xphp`) for handlers that go through worse-reflection. + +## Step definitions + +The step definitions live in `test/Behat/` as plain context classes: + +- `World` — the shared per-scenario state + helpers: the `LanguageServerTester` + (real dispatcher), the request dispatch, and the fixture/position/assertion + helpers. It is **constructor-injected** into every context and a fresh one is + used per scenario/example. +- `WorldArgumentResolver` + `WorldExtension` — the Behat extension that performs + that injection (tagged `context.argument_resolver`) and resets the `World` + before each scenario/example (tagged `event_dispatcher.subscriber`). +- `ServerContext` — the cross-theme fixture Givens and the generic request + dispatchers. +- `NavigateContext`, `EditContext`, `UnderstandContext`, `ValidateContext`, + `FindContext` — the When/Then steps for each theme, delegating to the injected + `World`. + +## Running + +```sh +make test/behat # sequential +make test/behat/parallel # one process per feature file +``` + +Behat is installed in an isolated tooling dir (`tools/behat/`) because Behat 3.x +caps `symfony/console` at `^7` while the project pins `^8` via `xphp-lang/xphp`. +`make test/behat` bootstraps it on first run. + +## @todo scenarios + +Deferred behavior is written as `@todo` scenarios that document the desired +outcome but are skipped (via the gherkin tag filter in `behat.dist.yml`), so the +suite stays green on what's expected to work. Current `@todo`s: + +- go-to-definition through a generic **method** call (navigate/definition) +- **duplicate-template** diagnostic on the edited file — the per-file pull + provider canonicalizes the edited file, so it surfaces on the other file; + needs the roadmap's cross-file diagnostic broadcast (validate/diagnostics) diff --git a/features/edit/code_action.feature b/features/edit/code_action.feature new file mode 100644 index 0000000..7624f24 --- /dev/null +++ b/features/edit/code_action.feature @@ -0,0 +1,48 @@ +Feature: Code actions + As a developer editing xphp + I want lightbulb quick-fixes for imports and diagnostics + + Background: + Given the file at "/Models/User.xphp" contains the following lines: + """ + bar(); + """ + And the FQN index has been warmed on initialize + + Scenario: Emit a "Show references" lens for a declaration + When I request code lenses for "/Foo.xphp" + Then a code lens titled "Show references" is offered + + Scenario: Resolve a lens to a usage count + When I request code lenses for "/Foo.xphp" + And I resolve the first code lens + Then the resolved lens reads "2 usages" + And the resolved lens carries the reference locations diff --git a/features/edit/negative.feature b/features/edit/negative.feature new file mode 100644 index 0000000..be43b22 --- /dev/null +++ b/features/edit/negative.feature @@ -0,0 +1,25 @@ +Feature: Editing when there is nothing to change + As a developer editing xphp + I want no quick-fix or rename produced when there is nothing to act on + + Scenario: No code actions on a clean position + Given the file at "/Clean.xphp" contains the following lines: + """ + + """ + And the FQN index has been warmed on initialize + When I request completion after "Box<" at line 2 of "/Use.xphp" + Then a completion item labeled "" is offered + And no completion item labeled "" is offered + + Examples: + | prefix | match | other | + | Pla | Plastic | Metal | + | Met | Metal | Plastic | + | Woo | Wood | Plastic | + + Scenario: Filter suggestions by a generic bound + Given the file at "/Box.xphp" contains the following lines: + """ + {} + """ + And the file at "/Models.xphp" contains the following lines: + """ + save(); + } + """ + And the file at "/Service.xphp" contains the following lines: + """ + save(); + } + } + """ + And the FQN index has been warmed on initialize + + Scenario: Prepare the call-hierarchy item at a method + When I prepare call hierarchy on "save" at line 3 of "/Repository.xphp" + Then the prepared item is named "App\Repository::save" + + Scenario: Find incoming calls to a method + When I prepare call hierarchy on "save" at line 3 of "/Repository.xphp" + And I request incoming calls + Then an incoming call comes from "App\persist" + + Scenario: Find outgoing calls from a method body + When I prepare call hierarchy on "run" at line 3 of "/Service.xphp" + And I request outgoing calls + Then an outgoing call goes to "save" diff --git a/features/navigate/definition.feature b/features/navigate/definition.feature new file mode 100644 index 0000000..2e45479 --- /dev/null +++ b/features/navigate/definition.feature @@ -0,0 +1,74 @@ +Feature: Go to definition + As a developer editing xphp + I want "Go to Definition" to jump to a symbol's declaration across files + + Background: + Given the file at "Containers/Collection.xphp" contains the following lines: + """ + + { + private T[] $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function first(): ?T + { + return $this->items[0] ?? null; + } + } + """ + And the file at "Models/User.xphp" contains the following lines: + """ + (new User('Alice'), new User('Bob')); + $first = $users->first(); + """ + And the FQN index has been warmed on initialize + + Scenario: Jump to a generic class declared in another file + When I request "textDocument/definition" on "Collection" at line 9 of "Use.xphp" + Then the response points to "Containers/Collection.xphp" + And the target range covers the "Collection" class name + + Scenario: Jump to an imported class used as a type argument + When I request "textDocument/definition" on "User" at line 9 of "Use.xphp" + Then the response points to "Models/User.xphp" + And the target range covers the "User" class name + + @todo + Scenario: Jump through a generic method call to its declaration + When I request "textDocument/definition" on "first" at line 10 of "Use.xphp" + Then the response points to "Containers/Collection.xphp" + And the target range covers the "first" method declaration diff --git a/features/navigate/document_highlight.feature b/features/navigate/document_highlight.feature new file mode 100644 index 0000000..62b4201 --- /dev/null +++ b/features/navigate/document_highlight.feature @@ -0,0 +1,19 @@ +Feature: Document highlight + As a developer editing xphp + I want every occurrence of the symbol under the cursor highlighted in the file + + Background: + Given the file at "/Use.xphp" contains the following lines: + """ + name = $name; } + public function shout(): string { return ''; } + } + """ + And the FQN index has been warmed on initialize + + Scenario: Outline a class with the right shape + When I request "textDocument/documentSymbol" for "/User.xphp" + Then the outline contains a class "User" with 5 members + And the "User" selection range in "/User.xphp" covers "User" + + Scenario Outline: Each declared member appears nested in the outline + When I request "textDocument/documentSymbol" for "/User.xphp" + Then the class "User" has a member named "" + + Examples: + | kind | member | + | constant | ROLE | + | property | $name | + | property | $age | + | constructor | __construct | + | method | shout | diff --git a/features/navigate/implementation.feature b/features/navigate/implementation.feature new file mode 100644 index 0000000..c17a366 --- /dev/null +++ b/features/navigate/implementation.feature @@ -0,0 +1,30 @@ +Feature: Go to implementation + As a developer editing xphp + I want "Go to Implementation" to list the classes that implement an interface + + Background: + Given the file at "/Speaker.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/definition" on "Missing" at line 2 of "/Use.xphp" + Then the response is null + + Scenario: An interface with no implementers yields no locations + Given the file at "/Speaker.xphp" contains the following lines: + """ + name; + """ + And the FQN index has been warmed on initialize + + Scenario: Jump from a variable use to the class of its inferred type + When I request "textDocument/typeDefinition" on "$user" at line 3 of "/Use.xphp" + Then the response points to "/User.xphp" diff --git a/features/navigate/type_hierarchy.feature b/features/navigate/type_hierarchy.feature new file mode 100644 index 0000000..c6d7f1e --- /dev/null +++ b/features/navigate/type_hierarchy.feature @@ -0,0 +1,46 @@ +Feature: Type hierarchy + As a developer editing xphp + I want to walk the supertypes and subtypes of a class or interface + + Background: + Given the file at "/Animal.xphp" contains the following lines: + """ + + { + public function __construct(public T $item) + { + } + + public function get(): T + { + return $this->item; + } + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/foldingRange" for "/Box.xphp" + Then the response contains 3 folding ranges + And a folding range of kind "region" spans 2 to 12 + + Scenario: Single-line declarations are not folded + Given the file at "/One.xphp" contains the following lines: + """ + {} + """ + And the FQN index has been warmed on initialize + When I request "textDocument/foldingRange" for "/One.xphp" + Then the response contains 0 folding ranges diff --git a/features/understand/hover.feature b/features/understand/hover.feature new file mode 100644 index 0000000..af4cfe2 --- /dev/null +++ b/features/understand/hover.feature @@ -0,0 +1,32 @@ +Feature: Hover + As a developer editing xphp + I want hover to explain generic instantiations and type parameters + + Scenario: Hover over a generic instantiation shows the specialized type + Given the file at "/doc.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "Box" at line 2 of "/doc.xphp" + Then the hover contents contain "Specializes to:" + And the hover contents contain "XPHP\Generated\App\Box\" + + Scenario: Hover over a type parameter explains it and its bound + Given the file at "/box.xphp" contains the following lines: + """ + + { + public T $item; + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "T" at line 4 of "/box.xphp" + Then the hover contents contain "Type parameter" + And the hover contents contain "`T`" + And the hover contents contain "App\Box" + And the hover contents contain "Stringable" diff --git a/features/understand/inlay_hints.feature b/features/understand/inlay_hints.feature new file mode 100644 index 0000000..f34338c --- /dev/null +++ b/features/understand/inlay_hints.feature @@ -0,0 +1,34 @@ +Feature: Inlay hints + As a developer editing xphp + I want the concrete type a generic method resolved to shown after an assignment + + Background: + Given the file at "/Collection.xphp" contains the following lines: + """ + + { + public function first(): ?T { return null; } + } + """ + And the file at "/User.xphp" contains the following lines: + """ + (); + $first = $users->first(); + """ + And the FQN index has been warmed on initialize + + Scenario: Hint the substituted return type of a generic method call + When I request "textDocument/inlayHint" for the visible range of "/Use.xphp" + Then exactly 1 inlay hint is rendered + And an inlay hint ": ?App\Models\User" is rendered after "$first" on line 4 of "/Use.xphp" diff --git a/features/understand/negative.feature b/features/understand/negative.feature new file mode 100644 index 0000000..ed13e43 --- /dev/null +++ b/features/understand/negative.feature @@ -0,0 +1,37 @@ +Feature: Understanding when there is nothing to explain + As a developer editing xphp + I want hover/signature/inlay to stay quiet where there is nothing to show + + Scenario: Hover over a literal yields nothing + Given the file at "/doc.xphp" contains the following lines: + """ + { + public T $item; + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/semanticTokens/full" for "/box.xphp" + Then the semantic tokens are non-empty + And a "typeParameter" token covers "T" in "/box.xphp" diff --git a/features/understand/signature_help.feature b/features/understand/signature_help.feature new file mode 100644 index 0000000..7522a7f --- /dev/null +++ b/features/understand/signature_help.feature @@ -0,0 +1,29 @@ +Feature: Signature help + As a developer editing xphp + I want the parameter list shown with the active argument highlighted + + Background: + Given the file at "/lib.xphp" contains the following lines: + """ + " at line 1 of "/Use.xphp" + Then the active parameter is + + Examples: + | after | param | + | greet( | 0 | + | greet('a', | 1 | diff --git a/features/validate/diagnostics.feature b/features/validate/diagnostics.feature new file mode 100644 index 0000000..ee77f1b --- /dev/null +++ b/features/validate/diagnostics.feature @@ -0,0 +1,117 @@ +Feature: Diagnostics + As a developer editing xphp + I want compile-time problems surfaced as diagnostics + + Scenario: A well-formed file reports nothing + Given the file at "/Clean.xphp" contains the following lines: + """ + { public T $item; } + """ + And the file at "/BoxTwo.xphp" contains the following lines: + """ + { public T $item; } + """ + And the FQN index has been warmed on initialize + When I analyze "/BoxTwo.xphp" for diagnostics + Then a "xphp.definition" diagnostic is reported saying "already declared" + + Scenario: Report a generic bound violation + Given the file at "/Box.xphp" contains the following lines: + """ + + { + public T $item; + } + """ + And the file at "/Use.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then a "xphp.bound" diagnostic is reported saying "Generic bound violated" + And the "xphp.bound" diagnostic underlines "Box" + + Scenario: Report a constructor argument-type mismatch + Given the file at "/StringableBox.xphp" contains the following lines: + """ + + { + public function __construct(public T $item) {} + } + """ + And the file at "/Tag.xphp" contains the following lines: + """ + (new User()); + """ + And the FQN index has been warmed on initialize + When I analyze "/Bounds.xphp" for diagnostics + Then a "xphp.ctor-arg-mismatch" diagnostic is reported + And the "xphp.ctor-arg-mismatch" diagnostic underlines "new User()" diff --git a/test/Behat/EditContext.php b/test/Behat/EditContext.php new file mode 100644 index 0000000..bba9226 --- /dev/null +++ b/test/Behat/EditContext.php @@ -0,0 +1,345 @@ +world->positionOfNeedle($path, $line, $needle), + $newName, + ); + $this->world->request('textDocument/rename', $params); + } + + /** + * @Then the rename touches :count files + */ + public function theRenameTouchesFiles(int $count): void + { + $changes = $this->renameDocumentChanges(); + $this->world->assert( + count($changes) === $count, + sprintf('expected the rename to touch %d files, got %d', $count, count($changes)), + ); + } + + /** + * @Then the rename applies :count edits + */ + public function theRenameAppliesEdits(int $count): void + { + $total = 0; + foreach ($this->renameDocumentChanges() as $change) { + $total += count($change->edits ?? []); + } + $this->world->assert($total === $count, sprintf('expected %d rename edits, got %d', $count, $total)); + } + + /** + * @Then every rename edit inserts :text + */ + public function everyRenameEditInserts(string $text): void + { + foreach ($this->renameDocumentChanges() as $change) { + foreach ($change->edits ?? [] as $edit) { + $this->world->assert( + $edit->newText === $text, + sprintf('expected every rename edit to insert "%s", saw "%s"', $text, $edit->newText), + ); + } + } + } + + /** + * @Then every rename edit covers :text + */ + public function everyRenameEditCovers(string $text): void + { + foreach ($this->renameDocumentChanges() as $change) { + $uri = $change->textDocument->uri ?? ''; + foreach ($change->edits ?? [] as $edit) { + $covered = $this->world->textForRange($uri, $edit->range); + $this->world->assert( + $covered === $text, + sprintf('expected every rename edit to cover "%s", got "%s"', $text, $covered), + ); + } + } + } + + /** @return list TextDocumentEdit entries */ + private function renameDocumentChanges(): array + { + $edit = $this->world->last(); + $this->world->assert(is_object($edit), 'expected a WorkspaceEdit response, got ' . get_debug_type($edit)); + $changes = $edit->documentChanges ?? null; + $this->world->assert(is_array($changes), 'expected the WorkspaceEdit to carry documentChanges'); + return $changes; + } + + // ---- workspace/willRenameFiles ----------------------------------------- + + /** + * @When I rename the file :oldUri to :newUri + */ + public function iRenameTheFileTo(string $oldUri, string $newUri): void + { + $params = new RenameFilesParams([new FileRename($oldUri, $newUri)]); + $this->world->request('workspace/willRenameFiles', $params); + } + + /** + * @Then a willRename edit inserts :text + */ + public function aWillRenameEditInserts(string $text): void + { + foreach ($this->renameDocumentChanges() as $change) { + foreach ($change->edits ?? [] as $edit) { + if ($edit->newText === $text) { + return; + } + } + } + $this->world->fail(sprintf('expected a willRename edit inserting "%s"', $text)); + } + + // ---- code actions ------------------------------------------------------ + + /** + * @When I request code actions on :needle at line :line of :path + */ + public function iRequestCodeActionsOnAtLineOf(string $needle, int $line, string $path): void + { + $pos = $this->world->positionOfNeedle($path, $line, $needle); + $params = new CodeActionParams( + new TextDocumentIdentifier($path), + new Range($pos, $pos), + new CodeActionContext([]), + ); + $this->world->request('textDocument/codeAction', $params); + } + + /** + * @When I request code actions for an undefined-name diagnostic on :needle at line :line of :path + */ + public function iRequestCodeActionsForADiagnosticOnAtLineOf(string $needle, int $line, string $path): void + { + $start = $this->world->positionOfNeedle($path, $line, $needle); + $end = new Position($start->line, $start->character + strlen($needle)); + $range = new Range($start, $end); + $diagnostic = new Diagnostic($range, "Undefined: {$needle}", null, DiagnosticCode::UndefinedName->value); + $params = new CodeActionParams( + new TextDocumentIdentifier($path), + $range, + new CodeActionContext([$diagnostic]), + ); + $this->world->request('textDocument/codeAction', $params); + } + + /** + * @Then a code action titled :title is offered + */ + public function aCodeActionTitledIsOffered(string $title): void + { + $titles = []; + foreach ((array) $this->world->last() as $action) { + if ($action instanceof CodeAction) { + $titles[] = $action->title; + if ($action->title === $title) { + return; + } + } + } + $this->world->fail(sprintf('expected a code action titled "%s"; got: [%s]', $title, implode(', ', $titles))); + } + + /** + * @Then no code actions are offered + */ + public function noCodeActionsAreOffered(): void + { + $actions = array_filter((array) $this->world->last(), static fn ($a): bool => $a instanceof CodeAction); + $this->world->assert( + $actions === [], + sprintf('expected no code actions, got %d', count($actions)), + ); + } + + /** + * @Then the :title action has kind :kind + */ + public function theActionHasKind(string $title, string $kind): void + { + $action = $this->findAction($title); + $this->world->assert( + $action->kind === $kind, + sprintf('expected action "%s" to have kind "%s", got "%s"', $title, $kind, (string) $action->kind), + ); + } + + /** + * @Then the :title action inserts :text + */ + public function theActionInserts(string $title, string $text): void + { + foreach ($this->actionEdits($this->findAction($title)) as $entry) { + if (trim($entry['edit']->newText) === trim($text)) { + return; + } + } + $this->world->fail(sprintf('expected the "%s" action to insert "%s"', $title, $text)); + } + + /** + * @Then the :title action removes the :text line + */ + public function theActionRemovesTheLine(string $title, string $text): void + { + foreach ($this->actionEdits($this->findAction($title)) as $entry) { + $covered = $this->world->textForRange($entry['uri'], $entry['edit']->range); + if ($entry['edit']->newText === '' && trim($covered) === trim($text)) { + return; + } + } + $this->world->fail(sprintf('expected the "%s" action to delete the "%s" line', $title, $text)); + } + + /** + * @Then the :title action replaces :old with :new + */ + public function theActionReplaces(string $title, string $old, string $new): void + { + foreach ($this->actionEdits($this->findAction($title)) as $entry) { + $covered = $this->world->textForRange($entry['uri'], $entry['edit']->range); + if ($entry['edit']->newText === $new && $covered === $old) { + return; + } + } + $this->world->fail(sprintf('expected the "%s" action to replace "%s" with "%s"', $title, $old, $new)); + } + + private function findAction(string $title): CodeAction + { + foreach ((array) $this->world->last() as $action) { + if ($action instanceof CodeAction && $action->title === $title) { + return $action; + } + } + $this->world->fail(sprintf('no code action titled "%s"', $title)); + } + + /** @return list */ + private function actionEdits(CodeAction $action): array + { + $out = []; + foreach ($action->edit->documentChanges ?? [] as $change) { + $uri = $change->textDocument->uri ?? ''; + foreach ($change->edits ?? [] as $edit) { + $out[] = ['uri' => $uri, 'edit' => $edit]; + } + } + return $out; + } + + // ---- code lens --------------------------------------------------------- + + /** + * @When I request code lenses for :path + */ + public function iRequestCodeLensesFor(string $path): void + { + $params = new CodeLensParams(new TextDocumentIdentifier($path)); + $this->world->request('textDocument/codeLens', $params); + } + + /** + * @When I resolve the first code lens + */ + public function iResolveTheFirstCodeLens(): void + { + $lenses = $this->world->last(); + $this->world->assert(is_array($lenses) && isset($lenses[0]) && $lenses[0] instanceof CodeLens, 'expected at least one code lens to resolve'); + $this->world->request('codeLens/resolve', $lenses[0]); + } + + /** + * @Then a code lens titled :title is offered + */ + public function aCodeLensTitledIsOffered(string $title): void + { + $titles = []; + foreach ((array) $this->world->last() as $lens) { + if ($lens instanceof CodeLens && $lens->command !== null) { + $titles[] = $lens->command->title; + if ($lens->command->title === $title) { + return; + } + } + } + $this->world->fail(sprintf('expected a code lens titled "%s"; got: [%s]', $title, implode(', ', $titles))); + } + + /** + * @Then the resolved lens reads :title + */ + public function theResolvedLensReads(string $title): void + { + $lens = $this->world->last(); + $this->world->assert($lens instanceof CodeLens && $lens->command !== null, 'expected a resolved code lens with a command'); + $this->world->assert( + $lens->command->title === $title, + sprintf('expected resolved lens to read "%s", got "%s"', $title, $lens->command->title), + ); + } + + /** + * @Then the resolved lens carries the reference locations + */ + public function theResolvedLensCarriesTheReferenceLocations(): void + { + $lens = $this->world->last(); + $this->world->assert($lens instanceof CodeLens && $lens->command !== null, 'expected a resolved code lens with a command'); + $this->world->assert( + $lens->command->command === 'editor.action.showReferences', + sprintf('expected showReferences command, got "%s"', (string) $lens->command->command), + ); + $args = $lens->command->arguments ?? []; + $this->world->assert( + isset($args[2]) && is_array($args[2]) && $args[2] !== [], + 'expected the resolved lens to carry a non-empty locations array', + ); + } +} diff --git a/test/Behat/FindContext.php b/test/Behat/FindContext.php new file mode 100644 index 0000000..9c4306a --- /dev/null +++ b/test/Behat/FindContext.php @@ -0,0 +1,182 @@ +world->positionOfNeedle($path, $line, $needle); + $cursor = new Position($start->line, $start->character + strlen($needle)); + $params = new CompletionParams(new TextDocumentIdentifier($path), $cursor); + $this->world->request('textDocument/completion', $params); + } + + /** + * @Then a completion item labeled :label is offered + */ + public function aCompletionItemLabeledIsOffered(string $label): void + { + $labels = $this->completionLabels(); + $this->world->assert( + in_array($label, $labels, true), + sprintf('expected a completion item labeled "%s"; got: [%s]', $label, implode(', ', $labels)), + ); + } + + /** + * @Then no completion item labeled :label is offered + */ + public function noCompletionItemLabeledIsOffered(string $label): void + { + $labels = $this->completionLabels(); + $this->world->assert( + !in_array($label, $labels, true), + sprintf('expected no completion item labeled "%s"; got: [%s]', $label, implode(', ', $labels)), + ); + } + + /** + * @Then the completion item :label inserts :text + */ + public function theCompletionItemInserts(string $label, string $text): void + { + foreach ($this->completionItems() as $item) { + if ($item->label === $label) { + $this->world->assert( + $item->insertText === $text, + sprintf('expected "%s" to insert "%s", got "%s"', $label, $text, (string) $item->insertText), + ); + return; + } + } + $this->world->fail(sprintf('no completion item labeled "%s"', $label)); + } + + /** + * @Then the completion item :label has kind :kind + */ + public function theCompletionItemHasKind(string $label, string $kind): void + { + $item = $this->findItem($label); + $want = $this->completionKind($kind); + $this->world->assert( + $item->kind === $want, + sprintf('expected "%s" to have kind %s, got %s', $label, $kind, var_export($item->kind, true)), + ); + } + + /** + * @Then the completion item :label has detail :detail + */ + public function theCompletionItemHasDetail(string $label, string $detail): void + { + $item = $this->findItem($label); + $this->world->assert( + $item->detail === $detail, + sprintf('expected "%s" detail "%s", got "%s"', $label, $detail, (string) $item->detail), + ); + } + + private function findItem(string $label): CompletionItem + { + foreach ($this->completionItems() as $item) { + if ($item->label === $label) { + return $item; + } + } + $this->world->fail(sprintf('no completion item labeled "%s"', $label)); + } + + private function completionKind(string $kind): int + { + return match ($kind) { + 'class' => CompletionItemKind::CLASS_, + 'interface' => CompletionItemKind::INTERFACE, + 'enum' => CompletionItemKind::ENUM, + 'function' => CompletionItemKind::FUNCTION, + 'method' => CompletionItemKind::METHOD, + 'property' => CompletionItemKind::PROPERTY, + 'keyword' => CompletionItemKind::KEYWORD, + default => throw new \RuntimeException("unknown completion kind: {$kind}"), + }; + } + + /** + * @When I resolve a class completion item for :fqn + */ + public function iResolveAClassCompletionItemFor(string $fqn): void + { + $shortName = substr((string) strrchr($fqn, '\\'), 1) ?: $fqn; + $item = new CompletionItem( + label: $shortName, + kind: CompletionItemKind::CLASS_, + data: ['kind' => 'class', 'fqn' => $fqn], + ); + $this->world->request('completionItem/resolve', $item); + } + + /** + * @Then the resolved item documentation is :text + */ + public function theResolvedItemDocumentationIs(string $text): void + { + $item = $this->world->last(); + $this->world->assert($item instanceof CompletionItem, 'expected a CompletionItem response, got ' . get_debug_type($item)); + $doc = $item->documentation; + $value = $doc instanceof MarkupContent ? $doc->value : (is_string($doc) ? $doc : ''); + $this->world->assert( + trim($value) === trim($text), + sprintf('expected resolved documentation "%s", got: %s', $text, $value === '' ? '' : $value), + ); + } + + /** + * @Then the resolved item has no documentation + */ + public function theResolvedItemHasNoDocumentation(): void + { + $item = $this->world->last(); + $this->world->assert($item instanceof CompletionItem, 'expected a CompletionItem response, got ' . get_debug_type($item)); + $this->world->assert( + $item->documentation === null, + 'expected no documentation on the resolved item', + ); + } + + /** @return list */ + private function completionItems(): array + { + $response = $this->world->last(); + $items = $response instanceof CompletionList ? $response->items : $response; + $this->world->assert(is_array($items), 'expected a completion list response'); + return $items; + } + + /** @return list */ + private function completionLabels(): array + { + return array_map(static fn (CompletionItem $i): string => $i->label, $this->completionItems()); + } +} diff --git a/test/Behat/NavigateContext.php b/test/Behat/NavigateContext.php new file mode 100644 index 0000000..ec73d95 --- /dev/null +++ b/test/Behat/NavigateContext.php @@ -0,0 +1,522 @@ + the hierarchy item resolved by a prepare step */ + private array $hierarchyItem = []; + + public function __construct(private readonly World $world) + { + } + + // ---- call hierarchy ---------------------------------------------------- + + /** + * @When I prepare call hierarchy on :needle at line :line of :path + */ + public function iPrepareCallHierarchyOnAtLineOf(string $needle, int $line, string $path): void + { + $params = new TextDocumentPositionParams(new TextDocumentIdentifier($path), $this->world->positionOfNeedle($path, $line, $needle)); + $items = $this->world->request('textDocument/prepareCallHierarchy', $params); + $this->hierarchyItem = $this->itemDict(is_array($items) ? ($items[0] ?? null) : null, $path); + } + + /** + * @When I request incoming calls + */ + public function iRequestIncomingCalls(): void + { + $this->world->request('callHierarchy/incomingCalls', ['item' => $this->hierarchyItem]); + } + + /** + * @When I request outgoing calls + */ + public function iRequestOutgoingCalls(): void + { + $this->world->request('callHierarchy/outgoingCalls', ['item' => $this->hierarchyItem]); + } + + /** + * @Then the prepared item is named :name + */ + public function thePreparedItemIsNamed(string $name): void + { + $names = $this->hierarchyNames($this->world->last(), 'name'); + $this->world->assert( + in_array($name, $names, true), + sprintf('expected a prepared item named "%s"; got: [%s]', $name, implode(', ', $names)), + ); + } + + /** + * @Then an incoming call comes from :name + */ + public function anIncomingCallComesFrom(string $name): void + { + $names = $this->hierarchyNames($this->world->last(), 'from'); + $this->world->assert( + in_array($name, $names, true), + sprintf('expected an incoming call from "%s"; got: [%s]', $name, implode(', ', $names)), + ); + } + + /** + * @Then an outgoing call goes to :name + */ + public function anOutgoingCallGoesTo(string $name): void + { + $names = $this->hierarchyNames($this->world->last(), 'to'); + $this->world->assert( + in_array($name, $names, true), + sprintf('expected an outgoing call to "%s"; got: [%s]', $name, implode(', ', $names)), + ); + } + + /** + * Pull a name list out of a hierarchy response. $field is 'name' (prepared + * items), 'from' (incoming) or 'to' (outgoing). + * + * @return list + */ + private function hierarchyNames(mixed $response, string $field): array + { + $this->world->assert(is_array($response), 'expected a hierarchy list response'); + $names = []; + foreach ($response as $entry) { + $target = match ($field) { + 'name' => $entry, + 'from' => $entry->from ?? null, + 'to' => $entry->to ?? null, + default => null, + }; + if (is_object($target) && isset($target->name)) { + $names[] = $target->name; + } elseif (is_array($target) && isset($target['name'])) { + $names[] = $target['name']; + } + } + return $names; + } + + /** + * Convert a prepared item (object or array) into the array dict the + * incoming/outgoing handlers expect (the client round-trips it as JSON). + * + * @return array + */ + private function itemDict(mixed $item, string $fallbackUri): array + { + if (is_array($item)) { + return $item + ['uri' => $fallbackUri]; + } + if (is_object($item)) { + return [ + 'uri' => $item->uri ?? $fallbackUri, + 'data' => $item->data ?? [], + ]; + } + return ['uri' => $fallbackUri, 'data' => []]; + } + + // ---- type hierarchy ---------------------------------------------------- + + /** + * @When I prepare type hierarchy on :needle at line :line of :path + */ + public function iPrepareTypeHierarchyOnAtLineOf(string $needle, int $line, string $path): void + { + $params = new TextDocumentPositionParams(new TextDocumentIdentifier($path), $this->world->positionOfNeedle($path, $line, $needle)); + $items = $this->world->request('textDocument/prepareTypeHierarchy', $params); + $this->hierarchyItem = $this->itemDict(is_array($items) ? ($items[0] ?? null) : null, $path); + } + + /** + * @When I request supertypes + */ + public function iRequestSupertypes(): void + { + $this->world->request('typeHierarchy/supertypes', ['item' => $this->hierarchyItem]); + } + + /** + * @When I request subtypes + */ + public function iRequestSubtypes(): void + { + $this->world->request('typeHierarchy/subtypes', ['item' => $this->hierarchyItem]); + } + + /** + * @Then a supertype is named :name + */ + public function aSupertypeIsNamed(string $name): void + { + $this->assertRelatedTypeNamed($name, 'supertype'); + } + + /** + * @Then a subtype is named :name + */ + public function aSubtypeIsNamed(string $name): void + { + $this->assertRelatedTypeNamed($name, 'subtype'); + } + + private function assertRelatedTypeNamed(string $name, string $label): void + { + $names = $this->hierarchyNames($this->world->last(), 'name'); + $this->world->assert( + in_array($name, $names, true), + sprintf('expected a %s named "%s"; got: [%s]', $label, $name, implode(', ', $names)), + ); + } + + /** + * @Then a supertype :name has fqn :fqn + * @Then a subtype :name has fqn :fqn + */ + public function aRelatedTypeHasFqn(string $name, string $fqn): void + { + foreach ((array) $this->world->last() as $entry) { + $entryName = is_array($entry) ? ($entry['name'] ?? null) : ($entry->name ?? null); + if ($entryName !== $name) { + continue; + } + $entryFqn = is_array($entry) ? ($entry['data']['fqn'] ?? null) : ($entry->data['fqn'] ?? null); + $this->world->assert( + $entryFqn === $fqn, + sprintf('expected %s to have fqn "%s", got "%s"', $name, $fqn, (string) $entryFqn), + ); + return; + } + $this->world->fail(sprintf('no related type named "%s"', $name)); + } + + // ---- covered-text assertions (references / highlights / outline) -------- + + /** + * @Then a reference in :path covers :text + */ + public function aReferenceInCovers(string $path, string $text): void + { + $seen = []; + foreach ((array) $this->world->last() as $loc) { + if (!$loc instanceof Location || !$this->matchesPath($loc->uri, $path)) { + continue; + } + $covered = $this->world->textForRange($loc->uri, $loc->range); + $seen[] = $covered; + if ($covered === $text) { + return; + } + } + $this->world->fail(sprintf('expected a reference in "%s" covering "%s"; got: [%s]', $path, $text, implode(', ', $seen))); + } + + /** + * @Then each highlight covers :text in :path + */ + public function eachHighlightCoversIn(string $text, string $path): void + { + $highlights = $this->world->last(); + $this->world->assert(is_array($highlights) && $highlights !== [], 'expected a non-empty highlight list'); + foreach ($highlights as $highlight) { + $covered = $this->world->textForRange($path, $highlight->range); + $this->world->assert( + $covered === $text, + sprintf('expected each highlight to cover "%s", got "%s"', $text, $covered), + ); + } + } + + private function matchesPath(string $uri, string $path): bool + { + return $uri === $path + || $this->world->stripFileScheme($uri) === $path + || str_ends_with($uri, '/' . $path) + || str_ends_with($this->world->stripFileScheme($uri), '/' . $path); + } + + // ---- document-symbol structure ----------------------------------------- + + /** + * @Then the outline contains a class :name with :count members + */ + public function theOutlineContainsAClassWithMembers(string $name, int $count): void + { + $class = $this->topLevelSymbol($name, SymbolKind::CLASS_); + $this->world->assert($class !== null, sprintf('expected a top-level class named "%s"', $name)); + $children = is_array($class->children) ? $class->children : []; + $this->world->assert( + count($children) === $count, + sprintf('expected class "%s" to have %d members, got %d', $name, $count, count($children)), + ); + } + + /** + * @Then the class :name has a :kind member named :member + */ + public function theClassHasAMemberNamed(string $name, string $kind, string $member): void + { + $class = $this->topLevelSymbol($name, SymbolKind::CLASS_); + $this->world->assert($class !== null, sprintf('expected a top-level class named "%s"', $name)); + $wantKind = $this->symbolKind($kind); + foreach (is_array($class->children) ? $class->children : [] as $child) { + if ($child instanceof DocumentSymbol && $child->name === $member && $child->kind === $wantKind) { + return; + } + } + $this->world->fail(sprintf('expected class "%s" to have a %s member named "%s"', $name, $kind, $member)); + } + + /** + * @Then the :name selection range in :path covers :text + */ + public function theSelectionRangeInCovers(string $name, string $path, string $text): void + { + $symbol = $this->topLevelSymbol($name, SymbolKind::CLASS_); + $this->world->assert($symbol !== null, sprintf('expected a top-level class named "%s"', $name)); + $covered = $this->world->textForRange($path, $symbol->selectionRange); + $this->world->assert( + $covered === $text, + sprintf('expected "%s" selection range to cover "%s", got "%s"', $name, $text, $covered), + ); + } + + private function topLevelSymbol(string $name, int $kind): ?DocumentSymbol + { + foreach ((array) $this->world->last() as $symbol) { + if ($symbol instanceof DocumentSymbol && $symbol->name === $name && $symbol->kind === $kind) { + return $symbol; + } + } + return null; + } + + // ---- workspace-symbol exactness ---------------------------------------- + + /** + * @Then there is exactly :count workspace symbol + * @Then there are exactly :count workspace symbols + */ + public function thereAreExactlyWorkspaceSymbols(int $count): void + { + $names = $this->symbolNames(); + $this->world->assert( + count($names) === $count, + sprintf('expected exactly %d workspace symbols, got %d: [%s]', $count, count($names), implode(', ', $names)), + ); + } + + /** + * @Then the workspace symbol :name has kind :kind + */ + public function theWorkspaceSymbolHasKind(string $name, string $kind): void + { + $wantKind = $this->symbolKind($kind); + foreach ((array) $this->world->last() as $symbol) { + if (is_object($symbol) && ($symbol->name ?? null) === $name) { + $this->world->assert( + ($symbol->kind ?? null) === $wantKind, + sprintf('expected workspace symbol "%s" to have kind %s', $name, $kind), + ); + return; + } + } + $this->world->fail(sprintf('no workspace symbol named "%s"', $name)); + } + + // ---- definition / references / highlight / symbols --------------------- + + /** + * @Then the response points to :path + */ + public function theResponsePointsTo(string $path): void + { + $location = $this->world->expectLocation(); + $uri = $location->uri; + $bare = $this->world->stripFileScheme($uri); + // Open-doc handlers return the bare workspace uri; worse-reflection-backed + // handlers (typeDefinition) emit file:// URIs -- accept either. + $ok = $uri === $path + || $bare === $path + || str_ends_with($uri, '/' . $path) + || str_ends_with($bare, '/' . $path); + $this->world->assert($ok, sprintf('expected response to point to "%s", got "%s"', $path, $uri)); + } + + /** + * @Then the response contains :count locations + */ + public function theResponseContainsLocations(int $count): void + { + $uris = $this->world->locationUris($this->world->last()); + $this->world->assert( + count($uris) === $count, + sprintf('expected %d locations, got %d: [%s]', $count, count($uris), implode(', ', $uris)), + ); + } + + /** + * @When I search workspace symbols for :query + */ + public function iSearchWorkspaceSymbolsFor(string $query): void + { + $this->world->request('workspace/symbol', new WorkspaceSymbolParams($query)); + } + + /** + * @Then the workspace symbols include :name + */ + public function theWorkspaceSymbolsInclude(string $name): void + { + $names = $this->symbolNames(); + $this->world->assert( + in_array($name, $names, true), + sprintf('expected workspace symbols to include "%s"; got: [%s]', $name, implode(', ', $names)), + ); + } + + /** + * @Then the workspace symbols exclude :name + */ + public function theWorkspaceSymbolsExclude(string $name): void + { + $names = $this->symbolNames(); + $this->world->assert( + !in_array($name, $names, true), + sprintf('expected workspace symbols to exclude "%s"; got: [%s]', $name, implode(', ', $names)), + ); + } + + /** @return list */ + private function symbolNames(): array + { + $response = $this->world->last(); + $this->world->assert(is_array($response), 'expected a workspace-symbol list response'); + $names = []; + foreach ($response as $symbol) { + if (is_object($symbol) && isset($symbol->name)) { + $names[] = $symbol->name; + } + } + return $names; + } + + /** + * @Then the document outline contains a :kind named :name + */ + public function theDocumentOutlineContainsANamed(string $kind, string $name): void + { + $response = $this->world->last(); + $this->world->assert(is_array($response), 'expected a document-symbol list response'); + $found = $this->findSymbol($response, $name, $this->symbolKind($kind)); + $this->world->assert( + $found, + sprintf('expected outline to contain a %s named "%s"', $kind, $name), + ); + } + + /** + * @param list $symbols + */ + private function findSymbol(array $symbols, string $name, int $kind): bool + { + foreach ($symbols as $symbol) { + if (!$symbol instanceof DocumentSymbol) { + continue; + } + if ($symbol->name === $name && $symbol->kind === $kind) { + return true; + } + if (is_array($symbol->children) && $this->findSymbol($symbol->children, $name, $kind)) { + return true; + } + } + return false; + } + + private function symbolKind(string $kind): int + { + return match ($kind) { + 'class' => SymbolKind::CLASS_, + 'interface' => SymbolKind::INTERFACE, + 'enum' => SymbolKind::ENUM, + 'method' => SymbolKind::METHOD, + 'constructor' => SymbolKind::CONSTRUCTOR, + 'property' => SymbolKind::PROPERTY, + 'constant' => SymbolKind::CONSTANT, + 'function' => SymbolKind::FUNCTION, + default => throw new \RuntimeException("unknown symbol kind: {$kind}"), + }; + } + + /** + * @Then the response contains :count highlights + */ + public function theResponseContainsHighlights(int $count): void + { + $response = $this->world->last(); + $this->world->assert(is_array($response), 'expected a highlight list response'); + $this->world->assert( + count($response) === $count, + sprintf('expected %d highlights, got %d', $count, count($response)), + ); + } + + /** + * @Then the response includes a location in :path + */ + public function theResponseIncludesALocationIn(string $path): void + { + $uris = $this->world->locationUris($this->world->last()); + foreach ($uris as $uri) { + if ($uri === $path || $this->world->stripFileScheme($uri) === $path || str_ends_with($uri, '/' . $path)) { + return; + } + } + $this->world->fail(sprintf('expected a location in "%s"; got: [%s]', $path, implode(', ', $uris))); + } + + /** + * @Then the target range covers the :name class name + */ + public function theTargetRangeCoversTheClassName(string $name): void + { + $covered = $this->world->textInRange($this->world->expectLocation()); + $this->world->assert( + $covered === $name, + sprintf('expected target range to cover "%s", got "%s"', $name, $covered), + ); + } + + /** + * @Then the target range covers the :name method declaration + */ + public function theTargetRangeCoversTheMethodDeclaration(string $name): void + { + $covered = $this->world->textInRange($this->world->expectLocation()); + $this->world->assert( + $covered === $name, + sprintf('expected target range to cover "%s", got "%s"', $name, $covered), + ); + } +} diff --git a/test/Behat/ServerContext.php b/test/Behat/ServerContext.php new file mode 100644 index 0000000..b4fe8a5 --- /dev/null +++ b/test/Behat/ServerContext.php @@ -0,0 +1,119 @@ +world->openFile($path, $lines->getRaw()); + } + + /** + * @Given the FQN index has been warmed on initialize + */ + public function theFqnIndexHasBeenWarmedOnInitialize(): void + { + // The index warms on the Initialized event, which fires during the + // initialize handshake. With an empty rootPath the filesystem walk is a + // no-op; open documents resolve live. + $this->world->boot(); + } + + // ---- generic request dispatchers --------------------------------------- + + /** + * @When I request :method on :needle at line :line of :path + */ + public function iRequestOnAtLineOf(string $method, string $needle, int $line, string $path): void + { + $pos = $this->world->positionOfNeedle($path, $line, $needle); + $doc = new TextDocumentIdentifier($path); + + match ($method) { + 'textDocument/definition' => $this->world->request($method, new DefinitionParams($doc, $pos)), + 'textDocument/typeDefinition' => $this->world->request($method, new TypeDefinitionParams($doc, $pos)), + 'textDocument/references' => $this->world->request($method, new ReferenceParams(new ReferenceContext(true), $doc, $pos)), + 'textDocument/implementation' => $this->world->request($method, new ImplementationParams($doc, $pos)), + 'textDocument/documentHighlight' => $this->world->request($method, new DocumentHighlightParams($doc, $pos)), + 'textDocument/hover' => $this->world->request($method, new HoverParams($doc, $pos)), + default => throw new \RuntimeException("Unsupported position method: {$method}"), + }; + } + + /** + * @When I request :method for :path + */ + public function iRequestForDocument(string $method, string $path): void + { + $doc = new TextDocumentIdentifier($path); + + match ($method) { + 'textDocument/documentSymbol' => $this->world->request($method, new DocumentSymbolParams($doc)), + 'textDocument/foldingRange' => $this->world->request($method, new FoldingRangeParams($doc)), + // The handler reads an unwrapped {uri} map (no published *Params type), + // so send the wire shape and let PassThroughArgumentResolver deliver it. + 'textDocument/semanticTokens/full' => $this->world->request($method, ['textDocument' => ['uri' => $path]]), + default => throw new \RuntimeException("Unsupported document method: {$method}"), + }; + } + + /** + * @When I request :method for the visible range of :path + */ + public function iRequestForTheVisibleRangeOf(string $method, string $path): void + { + if ($method !== 'textDocument/inlayHint') { + throw new \RuntimeException("Unsupported range method: {$method}"); + } + $params = new InlayHintParams( + new TextDocumentIdentifier($path), + new Range(new Position(0, 0), new Position(99999, 0)), + ); + $this->world->request($method, $params); + } + + /** + * Cross-theme negative assertion. + * + * @Then the response is null + */ + public function theResponseIsNull(): void + { + $this->world->assert( + $this->world->last() === null, + 'expected a null response, got ' . get_debug_type($this->world->last()), + ); + } +} diff --git a/test/Behat/UnderstandContext.php b/test/Behat/UnderstandContext.php new file mode 100644 index 0000000..a2c644b --- /dev/null +++ b/test/Behat/UnderstandContext.php @@ -0,0 +1,277 @@ +world->positionOfNeedle($path, $line, $needle); + $cursor = new Position($start->line, $start->character + strlen($needle)); + $params = new SignatureHelpParams(new TextDocumentIdentifier($path), $cursor); + $this->world->request('textDocument/signatureHelp', $params); + } + + /** + * @Then the active signature label contains :text + */ + public function theActiveSignatureLabelContains(string $text): void + { + $help = $this->world->last(); + $this->world->assert($help instanceof SignatureHelp, 'expected a SignatureHelp response, got ' . get_debug_type($help)); + $index = $help->activeSignature ?? 0; + $signature = $help->signatures[$index] ?? $help->signatures[0] ?? null; + $this->world->assert($signature !== null, 'expected at least one signature'); + $this->world->assert( + str_contains($signature->label, $text), + sprintf('expected active signature label to contain "%s", got "%s"', $text, $signature->label), + ); + } + + /** + * @Then the active signature label is :label + */ + public function theActiveSignatureLabelIs(string $label): void + { + $help = $this->world->last(); + $this->world->assert($help instanceof SignatureHelp, 'expected a SignatureHelp response, got ' . get_debug_type($help)); + $index = $help->activeSignature ?? 0; + $signature = $help->signatures[$index] ?? $help->signatures[0] ?? null; + $this->world->assert($signature !== null, 'expected at least one signature'); + $this->world->assert( + $signature->label === $label, + sprintf('expected active signature label "%s", got "%s"', $label, $signature->label), + ); + } + + /** + * @Then the active parameter is :index + */ + public function theActiveParameterIs(int $index): void + { + $help = $this->world->last(); + $this->world->assert($help instanceof SignatureHelp, 'expected a SignatureHelp response, got ' . get_debug_type($help)); + $this->world->assert( + $help->activeParameter === $index, + sprintf('expected active parameter %d, got %s', $index, var_export($help->activeParameter, true)), + ); + } + + /** + * @Then the hover contents contain :text + */ + public function theHoverContentsContain(string $text): void + { + $this->assertHoverContains($text); + } + + /** + * @Then the semantic tokens are non-empty + */ + public function theSemanticTokensAreNonEmpty(): void + { + $tokens = $this->world->last(); + $this->world->assert($tokens instanceof SemanticTokens, 'expected a SemanticTokens response, got ' . get_debug_type($tokens)); + $this->world->assert($tokens->data !== [], 'expected a non-empty token stream'); + $this->world->assert(count($tokens->data) % 5 === 0, 'expected the token stream length to be a multiple of 5'); + } + + /** + * @Then the semantic tokens include a :type token + */ + public function theSemanticTokensIncludeAToken(string $type): void + { + $tokens = $this->world->last(); + $this->world->assert($tokens instanceof SemanticTokens, 'expected a SemanticTokens response, got ' . get_debug_type($tokens)); + $typeIndex = array_search($type, TokenLegend::TOKEN_TYPES, true); + $this->world->assert($typeIndex !== false, "unknown token type: {$type}"); + + // Packed as 5 ints per token; the type index is the 4th of each tuple. + for ($i = 0; $i + 4 < count($tokens->data); $i += 5) { + if ($tokens->data[$i + 3] === $typeIndex) { + return; + } + } + $this->world->fail(sprintf('expected a "%s" (index %d) token in the stream', $type, $typeIndex)); + } + + /** + * @Then the response contains :count folding ranges + */ + public function theResponseContainsFoldingRanges(int $count): void + { + $response = $this->world->last(); + $this->world->assert(is_array($response), 'expected a folding-range list response'); + $this->world->assert( + count($response) === $count, + sprintf('expected %d folding ranges, got %d', $count, count($response)), + ); + } + + /** + * @Then a folding range spans lines :start to :end + */ + public function aFoldingRangeSpansLinesTo(int $start, int $end): void + { + $seen = []; + foreach ((array) $this->world->last() as $range) { + $seen[] = sprintf('%d-%d', $range->startLine, $range->endLine); + if ($range->startLine === $start && $range->endLine === $end) { + return; + } + } + $this->world->fail(sprintf('expected a folding range %d-%d; got: [%s]', $start, $end, implode(', ', $seen))); + } + + /** + * @Then a folding range of kind :kind spans :start to :end + */ + public function aFoldingRangeOfKindSpans(string $kind, int $start, int $end): void + { + $seen = []; + foreach ((array) $this->world->last() as $range) { + $seen[] = sprintf('%s %d-%d', (string) ($range->kind ?? '?'), $range->startLine, $range->endLine); + if (($range->kind ?? null) === $kind && $range->startLine === $start && $range->endLine === $end) { + return; + } + } + $this->world->fail(sprintf('expected a %s folding range %d-%d; got: [%s]', $kind, $start, $end, implode(', ', $seen))); + } + + /** + * @Then a :type token covers :text in :path + */ + public function aTokenCoversIn(string $type, string $text, string $path): void + { + $tokens = $this->world->last(); + $this->world->assert($tokens instanceof SemanticTokens, 'expected a SemanticTokens response, got ' . get_debug_type($tokens)); + foreach ($this->world->decodeSemanticTokens($tokens, $path) as $token) { + if ($token['type'] === $type && $token['text'] === $text) { + return; + } + } + $this->world->fail(sprintf('expected a %s token covering "%s" in %s', $type, $text, $path)); + } + + /** + * @Then there is no hover + */ + public function thereIsNoHover(): void + { + $this->world->assert( + $this->world->last() === null, + 'expected no hover, got ' . get_debug_type($this->world->last()), + ); + } + + /** + * @Then exactly :count inlay hint is rendered + * @Then exactly :count inlay hints are rendered + */ + public function exactlyInlayHintsAreRendered(int $count): void + { + $hints = $this->world->last(); + $this->world->assert(is_array($hints), 'expected an inlay-hint list response'); + $actual = count(array_filter($hints, static fn ($h): bool => $h instanceof InlayHint)); + $this->world->assert( + $actual === $count, + sprintf('expected exactly %d inlay hints, got %d', $count, $actual), + ); + } + + /** + * @Then an inlay hint :label is rendered after :var on line :line of :path + */ + public function anInlayHintIsRenderedAfterOnLineOf(string $label, string $var, int $line, string $path): void + { + $expectedChar = $this->world->positionOfNeedle($path, $line, $var)->character + strlen($var); + $hints = $this->world->last(); + $this->world->assert(is_array($hints), 'expected an inlay-hint list response'); + + $seen = []; + foreach ($hints as $hint) { + if (!$hint instanceof InlayHint) { + continue; + } + $hintLabel = is_string($hint->label) ? $hint->label : ''; + $seen[] = sprintf('%s@L%d:%d', $hintLabel, $hint->position->line, $hint->position->character); + if ($hintLabel === $label && $hint->position->line === $line && $hint->position->character === $expectedChar) { + return; + } + } + + $this->world->fail(sprintf( + 'no inlay hint "%s" just after "%s" (line %d, char %d); got: [%s]', + $label, + $var, + $line, + $expectedChar, + implode(', ', $seen) ?: '', + )); + } + + /** + * @Then an inlay hint :label is rendered after :var on line :line + */ + public function anInlayHintIsRenderedAfterOnLine(string $label, string $var, int $line): void + { + $hints = $this->world->last(); + $this->world->assert(is_array($hints), 'expected an inlay-hint list response'); + + $seen = []; + foreach ($hints as $hint) { + if (!$hint instanceof InlayHint) { + continue; + } + $hintLabel = is_string($hint->label) ? $hint->label : ''; + $seen[] = sprintf('%s@L%d', $hintLabel, $hint->position->line); + if ($hintLabel === $label && $hint->position->line === $line) { + return; + } + } + + $this->world->fail(sprintf( + 'no inlay hint "%s" on line %d (after "%s"); got: [%s]', + $label, + $line, + $var, + implode(', ', $seen) ?: '', + )); + } + + private function assertHoverContains(string $needle): void + { + $hover = $this->world->last(); + $this->world->assert($hover instanceof Hover, 'expected a Hover response, got ' . get_debug_type($hover)); + $contents = $hover->contents; + $text = $contents instanceof MarkupContent ? $contents->value : (is_string($contents) ? $contents : ''); + $this->world->assert( + str_contains($text, $needle), + sprintf('expected hover contents to contain "%s", got: %s', $needle, $text === '' ? '' : $text), + ); + } +} diff --git a/test/Behat/ValidateContext.php b/test/Behat/ValidateContext.php new file mode 100644 index 0000000..d355234 --- /dev/null +++ b/test/Behat/ValidateContext.php @@ -0,0 +1,128 @@ +analyzedPath = $path; + $this->world->request('textDocument/diagnostic', ['textDocument' => ['uri' => $path]]); + } + + /** + * @Then the :code diagnostic underlines :text + */ + public function theDiagnosticUnderlines(string $code, string $text): void + { + $seen = []; + foreach ($this->diagnosticItems() as $diagnostic) { + if (($diagnostic->code ?? null) !== $code) { + continue; + } + $covered = $this->world->textForRange($this->analyzedPath, $diagnostic->range); + $seen[] = $covered; + if ($covered === $text) { + return; + } + } + $this->world->fail(sprintf( + 'expected the "%s" diagnostic to underline "%s"; underlined: [%s]', + $code, + $text, + implode(' | ', $seen) ?: '', + )); + } + + /** + * @Then a :code diagnostic is reported + */ + public function aDiagnosticIsReported(string $code): void + { + $codes = $this->diagnosticCodes(); + $this->world->assert( + in_array($code, $codes, true), + sprintf('expected a "%s" diagnostic; got: [%s]', $code, implode(', ', $codes)), + ); + } + + /** + * @Then a :code diagnostic is reported saying :text + */ + public function aDiagnosticIsReportedSaying(string $code, string $text): void + { + $messages = []; + foreach ($this->diagnosticItems() as $diagnostic) { + if (($diagnostic->code ?? null) !== $code) { + continue; + } + $messages[] = $diagnostic->message; + if (str_contains($diagnostic->message, $text)) { + return; + } + } + $this->world->fail(sprintf( + 'expected a "%s" diagnostic saying "%s"; got messages: [%s]', + $code, + $text, + implode(' | ', $messages) ?: '', + )); + } + + /** + * @Then no diagnostics are reported + */ + public function noDiagnosticsAreReported(): void + { + $codes = $this->diagnosticCodes(); + $this->world->assert($codes === [], 'expected no diagnostics; got: [' . implode(', ', $codes) . ']'); + } + + /** @return list */ + private function diagnosticCodes(): array + { + $codes = []; + foreach ($this->diagnosticItems() as $diagnostic) { + if (is_object($diagnostic) && isset($diagnostic->code)) { + $codes[] = (string) $diagnostic->code; + } + } + return $codes; + } + + /** + * Extract the diagnostic list from the pull-mode report + * (`{kind: 'full', items: [...]}`). + * + * @return list + */ + private function diagnosticItems(): array + { + $report = $this->world->last(); + $this->world->assert(is_array($report), 'expected a diagnostic report, got ' . get_debug_type($report)); + $items = $report['items'] ?? $report; + $this->world->assert(is_array($items), 'expected the report to carry an items list'); + return array_values($items); + } +} diff --git a/test/Behat/World.php b/test/Behat/World.php new file mode 100644 index 0000000..8d33f61 --- /dev/null +++ b/test/Behat/World.php @@ -0,0 +1,239 @@ + uri -> source (for needle/position lookups) */ + private array $sources = []; + + private ?LanguageServerTester $tester = null; + + /** Last response result from a When step (Location, Hover, list, WorkspaceEdit, ...). */ + private mixed $lastResponse = null; + + // ---- server lifecycle + request dispatch ------------------------------- + + /** Ensure the server is initialized (the FQN index warms on Initialized). */ + public function boot(): void + { + $this->server(); + } + + /** Open a fixture as a real textDocument/didOpen notification. */ + public function openFile(string $uri, string $source): void + { + $this->sources[$uri] = $source; + $this->server()->textDocument()->open($uri, $source); + } + + /** + * Send a request through the real dispatcher; store and return the typed + * result so When steps store it and Then steps read it via {@see last()}. + */ + public function request(string $method, mixed $params): mixed + { + $response = $this->server()->requestAndWait($method, $params); + if ($response !== null && $response->error !== null) { + $this->fail(sprintf('LSP error on %s: %s', $method, $response->error->message ?? 'unknown')); + } + return $this->lastResponse = $response?->result; + } + + public function last(): mixed + { + return $this->lastResponse; + } + + private function server(): LanguageServerTester + { + if ($this->tester === null) { + $this->tester = new LanguageServerTester( + new LspDispatcherFactory(), + new InitializeParams(new ClientCapabilities()), + ); + $this->tester->initialize(); + } + return $this->tester; + } + + // ---- position / fixture helpers --------------------------------------- + + /** + * Resolve a needle on a 0-indexed line to an LSP {@see Position}. Picks the + * first occurrence that begins an identifier token and is NOT $-prefixed, + * so `first` matches `->first()` rather than the `$first` variable. + */ + public function positionOfNeedle(string $path, int $line, string $needle): Position + { + $source = $this->sources[$path] ?? throw new \RuntimeException("unknown fixture: {$path}"); + $lines = explode("\n", $source); + if (!isset($lines[$line])) { + throw new \RuntimeException("line {$line} out of range in {$path}"); + } + $lineStart = 0; + for ($i = 0; $i < $line; $i++) { + $lineStart += strlen($lines[$i]) + 1; // +1 for the stripped "\n" + } + $col = $this->columnInLine($lines[$line], $needle); + [$lspLine, $lspChar] = (new PositionMap($source))->offsetToPosition($lineStart + $col); + + return new Position($lspLine, $lspChar); + } + + private function columnInLine(string $haystack, string $needle): int + { + $from = 0; + while (($at = strpos($haystack, $needle, $from)) !== false) { + $before = $at > 0 ? $haystack[$at - 1] : ''; + $boundary = $before === '' || !preg_match('/[A-Za-z0-9_]/', $before); + if ($before !== '$' && $boundary) { + return $at; + } + $from = $at + 1; + } + $first = strpos($haystack, $needle); + if ($first === false) { + throw new \RuntimeException("needle \"{$needle}\" not found on line"); + } + return $first; + } + + // ---- assertion helpers ------------------------------------------------- + + public function normalizeLocation(mixed $response): ?Location + { + if (is_array($response)) { + $response = $response[0] ?? null; + } + return $response instanceof Location ? $response : null; + } + + public function expectLocation(): Location + { + $location = $this->normalizeLocation($this->lastResponse); + $this->assert( + $location !== null, + 'expected a Location response, got ' . get_debug_type($this->lastResponse), + ); + + return $location; + } + + /** + * @return list uris + */ + public function locationUris(mixed $locations): array + { + $this->assert(is_array($locations), 'expected a list of Locations, got ' . get_debug_type($locations)); + $uris = []; + foreach ($locations as $loc) { + if ($loc instanceof Location) { + $uris[] = $loc->uri; + } + } + return $uris; + } + + /** Slice the fixture identified by $uri by an LSP range; return covered text. */ + public function textForRange(string $uri, object $range): string + { + $target = $this->sourceFor($uri); + $map = new PositionMap($target); + $start = $map->positionToOffset($range->start->line, $range->start->character); + $end = $map->positionToOffset($range->end->line, $range->end->character); + + return substr($target, $start, max(0, $end - $start)); + } + + /** Slice the target document by a Location's range and return the covered text. */ + public function textInRange(Location $location): string + { + return $this->textForRange($location->uri, $location->range); + } + + /** + * Decode the delta-encoded semantic-token stream into absolute tokens, + * slicing the source for each token's text and mapping its type index. + * + * @return list + */ + public function decodeSemanticTokens(SemanticTokens $tokens, string $uri): array + { + $lines = explode("\n", $this->sourceFor($uri)); + $data = array_values($tokens->data); + $out = []; + $line = 0; + $char = 0; + for ($i = 0; $i + 5 <= count($data); $i += 5) { + [$deltaLine, $deltaChar, $length, $typeIndex] = array_slice($data, $i, 4); + if ($deltaLine > 0) { + $line += $deltaLine; + $char = $deltaChar; + } else { + $char += $deltaChar; + } + $out[] = [ + 'line' => $line, + 'char' => $char, + 'text' => substr($lines[$line] ?? '', $char, $length), + 'type' => TokenLegend::TOKEN_TYPES[$typeIndex] ?? (string) $typeIndex, + ]; + } + return $out; + } + + private function sourceFor(string $uri): string + { + return $this->sources[$this->stripFileScheme($uri)] + ?? $this->sources[$uri] + ?? throw new \RuntimeException("doc not in fixtures: {$uri}"); + } + + public function stripFileScheme(string $uri): string + { + return str_starts_with($uri, 'file://') ? substr($uri, strlen('file://')) : $uri; + } + + public function assert(bool $condition, string $message): void + { + if (!$condition) { + $this->fail($message); + } + } + + public function fail(string $message): never + { + throw new \RuntimeException($message); + } +} diff --git a/test/Behat/WorldArgumentResolver.php b/test/Behat/WorldArgumentResolver.php new file mode 100644 index 0000000..b387e1a --- /dev/null +++ b/test/Behat/WorldArgumentResolver.php @@ -0,0 +1,65 @@ + + * isolateEnvironment -> ContextFactory::createContext, where resolvers run), so + * reset() nulls the World first and resolveArguments() then lazily creates one + * fresh World shared across that scenario's contexts. + */ +final class WorldArgumentResolver implements ArgumentResolver, EventSubscriberInterface +{ + private ?World $world = null; + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + // Fresh World per scenario AND per Scenario Outline example. + return [ + ScenarioTested::BEFORE => 'reset', + ExampleTested::BEFORE => 'reset', + ]; + } + + public function reset(): void + { + $this->world = null; + } + + /** + * @param array $arguments + * @return array + */ + public function resolveArguments(ReflectionClass $classReflection, array $arguments): array + { + $constructor = $classReflection->getConstructor(); + if ($constructor === null) { + return $arguments; + } + + foreach ($constructor->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && $type->getName() === World::class) { + $arguments[$parameter->getName()] = $this->world ??= new World(); + } + } + + return $arguments; + } +} diff --git a/test/Behat/WorldExtension.php b/test/Behat/WorldExtension.php new file mode 100644 index 0000000..9d805cb --- /dev/null +++ b/test/Behat/WorldExtension.php @@ -0,0 +1,55 @@ +addTag(ContextExtension::ARGUMENT_RESOLVER_TAG); + // ...and reset it before each scenario / outline example. + $definition->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + $container->setDefinition(self::RESOLVER_ID, $definition); + } + + public function process(ContainerBuilder $container): void + { + } +} diff --git a/tools/behat/bootstrap.php b/tools/behat/bootstrap.php new file mode 100644 index 0000000..41159d6 --- /dev/null +++ b/tools/behat/bootstrap.php @@ -0,0 +1,17 @@ +=8.2 <8.6", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/translation": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "opis/json-schema": "^2.5", + "php-cs-fixer/shim": "^3.89", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "^9.6", + "rector/rector": "2.3.9", + "sebastian/diff": "^4.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "autoload": { + "psr-4": { + "Behat\\Hook\\": "src/Behat/Hook/", + "Behat\\Step\\": "src/Behat/Step/", + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Config\\": "src/Behat/Config/", + "Behat\\Testwork\\": "src/Behat/Testwork/", + "Behat\\Transformation\\": "src/Behat/Transformation/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.31.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-04-19T21:04:32+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.17.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": ">=8.1 <8.6" + }, + "require-dev": { + "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", + "friendsofphp/php-cs-fixer": "^3.77", + "mikey179/vfsstream": "^1.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpunit/phpunit": "^10.5", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Gherkin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "https://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.17.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-05-18T09:33:47+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-03T14:20:49+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T14:07:29+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:03:27+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:19:24+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +}