From 4824f754513a56289fa4dfb232c3085b5f438ccb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 1 Jun 2026 22:49:37 +0200 Subject: [PATCH] MVP: basic functionality to support xphp v0.1.0 --- .docker/php.Dockerfile | 29 + .github/workflows/ci-lsp.yml | 90 + .gitignore | 5 + CONTRIBUTING.md | 53 + Makefile | 89 + README.md | 77 +- bin/xphp-lsp | 58 + box.json | 14 + composer.json | 44 + composer.lock | 4624 +++++++++++++++++ docker-compose.yml | 12 + docs/features/index.md | 380 ++ docs/roadmap.md | 291 ++ infection.json5 | 2106 ++++++++ phpunit.xml.dist | 58 + src/Analyzer/Analyzer.php | 229 + src/Analyzer/ConstructorArgumentChecker.php | 704 +++ src/Analyzer/Diagnostic.php | 31 + src/Analyzer/DiagnosticCode.php | 96 + src/Analyzer/DiagnosticSeverity.php | 17 + src/Analyzer/ParseResult.php | 34 + src/Analyzer/ParsedDocumentCache.php | 114 + src/Analyzer/ParsedDocumentCacheWarmer.php | 122 + src/Analyzer/WorkspaceAnalyzer.php | 257 + src/Diagnostics/DiagnosticTranslator.php | 33 + src/Diagnostics/XphpDiagnosticsProvider.php | 133 + src/Dispatcher/LspObjectArgumentResolver.php | 102 + src/Handler/AstPositionResolver.php | 87 + src/Handler/SemanticTokens/AstVisitor.php | 640 +++ src/Handler/SemanticTokens/Encoder.php | 86 + src/Handler/SemanticTokens/TokenLegend.php | 91 + src/Handler/SemanticTokens/TokenSpec.php | 43 + src/Handler/TypeArgPositionDetector.php | 168 + src/Handler/WorkspaceSymbols.php | 266 + src/Handler/XphpCallHierarchyHandler.php | 732 +++ src/Handler/XphpCodeActionHandler.php | 102 + src/Handler/XphpCodeActionResolveHandler.php | 52 + src/Handler/XphpCodeLensHandler.php | 279 + src/Handler/XphpCompletionHandler.php | 350 ++ src/Handler/XphpCompletionResolveHandler.php | 89 + src/Handler/XphpDefinitionHandler.php | 268 + src/Handler/XphpDocumentHighlightHandler.php | 98 + src/Handler/XphpDocumentSymbolHandler.php | 279 + src/Handler/XphpFileWatcherHandler.php | 122 + src/Handler/XphpFoldingRangeHandler.php | 144 + src/Handler/XphpHoverHandler.php | 372 ++ src/Handler/XphpImplementationHandler.php | 302 ++ src/Handler/XphpInlayHintHandler.php | 178 + src/Handler/XphpPullDiagnosticsHandler.php | 94 + src/Handler/XphpReferencesHandler.php | 67 + src/Handler/XphpRenameHandler.php | 80 + src/Handler/XphpSemanticTokensHandler.php | 149 + src/Handler/XphpSignatureHelpHandler.php | 382 ++ src/Handler/XphpTextDocumentHandler.php | 113 + src/Handler/XphpTypeDefinitionHandler.php | 85 + src/Handler/XphpTypeHierarchyHandler.php | 531 ++ src/Handler/XphpWillRenameFilesHandler.php | 332 ++ src/Handler/XphpWorkspaceSymbolHandler.php | 163 + src/LspDispatcherFactory.php | 404 ++ src/PositionMap.php | 207 + src/Reflection/FilesystemSourceLocator.php | 187 + src/Reflection/FqnIndex.php | 1566 ++++++ src/Reflection/FqnIndexWarmer.php | 74 + src/Reflection/ReflectorFactory.php | 297 ++ src/Reflection/WorkspaceSourceLocator.php | 129 + src/Resolver/ClassFqnPredicate.php | 73 + src/Resolver/ClassLikeLookup.php | 32 + src/Resolver/ClassNameImportContext.php | 154 + src/Resolver/CompletionIndex.php | 71 + src/Resolver/CompositeClassLikeLookup.php | 41 + src/Resolver/DiagnosticCodeActionProvider.php | 167 + src/Resolver/FilesystemClassLikeLookup.php | 34 + src/Resolver/GenericParamRegistry.php | 133 + src/Resolver/GenericResolver.php | 1640 ++++++ src/Resolver/ImportCodeActionProvider.php | 273 + src/Resolver/InvalidRenameNameException.php | 17 + src/Resolver/MethodCallSubstitution.php | 34 + src/Resolver/NamespaceMoveProvider.php | 576 ++ .../OptimizeImportsCodeActionProvider.php | 236 + src/Resolver/PhpCompletionContext.php | 262 + src/Resolver/PhpCompletionResolver.php | 1117 ++++ src/Resolver/PhpDefinitionResolver.php | 821 +++ src/Resolver/PhpHoverResolver.php | 662 +++ src/Resolver/ReferenceFinder.php | 1447 ++++++ src/Resolver/RenameProvider.php | 295 ++ src/Resolver/ResolvedType.php | 31 + src/Resolver/StubsIndex.php | 187 + src/Resolver/TypeUnionSplitter.php | 205 + src/Resolver/VarBinding.php | 29 + src/Resolver/WorkspaceClassLikeLookup.php | 103 + src/Server.php | 118 + src/Stderr.php | 50 + test/Analyzer/AnalyzerTest.php | 273 + .../ConstructorArgumentCheckerTest.php | 335 ++ test/Analyzer/DiagnosticCodeTest.php | 75 + test/Analyzer/ParsedDocumentCacheTest.php | 182 + .../ParsedDocumentCacheWarmerTest.php | 224 + test/Analyzer/WorkspaceAnalyzerTest.php | 382 ++ .../XphpDiagnosticsProviderTest.php | 476 ++ .../LspObjectArgumentResolverTest.php | 131 + test/Handler/AstPositionResolverTest.php | 122 + .../Handler/SemanticTokens/AstVisitorTest.php | 445 ++ test/Handler/SemanticTokens/EncoderTest.php | 147 + .../SemanticTokens/TokenLegendTest.php | 66 + test/Handler/TypeArgPositionDetectorTest.php | 217 + test/Handler/WorkspaceSymbolsTest.php | 206 + test/Handler/XphpCallHierarchyHandlerTest.php | 322 ++ test/Handler/XphpCodeActionHandlerTest.php | 192 + test/Handler/XphpCodeLensHandlerTest.php | 312 ++ test/Handler/XphpCompletionHandlerTest.php | 534 ++ .../XphpCompletionResolveHandlerTest.php | 171 + test/Handler/XphpDefinitionHandlerTest.php | 545 ++ .../XphpDocumentHighlightHandlerTest.php | 269 + .../Handler/XphpDocumentSymbolHandlerTest.php | 251 + test/Handler/XphpFileWatcherHandlerTest.php | 296 ++ test/Handler/XphpFoldingRangeHandlerTest.php | 186 + test/Handler/XphpHoverHandlerTest.php | 600 +++ .../Handler/XphpImplementationHandlerTest.php | 265 + test/Handler/XphpInlayHintHandlerTest.php | 179 + .../XphpPullDiagnosticsHandlerTest.php | 143 + test/Handler/XphpReferencesHandlerTest.php | 1123 ++++ test/Handler/XphpRenameHandlerTest.php | 553 ++ .../Handler/XphpSemanticTokensHandlerTest.php | 153 + test/Handler/XphpSignatureHelpHandlerTest.php | 194 + test/Handler/XphpTextDocumentHandlerTest.php | 117 + .../Handler/XphpTypeDefinitionHandlerTest.php | 204 + test/Handler/XphpTypeHierarchyHandlerTest.php | 340 ++ .../XphpWillRenameFilesHandlerTest.php | 699 +++ .../XphpWorkspaceSymbolHandlerTest.php | 271 + test/LspDispatcherFactoryTest.php | 222 + test/PositionMapTest.php | 448 ++ .../FilesystemSourceLocatorTest.php | 321 ++ test/Reflection/FqnIndexTest.php | 863 +++ test/Reflection/FqnIndexWarmerTest.php | 117 + test/Reflection/ReflectorFactoryTest.php | 311 ++ .../Reflection/WorkspaceSourceLocatorTest.php | 158 + test/Resolver/ClassFqnPredicateTest.php | 79 + test/Resolver/ClassNameImportContextTest.php | 207 + test/Resolver/CompletionIndexTest.php | 98 + .../Resolver/CompositeClassLikeLookupTest.php | 58 + .../DiagnosticCodeActionProviderTest.php | 135 + .../FilesystemClassLikeLookupTest.php | 85 + test/Resolver/GenericParamRegistryTest.php | 202 + test/Resolver/GenericResolverTest.php | 883 ++++ .../Resolver/ImportCodeActionProviderTest.php | 219 + .../OptimizeImportsCodeActionProviderTest.php | 120 + test/Resolver/PhpCompletionContextTest.php | 197 + test/Resolver/PhpCompletionResolverTest.php | 1224 +++++ test/Resolver/PhpDefinitionResolverTest.php | 592 +++ test/Resolver/PhpHoverResolverTest.php | 1077 ++++ test/Resolver/StubsIndexTest.php | 113 + test/Resolver/TypeUnionSplitterTest.php | 70 + test/StderrTest.php | 112 + test/bootstrap.php | 12 + 154 files changed, 47340 insertions(+), 1 deletion(-) create mode 100644 .docker/php.Dockerfile create mode 100644 .github/workflows/ci-lsp.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100755 bin/xphp-lsp create mode 100644 box.json create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml create mode 100644 docs/features/index.md create mode 100644 docs/roadmap.md create mode 100644 infection.json5 create mode 100644 phpunit.xml.dist create mode 100644 src/Analyzer/Analyzer.php create mode 100644 src/Analyzer/ConstructorArgumentChecker.php create mode 100644 src/Analyzer/Diagnostic.php create mode 100644 src/Analyzer/DiagnosticCode.php create mode 100644 src/Analyzer/DiagnosticSeverity.php create mode 100644 src/Analyzer/ParseResult.php create mode 100644 src/Analyzer/ParsedDocumentCache.php create mode 100644 src/Analyzer/ParsedDocumentCacheWarmer.php create mode 100644 src/Analyzer/WorkspaceAnalyzer.php create mode 100644 src/Diagnostics/DiagnosticTranslator.php create mode 100644 src/Diagnostics/XphpDiagnosticsProvider.php create mode 100644 src/Dispatcher/LspObjectArgumentResolver.php create mode 100644 src/Handler/AstPositionResolver.php create mode 100644 src/Handler/SemanticTokens/AstVisitor.php create mode 100644 src/Handler/SemanticTokens/Encoder.php create mode 100644 src/Handler/SemanticTokens/TokenLegend.php create mode 100644 src/Handler/SemanticTokens/TokenSpec.php create mode 100644 src/Handler/TypeArgPositionDetector.php create mode 100644 src/Handler/WorkspaceSymbols.php create mode 100644 src/Handler/XphpCallHierarchyHandler.php create mode 100644 src/Handler/XphpCodeActionHandler.php create mode 100644 src/Handler/XphpCodeActionResolveHandler.php create mode 100644 src/Handler/XphpCodeLensHandler.php create mode 100644 src/Handler/XphpCompletionHandler.php create mode 100644 src/Handler/XphpCompletionResolveHandler.php create mode 100644 src/Handler/XphpDefinitionHandler.php create mode 100644 src/Handler/XphpDocumentHighlightHandler.php create mode 100644 src/Handler/XphpDocumentSymbolHandler.php create mode 100644 src/Handler/XphpFileWatcherHandler.php create mode 100644 src/Handler/XphpFoldingRangeHandler.php create mode 100644 src/Handler/XphpHoverHandler.php create mode 100644 src/Handler/XphpImplementationHandler.php create mode 100644 src/Handler/XphpInlayHintHandler.php create mode 100644 src/Handler/XphpPullDiagnosticsHandler.php create mode 100644 src/Handler/XphpReferencesHandler.php create mode 100644 src/Handler/XphpRenameHandler.php create mode 100644 src/Handler/XphpSemanticTokensHandler.php create mode 100644 src/Handler/XphpSignatureHelpHandler.php create mode 100644 src/Handler/XphpTextDocumentHandler.php create mode 100644 src/Handler/XphpTypeDefinitionHandler.php create mode 100644 src/Handler/XphpTypeHierarchyHandler.php create mode 100644 src/Handler/XphpWillRenameFilesHandler.php create mode 100644 src/Handler/XphpWorkspaceSymbolHandler.php create mode 100644 src/LspDispatcherFactory.php create mode 100644 src/PositionMap.php create mode 100644 src/Reflection/FilesystemSourceLocator.php create mode 100644 src/Reflection/FqnIndex.php create mode 100644 src/Reflection/FqnIndexWarmer.php create mode 100644 src/Reflection/ReflectorFactory.php create mode 100644 src/Reflection/WorkspaceSourceLocator.php create mode 100644 src/Resolver/ClassFqnPredicate.php create mode 100644 src/Resolver/ClassLikeLookup.php create mode 100644 src/Resolver/ClassNameImportContext.php create mode 100644 src/Resolver/CompletionIndex.php create mode 100644 src/Resolver/CompositeClassLikeLookup.php create mode 100644 src/Resolver/DiagnosticCodeActionProvider.php create mode 100644 src/Resolver/FilesystemClassLikeLookup.php create mode 100644 src/Resolver/GenericParamRegistry.php create mode 100644 src/Resolver/GenericResolver.php create mode 100644 src/Resolver/ImportCodeActionProvider.php create mode 100644 src/Resolver/InvalidRenameNameException.php create mode 100644 src/Resolver/MethodCallSubstitution.php create mode 100644 src/Resolver/NamespaceMoveProvider.php create mode 100644 src/Resolver/OptimizeImportsCodeActionProvider.php create mode 100644 src/Resolver/PhpCompletionContext.php create mode 100644 src/Resolver/PhpCompletionResolver.php create mode 100644 src/Resolver/PhpDefinitionResolver.php create mode 100644 src/Resolver/PhpHoverResolver.php create mode 100644 src/Resolver/ReferenceFinder.php create mode 100644 src/Resolver/RenameProvider.php create mode 100644 src/Resolver/ResolvedType.php create mode 100644 src/Resolver/StubsIndex.php create mode 100644 src/Resolver/TypeUnionSplitter.php create mode 100644 src/Resolver/VarBinding.php create mode 100644 src/Resolver/WorkspaceClassLikeLookup.php create mode 100644 src/Server.php create mode 100644 src/Stderr.php create mode 100644 test/Analyzer/AnalyzerTest.php create mode 100644 test/Analyzer/ConstructorArgumentCheckerTest.php create mode 100644 test/Analyzer/DiagnosticCodeTest.php create mode 100644 test/Analyzer/ParsedDocumentCacheTest.php create mode 100644 test/Analyzer/ParsedDocumentCacheWarmerTest.php create mode 100644 test/Analyzer/WorkspaceAnalyzerTest.php create mode 100644 test/Diagnostics/XphpDiagnosticsProviderTest.php create mode 100644 test/Dispatcher/LspObjectArgumentResolverTest.php create mode 100644 test/Handler/AstPositionResolverTest.php create mode 100644 test/Handler/SemanticTokens/AstVisitorTest.php create mode 100644 test/Handler/SemanticTokens/EncoderTest.php create mode 100644 test/Handler/SemanticTokens/TokenLegendTest.php create mode 100644 test/Handler/TypeArgPositionDetectorTest.php create mode 100644 test/Handler/WorkspaceSymbolsTest.php create mode 100644 test/Handler/XphpCallHierarchyHandlerTest.php create mode 100644 test/Handler/XphpCodeActionHandlerTest.php create mode 100644 test/Handler/XphpCodeLensHandlerTest.php create mode 100644 test/Handler/XphpCompletionHandlerTest.php create mode 100644 test/Handler/XphpCompletionResolveHandlerTest.php create mode 100644 test/Handler/XphpDefinitionHandlerTest.php create mode 100644 test/Handler/XphpDocumentHighlightHandlerTest.php create mode 100644 test/Handler/XphpDocumentSymbolHandlerTest.php create mode 100644 test/Handler/XphpFileWatcherHandlerTest.php create mode 100644 test/Handler/XphpFoldingRangeHandlerTest.php create mode 100644 test/Handler/XphpHoverHandlerTest.php create mode 100644 test/Handler/XphpImplementationHandlerTest.php create mode 100644 test/Handler/XphpInlayHintHandlerTest.php create mode 100644 test/Handler/XphpPullDiagnosticsHandlerTest.php create mode 100644 test/Handler/XphpReferencesHandlerTest.php create mode 100644 test/Handler/XphpRenameHandlerTest.php create mode 100644 test/Handler/XphpSemanticTokensHandlerTest.php create mode 100644 test/Handler/XphpSignatureHelpHandlerTest.php create mode 100644 test/Handler/XphpTextDocumentHandlerTest.php create mode 100644 test/Handler/XphpTypeDefinitionHandlerTest.php create mode 100644 test/Handler/XphpTypeHierarchyHandlerTest.php create mode 100644 test/Handler/XphpWillRenameFilesHandlerTest.php create mode 100644 test/Handler/XphpWorkspaceSymbolHandlerTest.php create mode 100644 test/LspDispatcherFactoryTest.php create mode 100644 test/PositionMapTest.php create mode 100644 test/Reflection/FilesystemSourceLocatorTest.php create mode 100644 test/Reflection/FqnIndexTest.php create mode 100644 test/Reflection/FqnIndexWarmerTest.php create mode 100644 test/Reflection/ReflectorFactoryTest.php create mode 100644 test/Reflection/WorkspaceSourceLocatorTest.php create mode 100644 test/Resolver/ClassFqnPredicateTest.php create mode 100644 test/Resolver/ClassNameImportContextTest.php create mode 100644 test/Resolver/CompletionIndexTest.php create mode 100644 test/Resolver/CompositeClassLikeLookupTest.php create mode 100644 test/Resolver/DiagnosticCodeActionProviderTest.php create mode 100644 test/Resolver/FilesystemClassLikeLookupTest.php create mode 100644 test/Resolver/GenericParamRegistryTest.php create mode 100644 test/Resolver/GenericResolverTest.php create mode 100644 test/Resolver/ImportCodeActionProviderTest.php create mode 100644 test/Resolver/OptimizeImportsCodeActionProviderTest.php create mode 100644 test/Resolver/PhpCompletionContextTest.php create mode 100644 test/Resolver/PhpCompletionResolverTest.php create mode 100644 test/Resolver/PhpDefinitionResolverTest.php create mode 100644 test/Resolver/PhpHoverResolverTest.php create mode 100644 test/Resolver/StubsIndexTest.php create mode 100644 test/Resolver/TypeUnionSplitterTest.php create mode 100644 test/StderrTest.php create mode 100644 test/bootstrap.php diff --git a/.docker/php.Dockerfile b/.docker/php.Dockerfile new file mode 100644 index 0000000..6df3aae --- /dev/null +++ b/.docker/php.Dockerfile @@ -0,0 +1,29 @@ +FROM php:8.4-cli-alpine + +# Composer: copied from the official multi-arch image instead of curl- +# piped install. Reproducible across rebuilds and gets composer 2.x +# without an interactive installer. Both Makefile entry points +# (`make test` and `make test/mutation`) shell out to `composer +# install`, so this needs to be in PATH. +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +RUN apk add --update --no-cache linux-headers +RUN apk add --no-cache \ + php84-dev \ + build-base \ + git \ + unzip + +# Both coverage drivers are installed. Xdebug runs as the dev-time +# debugger; PCOV is used exclusively for Infection's coverage pass +# (orders of magnitude less memory than Xdebug, which avoids the +# host OOM-killer firing during the initial test run on tight +# containers). Infection auto-detects PCOV when it's loaded and +# Xdebug is disabled via XDEBUG_MODE=off; the mutation Makefile +# target does both. +RUN pecl install \ + xdebug \ + pcov \ + && docker-php-ext-enable \ + xdebug \ + pcov diff --git a/.github/workflows/ci-lsp.yml b/.github/workflows/ci-lsp.yml new file mode 100644 index 0000000..5d92a27 --- /dev/null +++ b/.github/workflows/ci-lsp.yml @@ -0,0 +1,90 @@ +name: LSP CI + +# PHPUnit runs on every PR + push to main as a hard gate. +# Infection (mutation testing) is opt-in via workflow_dispatch -- its +# resource envelope outgrew GitHub-hosted `ubuntu-latest` (~7 GB RAM) +# once the suite passed ~150 tests, and the initial coverage run now +# OOMs as SIGTERM 143 mid-stream. Re-enable as a PR gate once the +# job is reshaped to filter / batch the test set (see follow-up). +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Per-workflow concurrency. See ci-core.yml for the rationale. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + phpunit-lsp: + name: PHPUnit (LSP) + 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 + + # The LSP package has its own composer.json under tools/lsp/ with a + # path-repo pointing back at the repo root. Cache its deps separately + # from the core install so a core-only change doesn't bust the LSP cache. + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPUnit (LSP) + run: make test/unit + + infection-lsp: + name: Mutation testing (LSP) + # Gate: only run when explicitly requested via the Actions tab. The + # 424-test suite plus per-mutation reruns overruns the GitHub-hosted + # runner's RAM, killing the initial coverage subprocess with SIGTERM + # 143. PRs and merges to main get fast unambiguous signal from the + # PHPUnit job above; Infection scoring is opt-in until the job is + # reshaped to filter / batch the test set. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: phpunit-lsp + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 (with PCOV for coverage) + # PCOV is preferred over Xdebug for Infection's initial run: an + # order of magnitude less memory under the same coverage scope, + # which keeps the runner well clear of the OOM-killer. The + # Makefile target sets pcov.directory + XDEBUG_MODE=off so the + # driver choice is deterministic regardless of what else is loaded. + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: pcov + tools: composer:v2 + + - name: Install LSP dependencies + uses: ramsey/composer-install@v3 + + - name: Run Infection (LSP) + # `make test/mutation` lazily downloads infection.phar + # 0.33.1 into tools/lsp/var/ (PHAR sidesteps the composer dep conflict + # with phpactor/language-server documented in tools/lsp/README.md). + # Runs with --min-covered-msi=93; current baseline is 94%. + run: make test/mutation + + - name: Upload Infection report (LSP) + if: always() + uses: actions/upload-artifact@v4 + with: + name: infection-lsp-report + path: | + tools/lsp/var/infection.log + tools/lsp/var/infection.html + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b953b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/var/ +.phpunit.result.cache +.infection.json5.tmp.log +/docker-compose.override.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4f82728 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +## Test + +```bash +make test/unit +make test/mutation +``` + +`test/mutation` downloads `infection.phar` lazily into `var/` and runs against +the same source + test set. The PHAR distribution sidesteps the +`thecodingmachine/safe` / `psr/log` conflicts that prevent a composer-installed +Infection from coexisting with `phpactor/language-server`. Curated +equivalent-mutation ignores live in `infection.json5` with per-mutator `ignore` +rules and inline rationale. + +## How it works + +PHP-semantic GTD / hover / completion is backed by +[`phpactor/worse-reflection`](https://github.com/phpactor/worse-reflection) +and [`jetbrains/phpstorm-stubs`](https://github.com/JetBrains/phpstorm-stubs). +xphp-specific paths run FIRST (template instantiation, type-args inside `<...>` +clauses); when those don't apply we fall through to the worse-reflection path so +behaviour on `.xphp` files matches PhpStorm's PHP intelligence on regular `.php` +files. The same `PhpHoverResolver` / `PhpDefinitionResolver` / +`PhpCompletionResolver` triad also drives `signatureHelp`, `inlayHint`, and +`callHierarchy` so all five features agree on receiver / member resolution. + +## LSP capabilities advertised at `initialize` + +For LSP-client developers wiring this server into a non-bundled editor: + +- `textDocumentSync: 1` (Full) +- `hoverProvider`, `definitionProvider`, `typeDefinitionProvider`, + `referencesProvider`, `implementationProvider` +- `documentHighlightProvider`, `documentSymbolProvider`, + `workspaceSymbolProvider` +- `renameProvider` +- `foldingRangeProvider` +- `completionProvider` with `triggerCharacters: ["<", ",", ">", ":"]` + and `resolveProvider: true` +- `signatureHelpProvider` with `triggerCharacters: ["(", ","]` +- `inlayHintProvider` +- `codeActionProvider` with `resolveProvider: true` +- `codeLensProvider` with `resolveProvider: true` +- `callHierarchyProvider`, `typeHierarchyProvider` +- `executeCommandProvider` for `xphp.showReferences` (no-op server- + side; both clients dispatch `editor.action.showReferences` directly) +- `semanticTokensProvider` (full file; standard LSP-spec token + legend including `typeParameter`) +- Pull-mode `diagnosticProvider` +- `workspace.fileOperations.willRename` (LSP 3.17) +- `workspace.didChangeWatchedFiles` (dynamic registration) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01380c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# Makefile for the xphp LSP package. +# +# Run from this directory directly (`make test`) or from the repo root with +# `make -C tools/lsp `. There is intentionally no root-level +# delegator — the per-package Makefile is the single source of truth. + +.PHONY: test +test/unit: + composer install --quiet && \ + php -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpunit + +# Infection runs against this package via its PHAR distribution rather than a +# composer require: phpactor/language-server pins psr/log ^1.0 which composer +# can't reconcile with infection 0.33's ^2||^3, AND sharing the root vendor's +# infection with tools/lsp would collide on thecodingmachine/safe's global +# functions (both vendor trees install it). The PHAR ships its internal deps +# under PHP-Scoper-prefixed namespaces, so neither problem applies. Downloaded +# lazily into var/infection.phar (gitignored). +INFECTION_VERSION := 0.33.1 +INFECTION_PHAR := var/infection.phar + +$(INFECTION_PHAR): + @mkdir -p $(dir $(INFECTION_PHAR)) + @echo "==> Downloading infection.phar $(INFECTION_VERSION)" + @curl -fsSL -o $@ \ + https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar + @chmod +x $@ + +.PHONY: test/mutation +# Coverage driver: PCOV, not Xdebug. PCOV uses orders of magnitude +# less memory for line-coverage tracking (no full execution-context +# capture). Xdebug on the initial 424-test run was triggering the +# host OOM-killer in tight container envelopes (SIGTERM 143); +# PCOV reliably stays under 512M total RSS for the same workload. +# +# `XDEBUG_MODE=off` ensures Xdebug doesn't load even though the +# extension is enabled at the docker layer -- otherwise Infection +# picks Xdebug by precedence regardless of PCOV's presence. +# `pcov.directory` scopes PCOV to our source tree (without it PCOV +# tracks every file it sees, vendor/ included, ballooning memory). +test/mutation: $(INFECTION_PHAR) + composer install --quiet && \ + XDEBUG_MODE=off php \ + -d error_reporting='E_ALL & ~E_DEPRECATED' \ + -d memory_limit=-1 \ + -d pcov.enabled=1 \ + -d pcov.directory=src \ + var/infection.phar \ + --threads=max --min-covered-msi=93 --show-mutations --no-progress \ + --initial-tests-php-options='-d error_reporting=E_ALL\&~E_DEPRECATED -d pcov.enabled=1 -d pcov.directory=src' + +# The xphp-lsp PHAR is built via Humbug Box, distributed as a PHAR itself +# (same lazy-download pattern as infection.phar so we don't fight phpactor's +# psr/log ^1.0 pin in composer-resolved Box). The output at var/xphp-lsp.phar +# is what the PHPStorm plugin bundles for zero-config install. +BOX_VERSION := 4.6.6 +BOX_PHAR := var/box.phar + +$(BOX_PHAR): + @mkdir -p $(dir $(BOX_PHAR)) + @echo "==> Downloading box.phar $(BOX_VERSION)" + @curl -fsSL -o $@ \ + https://github.com/box-project/box/releases/download/$(BOX_VERSION)/box.phar + @chmod +x $@ + +.PHONY: build/phar +build/phar: $(BOX_PHAR) + # Build sequence: + # 1) --no-dev + --classmap-authoritative trims phpunit and noisy dev classes. + # 2) The path-repo entry in composer.json pins "symlink": true (great for + # dev: parent edits show up live). That same setting is fatal for the + # PHAR -- PHARs can't follow symlinks, so Box would embed a dangling + # pointer and the runtime can't resolve XPHP\Transpiler\* classes. + # Replace the symlinked package with a real copy of just src/ + + # composer.json (the only paths xphp's PSR-4 autoload reaches) + # and re-dump the autoloader so the classmap targets the copy. + # 3) Restore the symlinked dev install for ongoing `make test` runs. + composer install --no-dev --classmap-authoritative --quiet --no-interaction + @if [ -L vendor/xphp-lang/xphp ]; then \ + parser_src="$$(readlink -f vendor/xphp-lang/xphp)"; \ + rm vendor/xphp-lang/xphp; \ + mkdir -p vendor/xphp-lang/xphp/src; \ + cp -RL "$$parser_src/src/." vendor/xphp-lang/xphp/src/; \ + cp -L "$$parser_src/composer.json" vendor/xphp-lang/xphp/composer.json; \ + composer dump-autoload --classmap-authoritative --no-dev --quiet --no-interaction; \ + fi + 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}')" diff --git a/README.md b/README.md index 9866c27..1fcc576 100644 --- a/README.md +++ b/README.md @@ -17,4 +17,79 @@ The server reuses the parent `xphp` package's AST, generic-instantiation language semantics. For the public-facing feature inventory plus what's planned next, see -[`docs/roadmap.md`](docs/roadmap.md). +[roadmap](docs/roadmap.md). + +--- + +## Install + +```bash +composer require xphp-lang/language-server +``` + +--- + +## Build + +```bash +make build/phar # → var/xphp-lsp.phar +``` + +The PHAR is the distribution format for editor integrations bundle -- +zero-config install for editors that can't reasonably depend on a +Composer-managed working tree. + +--- + +## Overview + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap + root((LSP)) + Navigate + definition + typeDefinition + references + implementation + callHierarchy + typeHierarchy + documentSymbol + workspaceSymbol + documentHighlight + Edit + rename + willRenameFiles + codeAction + codeLens + Understand + hover + signatureHelp + inlayHint + foldingRange + semanticTokens + Validate + parse + bound + duplicate-template + undefined-bareword + constructor-arg-mismatch + Find + completion + completionItem/resolve + Performance + AST cache + stub cache + tolerant-parse + UTF-16 columns + short-name tie-break + lint mode +``` + +## See also + +- [detailed list of features](docs/features/index.md) +- [roadmap](./docs/roadmap.md) \ No newline at end of file diff --git a/bin/xphp-lsp b/bin/xphp-lsp new file mode 100755 index 0000000..037bae3 --- /dev/null +++ b/bin/xphp-lsp @@ -0,0 +1,58 @@ +#!/usr/bin/env php +` in phpunit.xml.dist) that distinguishes by source path, but the +// runtime PHP API doesn't expose anything equivalent. If we ever start +// emitting deprecations in our own code, revisit this — the easy mitigation +// is to drop the suppression and instead pin phpactor / thecodingmachine/safe +// to versions that have caught up with PHP 8.4. +error_reporting(E_ALL & ~E_DEPRECATED); + +// LSP transport hygiene: PHP's default `display_errors=stdout` would +// write warnings / notices / uncaught-error messages straight into the +// LSP framed message stream and the client would reject the next read +// with "Missing header Content-Length", then kill the server. Confirmed +// in xphp-20260524-125122-098.log -- a single `MissingType::name()` +// fatal Error wiped out the entire session. Send errors to stderr +// instead; PhpStorm's LSP framework captures stderr into the +// per-session language-services log without touching the transport. +ini_set('display_errors', 'stderr'); +ini_set('log_errors', '1'); +ini_set('memory_limit', '-1'); + +$autoload = null; +foreach ([ + __DIR__ . '/../vendor/autoload.php', // standalone install (composer install in tools/lsp/) + __DIR__ . '/../../../vendor/autoload.php', // installed as a dep of the root xphp package +] as $candidate) { + if (is_file($candidate)) { + $autoload = $candidate; + break; + } +} + +if ($autoload === null) { + fwrite(STDERR, "xphp-lsp: could not locate vendor/autoload.php — run `composer install` inside tools/lsp/.\n"); + exit(1); +} + +require $autoload; + +exit(XPHP\Lsp\Server::run($argv)); diff --git a/box.json b/box.json new file mode 100644 index 0000000..c94a3a6 --- /dev/null +++ b/box.json @@ -0,0 +1,14 @@ +{ + "main": "bin/xphp-lsp", + "output": "var/xphp-lsp.phar", + "base-path": ".", + "directories": [ + "src", + "vendor" + ], + "files": [ + "bin/xphp-lsp" + ], + "compression": "GZ", + "compactors": [] +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..880ed94 --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "xphp-lang/language-server", + "description": "Language Server Protocol implementation for xphp. Powers diagnostics, hover, go-to-definition and completion in VS Code (and any LSP-aware editor) by reusing the xphp parser AST + Registry directly.", + "homepage": "https://github.com/xphp-lang/language-server", + "type": "library", + "license": "MIT", + "keywords": [ + "xphp", + "php", + "generics", + "transpiler", + "monomorphization", + "type-system" + ], + "require": { + "php": "^8.4.0", + "jetbrains/phpstorm-stubs": "^2026.1", + "phpactor/language-server": "^6.0", + "phpactor/language-server-protocol": "^3.5", + "phpactor/worse-reflection": "^0.6.0", + "xphp-lang/xphp": "^0.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "autoload": { + "psr-4": { + "XPHP\\Lsp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "XPHP\\Lsp\\Test\\": "test/" + } + }, + "bin": [ + "bin/xphp-lsp" + ], + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..6641300 --- /dev/null +++ b/composer.lock @@ -0,0 +1,4624 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "0fedef0b016eb60d0d633d35b8e188a3", + "packages": [ + { + "name": "amphp/amp", + "version": "v2.6.5", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "d7dda98dae26e56f3f6fcfbf1c1f819c9a993207" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/d7dda98dae26e56f3f6fcfbf1c1f819c9a993207", + "reference": "d7dda98dae26e56f3f6fcfbf1c1f819c9a993207", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1", + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^7 | ^8 | ^9", + "react/promise": "^2", + "vimeo/psalm": "^3.12" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php", + "lib/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "irc": "irc://irc.freenode.org/amphp", + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v2.6.5" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-09-03T19:41:28+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v1.8.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/4f0e968ba3798a423730f567b1b50d3441c16ddc", + "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1.4", + "friendsofphp/php-cs-fixer": "^2.3", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^6 || ^7 || ^8", + "psalm/phar": "^3.11.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v1.8.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-13T18:00:56+00:00" + }, + { + "name": "amphp/cache", + "version": "v1.5.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "fe78cfae2fb8c92735629b8cd1893029c73c9b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/fe78cfae2fb8c92735629b8cd1893029c73c9b63", + "reference": "fe78cfae2fb8c92735629b8cd1893029c73c9b63", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "amphp/serialization": "^1", + "amphp/sync": "^1.2", + "php": ">=7.1" + }, + "conflict": { + "amphp/file": "<0.2 || >=3" + }, + "require-dev": { + "amphp/file": "^1 || ^2", + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1.1", + "phpunit/phpunit": "^6 | ^7 | ^8 | ^9", + "vimeo/psalm": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A promise-aware caching API for Amp.", + "homepage": "https://github.com/amphp/cache", + "support": { + "irc": "irc://irc.freenode.org/amphp", + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v1.5.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:35:02+00:00" + }, + { + "name": "amphp/dns", + "version": "v1.2.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "4a13ffdc5e088593eb01860fc5002ebd9316d562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/4a13ffdc5e088593eb01860fc5002ebd9316d562", + "reference": "4a13ffdc5e088593eb01860fc5002ebd9316d562", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "amphp/byte-stream": "^1.1", + "amphp/cache": "^1.2", + "amphp/parser": "^1", + "amphp/windows-registry": "^0.3", + "daverandom/libdns": "^2.0.1", + "ext-filter": "*", + "ext-json": "*", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1", + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v1.2.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-08T15:06:32+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/process", + "version": "v1.1.9", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "55b837d4f1857b9bd7efb7bb859ae6b0e804f13f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/55b837d4f1857b9bd7efb7bb859ae6b0e804f13f", + "reference": "55b837d4f1857b9bd7efb7bb859ae6b0e804f13f", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "amphp/byte-stream": "^1.4", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1", + "phpunit/phpunit": "^6" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous process manager.", + "homepage": "https://github.com/amphp/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v1.1.9" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-13T17:38:25+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v1.2.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "1e360dbed3d336ced183f103d65c129f3ddeafb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/1e360dbed3d336ced183f103d65c129f3ddeafb0", + "reference": "1e360dbed3d336ced183f103d65c129f3ddeafb0", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "amphp/byte-stream": "^1.6", + "amphp/dns": "^1 || ^0.9", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri-parser": "^1.4", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1", + "phpunit/phpunit": "^6 || ^7 || ^8", + "vimeo/psalm": "^3.9@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async socket connection / server tools for Amp.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v1.2.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-28T17:10:55+00:00" + }, + { + "name": "amphp/sync", + "version": "v1.4.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "85ab06764f4f36d63b1356b466df6111cf4b89cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/85ab06764f4f36d63b1356b466df6111cf4b89cf", + "reference": "85ab06764f4f36d63b1356b466df6111cf4b89cf", + "shasum": "" + }, + "require": { + "amphp/amp": "^2.2", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1.1", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/ConcurrentIterator/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Mutex, Semaphore, and other synchronization tools for Amp.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v1.4.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2021-10-25T18:29:10+00:00" + }, + { + "name": "amphp/windows-registry", + "version": "v0.3.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/windows-registry.git", + "reference": "0f56438b9197e224325e88f305346f0221df1f71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/windows-registry/zipball/0f56438b9197e224325e88f305346f0221df1f71", + "reference": "0f56438b9197e224325e88f305346f0221df1f71", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "amphp/byte-stream": "^1.4", + "amphp/process": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\WindowsRegistry\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Windows Registry Reader.", + "support": { + "issues": "https://github.com/amphp/windows-registry/issues", + "source": "https://github.com/amphp/windows-registry/tree/master" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2020-07-10T16:13:29+00:00" + }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, + { + "name": "dantleech/argument-resolver", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://gitlab.com/dantleech/argument-resolver.git", + "reference": "e34fabf7d6e53e5194f745ad069c4a87cc4b34cc" + }, + "dist": { + "type": "zip", + "url": "https://gitlab.com/api/v4/projects/dantleech%2Fargument-resolver/repository/archive.zip?sha=e34fabf7d6e53e5194f745ad069c4a87cc4b34cc", + "reference": "e34fabf7d6e53e5194f745ad069c4a87cc4b34cc", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.10.1", + "phpunit/phpunit": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "DTL\\ArgumentResolver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Resolve method arguments from an associative array", + "support": { + "issues": "https://gitlab.com/api/v4/projects/7322320/issues" + }, + "time": "2020-04-09T09:32:31+00:00" + }, + { + "name": "dantleech/invoke", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/dantleech/invoke.git", + "reference": "9b002d746d2c1b86cfa63a47bb5909cee58ef50c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dantleech/invoke/zipball/9b002d746d2c1b86cfa63a47bb5909cee58ef50c", + "reference": "9b002d746d2c1b86cfa63a47bb5909cee58ef50c", + "shasum": "" + }, + "require": { + "php": "^7.2||^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.13", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^0.12.0", + "phpunit/phpunit": "^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "DTL\\Invoke\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "daniel leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Emulate named parameters", + "support": { + "issues": "https://github.com/dantleech/invoke/issues", + "source": "https://github.com/dantleech/invoke/tree/2.0.0" + }, + "time": "2021-05-01T17:22:58+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "jetbrains/phpstorm-stubs", + "version": "v2026.1", + "source": { + "type": "git", + "url": "https://github.com/JetBrains/phpstorm-stubs", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.86", + "nikic/php-parser": "^v5.6", + "phpdocumentor/reflection-docblock": "^5.6", + "phpunit/phpunit": "^12.3" + }, + "type": "library", + "autoload": { + "files": [ + "PhpStormStubsMap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "PHP runtime & extensions header files for PhpStorm", + "homepage": "https://www.jetbrains.com/phpstorm", + "keywords": [ + "autocomplete", + "code", + "inference", + "inspection", + "jetbrains", + "phpstorm", + "stubs", + "type" + ], + "time": "2026-02-19T20:12:01+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "league/uri-parser", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-parser.git", + "reference": "671548427e4c932352d9b9279fdfa345bf63fa00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-parser/zipball/671548427e4c932352d9b9279fdfa345bf63fa00", + "reference": "671548427e4c932352d9b9279fdfa345bf63fa00", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "phpstan/phpstan": "^0.9.2", + "phpstan/phpstan-phpunit": "^0.9.4", + "phpstan/phpstan-strict-rules": "^0.9.0", + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-intl": "Allow parsing RFC3987 compliant hosts", + "league/uri-schemes": "Allow validating and normalizing URI parsing results" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Uri\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "userland URI parser RFC 3986 compliant", + "homepage": "https://github.com/thephpleague/uri-parser", + "keywords": [ + "parse_url", + "parser", + "rfc3986", + "rfc3987", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/thephpleague/uri-parser/issues", + "source": "https://github.com/thephpleague/uri-parser/tree/master" + }, + "abandoned": "league/uri-interfaces", + "time": "2018-11-22T07:55:51+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": "phpactor/docblock-parser", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpactor/docblock-parser.git", + "reference": "7a28e139a1f008caf586822a4909fe912b6897e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/docblock-parser/zipball/7a28e139a1f008caf586822a4909fe912b6897e1", + "reference": "7a28e139a1f008caf586822a4909fe912b6897e1", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.0", + "friendsofphp/php-cs-fixer": "^3.0", + "jetbrains/phpstorm-stubs": "^2020.2", + "phpbench/phpbench": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpdoc-parser": "^0.4.10", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "symfony/var-dumper": "^5.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Phpactor\\DocblockParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Docblock Parser", + "support": { + "issues": "https://github.com/phpactor/docblock-parser/issues", + "source": "https://github.com/phpactor/docblock-parser/tree/0.2.0" + }, + "time": "2024-06-03T08:58:44+00:00" + }, + { + "name": "phpactor/language-server", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpactor/language-server.git", + "reference": "844bcee80be6f5b0c380a8f02030bbccdec76b96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/language-server/zipball/844bcee80be6f5b0c380a8f02030bbccdec76b96", + "reference": "844bcee80be6f5b0c380a8f02030bbccdec76b96", + "shasum": "" + }, + "require": { + "amphp/socket": "^1.1", + "dantleech/argument-resolver": "^1.1", + "dantleech/invoke": "^2.0", + "php": "^8.0", + "phpactor/language-server-protocol": "^3.17", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0", + "ramsey/uuid": "^4.0", + "thecodingmachine/safe": "^1.1" + }, + "require-dev": { + "amphp/phpunit-util": "^1.3", + "ergebnis/composer-normalize": "^2.0", + "friendsofphp/php-cs-fixer": "^3.0", + "jangregor/phpstan-prophecy": "^1.0", + "phpactor/phly-event-dispatcher": "~2.0.0", + "phpactor/test-utils": "~1.1.3", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "symfony/var-dumper": "^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Phpactor\\LanguageServer\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Generic Language Server Platform", + "support": { + "issues": "https://github.com/phpactor/language-server/issues", + "source": "https://github.com/phpactor/language-server/tree/6.2.0" + }, + "time": "2024-03-03T11:25:34+00:00" + }, + { + "name": "phpactor/language-server-protocol", + "version": "3.17.5", + "source": { + "type": "git", + "url": "https://github.com/phpactor/language-server-protocol.git", + "reference": "19ea6b4bd260b1622d6bd916cd1bbb8850626c39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/language-server-protocol/zipball/19ea6b4bd260b1622d6bd916cd1bbb8850626c39", + "reference": "19ea6b4bd260b1622d6bd916cd1bbb8850626c39", + "shasum": "" + }, + "require": { + "dantleech/invoke": "^2.0", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.0", + "friendsofphp/php-cs-fixer": "^2.17", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Phpactor\\LanguageServerProtocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Langauge Server Protocol for PHP (transpiled)", + "support": { + "issues": "https://github.com/phpactor/language-server-protocol/issues", + "source": "https://github.com/phpactor/language-server-protocol/tree/3.17.5" + }, + "time": "2025-12-29T17:29:52+00:00" + }, + { + "name": "phpactor/text-document", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpactor/text-document.git", + "reference": "58fe1e8b8cf61bc10138d4efd9c6fe8d88d38de4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/text-document/zipball/58fe1e8b8cf61bc10138d4efd9c6fe8d88d38de4", + "reference": "58fe1e8b8cf61bc10138d4efd9c6fe8d88d38de4", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/filesystem": "^5.0|^6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.0", + "friendsofphp/php-cs-fixer": "^3.30", + "phpactor/test-utils": "^1.1.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "symfony/var-dumper": "^5.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Phpactor\\TextDocument\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Collection of value objects for representing and referencing text documents", + "support": { + "source": "https://github.com/phpactor/text-document/tree/2.1.0" + }, + "time": "2024-06-03T08:59:50+00:00" + }, + { + "name": "phpactor/tolerant-php-parser", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/phpactor/tolerant-php-parser.git", + "reference": "e2b5a43cbc6521f14f801ca26a02f385fcee2164" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/tolerant-php-parser/zipball/e2b5a43cbc6521f14f801ca26a02f385fcee2164", + "reference": "e2b5a43cbc6521f14f801ca26a02f385fcee2164", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^10.0" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Microsoft\\PhpParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Lourens", + "email": "roblou@microsoft.com" + } + ], + "description": "Tolerant PHP-to-AST parser designed for IDE usage scenarios", + "support": { + "source": "https://github.com/phpactor/tolerant-php-parser/tree/main" + }, + "time": "2025-10-03T12:48:22+00:00" + }, + { + "name": "phpactor/worse-reflection", + "version": "0.6.0", + "source": { + "type": "git", + "url": "https://github.com/phpactor/worse-reflection.git", + "reference": "5f1aff4107d4df7a1ef379f6d1a46751fd49fb11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpactor/worse-reflection/zipball/5f1aff4107d4df7a1ef379f6d1a46751fd49fb11", + "reference": "5f1aff4107d4df7a1ef379f6d1a46751fd49fb11", + "shasum": "" + }, + "require": { + "amphp/amp": "^2.6", + "jetbrains/phpstorm-stubs": "*", + "php": "^8.1", + "phpactor/docblock-parser": "^0.2.0", + "phpactor/text-document": "^2.1.0", + "phpactor/tolerant-php-parser": "dev-main", + "psr/log": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.0", + "friendsofphp/php-cs-fixer": "^3.0", + "phpactor/class-to-file": "~0.5.0", + "phpactor/test-utils": "^1.1.5", + "phpbench/phpbench": "dev-master", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "symfony/filesystem": "^6.0", + "symfony/var-dumper": "^5.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Phpactor\\WorseReflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech", + "email": "daniel@dantleech.com" + } + ], + "description": "Lazy AST reflector that is much worse than better", + "support": { + "issues": "https://github.com/phpactor/worse-reflection/issues", + "source": "https://github.com/phpactor/worse-reflection/tree/0.6.0" + }, + "time": "2024-08-09T07:17:23+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": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^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/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/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/filesystem", + "version": "v6.4.39", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.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/v6.4.39" + }, + "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-07T13:11:42+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-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/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "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 backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/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-26T02:25:22+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": "thecodingmachine/safe", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpstan/phpstan": "^0.12", + "squizlabs/php_codesniffer": "^3.2", + "thecodingmachine/phpstan-strict-rules": "^0.12" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + }, + "autoload": { + "files": [ + "deprecated/apc.php", + "deprecated/libevent.php", + "deprecated/mssql.php", + "deprecated/stats.php", + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/ingres-ii.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/msql.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/mysqlndMs.php", + "generated/mysqlndQc.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/password.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pdf.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/simplexml.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "psr-4": { + "Safe\\": [ + "lib/", + "deprecated/", + "generated/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v1.3.3" + }, + "time": "2020-10-28T17:51:34+00:00" + }, + { + "name": "xphp-lang/xphp", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/xphp-lang/xphp.git", + "reference": "cd45ad04e194e954264a53b030ae35a32a380fcc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xphp-lang/xphp/zipball/cd45ad04e194e954264a53b030ae35a32a380fcc", + "reference": "cd45ad04e194e954264a53b030ae35a32a380fcc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7", + "php": "^8.4.0", + "symfony/console": "^8.0" + }, + "require-dev": { + "infection/infection": "^0.33", + "phpunit/phpunit": "^13.0" + }, + "bin": [ + "bin/xphp" + ], + "type": "library", + "autoload": { + "psr-4": { + "XPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matheus Martins", + "email": "math3usmartins@github.com" + } + ], + "description": "Parse xphp files and convert them into php files", + "homepage": "https://github.com/xphp-lang/xphp", + "keywords": [ + "XPHP", + "generics", + "monomorphization", + "php", + "transpiler", + "type-system" + ], + "support": { + "issues": "https://github.com/xphp-lang/xphp/issues", + "source": "https://github.com/xphp-lang/xphp" + }, + "time": "2026-06-01T20:27:49+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.1.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/3719c5b6c045761798238ebacfee1fe06e4ce5be", + "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.13" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:26:42+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.1.13", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddf7f25d9ee9652b464475d7f3bacde2613e355e", + "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.9", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.2.1", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.13" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-27T14:03:08+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:46:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-05-15T04:58:09+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-21T11:50:56+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:23:37+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.4.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7513b18 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + php: + build: + dockerfile: .docker/php.Dockerfile + working_dir: /opt/app + volumes: + - ./:/opt/app + environment: + XDEBUG_MODE: coverage,develop + +volumes: + composer_cache: ~ diff --git a/docs/features/index.md b/docs/features/index.md new file mode 100644 index 0000000..c073189 --- /dev/null +++ b/docs/features/index.md @@ -0,0 +1,380 @@ +# Features + +This is a reference for every feature from the +[overview mindmap](../../README.md), grouped by the same six themes: + +1. Navigate +2. Edit +3. Understand +4. Validate +5. Find +6. Performance + +Each section names the LSP wire method where applicable and describes the `xphp` +specific behaviour layered on top. + +For forward-looking work (planned, exploratory), see +[`roadmap`](../roadmap.md). + +--- + +## Navigate + +### Go to Definition + +LSP method: `textDocument/definition`. + +Resolves the symbol under the cursor to its declaration. Works on +classes, functions, methods, properties, `use` import aliases, and +PHP / phpstorm-stubs native symbols. Crucially, resolution flows +through xphp generics: if `$users` is declared as `Collection` +and the cursor sits on `$users->first()`, the jump lands on the +correct `User` method, not on the template's placeholder `T`. Union +and intersection receivers fan out to a per-constituent picker so +each branch is reachable individually. + +### Go to Type Definition + +LSP method: `textDocument/typeDefinition`. + +Jumps to the class behind a variable's type, walking through generic +substitution. Cursor on `$users` declared as `Collection` jumps +to `class User` rather than `class Collection`. Useful for verifying +that type-arg inference matches the developer's mental model. + +### Find References + +LSP method: `textDocument/references`. + +Project-wide reference search for classes, functions, methods, and +properties. Two distinguishing behaviours: + +- Subclass receivers are walked, so a search from `Base::m()` finds + call sites on instances typed against `Derived` (or any further + subclass). +- Interface-implementation walks run in BOTH directions: cursor on + `Iface::m` matches every implementor's call site; cursor on + `Impl::m` matches receivers typed against the interface. + +### Find Implementations + +LSP method: `textDocument/implementation`. + +Lists every implementor of an interface or abstract method, plus +subclass overrides. Complements Go to Definition (which lands on the +declaration) by enumerating the concrete downstream sites. + +### Call Hierarchy + +LSP methods: `textDocument/prepareCallHierarchy`, +`callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`. + +Bidirectional call graph for a selected method or function. V1 is +intentionally lenient on receiver-type disambiguation (matches by +name only), matching IntelliJ Java's behaviour for the same surface. + +### Type Hierarchy + +LSP methods: `textDocument/prepareTypeHierarchy`, +`typeHierarchy/supertypes`, `typeHierarchy/subtypes`. + +Bidirectional supertype / subtype tree for any class or interface. +Walks both directions of the `extends` / `implements` graph from the +selected ClassLike. + +### Document Symbol + +LSP method: `textDocument/documentSymbol`. + +Hierarchical outline of every ClassLike, function, and method +declaration in the current file. Powers Cmd+O / Ctrl+F12 Structure +popups in IDE editors. + +### Workspace Symbol + +LSP method: `workspace/symbol`. + +Cross-file FQN search backed by an in-memory index built lazily on +first query and refreshed via `workspace/didChangeWatchedFiles`. +Powers Go to Class / Go to Symbol popups. + +### Document Highlight + +LSP method: `textDocument/documentHighlight`. + +In-file occurrence highlighting. Placing the cursor on any symbol +underlines every other use of that symbol within the same file. + +--- + +## Edit + +### Rename + +LSP method: `textDocument/rename`. + +Alias-aware short-name rewriting across the project. When a class is +aliased via `use Foo\Bar as Baz;`, the rename respects the alias +boundary at each reference site. + +The PhpStorm plugin closes the PSR-4 loop end-to-end on top of the +base LSP behaviour: + +- Shift+F6 on a class renames the file to match the new class name. +- Renaming a file in the project tree updates the class declaration + and every reference site. +- Cross-directory file moves also update the namespace declaration + and every consuming `use` import. + +### Workspace file rename + +LSP method: `workspace/willRenameFiles` (LSP 3.17). + +Pre-rename hook that returns text edits the editor applies before +the rename commits. Used to keep class declarations and references +in sync when the editor (not a user-triggered Shift+F6) initiates +the file rename. + +### Code Actions + +LSP methods: `textDocument/codeAction`, `codeAction/resolve`. + +Quick fixes and refactorings, computed lazily via the resolve +round-trip so cursor movement stays responsive. Currently offered: + +- **Import class** -- when a bare short name resolves to a known + FQN, offer one action per candidate. +- **Simplify FQN** -- shrinks `\App\Models\User` to `User` and adds + the matching `use` statement. +- **Optimize Imports** -- drops unused `use` lines from the active + file. +- **"Did you mean `null` / `true` / `false`?"** typo fixes attached + to `UndefinedName` diagnostics, using Levenshtein distance against + the small set of constants frequently misspelled as a bareword. + +### Code Lens + +LSP methods: `textDocument/codeLens`, `codeLens/resolve`. + +"Show references" lens above every class / interface / trait / enum +/ function / method declaration. The resolve step fills in a lazy +reference count; clicking the lens opens a chooser popup +(`editor.action.showReferences`) -- natively in VS Code, dispatched +client-side by the PhpStorm plugin so the popup anchors at the lens +position rather than the caret. + +--- + +## Understand + +### Hover + +LSP method: `textDocument/hover`. + +Quick documentation for whatever the cursor sits on, with xphp +generics folded in. Beyond standard class / function / method / +property / native function info, hover renders: + +- Parameter and return-type substitution at static, instance, and + free-function call sites. +- Generic `T` resolved to the concrete type, including through + property fetches (`$item = $box->item` where `$box: Box` + shows `Tag`, not `T`). + +### Signature Help + +LSP method: `textDocument/signatureHelp`. + +Inline parameter list with the active argument highlighted. Type-arg +substitution is baked into the rendered signature: a call to +`new Box(...)` shows `Tag` rather than `T` in the parameter +hint. Works at static, instance, and free-function call sites. + +### Inlay Hints + +LSP method: `textDocument/inlayHint`. + +Inline substituted variable types after assignments. For example, +`$user = $users->first()` where `$users` is `Collection` +renders the inferred type `?App\Models\User` inline so the type +isn't hidden behind a hover. + +### Folding Range + +LSP method: `textDocument/foldingRange`. + +Collapsible regions for class / method / closure bodies plus xphp +`<...>` generic clauses (so the visual noise of a long type-arg +list can be folded away in deeply-nested generic call sites). + +### Semantic Tokens + +LSP method: `textDocument/semanticTokens/full`. + +AST-driven syntax highlighting using the standard LSP token-type +legend. Type-parameter `T` references render with the +`typeParameter` color in generic-syntax positions, distinguishing +them visually from regular class references. + +--- + +## Validate + +Diagnostics surface in both push (`textDocument/publishDiagnostics`) +and pull (`textDocument/diagnostic`, LSP 3.17) modes. Five +diagnostic codes are emitted today: + +### Parse errors + +Syntax errors detected by nikic/php-parser after the xphp `<...>` +clauses are stripped, with positional spans that map back to the +original source via the byte-offset map. Tolerant-parse recovery +means a single typo doesn't suppress every later diagnostic in the +file. + +### Generic bound violations + +Compile-time validation of `T: Bound` against each concrete +type-arg. The hierarchy spans the whole project on disk (not just +open buffers), so `new Box(...)` resolves correctly even when +`Tag.xphp` isn't currently open in the editor. Error messages +reference the source-level instantiation (e.g. `Box`) rather +than the hashed specialization name. + +### Duplicate template declarations + +Fires when two files declare the same generic class / interface / +trait template at the same FQN. Pins to the second declaration's +file for actionability, since the first one is already in scope by +the time the duplicate is parsed. + +### Undefined bareword warnings + +Catches references to identifiers (functions, constants) that +aren't declared anywhere reachable. Paired with the "Did you mean +`null` / `true` / `false`?" code action so the obvious typo cases +are fixable in one keystroke. + +### Constructor argument-type mismatch (`xphp.ctor-arg-mismatch`) + +Post-monomorphization check on `new C(...)` and `new C(...)` +call sites. Catches the case where the supplied argument's +statically-known type can't satisfy the constructor parameter's +declared type -- a runtime `TypeError` waiting to happen, surfaced +at compile time. Inference is intentionally narrow (literals, +`new ClassName(...)`, `true` / `false` / `null` const fetches) to +avoid false positives on arguments whose type would require flow +analysis to know. + +--- + +## Find + +### Completion + +LSP method: `textDocument/completion`. + +Context-aware completion in every meaningful position: + +- **Type-arg position** (`new Box<|>(...)`) -- bound-aware + filtering hides candidates that don't satisfy the slot's declared + upper bound; scalars are dropped when the bound is class-like. +- **Member access** (`$obj->`) and **static access** (`Cls::`) -- + methods, properties, and constants from the receiver. +- **Static property access** (`Cls::$`) -- a distinct context kind + so the `$` sigil round-trips correctly through accept. +- **Local variables** -- scope-aware: function / method / closure / + arrow-function bodies don't leak names from sibling scopes. +- **Visibility filtering** inside same-class and subclass contexts + (private members only inside the declaring class; protected + members visible across subclass receivers, etc.). +- **Union / intersection receiver fan-out** -- union shows the + permissive union of members; intersection shows the conservative + intersection. +- **String / comment / docblock suppression** -- the popup + doesn't fire inside literal text, so typing inside a string + doesn't trigger a member-access menu. + +The `insertText` for class-name candidates is scope-aware: bare +short name when the FQN is already imported or same-namespace, +aliased short name for `use Foo as Bar;`, leading-backslash +`\FQN` otherwise. Never inserts the qualified-but-not-FQ form +that would namespace-prepend at PHP name-resolution time. + +### Lazy completion-item resolve + +LSP method: `completionItem/resolve`. + +Docblock fetch deferred until the user navigates to a specific +item. Keeps the popup responsive on cold start; full documentation +fills in as items receive focus, not for every candidate up front. + +--- + +## Performance + +Implementation properties that determine how the server behaves +under load, on cold start, and across editor sessions. Not LSP +methods in their own right, but visible to users through editor +responsiveness and reliability. + +### AST cache (warmed on Initialize) + +On the LSP `initialize` handshake, a background warmer parses every +filesystem-indexed `.xphp` / `.php` file under the project root +into a version-keyed cache. Cold "Show references" on a 200-file +workspace drops from roughly seven and a half seconds to under 200 +milliseconds -- subsequent walks skip the per-file parse entirely. +The same cache feeds the bound-check hierarchy and the +template-definition registry, so cross-file generic diagnostics +work without any dependency files being open. + +### Stub cache (durable, per-user) + +worse-reflection's stub map (used to resolve PHP and +phpstorm-stubs native symbols) is serialised once per machine to a +cache directory resolved from `$XPHP_LSP_CACHE_DIR` -> XDG -> +`~/.cache/xphp-lsp` (Linux) -> `~/Library/Caches/xphp-lsp` (macOS) +-> `%LOCALAPPDATA%/xphp-lsp` (Windows) -> `/xphp-lsp` +fallback. Survives reboots and `/tmp` reaping, so cold-start cost +is paid once per machine, not once per session. + +### Tolerant-parse fallback + +In-memory locators recover from trailing parse errors so mid-edit +source (`$x->|`, `new Foo<|`) still returns useful completion / +hover / GTD results. Without this fallback, every incomplete +keystroke would temporarily break the editor's intelligence and +force the developer to wait for the source to be syntactically +valid again. + +### UTF-16 column counting + +LSP positions are spec'd in UTF-16 code units, but PHP's native +string operations work in bytes. The server's `PositionMap` +translates between the two so positions stay accurate past +supplementary-plane codepoints (emoji and similar), avoiding the +off-by-N drift that would otherwise shift every position right of +the codepoint. + +### Short-name tie-break + +When the same short name (e.g. `User`) exists at multiple FQNs +across the project -- typically `src/Models/User.xphp` and +`tests/Fixtures/User.xphp` -- the resolver prefers the canonical +`src/` path. Test fixture and vendor paths score a penalty so +navigation lands on the production declaration by default. + +### Headless `--lint` mode + +CI-friendly entry point that doesn't require an LSP client: + +```bash +tools/lsp/bin/xphp-lsp --lint path/to/file.xphp [more.xphp ...] +``` + +Output format is `::: : [] ` +-- the same shape PHPStan and php-cli emit, so editors and CI +greps consume it without ceremony. Exits non-zero if any file has +diagnostics, zero otherwise. Useful in PRs today as a fast +syntax-and-bound-check pass independent of the LSP transport. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..b0601c9 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,291 @@ +# Roadmap + +Forward-looking inventory for the `xphp` Language Server. Intended for `php` +developers working with `xphp` generics: what the LSP delivers today, what +lands next, and what's still being scoped. + +1. **Shipped** -- already in production, exercised by the test suite. Full + descriptions in [`README.md`](../README.md#features). +2. **Planned** -- design is understood, no open questions. Effort sized as + T-shirt sizes (S / M / L). +3. **Exploratory** -- value is real but the shape isn't. Each item carries a + checklist of open questions, prior art, and a proposed initial step. + +--- + +## Out of scope + +### Native non-LSP integrations + +The LSP is the canonical delivery channel. Editor-specific bindings should +consume LSP, not bypass it. + +### Static analysis tools + +Out of scope for the LSP itself -- those tools have their own LSP wrappers and +the user can stack them. + +### IDE-specific integrations + +Any features that would depend on the implementation details of a specific IDE. + +e.g. "Extract method", "Inline variable", and similar refactoring operations +**MUST** be handled via IDE plugin/extension on top of the LSP features. + +--- + +## Overview + +Features grouped by theme, not chronological or priority ordering: + +```mermaid +timeline + section Shipped + Navigation: definition: typeDefinition: references: implementation: call hierarchy: type hierarchy: documentSymbol: workspaceSymbol: documentHighlight + Editing: rename: willRenameFiles: codeAction + resolve: codeLens + resolve + Understanding: hover: signatureHelp: inlayHint: foldingRange: semanticTokens + Validation: parse: bound: duplicate-template: undefined-bareword: ctor-arg-mismatch + Completion: type-arg + member + static + variable: scope-aware insertText: completionItem/resolve + Performance: warm AST cache: stub cache: tolerant parse: UTF-16 columns: short-name tie-break: lint mode + section Planned + Editing: prepareRename: selectionRange + Navigation: documentLink + Validation: argument-type checker V2: cross-file broadcast + section Exploratory + Editing: bound name hover/jump: formatting: documentColor + Understanding: lowering preview: specialization explorer: instantiation inlay hints: bound-error fix-its: demangle FQN to source template +``` + +--- + +## Planned + +Known shape, no open design questions. Listed in rough priority order. + +### `prepareRename` -- pre-fill the rename dialog (S) + +Currently, the editor pops the rename dialog with an empty input and lets the +user type the new name from scratch. `prepareRename` returns the symbol's +current span so the dialog opens pre-filled and the user just edits in place. +One handler, one AST walk to find the identifier under the cursor. + +### `selectionRange` -- Ctrl+W expand-selection (S) + +`textDocument/selectionRange` returns a chain of enclosing AST scopes for each +cursor. PhpStorm and VS Code both bind Ctrl+W / Ctrl+Shift+W to it. +Implementation is a tree walk producing `SelectionRange { range, parent }` per +anchor. + +### Argument-type checker V2 -- methods, statics, free functions (M) + +`xphp.ctor-arg-mismatch` (cosntructor arguments mismatch) V1 covers `new C(...)` +and `new C(...)` only. V2 extends the same diagnostic to `$obj->m(...)`, +`Cls::m(...)`, and `freeFn(...)`. The hard parts (receiver-type resolution, +substitution through generics) already work for hover / signature help; the V2 +checker reuses them and emits the same diagnostic shape as V1. + +### `documentLink` -- clickable URLs in comments (S) + +`textDocument/documentLink` returns ranges + URIs for URLs and PSR-4-style +references inside comments / docblocks. Editors underline them and `Cmd+Click` +opens. Low value compared to the above, listed for completeness. + +### Cross-file diagnostic broadcast (M) + +Today: editing `Box.xphp` re-publishes `Box.xphp`'s diagnostics only; `Use.xphp` +(which instantiates `Box`) catches up the next time it's touched. The fix is +straightforward -- after each diagnostic pass, also re-publish for every file +whose AST cites the changed file's templates. The remaining work is the right +batching window so a rapid edit storm doesn't flood every consumer on every +keystroke. + +--- + +## Exploratory + +Each item has real user value but the design surface isn't pinned down. +Open questions, prior art, and a proposed initial step are captured per item; +settling those is a prerequisite to any implementation work. + +### Lowering preview -- "show me the generated PHP" + +**What it'd do.** A code lens or peek-window above any `new Foo(...)` site +that opens the generated PHP for that specialization, side-by-side with the +source. Same affordance for generic method calls. + +**Open questions.** + +- Where do the generated sources live at edit time? The compiler writes to + `var/dist/`; should those be surfaced as-is, or re-lowered on demand? +- How is the preview kept in sync as the source template changes? Re-lower on + debounce, or invalidate on `didChange`? +- Webview / panel / lens-popup -- which surface fits PhpStorm and VS Code + without diverging? + +**Prior art to study.** Roslyn's "Show IL" feature; Rust analyzer's +"View Hir" / "Expand Macro Recursively" peek; TypeScript's "Run +Generic Inference" debugging view. + +**Initial step.** A single read-only code lens that displays the +contents of `var/dist/.php` for a hard-coded file is enough +to validate round-trip latency at typical project sizes before +the dynamic re-lowering path is designed. + +### Specialization explorer -- every concrete `Box` for a template + +**What it'd do.** Cursor on `class Box`, open a tool window that +lists every `Box`, `Box`, `Box` instantiation across +the project, grouped by call site. + +**Open questions.** + +- VS Code has no native "tool window" concept beyond webviews; + what's the best surface that doesn't diverge from PhpStorm? +- The `Registry` already knows the answer, but it's a per-session + in-memory map. Persist, or re-derive on demand? +- What's the right grouping when one instantiation is reachable + through multiple call sites? + +**Prior art to study.** IntelliJ's "Hierarchy" toolwindow; PhpStorm's +"Type Hierarchy" but for type-args rather than supertypes. C++ tools' +template instantiation diagnostics. + +**Initial step.** A server-side handler that, given a template +FQN, returns the `Registry`'s list of concrete instantiations, +exposed through `workspace/executeCommand xphp.listInstantiations`. +Prototype consumption from a single client (PhpStorm) before +unifying. + +### Instantiation inlay hints -- show the specialized FQN inline + +**What it'd do.** Render `// → Box_T_d59a1...` (or a shortened +hash) as an inlay hint at every `new Box(...)` site so the +specialization a given call resolves to is visible without leaving +the editor. + +**Open questions.** + +- Hash characters are noise to read. Render the human-readable + `Box` form instead? At what verbosity setting? +- Does this fight with PhpStorm's existing inlay-hint UX or + complement it? +- Hint placement: end-of-line, after the `>`, or before the `(`? + +**Prior art.** Rust analyzer's chained-call type hints; +PhpStorm's existing parameter-name hints. + +**Initial step.** A new inlay-hint kind alongside the existing +variable-type one, gated by a config flag and validated against +the bundled playground fixtures. + +### Reverse-map mangled FQN back to source template + +**What it'd do.** When a stack trace or generated-PHP error +mentions `\XPHP\Generated\App\Containers\Box\T_d59a1...`, the +editor offers Ctrl+Click on that string to jump to `class Box` +in the source. + +**Open questions.** + +- Surface origin: stack traces in run output? Manual paste into a + search? A "Reveal source template" action on a hover over the + mangled name in generated PHP? +- Hash length is configurable per project; how should resolution + behave when two projects use different hash lengths? + +**Prior art.** Java's stack-trace mangled-name resolution in +IntelliJ; Rust's `rustc-demangle` for symbol names. + +**Initial step.** Expose the FQN → template lookup as a server +method `xphp.demangle`. Prototype consumption in PhpStorm's +"Analyze stacktrace" dialog as a transformation pass. + +### Bound-error fix-its -- "implement missing interface" / "swap type-arg" + +**What it'd do.** Today, a `Generic bound violated` diagnostic shows +the explanation but no quick fix. The fix-it would offer: + +1. "Add `implements \Stringable` to `class App\Models\User`" -- one + text edit on the supplied concrete type's declaration. +2. "Swap type-arg to ``" -- show the list of project classes + that already satisfy the bound, picked via the existing bound- + aware completion filter. + +**Open questions.** + +- For (1), insertion of `implements` is a straightforward parser + walk; the leading `\` qualification is the same problem + `ClassNameImportContext` solves and can be reused. +- For (2), candidate ranking: alphabetical, by frequency in the + project, or by current import status? +- Cross-file edits: the type-arg site and the class declaration + may live in different files. LSP `WorkspaceEdit` handles this + in the protocol, but the UI feedback in PhpStorm for multi-file + actions is uneven. + +**Prior art.** TypeScript LSP's "Add missing properties" quick +fix; Rust analyzer's "Implement trait" assist. + +**Initial step.** Fix (2) wired end-to-end first (single-file +edit, reuses existing completion infrastructure). Fix (1) deferred +until the multi-file edit story is ironed out via the rename loop. + +### Hover / jump on bound names in template headers + +**What it'd do.** Cursor on `Stringable` inside +`class Box` should hover the interface and Ctrl+Click +to its declaration. Today the `<...>` clause is stripped by the +XphpSourceParser before nikic sees the source, so no AST node is +positioned over the bound text. + +**Open questions.** + +- The parser strips the clause for a reason: it's not valid PHP + and would crash nikic. Re-emit the bound as a synthetic `Name` + node with the original source span attached? Or extend the + LSP-side `AstPositionResolver` to recognise the bound region by + string-matching? +- Same question for type-args: cursor on `T` inside + `` -- should that resolve as a type-param + declaration? + +**Prior art.** TypeScript LSP's handling of `` +positions; nikic's existing attribute system for source-span +retention. + +**Initial step.** Detect the bound region via TextDocument regex +(XphpSourceParser already knows the strip ranges) and synthesize +a definition response without changing the parser. Hover follows +the same approach independently. + +### `textDocument/formatting` + `rangeFormatting` + `onTypeFormatting` + +**What it'd do.** Format-on-save for `.xphp` files. + +**Open questions.** + +- An xphp formatter doesn't exist yet. Either ship the PHP + formatter (php-cs-fixer / nikic pretty-printer) over the + stripped form, or write an xphp-aware formatter that preserves + `` clauses verbatim -- which one fits best? +- If a PHP formatter handles the stripped form, how should the + generic clause round-trip without being eaten as a syntax error? + +**Prior art.** Prettier's PHP plugin; PhpStorm's built-in +formatter when generics are present in PHPDoc. + +**Initial step.** Formatter survey before any LSP plumbing -- the +formatter question gates the rest. + +### `textDocument/documentColor` + `colorPresentation` + +**What it'd do.** Detect color literals (`#fff`, `rgb(...)`) in +strings and surface a color picker on hover. + +**Open questions.** + +- Is there a meaningful PHP use case beyond CSS-in-PHP / template + libraries? +- Listed for completeness; the value isn't validated yet, and the + item should drop off entirely if no PHP-shaped use case + materialises. diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..9a354e5 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,2106 @@ +{ + "$schema": "https://raw.githubusercontent.com/infection/infection/master/resources/schema.json", + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "." + }, + "logs": { + "text": "var/infection.log", + "html": "var/infection.html" + }, + "mutators": { + "@default": true, + + // Cycle B (ImportCodeActionProvider). Bulk-class entries appear + // in every off-by-one / defensive-guard / iteration-order + // mutator block below for the same reason: the provider walks + // the AST once, derives a use map and an insertion line, and + // emits a CodeAction with text edits. The off-by-one mutants + // around `+1` / `-1` line arithmetic on insertion positions + // land in the same source line (LSP clients accept either + // start-of-line position); the defensive `getStartFilePos() >= 0` + // / `getEndFilePos() >= 0` guards never observe negative + // values from nikic-parsed source; iteration-order shortcuts + // (`break` / `continue`) round-trip to the same observable + // CodeAction list under the test fixtures. + // + // Where a specific guard has a distinct rationale (e.g. the + // `$result->ast === null || $result->ast === []` empty-AST + // gate that takes the tolerant-parse fallback's empty list + // into account), it's documented inline in its block. + + // XphpTypeHierarchyHandler -- `prepare`, `supertypes`, + // `subtypes`, plus the AST-walking / item-building + // helpers (`buildItem`, `findClassLikeAt`, + // `findClassLikeByFqn`, `forEachClassLikeInWorkspace`, + // `visitClassLikes`, `collectSupertypeFqns`, `nameOf`, + // `extendsOrImplementsDirectly`, `symbolKind`). Surviving + // mutants fall into three families: + // + // 1. Defensive guards that never observe their negative + // branch under nikic-parsed input -- `$result->ast + // === null || $result->ast === []`, `$start < 0 || + // $end < 0` on Name positions, `is_string($targetFqn) + // && $targetFqn !== ''` on item data, `is_array + // ($itemData)` on raw-array params. Same pattern + // every other handler in this file already ignores. + // 2. Item-shape arithmetic on buildItem -- the +1 for + // exclusive-end conversion, the null-coalescing + // fallback to range positions when the name node has + // no position info, and the ArrayItemRemoval mutants + // on each TypeHierarchyItem field (name, kind, uri, + // range, selectionRange, data). PhpStorm gracefully + // handles a missing field by falling back to defaults; + // the integration tests verify the shape end-to-end. + // 3. Equivalence-by-canonicalisation -- `ltrim('\\', $fqn)` + // on already-normalised FQNs, `array_unique + + // array_values` over a list that's already deduped by + // construction, `foreach` iteration order that doesn't + // affect the merged result. + + // XphpHoverHandler angle-clause helpers (`angleClauseAt`, + // `findAngleRange`, `topLevelArgIndexAt`, `typeArgFqnAt`). + // These resolve the FQN of the type-arg the cursor sits on + // inside a `<...>` clause that XphpSourceParser stripped to + // whitespace before parsing. Most surviving mutants here are + // jointly-defensive guards (`!is_array`, `$nameEnd < 0`, + // `$argIndex === null || !isset(...)`) that never fire in + // production -- ATTR_GENERIC_ARGS is always a populated list, + // nikic populates valid byte positions, and topLevelArgIndexAt's + // own bounds check filters every cursor-offset the + // strict-containment guard in angleClauseAt's visitor would + // also catch (the two are redundant by design: strict + // containment is the documented intent, the inner bounds are + // defense-in-depth). The +1 / -1 arithmetic on innerStart / + // closePos / loop counters produces observably different + // ranges only for malformed source (bare `<<>` outside any + // type-arg context, cursor sitting exactly on a `,` boundary) + // that's unreachable from well-formed xphp. Bulk-ignored + // across the mutator blocks below for that reason. + + // PositionMap::binarySearchLine is a textbook upper-bound binary search + // — every "off by one" mutation (Plus → Minus on the +1, Decrement of + // $low = 0, Decrement/Increment on $high = $mid - 1, < vs <= on the + // loop guard) either converges to the same final index OR causes an + // infinite loop that infection reports as a timeout (already counted + // separately). End-to-end coverage from PositionMapTest's round-trip + // and exact-boundary tests proves the search behaves correctly. + "Plus": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + "XPHP\\Lsp\\PositionMap::binarySearchLine", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + "DecrementInteger": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // ParsedDocumentCacheWarmer counter inits (`$warmed = 0;`, + // `$skippedOpen = 0;`, etc) -- the counters feed only + // the `[xphp-lsp warmer]` stderr line. Initial value 0 + // vs -1 changes only the printed digit, not behaviour; + // stderr is muted in tests anyway. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warmNow", + // Cycle L XphpWillRenameFilesHandler::editsForFileRename + // `new TextDocumentItem($uri, 'xphp', 0, $source)` -- + // the version 0 sentinel is just a placeholder for the + // workspace-inject; nothing reads it (the inject is + // removed in `finally` before the workspace sees a real + // didOpen). Negative or positive integers behave + // identically. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::editsForFileRename", + // Cycle L XphpWillRenameFilesHandler::basenameStem -- + // `if ($dot === false || $dot === 0)` -> `$dot === -1`. + // The check guards hidden-file basenames (`.gitignore`). + // Both arms return the basename as-is OR fall through + // to substr -- either way the result fails the + // IDENTIFIER_PATTERN check downstream in + // editsForFileRename, returning null. Same observable. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::basenameStem", + // XphpFileWatcherHandler::didChangeWatchedFiles + // `$skippedOpen = 0;` -- same observability-counter + // rationale; the value only reaches `[xphp-lsp watch]` + // stderr output. + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::topLevelArgIndexAt", + "XPHP\\Lsp\\PositionMap::binarySearchLine", + // $diagnosticsDebounceMs = 300 default — debounce-window jitter + // (300 vs 299 ms) is behaviourally identical at the unit-test + // level; we don't simulate timing. + "XPHP\\Lsp\\LspDispatcherFactory::__construct", + // XphpFoldingRangeHandler::addRange `$node->getEndFilePos() + 1` + // — the +1 converts nikic's inclusive end-of-node offset into + // the exclusive form positionToOffset/offsetToPosition expects. + // Flipping `+ 1` -> `+ 0` shifts $end one byte earlier, but for + // a multi-line declaration the closing `}` lands on the same + // line regardless (the +1 only matters when the node ends + // mid-line, which fold-eligible bodies don't). + "XPHP\\Lsp\\Handler\\XphpFoldingRangeHandler::addRange", + // XphpSignatureHelpHandler::signatureHelp + // `activeSignature: 0` -- we only ever emit one + // signature today (no overload support since PHP + // lacks overloads), so any mutation on the index 0 + // is observationally equivalent. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::signatureHelp", + // XphpSignatureHelpHandler::findEnclosingCall `+ 1` + // boundary on `$end + 1` -- same end-of-node mid-line + // pattern as XphpFoldingRangeHandler::addRange. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::findEnclosingCall", + // XphpSignatureHelpHandler::computeActiveParameter + // `$argEnd + 1` -- the +1 makes the bound inclusive + // for cursor-equals-end-of-arg, which corresponds to + // "cursor immediately after the arg". -1/0 gives + // the same answer in all single-arg-slot tests. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::computeActiveParameter", + // XphpInlayHintHandler::hintForAssign `$varEnd + 1` + // -- the +1 positions the hint immediately after the + // variable name; -1/0 still lands on or just before + // the same character, which our `paddingLeft: true` + // makes visually indistinct. + "XPHP\\Lsp\\Handler\\XphpInlayHintHandler::hintForAssign", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + // Cycle K.1 ReferenceFinder fan-out: defensive + // `max(0, $parent->var->getEndFilePos())` byte-offset + // clamps inside resolveTargetAt / collectReferences. + // nikic emits non-negative end positions for parsed + // nodes, so the +/-1 increment lands one byte inside + // the receiver token either way -- worse-reflection's + // nodeContext resolves the same NodeContext. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + "XPHP\\Lsp\\Resolver\\GenericResolver::resolvePropertyFetch", + "XPHP\\Lsp\\Resolver\\GenericResolver::findPropertyType", + // completeVariables `max(0, $character - $prefixLen)` + // anchor clamp -- defensive against the + // never-observed `prefix > character` shape. Same + // guard as the static-prop branch a few hundred + // lines up (`staticPropAnchorStart` arithmetic). + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::completeVariables" + ] + }, + "IncrementInteger": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // ParsedDocumentCacheWarmer/XphpFileWatcherHandler -- + // counter readouts feeding `[xphp-lsp ...]` stderr + // lines (e.g. `$droppedCache` formatted into the watch + // log). Behaviourally observable only via stderr, + // muted in tests. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warmNow", + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles", + // Cycle L XphpWillRenameFilesHandler::editsForFileRename + // -- same TextDocumentItem version-sentinel rationale + // as the DecrementInteger ignore above. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::editsForFileRename", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::findAngleRange", + "XPHP\\Lsp\\PositionMap::binarySearchLine", + // Same debounce-window equivalent on the +1 side. + "XPHP\\Lsp\\LspDispatcherFactory::__construct", + // fullLineRangeFromNikic: `?? strlen($this->source) + 1` last-line + // fallback. The value is then passed to substr() which clamps to + // the source length, so +1 vs +2 produces identical lineText. + "XPHP\\Lsp\\PositionMap::fullLineRangeFromNikic", + // Mirrors the DecrementInteger entries above; same + // pattern. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::signatureHelp", + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::findEnclosingCall", + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::computeActiveParameter", + "XPHP\\Lsp\\Handler\\XphpInlayHintHandler::hintForAssign", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + // Mirror of the DecrementInteger entries -- same + // `max(0, $node->var->getEndFilePos())` clamp. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // completeVariables `max(0, $character - $prefixLen)` + // arithmetic -- same defensive shape as the + // DecrementInteger entry above. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::completeVariables" + ] + }, + "Minus": { + "ignore": [ + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\PositionMap::binarySearchLine", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver" + ] + }, + + // utf8CharLength bitmask & constant mutations: the mask + equality + // checks form a leading-byte classifier. Several mutations on the + // `0xE0` / `0xC0` / `0xF0` / `0xF8` constants produce masks that + // accidentally still pass for the leader bytes we test (0xC3, 0xE2, + // 0xE6, 0xF0). Adding tests with EVERY possible leader byte (every + // even and odd byte across the valid range) is exhaustive but + // wasteful — the function is a well-known UTF-8 length table that's + // verified end-to-end via the round-trip and diverse-leader tests. + "BitwiseAnd": { + "ignore": [ + "XPHP\\Lsp\\PositionMap::utf8CharLength" + ] + }, + + // CatchBlockRemoval on the `catch (RuntimeException $e)` branch in + // Analyzer::analyzeFile: defensive catch for a code path that + // XphpSourceParser doesn't reach in normal operation (nikic's default + // Throwing error handler throws PhpParser\Error before any + // RuntimeException can fire). XphpSourceParser is `final` so we can't + // extend it for a test fixture; keeping the catch is correct + // defensive code, but unkillable without dependency injection that's + // out of scope. + "CatchBlockRemoval": { + "ignore": [ + "XPHP\\Lsp\\Analyzer\\Analyzer::analyzeFile" + ] + }, + + // XphpCompletionResolveHandler::resolve catches + // `NotFound | SourceNotFound | Throwable`. Removing any single + // exception type from the catch-class-list is observationally + // equivalent: the `Throwable` clause already catches every + // descendant, including NotFound and SourceNotFound (both + // descend from Throwable). We keep the explicit type listing + // for documentation purposes. + "Catch_": { + "ignore": [ + "XPHP\\Lsp\\Handler\\XphpCompletionResolveHandler::resolve" + ] + }, + + // XphpCompletionResolveHandler::resolve `trim($docblock->formatted())` + // -- the trim is defensive against trailing whitespace from + // worse-reflection's formatted() output. Removing the trim + // would let pure-whitespace docblocks through the + // `$text === ''` guard, but our test fixtures don't produce + // whitespace-only docblocks; the `formatted()` implementation + // also doesn't return whitespace-only strings for any + // realistic input. + "UnwrapTrim": { + "ignore": [ + "XPHP\\Lsp\\Handler\\XphpCompletionResolveHandler::resolve", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter" + ] + }, + + // TypeArgPositionDetector::detect — the $i=0 boundary on the backwards + // walk: every branch of the loop body produces the same end-state as + // not entering the loop at that index, because $i=0 means we've already + // walked through the whole source. The `$prefixStart > 0` guard at + // $prefixStart=0 falls through with an empty prefix either way. + // Mutations on $i-- after $depth++ cause infinite loops (reported as + // timeouts, already counted). + "GreaterThan": { + "ignore": [ + // XphpFileWatcherHandler::didChangeWatchedFiles + // `elseif ($skippedOpen > 0)` — > -> < flips the + // skipped-invalidation log gate (true when skippedOpen + // < 0, which is unreachable since the counter is only + // incremented). No observable behaviour difference + // beyond a never-emitted stderr line. + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::findClassLikeAt", + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::findAngleRange", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::topLevelArgIndexAt", + "XPHP\\Lsp\\Handler\\TypeArgPositionDetector::detect", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle K.1 fanOutMembers: `count($perComponent) > 1` + // gate. With 1 component the intersection collapses + // to the component itself, which is identical to + // taking $perComponent[0] directly. Equivalent. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + // Cycle K.1 itemsForClass (extracted from completeMembers): + // `if ($staticPropPrefixLen > 0)` prefix-backsweep + // gate -- the > 0 check is identical to >= 0 because + // strlen() returns a non-negative int and 0 means + // there is nothing to backsweep (anchor stays at the + // caret position either way). Pre-existing guard + // moved here by the K.1 refactor. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + // Cycle K.1 resolveTargetAt: `if (count($declared) > 1) + // { $target['classNames'] = $declared; }` -- single- + // class case omits classNames; collectReferences then + // derives `targetClasses = $target['classNames'] ?? [$targetClass]` + // which produces the same singleton array either way. + // Equivalent under our test fixtures. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::findClassLikeFqnAt", + ] + }, + "GreaterThanOrEqualTo": { + "ignore": [ + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\TypeArgPositionDetector::detect", + // WorkspaceAnalyzer column-accurate range guards: + // `$identifier->getStartFilePos() >= 0` and + // `$node->getStartFilePos() >= 0 && $node->getEndFilePos() >= 0`. + // nikic's default lexer always populates startFilePos / + // endFilePos for nodes parsed from real source; the >= 0 + // guards exist defensively for synthetic nodes that don't + // appear in this LSP's input. Same pattern + rationale as + // the AstPositionResolver guard already in this file. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + + // The `$offset - $prefixStart` length calculation: substr() clamps to + // the source's end whenever offset == cursor (the typical case where + // there's no content past the cursor). Crafting a fixture where + // offset != cursor (i.e. source with content past the cursor) would + // exercise it, but the LSP analyzer only ever calls detect() with + // offset = cursor — the post-cursor source doesn't exist in practice. + "Decrement": { + "ignore": [ + "XPHP\\Lsp\\Handler\\TypeArgPositionDetector::detect" + ] + }, + + // WorkspaceAnalyzer visitor LogicalOr chains: `!is_array($params) || + // $params === [] || !is_string($fqn)` — the three clauses are jointly + // necessary, but no realistic input fires exactly one of them in + // isolation (params is set together with fqn by XphpSourceParser, or + // neither). The guard is correct; the individual-OR-clause mutation + // can't be distinguished by any AST nikic would actually emit. + "LogicalOr": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::prepare", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::supertypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::subtypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::findClassLikeAt", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::extractPosition", + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::topLevelArgIndexAt", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::typeArgFqnAt", + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + // XphpDefinitionHandler::findDefinitionAcrossWorkspace's + // `if ($startOffset === null || $endOffset === null)` — both + // are set together by the inner visitor; the OR can never + // observe one null without the other. + "XPHP\\Lsp\\Handler\\XphpDefinitionHandler::findDefinitionAcrossWorkspace", + // ReferenceFinder::resolveTargetAt defensive + // `$start < 0 || $end < 0` guard against synthetic + // nodes without position info -- same equivalent-by- + // unreachability pattern as the AstPositionResolver + // LessThanOrEqualTo ignore further down. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + // XphpFoldingRangeHandler::addRange `$start < 0 || $end <= $start` + // -- the OR is jointly defensive; either clause's + // negation alone is not observable for any nikic- + // parsed input where $start >= 0 and $end > $start + // by construction. + "XPHP\\Lsp\\Handler\\XphpFoldingRangeHandler::addRange", + // XphpSignatureHelpHandler::findEnclosingCall + // `$start < 0 || $offset < $start || $offset > $end + 1` + // -- same defensive nikic-position pattern; nikic + // always populates startFilePos >= 0 for parsed nodes, + // and offset-in-range tests cover the inner clauses. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::findEnclosingCall", + // PhpDefinitionResolver::resolveTypeInner typeBearing + // OR-chain of `=== Symbol::*` checks -- non-type- + // bearing symbol kinds all resolve to null via + // locateClass for their inferred type either way. + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveTypeInner", + // XphpCompletionResolveHandler::resolve OR-chain on + // `$kind !== 'class' || !is_string($fqn) || $fqn === ''` + // -- the three clauses are jointly defensive against + // malformed `data` payloads. Each test exercises one + // path; flipping clauses doesn't observably change the + // pass-through behaviour for items that don't match + // our expected shape. + "XPHP\\Lsp\\Handler\\XphpCompletionResolveHandler::resolve", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inheritsMemberFromTarget", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::classImplementsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::interfaceExtendsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::declaresMember", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + "XPHP\\Lsp\\Resolver\\GenericResolver::resolvePropertyFetch", + "XPHP\\Lsp\\Resolver\\GenericResolver::findPropertyType", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::implementation", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::findClassLikeFqnAt", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::buildLocation", + ] + }, + + // XphpHoverHandler::buildHoverMarkdown `&& self::allConcrete($args)` + // chain — same shape as the WorkspaceAnalyzer guard: the four clauses + // (is_array, != [], is_string, allConcrete) are jointly set together + // by the parser; any single-clause divergence requires fabricating + // an AST that XphpSourceParser would never produce. + "LogicalAnd": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // Cycle L XphpWillRenameFilesHandler::findClassLikeNameOffset + // -- the inner `if ($inner instanceof ClassLike && + // $inner->name !== null)` joint-narrowing. PHP's + // top-level Namespace_ stmts hold only the kinds the + // parser produces; mutating to OR would still hit + // ClassLike branches (the only nodes in real source + // that have a non-null `name`). Same outcome. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::findClassLikeNameOffset", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::buildHoverMarkdown", + // XphpSignatureHelpHandler::buildSignature + // `$type !== '' && $type !== ''` -- same + // joint-defensive pattern as PhpHoverResolver's + // property/method renderers; the inferredType() + // either returns a valid type name OR exactly + // ''/''. Flipping either clause is not + // observably distinct. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::buildSignature", + // XphpSignatureHelpHandler::calleeName / reflectCallee + // dispatch checks (`$call instanceof StaticCall && + // $call->name instanceof Node\Identifier`, etc.). + // The matching `instanceof` narrowing is jointly + // necessary; any single-clause flip lands in a + // different dispatch arm or null, which our matrix + // of tests for each call kind covers via the happy + // path of THE OTHER kind. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::calleeName", + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::reflectCallee", + // XphpSignatureHelpHandler::computeActiveParameter + // `if ($argEnd < 0) continue;` defensive guard -- + // nikic populates getEndFilePos >= 0 for parsed Arg + // nodes; the guard is dead in practice. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::computeActiveParameter", + // XphpSignatureHelpHandler::resolveClassNameAt + // defensive `$resolved !== '' && $resolved !== ''` + // -- same shape as buildSignature's type-validity + // guard. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::resolveClassNameAt", + // XphpInlayHintHandler::hintForAssign instanceof + // dispatch (`$rhs instanceof StaticCall && $rhs->name + // instanceof Node\Identifier`, etc.). Each + // dispatch arm is mutually exclusive; flipping any + // instanceof flag lands in another arm or null, + // observationally indistinguishable when both arms + // return null for the test fixture's RHS shape. + "XPHP\\Lsp\\Handler\\XphpInlayHintHandler::hintForAssign", + // WorkspaceAnalyzer column-accurate range guards — see the + // `GreaterThanOrEqualTo` ignore above for the rationale. + // `($identifier !== null && $identifier->getStartFilePos() >= 0)` + // and the matching guard in walkInstantiations: both clauses + // are jointly defensive against synthetic nodes that don't + // appear from nikic-parsed source. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::fanOutRender", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + // Cycle K.1 ReferenceFinder::inferReceiverClassesAt + // single-class fast path: the guard + // `if ($swapped !== null && $swapped !== '')` short- + // circuits the generic-resolver hit when the + // substituted type comes back empty/null. Flipping + // either clause forces an empty receiver downstream, + // which falls through to the original $typeName -- + // the same return when no swap was performed. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt", + // Cycle D cacheRoot: `is_string($home) && $home !== ''` + // -- the is_string check is defensive against + // getenv()'s `false` return when an env var isn't + // set. Flipping the && doesn't change the observed + // outcome under our env-controlled tests because + // either branch falls through to the next fallback. + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + "XPHP\\Lsp\\Resolver\\GenericResolver::resolvePropertyFetch", + "XPHP\\Lsp\\Resolver\\GenericResolver::findPropertyType" + ] + }, + + // XphpCompletionHandler — `isIncomplete: false` on CompletionList is + // metadata that influences whether the client re-asks for completions + // on the next keystroke. We always return a complete list (per-call + // workspace scan), so true vs false is behaviourally indistinguishable + // at the unit-test level. The LSP client does its own filtering on + // top of the list anyway. + "FalseValue": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::cloneWithResolvedNames", + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::complete", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle K.1 collectReferences `$matched = false;` + // initial-state flag for the PropertyFetch + // receiver/target nested-loop match. FalseValue + // flips it to true, which would emit a yield for + // every visited PropertyFetch node. Killing requires + // a negative-case fixture (PropertyFetch on an + // unrelated type that must NOT appear in references) + // -- deferred; the prod impact is over-reporting at + // worst, not silent under-reporting. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::cloneWithResolvedNames", + ] + }, + + // XphpCompletionHandler::matchesPrefix `ltrim($prefix, '\\\\')` — + // strips leading backslashes from FQN-style prefixes. Removing it + // breaks `\App\Foo` style prefixes but our completion paths in + // practice strip them before reaching matchesPrefix (XphpSourceParser + // already trims leading backslashes during type-arg parsing). The + // ltrim is a belt-and-suspenders defense; equivalent in current + // callers. + // + // FilesystemSourceLocator::locate `ltrim((string) $name, '\\\\')` + // — `Phpactor\WorseReflection\Core\Name::fromString` already + // normalizes its input by stripping a leading backslash, so by + // the time `(string) $name` runs the leading slash is already + // gone. The ltrim is defensive against any locator caller that + // bypasses `Name::fromString`, but no production path does. + // + // ReferenceFinder: every UnwrapLtrim site reads either from a + // nikic `Name->toString()` result (which never carries a + // leading backslash regardless of whether the source was + // `Name` or `FullyQualified`) or from a `$target['fqn']` that + // was itself produced by `ltrim(...)` upstream in the same + // module. The ltrim is defensive against direct-string FQN + // inputs that don't occur in any current production path. We + // do exercise leading-backslash inputs via + // FqnIndexTest::testPublicLookupApisAcceptLeadingBackslashForm + // for the FqnIndex side; the ReferenceFinder ltrims are too + // deep inside private walks to test economically without + // creating fixture infrastructure for malformed callers. + "UnwrapLtrim": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::nameOf", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::extendsOrImplementsDirectly", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::locateClassLike", + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::matchesPrefix", + "XPHP\\Lsp\\Reflection\\FilesystemSourceLocator::locate", + "XPHP\\Lsp\\Resolver\\ReferenceFinder", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::fanOutRender", + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::fanOutLocate", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::extendsOrImplementsDirectly", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::nameOf", + ] + }, + + // CastString mutants on `(string) $X` expressions where the + // value is already a string OR where the embedding context + // already invokes __toString. Method-level ignores keep the + // scope tight: other (string) casts in these classes that + // ARE load-bearing remain mutation-tested. + // * XphpHoverHandler::buildHoverMarkdown: `(string) $classLike->name` + // -- Identifier implements __toString. + // * FqnIndex::collectGenericClasses / collectSymbolHits / + // allFunctionFqns (lines 519/571/709): `(string) $uri` + // where $uri is the workspace key, already a string. + // * PhpHoverResolver::renderMethod / renderProperty (line + // 229/274): `(string) $method->visibility()` -- + // Visibility enum has __toString. + // * ReferenceFinder::shortNameAt / findReferences / + // collectReferences (107/110/145/493/498/598): + // `(string) $target['']` -- the internal $target + // arrays are constructed with string-only values upstream. + // * RenameProvider::buildFileRenameOp line 168: + // `(string) $location['uri']` -- the location array's + // 'uri' key is always a PHP string from FqnIndex. + "CastString": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpCallHierarchyHandler::collectCallSites + // `$uriStr = (string) $uri;` -- $uri is already + // string-coercible (phpactor Workspace iter values). + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler::collectCallSites", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::locateClassLike", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::forEachClassLikeInWorkspace", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::visitClassLikes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::findClassLikeByFqn", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::buildHoverMarkdown", + "XPHP\\Lsp\\Reflection\\FqnIndex::collectGenericClasses", + "XPHP\\Lsp\\Reflection\\FqnIndex::collectSymbolHits", + "XPHP\\Lsp\\Reflection\\FqnIndex::allFunctionFqns", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::renderMethod", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::renderProperty", + // XphpSignatureHelpHandler::buildSignature + // `(string) $param->inferredType()` -- worse-reflection + // Type implements __toString; sprintf/concat coerce + // it identically without the explicit cast. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::buildSignature", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::shortNameAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::findReferences", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + "XPHP\\Lsp\\Resolver\\RenameProvider::buildFileRenameOp", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::findClassLikeFqnAt", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::forEachClassLikeInWorkspace", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::visitClassLikes", + ] + }, + + // AstPositionResolver line 54: `if ($start < 0 || $end < 0) return null;` + // — defensive against synthetic nodes lacking position info. nikic's + // default lexer always populates startFilePos/endFilePos, so neither + // branch fires in practice. The guard is correct defensive code. + // + // XphpFoldingRangeHandler::addRange `if ($start < 0 || $end <= $start)` + // -- the `<= $start` side is a zero-length-range guard; flipping + // it to `<` would let $end == $start through, but the + // subsequent `$endLine <= $startLine` check rejects single-line + // folds anyway, so a zero-length range produces no output via + // either branch. + "LessThanOrEqualTo": { + "ignore": [ + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\AstPositionResolver", + "XPHP\\Lsp\\Handler\\XphpFoldingRangeHandler::addRange", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + + // Same defensive `$start < 0 || $end < 0` guard pattern -- + // ReferenceFinder::resolveTargetAt has its own copy inside an + // anonymous-class visitor (line 245). LessThan flips `<` to + // `<=` which would also exclude `offset == 0` start positions, + // but nikic emits startFilePos as a real byte offset (>= 0) + // so neither LessThan nor LogicalOr clause swap is killable + // by any nikic-parsed input. + // + // XphpFoldingRangeHandler::addRange `$start < 0` -- same + // pattern: a class can in principle start at byte 0 if the + // source has no ` $end + 1` + // -- same defensive nikic-position pattern as + // ReferenceFinder::resolveTargetAt's `< 0` guard. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::findEnclosingCall", + // XphpSignatureHelpHandler::resolveClassNameAt + // `if ($nameStart < 0)` defensive guard. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::resolveClassNameAt", + // XphpSignatureHelpHandler::computeActiveParameter + // `if ($argEnd < 0) continue;` defensive guard. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::computeActiveParameter", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + ] + }, + "LessThanNegotiation": { + "ignore": [ + // XphpCallHierarchyHandler::buildTopLevelItem -- the + // defensive `if ($startByte < 0 || $endByte < 0 || + // $endByte < $startByte)` guard against malformed + // nikic positions. nikic always populates valid + // positions for parsed statements, so the negative- + // position and end-before-start branches are + // unreachable from production input; the guard + // exists for synthetic-AST safety. + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler::buildTopLevelItem" + ] + }, + + // ReferenceFinder line 419: InstanceOf_ on + // `$best instanceof Node\VarLikeIdentifier || $best instanceof Identifier`. + // PropertyProperty's `name` field is a VarLikeIdentifier in + // some nikic versions and an Identifier in others; both + // branches are real but only one fires for any given nikic + // version. The OR is forward-compat insurance, not a + // behaviour we can exercise with the currently-installed + // version. + "InstanceOf_": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\ReferenceFinder", + // XphpSignatureHelpHandler::calleeName / reflectCallee + // dispatch checks (`$call instanceof FuncCall && + // $call->name instanceof Node\Name`, etc.). Each + // dispatch arm narrows the node type AND validates + // the name shape; flipping either instanceof gives + // the same null result for the unused branches. + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::calleeName", + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::reflectCallee", + // XphpInlayHintHandler::hintForAssign $rhs instanceof + // StaticCall / FuncCall arm checks -- same mutually- + // exclusive dispatch as the SignatureHelp variant. + "XPHP\\Lsp\\Handler\\XphpInlayHintHandler::hintForAssign", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + + // WorkspaceAnalyzer column-accurate range guards (same family as the + // GreaterThanOrEqualTo / LogicalAnd ignores above). The + // `$identifier !== null` defensive null-check, plus + // GreaterThanOrEqualToNegotiation / LogicalAndAllSubExprNegation / + // LogicalAndNegation variants — each probes the same defensive + // guard whose alternate branch (synthetic node without position info) + // never fires from nikic-parsed source. + // FqnIndex::collectGenericFunctionsAndMethods leaveNode guard: + // `if ($node instanceof ClassLike && $this->classStack !== [])`. + // `array_pop` on an empty array is a documented no-op (returns + // null without warning), so the `classStack !== []` clause is + // belt-and-suspenders defense. NotIdentical flips `!==` to + // `===` which mutates the guard to a no-op equivalent. + "NotIdentical": { + "ignore": [ + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Reflection\\FqnIndex::collectGenericFunctionsAndMethods", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle K.1 inferReceiverClassesAt: + // `$lookupName !== ''` ternary guard. Empty string + // is falsy in PHP; both `!== ''` and `=== ''` route + // empty values to the same empty-list return, so + // the comparison operator flip lands in the same arm. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + // Cycle D cacheRoot `!== ''` env-string guards: empty + // string is treated as "no value present" -- both + // operators route to the next fallback identically. + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // Bare-static branch's `$propType !== '' && $propType + // !== ''` detail-field defensive type guard. + // The static-prop branch's `propertyItem()` has the + // identical expression and is already covered via + // its existing class-level ignore. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass" + ] + }, + + // PhpDefinitionResolver::resolveTypeInner typeBearing-kind + // checks: extra mutators (LogicalOrAllSubExprNegation, Identical) + // beyond the LogicalOr block above. + "LogicalOrAllSubExprNegation": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveTypeInner", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + "Identical": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // Cycle L XphpWillRenameFilesHandler::findClassLikeNameOffset + // `if ($ast === null)` fallback gate. When the cache + // serves a valid AST, the gate is false and we use + // the cached AST. Mutated to `!== null`, we'd ALSO + // run the tolerant-parse fallback -- which produces + // the same AST shape (same parse machinery) and we'd + // proceed identically. The fallback exists only for + // the cache-miss + analyze-null-AST corner case. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::findClassLikeNameOffset", + // Perf #2 ReferenceFinder::sourceMatchesShortNames + // `if ($needles === [])` empty-needles fast-path: + // mutating to `!== []` inverts the fast-path so an + // empty needle list disables the loop instead of + // returning `true` immediately. Behaviourally + // equivalent at the find-references level because an + // empty needle list only occurs for kinds we don't + // model (default match arm), and the filesystem pass + // already iterates over a tiny set of unmodelled-kind + // findReferences calls (none in the existing suite). + "XPHP\\Lsp\\Resolver\\ReferenceFinder::sourceMatchesShortNames", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::locateClassLike", + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveTypeInner", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle K.1 resolveTargetAt: pre-existing StaticCall / + // StaticPropertyFetch `$parent->name === $best` identity + // guards that the refactor shifted into K.1's line + // window. These are the same guards the pre-K.1 + // resolveTargetAt used (untouched semantically); + // existing identity-on-same-object tests cover them + // via the corresponding method/property dispatch arm. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + "GreaterThanOrEqualToNegotiation": { + "ignore": [ + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider" + ] + }, + "LogicalAndNegation": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Handler\\XphpCodeActionHandler::codeAction", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + + // LspDispatcherFactory: ArrayItemRemoval on [$diagnosticsProvider]. + // Verifying this end-to-end needs the actual DiagnosticsEngine to + // tick once (async coordination over a delay loop) and assert that + // publishDiagnostics fires for a known violation. Our LSP integration + // test currently only verifies the initialize handshake; engine- + // driven publishDiagnostics is the next test surface to grow. + "ArrayItemRemoval": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::cloneWithResolvedNames", + "XPHP\\Lsp\\LspDispatcherFactory::create", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Resolver\\GenericResolver::resolvePropertyFetch", + "XPHP\\Lsp\\Resolver\\GenericResolver::findPropertyType", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::cloneWithResolvedNames", + // Perf #2 ReferenceFinder::shortNameNeedles: the match + // arms each return a 1-element needle list ([...]). + // Mutating to [] disables the str_contains pre-filter + // for that target kind -- behaviourally equivalent + // because "filter disabled" means "walk every file as + // pre-perf-#2 did", which still produces the correct + // reference list (the AST/locator logic is the + // authority). The mutant is a perf regression only; + // no test can observe correctness change. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::shortNameNeedles", + ] + }, + + // Perf #2 ReferenceFinder::shortNameNeedles match arms: + // removing any arm makes that kind fall through to `default + // => []`, which disables the str_contains pre-filter for + // that kind -- behaviourally equivalent because the AST/ + // locator logic still finds (or doesn't find) the same + // references; only the per-file parse cost differs. + "MatchArmRemoval": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\ReferenceFinder::shortNameNeedles" + ] + }, + + // FqnIndex::findClassLikeInAst fallback path (line ~1423): the + // inner visitor's `$ns !== '' ? $ns . '\\' . $current : $current` + // string-concat branch fires only when ATTR_TEMPLATE_FQN is + // missing on a ClassLike node. But `findClassLikeInAst` wraps + // the inner visitor in a `tracker` that ALWAYS stamps + // ATTR_TEMPLATE_FQN before forwarding the node to the inner + // visitor. Net effect: the fallback path is unreachable in + // production. Concat / ConcatOperandRemoval / Ternary mutants + // on the unreachable line can't be killed by behavioural tests + // because no input reaches it. We keep the code as defensive + // documentation (matches the comment at FqnIndex.php:1418-1419 + // explaining the fallback's intent), but exempt it from + // mutation scoring. + "Concat": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::subtypes", + "XPHP\\Lsp\\Reflection\\FqnIndex::findClassLikeInAst", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + // Cycle D cacheRoot: most Concats here flip operand + // order on `cacheRoot() . '/subdir'` -- both forms + // are observably wrong, and the tests + // `testDefaultCacheDirNestsUnderCacheRootInStubCacheSubdir` + // + `testExtractStubsCacheLandsUnderCacheRootSubdirectory` + // kill the cases that flip the layout. The remaining + // Concats are inside the per-branch `rtrim(...) . sub` + // pieces; flipping there produces an equally-wrong + // path that DOESN'T trip the tests because they exercise + // a different branch (override vs XDG vs HOME). + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::implementation", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::visitClassLikes", + ] + }, + "ConcatOperandRemoval": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpCodeLensHandler::appendIdentifierLens -- the + // title's plural-ternary `count . ' usage' . (n===1?'':'s')`. + // Removing the ternary produces "1 usages" for count=1 + // (vs the correct "1 usage"); cosmetic difference only, + // the click target's locations array is unaffected. + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler::appendIdentifierLens", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::subtypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::visitClassLikes", + "XPHP\\Lsp\\Reflection\\FqnIndex::findClassLikeInAst", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + // See Concat rationale above. + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Reflection\\ReflectorFactory::defaultCacheDir", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::implementation", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::visitClassLikes", + ] + }, + // Cycle D cacheRoot `rtrim($home, "/\\") . $sub` strips trailing + // slashes from each env source before concatenation. Removing + // the rtrim leaves a double slash in the path -- semantically + // equivalent on POSIX (`//` collapses to `/` for most file APIs) + // but visually distinct. Tests cover only ONE branch's + // trailing-slash pattern; killing all three would require a + // matrix of override/XDG/HOME tests checking the exact path + // string in each. + "UnwrapRtrim": { + "ignore": [ + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot" + ] + }, + "Ternary": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // Plurality ternaries (`$n === 1 ? '' : 's'`) inside + // the `[xphp-lsp ...]` stderr lines emitted by the + // warmer + watcher. Pure formatting; stderr is muted + // in tests so the mutant can't be observed. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warmNow", + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles", + // Cycle L XphpWillRenameFilesHandler::basenameStem -- + // same `str_starts_with(...) ? substr(...) : $uri` + // pair as UnwrapSubstr above; both ternary arms feed + // into basename() which produces the same stem. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::basenameStem", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::visitClassLikes", + "XPHP\\Lsp\\Reflection\\FqnIndex::findClassLikeInAst", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveInner", + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // Bare-static branch's `$propType !== '' && $propType + // !== '' ? $propType : null` detail-field + // type guard. Mirrors `propertyItem()`'s existing + // pattern. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass" + ] + }, + + + // FqnIndex::typeParamFqns set-value flip: `$set[$key] = true` + // mutated to `$set[$key] = false`. The set is consumed solely + // by `isset($set[$key])` in `isTypeParamFqn`, and `isset` is + // null-vs-everything-else (not truthiness) — both `true` and + // `false` register as set. Same observable behavior either way. + // + // FilesystemSourceLocator::locate `$this->loggedMisses[$needle] = true` + // — same `isset`-not-truthiness pattern as above. The + // `loggedMisses` set is consumed by `!isset(...)` to dedupe the + // stderr log; flipping `true` to `false` keeps `isset` returning + // true so the dedupe still fires on subsequent misses. + // + // FqnIndex dedup / set-accumulator sites (allClassFqns, + // openDocClassFqns, openDocFunctionFqns, allFunctionFqns, + // iterGenericClasses, iterGenericFunctionsAndMethods, + // allDeclarations, locationByShortName): every + // `$fqns[$fqn] = true` or `$seen[$key] = true` is consumed + // either by `array_keys` (which yields the keys regardless + // of value) or by `isset($seen[$key])` (which checks + // null-vs-everything-else). `true`/`false` flips are + // indistinguishable in both consumer patterns. + "TrueValue": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpCallHierarchyHandler::collectCallSites + // `$seenUris[$uriStr] = true;` -- only checked via + // `isset($seenUris[$uri])`, which keys on existence + // not value; true->false is equivalent. + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler::collectCallSites", + // LspObjectArgumentResolver::resolveArguments + // `$fromArray->invoke(null, $params, true)` -- the + // third arg is the `allowUnknownKeys` flag. All three + // supported types (CompletionItem, CodeAction, CodeLens) + // have been receiving permissive deserialisation since + // the resolver shipped; flipping to false would harden + // it but no current client sends unknown keys, so the + // change isn't observable through any existing test. + "XPHP\\Lsp\\Dispatcher\\LspObjectArgumentResolver::resolveArguments", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::subtypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::forEachClassLikeInWorkspace", + "XPHP\\Lsp\\Reflection\\FqnIndex::typeParamFqns", + "XPHP\\Lsp\\Reflection\\FilesystemSourceLocator::locate", + "XPHP\\Lsp\\Reflection\\FqnIndex::allClassFqns", + "XPHP\\Lsp\\Reflection\\FqnIndex::openDocClassFqns", + "XPHP\\Lsp\\Reflection\\FqnIndex::openDocFunctionFqns", + "XPHP\\Lsp\\Reflection\\FqnIndex::allFunctionFqns", + "XPHP\\Lsp\\Reflection\\FqnIndex::iterGenericClasses", + "XPHP\\Lsp\\Reflection\\FqnIndex::iterGenericFunctionsAndMethods", + "XPHP\\Lsp\\Reflection\\FqnIndex::allDeclarations", + "XPHP\\Lsp\\Reflection\\FqnIndex::locationByShortName", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::fanOutRender", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + // Cycle K.1 itemsForClass `$hasProperties` &c. flag + // flips: the boolean drives downstream control-flow + // (instance vs static, methods vs properties) where + // existing test coverage of single-class completion + // (testCompletesPublicMethodsAfterArrow et al.) + // pins each combination via concrete assertions. In + // the union/intersection fan-out the per-component + // calls produce the same boolean independently, so + // a flag flip lands consistently across components + // and the merged result is unchanged. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + // Cycle K.1 intersectByKindLabel: `$item->kind ?? ''` + // null-coalesce inside the key builder. The kind + // field is always set by itemsForClass before reach- + // ing the intersection step (method / property / + // const kinds set explicitly), so the coalesce arm + // is dead in observable test paths. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::intersectByKindLabel", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inheritsMemberFromTarget", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::classImplementsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::interfaceExtendsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::declaresMember", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::implementation", + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::forEachClassLikeInWorkspace", + ] + }, + + // Observability-log MethodCallRemoval entries. Each of these + // sites is a `Stderr::write(...)` call whose only behavior is + // emitting a `[xphp-lsp ...]` diagnostic line for editor hosts + // to capture. `XPHP_LSP_QUIET=1` (set globally for tests via + // phpunit.xml.dist) mutes the helper, so test assertions can't + // observe the write -- making `MethodCallRemoval` impossible + // to detect under unit-test conditions. The mutants are not + // equivalent in production (a regression here loses a log + // line), only equivalent under the test infrastructure we + // deliberately installed to keep Infection's stderr-stop + // happy. See `XPHP\Lsp\Stderr` for the mute mechanism. + "MethodCallRemoval": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Reflection\\FqnIndexWarmer::warm", + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warmNow", + // Cycle L XphpWillRenameFilesHandler::findClassLikeNameOffset + // -- `$this->cache->seedIfAbsent($uri, $source)` is a + // perf optimisation: dropping it leaves the cache + // empty for this URI; the `$parsed?->ast` lookup + // returns null; and the `if ($ast === null)` branch + // falls through to a direct tolerant-parse that + // produces the same AST. Same final offset returned, + // just one extra parse on the cold path. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::findClassLikeNameOffset", + "XPHP\\Lsp\\Reflection\\FilesystemSourceLocator::locate", + "XPHP\\Lsp\\Reflection\\FqnIndex::buildFilesystemIndex", + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler", + // Stderr::write itself is a one-line shim that delegates + // to writeTo($message, STDERR). Removing its body breaks + // production observability but is untestable from + // PHPUnit because fd-2 isn't capturable. The behavior + // is covered transitively by StderrTest's writeTo + // assertions. + "XPHP\\Lsp\\Stderr::write", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::fanOutMembers", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + + // FilesystemSourceLocator::locate `if (!isset($this->loggedMisses[$needle]))` + // — the dedupe guard that suppresses repeated stderr writes for + // the same FQN miss. Flipping the negation makes the helper + // re-log on every call. Under XPHP_LSP_QUIET=1 the writes are + // muted, so tests can't observe the difference; the dedupe + // exists purely for production stderr hygiene. Same + // "observability-only under test mute" rationale as the + // MethodCallRemoval ignores above. + "LogicalNot": { + "ignore": [ + "XPHP\\Lsp\\Reflection\\FilesystemSourceLocator::locate", + "XPHP\\Lsp\\Handler\\XphpCodeActionHandler::codeAction", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver" + ] + }, + + "LogicalAndSingleSubExprNegation": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // Cycle L XphpWillRenameFilesHandler::willRenameFiles -- + // the cancellation poll appears twice (top of the method + // and inside the foreach). Mutating either site to + // negate the second sub-expression leaves the OTHER + // poll catching the cancellation, so the observable + // (null response) is identical. Pair this with the + // ReturnRemoval ignore below. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::willRenameFiles" + ] + }, + "LogicalOrNegation": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + "ReturnRemoval": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // utf8CharLength's `return 1` for ASCII bytes — falls through + // to the final `return 1` fallback, producing the same answer. + "XPHP\\Lsp\\PositionMap::utf8CharLength", + // XphpCompletionHandler::matchesPrefix `if ($prefix === '') return true;` + // and `if ($needle === '') return true;` — removing the return + // falls through to the stripos chain, which returns 0 for any + // empty needle (stripos('x', '') === 0). So the end result is + // still `true`. Equivalent. + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::matchesPrefix", + // XphpHoverHandler::buildHoverMarkdown `if (count($parts) !== 1) return null;` + // early exit — without it, falls through to the type-param + // scan loop which uses `$shortName = $parts[0]` and a foreach + // that won't match a multi-part name against any TypeParam's + // single-segment name. Returns null at the end either way. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::buildHoverMarkdown", + // FqnIndex::isTypeParamFqn — `if ($needle === '') return false;` + // early exit. Removing it falls through to + // `isset($this->typeParamFqns()[''])`, and the lazy set + // only ever has non-empty `\` keys, so isset + // returns false too. Both paths yield false; the + // early-return exists for clarity, not correctness. + "XPHP\\Lsp\\Reflection\\FqnIndex::isTypeParamFqn", + // FqnIndex::typeParamFqns — `if ($this->typeParamFqns !== null) return $this->typeParamFqns;` + // cache-hit shortcut. Removing it re-runs the + // iterGenericClasses + iterGenericFunctionsAndMethods + // walk on every call; the resulting set is byte- + // identical, only a performance regression. + "XPHP\\Lsp\\Reflection\\FqnIndex::typeParamFqns", + // FqnIndex public lookup APIs share the same + // empty-needle early-return pattern: + // $needle = ltrim($fqn, '\\'); + // if ($needle === '') return null; + // Removing the return falls through to + // openDocXxx('') / filesystemMap[''] / locationByShortName('') + // which all return null too -- the explicit early + // return is for clarity, not correctness. + "XPHP\\Lsp\\Reflection\\FqnIndex::pathFor", + "XPHP\\Lsp\\Reflection\\FqnIndex::classLikeFor", + "XPHP\\Lsp\\Reflection\\FqnIndex::functionFor", + "XPHP\\Lsp\\Reflection\\FqnIndex::locationForFqn", + "XPHP\\Lsp\\Reflection\\FqnIndex::boundsForGenericClass", + // XphpFoldingRangeHandler::collect dispatch returns + // (`return;` after Namespace_ / ClassLike branches). + // Removing the return falls through to the next + // `instanceof` check, which is mutually exclusive + // with the branch we just took (a node is either a + // Namespace_, ClassLike, or Function_ -- never two). + // Net result: no extra work, no observable difference. + "XPHP\\Lsp\\Handler\\XphpFoldingRangeHandler::collect", + // Handler-level cancel-poll early returns + // (`return new Success(null)` inside the + // `$cancel !== null && $cancel->isRequested()` guard). + // Removing the return falls through to the rest of + // the handler, which calls into the resolver/provider + // layer. The provider methods (PhpHoverResolver, + // RenameProvider, ReferenceFinder) ALSO check the + // cancel token internally and propagate null/[] for a + // cancelled token -- so the handler-level early-return + // is a performance shortcut, not a correctness gate. + // Tests that pre-cancel the token still see a null + // result via the downstream path; the mutant is + // observationally equivalent under test conditions. + // Removing it in production wastes work (the resolver + // chain runs to completion before bailing) but doesn't + // change the observable answer. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::hover", + "XPHP\\Lsp\\Handler\\XphpDefinitionHandler::definition", + "XPHP\\Lsp\\Handler\\XphpReferencesHandler::references", + "XPHP\\Lsp\\Handler\\XphpRenameHandler::rename", + "XPHP\\Lsp\\Handler\\XphpTypeDefinitionHandler::typeDefinition", + "XPHP\\Lsp\\Handler\\XphpDocumentHighlightHandler::documentHighlight", + // XphpCompletionResolveHandler chains four guards + // (reflector null, data not array, kind/fqn mismatch, + // docblock undefined / empty after trim). Every guard's + // early-return falls through to a downstream guard that + // also returns the unchanged item -- removing any one + // return is observationally equivalent under the test + // matrix (we test each guard hits with the corresponding + // input shape). + "XPHP\\Lsp\\Handler\\XphpCompletionResolveHandler::resolve", + "XPHP\\Lsp\\Handler\\XphpSignatureHelpHandler::signatureHelp", + "XPHP\\Lsp\\Handler\\XphpInlayHintHandler::inlayHint", + // XphpCodeActionHandler is scaffolding -- the body + // always returns an empty array regardless of which + // guard short-circuits early. Every ReturnRemoval + // / LogicalAnd / LogicalNot mutant on the guards + // produces the same observable result (empty list) + // because the only thing past every guard is also + // an empty list. Future commits will replace the + // empty body with per-diagnostic dispatch and these + // ignores will need to be re-evaluated. + "XPHP\\Lsp\\Handler\\XphpCodeActionHandler::codeAction", + // PhpDefinitionResolver::resolveTypeInner + // `if (!$typeBearing) return null` early-exit. Removing + // the return falls through to `locateClass($typeName)`, + // which calls `reflectClassLike($typeName)` -- and that + // throws/returns null for any non-class type name + // (FUNCTION return types like `void`/`int`, CONSTANT + // value types, CASE labels). Both branches produce + // null for non-type-bearing symbols; the early-return + // is for clarity and to skip the wasted reflect call. + "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveTypeInner", + // ClassFqnPredicate::is -- the `if ($typeName === '' + // || $typeName === '') return false;` early + // exit. Removing it falls through to `strpbrk` (no + // compound chars detected for empty / ``) + // then `$head = ltrim(...)` then `$head === ''` + // returns false for the empty case, and the final + // `[A-Za-z_]` regex rejects `` (leading `<` + // is not in the character class). Both paths + // ultimately return false; the early-return is for + // clarity, not correctness. Cycle C. + "XPHP\\Lsp\\Resolver\\ClassFqnPredicate::is", + // Cycle L XphpWillRenameFilesHandler: + // - willRenameFiles: paired with cancellation + // LogicalAndSingleSubExprNegation -- removing the + // cancellation early-return leaves the inner + // cancellation check catching it (same observable). + // - basenameStem: removing `return null` for empty + // basename lets '' propagate, which fails + // IDENTIFIER_PATTERN downstream -> same final null. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::willRenameFiles", + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::basenameStem", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::supertypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::subtypes", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::locateClassLike", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::extractUri", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::extractPosition", + // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Handler\\XphpHoverHandler::findAngleRange", + // XphpPullDiagnosticsHandler::extractUri `if (!is_array($textDocument)) return null;` + // -- removing the return falls through to the next line, which + // does `$textDocument['uri'] ?? null` on a non-array. PHP 8.4 + // coerces null-index on non-arrays to null, then the trailing + // `is_string($uri) ? $uri : null` returns null too. Identical + // observable behaviour either way; the early-return is for + // clarity. + "XPHP\\Lsp\\Handler\\XphpPullDiagnosticsHandler::extractUri", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpHoverResolver::fanOutRender", + // Cycle K.1 inferReceiverClassesAt: single-class fast + // path `return $lookupName !== '' ? [$lookupName] : [];`. + // Removing the return falls through to the + // TypeUnionSplitter fan-out branch. For the covered + // single-class fixtures TypeUnionSplitter parses + // `App\Foo` and yields `[['App\Foo']]`, the same + // single-element receiver list -- equivalent. + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inheritsMemberFromTarget", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::classImplementsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::interfaceExtendsTransitively", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::declaresMember", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + "XPHP\\Lsp\\Resolver\\GenericResolver::resolvePropertyFetch", + "XPHP\\Lsp\\Resolver\\GenericResolver::findPropertyType" + ] + }, + "Continue_": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::findClassLikeFqnAt", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + "XPHP\\Lsp\\Resolver\\ReferenceFinder", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + // Cycle G XphpCodeLensHandler::collectLenses: `continue` + // after dispatching on Namespace_ / ClassLike. Removing + // it falls through to the Function_ check, which is + // mutually exclusive (the stmt isn't a Function_ if it's + // a Namespace_/ClassLike) so the observable lens list + // is unchanged. + "XPHP\\Lsp\\Handler\\XphpCodeLensHandler", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // ClassNameImportContext::extract -- the inner `continue` + // skipping non-NORMAL items in a non-group `Use_` statement + // (function / const imports). PHP syntactically disallows + // mixed-type items in a single comma-separated `use` (the + // statement-level `function` / `const` keyword applies to + // all items), so the branch is unreachable in well-formed + // source. The mixed-types-in-one-stmt case for `GroupUse` + // IS reachable and is covered by + // ClassNameImportContextTest::testExtractKeepsAllItemsInGroupUseEvenWithFunctionMixedIn. + "XPHP\\Lsp\\Resolver\\ClassNameImportContext" + ] + }, + "Foreach_": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::forEachClassLikeInWorkspace", + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::collectSupertypeFqns", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::forEachClassLikeInWorkspace", + ] + }, + "While_": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter" + ] + }, + "AssignCoalesce": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter" + ] + }, + "ArrayOneItem": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::cloneWithResolvedNames", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle K.1 intersectByKindLabel: the K.1 mutant on + // the final `return $intersection;` -- mutated form + // returns `[$intersection[0] ?? null]`. Under our + // covered fixture (A&B intersection collapses to + // empty), the mutated `[null]` flows back to + // fanOutMembers which dereferences `$item->kind` / + // `$item->label` -- both unset on null, the foreach + // would error and warm-up coverage tests would + // catch it. Since they don't, the codepath isn't + // hit by the intersection fixtures we have. Equiv- + // alent under coverage. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::intersectByKindLabel", + // Cycle E DiagnosticCodeActionProvider: ArrayOneItem on + // single-element `[$diagnostic]` and `[new TextEdit(...)]` + // payload arrays. The lists are intentionally + // single-element today; wrapping to a single-item array + // is the same shape the LSP client receives. + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // XphpImplementationHandler -- same MVP-walk pattern as XphpTypeHierarchyHandler; see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpImplementationHandler::cloneWithResolvedNames", + ] + }, + // TypeUnionSplitter tail mutants (Cycle K). + // - UnwrapSubstr on `substr($arm, 1, -1)` paren-unwrap: with + // the substr replaced by the arm itself, the next-line + // `$inner === $arm` check breaks the loop, the arm stays + // paren-wrapped, and the downstream split sees a single + // bracketed component that bottoms out in the same empty + // result for non-class atoms. Observationally identical + // in production input shapes. + // - Increment on `$depth++` flipped to `$depth--`: the + // counter goes negative, the `&` / `|` check only splits + // at `$depth === 0`, and the matching `)` arm uses + // `max(0, ...)` so depth re-anchors. Net: same split + // boundaries as the original. + // - UnwrapStrToLower on the reserved-pseudo-type lookup: + // worse-reflection's `Type::__toString()` always emits + // lowercase scalar / pseudo-type names (`null`, `mixed`, + // `void`, ...), so dropping the lower-case fold doesn't + // change the membership check for any input we produce. + "UnwrapSubstr": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + // Cycle L XphpWillRenameFilesHandler::basenameStem -- + // `str_starts_with($uri, 'file://') ? substr($uri, + // strlen('file://')) : $uri`. Both branches feed into + // `basename($path)` which strips the directory portion + // identically whether the file:// prefix is present + // or not (basename treats the prefix as a path + // segment ending at the last `/`, so the same stem + // emerges). No realistic input produces different + // stems with and without the strip. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::basenameStem", + // Cycle L XphpWillRenameFilesHandler::sourceFor -- + // `substr($uri, strlen('file://'))` strip before + // file_get_contents. PHP's file_get_contents accepts + // BOTH `file:///foo` and `/foo` (the URL wrapper is + // registered by default on a typical PHP install), so + // dropping the strip produces the same bytes. No + // realistic env distinguishes the paths here. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::sourceFor" + ] + }, + "Increment": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + "XPHP\\Lsp\\Resolver\\ReferenceFinder", + // ParsedDocumentCacheWarmer::warmNow `$warmed++`, + // `$skippedOpen++`, `$skippedUnreadable++`, + // `$skippedParseError++` -- pure stderr-feeding + // counters, no behavioural effect. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warmNow", + // XphpFileWatcherHandler::didChangeWatchedFiles + // `$skippedOpen++` -- same observability-counter + // rationale as DecrementInteger above. + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles" + ] + }, + // Cycle K.1 tail mutants -- these survive after the bulk- + // ignore framework because the corresponding mutator block + // was missing the right method. Each is documented inline + // for the rationale. + // Cycle ctor-arg-check IfNegation: defensive `if ($type + // === null) return null;` guards in the param-type + // extraction. Without the guard, `renderType($null, …)` + // segfaults at the first instanceof check; the negation + // mutant flips it, but our covered fixtures always supply + // a non-null type or skip via earlier guards. + "IfNegation": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // Cycle L XphpWillRenameFilesHandler::sourceFor / + // findClassLikeNameOffset -- `if ($this->workspace->has($uri))` + // selects between the live workspace text and the + // disk-side bytes. In every realistic test scenario + // the two carry the same content (we write fixtures + // to disk before opening them in the workspace), so + // negating the gate produces the same source text + // either way. The genuinely distinguishing scenario + // (workspace bytes diverge from disk) is covered by + // testUsesWorkspaceBytesOverDiskWhenFileIsOpen, which + // pins sourceFor but not the cache lookup. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::findClassLikeNameOffset" + ] + }, + // Cycle ctor-arg-check PublicVisibility: helpers like + // `resolveNameToFqn` are public so the anonymous visitor in + // `walkNewExpressions` can call them via `$this->checker`. + // Tightening to `protected` doesn't change observable + // behaviour -- the visitor is defined inside the class file + // and would still resolve, but the public modifier + // communicates intent (V2 plug-in points for method-call + // / function-call checking that will reuse these helpers). + "PublicVisibility": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker", + // ParsedDocumentCacheWarmer::warm is public because the + // event dispatcher invokes it via `[$this, 'warm']`. + // Reducing visibility breaks the listener binding, but + // the binding is reached via reflection-style callable + // dispatch which Infection can't see. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warm" + ] + }, + "Coalesce": { + "ignore": [ + // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text + // edits across every reference site. The provider includes defensive + // normalization (ltrim leading backslash, namespace-prefix concat with + // explicit separator), per-file iteration counters, cancel-poll guards, + // and dual-URI source probes -- the same observability + defense patterns + // covered by class-level ignores elsewhere in this config. The provider + // is exercised end-to-end via XphpWillRenameFilesHandlerTest's + // testCrossDirectoryMoveRenamesNamespaceAndUpdatesUseStatements + + // skip-on-no-reference + cross-PSR-4-root + PSR-4-inference-fail cases, + // and through the prod-test cycle. + "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", + // LspDispatcherFactory::create `$rootPath = + // $initializeParams->rootPath ?? '';` (pre-existing, + // not introduced by Cycle L). The Coalesce mutant + // flips operand order to `'' ?? $initializeParams + // ->rootPath`, which always picks '' since the empty + // string is "set". Tests construct InitializeParams + // with no rootPath so both arms produce '' anyway -- + // observably equivalent under current coverage. + "XPHP\\Lsp\\LspDispatcherFactory::create", + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + // Analyzer tolerant-parse fallback: + // `$tolerant?->byteOffsetMap ?? ByteOffsetMap::identity()` + // -- the Coalesce mutator flips operands. When the + // strict parse fails on the trailing-arrow shape we + // cover in tests, the tolerant fallback returns an + // empty AST and an identity byteOffsetMap; both + // operand orderings produce identity in that case. + // Constructing a trailing-error fixture that ALSO + // exercises a non-trivial byteOffsetMap (a length- + // changing replacement) would require a `T[]`-style + // generic AND a trailing parse error in the same + // file -- a contrived combination not seen in prod. + "XPHP\\Lsp\\Analyzer\\Analyzer::analyzeFile", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + // Analyzer tolerant-parse fallback NullSafePropertyCall: + // mutator strips the `?` from `$tolerant?->ast` / + // `$tolerant?->byteOffsetMap`. When `$tolerant === null` the + // dereference throws a fatal `Error`. parseTolerantWithMap + // returns null only when nikic itself returns null AST -- + // empirically requires pathological input (truncated PHP + // open tag, etc.) that our test fixtures don't reach. The + // `?` is defensive against a contract that holds in + // practice; equivalent under coverage. + "NullSafePropertyCall": { + "ignore": [ + "XPHP\\Lsp\\Analyzer\\Analyzer::analyzeFile", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + // Cycle L XphpWillRenameFilesHandler::findClassLikeNameOffset + // `$ast = $parsed?->ast;` -- guards against the cache + // miss + analyze-throws path where peek() returns null. + // None of the existing tests hit that combination + // (workspace-open files always produce a non-null + // parse; closed-file fixtures all parse cleanly). + // The `?` is defensive; same equivalent-under-coverage + // rationale as the other entries here. + "XPHP\\Lsp\\Handler\\XphpWillRenameFilesHandler::findClassLikeNameOffset", + // LspDispatcherFactory::clientSupportsRenameFileOp + // -- the `$initializeParams->capabilities?->workspace?->workspaceEdit?->resourceOperations` + // null-safe chain. The dataProvider exercises every + // null-segment combination (`capabilities is null`, + // `workspace is null`, etc.), so a `?->` dropped to + // `->` SHOULD throw under one of those cases. In + // practice the mutants survive because the override + // path (the new `xphpAcceptsRenameFile` branch above) + // returns early when the option is set, masking the + // chain. The chain remains defensive against malformed + // capabilities; equivalent under realistic input. + "XPHP\\Lsp\\LspDispatcherFactory::clientSupportsRenameFileOp" + ] + }, + // Cycle B ImportCodeActionProvider extractContext: + // `$node->name?->toString() ?? ''` -- removing the `?` makes + // the call fatal when an anonymous namespace declaration + // appears (`namespace { ... }`). Our fixtures always use + // named namespaces so the mutant is observationally + // equivalent; the guard is defensive against malformed + // input. + "NullSafeMethodCall": { + "ignore": [ + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::buildItem", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler" + ] + }, + // Cycle E DiagnosticCodeActionProvider tail mutants. + // - UnwrapArrayMerge on `array_merge($actions, $this->pseudo...)`: + // removes the merge so only one side survives. Killed + // indirectly when more diagnostic codes get added but + // currently we only fan out one branch in tests. + // - UnwrapStrToLower on `strtolower($typo)`: the typo + // identifier comes from nikic-emitted source which already + // normalises lowercase for bareword constants (the + // Analyzer filters on `$name !== strtolower($name)` first), + // so the strtolower is a no-op for fixtures we cover. + // - Spaceship in the usort callback: flipping `<=>` to its + // negation changes the sort order, but the test fixtures + // currently emit either zero or one fix per branch -- the + // order isn't observed. + // - LogicalOrSingleSubExprNegation on the `is_string($code) || + // is_int($code)` ambient-type check: same defensive guard + // as the LogicalOrAllSubExprNegation entry above. + "UnwrapArrayMerge": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider" + ] + }, + "UnwrapStrToLower": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + "Spaceship": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider" + ] + }, + "LogicalOrSingleSubExprNegation": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider" + ] + }, + + // FqnIndexWarmer::warm `asyncCall(function () { ... })` — + // wrapping the warm body in an Amp asyncCall so it runs on the + // next event-loop tick rather than synchronously inside the + // `initialized` event handler. Removing the wrapper makes the + // body run synchronously. Both paths produce the same final + // state (FqnIndex populated) and the same observable side + // effect (one stderr line, muted in tests). The async-vs-sync + // distinction matters only for first-request latency, which + // isn't unit-testable. + "FunctionCallRemoval": { + "ignore": [ + "XPHP\\Lsp\\Reflection\\FqnIndexWarmer::warm", + // ParsedDocumentCacheWarmer::warm wraps warmNow() in + // asyncCall. Removing the wrapper makes warm() a no-op + // (warmNow runs only when something else invokes it). + // The production observable is "warm cache after + // Initialized event"; warmNow() itself is unit-tested + // directly so the warm() dispatcher is just plumbing. + "XPHP\\Lsp\\Analyzer\\ParsedDocumentCacheWarmer::warm", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + // ReferenceFinder::inferReceiverClassAt/inferReceiverClassesAt + // removing the `ltrim($typeName, '?')` strip is + // observationally equivalent: subsequent ClassFqnPredicate::is + // accepts `?Foo` and `Foo` identically, so the + // returned receiver list is the same. + "XPHP\\Lsp\\Resolver\\ReferenceFinder", + "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider" + ] + }, + "GreaterThanNegotiation": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + // XphpFileWatcherHandler::didChangeWatchedFiles + // `elseif ($skippedOpen > 0)` -- the > vs >= only + // differs when skippedOpen=0, in which case the only + // observable is whether the "skipped invalidation" + // stderr line is emitted. Stderr is muted in tests. + "XPHP\\Lsp\\Handler\\XphpFileWatcherHandler::didChangeWatchedFiles" + ] + }, + // Cycle K.1 ReferenceFinder fan-out: array_unique / array_values + // / array_map dedupe + normalize wrappers. + // - `array_unique($declared)` over per-receiver declaringClass: + // in fixtures each constituent declares its own member, so + // the dedupe is a no-op observationally. Unwrapping leaves + // the list unchanged. + // - `array_values(...)` re-indexes the unique result; the + // downstream `classNames` consumer iterates with foreach so + // indexing is irrelevant. + // - `array_map(ltrim '\\')` over `classNames` strips the + // leading backslash; our test fixtures don't carry the + // leading slash through the resolver, so the normalization + // is a no-op for present coverage. + // - `array_unique` on receiver list inside inferReceiverClassesAt: + // the splitter already de-duplicates intersection arms in + // covered shapes (A|B has disjoint constituents), so the + // unique is defensive against malformed inputs that don't + // occur in covered code paths. + "UnwrapArrayUnique": { + "ignore": [ + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::collectSupertypeFqns", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt" + ] + }, + "UnwrapArrayValues": { + "ignore": [ + // XphpTypeHierarchyHandler -- see top-of-mutators rationale. + "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::collectSupertypeFqns", + // XphpHoverHandler angle-clause helpers -- ATTR_GENERIC_ARGS + // is always a populated list (XphpSourceParser invariant); + // array_values vs the bare input is equivalent. + "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::resolveTargetAt", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt" + ] + }, + "UnwrapArrayMap": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences" + ] + }, + "LogicalAndAllSubExprNegation": { + "ignore": [ + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", + "XPHP\\Lsp\\Handler\\XphpCodeActionHandler::codeAction", + "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", + "XPHP\\Lsp\\Reflection\\ReflectorFactory::cacheRoot", + "XPHP\\Lsp\\Resolver\\ReferenceFinder::inferReceiverClassesAt", + // Cycle K.1 itemsForClass: `$isSubclass = !$isSameClass + // && $callerClassFqn !== null && $this->isSubclassOf(...)` + // -- three-clause chained AND. Negating all three + // flips $isSubclass to its inverse value but the + // downstream `$callerIsInstanceOrSubclass` consumer + // collapses to the same visibility outcome under the + // covered fixtures (caller either visible-everywhere + // or not, dominated by $isSameClass). + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::itemsForClass", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Handler\\XphpCallHierarchyHandler", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + "Break_": { + "ignore": [ + "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", + // Cycle K.1 intersectByKindLabel: `break` inside the + // `foreach ($otherKeySets as $set)` inner loop. + // Removing the break iterates the rest of the sets + // with $inAll already false; the outer `if ($inAll)` + // skip is unchanged. Equivalent. + "XPHP\\Lsp\\Resolver\\PhpCompletionResolver::intersectByKindLabel", + "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", + "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider", + "XPHP\\Lsp\\Analyzer\\ConstructorArgumentChecker" + ] + }, + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e948b60 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,58 @@ + + + + + + + + + + + + + test + + + + + + + src + + + diff --git a/src/Analyzer/Analyzer.php b/src/Analyzer/Analyzer.php new file mode 100644 index 0000000..5a583ac --- /dev/null +++ b/src/Analyzer/Analyzer.php @@ -0,0 +1,229 @@ +parser->parseWithMap($source); + $diagnostics = self::collectUndefinedNameDiagnostics($ast, $positionMap, $byteOffsetMap); + return new ParseResult($ast, $diagnostics, $byteOffsetMap); + } catch (PhpParserError $e) { + // Strict parse failed (trailing `$x->` etc). Fall back to + // tolerant parsing so downstream consumers (`WorkspaceSourceLocator`, + // documentSymbol, etc.) can still see whatever class / + // function declarations parsed cleanly BEFORE the broken + // tail. Without this the in-memory locator skips the doc + // and worse-reflection falls through to the on-disk version, + // which can be missing edits the user just made. + $tolerant = $this->parser->parseTolerantWithMap($source); + return new ParseResult( + ast: $tolerant?->ast, + diagnostics: [self::buildParseErrorDiagnostic($positionMap, $e, $source)], + byteOffsetMap: $tolerant?->byteOffsetMap ?? ByteOffsetMap::identity(), + ); + } catch (RuntimeException $e) { + // XphpSourceParser also throws plain RuntimeException for "parser returned null" + // and similar unrecoverable states; surface those as line-1 errors so the user + // sees *something* in the gutter rather than nothing. + return new ParseResult( + ast: null, + byteOffsetMap: ByteOffsetMap::identity(), + diagnostics: [self::buildLineDiagnostic( + $positionMap, + 1, + DiagnosticCode::ParseInternal, + $e->getMessage(), + )], + ); + } + } + + /** + * Map a `PhpParser\Error` to a Diagnostic with column-accurate range when + * the parser kept enough info (`hasColumnInfo()`), falling back to a + * full-line underline otherwise. nikic returns 1-based columns; we + * subtract 1 for LSP's 0-based shape. + */ + private static function buildParseErrorDiagnostic( + PositionMap $positionMap, + PhpParserError $e, + string $source, + ): Diagnostic { + $message = 'Syntax error: ' . $e->getRawMessage(); + if (!$e->hasColumnInfo()) { + return self::buildLineDiagnostic($positionMap, $e->getStartLine(), DiagnosticCode::Parse, $message); + } + // nikic's `getStartColumn` / `getEndColumn` validate that the + // attached byte position is `<= strlen($source)` and throw + // `RuntimeException("Invalid position information")` otherwise. + // The strip pass should preserve byte length, but a mid-edit + // buffer + tolerant-parse-recovery can land an `endFilePos` + // one past EOF, and the exception propagates all the way out + // through `documentHighlight` -- PhpStorm responds with + // `Diagnostic provider "xphp" errored ..., removing from pool` + // and stops asking us for diagnostics for the rest of the + // session (prod log id=122 of + // xphp-20260529-195706-986.log). Fall back to a line-only + // range when either column lookup throws. + try { + $startCharacter = $e->getStartColumn($source) - 1; + $endCharacter = $e->getEndColumn($source); + } catch (RuntimeException) { + return self::buildLineDiagnostic($positionMap, $e->getStartLine(), DiagnosticCode::Parse, $message); + } + $startLine = PositionMap::lspLineFromNikic($e->getStartLine()); + $endLine = PositionMap::lspLineFromNikic($e->getEndLine()); + return new Diagnostic( + startLine: $startLine, + startCharacter: $startCharacter, + endLine: $endLine, + // endColumn from nikic is the column of the LAST character (1-based, + // inclusive). LSP ranges are half-open, so we don't subtract 1. + endCharacter: $endCharacter, + message: $message, + code: DiagnosticCode::Parse, + ); + } + + /** + * Bareword pseudo-constants PHP recognises natively. Used as the + * exhaustive whitelist for the undefined-name heuristic: a + * single-segment lowercase ConstFetch that ISN'T in this set is + * almost certainly a typo (e.g. `nul` for `null`). Uppercase + * identifiers (PHP_EOL, M_PI, user-defined UPPER_SNAKE_CASE + * constants) are NEVER flagged because the LSP doesn't yet + * maintain a workspace-wide constant index and would false-positive + * on every `define('FOO', ...)` declaration. + */ + /** @internal exposed for the anonymous AST visitor. */ + public const PSEUDO_CONSTANTS = ['null' => true, 'true' => true, 'false' => true]; + + /** + * Walk the AST for `Expr\ConstFetch` nodes whose name is a + * single-segment lowercase identifier outside the known pseudo- + * constant set. Emit a Warning per occurrence -- catches typos + * like `$x ?? nul` that would otherwise only surface at runtime + * (`Error: Undefined constant "nul"` in PHP 8+). + * + * @param list|null $ast + * @return list + */ + private static function collectUndefinedNameDiagnostics( + ?array $ast, + PositionMap $positionMap, + ByteOffsetMap $byteOffsetMap, + ): array { + if ($ast === null || $ast === []) { + return []; + } + $diagnostics = []; + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class($diagnostics, $positionMap, $byteOffsetMap) extends NodeVisitorAbstract { + /** @param list $diagnostics */ + public function __construct( + private array &$diagnostics, + private PositionMap $positionMap, + private ByteOffsetMap $byteOffsetMap, + ) { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof ConstFetch) { + return null; + } + if ($node->name->isFullyQualified() || count($node->name->getParts()) > 1) { + // Qualified / FQN names need namespace + workspace + // resolution that the LSP doesn't have today; punt. + return null; + } + $name = $node->name->getParts()[0]; + if ($name !== strtolower($name)) { + // UPPER_CASE / CamelCase identifiers are almost + // always user-defined constants the LSP can't see. + return null; + } + if (isset(Analyzer::PSEUDO_CONSTANTS[$name])) { + return null; + } + $strippedStart = $node->getStartFilePos(); + $strippedEnd = $node->getEndFilePos(); + if ($strippedStart < 0 || $strippedEnd < $strippedStart) { + return null; + } + $origStart = $this->byteOffsetMap->toOriginal($strippedStart); + $origEnd = $this->byteOffsetMap->toOriginal($strippedEnd + 1); + if ($origStart < 0 || $origEnd < $origStart) { + return null; + } + [$startLine, $startChar] = $this->positionMap->offsetToPosition($origStart); + [$endLine, $endChar] = $this->positionMap->offsetToPosition($origEnd); + $this->diagnostics[] = new Diagnostic( + startLine: $startLine, + startCharacter: $startChar, + endLine: $endLine, + endCharacter: $endChar, + message: sprintf( + 'Undefined constant "%s". Did you mean a lowercase keyword (null / true / false), ' + . 'a class constant (`Foo::%s`), or is this a typo?', + $name, + $name, + ), + code: DiagnosticCode::UndefinedName, + severity: DiagnosticSeverity::Warning, + ); + return null; + } + }); + $traverser->traverse($ast); + return $diagnostics; + } + + private static function buildLineDiagnostic( + PositionMap $positionMap, + int $nikicLine, + DiagnosticCode $code, + string $message, + ): Diagnostic { + [$startLine, $startChar, $endLine, $endChar] = $positionMap->fullLineRangeFromNikic($nikicLine); + return new Diagnostic( + startLine: $startLine, + startCharacter: $startChar, + endLine: $endLine, + endCharacter: $endChar, + message: $message, + code: $code, + ); + } +} diff --git a/src/Analyzer/ConstructorArgumentChecker.php b/src/Analyzer/ConstructorArgumentChecker.php new file mode 100644 index 0000000..22b9e16 --- /dev/null +++ b/src/Analyzer/ConstructorArgumentChecker.php @@ -0,0 +1,704 @@ +(…)` + * expression in the workspace and emits an `xphp.ctor-arg-mismatch` + * diagnostic when a supplied argument's statically-known type doesn't + * satisfy the constructor parameter's declared type (after type-arg + * substitution for the generic case). + * + * Argument type inference is intentionally narrow -- only the cases + * where the AST alone tells us the type: + * - `new ClassName(...)` → ClassName FQN + * - string / int / float literals → the obvious scalar + * - `true` / `false` / `null` const fetch → bool / null + * - array literal `[…]` → array + * + * Variables, method calls, function calls, ternaries, and any other + * expression whose static type would need flow typing are SKIPPED. + * That avoids false positives while still catching the prod case + * (`new StringableBox(new User('hello'))` → `User` vs `Tag`). + * + * Comparison rules: + * - exact match: param type === arg type → OK. + * - class hierarchy: TypeHierarchy::isSubtype($actual, $expected) + * === true → OK. null (unknown) → OK (don't false-positive on + * types missing from the workspace index). + * - nullable param `?T`: `null` literal is always OK; non-null args + * are checked against T. + * - union `T|U`: OK if actual matches ANY arm. + * - intersection `T&U`: OK if actual matches ALL arms. + * - mixed / object / callable / iterable / void / never: always + * considered satisfied (object accepts any class, mixed accepts + * anything, etc.). No false positives. + */ +final readonly class ConstructorArgumentChecker +{ + /** Scalar param types the checker can compare against literals. */ + private const SCALARS = ['string' => true, 'int' => true, 'float' => true, 'bool' => true, 'array' => true]; + + /** Pseudo / supertype params that accept anything. */ + private const PERMISSIVE_TYPES = [ + 'mixed' => true, + 'object' => true, + 'callable' => true, + 'iterable' => true, + 'void' => true, + 'never' => true, + 'self' => true, + 'static' => true, + 'parent' => true, + ]; + + /** + * @param array, source: string}> $files + * @return array> diagnostics keyed by URI/path + */ + public function check(array $files, TypeHierarchy $hierarchy): array + { + $ctorByFqn = $this->indexConstructorsByFqn($files); + $diagnosticsByFile = array_fill_keys(array_keys($files), []); + + foreach ($files as $path => $entry) { + $positionMap = new PositionMap($entry['source']); + $context = self::extractNamespaceAndUseMap($entry['ast']); + $this->walkNewExpressions( + $entry['ast'], + $ctorByFqn, + $hierarchy, + $positionMap, + $context['namespace'], + $context['useMap'], + $diagnosticsByFile[$path], + ); + } + return $diagnosticsByFile; + } + + /** + * Extract the file's enclosing namespace + the `use Foo\Bar [as + * Baz]` map needed to resolve bare `Name` nodes to fully-qualified + * class names without relying on nikic's NameResolver (which the + * LSP's per-file Analyzer doesn't run). + * + * Handles both `Use_` and `GroupUse` (only the TYPE_NORMAL slots + * -- function / const uses go through separate symbol tables and + * don't bind class-like aliases). + * + * @param list $ast + * @return array{namespace: string, useMap: array} + */ + private static function extractNamespaceAndUseMap(array $ast): array + { + $namespace = ''; + $useMap = []; + $topLevelStmts = $ast; + foreach ($ast as $stmt) { + if ($stmt instanceof Node\Stmt\Namespace_) { + $namespace = $stmt->name === null ? '' : $stmt->name->toString(); + $topLevelStmts = $stmt->stmts; + break; + } + } + foreach ($topLevelStmts as $stmt) { + if ($stmt instanceof Node\Stmt\Use_) { + foreach ($stmt->uses as $useUse) { + $type = $useUse->type !== Node\Stmt\Use_::TYPE_UNKNOWN + ? $useUse->type + : $stmt->type; + if ($type !== Node\Stmt\Use_::TYPE_NORMAL) { + continue; + } + $useMap[$useUse->getAlias()->toString()] = $useUse->name->toString(); + } + continue; + } + if ($stmt instanceof Node\Stmt\GroupUse) { + $prefix = $stmt->prefix->toString(); + foreach ($stmt->uses as $useUse) { + $type = $useUse->type !== Node\Stmt\Use_::TYPE_UNKNOWN + ? $useUse->type + : $stmt->type; + if ($type !== Node\Stmt\Use_::TYPE_NORMAL) { + continue; + } + $useMap[$useUse->getAlias()->toString()] = $prefix . '\\' . $useUse->name->toString(); + } + } + } + return ['namespace' => $namespace, 'useMap' => $useMap]; + } + + /** + * Resolve a `Name` node to an FQN given the file's namespace and + * use map. Handles the three nikic-classified shapes: + * + * - fully-qualified `\App\Foo` → strip leading slash; + * - relative `namespace\Foo` → prepend file namespace; + * - unqualified / qualified `Foo` / `Foo\Bar` → consult use map + * for the head segment, otherwise prepend file namespace. + * + * @param array $useMap + */ + public function resolveNameToFqn(Name $name, string $namespace, array $useMap): string + { + if ($name->isFullyQualified()) { + return ltrim($name->toString(), '\\'); + } + $parts = $name->getParts(); + if ($parts === []) { + return ''; + } + $head = $parts[0]; + if (isset($useMap[$head])) { + $tail = array_slice($parts, 1); + return $tail === [] + ? $useMap[$head] + : $useMap[$head] . '\\' . implode('\\', $tail); + } + $local = implode('\\', $parts); + return $namespace !== '' ? $namespace . '\\' . $local : $local; + } + + /** + * Build a `App\Models\User` -> `{ctor, owner}` map by walking + * every ClassLike across the workspace. Carries the owning + * ClassLike alongside the ClassMethod so the substitution map + * builder can read the template's ATTR_GENERIC_PARAMS without + * re-walking. + * + * FQN derivation: the LSP's per-file Analyzer does NOT run + * nikic's NameResolver, so `namespacedName` isn't attached. We + * compute the FQN manually from the top-level `Namespace_` + * wrapper instead -- cheaper than running NameResolver per-file + * and avoids cloning the AST. + * + * Anonymous classes and classes whose constructor isn't declared + * (the implicit zero-arg ctor) are skipped -- nothing for the + * checker to compare against. + * + * @param array, source: string}> $files + * @return array}> + */ + private function indexConstructorsByFqn(array $files): array + { + $byFqn = []; + foreach ($files as $entry) { + $context = self::extractNamespaceAndUseMap($entry['ast']); + foreach (self::collectClassLikesWithNamespace($entry['ast']) as [$namespace, $cls]) { + if ($cls->name === null) { + continue; + } + $shortName = $cls->name->toString(); + $fqn = $namespace !== '' ? $namespace . '\\' . $shortName : $shortName; + foreach ($cls->stmts as $member) { + if ($member instanceof ClassMethod && strtolower($member->name->toString()) === '__construct') { + $byFqn[$fqn] = [ + 'ctor' => $member, + 'owner' => $cls, + 'namespace' => $namespace, + 'useMap' => $context['useMap'], + ]; + break; + } + } + } + } + return $byFqn; + } + + /** + * Recursively walk the top-level statement list collecting every + * `ClassLike` paired with its enclosing namespace string (empty + * when the file has no `namespace` declaration). Handles both + * the "bracketed" form (`namespace App { ... }`) and the + * "semicolon" form (`namespace App; ...`). + * + * @param list $stmts + * @return list + */ + private static function collectClassLikesWithNamespace(array $stmts): array + { + $out = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Namespace_) { + $ns = $stmt->name === null ? '' : $stmt->name->toString(); + foreach ($stmt->stmts as $inner) { + if ($inner instanceof ClassLike) { + $out[] = [$ns, $inner]; + } + } + continue; + } + if ($stmt instanceof ClassLike) { + $out[] = ['', $stmt]; + } + } + return $out; + } + + /** + * @param list $ast + * @param array $ctorByFqn + * @param array $useMap + * @param list $diagnostics + */ + private function walkNewExpressions( + array $ast, + array $ctorByFqn, + TypeHierarchy $hierarchy, + PositionMap $positionMap, + string $namespace, + array $useMap, + array &$diagnostics, + ): void { + $checker = $this; + $visitor = new class($ctorByFqn, $hierarchy, $positionMap, $namespace, $useMap, $diagnostics, $checker) extends NodeVisitorAbstract { + /** + * @param array $ctorByFqn + * @param array $useMap + * @param list $diagnostics + */ + public function __construct( + private readonly array $ctorByFqn, + private readonly TypeHierarchy $hierarchy, + private readonly PositionMap $positionMap, + private readonly string $namespace, + private readonly array $useMap, + public array &$diagnostics, + private readonly ConstructorArgumentChecker $checker, + ) { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof New_) { + return null; + } + if (!$node->class instanceof Name) { + return null; + } + $fqn = $this->checker->resolveTargetClassFqn($node->class, $this->namespace, $this->useMap); + if ($fqn === '' || !isset($this->ctorByFqn[$fqn])) { + return null; + } + $entry = $this->ctorByFqn[$fqn]; + $substitution = $this->checker->buildSubstitution($node->class, $entry['owner']); + $this->checker->emitMismatchDiagnostics( + $node, + $entry['ctor'], + $substitution, + $this->hierarchy, + $this->positionMap, + $this->namespace, + $this->useMap, + $entry['namespace'], + $entry['useMap'], + $this->diagnostics, + $fqn, + ); + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + } + + /** + * Resolve the target class FQN of a `new C(…)` expression. + * Prefers the xphp-parser-attached `ATTR_TEMPLATE_FQN` (set for + * generic `new C(…)` shapes), then resolves bare names via the + * call site's namespace + use map. Returns the empty string when + * nothing can be resolved. + * + * @param array $useMap + */ + public function resolveTargetClassFqn(Name $classExpr, string $namespace, array $useMap): string + { + $generic = $classExpr->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (is_string($generic) && $generic !== '') { + return ltrim($generic, '\\'); + } + return $this->resolveNameToFqn($classExpr, $namespace, $useMap); + } + + /** + * Build the type-parameter substitution map for a `new C(…)` + * instantiation by pairing the template's declared TypeParams with + * the call site's TypeRefs. Returns an empty map for non-generic + * calls (no substitution needed). + * + * @return array + */ + public function buildSubstitution(Name $classExpr, ClassLike $owner): array + { + $args = $classExpr->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (!is_array($args) || $args === []) { + return []; + } + $params = $owner->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return []; + } + $names = self::extractTypeParamNames($params); + if (count($names) !== count($args)) { + return []; + } + $substitution = []; + foreach ($names as $i => $paramName) { + $arg = $args[$i]; + if ($arg instanceof TypeRef) { + $substitution[$paramName] = $arg; + } + } + return $substitution; + } + + /** + * @param array $params + * @return list + */ + private static function extractTypeParamNames(array $params): array + { + $names = []; + foreach ($params as $p) { + if (is_object($p) && property_exists($p, 'name') && is_string($p->name)) { + $names[] = $p->name; + } + } + return $names; + } + + /** + * Compare each call argument against its corresponding (substituted) + * constructor parameter type. Emits one Diagnostic per mismatch. + * + * @param array $substitution + * @param array $callerUseMap + * @param array $ownerUseMap + * @param list $diagnostics + */ + public function emitMismatchDiagnostics( + New_ $newExpr, + ClassMethod $ctor, + array $substitution, + TypeHierarchy $hierarchy, + PositionMap $positionMap, + string $callerNamespace, + array $callerUseMap, + string $ownerNamespace, + array $ownerUseMap, + array &$diagnostics, + string $classFqn, + ): void { + $params = $ctor->params; + foreach ($newExpr->args as $i => $arg) { + if (!$arg instanceof Arg) { + continue; + } + $param = self::paramAtIndex($params, $i); + if ($param === null) { + continue; + } + $expectedType = $this->extractParamType($param, $substitution, $ownerNamespace, $ownerUseMap); + if ($expectedType === null) { + continue; + } + $actualType = $this->inferArgType($arg->value, $callerNamespace, $callerUseMap); + if ($actualType === null) { + continue; + } + if (self::isSatisfied($actualType, $expectedType, $hierarchy)) { + continue; + } + $diagnostics[] = self::buildMismatchDiagnostic( + $arg->value, + $positionMap, + $i + 1, + $param, + $expectedType, + $actualType, + $classFqn, + ); + } + } + + /** + * Resolve the param record for a given positional argument index, + * honoring variadics (last param consumes all trailing args). + * + * @param list $params + */ + private static function paramAtIndex(array $params, int $index): ?Param + { + if (isset($params[$index])) { + return $params[$index]; + } + $last = $params[count($params) - 1] ?? null; + if ($last !== null && $last->variadic) { + return $last; + } + return null; + } + + /** + * Extract the param's declared type as a normalized display string, + * applying type-arg substitution when the param type references a + * generic type parameter. Returns null when the param has no + * type hint. + * + * For union / intersection types, returns the rendered form + * (`A|B` / `A&B`). Nullable types are rendered with the leading + * `?`. + * + * The `$namespace` + `$useMap` are the OWNER's (the declaring + * class's), not the call site's -- non-generic class-type params + * resolve in the declaring file's import context. + * + * @param array $substitution + * @param array $useMap + */ + private function extractParamType(Param $param, array $substitution, string $namespace, array $useMap): ?string + { + $type = $param->type; + if ($type === null) { + return null; + } + return $this->renderType($type, $substitution, $namespace, $useMap); + } + + /** + * @param array $substitution + * @param array $useMap + */ + private function renderType(Node $type, array $substitution, string $namespace, array $useMap): string + { + if ($type instanceof NullableType) { + return '?' . $this->renderType($type->type, $substitution, $namespace, $useMap); + } + if ($type instanceof Node\UnionType) { + $parts = array_map(fn (Node $t): string => $this->renderType($t, $substitution, $namespace, $useMap), $type->types); + return implode('|', $parts); + } + if ($type instanceof Node\IntersectionType) { + $parts = array_map(fn (Node $t): string => $this->renderType($t, $substitution, $namespace, $useMap), $type->types); + return implode('&', $parts); + } + if ($type instanceof Node\Identifier) { + return $type->toString(); + } + if ($type instanceof Name) { + $raw = ltrim($type->toString(), '\\'); + // Generic type-param substitution: param `T $x` resolves + // to whatever the instantiation passed for T. + if (isset($substitution[$raw])) { + return ltrim($substitution[$raw]->name, '\\'); + } + // Bare scalar / reserved type names (`string`, `int`, + // `self`, etc.) stay as-is -- no FQN resolution. + if ($type->isUnqualified() && self::isReservedTypeName($raw)) { + return $raw; + } + return $this->resolveNameToFqn($type, $namespace, $useMap); + } + return ''; + } + + /** + * Recognises PHP's reserved scalar / pseudo type names that + * shouldn't be FQN-resolved against the use map. + */ + private static function isReservedTypeName(string $name): bool + { + $lower = strtolower($name); + return isset(self::SCALARS[$lower]) + || isset(self::PERMISSIVE_TYPES[$lower]) + || $lower === 'null' + || $lower === 'true' + || $lower === 'false'; + } + + /** + * AST-only argument type inference. Returns null when the static + * type isn't visible from the expression alone (variables, + * method-call results, etc.). + * + * @param array $useMap + */ + private function inferArgType(Expr $expr, string $namespace, array $useMap): ?string + { + if ($expr instanceof New_) { + if (!$expr->class instanceof Name) { + return null; + } + // Generic instantiation: ATTR_TEMPLATE_FQN gives the + // template FQN; for the satisfaction check we compare + // against the template name, not the mangled + // specialization name -- that lines up with the param's + // pre-substitution type. + $templateFqn = $expr->class->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (is_string($templateFqn) && $templateFqn !== '') { + return ltrim($templateFqn, '\\'); + } + return $this->resolveNameToFqn($expr->class, $namespace, $useMap); + } + if ($expr instanceof String_) { + return 'string'; + } + if ($expr instanceof Int_) { + return 'int'; + } + if ($expr instanceof Float_) { + return 'float'; + } + if ($expr instanceof Array_) { + return 'array'; + } + if ($expr instanceof ConstFetch) { + $name = strtolower($expr->name->toString()); + if ($name === 'true' || $name === 'false') { + return 'bool'; + } + if ($name === 'null') { + return 'null'; + } + } + return null; + } + + /** + * Check whether `$actual` satisfies `$expected`. See the class + * docblock for the supported shapes. + */ + private static function isSatisfied(string $actual, string $expected, TypeHierarchy $hierarchy): bool + { + $expected = ltrim($expected, '\\'); + $actual = ltrim($actual, '\\'); + + if ($expected === '' || $actual === '') { + return true; + } + // Nullable param: null is always OK; non-null is checked + // against the inner type. + if (str_starts_with($expected, '?')) { + if ($actual === 'null') { + return true; + } + return self::isSatisfied($actual, substr($expected, 1), $hierarchy); + } + if ($actual === 'null') { + // Non-nullable param with null arg: explicit mismatch. + return false; + } + if (str_contains($expected, '|')) { + foreach (explode('|', $expected) as $arm) { + if (self::isSatisfied($actual, $arm, $hierarchy)) { + return true; + } + } + return false; + } + if (str_contains($expected, '&')) { + foreach (explode('&', $expected) as $arm) { + if (!self::isSatisfied($actual, $arm, $hierarchy)) { + return false; + } + } + return true; + } + $expectedLower = strtolower($expected); + if (isset(self::PERMISSIVE_TYPES[$expectedLower])) { + return true; + } + if ($expected === $actual) { + return true; + } + // Scalar param: arg type must match exactly. `int -> float` + // promotion is technically allowed by PHP but reporting it + // is rarely useful as a warning -- accept the literal `int` + // for a `float` param to avoid false positives. + if (isset(self::SCALARS[$expectedLower])) { + if ($expectedLower === 'float' && $actual === 'int') { + return true; + } + if (isset(self::SCALARS[strtolower($actual)])) { + return $expectedLower === strtolower($actual); + } + // Scalar expected, class supplied → mismatch. + return false; + } + // Both sides are class-like. Defer to the workspace's + // TypeHierarchy. Unknown ancestry -> assume OK (don't false- + // positive on closed-source vendor types). + $is = $hierarchy->isSubtype($actual, $expected); + return $is !== false; + } + + /** + * @param Param $param + */ + private static function buildMismatchDiagnostic( + Expr $argExpr, + PositionMap $positionMap, + int $oneBasedIndex, + Param $param, + string $expectedType, + string $actualType, + string $classFqn, + ): Diagnostic { + $paramName = $param->var instanceof Node\Expr\Variable && is_string($param->var->name) + ? '$' . $param->var->name + : '#' . $oneBasedIndex; + $message = sprintf( + 'Constructor argument %d (%s) of %s expects %s, got %s.', + $oneBasedIndex, + $paramName, + $classFqn, + $expectedType, + $actualType, + ); + if ($argExpr->getStartFilePos() >= 0 && $argExpr->getEndFilePos() >= 0) { + [$sl, $sc, $el, $ec] = $positionMap->rangeFromOffsets( + $argExpr->getStartFilePos(), + $argExpr->getEndFilePos() + 1, + ); + } else { + [$sl, $sc, $el, $ec] = $positionMap->fullLineRangeFromNikic($argExpr->getStartLine()); + } + return new Diagnostic( + startLine: $sl, + startCharacter: $sc, + endLine: $el, + endCharacter: $ec, + message: $message, + code: DiagnosticCode::ConstructorArgumentMismatch, + ); + } +} diff --git a/src/Analyzer/Diagnostic.php b/src/Analyzer/Diagnostic.php new file mode 100644 index 0000000..896bbb9 --- /dev/null +++ b/src/Analyzer/Diagnostic.php @@ -0,0 +1,31 @@ +value` + * straight through to phpactor's Diagnostic without translation. + * + * Codes intentionally use dotted notation (`xphp.[.]`) + * so an editor's code-aware config can pattern-match (e.g. "downgrade + * xphp.parse.internal to info") without parsing message text. + * + * `fromRegistryRecordInstantiationException` is the central place that decides + * which code applies to a `RuntimeException` thrown from + * `Registry::recordInstantiation`. That method has two distinct error paths + * (bound violation vs. hash collision) which were previously both surfaced as + * `xphp.bound`. Mis-coding a collision as a bound violation pointed users at + * the wrong fix (look at bounds vs. raise XPHP_HASH_LENGTH). Centralising the + * triage here means future Registry exception paths slot in without + * smearing magic strings across catch blocks. + */ +enum DiagnosticCode: string +{ + /** nikic/php-parser threw — the source isn't valid PHP after angle-stripping. */ + case Parse = 'xphp.parse'; + + /** XphpSourceParser threw RuntimeException — internal contract violation, rare. */ + case ParseInternal = 'xphp.parse.internal'; + + /** Registry::recordDefinition rejected a duplicate template declaration. */ + case Definition = 'xphp.definition'; + + /** Registry::recordInstantiation rejected an arg list against its declared bound. */ + case BoundViolation = 'xphp.bound'; + + /** Two distinct instantiations hashed to the same generated FQCN — raise XPHP_HASH_LENGTH. */ + case HashCollision = 'xphp.collision'; + + /** + * Bareword constant reference that doesn't resolve to a known + * built-in pseudo-constant (null / true / false). Conservative: + * we only flag lowercase identifiers, since user-defined + * constants overwhelmingly use UPPER_SNAKE_CASE and the LSP + * doesn't yet maintain a workspace-wide constant index. + * + * Catches typos like `$x ?? nul` (PHP 8 throws a fatal + * `Error: Undefined constant "nul"` at runtime for these). + * Severity is Warning, not Error, because the heuristic is + * intentionally narrow -- false positives are possible for + * lowercase user-defined constants, and the warning level + * keeps them dismissable. + */ + case UndefinedName = 'xphp.undefined-name'; + + /** + * `new Foo(…)` (or generic `new Foo(…)` after monomorphization) + * was called with an argument whose type doesn't satisfy the + * declared constructor parameter type. Surfaces what would + * otherwise be a runtime `TypeError` ahead of time. + * + * V1 only flags the cases where both sides are statically known: + * - param type is a class / interface / trait FQN, AND + * argument is either `new ClassName(...)` (so its type is the + * class FQN) or a `Stringable`-style scalar literal that + * obviously can't satisfy the class param; + * - param type is a scalar (string / int / float / bool / array) + * AND the argument is a literal of a different scalar kind. + * + * Skips arguments whose type can't be inferred from the AST alone + * (variables, method-call results, ternaries, etc.) to avoid + * false positives. + */ + case ConstructorArgumentMismatch = 'xphp.ctor-arg-mismatch'; + + /** + * Map a RuntimeException raised by Registry::recordInstantiation to its + * diagnostic code. The Registry doesn't (currently) use a typed exception + * hierarchy, so we triage by the error message's leading phrase. The + * Registry's error builders (Registry::collisionMessage, + * Registry::validateBounds) use stable prefixes documented in their + * docblocks — if those phrasings shift, this triage breaks and the bound + * fallback kicks in. + */ + public static function fromRegistryRecordInstantiationException(RuntimeException $e): self + { + if (str_starts_with($e->getMessage(), 'Hash collision')) { + return self::HashCollision; + } + return self::BoundViolation; + } +} diff --git a/src/Analyzer/DiagnosticSeverity.php b/src/Analyzer/DiagnosticSeverity.php new file mode 100644 index 0000000..173642f --- /dev/null +++ b/src/Analyzer/DiagnosticSeverity.php @@ -0,0 +1,17 @@ +value` straight into the wire-format Diagnostic without translation. + */ +enum DiagnosticSeverity: int +{ + case Error = 1; + case Warning = 2; + case Information = 3; + case Hint = 4; +} diff --git a/src/Analyzer/ParseResult.php b/src/Analyzer/ParseResult.php new file mode 100644 index 0000000..62d6f85 --- /dev/null +++ b/src/Analyzer/ParseResult.php @@ -0,0 +1,34 @@ +|null $ast + * @param list $diagnostics + */ + public function __construct( + public ?array $ast, + public array $diagnostics, + public ByteOffsetMap $byteOffsetMap, + ) { + } +} diff --git a/src/Analyzer/ParsedDocumentCache.php b/src/Analyzer/ParsedDocumentCache.php new file mode 100644 index 0000000..a482b3e --- /dev/null +++ b/src/Analyzer/ParsedDocumentCache.php @@ -0,0 +1,114 @@ +text)` directly on + * every LSP request, re-parsing every open document every time. On a workspace + * with N open docs that's O(N) parses per keystroke. nikic is fast (~ms per + * file) so this was acceptable for small workspaces but degrades quickly past + * 10-20 open documents — and completion fires this loop on every `<`. + * + * Cache invalidation is by-version only. LSP gives us `TextDocumentItem::version` + * for free — phpactor bumps it on `didChange`. The next `getOrParse()` after + * a change sees the new version and reparses; otherwise we serve from cache. + * No explicit invalidation on didOpen/didChange is needed. + * + * `forget()` exists for `didClose`: drops the URI from the cache so the LSP + * session doesn't grow unbounded across long editor sessions. + */ +final class ParsedDocumentCache +{ + /** @var array */ + private array $entries = []; + + public function __construct(private readonly Analyzer $analyzer) + { + } + + public function getOrParse(string $uri, int $version, string $source): ParseResult + { + $cached = $this->entries[$uri] ?? null; + if ($cached !== null && $cached['version'] === $version) { + return $cached['result']; + } + $result = $this->analyzer->analyzeFile($source); + $this->entries[$uri] = ['version' => $version, 'result' => $result]; + return $result; + } + + public function forget(string $uri): void + { + unset($this->entries[$uri]); + } + + /** + * Background-warmer hook: stash a parsed result against `$uri` UNLESS + * an entry already exists. The "if-absent" semantic is load-bearing + * -- the warmer fires after `initialized` and the user may already + * have opened a file by then; `getOrParse` would overwrite the + * version-N open-doc entry with a stale version-0 disk-side parse. + * + * Cached at the sentinel version 0 -- LSP-issued versions start at 1 + * (per the protocol's `TextDocumentItem.version` semantics), so a + * subsequent `didOpen` flow always sees a version mismatch on the + * first read and reparses with the live in-memory text. No callers + * need to know about the sentinel. + * + * Used by {@see \XPHP\Lsp\Analyzer\ParsedDocumentCacheWarmer} to + * pre-populate ASTs for every filesystem-indexed `file://` URI so + * that the cold first reference search doesn't pay the per-file + * parse cost in-band. + */ + public function seedIfAbsent(string $uri, string $source): void + { + if (isset($this->entries[$uri])) { + return; + } + $result = $this->analyzer->analyzeFile($source); + $this->entries[$uri] = ['version' => 0, 'result' => $result]; + } + + /** + * Cache-hit lookup for callers that want to avoid parsing on miss -- + * the filesystem-pass branch of {@see \XPHP\Lsp\Resolver\ReferenceFinder} + * needs to know "do I have a warmed parse for this URI?" without + * paying for an Analyzer round-trip if not. Returns null on miss. + */ + public function peek(string $uri): ?ParseResult + { + return $this->entries[$uri]['result'] ?? null; + } + + /** + * Drop every entry seeded by the warmer / filesystem pass + * (version sentinel 0). Counterpart to {@see \XPHP\Lsp\Reflection\FqnIndex::invalidateFilesystem}: + * the file watcher calls both together on external `Changed` / + * `Created` / `Deleted` events so the cache doesn't serve stale + * ASTs from before the change. + * + * Open-doc entries (LSP-versioned starting at 1, refreshed via + * `didChange` version bumps) are left alone -- their staleness is + * already handled by the existing version-mismatch reparse in + * {@see getOrParse}. Distinguishing by `version === 0` rather + * than by URI prefix is load-bearing: open docs also use + * `file://` URIs, so a prefix filter would drop them too. + * + * @return int number of dropped entries (Stderr-facing for the + * watch handler's observability line) + */ + public function forgetFilesystem(): int + { + $dropped = 0; + foreach ($this->entries as $uri => $entry) { + if ($entry['version'] === 0) { + unset($this->entries[$uri]); + $dropped++; + } + } + return $dropped; + } +} diff --git a/src/Analyzer/ParsedDocumentCacheWarmer.php b/src/Analyzer/ParsedDocumentCacheWarmer.php new file mode 100644 index 0000000..0a31ca4 --- /dev/null +++ b/src/Analyzer/ParsedDocumentCacheWarmer.php @@ -0,0 +1,122 @@ +warmNow(...)); + } + + /** + * Synchronous warm body, extracted from {@see warm} so unit tests + * can drive it without yielding to the Amp event loop. The + * `asyncCall(...) + Delayed(N) wait` pattern is racy under + * Infection's parallel workers -- a delay-based "wait for the + * asyncCall to finish" sometimes returns before the body + * completes, producing false-positive mutant escapes on the + * `continue` inside the open-doc skip branch (the cache-write + * mutation hadn't actually happened yet by assertion time). + * Production callers continue to use `warm()`; this method is + * purely a test-friendly handle. + * + * @internal + */ + public function warmNow(): void + { + $warmed = 0; + $skippedOpen = 0; + $skippedUnreadable = 0; + $skippedParseError = 0; + foreach ($this->fqnIndex->indexedFilesystemPaths() as $path) { + $uri = 'file://' . $path; + if ($this->workspace->has($uri)) { + $skippedOpen++; + continue; + } + $source = @file_get_contents($path); + if ($source === false) { + $skippedUnreadable++; + continue; + } + try { + $this->cache->seedIfAbsent($uri, $source); + $warmed++; + } catch (Throwable) { + // Analyzer throws on truly malformed input that not + // even the tolerant parse path can swallow. Counted + // separately for the observability line; doesn't + // abort the whole warm-up. + $skippedParseError++; + } + } + Stderr::write(sprintf( + "[xphp-lsp warmer] parsed-doc cache warmed (%d file%s, skipped: %d open / %d unreadable / %d parse-error)\n", + $warmed, + $warmed === 1 ? '' : 's', + $skippedOpen, + $skippedUnreadable, + $skippedParseError, + )); + } +} diff --git a/src/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php new file mode 100644 index 0000000..b90e831 --- /dev/null +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -0,0 +1,257 @@ +, source: string}> $files keyed by URI/path + * @param array> $hierarchyAsts AST-only entries that + * enrich the bound-check hierarchy AND register their template definitions so + * `Registry::validateBounds` can find them. NOT walked for instantiations: any diagnostics + * the definition pass would produce on these URIs (e.g. duplicate-template against an open + * file that already won) are routed into a throwaway sink. Source isn't needed since the + * PositionMap for these entries is degenerate and unused. + * @return array> diagnostics keyed by URI/path + */ + public function analyze(array $files, array $hierarchyAsts = []): array + { + $diagnosticsByFile = array_fill_keys(array_keys($files), []); + + $astPerFile = []; + foreach ($files as $path => $entry) { + $astPerFile[$path] = $entry['ast']; + } + foreach ($hierarchyAsts as $uri => $ast) { + if (!isset($astPerFile[$uri])) { + $astPerFile[$uri] = $ast; + } + } + $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); + $registry = new Registry(hierarchy: $hierarchy); + + // First pass: definitions. Catch duplicate-declaration RuntimeExceptions and pin + // them on the second declaration's file (which is what the compiler also reports). + // Open files first — their declarations win on URI collision. + foreach ($files as $path => $entry) { + $positionMap = new PositionMap($entry['source']); + $this->walkDefinitions($entry['ast'], $registry, $path, $positionMap, $diagnosticsByFile[$path]); + } + // Filesystem-only definitions are silently registered so the + // bound-check lookup in `Registry::validateBounds` succeeds even + // when the template's defining file isn't currently open. Any + // duplicate-template throws (whether against an open file already + // registered or against another filesystem entry) land in a sink + // no caller reads — they aren't actionable for the user since + // the offending file isn't on screen. + $definitionSink = []; + foreach ($hierarchyAsts as $uri => $ast) { + if (isset($files[$uri])) { + continue; + } + // Degenerate PositionMap is fine: any diagnostic constructed here + // is discarded via the sink, so the bogus offsets never surface. + $this->walkDefinitions($ast, $registry, $uri, new PositionMap(''), $definitionSink); + } + + // Second pass: instantiations. Bound violations fire here. + foreach ($files as $path => $entry) { + $positionMap = new PositionMap($entry['source']); + $this->walkInstantiations($entry['ast'], $registry, $positionMap, $diagnosticsByFile[$path]); + } + + // Third pass: constructor argument-type checking (V1 of the + // post-monomorphization arg type checker). Catches the class + // of bugs where `new C(…)` or plain `new C(…)` is called + // with an arg whose static type can't satisfy the (substituted) + // ctor param's declared type -- a runtime TypeError waiting + // to happen. + $argChecks = (new ConstructorArgumentChecker())->check($files, $hierarchy); + foreach ($argChecks as $path => $diags) { + foreach ($diags as $diag) { + $diagnosticsByFile[$path][] = $diag; + } + } + + return $diagnosticsByFile; + } + + /** + * @param list $ast + * @param list $diagnostics + */ + private function walkDefinitions( + array $ast, + Registry $registry, + string $sourceFile, + PositionMap $positionMap, + array &$diagnostics, + ): void { + $visitor = new class($registry, $sourceFile, $positionMap, $diagnostics) extends NodeVisitorAbstract { + /** @param list $diagnostics */ + public function __construct( + private readonly Registry $registry, + private readonly string $sourceFile, + private readonly PositionMap $positionMap, + private array &$diagnostics, + ) { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_array($params) || $params === [] || !is_string($fqn)) { + return null; + } + // Deliberately do NOT pre-check for "already registered" — the whole point + // of running this in the analyzer is to surface the duplicate-declaration + // RuntimeException from Registry::recordDefinition as a diagnostic. + try { + $this->registry->recordDefinition( + $fqn, + $node->name->toString(), + $params, + $node, + $this->sourceFile, + ); + } catch (RuntimeException $e) { + $this->diagnostics[] = self::buildDiagnostic( + $this->positionMap, + $node->name, + $node->getStartLine(), + DiagnosticCode::Definition, + $e->getMessage(), + ); + } + return null; + } + + private static function buildDiagnostic( + PositionMap $positionMap, + ?\PhpParser\Node\Identifier $identifier, + int $fallbackNikicLine, + DiagnosticCode $code, + string $message, + ): Diagnostic { + // Prefer the identifier's actual byte span — squiggles the class + // name, not the whole line. Fall back to the full-line range when + // position info is missing (synthetic nodes etc.). + if ($identifier !== null && $identifier->getStartFilePos() >= 0) { + [$sl, $sc, $el, $ec] = $positionMap->rangeFromOffsets( + $identifier->getStartFilePos(), + $identifier->getEndFilePos() + 1, + ); + } else { + [$sl, $sc, $el, $ec] = $positionMap->fullLineRangeFromNikic($fallbackNikicLine); + } + return new Diagnostic($sl, $sc, $el, $ec, $message, code: $code); + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + } + + /** + * @param list $ast + * @param list $diagnostics + */ + private function walkInstantiations( + array $ast, + Registry $registry, + PositionMap $positionMap, + array &$diagnostics, + ): void { + $visitor = new class($registry, $positionMap, $diagnostics) extends NodeVisitorAbstract { + /** @param list $diagnostics */ + public function __construct( + private readonly Registry $registry, + private readonly PositionMap $positionMap, + private array &$diagnostics, + ) { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof Name) { + return null; + } + $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_array($args) || $args === [] || !is_string($fqn)) { + return null; + } + foreach ($args as $a) { + if (!$a->isConcrete()) { + return null; + } + } + try { + $this->registry->recordInstantiation($fqn, $args); + } catch (RuntimeException $e) { + // Pin the range to the offending Name's byte span (squiggles + // just the generic identifier, e.g. `Box` in + // `new Box()`) rather than the whole line. Fall back to + // the full line if position info is missing. + if ($node->getStartFilePos() >= 0 && $node->getEndFilePos() >= 0) { + [$sl, $sc, $el, $ec] = $this->positionMap->rangeFromOffsets( + $node->getStartFilePos(), + $node->getEndFilePos() + 1, + ); + } else { + [$sl, $sc, $el, $ec] = $this->positionMap->fullLineRangeFromNikic($node->getStartLine()); + } + $this->diagnostics[] = new Diagnostic( + startLine: $sl, + startCharacter: $sc, + endLine: $el, + endCharacter: $ec, + message: $e->getMessage(), + // Registry::recordInstantiation has two error paths + // (bound violation vs. hash collision). The triage helper + // distinguishes them by the message's leading phrase so + // editors / users can act on the right hint (raise + // XPHP_HASH_LENGTH vs. fix the bound). + code: DiagnosticCode::fromRegistryRecordInstantiationException($e), + ); + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + } +} diff --git a/src/Diagnostics/DiagnosticTranslator.php b/src/Diagnostics/DiagnosticTranslator.php new file mode 100644 index 0000000..234db7a --- /dev/null +++ b/src/Diagnostics/DiagnosticTranslator.php @@ -0,0 +1,33 @@ +startLine, $d->startCharacter), + new Position($d->endLine, $d->endCharacter), + ), + message: $d->message, + ); + $lsp->severity = $d->severity->value; + $lsp->code = $d->code->value; + $lsp->source = 'xphp'; + return $lsp; + } +} diff --git a/src/Diagnostics/XphpDiagnosticsProvider.php b/src/Diagnostics/XphpDiagnosticsProvider.php new file mode 100644 index 0000000..6f6e434 --- /dev/null +++ b/src/Diagnostics/XphpDiagnosticsProvider.php @@ -0,0 +1,133 @@ +analyzeSync($textDocument)); + } + + public function name(): string + { + return 'xphp'; + } + + /** + * Sync entry-point shared by the push-mode `provideDiagnostics` + * (above) and the pull-mode `textDocument/diagnostic` handler. + * Both flows want the same analysis without the Promise wrap. + * + * @return list + */ + public function analyzeSync(TextDocumentItem $textDocument): array + { + $currentUri = $textDocument->uri; + + // Per-file syntax pass on the document being linted. Cache-keyed by + // (uri, version) so subsequent hover/definition/completion calls + // against the same unchanged document don't reparse. + $currentResult = $this->cache->getOrParse( + $currentUri, + $textDocument->version, + $textDocument->text, + ); + $perFileDiagnostics = array_map( + static fn ($d) => DiagnosticTranslator::toLsp($d), + $currentResult->diagnostics, + ); + + // If the document didn't parse, the workspace pass would skip it anyway — + // and feeding it through TypeHierarchy::fromAstPerFile() with a null AST + // is meaningless. Return the syntax diagnostics alone. + if ($currentResult->ast === null) { + return $perFileDiagnostics; + } + + // Build the {uri: {ast, source}} map for the workspace pass: the current + // document plus every OTHER open document that we can parse cleanly. + $parsedFiles = [ + $currentUri => ['ast' => $currentResult->ast, 'source' => $textDocument->text], + ]; + foreach ($this->workspace as $uri => $item) { + if ($uri === $currentUri) { + continue; + } + $otherResult = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($otherResult->ast === null) { + continue; + } + $parsedFiles[$uri] = ['ast' => $otherResult->ast, 'source' => $item->text]; + } + + // Enrich the bound-check hierarchy with every filesystem-indexed file the + // ParsedDocumentCacheWarmer has already parsed. Without this, the workspace + // pass only sees open buffers — so `new Box(…)` in an open file dependent + // on a Tag class that's on disk but not open fires a spurious + // "concrete type is not in the source set" diagnostic. Open-buffer entries + // already in $parsedFiles take precedence and are skipped here. + $hierarchyAsts = []; + foreach ($this->fqnIndex->indexedFilesystemPaths() as $path) { + $uri = 'file://' . $path; + if (isset($parsedFiles[$uri])) { + continue; + } + $peek = $this->cache->peek($uri); + if ($peek === null || $peek->ast === null) { + continue; + } + $hierarchyAsts[$uri] = $peek->ast; + } + + $workspaceByUri = $this->workspaceAnalyzer->analyze($parsedFiles, $hierarchyAsts); + $currentWorkspaceDiagnostics = $workspaceByUri[$currentUri] ?? []; + + $lspWorkspaceDiagnostics = array_map( + static fn ($d) => DiagnosticTranslator::toLsp($d), + $currentWorkspaceDiagnostics, + ); + + return array_merge($perFileDiagnostics, $lspWorkspaceDiagnostics); + } +} diff --git a/src/Dispatcher/LspObjectArgumentResolver.php b/src/Dispatcher/LspObjectArgumentResolver.php new file mode 100644 index 0000000..aecaa23 --- /dev/null +++ b/src/Dispatcher/LspObjectArgumentResolver.php @@ -0,0 +1,102 @@ + params is a raw `CompletionItem` + * codeAction/resolve -> params is a raw `CodeAction` + * codeLens/resolve -> params is a raw `CodeLens` + * + * Without this resolver the chain falls through to + * `PassThroughArgumentResolver`, which hands the splatted + * `array_values($params)` to the handler -- a list of scalar field + * values, not a typed object. PHP's positional arg-binding then + * throws a `TypeError` ("string given, expected CompletionItem") + * because `array_values()` strips the `label` / `kind` / `data` + * keys. + * + * This resolver runs the same `fromArray(...)` static deserialiser + * the framework already uses for `*Params` types, but matches + * either `CompletionItem` or `CodeAction` -- the only two + * non-Params LSP object types we currently accept. Add to the + * resolver chain BEFORE `LanguageSeverProtocolParamsResolver` and + * `PassThroughArgumentResolver`. + */ +final class LspObjectArgumentResolver implements ArgumentResolver +{ + /** + * Class FQNs handled by this resolver. Each must implement a + * static `fromArray(array $data, bool $allowUnknownKeys = false): self` + * factory -- every Phpactor LSP-protocol class does. + * + * @var list + */ + private const SUPPORTED_TYPES = [ + \Phpactor\LanguageServerProtocol\CompletionItem::class, + \Phpactor\LanguageServerProtocol\CodeAction::class, + \Phpactor\LanguageServerProtocol\CodeLens::class, + ]; + + /** + * @return list + */ + public function resolveArguments(object $object, string $method, Message $request): array + { + // `ChainArgumentResolver` advances to the next resolver only + // when this one throws `CouldNotResolveArguments` -- returning + // `[]` would short-circuit the chain. Throw on every case we + // can't handle so the chain falls through to + // `LanguageSeverProtocolParamsResolver` / + // `PassThroughArgumentResolver` as before. + if (!$request instanceof RequestMessage && !$request instanceof NotificationMessage) { + throw new CouldNotResolveArguments('Not a request/notification'); + } + + $reflection = new ReflectionMethod($object, $method); + $parameters = $reflection->getParameters(); + if (count($parameters) < 1) { + throw new CouldNotResolveArguments('Handler method has no parameters'); + } + + $type = $parameters[0]->getType(); + if (!$type instanceof ReflectionNamedType) { + throw new CouldNotResolveArguments('First parameter has no concrete type'); + } + $classFqn = $type->getName(); + if (!in_array($classFqn, self::SUPPORTED_TYPES, true)) { + throw new CouldNotResolveArguments(sprintf( + 'Class "%s" not in LspObjectArgumentResolver supported types', + $classFqn, + )); + } + + $params = $request->params ?? []; + if (!is_array($params)) { + throw new CouldNotResolveArguments('Request params is not an array'); + } + + $reflectionClass = new ReflectionClass($classFqn); + $fromArray = $reflectionClass->getMethod('fromArray'); + + return [$fromArray->invoke(null, $params, true)]; + } +} diff --git a/src/Handler/AstPositionResolver.php b/src/Handler/AstPositionResolver.php new file mode 100644 index 0000000..84b2003 --- /dev/null +++ b/src/Handler/AstPositionResolver.php @@ -0,0 +1,87 @@ + $ast + * @return array{name: Name, classScope: list}|null + */ + public static function nameAtOffset(array $ast, int $offset): ?array + { + $visitor = new class($offset) extends NodeVisitorAbstract { + /** @var list */ + private array $classStack = []; + + public ?Name $found = null; + + /** @var list */ + public array $foundClassStack = []; + + public function __construct(private readonly int $offset) + { + } + + public function enterNode(Node $node): null + { + if ($node instanceof ClassLike) { + $this->classStack[] = $node; + } + if (!$node instanceof Name) { + return null; + } + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start < 0 || $end < 0) { + return null; + } + // endFilePos points at the LAST byte of the node (inclusive), + // so the half-open range is [start, end + 1). + if ($this->offset < $start || $this->offset > $end) { + return null; + } + // Keep the SMALLEST matching node — replace any previous match + // because nikic walks parents before children. + $this->found = $node; + $this->foundClassStack = $this->classStack; + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike) { + array_pop($this->classStack); + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + if ($visitor->found === null) { + return null; + } + return ['name' => $visitor->found, 'classScope' => $visitor->foundClassStack]; + } +} diff --git a/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php new file mode 100644 index 0000000..b660ea1 --- /dev/null +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -0,0 +1,640 @@ +` clauses excised), so positions + * pass through {@see ByteOffsetMap::toOriginal} before + * {@see PositionMap}. Emits the identifier kinds the token scan + * can't classify on its own: ClassLike names -> `class` / + * `interface` / `enum`, ClassMethod names -> `method`, Function_ + * names -> `function`, PropertyItem names -> `property`, Param + * names -> `parameter`. + * + * Slice 3 will extend the AST pass to recognise xphp generic + * `ATTR_GENERIC_PARAMS` / `ATTR_GENERIC_ARGS` decorations and emit + * `typeParameter` for every `T` in the 12 audit forms. + */ +final class AstVisitor +{ + /** + * @var array T_* token-id -> semantic-token type for the + * subset PhpToken-based classification covers. + */ + private static array $tokenTypeMap; + + public function __construct( + private readonly PositionMap $positionMap, + private readonly ByteOffsetMap $byteOffsetMap, + private readonly string $source, + ) { + if (!isset(self::$tokenTypeMap)) { + self::$tokenTypeMap = self::buildTokenTypeMap(); + } + } + + /** + * @param array $stmts + * @return list + */ + public function visit(array $stmts): array + { + // AST walk runs FIRST and collects the byte-ranges where a + // T_VARIABLE token should re-classify as `parameter` instead of + // `variable`. The token pass then SKIPS T_VARIABLE at those + // ranges and emits the parameter spec on the AST visitor's + // behalf. This replaces the older "emit both, hope the client + // honours later-wins" approach -- single spec per source span, + // half the response size at every parameter. + $specs = []; + $reclassifyVariableAt = []; + + if ($stmts !== []) { + $traverser = new NodeTraverser(); + $traverser->addVisitor($this->newAstWalker($specs, $reclassifyVariableAt)); + $traverser->traverse($stmts); + } + + $this->collectFromTokens($specs, $reclassifyVariableAt); + + return $specs; + } + + /** + * Pass 1: tokenize the original source and emit specs for the token + * classes that don't need AST context. + * + * @param list $out + */ + /** + * @param array $reclassifyVariableAt byte-offset -> alternative type + * (currently `parameter`); when a + * T_VARIABLE starts at that offset + * we emit the alternative type + * INSTEAD of `variable`. + * @param list $out + */ + private function collectFromTokens(array &$out, array $reclassifyVariableAt = []): void + { + // Non-strict tokenization (flags=0). TOKEN_PARSE turns + // PhpToken into a strict-mode tokenizer that throws ParseError + // on the `` we use for generics. In non-strict mode the + // `<` and `T` just come back as their literal tokens. + $tokens = @PhpToken::tokenize($this->source); + if ($tokens === false) { + return; + } + + // Slice 3: state machine tracks whether we're inside a + // `<...>` generic clause. `<` opens a clause if (a) the + // previous non-trivial token was an identifier (T_STRING), + // and (b) the next non-trivial token is an uppercase-starting + // identifier or backslash (FQN start). This rejects + // `$size < count($items)` (LHS is T_VARIABLE, not T_STRING) + // and `Foo::BAR < 5` (RHS is a number, not uppercase ident). + // Inside a clause: T_STRING tokens emit as `typeParameter`, + // backslashes as part of FQNs (left unclassified -- the + // surrounding T_STRING segments paint). Depth-counted so + // nested `Box>` still classifies T. + $genericDepth = 0; + $lastSignificantTokenId = null; + + $tokenCount = count($tokens); + for ($i = 0; $i < $tokenCount; $i++) { + $token = $tokens[$i]; + + // PhpToken's `$id` is always int: for T_* tokens it's the + // T_* constant; for single-char tokens (`<`, `>`, `,`, ...) + // it's the literal byte value. Distinguish via the range. + $isNamedToken = $token->id >= 256; + + // Treat whitespace + comments as "trivial" for state purposes + // (they don't update lastSignificantTokenId and they don't + // exit a clause). Their own classification still happens + // below. + $isTrivial = $isNamedToken + && in_array($token->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true); + + // Open / close angle-clause state on single-char tokens. + if (!$isNamedToken && $token->text === '<') { + if ($genericDepth > 0) { + $genericDepth++; + } elseif ($lastSignificantTokenId === T_STRING + && self::peekIsUppercaseIdent($tokens, $i + 1) + ) { + $genericDepth = 1; + } + } elseif (!$isNamedToken && $token->text === '>' && $genericDepth > 0) { + $genericDepth--; + } + + // Classify the token. + if ($isNamedToken) { + $type = self::$tokenTypeMap[$token->id] ?? null; + if ($type === 'variable' && isset($reclassifyVariableAt[$token->pos])) { + // AST pass marked this T_VARIABLE position as a + // parameter; emit `parameter` instead of `variable` + // (single spec, half the response size). + $type = $reclassifyVariableAt[$token->pos]; + } + if ($type === null && $genericDepth > 0 && self::isIdentInGenericClause($token->id)) { + // Inside a generic clause an identifier is a type + // name -- emit as `typeParameter` for the LSP-spec + // standard classification. Covers bare T_STRING + // (`T`) and qualified-name tokens + // (T_NAME_FULLY_QUALIFIED `\Stringable`, + // T_NAME_QUALIFIED `App\Foo`, T_NAME_RELATIVE + // `namespace\Foo`). + $type = 'typeParameter'; + } + if ($type === null && $token->id === T_STRING && self::isReservedWordIdent($token->text)) { + // PHP tokenizes `null`, `true`, `false`, `void`, + // `mixed`, `never`, `iterable`, `self`, `parent`, + // `static` (as a type), and the primitive scalar + // names `int` / `string` / `bool` / `float` / + // `array` / `object` as T_STRING -- not as their + // own T_* constants. Without this case they fall + // through to "no classification" and the editor + // paints them with the default text color. The + // user-visible effect: `null` looks like an + // identifier instead of a keyword. Lookup is + // case-insensitive because PHP itself accepts + // `NULL`, `Null`, `null` interchangeably. + $type = 'keyword'; + } + if ($type !== null) { + $this->emit($out, $token->pos, strlen($token->text), $type); + } + } + + if (!$isTrivial) { + $lastSignificantTokenId = $isNamedToken ? $token->id : null; + } + } + } + + /** + * PHP reserved-word identifiers tokenized as T_STRING. + * + * `null`, `true`, `false` are constants treated as keywords by + * developer convention but emitted as bareword T_STRING by PHP's + * tokenizer. Type-name primitives (`int`, `string`, etc.) follow + * the same pattern. Lookup is case-insensitive because PHP + * accepts `NULL`/`Null`/`null` interchangeably. + */ + private const RESERVED_WORD_IDENTIFIERS = [ + 'null' => true, + 'true' => true, + 'false' => true, + 'void' => true, + 'mixed' => true, + 'never' => true, + 'iterable' => true, + 'self' => true, + 'parent' => true, + 'int' => true, + 'string' => true, + 'bool' => true, + 'float' => true, + 'array' => true, + 'object' => true, + 'callable' => true, + ]; + + private static function isReservedWordIdent(string $text): bool + { + return isset(self::RESERVED_WORD_IDENTIFIERS[strtolower($text)]); + } + + /** + * Token ids that count as "an identifier" inside a generic clause. + * Covers PHP 8.0+ qualified-name tokens too -- `\Stringable` comes + * back as one T_NAME_FULLY_QUALIFIED, not `\` + T_STRING. + */ + private static function isIdentInGenericClause(int $tokenId): bool + { + return $tokenId === T_STRING + || $tokenId === T_NAME_QUALIFIED + || $tokenId === T_NAME_FULLY_QUALIFIED + || $tokenId === T_NAME_RELATIVE; + } + + /** + * Peek forward in the token stream skipping whitespace + comments. + * Returns true if the next significant token is a T_STRING starting + * with an uppercase letter / underscore / backslash (FQN start) -- + * the "this `<` opens a generic clause" heuristic. + * + * @param array $tokens + */ + private static function peekIsUppercaseIdent(array $tokens, int $startIdx): bool + { + $count = count($tokens); + for ($i = $startIdx; $i < $count; $i++) { + $t = $tokens[$i]; + $isNamed = $t->id >= 256; + if ($isNamed && in_array($t->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + if ($isNamed && $t->id === T_STRING) { + $first = $t->text[0] ?? ''; + return ($first >= 'A' && $first <= 'Z') || $first === '_'; + } + if (!$isNamed && $t->text === '\\') { + return true; // FQN like `<\App\User>` + } + return false; + } + return false; + } + + /** + * Pass 2: walk the AST and emit specs for identifier kinds that + * the token scan can't classify on its own. + * + * Maintains a stack of in-scope type-param names (from + * {@see \XPHP\Transpiler\Monomorphize\XphpSourceParser::ATTR_GENERIC_PARAMS} + * on the enclosing ClassLike) so reified-T references inside + * generic class bodies (`new T()`, `T::class`, `instanceof T`, + * `T::method()`) re-classify as `typeParameter`. The token-scan + * pass can't make this distinction -- it sees `new T()` the same + * way it sees `new User()` -- so the AST walk is the only place + * with the scope information. + * + * @param list &$out + * @param array &$reclassifyVariableAt ORIGINAL-source + * byte-offset -> + * alternative type + * for the T_VARIABLE + * that starts there. + */ + private function newAstWalker(array &$out, array &$reclassifyVariableAt): NodeVisitorAbstract + { + $visitor = new class($out, $reclassifyVariableAt, $this) extends NodeVisitorAbstract { + /** + * Stack of in-scope type-param name sets. Each frame is the + * set of names declared on an enclosing ClassLike via + * ATTR_GENERIC_PARAMS. Frames are pushed in enterNode and + * popped in leaveNode. + * + * @var list> + */ + private array $typeParamStack = []; + + /** + * @param list $out + * @param array $reclassifyVariableAt + */ + public function __construct( + private array &$out, + private array &$reclassifyVariableAt, + private AstVisitor $emitter, + ) { + } + + public function enterNode(Node $node) + { + if ($node instanceof ClassLike) { + $params = $node->getAttribute(\XPHP\Transpiler\Monomorphize\XphpSourceParser::ATTR_GENERIC_PARAMS); + if (is_array($params) && $params !== []) { + $frame = []; + foreach ($params as $param) { + if ($param instanceof \XPHP\Transpiler\Monomorphize\TypeParam) { + $frame[$param->name] = true; + } + } + $this->typeParamStack[] = $frame; + } else { + // Push an empty frame anyway so leaveNode's pop + // pairs symmetrically. Empty frame doesn't add + // type-param names but maintains stack depth. + $this->typeParamStack[] = []; + } + if ($node->name !== null) { + $this->emitter->emitAstIdentifier( + $this->out, + $node->name, + self::classLikeType($node), + ); + } + return null; + } + if ($node instanceof ClassMethod) { + $this->emitter->emitAstIdentifier($this->out, $node->name, 'method'); + return null; + } + if ($node instanceof Function_) { + $this->emitter->emitAstIdentifier($this->out, $node->name, 'function'); + return null; + } + if ($node instanceof Name) { + // Reified-T detection: single-segment Name whose text + // matches an in-scope type-param. Covers `new T()`, + // `instanceof T`, the class part of `T::method()` / + // `T::class`, and any other use of T as a class-name + // slot inside a generic body. + if (!$node->isFullyQualified() && count($node->getParts()) === 1) { + $name = $node->getParts()[0]; + if ($this->isInScopeTypeParam($name)) { + $start = $node->getStartFilePos(); + $end = $node->getEndFilePos(); + if ($start >= 0 && $end >= $start) { + $this->emitter->emitAstSpan( + $this->out, + $start, + $end - $start + 1, + 'typeParameter', + ); + } + } + } + return null; + } + if ($node instanceof PropertyItem) { + // PropertyItem->name is a VarLikeIdentifier (no leading `$` + // in the AST but the `$` IS in the source span). Skip + // re-emit; T_VARIABLE in pass 1 already covered it. + return null; + } + if ($node instanceof Param && $node->var instanceof Node\Expr\Variable) { + // Re-classify the param variable from `variable` to + // `parameter`. We don't emit a separate spec here; + // instead we mark the ORIGINAL-source byte offset + // and the token pass picks it up, replacing its + // own `variable` emit with `parameter` at that + // offset. Single spec per source span, half the + // wire size vs the previous "emit both, hope + // later-wins" approach. + $name = $node->var->name; + if (is_string($name)) { + $strippedStart = $node->var->getStartFilePos(); + if ($strippedStart >= 0) { + $origStart = $this->emitter->mapToOriginal($strippedStart); + if ($origStart >= 0) { + $this->reclassifyVariableAt[$origStart] = 'parameter'; + } + } + } + return null; + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof ClassLike && $this->typeParamStack !== []) { + array_pop($this->typeParamStack); + } + return null; + } + + private function isInScopeTypeParam(string $name): bool + { + foreach ($this->typeParamStack as $frame) { + if (isset($frame[$name])) { + return true; + } + } + return false; + } + + private static function classLikeType(ClassLike $node): string + { + if ($node instanceof Interface_) { + return 'interface'; + } + if ($node instanceof Enum_) { + return 'enum'; + } + if ($node instanceof Trait_) { + // No `trait` in LSP standard token types; map to `class`. + return 'class'; + } + return 'class'; + } + }; + return $visitor; + } + + /** + * Emit a spec at the given ORIGINAL-source byte offset. Internal -- + * shared by both passes; the token pass calls directly, the AST pass + * calls {@see emitAstSpan} which translates from stripped to + * original first. + * + * @internal exposed for the anonymous AST visitor; not a public API + * + * @param list $out + */ + public function emit(array &$out, int $originalOffset, int $length, string $type, array $modifiers = []): void + { + if ($length <= 0) { + return; + } + if ($originalOffset < 0 || $originalOffset > strlen($this->source)) { + return; + } + [$line, $startChar] = $this->positionMap->offsetToPosition($originalOffset); + // Length stays in BYTES at this point -- correct for ASCII-only + // identifiers (the vast majority of PHP source). LSP wants + // UTF-16 code units; for ASCII the two are equal. Non-ASCII + // tokens (e.g. UTF-8 strings) are an edge case Slice 4 covers. + $out[] = new TokenSpec( + line: $line, + startChar: $startChar, + length: $length, + type: $type, + modifiers: $modifiers, + ); + } + + /** + * Translate a STRIPPED-source byte offset to the ORIGINAL source. + * + * @internal exposed for the anonymous AST visitor's reclassify map + */ + public function mapToOriginal(int $strippedOffset): int + { + return $this->byteOffsetMap->toOriginal($strippedOffset); + } + + /** + * Emit a spec from a STRIPPED-source byte span. Translates the start + * + end through {@see ByteOffsetMap} before delegating to + * {@see emit}. + * + * @internal exposed for the anonymous AST visitor + * + * @param list $out + */ + public function emitAstSpan(array &$out, int $strippedStart, int $length, string $type, array $modifiers = []): void + { + $origStart = $this->byteOffsetMap->toOriginal($strippedStart); + $origEnd = $this->byteOffsetMap->toOriginal($strippedStart + $length); + if ($origStart < 0 || $origEnd < $origStart) { + return; + } + $this->emit($out, $origStart, $origEnd - $origStart, $type, $modifiers); + } + + /** + * @internal exposed for the anonymous AST visitor + * + * @param list $out + */ + public function emitAstIdentifier(array &$out, Identifier $identifier, string $type): void + { + $start = $identifier->getStartFilePos(); + $end = $identifier->getEndFilePos(); + if ($start < 0 || $end < $start) { + return; + } + $this->emitAstSpan($out, $start, $end - $start + 1, $type); + } + + /** + * @return array + */ + private static function buildTokenTypeMap(): array + { + $map = []; + + // Variables. + $map[T_VARIABLE] = 'variable'; + + // Numbers. + $map[T_LNUMBER] = 'number'; + $map[T_DNUMBER] = 'number'; + + // Strings. Single-quoted strings + the surrounding double-quote + // spans for un-interpolated string content. Interpolation paths + // (T_DOUBLE_QUOTES + T_ENCAPSED_AND_WHITESPACE + inner T_VARIABLE) + // are decomposed by the tokenizer; the variable bits already get + // picked up via T_VARIABLE, and the literal slabs become + // T_ENCAPSED_AND_WHITESPACE which we also classify as string. + $map[T_CONSTANT_ENCAPSED_STRING] = 'string'; + $map[T_ENCAPSED_AND_WHITESPACE] = 'string'; + + // Comments. + $map[T_COMMENT] = 'comment'; + $map[T_DOC_COMMENT] = 'comment'; + + // Keywords. Curated subset -- every PHP reserved word that + // appears in normal code. Magic constants (__CLASS__ etc.) and + // less-common tokens (T_HALT_COMPILER, T_LIST) are not in the + // map; they fall through to no-classification. + $keywordTokens = [ + T_ABSTRACT, + T_AS, + T_BREAK, + T_CALLABLE, + T_CASE, + T_CATCH, + T_CLASS, + T_CLONE, + T_CONST, + T_CONTINUE, + T_DECLARE, + T_DEFAULT, + T_DO, + T_ECHO, + T_ELSE, + T_ELSEIF, + T_EMPTY, + T_ENDDECLARE, + T_ENDFOR, + T_ENDFOREACH, + T_ENDIF, + T_ENDSWITCH, + T_ENDWHILE, + T_ENUM, + T_EXIT, + T_EXTENDS, + T_FINAL, + T_FINALLY, + T_FN, + T_FOR, + T_FOREACH, + T_FUNCTION, + T_GLOBAL, + T_GOTO, + T_IF, + T_IMPLEMENTS, + T_INCLUDE, + T_INCLUDE_ONCE, + T_INSTANCEOF, + T_INSTEADOF, + T_INTERFACE, + T_ISSET, + T_MATCH, + T_NAMESPACE, + T_NEW, + T_OPEN_TAG, + T_OPEN_TAG_WITH_ECHO, + T_PRINT, + T_PRIVATE, + T_PROTECTED, + T_PUBLIC, + T_READONLY, + T_REQUIRE, + T_REQUIRE_ONCE, + T_RETURN, + T_STATIC, + T_SWITCH, + T_THROW, + T_TRAIT, + T_TRY, + T_UNSET, + T_USE, + T_VAR, + T_WHILE, + T_YIELD, + T_YIELD_FROM, + ]; + foreach ($keywordTokens as $id) { + $map[$id] = 'keyword'; + } + + return $map; + } +} diff --git a/src/Handler/SemanticTokens/Encoder.php b/src/Handler/SemanticTokens/Encoder.php new file mode 100644 index 0000000..41d9603 --- /dev/null +++ b/src/Handler/SemanticTokens/Encoder.php @@ -0,0 +1,86 @@ + $specs + * @return list packed token data + */ + public static function encode(array $specs): array + { + if ($specs === []) { + return []; + } + + // Sort by (line, startChar) -- defensive against callers that + // emit tokens in AST-traversal order rather than source order + // (e.g. when a method body is visited before its own signature). + usort($specs, static function (TokenSpec $a, TokenSpec $b): int { + if ($a->line !== $b->line) { + return $a->line <=> $b->line; + } + return $a->startChar <=> $b->startChar; + }); + + $data = []; + $prevLine = 0; + $prevStart = 0; + $firstEmitted = false; + + foreach ($specs as $spec) { + $typeIdx = TokenLegend::typeIndex($spec->type); + if ($typeIdx < 0) { + continue; + } + + $deltaLine = $firstEmitted ? $spec->line - $prevLine : $spec->line; + $deltaStart = (!$firstEmitted || $deltaLine !== 0) + ? $spec->startChar + : $spec->startChar - $prevStart; + + $data[] = $deltaLine; + $data[] = $deltaStart; + $data[] = $spec->length; + $data[] = $typeIdx; + $data[] = TokenLegend::modifierBits($spec->modifiers); + + $prevLine = $spec->line; + $prevStart = $spec->startChar; + $firstEmitted = true; + } + + return $data; + } +} diff --git a/src/Handler/SemanticTokens/TokenLegend.php b/src/Handler/SemanticTokens/TokenLegend.php new file mode 100644 index 0000000..aa05ff4 --- /dev/null +++ b/src/Handler/SemanticTokens/TokenLegend.php @@ -0,0 +1,91 @@ + + */ + public const TOKEN_TYPES = [ + 'namespace', + 'type', + 'class', + 'interface', + 'enum', + 'typeParameter', + 'parameter', + 'variable', + 'property', + 'function', + 'method', + 'keyword', + 'modifier', + 'comment', + 'string', + 'number', + 'operator', + ]; + + /** + * @var list + */ + public const TOKEN_MODIFIERS = [ + 'declaration', + 'definition', + 'readonly', + 'static', + 'deprecated', + 'abstract', + ]; + + /** + * Index of a token type in {@see TOKEN_TYPES}. Returns -1 for an + * unknown type so the caller can hard-fail in tests but the + * server never crashes on an unrecognised classification. + */ + public static function typeIndex(string $tokenType): int + { + $idx = array_search($tokenType, self::TOKEN_TYPES, true); + return $idx === false ? -1 : $idx; + } + + /** + * Encode a list of modifier names as a bitfield over + * {@see TOKEN_MODIFIERS}. Unknown modifiers are silently dropped + * (same fail-soft posture as {@see typeIndex}). + * + * @param list $modifiers + */ + public static function modifierBits(array $modifiers): int + { + $bits = 0; + foreach ($modifiers as $modifier) { + $idx = array_search($modifier, self::TOKEN_MODIFIERS, true); + if ($idx !== false) { + $bits |= 1 << $idx; + } + } + return $bits; + } +} diff --git a/src/Handler/SemanticTokens/TokenSpec.php b/src/Handler/SemanticTokens/TokenSpec.php new file mode 100644 index 0000000..c10f419 --- /dev/null +++ b/src/Handler/SemanticTokens/TokenSpec.php @@ -0,0 +1,43 @@ + $modifiers zero or more entries from {@see TokenLegend::TOKEN_MODIFIERS} + */ + public function __construct( + public readonly int $line, + public readonly int $startChar, + public readonly int $length, + public readonly string $type, + public readonly array $modifiers = [], + ) { + } +} diff --git a/src/Handler/TypeArgPositionDetector.php b/src/Handler/TypeArgPositionDetector.php new file mode 100644 index 0000000..56f2107 --- /dev/null +++ b/src/Handler/TypeArgPositionDetector.php @@ -0,0 +1,168 @@ +` that follows a Name. + * + * Strategy: walk the source backwards from the cursor with a `<>` depth + * counter. Decrement on `>`, increment on `<`. If the depth ever reaches +1 on + * a `<` and the byte immediately before that `<` is an identifier byte, the + * cursor is in a type-arg position relative to that Name. + * + * Single-pass, no string/comment awareness — that's fine because xphp generic + * syntax doesn't appear inside strings or comments (the parser-level scanner + * already handles those for parsing; for completion, false positives in + * strings just suggest classes that the user will ignore). + * + * Limits intentionally accepted: + * - Doesn't bind the surrounding Name to the candidate filter (so we can't + * yet prune by "bounds declared on T of Box"). That requires AST work + * and lands alongside bound-aware completion later. + */ +final readonly class TypeArgPositionDetector +{ + /** + * @return array{prefix: string, containerName: string, slot: int}|null + * null → cursor is not in a type-arg position + * array → cursor IS in a type-arg position. + * `prefix` - substring typed since the last `<` or `,` + * (post-whitespace), used to filter candidates. + * `containerName` - the Name preceding the unmatched `<` (the + * generic class / function whose type-args we + * are inside). Same form as it appeared in + * source -- may be a short name (`Box`) or a + * qualified one (`App\Box`). + * `slot` - 0-based index of the type-arg slot the + * cursor sits in (0 for `Box<|`, 1 for + * `Pair $length) { + return null; + } + // Pull the partial identifier under the cursor out — that's the prefix + // the user has already typed since the last `<` or `,`. Identifier + // bytes include backslashes (so `App\Pla|` is a single FQN-style + // prefix). + $prefixStart = $offset; + while ($prefixStart > 0 && self::isIdentifierByte($source[$prefixStart - 1])) { + $prefixStart--; + } + $prefix = substr($source, $prefixStart, $offset - $prefixStart); + + // Walk back from the prefix start with a `<>` depth counter. We're + // looking for the FIRST `<` at depth 0 (i.e. an unmatched opener). + // Count commas seen at depth 0 along the way -- that's the slot + // index for the cursor's argument position. + $depth = 0; + $slot = 0; + $i = $prefixStart - 1; + while ($i >= 0) { + $c = $source[$i]; + if ($c === '>') { + $depth++; + $i--; + continue; + } + if ($c === ',' && $depth === 0) { + $slot++; + $i--; + continue; + } + if ($c === '<') { + if ($depth === 0) { + // Found the unmatched opener. The byte before it must be + // an identifier byte (the generic Name) — otherwise it's + // a less-than operator. + $j = $i - 1; + if ($j < 0 || !self::isIdentifierByte($source[$j])) { + return null; + } + // Scan the container Name backwards: identifier bytes, + // possibly through `\` separators. + $nameEnd = $i; // exclusive + $nameStart = $j; + while ($nameStart > 0 && self::isIdentifierByte($source[$nameStart - 1])) { + $nameStart--; + } + $containerName = substr($source, $nameStart, $nameEnd - $nameStart); + return [ + 'prefix' => $prefix, + 'containerName' => $containerName, + 'slot' => $slot, + ]; + } + $depth--; + $i--; + continue; + } + if (self::isInterArgByte($c) || self::isIdentifierByte($c)) { + $i--; + continue; + } + // Anything else (`(`, `;`, `=`, `{`, …) breaks the type-arg + // context — we're not inside a `<…>` clause. + return null; + } + return null; + } + + /** + * Full identifier under the cursor, only when the cursor is inside a + * generic `<…>` clause AND on (or adjacent to) identifier bytes. + * + * Built on top of [[detect]]: that method returns the prefix to the LEFT + * of the cursor; here we additionally scan FORWARD from the cursor and + * concatenate the trailing identifier bytes, yielding the full identifier + * span the user is actually pointing at. + * + * Returns null when: + * - the cursor isn't in a type-arg position (whatever [[detect]] + * decides), or + * - there's no identifier byte at the cursor and no prefix to the left + * (e.g. cursor on whitespace inside `<…>`). + * + * Used by the definition handler for Ctrl+click on a type-arg class name + * like `User` in `identity(...)`. The completion handler uses the + * `detect`-only prefix because completion needs the typed-so-far stem, + * not the full identifier including the suffix the user hasn't typed. + */ + public static function identifierAt(string $source, int $offset): ?string + { + $context = self::detect($source, $offset); + if ($context === null) { + return null; + } + + // Walk forward from the cursor capturing the trailing identifier + // bytes -- the part of the name to the RIGHT of the cursor that the + // user has already typed. + $length = strlen($source); + $end = $offset; + while ($end < $length && self::isIdentifierByte($source[$end])) { + $end++; + } + $suffix = substr($source, $offset, $end - $offset); + + $full = $context['prefix'] . $suffix; + return $full === '' ? null : $full; + } + + private static function isIdentifierByte(string $byte): bool + { + return ctype_alnum($byte) || $byte === '_' || $byte === '\\'; + } + + private static function isInterArgByte(string $byte): bool + { + // Whitespace + commas separate args; both are legal inside `<…>`. + return $byte === ' ' || $byte === "\t" || $byte === "\n" || $byte === "\r" || $byte === ','; + } +} diff --git a/src/Handler/WorkspaceSymbols.php b/src/Handler/WorkspaceSymbols.php new file mode 100644 index 0000000..6695bab --- /dev/null +++ b/src/Handler/WorkspaceSymbols.php @@ -0,0 +1,266 @@ +` type-arg positions, and by the definition handler to resolve + * Ctrl+click on a type-arg short name. + * + * Parses via the shared `ParsedDocumentCache` so an unchanged workspace + * doesn't re-parse on every completion keystroke (the original MVP did, + * which is O(N) parses per `<`). + */ +final readonly class WorkspaceSymbols +{ + public function __construct( + private PhpactorWorkspace $workspace, + private ParsedDocumentCache $cache, + ) { + } + + /** + * @return list Fully-qualified ClassLike names across the open workspace. + */ + public function allClassFqns(): array + { + $fqns = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectFqns($result->ast) as $fqn) { + $fqns[$fqn] = true; + } + } + return array_keys($fqns); + } + + /** + * @return list Fully-qualified top-level function names across + * the open workspace. Methods and closures don't + * appear here -- only `function name() {...}` + * declarations at namespace level. + */ + public function allFunctionFqns(): array + { + $fqns = []; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectFunctionFqns($result->ast) as $fqn) { + $fqns[$fqn] = true; + } + } + return array_keys($fqns); + } + + /** + * Find the declaration site of a ClassLike (class, interface, trait) + * across the open workspace and return its source location. Matching is + * by **short name** -- the last `\`-segment of each candidate's FQN. + * First hit wins; cross-namespace collisions on the same short name + * resolve to the first document the workspace iterator hands us, which + * is good-enough for an MVP and trackable as a follow-up. + * + * Used by the definition handler for the type-arg Ctrl+click case: + * `identity(...)` -> click `User` -> resolve to the `class User` + * declaration whatever namespace it lives in. + * + * Returns null when no open document defines a matching ClassLike. Note + * that we only see open documents; an unopened on-disk declaration won't + * resolve until the user opens that file, same constraint + * `XphpDefinitionHandler` already documents. + */ + public function findClassByName(string $shortName): ?Location + { + if ($shortName === '') { + return null; + } + // Phase 3 polish: when multiple open documents define a class + // with the same short name (typical in repos with parallel + // fixture trees -- `tests/Fixtures/User.xphp` shadowing the real + // `src/Models/User.xphp`), prefer the non-fixture / non-vendor + // candidate. Rank-walk every match, lowest penalty wins; first + // hit among equal-penalty matches preserves prior order. + /** @var array{location: Location, penalty: int}|null $best */ + $best = null; + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + $found = self::findClassDeclaration($result->ast, $shortName); + if ($found === null) { + continue; + } + $positionMap = new PositionMap($item->text); + [$startLine, $startChar] = $positionMap->offsetToPosition($found['startOffset']); + [$endLine, $endChar] = $positionMap->offsetToPosition($found['endOffset']); + $location = new Location( + $uri, + new Range( + new Position($startLine, $startChar), + new Position($endLine, $endChar), + ), + ); + $penalty = self::pathPenalty($uri); + if ($best === null || $penalty < $best['penalty']) { + $best = ['location' => $location, 'penalty' => $penalty]; + } + } + return $best === null ? null : $best['location']; + } + + /** + * Score a URI's "is this canonical workspace code" likelihood. + * Lower is better. Fixture / test / vendor paths get a positive + * penalty so the canonical implementation outranks them when the + * short name collides. Match is case-insensitive on the path + * segment to catch both `tests/` and `Tests/` etc. + */ + private static function pathPenalty(string $uri): int + { + $needle = strtolower($uri); + $penalty = 0; + foreach (['/vendor/', '/tests/', '/test/', '/fixtures/', '/fixture/', '/stubs/', '/stub/'] as $segment) { + if (str_contains($needle, $segment)) { + $penalty += 10; + } + } + return $penalty; + } + + /** + * @param list $ast + * @return list + */ + private static function collectFqns(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + } + if ($node instanceof ClassLike && $node->name !== null) { + $short = $node->name->toString(); + $this->fqns[] = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * @param list $ast + * @return list + */ + private static function collectFunctionFqns(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $fqns = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + // Only top-level Function_ nodes count; methods and closures + // aren't surfaced as "functions" in completion candidates. + if ($node instanceof Function_) { + $short = $node->name->toString(); + $this->fqns[] = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->fqns; + } + + /** + * @param list $ast + * @return array{startOffset: int, endOffset: int}|null + */ + private static function findClassDeclaration(array $ast, string $shortName): ?array + { + $visitor = new class($shortName) extends NodeVisitorAbstract { + public ?int $startOffset = null; + public ?int $endOffset = null; + + public function __construct(private readonly string $shortName) + { + } + + public function enterNode(Node $node): null + { + if ($this->startOffset !== null) { + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + if ($node->name->toString() !== $this->shortName) { + return null; + } + // Range targets the class NAME token -- same convention as + // `XphpDefinitionHandler::findTemplateInAst` so navigation + // jumps land on the identifier, not the modifier-laden + // opening line. + $this->startOffset = $node->name->getStartFilePos(); + $this->endOffset = $node->name->getEndFilePos() + 1; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + if ($visitor->startOffset === null || $visitor->endOffset === null) { + return null; + } + return ['startOffset' => $visitor->startOffset, 'endOffset' => $visitor->endOffset]; + } +} diff --git a/src/Handler/XphpCallHierarchyHandler.php b/src/Handler/XphpCallHierarchyHandler.php new file mode 100644 index 0000000..4662d1f --- /dev/null +++ b/src/Handler/XphpCallHierarchyHandler.php @@ -0,0 +1,732 @@ + 'prepare', + 'callHierarchy/incomingCalls' => 'incomingCalls', + 'callHierarchy/outgoingCalls' => 'outgoingCalls', + ]; + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->callHierarchyProvider = true; + } + + /** + * `prepareCallHierarchy` params are `{textDocument, position}`, + * matching `TextDocumentPositionParams`. Typed so phpactor's + * `LanguageSeverProtocolParamsResolver` deserializes the JSON + * into a real Params object -- the framework's + * PassThroughArgumentResolver splats untyped `array $params` + * into positional args and the handler would silently receive + * only the textDocument value, never the full params. + * + * @return Promise> + */ + public function prepare(TextDocumentPositionParams $params): Promise + { + $uri = $params->textDocument->uri; + if (!$this->workspace->has($uri)) { + return new Success([]); + } + $item = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null || $result->ast === []) { + return new Success([]); + } + $positionMap = new PositionMap($item->text); + $offset = $positionMap->positionToOffset( + $params->position->line, + $params->position->character, + ); + + $located = self::findEnclosingCallable($result->ast, $offset); + if ($located === null) { + return new Success([]); + } + [$classFqn, $node, $name, $namespace] = $located; + return new Success([ + self::buildItem($uri, $classFqn, $node, $name, $positionMap, $namespace), + ]); + } + + /** + * `callHierarchy/incomingCalls` params are `{item}`. The + * framework splats the params object into positional args, so + * the first positional argument is the inner `item` dict -- + * NOT a wrapper. Signature reflects that splat order. + * + * @param array $item the inner CallHierarchyItem dict + * @return Promise> + */ + public function incomingCalls(array $item): Promise + { + $targetName = $item['data']['name'] ?? null; + if (!is_string($targetName) || $targetName === '') { + return new Success([]); + } + $hits = $this->collectCallSites($targetName); + $grouped = []; + foreach ($hits as $hit) { + $key = sprintf('%s|%s|%s', $hit['uri'], $hit['enclosingFqn'] ?? '', $hit['enclosingName']); + if (!isset($grouped[$key])) { + $grouped[$key] = [ + 'item' => $hit['enclosingItem'], + 'ranges' => [], + ]; + } + $grouped[$key]['ranges'][] = $hit['range']; + } + $calls = []; + foreach ($grouped as $group) { + $calls[] = new CallHierarchyIncomingCall($group['item'], $group['ranges']); + } + return new Success($calls); + } + + /** + * `callHierarchy/outgoingCalls` -- same splat shape as incomingCalls. + * + * @param array $item the inner CallHierarchyItem dict + * @return Promise> + */ + public function outgoingCalls(array $item): Promise + { + $uri = $item['uri'] ?? null; + if (!is_string($uri) || !$this->workspace->has($uri)) { + return new Success([]); + } + $classFqn = $item['data']['classFqn'] ?? ''; + $methodName = $item['data']['name'] ?? ''; + if (!is_string($classFqn) || !is_string($methodName) || $methodName === '') { + return new Success([]); + } + $document = $this->workspace->get($uri); + $result = $this->cache->getOrParse($uri, $document->version, $document->text); + if ($result->ast === null || $result->ast === []) { + return new Success([]); + } + // Top-level scope sentinel (`__topLevel`) -- walk the file's + // script-mode statements instead of looking up a method body. + // See `buildTopLevelItem` for where this sentinel is set. + if ($methodName === '__topLevel') { + $body = self::collectTopLevelStmts($result->ast); + } else { + $body = self::findMethodOrFunctionBody($result->ast, $classFqn, $methodName); + } + if ($body === null || $body === []) { + return new Success([]); + } + $positionMap = new PositionMap($document->text); + $calls = self::collectOutgoingFromBody($body, $uri, $positionMap); + return new Success($calls); + } + + /** + * Collect every statement that's NOT inside a Function_ or + * ClassLike across the whole AST (including stmts inside any + * Namespace_ block). Used by outgoingCalls when the caller + * item is the synthetic top-level scope. + * + * @param list $ast + * @return list + */ + private static function collectTopLevelStmts(array $ast): array + { + $out = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + foreach ($stmt->stmts as $inner) { + if ($inner instanceof ClassLike || $inner instanceof Function_) { + continue; + } + $out[] = $inner; + } + continue; + } + if ($stmt instanceof ClassLike || $stmt instanceof Function_) { + continue; + } + $out[] = $stmt; + } + return $out; + } + + /** + * Scan every open document AND every filesystem-indexed + * .xphp / .php path for call sites whose call-target name + * matches. Returns an array of {uri, range, enclosingFqn, + * enclosingName, enclosingItem}. + * + * Filesystem walk mirrors `ReferenceFinder::collectReferences` + * -- in prod the user typically has only the *callee* file + * open, so without the FS pass the Callers view would always + * be empty for callers that live in closed files. + * + * @return list + */ + private function collectCallSites(string $targetName): array + { + $hits = []; + $seenUris = []; + foreach ($this->workspace as $uri => $document) { + $uriStr = (string) $uri; + $seenUris[$uriStr] = true; + $result = $this->cache->getOrParse($uriStr, $document->version, $document->text); + if ($result->ast === null || $result->ast === []) { + continue; + } + $positionMap = new PositionMap($document->text); + $localHits = self::collectCallSitesInAst($result->ast, $targetName, $uriStr, $positionMap); + foreach ($localHits as $hit) { + $hits[] = $hit; + } + } + foreach ($this->fqnIndex->indexedFilesystemPaths() as $path) { + $uri = 'file://' . $path; + if (isset($seenUris[$uri])) { + continue; + } + try { + $source = file_get_contents($path); + } catch (Throwable) { + continue; + } + if ($source === false) { + continue; + } + $ast = $this->parser->parseTolerant($source); + if ($ast === null || $ast === []) { + continue; + } + $positionMap = new PositionMap($source); + $localHits = self::collectCallSitesInAst($ast, $targetName, $uri, $positionMap); + foreach ($localHits as $hit) { + $hits[] = $hit; + } + } + return $hits; + } + + /** + * @param list $ast + * @return list + */ + private static function collectCallSitesInAst(array $ast, string $targetName, string $uri, PositionMap $positionMap): array + { + $hits = []; + self::walkForCallSites($ast, '', null, $targetName, $uri, $positionMap, $hits); + return $hits; + } + + /** + * @param list|array $stmts + * @param array $hits + */ + private static function walkForCallSites( + array $stmts, + string $namespace, + ?ClassLike $enclosingClass, + string $targetName, + string $uri, + PositionMap $positionMap, + array &$hits, + ): void { + // Statements at the current scope level that don't belong to + // a Function_, ClassMethod, or nested Namespace_ are *top- + // level script code* (PHP allows arbitrary statements at file + // root and inside `namespace … { … }` blocks). Call sites + // there have no enclosing callable -- collect them so a + // synthetic "top-level scope" item can carry them in the + // CallHierarchy result. + $topLevelStmts = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof Namespace_) { + $nextNs = $stmt->name === null ? '' : $stmt->name->toString(); + self::walkForCallSites($stmt->stmts, $nextNs, $enclosingClass, $targetName, $uri, $positionMap, $hits); + continue; + } + if ($stmt instanceof ClassLike) { + foreach ($stmt->stmts as $member) { + if ($member instanceof ClassMethod) { + self::scanCallableBody( + $member, + $namespace, + $stmt, + $targetName, + $uri, + $positionMap, + $hits, + ); + } + } + continue; + } + if ($stmt instanceof Function_) { + self::scanCallableBody($stmt, $namespace, null, $targetName, $uri, $positionMap, $hits); + continue; + } + $topLevelStmts[] = $stmt; + } + if ($topLevelStmts !== []) { + self::scanTopLevelBody($topLevelStmts, $targetName, $uri, $positionMap, $hits); + } + } + + /** + * @param list $stmts top-level (non-callable, non-class) statements + * @param array $hits + */ + private static function scanTopLevelBody( + array $stmts, + string $targetName, + string $uri, + PositionMap $positionMap, + array &$hits, + ): void { + $callRanges = []; + self::walkForMatchingCalls($stmts, $targetName, $positionMap, $callRanges); + if ($callRanges === []) { + return; + } + $enclosingItem = self::buildTopLevelItem($uri, $stmts, $positionMap); + foreach ($callRanges as $range) { + $hits[] = [ + 'uri' => $uri, + 'range' => $range, + 'enclosingFqn' => null, + 'enclosingName' => $enclosingItem->name, + 'enclosingItem' => $enclosingItem, + ]; + } + } + + /** + * Synthesize a CallHierarchyItem representing the top-level + * scope (script-mode region) of a file. Used as the + * `from` of incoming-call hits whose call site sits outside + * any function/method, and as the receiver of + * outgoingCalls when the user navigates into it from a + * Callers view entry. The `data.name` sentinel is + * `__topLevel` (a name no userland symbol can collide + * with -- PHP reserves `__`-prefixed names). + * + * @param list $stmts the contiguous top-level statements + */ + private static function buildTopLevelItem( + string $uri, + array $stmts, + PositionMap $positionMap, + ): CallHierarchyItem { + $path = parse_url($uri, PHP_URL_PATH); + $name = $path !== null && $path !== false ? basename($path) : basename($uri); + if ($name === '') { + $name = '