From cb56275fef8438bb1669a2c93f9983e410ba774b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 18 May 2026 22:52:17 +0600 Subject: [PATCH 1/3] fix_doc --- .github/ISSUE_TEMPLATE/bug_report.yml | 70 +++++++++ .github/ISSUE_TEMPLATE/ci_failure.yml | 48 ++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/docs_improvement.yml | 34 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 44 ++++++ .github/ISSUE_TEMPLATE/question.yml | 40 +++++ .github/ISSUE_TEMPLATE/regression_report.yml | 51 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 33 ++++ CODE_OF_CONDUCT.md | 153 ++++--------------- CONTRIBUTING.md | 68 +++++++++ README.md | 65 +++----- SECURITY.md | 62 ++++---- src/DI/Container.php | 2 +- src/DI/Support/ReflectionResource.php | 2 +- 14 files changed, 468 insertions(+), 205 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/ci_failure.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs_improvement.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 .github/ISSUE_TEMPLATE/regression_report.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8a5f881 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Report a reproducible problem +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please include enough detail to reproduce it. + - type: textarea + id: summary + attributes: + label: Summary + description: What is wrong? + placeholder: Clear and short description of the bug. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Share exact commands, config, and steps. + placeholder: | + 1. Run `composer ic:tests` + 2. ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What happened instead? Include full error output if possible. + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: "e.g. 8.3.8" + validations: + required: true + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: "e.g. 2.9.2" + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: OS, CI provider, shell, and anything else relevant. + placeholder: Ubuntu 24.04, GitHub Actions, bash... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Links, screenshots, logs, or related issues. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml new file mode 100644 index 0000000..3dcbac9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -0,0 +1,48 @@ +name: CI failure +description: Report a reproducible CI or workflow failure +title: "[CI]: " +labels: + - ci +body: + - type: markdown + attributes: + value: | + Use this form when CI fails unexpectedly and can be reproduced. + - type: input + id: workflow + attributes: + label: Workflow/job name + placeholder: security-standards / phpforge + validations: + required: true + - type: input + id: run_url + attributes: + label: Failing run URL + placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... + validations: + required: true + - type: textarea + id: command + attributes: + label: Failing command + description: Exact command or step that failed. + placeholder: composer ic:ci + validations: + required: true + - type: textarea + id: logs + attributes: + label: Error output + description: Paste the relevant error section. + render: shell + validations: + required: true + - type: textarea + id: local_check + attributes: + label: Local reproduction + description: Can you reproduce locally? If yes, include steps. + placeholder: Yes/No + details + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml new file mode 100644 index 0000000..80b9607 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -0,0 +1,34 @@ +name: Docs improvement +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: + - documentation +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: File path or URL. + placeholder: README.md section "Quick Start" + validations: + required: true + - type: textarea + id: issue + attributes: + label: What is unclear or incorrect? + placeholder: This section says... + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Propose revised wording, structure, or examples. + placeholder: It would be clearer if... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related links, screenshots, or prior discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..cc29614 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for the idea. Please describe the use case first, then the proposed solution. + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What limitation are you hitting? + placeholder: I need to... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should happen? + placeholder: Add a command/config/workflow option that... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workaround or alternative approach you evaluated. + - type: textarea + id: impact + attributes: + label: Expected impact + description: Who benefits and what changes for users/CI? + placeholder: This would improve... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related issues, links, examples, or prior art. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..2ca776f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,40 @@ +name: Question +description: Ask a usage or integration question +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Use this form for usage questions. For confirmed defects, use the bug report form. + - type: textarea + id: context + attributes: + label: What are you trying to do? + description: Describe your goal and expected outcome. + placeholder: I want to... + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include commands, config snippets, or links you already checked. + placeholder: I tried... + validations: + required: true + - type: textarea + id: output + attributes: + label: Current output or behavior + description: Include relevant command output, logs, or errors. + render: shell + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml new file mode 100644 index 0000000..36392bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/regression_report.yml @@ -0,0 +1,51 @@ +name: Regression report +description: Report behavior that previously worked but now fails +title: "[Regression]: " +labels: + - regression + - bug +body: + - type: textarea + id: summary + attributes: + label: Regression summary + placeholder: This worked before, but now... + validations: + required: true + - type: input + id: last_known_good + attributes: + label: Last known working version/commit + placeholder: v1.2.3 or abc1234 + validations: + required: true + - type: input + id: first_bad + attributes: + label: First broken version/commit + placeholder: v1.2.4 or def5678 + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected_actual + attributes: + label: Expected vs actual behavior + placeholder: Expected ..., but got ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..59ae734 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## Summary + +Describe what changed and why. + +## Related Issues + +Link issues with `Closes #...` or `Relates #...`. + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor +- [ ] Documentation update +- [ ] CI or tooling update +- [ ] Other (describe in summary) + +## Validation + +List the commands you ran and their result. + +```bash +composer ic:tests +``` + +If full suite was not run, explain why and list focused checks. + +## Checklist + +- [ ] I followed `CONTRIBUTING.md`. +- [ ] I added or updated tests for behavior changes. +- [ ] I updated docs/config/examples when needed. +- [ ] I confirmed no security-sensitive data is exposed. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 18f6d18..9c2638f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,143 +1,50 @@ -# Contributor Covenant Code of Conduct +# Code of Conduct -## Our Pledge +## Our Commitment -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of age, body size, disability, ethnicity, +gender identity and expression, level of experience, nationality, personal +appearance, race, religion or sexual identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +## Expected Behavior -## Our Standards +Examples of behavior that contributes to a positive environment: -Examples of behavior that contributes to a positive environment for our -community include: +- Be respectful and constructive. +- Assume good intent and ask clarifying questions. +- Give and receive feedback professionally. +- Focus on what is best for the community and project. -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community +## Unacceptable Behavior Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- Harassment, discrimination or personal attacks. +- Trolling, insulting or derogatory comments. +- Publishing private information without consent. +- Any conduct that is inappropriate in a professional setting. ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for -moderation decisions when appropriate. +Project maintainers are responsible for clarifying and enforcing this code of +conduct. They may remove, edit or reject comments, commits, code, issues, and +other contributions that violate this policy. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official email address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at: -`infocyph@gmail.com` - -When reporting, include: - -- A description of the incident -- Relevant links, screenshots, or logs -- Date/time and context -- Any immediate safety concerns - -All complaints will be reviewed and investigated promptly and fairly. We will -acknowledge reports as soon as possible and keep reporters informed of high-level -status where appropriate. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. +This code of conduct applies in all project spaces, including: -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. +- Issue trackers +- Pull requests +- Discussions and chat related to the project +- Any public or private communication where someone represents the project -### 2. Warning +## Reporting -**Community Impact**: A violation through a single incident or series -of actions. +To report unacceptable behavior, contact project maintainers privately. -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Appeals - -If you believe an enforcement decision was made in error, you may appeal by -replying to the enforcement communication or by emailing `infocyph@gmail.com` -with additional context. Appeals will be reviewed by maintainers not directly -involved in the original decision whenever possible. - -## Attribution - -This Code of Conduct is adapted from the Contributor Covenant, version 2.1, -available at: -https://www.contributor-covenant.org/version/2/1/code_of_conduct.html - -Community Impact Guidelines were inspired by Mozilla's code of conduct -enforcement ladder: -https://github.com/mozilla/diversity +## Enforcement -For answers to common questions about this code of conduct, see: -https://www.contributor-covenant.org/faq +Maintainers may take any action they deem appropriate, including warnings, +temporary bans or permanent bans from community participation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9950065 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Thanks for contributing. + +## Before You Start + +- Review the project code of conduct. +- For security issues, use private reporting and avoid opening a public issue. +- Check existing issues and pull requests first to avoid duplicates. + +## Local Setup + +Requirements: + +- See `README.md` for current PHP and Composer requirements. + +Install dependencies: + +```bash +composer install +``` + +## Development Workflow + +Typical contributor workflow: + +1. Create a branch from `main`. +2. Make focused changes. +3. Run quality checks locally. +4. Open a pull request with context and verification notes. + +Recommended checks: + +```bash +composer ic:tests +``` + +Useful targeted commands: + +```bash +composer ic:test:syntax +composer ic:test:code +composer ic:test:lint +composer ic:test:sniff +composer ic:test:static +composer ic:test:security +composer ic:test:architecture +``` + +Auto-fix and processing helpers: + +```bash +composer ic:process +``` + +## Pull Request Guidelines + +- Keep pull requests scoped to one logical change. +- Include why the change is needed and what behavior changed. +- Add or update tests when behavior changes. +- Update docs when command behavior, config, or workflow behavior changes. +- Ensure CI is green before requesting review. + +## Reporting Bugs and Requesting Features + +- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. +- Include reproducible steps, expected behavior, and actual behavior. +- Share environment details (PHP version, OS, Composer version). diff --git a/README.md b/README.md index 4f8dcf7..67bf7da 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,7 @@ `InterMix` is a modern, lightweight PHP toolkit for developers who value class-oriented design, clean architecture, and fast execution. It combines dependency injection, serialization, macro-style extensibility, and helper utilities with minimal config and maximum control. ---- - -## 🚀 Key Features +## Key Features - **Dependency Injection (DI)** — PSR-11 compliant container with: - attribute-based injection @@ -26,9 +24,7 @@ - **MacroMix** — Dynamically extend objects or classes with macros - **Global Utilities** — Like `pipe()`, `retry()`, `measure()` and more ---- - -## 📦 Installation +## Installation ```bash composer require infocyph/intermix @@ -41,11 +37,9 @@ Supported PHP versions: | 2.x.x and above | 8.3 or newer | | 1.x.x | 8.0-8.2 compatible | ---- - -## ⚡ Quick Examples +## Quick Examples -### 🧱 Dependency Injection +### Dependency Injection ```php use function Infocyph\InterMix\container; @@ -77,12 +71,9 @@ foreach ($c->findByTag('service') as $svc) { } ``` -See full container guide at: -📖 [https://docs.infocyph.com/projects/intermix/di/overview.html](https://docs.infocyph.com/projects/intermix/di/overview.html) +See full container guide at: [https://docs.infocyph.com/projects/intermix/di/overview.html](https://docs.infocyph.com/projects/intermix/di/overview.html) ---- - -### 🧬 Dynamic Macros +### Dynamic Macros ```php MacroTest::mix(new class { @@ -94,9 +85,7 @@ MacroTest::mix(new class { echo (new MacroTest)->hello('Ali'); // Hey, Ali! ``` ---- - -### 🧠 Definition Cache (Injectable) +### Definition Cache (Injectable) ```php use Psr\Cache\CacheItemPoolInterface; @@ -105,44 +94,28 @@ $pool = /* any PSR-6 pool, e.g. from infocyph/cachelayer */; $c->definitions()->enableDefinitionCache($pool); ``` ---- - -## 📚 Documentation - -Full documentation available at: - -🔗 [https://docs.infocyph.com/projects/intermix/](https://docs.infocyph.com/projects/intermix/) - -Includes: - -* ✅ Getting Started & Quickstart -* 📦 DI Container Guide (bindings, scopes, attributes, lifetimes) -* 🧩 Modules: DI, Serializer, Remix, Fence, MacroMix -* 🧪 Testing & Caching Tips -* 📘 PDF/ePub formats - ---- - -## ✅ Testing +## Testing ```bash composer install composer test ``` ---- - -## 🤝 Contributing -Got ideas or improvements? Join us! +## Security -📂 [Open issues](https://github.com/infocyph/InterMix/issues) -📬 Submit a PR — we welcome quality contributions +Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. --- -## 🛡 License +
+ Made with ❤️ for the PHP community
+ MIT Licensed
+ Documentation • + Security • + Code of Conduct • + Contributing • + Report | Request | Suggest +
-MIT Licensed — use it freely, modify it openly. -🔗 [opensource.org/licenses/MIT](https://opensource.org/licenses/MIT) diff --git a/SECURITY.md b/SECURITY.md index 5a9baba..37a355e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,55 +1,49 @@ # Security Policy -Thank you for helping keep InterMix and its users safe. - ## Supported Versions -Security fixes are provided for the current major release line. - -| Version | Supported | -|---------| --- | -| `7.x` | ✅ | -| `< 7.0` | ❌ | +The project currently supports security updates for the latest release. ## Reporting a Vulnerability -Please report vulnerabilities privately. Do not open a public issue. - -Preferred channels: +Please report vulnerabilities privately. -1. GitHub private vulnerability reporting: - `Security` -> `Advisories` -> `Report a vulnerability` -2. Email fallback: `infocyph@gmail.com` +1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). +2. If private reporting is unavailable, contact maintainers through a private channel. +3. Do not open a public issue for security vulnerabilities. Please include: - Affected package version(s) - PHP version and runtime environment -- Reproduction steps or proof-of-concept -- Impact assessment (confidentiality, integrity, availability) -- Known mitigations or workarounds - -## Response Targets - -- Initial acknowledgment: within 3 business days -- Triage decision: within 7 business days -- Remediation timeline: severity and exploitability dependent +- Reproduction steps or proof of concept +- Impact assessment (confidentiality/integrity/availability) +- Any known workaround -If a report is accepted, we will prepare a patched release and publish an advisory. Reporter credit will be included unless you request to remain anonymous. +## Response Process -## Coordinated Disclosure +- Initial acknowledgment: best effort, typically within a few days +- Triage: best effort, based on maintainer availability +- Fix and release timeline depends on severity and exploitability -Please keep details private until a fix is released. After remediation, advisory details and release notes will be published. CVE assignment may be requested when appropriate. +If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. -## Scope +## Protected by PHPForge -In scope: +This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. -- Vulnerabilities in maintained code under `src/` -- Security-impacting dependency risks in direct dependencies +PHPForge helps keep the project reliable by running checks for: -Out of scope: +- Code style and standards +- Tests and syntax validation +- Static analysis and type safety +- Security and taint analysis +- Dependency vulnerability audit +- Architecture boundary validation +- Duplicate-code detection +- API snapshot and comment-policy checks +- Refactor safety checks +- Benchmark and release-readiness checks +- Git hooks and CI workflow protection -- Issues affecting unsupported versions only -- Local-only misconfiguration without a library defect -- Non-security bugs or feature requests +These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. diff --git a/src/DI/Container.php b/src/DI/Container.php index bfae8c7..4be2e00 100644 --- a/src/DI/Container.php +++ b/src/DI/Container.php @@ -425,7 +425,7 @@ public function options(): OptionsManager * - callable object (e.g. closure, invokable class) * - array of class and method name * - * @param string|array{0:string,1:string}|Closure|callable $spec The callable string/array to parse. + * @param string|array|Closure|callable $spec The callable string/array to parse. * @return array{kind:'closure',closure:callable} * |array{kind:'class',class:string} * |array{kind:'method',class:string,method:string} diff --git a/src/DI/Support/ReflectionResource.php b/src/DI/Support/ReflectionResource.php index 85711e7..c7ab5ac 100644 --- a/src/DI/Support/ReflectionResource.php +++ b/src/DI/Support/ReflectionResource.php @@ -46,7 +46,7 @@ public static function clearCache(): void } /** - * @param callable|array{0: object|string, 1: string}|string|object $callable + * @param callable|array|string|object $callable * * @throws InvalidArgumentException|ReflectionException */ From faab5ac249b0249270e59341dfbff6437655ae98 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 20 May 2026 17:09:37 +0600 Subject: [PATCH 2/3] updated for better refinement --- .github/workflows/security-standards.yml | 13 +- README.md | 35 +- composer.json | 15 +- docs/di/cheat_sheet.rst | 2 +- docs/di/quickstart.rst | 5 +- docs/functions.rst | 21 +- docs/remix/helpers.rst | 10 +- src/DI/Container.php | 381 +++++++++++++++- src/DI/Invoker.php | 52 ++- src/DI/Managers/DefinitionManager.php | 11 +- src/DI/Managers/InvocationManager.php | 43 +- src/DI/Resolver/ClassResolver.php | 30 +- .../ResolvesAssociativeParameters.php | 8 + src/DI/Resolver/DefinitionResolver.php | 54 ++- src/DI/Resolver/ParameterResolver.php | 150 ++----- src/DI/Resolver/Repository.php | 163 ++++++- src/DI/Support/CompiledResolverGenerator.php | 137 ++++++ src/DI/Support/ContextualBindingBuilder.php | 41 ++ src/DI/Support/DebugTracer.php | 10 + src/DI/Support/PendingFactoryBinding.php | 71 +++ src/DI/Support/ReflectionResource.php | 100 ++++- src/DI/Support/TaggedPipeline.php | 54 +++ src/Remix/MacroMix.php | 16 +- src/Serializer/SignedValueSerializer.php | 34 ++ src/Serializer/ValueSerializer.php | 10 + src/functions.php | 10 +- tests/Container/AdditionalContainerTest.php | 21 + tests/Container/AdditionalTest.php | 408 ++++++++++++++++++ tests/Container/InvokerTest.php | 38 ++ tests/Fixture/MethodAttrResolver.php | 2 +- tests/Pest.php | 6 + 31 files changed, 1714 insertions(+), 237 deletions(-) create mode 100644 src/DI/Support/CompiledResolverGenerator.php create mode 100644 src/DI/Support/ContextualBindingBuilder.php create mode 100644 src/DI/Support/PendingFactoryBinding.php create mode 100644 src/DI/Support/TaggedPipeline.php create mode 100644 src/Serializer/SignedValueSerializer.php create mode 100644 tests/Pest.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 68e846a..df17b31 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -19,11 +19,22 @@ jobs: php_versions: '["8.3","8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' php_extensions: "mbstring" - coverage: "xdebug" composer_flags: "" phpstan_memory_limit: "1G" psalm_threads: "1" run_analysis: true run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" artifact_retention_days: 61 diff --git a/README.md b/README.md index 67bf7da..be00c64 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ `InterMix` is a modern, lightweight PHP toolkit for developers who value class-oriented design, clean architecture, and fast execution. It combines dependency injection, serialization, macro-style extensibility, and helper utilities with minimal config and maximum control. +> Global helper functions are optional in `v2`: core APIs are namespaced and helper loading is opt-in. + ## Key Features - **Dependency Injection (DI)** — PSR-11 compliant container with: @@ -30,6 +32,12 @@ composer require infocyph/intermix ``` +Optional global helpers: + +```php +require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php'; +``` + Supported PHP versions: | InterMix Version | PHP Version | @@ -91,7 +99,30 @@ echo (new MacroTest)->hello('Ali'); // Hey, Ali! use Psr\Cache\CacheItemPoolInterface; $pool = /* any PSR-6 pool, e.g. from infocyph/cachelayer */; -$c->definitions()->enableDefinitionCache($pool); +$c->definitions()->enableDefinitionCache($pool, cacheRuntimeObjects: false); +``` + +### Compiled Resolvers + +```php +$path = __DIR__ . '/var/intermix.compiled.php'; + +// build-time: generate compiled resolver map +$c->compileTo($path); + +// runtime: activate compiled resolver map explicitly +$c->useCompiled($path); + +// optional one-step build + activate +$c->compileTo($path, load: true); +``` + +### Signed Serialization + +```php +$signed = \Infocyph\InterMix\Serializer\ValueSerializer::signed($_ENV['APP_KEY']); +$token = $signed->encode(['user_id' => 1]); +$payload = $signed->decode($token); ``` ## Testing @@ -117,5 +148,3 @@ Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated q ContributingReport | Request | Suggest - - diff --git a/composer.json b/composer.json index e4cca9e..a69f7d9 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,18 @@ { "name": "infocyph/intermix", - "description": "A Collection of useful PHP class functions.", + "description": "A lightweight PHP DI container, invoker, serializer, and utility toolkit.", "license": "MIT", "type": "library", "keywords": [ "intermix", "di", "container", - "dependency", - "injection", + "dependency-injection", + "psr-11", + "invoker", + "serializer", + "attributes", + "scoped-services", "macro", "mixin", "cache", @@ -42,10 +46,7 @@ "autoload": { "psr-4": { "Infocyph\\InterMix\\": "src/" - }, - "files": [ - "src/functions.php" - ] + } }, "autoload-dev": { "psr-4": { diff --git a/docs/di/cheat_sheet.rst b/docs/di/cheat_sheet.rst index 8affe7c..798b646 100644 --- a/docs/di/cheat_sheet.rst +++ b/docs/di/cheat_sheet.rst @@ -138,7 +138,7 @@ Scoped resolution: Advanced Helpers --------------------------- -* ``$c->parseCallable($spec)``: normalize closure/function/class/method input. +* ``$c->parseCallable($spec)``: normalize closure/function/class/method input (class-method targets must be autoloadable and exist). * ``$c->resolveNow(...)``: resolve with explicit runtime knobs. * ``$c->getRepository()``: inspect low-level runtime state. * ``$c->setResolverClass(FooResolver::class)``: swap resolver implementation. diff --git a/docs/di/quickstart.rst b/docs/di/quickstart.rst index d92a8d9..97b578f 100644 --- a/docs/di/quickstart.rst +++ b/docs/di/quickstart.rst @@ -19,14 +19,15 @@ Each alias is an *isolated* registry of services. use Infocyph\InterMix\DI\Container; use function Infocyph\InterMix\container; - $c1 = container(); // default alias is current source directory (__DIR__) + require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php'; + $c1 = container(); // default alias: intermix.default $c2 = Container::instance('cli'); // a second, independent container // identical: $c3 = Container::instance('cli'); Keep aliases short and memorable – tests often use a random alias to isolate state. For production/runtime stability, prefer explicit aliases instead of relying on the -default ``__DIR__`` alias. +default ``intermix.default`` alias. Configure behaviour (optional) ------------------------------ diff --git a/docs/functions.rst b/docs/functions.rst index 19fe405..19795f3 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -4,8 +4,15 @@ Global Functions Reference ========================== -InterMix autoloads helper functions from ``src/functions.php``. -This page documents each public helper and its runtime behavior. +Global helpers are optional. +InterMix does **not** autoload ``src/functions.php`` by default. +Load helpers manually when you want them: + +.. code-block:: php + + require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php'; + +This page documents each helper and its runtime behavior. DI Helpers ---------- @@ -13,11 +20,11 @@ DI Helpers container() =========== -.. php:function:: container(string|Closure|callable|array|null $closureOrClass = null, string $alias = __DIR__): mixed +.. php:function:: container(string|Closure|callable|array|null $closureOrClass = null, string $alias = Container::DEFAULT_ALIAS): mixed -Get the helper container (default alias ``__DIR__``), or resolve/call immediately. +Get the helper container (default alias ``intermix.default``), or resolve/call immediately. -- ``container()`` returns ``Container::instance(__DIR__)``. +- ``container()`` returns ``Container::instance('intermix.default')``. - ``container('my-alias')`` resolves ``'my-alias'`` as a service/callable. - For explicit named containers, use ``Container::instance('my-alias')``. - For stable runtime behavior, prefer explicit aliases across bootstrap and app code. @@ -25,7 +32,7 @@ Get the helper container (default alias ``__DIR__``), or resolve/call immediatel resolve() ========= -.. php:function:: resolve(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = __DIR__ . 'DI'): mixed +.. php:function:: resolve(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = Container::DI_ALIAS): mixed Resolve immediately with DI/autowiring path enabled. @@ -35,7 +42,7 @@ Resolve immediately with DI/autowiring path enabled. direct() ======== -.. php:function:: direct(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = __DIR__ . 'DR'): mixed +.. php:function:: direct(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = Container::DIRECT_ALIAS): mixed Resolve immediately with injection disabled (generic invocation path). diff --git a/docs/remix/helpers.rst b/docs/remix/helpers.rst index 07e3a11..63c2e0d 100644 --- a/docs/remix/helpers.rst +++ b/docs/remix/helpers.rst @@ -5,8 +5,14 @@ Global Helper Functions ========================= In addition to :php:func:`tap`, Remix provides several *global functions* to make -common tasks more fluent. They are declared in ``src/functions.php`` and are -available as global helpers. +common tasks more fluent. They are declared in ``src/functions.php``. + +Global helper loading is optional: the package does not autoload ``src/functions.php``. +Load it manually when needed: + +.. code-block:: php + + require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php'; - **tap()** – covered in :ref:`remix.tap-proxy`. - **when()** – conditionally apply callbacks to values. diff --git a/src/DI/Container.php b/src/DI/Container.php index 4be2e00..647be47 100644 --- a/src/DI/Container.php +++ b/src/DI/Container.php @@ -15,8 +15,13 @@ use Infocyph\InterMix\DI\Managers\OptionsManager; use Infocyph\InterMix\DI\Managers\RegistrationManager; use Infocyph\InterMix\DI\Resolver\Repository; +use Infocyph\InterMix\DI\Support\CompiledResolverGenerator; use Infocyph\InterMix\DI\Support\ContainerProxy; +use Infocyph\InterMix\DI\Support\ContextualBindingBuilder; use Infocyph\InterMix\DI\Support\DebugTracer; +use Infocyph\InterMix\DI\Support\LifetimeEnum; +use Infocyph\InterMix\DI\Support\PendingFactoryBinding; +use Infocyph\InterMix\DI\Support\TaggedPipeline; use Infocyph\InterMix\DI\Support\TraceLevelEnum; use Infocyph\InterMix\Exceptions\ContainerException; use Infocyph\InterMix\Exceptions\NotFoundException; @@ -32,9 +37,17 @@ final class Container implements ContainerInterface, ArrayAccess { use ContainerProxy; + public const string DEFAULT_ALIAS = 'intermix.default'; + + public const string DI_ALIAS = 'intermix.di'; + + public const string DIRECT_ALIAS = 'intermix.direct'; + /** @var array */ protected static array $instances = []; + private static int $generatedClosureSequence = 0; + protected DefinitionManager $definitionManager; protected InvocationManager $invocationManager; @@ -54,7 +67,7 @@ final class Container implements ContainerInterface, ArrayAccess * Defaults to 'default'. * @throws ContainerException */ - public function __construct(private readonly string $instanceAlias = __DIR__) + public function __construct(private readonly string $instanceAlias = self::DEFAULT_ALIAS) { self::$instances[$this->instanceAlias] ??= $this; $this->repository = new Repository($this); @@ -82,11 +95,21 @@ public function __construct(private readonly string $instanceAlias = __DIR__) * @return static The container instance. * @throws ContainerException */ - public static function instance(string $instanceAlias = __DIR__): self + public static function instance(string $instanceAlias = self::DEFAULT_ALIAS): self { return self::$instances[$instanceAlias] ??= new self($instanceAlias); } + /** + * @throws ContainerException + */ + public function alias(string $id, string $target, LifetimeEnum $lifetime = LifetimeEnum::Singleton): self + { + $this->definitions()->bind($id, $target, $lifetime); + + return $this; + } + /** * Retrieve the attribute registry. * @@ -101,6 +124,21 @@ public function attributeRegistry(): AttributeRegistry return $this->repository->attributeRegistry(); } + /** + * @param array $tags + * @throws ContainerException + */ + public function bind( + string $id, + mixed $definition, + LifetimeEnum $lifetime = LifetimeEnum::Singleton, + array $tags = [], + ): self { + $this->definitions()->bind($id, $definition, $lifetime, $tags); + + return $this; + } + /** * Resolves a class with method name (if provided) and executes the method with optional parameters. * @@ -117,6 +155,19 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $ return $this->invocationManager->call($classOrClosure, $method); } + /** + * @throws ReflectionException + */ + public function compileTo(string $path, bool $load = false): self + { + $compiled = (new CompiledResolverGenerator())->generate($this, $path); + if ($load) { + $this->repository->setCompiledResolvers($compiled); + } + + return $this; + } + /** * Debugs the service resolution process for a given ID. * @@ -130,16 +181,22 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $ */ public function debug(string $id): array { + $tracer = $this->repository->tracer(); + $previousLevel = $tracer->level(); + $previousCaptureLocation = $tracer->isCaptureLocationEnabled(); + try { - $tracer = $this->repository->tracer(); $tracer->setCaptureLocation(true); $tracer->setLevel(TraceLevelEnum::Verbose); $this->get($id); } catch (Throwable) { // swallow; we still want trace + } finally { + $tracer->setCaptureLocation($previousCaptureLocation); + $tracer->setLevel($previousLevel); } - return $this->repository->tracer()->toArray(); + return $tracer->toArray(); } /** @@ -216,6 +273,14 @@ public function exportGraph(?string $warmFromId = null, bool $clear = false): ar return $this->repository->tracer()->dependencyGraph($clear); } + /** + * @throws ContainerException + */ + public function factory(string $id, Closure $factory): PendingFactoryBinding + { + return new PendingFactoryBinding($this, $id, $factory); + } + /** * Finds and retrieves all service definitions tagged with a specified tag. * @@ -238,6 +303,16 @@ public function findByTag(string $tag): array return $matches; } + /** + * @return iterable + */ + public function findByTagLazy(string $tag): iterable + { + foreach ($this->repository->getIdsByTag($tag) as $id) { + yield $id => fn() => $this->get($id); + } + } + /** * Retrieves a value from the container. * @@ -399,6 +474,36 @@ public function make(string $class, string|bool $method = false): mixed return $this->invocationManager->make($class, $method); } + /** + * @throws ContainerException + */ + public function onResolved(string $id, callable $callback): self + { + $this->repository->onResolved($id, $callback); + + return $this; + } + + /** + * @throws ContainerException + */ + public function onResolving(string $id, callable $callback): self + { + $this->repository->onResolving($id, $callback); + + return $this; + } + + /** + * @throws ContainerException + */ + public function onScopeLeave(string $scope, callable $callback): self + { + $this->repository->onScopeLeave($scope, $callback); + + return $this; + } + /** * Retrieve the options manager. * @@ -425,6 +530,9 @@ public function options(): OptionsManager * - callable object (e.g. closure, invokable class) * - array of class and method name * + * Class-method targets are validated eagerly, so referenced classes must be + * autoloadable and methods must exist at parse time. + * * @param string|array|Closure|callable $spec The callable string/array to parse. * @return array{kind:'closure',closure:callable} * |array{kind:'class',class:string} @@ -444,22 +552,14 @@ public function parseCallable(string|array|Closure|callable $spec): array => ['kind' => 'closure', 'closure' => $spec], is_array($spec) && count($spec) === 2 && is_string($spec[0]) && is_string($spec[1]) - => ['kind' => 'method', 'class' => $spec[0], 'method' => $spec[1]], + => $this->parseArrayMethodCallable($spec), is_string($spec) => match (true) { str_contains($spec, '@') - => (static function () use ($spec) { - [$cls, $m] = explode('@', $spec, 2); - - return ['kind' => 'method', 'class' => $cls, 'method' => $m]; - })(), + => $this->parseClassMethodString($spec, '@'), str_contains($spec, '::') - => (static function () use ($spec) { - [$cls, $m] = explode('::', $spec, 2); - - return ['kind' => 'method', 'class' => $cls, 'method' => $m]; - })(), + => $this->parseClassMethodString($spec, '::'), class_exists($spec) => ['kind' => 'class', 'class' => $spec], @@ -490,6 +590,11 @@ function_exists($spec) }; } + public function pipeline(string $tag): TaggedPipeline + { + return new TaggedPipeline($this, $tag); + } + /** * Retrieve the registration manager. * @@ -532,6 +637,22 @@ public function resolveNow( }; } + /** + * @param array $tags + * @throws ContainerException + */ + public function scoped(string $id, mixed $definition = null, array $tags = []): self + { + $this->definitions()->bind( + $id, + $definition ?? $id, + LifetimeEnum::Scoped, + $tags, + ); + + return $this; + } + /** * Sets the environment for the container. * @@ -565,6 +686,31 @@ public function setResolverClass(string $resolverClass): void $this->resolver = fn() => new $resolverClass($this->repository); } + /** + * @param array $tags + * @throws ContainerException + */ + public function singleton(string $id, mixed $definition = null, array $tags = []): self + { + $this->definitions()->bind( + $id, + $definition ?? $id, + LifetimeEnum::Singleton, + $tags, + ); + + return $this; + } + + /** + * @return iterable + * @throws ContainerException + */ + public function tagged(string $tag): iterable + { + return $this->findByTagLazy($tag); + } + /** * Retrieves the debug tracer instance. * @@ -580,6 +726,22 @@ public function tracer(): DebugTracer return $this->repository->tracer(); } + /** + * @param array $tags + * @throws ContainerException + */ + public function transient(string $id, mixed $definition = null, array $tags = []): self + { + $this->definitions()->bind( + $id, + $definition ?? $id, + LifetimeEnum::Transient, + $tags, + ); + + return $this; + } + /** * Remove the container instance from the registry. * @@ -593,6 +755,54 @@ public function unset(): void unset(self::$instances[$this->instanceAlias]); } + /** + * @throws ContainerException + */ + public function useCompiled(string $path): self + { + $compiled = require $path; + if (!is_array($compiled)) { + throw new ContainerException('Compiled resolver file must return an array.'); + } + /** @var array $compiled */ + $this->repository->setCompiledResolvers($compiled); + + return $this; + } + + /** + * @return array + * @throws ContainerException + */ + public function validate(bool $strict = false, bool $resolveFactories = false): array + { + $issues = $this->validateDefinitionTargets(); + if ($resolveFactories) { + $issues = array_merge($issues, $this->validateResolvableDefinitions()); + } + + if ($strict && $issues !== []) { + throw new ContainerException("Container validation failed:\n- " . implode("\n- ", $issues)); + } + + return $issues; + } + + /** + * @throws ContainerException + */ + public function value(string $id, mixed $value): self + { + $this->definitions()->bind($id, $value, LifetimeEnum::Singleton); + + return $this; + } + + public function when(string $consumer): ContextualBindingBuilder + { + return new ContextualBindingBuilder($this, $consumer); + } + /** * Execute a callback inside a named scope and always leave it afterward. * @@ -620,6 +830,98 @@ public function withinScope(string $scope, callable $callback): mixed } } + /** + * @param array $spec + * @return array{kind:'method',class:string,method:string} + */ + private function parseArrayMethodCallable(array $spec): array + { + [$class, $method] = array_values($spec); + + if (!is_string($class) || !is_string($method)) { + throw new ContainerException( + 'Invalid callable array. Expected [class, method] with non-empty strings.', + ); + } + + return $this->parseClassMethodParts($class, $method, '[class, method]'); + } + + /** + * @return array{kind:'method',class:string,method:string} + */ + private function parseClassMethodParts( + string $class, + string $method, + string $spec, + ?string $separator = null, + ): array { + $class = trim($class); + $method = trim($method); + + if ($class === '' || $method === '') { + if ($separator === null) { + throw new ContainerException( + 'Invalid callable array. Expected [class, method] with non-empty strings.', + ); + } + + throw new ContainerException( + sprintf( + 'Invalid callable string "%s". Expected non-empty class and method around "%s".', + $spec, + $separator, + ), + ); + } + + if (!class_exists($class)) { + if ($separator === null) { + throw new ContainerException( + sprintf('Invalid callable array. Class "%s" does not exist.', $class), + ); + } + + throw new ContainerException( + sprintf('Invalid callable string "%s". Class "%s" does not exist.', $spec, $class), + ); + } + + if (!method_exists($class, $method)) { + if ($separator === null) { + throw new ContainerException( + sprintf('Invalid callable array. Method "%s::%s" does not exist.', $class, $method), + ); + } + + throw new ContainerException( + sprintf( + 'Invalid callable string "%s". Method "%s::%s" does not exist.', + $spec, + $class, + $method, + ), + ); + } + + return [ + 'kind' => 'method', + 'class' => $class, + 'method' => $method, + ]; + } + + /** + * @param non-empty-string $separator + * @return array{kind:'method',class:string,method:string} + */ + private function parseClassMethodString(string $spec, string $separator): array + { + [$class, $method] = explode($separator, $spec, 2); + + return $this->parseClassMethodParts($class, $method, $spec, $separator); + } + /** * @param array{kind:'class',class:string}|array{kind:'method',class:string,method:string} $desc * @param array $parameters @@ -648,7 +950,8 @@ private function resolveRegisteredClosureCallable(array $desc, array $parameters } $callback = $id(...); } else { - $id = random_bytes(5); + self::$generatedClosureSequence++; + $id = '__imx_closure_' . self::$generatedClosureSequence; $callback = $desc['closure']; } @@ -657,6 +960,52 @@ private function resolveRegisteredClosureCallable(array $desc, array $parameters return $this->getReturn($id); } + /** + * @return array + */ + private function validateDefinitionTargets(): array + { + $issues = []; + foreach ($this->repository->getFunctionReference() as $id => $definition) { + if ($id === '') { + $issues[] = 'Invalid definition id detected.'; + + continue; + } + + if (is_string($definition) + && !class_exists($definition) + && !function_exists($definition) + && !$this->repository->hasFunctionReference($definition) + ) { + $issues[] = "Definition '{$id}' points to unknown string target '{$definition}'."; + } + + if (is_array($definition) && isset($definition[0]) && is_string($definition[0]) && !class_exists($definition[0])) { + $issues[] = "Definition '{$id}' references missing class '{$definition[0]}'."; + } + } + + return $issues; + } + + /** + * @return array + */ + private function validateResolvableDefinitions(): array + { + $issues = []; + foreach (array_keys($this->repository->getFunctionReference()) as $id) { + try { + $this->get($id); + } catch (Throwable $e) { + $issues[] = "Resolution failure for '{$id}': {$e->getMessage()}"; + } + } + + return $issues; + } + /** * Wraps an exception into a NotFoundException if it's a NotFoundException, * or a ContainerException with the given ID otherwise. diff --git a/src/DI/Invoker.php b/src/DI/Invoker.php index 6420f0e..b7fa7dd 100644 --- a/src/DI/Invoker.php +++ b/src/DI/Invoker.php @@ -8,7 +8,6 @@ use Infocyph\InterMix\Exceptions\ContainerException; use Infocyph\InterMix\Serializer\ValueSerializer; use InvalidArgumentException; -use Random\RandomException; use ReflectionException; final class Invoker @@ -37,7 +36,7 @@ private function __construct(private readonly Container $container) {} public static function shared(): self { if (!self::$sharedInstance instanceof self) { - self::$sharedInstance = new self(Container::instance(__DIR__)); + self::$sharedInstance = new self(Container::instance(Container::DI_ALIAS)); } return self::$sharedInstance; @@ -72,25 +71,32 @@ public static function with(Container $container): self */ public function callableFor(string|object $target): callable { - $key = \is_string($target) ? $target : $target::class; + if (\is_object($target)) { + if (!\is_callable($target)) { + throw new InvalidArgumentException( + sprintf('%s is not invokable.', $target::class), + ); + } + + return $target(...); + } + $key = 'container:' . spl_object_id($this->container) . ':class:' . $target; if (isset(self::$callableCache[$key])) { return self::$callableCache[$key]; } - $instance = \is_string($target) ? $this->make($target) : $target; + $instance = $this->make($target); if (!\is_object($instance) || !\is_callable($instance)) { throw new InvalidArgumentException( sprintf( '%s is not invokable.', - \is_object($instance) ? $instance::class : $key, + $target, ), ); } - self::$callableCache[$key] = $instance(...); - - return self::$callableCache[$key]; + return self::$callableCache[$key] = $instance(...); } /** @@ -118,7 +124,7 @@ public function getContainer(): Container * * @throws ContainerException If the callable cannot be resolved. * @throws ReflectionException If the callable is a class without a default constructor. - * @throws InvalidArgumentException|RandomException If the callable is not a valid callable. + * @throws InvalidArgumentException If the callable is not a valid callable. */ public function invoke(string|array|callable $target, array $args = []): mixed { @@ -148,6 +154,19 @@ public function invoke(string|array|callable $target, array $args = []): mixed }; } + /** + * Invoke a closure directly through the active resolver without registering aliases. + * + * @param array $args + * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException + */ + public function invokeClosure(Closure $closure, array $args = []): mixed + { + return $this->container + ->getCurrentResolver() + ->closureSettler($closure, $args); + } + /** * Create a new instance of a class, optionally invoking a method. * @@ -245,7 +264,7 @@ public function unserialize(string $b): mixed * @param mixed $callable The callable to be routed and executed. * @param array $args The arguments to pass to the callable. * @return mixed The result of executing the callable. - * @throws ContainerException|RandomException|ReflectionException|\Psr\Cache\InvalidArgumentException + * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException */ private function routeCallable(mixed $callable, array $args): mixed { @@ -278,19 +297,10 @@ private function routeCallable(mixed $callable, array $args): mixed * @param Closure $fn The closure to invoke. * @param array $args The arguments to pass to the closure. * @return mixed The result of the closure invocation. - * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException|RandomException + * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException */ private function viaClosure(Closure $fn, array $args): mixed { - static $i = 0; - if (!is_int($i)) { - $i = 0; - } - $alias = 'λ' . $i; - $i++; - - return $this->container - ->registration()->registerClosure($alias, $fn, $args) - ->invocation()->getReturn($alias); + return $this->invokeClosure($fn, $args); } } diff --git a/src/DI/Managers/DefinitionManager.php b/src/DI/Managers/DefinitionManager.php index a55bc06..17f3553 100644 --- a/src/DI/Managers/DefinitionManager.php +++ b/src/DI/Managers/DefinitionManager.php @@ -75,8 +75,8 @@ public function bind( LifetimeEnum $lifetime = LifetimeEnum::Singleton, array $tags = [], ): self { - if ($id === $definition) { - throw new ContainerException("Id and definition cannot be the same ($id)"); + if (is_string($definition) && $id === $definition && !class_exists($definition)) { + throw new ContainerException("Scalar/string alias cannot point to itself ($id)."); } $this->repository->setFunctionReference($id, $definition); $this->repository->setDefinitionMeta($id, [ @@ -135,9 +135,12 @@ public function cacheAllDefinitions(bool $forceClearFirst = false): self * @return $this * @throws ContainerException */ - public function enableDefinitionCache(CacheItemPoolInterface $adapter): self - { + public function enableDefinitionCache( + CacheItemPoolInterface $adapter, + bool $cacheRuntimeObjects = false, + ): self { $this->repository->setCacheAdapter($adapter); + $this->repository->setCacheRuntimeObjects($cacheRuntimeObjects); return $this; } diff --git a/src/DI/Managers/InvocationManager.php b/src/DI/Managers/InvocationManager.php index da6b90a..11f7b63 100644 --- a/src/DI/Managers/InvocationManager.php +++ b/src/DI/Managers/InvocationManager.php @@ -119,20 +119,27 @@ public function definitions(): DefinitionManager */ public function get(string $id): mixed { - $this->repository->tracer()->push("return:$id", TraceLevelEnum::Verbose); + $lifetime = null; + $resolved = $this->repository->getResolvedEntry($id); + if ($resolved !== null || $this->repository->hasResolved($id)) { + $lifetime = $this->repository->getDefinitionLifetime($id); + } - $lifetime = $this->repository->getDefinitionLifetime($id); if ($lifetime === LifetimeEnum::Singleton) { - $resolved = $this->repository->getResolvedEntry($id); - if ($resolved !== null || $this->repository->hasResolved($id)) { - if ($resolved instanceof DeferredInitializer) { - $resolved = $resolved(); - $this->repository->setResolved($id, $resolved); - } - - return $this->repository->fetchInstanceOrValue($resolved); + if ($resolved instanceof DeferredInitializer) { + $resolved = $resolved(); + $this->repository->setResolved($id, $resolved); } + return $this->repository->fetchInstanceOrValue($resolved); + } + + if ($this->repository->isTracingEnabled()) { + $this->repository->tracer()->push("return:$id", TraceLevelEnum::Verbose); + } + + $lifetime ??= $this->repository->getDefinitionLifetime($id); + if ($lifetime === LifetimeEnum::Singleton) { return $this->resolveAndCache($id, $id, true, false); } @@ -164,7 +171,9 @@ public function get(string $id): mixed public function getReturn(string $id): mixed { $resolved = $this->get($id); - $resource = $this->repository->getResolvedEntry($id) ?? []; + $lifetime = $this->repository->getDefinitionLifetime($id); + $key = $this->scopeKeyFor($id, $lifetime); + $resource = $this->repository->getResolvedEntry($key) ?? []; return is_array($resource) && array_key_exists('returned', $resource) ? $resource['returned'] : $resolved; } @@ -251,20 +260,18 @@ protected function resolveDefinition(string $id): mixed $definition = $this->repository->getFunctionDefinition($id); if ($this->repository->isLazyLoading() && !($definition instanceof Closure)) { - $lazy = new DeferredInitializer(fn() => $resolver->resolveByDefinition($id), $this->container); - $this->repository->setResolved($id, $lazy); - - return $lazy; + return new DeferredInitializer(fn() => $resolver->resolveByDefinition($id), $this->container); } $value = $resolver->resolveByDefinition($id); - $this->repository->setResolved($id, $value); return $this->repository->fetchInstanceOrValue($value); } private function resolveAndCache(string $id, string $scopeKey, bool $cacheable, bool $isScoped): mixed { + $this->repository->dispatchResolvingHooks($id); + if ($this->repository->hasFunctionReference($id)) { $resolved = $this->resolveDefinition($id); $resolved = $resolved instanceof DeferredInitializer ? $resolved() : $resolved; @@ -273,6 +280,8 @@ private function resolveAndCache(string $id, string $scopeKey, bool $cacheable, $this->storeResolvedByLifetime($scopeKey, $resolved, $isScoped); } + $this->repository->dispatchResolvedHooks($id, $resolved); + return $resolved; } @@ -281,6 +290,8 @@ private function resolveAndCache(string $id, string $scopeKey, bool $cacheable, $this->storeResolvedByLifetime($scopeKey, $resolved, $isScoped); } + $this->repository->dispatchResolvedHooks($id, $resolved); + return $this->repository->fetchInstanceOrValue($resolved); } diff --git a/src/DI/Resolver/ClassResolver.php b/src/DI/Resolver/ClassResolver.php index 0d19fb8..175dbe3 100644 --- a/src/DI/Resolver/ClassResolver.php +++ b/src/DI/Resolver/ClassResolver.php @@ -420,20 +420,26 @@ private function resolveMake( string $className, string|bool|null $callMethod, ): array { - $existing = $this->repository->getResolvedResourceFor($className); + $allExisting = $this->repository->getResolvedResource(); + $hadExisting = array_key_exists($className, $allExisting); + $existing = $allExisting[$className] ?? []; - // build fresh - $this->resolveConstructor($class); - $this->propertyResolver->resolve($class); - $this->resolveMethod($class, $callMethod); - - $newlyBuilt = $this->normalizeResolvedResource( - $this->repository->getResolvedResourceFor($className), - ); - // revert the old - $this->repository->setResolvedResource($className, $existing); + try { + // build fresh + $this->resolveConstructor($class); + $this->propertyResolver->resolve($class); + $this->resolveMethod($class, $callMethod); - return $newlyBuilt; + return $this->normalizeResolvedResource( + $this->repository->getResolvedResourceFor($className), + ); + } finally { + if ($hadExisting) { + $this->repository->setResolvedResource($className, $existing); + } else { + $this->repository->unsetResolvedResource($className); + } + } } /** diff --git a/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php b/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php index b112190..022cb64 100644 --- a/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php +++ b/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php @@ -197,6 +197,14 @@ private function tryResolveAssociative( $classReflection = $this->getResolvableReflection($reflector, $param, $type, $processed); if ($classReflection) { + $contextual = $this->resolveContextualDependency( + $this->ownerFor($reflector), + $classReflection, + ); + if ($contextual !== $this->stdClass) { + return $contextual; + } + $nameHint = $classReflection->isInterface() ? $classReflection->getName() : $paramName; diff --git a/src/DI/Resolver/DefinitionResolver.php b/src/DI/Resolver/DefinitionResolver.php index 67f1e73..1355c03 100644 --- a/src/DI/Resolver/DefinitionResolver.php +++ b/src/DI/Resolver/DefinitionResolver.php @@ -114,26 +114,14 @@ private function getFromCacheOrResolve(string $name): mixed return $this->resolveDefinition($name); } - if (!$this->repository->hasResolvedDefinition($resolvedKey)) { - $resolverCallback = fn() => $this->resolveDefinition($name); - $cacheAdapter = $this->repository->getCacheAdapter(); - if ($cacheAdapter) { - $cacheKey = $this->repository->makeCacheKey('def:' . self::stableHash($resolvedKey)); - $item = $cacheAdapter->getItem($cacheKey); - if ($item->isHit()) { - $value = $item->get(); - } else { - $value = $resolverCallback(); - $item->set($value); - $cacheAdapter->save($item); - } - } else { - $value = $resolverCallback(); - } - $this->repository->setResolvedDefinition($resolvedKey, $value); + if ($this->repository->hasResolvedDefinition($resolvedKey)) { + return $this->repository->getResolvedDefinitionEntry($resolvedKey); } - return $this->repository->getResolvedDefinitionEntry($resolvedKey); + $value = $this->resolveSingletonDefinition($name, $resolvedKey); + $this->repository->setResolvedDefinition($resolvedKey, $value); + + return $value; } /** @@ -181,6 +169,11 @@ private function resolveArrayDefinition(array $definition): mixed */ private function resolveDefinition(string $name): mixed { + $compiled = $this->repository->getCompiledResolver($name); + if (is_callable($compiled)) { + return $compiled($this->repository->container()); + } + $definition = $this->repository->getFunctionDefinition($name); switch (true) { case $definition instanceof Closure: @@ -206,4 +199,29 @@ private function resolveDefinition(string $name): mixed return $definition; } } + + private function resolveSingletonDefinition(string $name, string $resolvedKey): mixed + { + $cacheAdapter = $this->repository->getCacheAdapter(); + if ($cacheAdapter === null) { + return $this->resolveDefinition($name); + } + + $cacheKey = $this->repository->makeCacheKey('def:' . self::stableHash($resolvedKey)); + $item = $cacheAdapter->getItem($cacheKey); + if ($item->isHit()) { + $cachedValue = $item->get(); + if ($this->repository->shouldPersistDefinitionValue($cachedValue)) { + return $cachedValue; + } + } + + $value = $this->resolveDefinition($name); + if ($this->repository->shouldPersistDefinitionValue($value)) { + $item->set($value); + $cacheAdapter->save($item); + } + + return $value; + } } diff --git a/src/DI/Resolver/ParameterResolver.php b/src/DI/Resolver/ParameterResolver.php index 8041447..0c93590 100644 --- a/src/DI/Resolver/ParameterResolver.php +++ b/src/DI/Resolver/ParameterResolver.php @@ -47,8 +47,6 @@ class ParameterResolver private const int PARAM_ATTRIBUTE_PLAN_CACHE_LIMIT = 4096; - private const int RESOLUTION_CACHE_LIMIT = 4096; - private const int RESOLUTION_PLAN_CACHE_LIMIT = 2048; private readonly IMStdClass $stdClass; @@ -73,9 +71,6 @@ class ParameterResolver */ private array $resolutionPlanCache = []; - /** @var array> */ - private array $resolvedCache = []; - public function __construct( private readonly Repository $repository, private readonly DefinitionResolver $definitionResolver, @@ -96,16 +91,6 @@ public function resolve( array $suppliedParameters, string $type, ): array { - $useValueCache = $this->shouldUseValueCache($reflector, $type); - $cacheKey = null; - - if ($useValueCache) { - $cacheKey = $this->makeResolutionCacheKey($reflector, $suppliedParameters, $type); - if (array_key_exists($cacheKey, $this->resolvedCache)) { - return $this->resolvedCache[$cacheKey]; - } - } - $this->repository->tracer()->push( "{$reflector->getShortName()}() params", TraceLevelEnum::Verbose, @@ -114,7 +99,7 @@ public function resolve( $plan = $this->getResolutionPlan($reflector, $type); $availableParams = $plan['availableParams']; if (!$availableParams) { - return $useValueCache ? $this->rememberResolved((string) $cacheKey, []) : []; + return []; } $applyAttribute = $plan['applyAttribute']; @@ -133,7 +118,7 @@ public function resolve( $attributeData, ); if (!$paramsLeft) { - return $useValueCache ? $this->rememberResolved((string) $cacheKey, $processed) : $processed; + return $processed; } [ @@ -151,7 +136,7 @@ public function resolve( $processed = $this->processVariadic($processed, $variadic, $sort); } - return $useValueCache ? $this->rememberResolved((string) $cacheKey, $processed) : $processed; + return $processed; } /** @@ -182,38 +167,53 @@ public function resolveByDefinitionType(string $name, ReflectionParameter $param if ($this->repository->hasFunctionReference($typeName)) { return $this->definitionResolver->resolve($typeName); } - - if (class_exists($typeName) || interface_exists($typeName)) { - $ref = ReflectionResource::getClassReflection($typeName); - - return $this->classResolver->resolve($ref)['instance']; - } } return $this->stdClass; } - public function setClassResolverInstance(ClassResolver $classResolver): void + /** + * @param ReflectionClass $dependency + */ + public function resolveContextualDependency(string $consumer, ReflectionClass $dependency): mixed { - $this->classResolver = $classResolver; - } + if ($consumer === '') { + return $this->stdClass; + } - private static function normalise(mixed $value): mixed - { - return match (true) { - $value instanceof \Closure => 'closure#' . spl_object_id($value), - is_object($value) => 'obj#' . spl_object_id($value), - is_resource($value) => 'res#' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => array_map([self::class, 'normalise'], $value), - default => $value, - }; + $binding = $this->repository->getContextualBinding($consumer, $dependency->getName()); + if ($binding === null) { + return $this->stdClass; + } + + if (is_callable($binding)) { + return $binding($this->repository->container()); + } + + if (is_string($binding)) { + if ($this->repository->hasFunctionReference($binding)) { + return $this->definitionResolver->resolve($binding); + } + + if (class_exists($binding) || interface_exists($binding)) { + $resolvedClass = $this->applyEnvOverride($binding); + + return $this->classResolver->resolveClassInstance( + ReflectionResource::getClassReflection($resolvedClass), + ); + } + } + + if (is_object($binding) && is_a($binding, $dependency->getName())) { + return $binding; + } + + return $binding; } - private static function stableHash(string $value): string + public function setClassResolverInstance(ClassResolver $classResolver): void { - $algorithm = in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'xxh3'; - - return hash($algorithm, $value); + $this->classResolver = $classResolver; } /** @@ -384,33 +384,6 @@ private function getResolvableReflection( return $reflection; } - /** - * @param array $supplied - */ - private function makeFastScalarFingerprint(array $supplied): ?string - { - if (count($supplied) > 16) { - return null; - } - - $parts = []; - foreach ($supplied as $key => $value) { - if (is_array($value) || is_object($value) || is_resource($value)) { - return null; - } - - $parts[] = $key . '=' . match (true) { - $value === null => 'n:null', - is_bool($value) => 'b:' . ($value ? '1' : '0'), - is_int($value) => 'i:' . $value, - is_float($value) => 'f:' . $value, - default => 's:' . base64_encode(is_string($value) ? $value : var_export($value, true)), - }; - } - - return 'f:' . implode('|', $parts); - } - private function makeParameterAttributePlanKey(ReflectionParameter $parameter): string { $function = $parameter->getDeclaringFunction(); @@ -420,30 +393,6 @@ private function makeParameterAttributePlanKey(ReflectionParameter $parameter): return $owner . '::' . $function->getName() . '|p:' . $parameter->getPosition() . '|sig:' . $signature; } - /** - * @param array $supplied - */ - private function makeResolutionCacheKey( - ReflectionFunctionAbstract $reflector, - array $supplied, - string $type, - ): string { - $owner = $this->ownerFor($reflector); - if ($supplied === []) { - return "$owner::{$reflector->getName()}|$type|empty"; - } - - $fast = $this->makeFastScalarFingerprint($supplied); - if ($fast !== null) { - return "$owner::{$reflector->getName()}|$type|$fast"; - } - - $norm = array_map([self::class, 'normalise'], $supplied); - $argsHash = self::stableHash(json_encode($norm, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); - - return "$owner::{$reflector->getName()}|$type|h:$argsHash"; - } - private function makeResolutionPlanKey(ReflectionFunctionAbstract $reflector, string $type): string { $owner = $this->ownerFor($reflector); @@ -557,23 +506,4 @@ private function rememberResolutionPlan(string $key, array $value): array return $value; } - - /** - * @param array $value - * @return array - */ - private function rememberResolved(string $key, array $value): array - { - $this->evictCacheKeyIfNeeded($this->resolvedCache, $key, self::RESOLUTION_CACHE_LIMIT); - $this->resolvedCache[$key] = $value; - - return $value; - } - - private function shouldUseValueCache(ReflectionFunctionAbstract $reflector, string $type): bool - { - return $type === 'constructor' - && $reflector instanceof ReflectionMethod - && $reflector->getName() === '__construct'; - } } diff --git a/src/DI/Resolver/Repository.php b/src/DI/Resolver/Repository.php index a457053..5fd868c 100644 --- a/src/DI/Resolver/Repository.php +++ b/src/DI/Resolver/Repository.php @@ -31,15 +31,23 @@ class Repository private ?DefinitionCachePoolInterface $cacheAdapter = null; + private bool $cacheRuntimeObjects = false; + /** @var array> */ private array $classResource = []; /** @var array}> */ private array $closureResource = []; + /** @var array> */ + private array $compiledResolvers = []; + /** @var array> */ private array $conditionalBindings = []; + /** @var array> */ + private array $contextualBindings = []; + private string $currentScope = 'root'; private ?string $defaultMethod = null; @@ -59,10 +67,21 @@ class Repository /** @var array */ private array $functionReference = []; + private bool $hasHooks = false; + private bool $isLocked = false; private bool $lazyLoading = true; + /** @var array> */ + private array $onResolvedHooks = []; + + /** @var array> */ + private array $onResolvingHooks = []; + + /** @var array> */ + private array $onScopeLeaveHooks = []; + /** @var array */ private array $resolved = []; @@ -84,6 +103,9 @@ class Repository /** @var array>> */ private array $tagIndexByEnv = []; + /** @var array> */ + private array $tagOverrideIdsByEnv = []; + /** * Constructs a Repository instance. * @@ -91,7 +113,7 @@ class Repository * and interactions within the container. This aids in debugging and * tracing the service resolution process. */ - public function __construct(Container $container) + public function __construct(private readonly Container $container) { $this->tracer = new DebugTracer(); $this->attributeRegistry = new AttributeRegistry($container); @@ -170,6 +192,33 @@ public function bindInterfaceForEnv(string $env, string $interface, string $conc $this->conditionalBindings[$env][$interface] = $concrete; } + public function container(): Container + { + return $this->container; + } + + public function dispatchResolvedHooks(string $id, mixed $value): void + { + if (!$this->hasHooks) { + return; + } + + foreach ($this->onResolvedHooks[$id] ?? [] as $hook) { + $hook($id, $value); + } + } + + public function dispatchResolvingHooks(string $id): void + { + if (!$this->hasHooks) { + return; + } + + foreach ($this->onResolvingHooks[$id] ?? [] as $hook) { + $hook($id); + } + } + /** * Enables or disables lazy loading for the repository. * @@ -335,6 +384,16 @@ public function getClosureResourceEntry(string $alias): ?array return $this->closureResource[$alias] ?? null; } + public function getCompiledResolver(string $id): mixed + { + return $this->compiledResolvers[$id]['factory'] ?? null; + } + + public function getContextualBinding(string $consumer, string $dependency): mixed + { + return $this->contextualBindings[$consumer][$dependency] ?? null; + } + /** * Retrieves the default method to be called when resolving a class. * @@ -456,9 +515,9 @@ public function getIdsByTag(string $tag): array $ids[$id] = true; } - $overrides = $this->definitionMetaByEnv[$env] ?? []; - foreach ($overrides as $id => $override) { - if (!array_key_exists('tags', $override)) { + foreach ($this->tagOverrideIdsByEnv[$env] ?? [] as $id => $_) { + $override = $this->definitionMetaByEnv[$env][$id] ?? null; + if (!is_array($override) || !array_key_exists('tags', $override)) { continue; } @@ -614,8 +673,19 @@ public function isPropertyAttributeEnabled(): bool return $this->enablePropertyAttribute; } + public function isTracingEnabled(): bool + { + return $this->tracer->isEnabled(); + } + public function leaveScope(): void { + if ($this->hasHooks) { + foreach ($this->onScopeLeaveHooks[$this->currentScope] ?? [] as $hook) { + $hook($this->currentScope, $this->container); + } + } + $this->clearScopeResolvedEntries($this->currentScope); $previous = array_pop($this->scopeStack); $this->currentScope = is_string($previous) ? $previous : 'root'; @@ -647,6 +717,24 @@ public function makeCacheKey(string $suffix): string return $this->alias . '-' . $suffix; } + public function onResolved(string $id, callable $hook): void + { + $this->hasHooks = true; + $this->onResolvedHooks[$id][] = $hook; + } + + public function onResolving(string $id, callable $hook): void + { + $this->hasHooks = true; + $this->onResolvingHooks[$id][] = $hook; + } + + public function onScopeLeave(string $scope, callable $hook): void + { + $this->hasHooks = true; + $this->onScopeLeaveHooks[$scope][] = $hook; + } + /** * Resets the current scope, removing all resolved resources that were * created under the current scope. @@ -699,6 +787,34 @@ public function setCacheAdapter(CacheItemPoolInterface $adapter): void : new Psr6DefinitionCachePoolAdapter($adapter); } + public function setCacheRuntimeObjects(bool $cacheRuntimeObjects): void + { + $this->checkIfLocked(); + $this->cacheRuntimeObjects = $cacheRuntimeObjects; + } + + /** + * @param array $resolvers + */ + public function setCompiledResolvers(array $resolvers): void + { + $this->checkIfLocked(); + $normalized = []; + foreach ($resolvers as $id => $resolver) { + if (!is_string($id) || !is_callable($resolver)) { + continue; + } + $normalized[$id] = ['factory' => $resolver]; + } + $this->compiledResolvers = $normalized; + } + + public function setContextualBinding(string $consumer, string $dependency, mixed $give): void + { + $this->checkIfLocked(); + $this->contextualBindings[$consumer][$dependency] = $give; + } + /** * Sets the default method to be called when resolving a class. * @@ -777,6 +893,7 @@ public function setDefinitionMetaForEnv( if (array_key_exists('tags', $normalized)) { $oldTags = $existing['tags'] ?? []; $this->refreshEnvTagIndex($env, $id, $oldTags, $normalized['tags']); + $this->tagOverrideIdsByEnv[$env][$id] = true; } } @@ -892,6 +1009,20 @@ public function setScope(string $scope): void $this->currentScope = $scope; } + public function shouldCacheRuntimeObjects(): bool + { + return $this->cacheRuntimeObjects; + } + + public function shouldPersistDefinitionValue(mixed $value): bool + { + if ($this->cacheRuntimeObjects) { + return !is_resource($value) && !$value instanceof \Closure; + } + + return $this->isSafeCachedDefinitionValue($value); + } + /** * @return DebugTracer The debug tracer associated with this repository. * @@ -904,6 +1035,11 @@ public function tracer(): DebugTracer return $this->tracer; } + public function unsetResolvedResource(string $className): void + { + unset($this->resolvedResource[$className]); + } + /** * Throw an exception if the container is locked and we try to set/modify values. * @@ -937,6 +1073,25 @@ private function clearScopeResolvedEntries(string $scope): void } } + private function isSafeCachedDefinitionValue(mixed $value): bool + { + if (is_scalar($value) || $value === null) { + return true; + } + + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (!$this->isSafeCachedDefinitionValue($item)) { + return false; + } + } + + return true; + } + /** * @param array $oldTags * @param array $newTags diff --git a/src/DI/Support/CompiledResolverGenerator.php b/src/DI/Support/CompiledResolverGenerator.php new file mode 100644 index 0000000..5b233fc --- /dev/null +++ b/src/DI/Support/CompiledResolverGenerator.php @@ -0,0 +1,137 @@ + + * @throws ReflectionException + */ + public function generate(Container $container, string $filePath): array + { + $repo = $container->getRepository(); + $definitions = $repo->getFunctionReference(); + $compiledCodeEntries = []; + + foreach ($definitions as $id => $definition) { + $compiledEntry = $this->compileEntry($definition); + if ($compiledEntry === null) { + continue; + } + + $compiledCodeEntries[] = var_export($id, true) . ' => ' . $compiledEntry; + } + + $code = " $result */ + $result = require $filePath; + + return $result; + } + + /** + * @param array $definition + */ + private function compileArrayDefinition(array $definition): ?string + { + if (!isset($definition[0]) || !is_string($definition[0]) || !class_exists($definition[0])) { + return null; + } + + $class = '\\' . ltrim($definition[0], '\\'); + $method = isset($definition[1]) && is_string($definition[1]) + ? var_export($definition[1], true) + : 'false'; + + return "static fn(Container \$c): mixed => \$c->make({$class}::class, {$method})"; + } + + private function compileClassDefinition(string $definition): ?string + { + $class = ReflectionResource::getClassReflection($definition); + if (!$class->isInstantiable()) { + return null; + } + + $constructor = $class->getConstructor(); + if ($constructor === null || $constructor->getParameters() === []) { + $fqcn = '\\' . ltrim($definition, '\\'); + + return "static fn(Container \$c): mixed => new {$fqcn}()"; + } + + $arguments = $this->compileConstructorArguments($constructor->getParameters()); + if ($arguments === null) { + return null; + } + + $fqcn = '\\' . ltrim($definition, '\\'); + $args = implode(', ', $arguments); + + return "static fn(Container \$c): mixed => new {$fqcn}({$args})"; + } + + /** + * @param array $parameters + * @return array|null + */ + private function compileConstructorArguments(array $parameters): ?array + { + $arguments = []; + foreach ($parameters as $parameter) { + if ($parameter->isVariadic()) { + return null; + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $arguments[] = '$c->get(\\' . ltrim($type->getName(), '\\') . '::class)'; + + continue; + } + + if ($parameter->isDefaultValueAvailable()) { + $arguments[] = var_export($parameter->getDefaultValue(), true); + + continue; + } + + if ($parameter->allowsNull()) { + $arguments[] = 'null'; + + continue; + } + + return null; + } + + return $arguments; + } + + private function compileEntry(mixed $definition): ?string + { + if (is_array($definition)) { + return $this->compileArrayDefinition($definition); + } + + if (!is_string($definition) || !class_exists($definition)) { + return null; + } + + return $this->compileClassDefinition($definition); + } +} diff --git a/src/DI/Support/ContextualBindingBuilder.php b/src/DI/Support/ContextualBindingBuilder.php new file mode 100644 index 0000000..d091b1e --- /dev/null +++ b/src/DI/Support/ContextualBindingBuilder.php @@ -0,0 +1,41 @@ +dependency === null || $this->dependency === '') { + throw new ContainerException('Contextual binding requires needs() before give(...).'); + } + + $this->container + ->getRepository() + ->setContextualBinding($this->consumer, $this->dependency, $implementation); + + return $this->container; + } + + public function needs(string $dependency): self + { + $this->dependency = $dependency; + + return $this; + } +} diff --git a/src/DI/Support/DebugTracer.php b/src/DI/Support/DebugTracer.php index f50fc25..d94c74b 100644 --- a/src/DI/Support/DebugTracer.php +++ b/src/DI/Support/DebugTracer.php @@ -170,6 +170,16 @@ public function getFormatted(callable $formatter): mixed return $out; } + public function isCaptureLocationEnabled(): bool + { + return $this->captureLocation; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + /* --------------------------------------------------------------------- */ public function level(): TraceLevelEnum diff --git a/src/DI/Support/PendingFactoryBinding.php b/src/DI/Support/PendingFactoryBinding.php new file mode 100644 index 0000000..e97bde9 --- /dev/null +++ b/src/DI/Support/PendingFactoryBinding.php @@ -0,0 +1,71 @@ + $tags + * @throws ContainerException + */ + public function register(array $tags = []): Container + { + return $this->singleton($tags); + } + + /** + * @param array $tags + * @throws ContainerException + */ + public function scoped(array $tags = []): Container + { + return $this->apply(LifetimeEnum::Scoped, $tags); + } + + /** + * @param array $tags + * @throws ContainerException + */ + public function singleton(array $tags = []): Container + { + return $this->apply(LifetimeEnum::Singleton, $tags); + } + + /** + * @param array $tags + * @throws ContainerException + */ + public function transient(array $tags = []): Container + { + return $this->apply(LifetimeEnum::Transient, $tags); + } + + /** + * @param array $tags + * @throws ContainerException + */ + private function apply(LifetimeEnum $lifetime, array $tags = []): Container + { + $factory = $this->factory; + $container = $this->container; + + return $this->container->bind( + $this->id, + static fn() => $factory($container), + $lifetime, + $tags, + ); + } +} diff --git a/src/DI/Support/ReflectionResource.php b/src/DI/Support/ReflectionResource.php index c7ab5ac..6a11ad3 100644 --- a/src/DI/Support/ReflectionResource.php +++ b/src/DI/Support/ReflectionResource.php @@ -20,10 +20,12 @@ */ final class ReflectionResource { + private static int $cacheLimit = 0; + /** * @var array{ - * classes: array>, - * enums: array>, + * classes: array>, + * enums: array>, * functions: array, * methods: array * } @@ -35,6 +37,26 @@ final class ReflectionResource 'methods' => [], ]; + /** + * @return array{limit:int,classes:int,enums:int,functions:int,methods:int,total:int} + */ + public static function cacheStats(): array + { + $classes = count(self::$reflectionCache['classes']); + $enums = count(self::$reflectionCache['enums']); + $functions = count(self::$reflectionCache['functions']); + $methods = count(self::$reflectionCache['methods']); + + return [ + 'limit' => self::$cacheLimit, + 'classes' => $classes, + 'enums' => $enums, + 'functions' => $functions, + 'methods' => $methods, + 'total' => $classes + $enums + $functions + $methods, + ]; + } + public static function clearCache(): void { self::$reflectionCache = [ @@ -92,7 +114,11 @@ public static function getClassReflection(string|object $class): ReflectionClass } if (!isset(self::$reflectionCache['classes'][$className])) { - self::$reflectionCache['classes'][$className] = new ReflectionClass($className); + self::rememberCachedEntry( + self::$reflectionCache['classes'], + $className, + new ReflectionClass($className), + ); } return self::$reflectionCache['classes'][$className]; @@ -110,7 +136,11 @@ public static function getEnumReflection(string $enumName): ReflectionEnum } if (!isset(self::$reflectionCache['enums'][$enumName])) { - self::$reflectionCache['enums'][$enumName] = new ReflectionEnum($enumName); + self::rememberCachedEntry( + self::$reflectionCache['enums'], + $enumName, + new ReflectionEnum($enumName), + ); } return self::$reflectionCache['enums'][$enumName]; @@ -120,7 +150,11 @@ public static function getFunctionReflection(string|Closure $function): Reflecti { $key = is_string($function) ? $function : spl_object_hash($function); if (!isset(self::$reflectionCache['functions'][$key])) { - self::$reflectionCache['functions'][$key] = new ReflectionFunction($function); + self::rememberCachedEntry( + self::$reflectionCache['functions'], + $key, + new ReflectionFunction($function), + ); } return self::$reflectionCache['functions'][$key]; @@ -173,6 +207,40 @@ public static function getSignature( return base64_encode("$fileName:$startLine"); } + public static function setCacheLimit(int $limit): void + { + self::$cacheLimit = max(0, $limit); + self::trimAllCachesIfNeeded(); + } + + /** + * @template T + * @param array $cache + */ + private static function evictOldestIfNeeded(array &$cache): void + { + if (self::$cacheLimit <= 0 || count($cache) < self::$cacheLimit) { + return; + } + + $oldestKey = array_key_first($cache); + unset($cache[$oldestKey]); + } + + /** + * @template T + * @param array $cache + * @param T $value + */ + private static function rememberCachedEntry(array &$cache, string $key, mixed $value): void + { + if (!isset($cache[$key])) { + self::evictOldestIfNeeded($cache); + } + + $cache[$key] = $value; + } + /** * @param callable(): ReflectionMethod $resolver */ @@ -181,7 +249,7 @@ private static function rememberMethod(string $key, callable $resolver): Reflect if (!isset(self::$reflectionCache['methods'][$key])) { try { $method = $resolver(); - self::$reflectionCache['methods'][$key] = $method; + self::rememberCachedEntry(self::$reflectionCache['methods'], $key, $method); } catch (Throwable $exception) { throw new InvalidArgumentException($exception->getMessage(), 0, $exception); } @@ -253,4 +321,24 @@ private static function resolveStringCallable(string $callable): ReflectionMetho throw new InvalidArgumentException("Function or method '$callable' does not exist."); } + + private static function trimAllCachesIfNeeded(): void + { + self::trimBucket(self::$reflectionCache['classes']); + self::trimBucket(self::$reflectionCache['enums']); + self::trimBucket(self::$reflectionCache['functions']); + self::trimBucket(self::$reflectionCache['methods']); + } + + /** + * @template T + * @param array $bucket + */ + private static function trimBucket(array &$bucket): void + { + while (self::$cacheLimit > 0 && count($bucket) > self::$cacheLimit) { + $oldestKey = array_key_first($bucket); + unset($bucket[$oldestKey]); + } + } } diff --git a/src/DI/Support/TaggedPipeline.php b/src/DI/Support/TaggedPipeline.php new file mode 100644 index 0000000..4f3b7cd --- /dev/null +++ b/src/DI/Support/TaggedPipeline.php @@ -0,0 +1,54 @@ +passable = $passable; + + return $this; + } + + /** + * @throws ContainerException + */ + public function thenReturn(): mixed + { + $current = $this->passable; + foreach ($this->container->findByTagLazy($this->tag) as $resolver) { + $service = $resolver(); + + if (is_callable($service)) { + $current = $service($current); + + continue; + } + + if (is_object($service) && method_exists($service, 'handle')) { + $current = $service->handle($current); + + continue; + } + + if (is_object($service) && method_exists($service, 'process')) { + $current = $service->process($current); + } + } + + return $current; + } +} diff --git a/src/Remix/MacroMix.php b/src/Remix/MacroMix.php index 40aab83..76b336b 100644 --- a/src/Remix/MacroMix.php +++ b/src/Remix/MacroMix.php @@ -13,6 +13,9 @@ trait MacroMix { + /** @var array */ + protected static array $macroIsStaticClosure = []; + /** * @var array */ @@ -146,6 +149,13 @@ public static function loadMacrosFromConfig(array $config): void public static function macro(string $name, callable|object $macro): void { static::$macros[$name] = $macro; + if ($macro instanceof Closure) { + static::$macroIsStaticClosure[$name] = (new ReflectionFunction($macro))->isStatic(); + + return; + } + + unset(static::$macroIsStaticClosure[$name]); } /** @@ -193,6 +203,7 @@ public static function mix(object|string $mixin, bool $replace = true): void public static function removeMacro(string $name): void { unset(static::$macros[$name]); + unset(static::$macroIsStaticClosure[$name]); } /** @@ -260,7 +271,10 @@ private static function process(?object $bind, string $method, array $parameters $result = null; $closure = $macro; - if ($bind !== null && !(new ReflectionFunction($macro))->isStatic()) { + $isStaticClosure = static::$macroIsStaticClosure[$method] + ??= (new ReflectionFunction($macro))->isStatic(); + + if ($bind !== null && !$isStaticClosure) { $bound = $macro->bindTo($bind, static::class); $closure = $bound instanceof Closure ? $bound : $macro; } diff --git a/src/Serializer/SignedValueSerializer.php b/src/Serializer/SignedValueSerializer.php new file mode 100644 index 0000000..3e5ceff --- /dev/null +++ b/src/Serializer/SignedValueSerializer.php @@ -0,0 +1,34 @@ +key); + + try { + return ValueSerializer::decode($payload, $base64); + } finally { + ValueSerializer::setPayloadSigningKey($previous); + } + } + + public function encode(mixed $value, bool $base64 = true): string + { + $previous = ValueSerializer::currentPayloadSigningKey(); + ValueSerializer::setPayloadSigningKey($this->key); + + try { + return ValueSerializer::encode($value, $base64); + } finally { + ValueSerializer::setPayloadSigningKey($previous); + } + } +} diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php index 05ed744..9d61a72 100644 --- a/src/Serializer/ValueSerializer.php +++ b/src/Serializer/ValueSerializer.php @@ -36,6 +36,11 @@ public static function clearResourceHandlers(): void self::$serializedClosureMemo = []; } + public static function currentPayloadSigningKey(): ?string + { + return self::$payloadSigningKey; + } + /** * Decode a payload produced by {@see encode()}. * @@ -178,6 +183,11 @@ public static function setPayloadSigningKey(?string $key): void self::$serializedClosureMemo = []; } + public static function signed(string $key): SignedValueSerializer + { + return new SignedValueSerializer($key); + } + /** * Unserializes a given string into its original value. * diff --git a/src/functions.php b/src/functions.php index 3ad738d..0c611b0 100644 --- a/src/functions.php +++ b/src/functions.php @@ -24,7 +24,7 @@ */ function container( string|Closure|callable|array|null $closureOrClass = null, - string $alias = __DIR__, + string $alias = Container::DEFAULT_ALIAS, ): mixed { $instance = Container::instance($alias); @@ -40,7 +40,7 @@ function container( * * @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method" * @param array $parameters Parameters for constructor/method/closure - * @param string $alias Optional container instance alias (default: __DIR__) + * @param string $alias Optional container instance alias (default: intermix.di) * @return mixed Container instance (when $spec is null) or resolved return value * @throws ContainerException|ReflectionException|InvalidArgumentException * @param array{0:string,1:string}|null|string|Closure|callable $spec @@ -48,7 +48,7 @@ function container( function resolve( string|Closure|callable|array|null $spec = null, array $parameters = [], - string $alias = __DIR__ . 'DI', + string $alias = Container::DI_ALIAS, ): mixed { $instance = Container::instance($alias); // DI is on by default @@ -64,7 +64,7 @@ function resolve( * * @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method" * @param array $parameters Parameters for constructor/method/closure - * @param string $alias Optional container instance alias (default: __DIR__) + * @param string $alias Optional container instance alias (default: intermix.direct) * @return mixed Container instance (when $spec is null) or resolved return value * @throws ContainerException|ReflectionException|InvalidArgumentException * @param array{0:string,1:string}|null|string|Closure|callable $spec @@ -72,7 +72,7 @@ function resolve( function direct( string|Closure|callable|array|null $spec = null, array $parameters = [], - string $alias = __DIR__ . 'DR', + string $alias = Container::DIRECT_ALIAS, ): mixed { $instance = Container::instance($alias) ->options() diff --git a/tests/Container/AdditionalContainerTest.php b/tests/Container/AdditionalContainerTest.php index 236119a..a30a2d6 100644 --- a/tests/Container/AdditionalContainerTest.php +++ b/tests/Container/AdditionalContainerTest.php @@ -214,3 +214,24 @@ public function doSomething(BasicClass $basic, string $another): array ->end(); $container->call(InfuseUnknownParam::class, 'doSomething'); })->throws(ContainerException::class); + +test('helper default aliases are readable and isolated', function () { + $cDefault = container(); + $cResolve = resolve(); + $cDirect = direct(); + + expect($cDefault)->toBeInstanceOf(Container::class) + ->and($cResolve)->toBeInstanceOf(Container::class) + ->and($cDirect)->toBeInstanceOf(Container::class) + ->and($cDefault)->not->toBe($cResolve) + ->and($cResolve)->not->toBe($cDirect) + ->and($cDefault)->not->toBe($cDirect); +}); + +test('composer autoload files does not include global helpers by default', function () { + $composer = json_decode((string) file_get_contents(__DIR__ . '/../../composer.json'), true, 512, JSON_THROW_ON_ERROR); + $autoload = is_array($composer['autoload'] ?? null) ? $composer['autoload'] : []; + $files = is_array($autoload['files'] ?? null) ? $autoload['files'] : []; + + expect($files)->not->toContain('src/functions.php'); +}); diff --git a/tests/Container/AdditionalTest.php b/tests/Container/AdditionalTest.php index 61c311f..acf1431 100644 --- a/tests/Container/AdditionalTest.php +++ b/tests/Container/AdditionalTest.php @@ -4,7 +4,9 @@ use Infocyph\InterMix\DI\Support\DebugTracer; use Infocyph\InterMix\DI\Support\LifetimeEnum; use Infocyph\InterMix\DI\Support\PreloadGenerator; +use Infocyph\InterMix\DI\Support\ReflectionResource; use Infocyph\InterMix\DI\Support\TraceLevelEnum; +use Infocyph\InterMix\Exceptions\ContainerException; use Infocyph\InterMix\Tests\Fixture\DemoProvider; use Infocyph\InterMix\Tests\Fixture\DummyLogger; use Infocyph\InterMix\Tests\Fixture\FooService; @@ -13,6 +15,180 @@ use Infocyph\InterMix\Tests\Fixture\PaymentGateway; use Infocyph\InterMix\Tests\Fixture\PaypalGateway; use Infocyph\InterMix\Tests\Fixture\StripeGateway; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +class FailingMakeTarget +{ + public function required(string $value): string + { + return $value; + } +} + +interface ContextLogger +{ + public function channel(): string; +} + +class OrderLogger implements ContextLogger +{ + public function channel(): string + { + return 'order'; + } +} + +class BillingLogger implements ContextLogger +{ + public function channel(): string + { + return 'billing'; + } +} + +class OrderService +{ + public function __construct(public ContextLogger $logger) {} +} + +class BillingService +{ + public function __construct(public ContextLogger $logger) {} +} + +class LazyTaggedProbe +{ + public function __construct(public int $id) {} +} + +class CompiledDep {} + +class CompiledSvc +{ + public function __construct(public CompiledDep $dep) {} +} + +class SpyCacheItem implements CacheItemInterface +{ + public function __construct( + private readonly string $key, + private mixed $value = null, + private bool $hit = false, + ) {} + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->hit; + } + + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + public function expiresAfter(int|\DateInterval|null $time): static + { + return $this; + } + + public function expiresAt(?\DateTimeInterface $expiration): static + { + return $this; + } +} + +class SpyCachePool implements CacheItemPoolInterface +{ + /** @var array */ + public array $items = []; + + /** @var array */ + public array $savedValues = []; + + public function getItem(string $key): CacheItemInterface + { + if (!isset($this->items[$key])) { + $this->items[$key] = new SpyCacheItem($key, null, false); + } + + return $this->items[$key]; + } + + public function getItems(array $keys = []): iterable + { + $items = []; + foreach ($keys as $key) { + if (is_string($key)) { + $items[$key] = $this->getItem($key); + } + } + + return $items; + } + + public function hasItem(string $key): bool + { + return isset($this->items[$key]) && $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + $this->savedValues = []; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + if (is_string($key)) { + unset($this->items[$key]); + } + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $stored = new SpyCacheItem($item->getKey(), $item->get(), true); + $this->items[$item->getKey()] = $stored; + $this->savedValues[] = $item->get(); + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } +} it('collects a readable trace', function () { $c = Container::instance('intermix')->options()->enableDebugTracing(); @@ -73,6 +249,63 @@ expect($first)->not->toBe($second); }); +it('stores resolved entries only through lifetime-aware keys', function () { + $c = Container::instance(uniqid('lifetime_keys_')); + $repo = $c->getRepository(); + + $c->definitions()->bind('svc.singleton', fn () => new stdClass(), LifetimeEnum::Singleton); + $c->definitions()->bind('svc.scoped', fn () => new stdClass(), LifetimeEnum::Scoped); + $c->definitions()->bind('svc.transient', fn () => new stdClass(), LifetimeEnum::Transient); + + $c->enterScope('request-a'); + $c->get('svc.singleton'); + $c->get('svc.scoped'); + $c->get('svc.transient'); + + expect($repo->hasResolved('svc.singleton'))->toBeTrue() + ->and($repo->hasResolved('svc.scoped'))->toBeFalse() + ->and($repo->hasResolved('svc.scoped@request-a'))->toBeTrue() + ->and($repo->hasResolved('svc.transient'))->toBeFalse() + ->and($repo->hasResolved('svc.transient@request-a'))->toBeFalse(); + + $c->leaveScope(); +}); + +it('reads scoped getReturn values from the current scope key', function () { + $c = Container::instance(uniqid('scope_return_')); + $repo = $c->getRepository(); + $c->definitions()->bind('scope.return', fn () => new stdClass(), LifetimeEnum::Scoped); + $repo->setResolved('scope.return', ['instance' => new stdClass(), 'returned' => 'stale-base']); + + $repo->setScope('request-1'); + $repo->setResolvedScoped('request-1', 'scope.return@request-1', ['instance' => new stdClass(), 'returned' => 'scope-1']); + $first = $c->getReturn('scope.return'); + + $repo->setScope('request-2'); + $repo->setResolvedScoped('request-2', 'scope.return@request-2', ['instance' => new stdClass(), 'returned' => 'scope-2']); + $second = $c->getReturn('scope.return'); + + expect($first)->toBe('scope-1') + ->and($second)->toBe('scope-2'); +}); + +it('factory binding is deferred until lifetime selection', function () { + $c = Container::instance(uniqid('factory_deferred_')); + $repo = $c->getRepository(); + + $pending = $c->factory( + 'factory.db', + static fn(Container $container): stdClass => $container->get(stdClass::class), + ); + + expect($repo->hasFunctionReference('factory.db'))->toBeFalse(); + + $pending->scoped(); + + expect($repo->hasFunctionReference('factory.db'))->toBeTrue() + ->and($repo->getDefinitionLifetime('factory.db'))->toBe(LifetimeEnum::Scoped); +}); + it('offers the same sugar directly on DefinitionManager', function () { $c = Container::instance(uniqid('mp_')); $def = $c->definitions(); @@ -310,6 +543,181 @@ ->and($empty['edges'])->toBe([]); }); +it('restores tracer configuration after debug inspection', function () { + $c = Container::instance(uniqid('debug_restore_')); + $tracer = $c->tracer(); + $tracer->setLevel(TraceLevelEnum::Off); + $tracer->setCaptureLocation(false); + + $c->debug('missing.service'); + + expect($tracer->level())->toBe(TraceLevelEnum::Off) + ->and($tracer->isCaptureLocationEnabled())->toBeFalse(); +}); + +it('does not leak resolved-resource state when make() fails', function () { + $c = Container::instance(uniqid('make_leak_')); + + expect(fn () => $c->make(FailingMakeTarget::class, 'required')) + ->toThrow(ContainerException::class); + + $resolved = $c->getRepository()->getResolvedResource(); + + expect(array_key_exists(FailingMakeTarget::class, $resolved))->toBeFalse() + ->and($c->getRepository()->getResolvedResourceFor(FailingMakeTarget::class))->toBe([]); +}); + +it('allows class-string self binding and rejects scalar self aliases', function () { + $c = Container::instance(uniqid('self_bind_')); + $c->definitions()->bind(CompiledDep::class, CompiledDep::class); + + expect($c->get(CompiledDep::class))->toBeInstanceOf(CompiledDep::class); + + expect(fn () => $c->definitions()->bind('foo', 'foo')) + ->toThrow(ContainerException::class); +}); + +it('does not persist runtime objects to PSR-6 definition cache by default', function () { + $pool = new SpyCachePool(); + $c = Container::instance(uniqid('cache_safe_')); + $c->definitions()->enableDefinitionCache($pool); + $c->definitions()->bind('unsafe.obj', fn () => new stdClass()); + $c->definitions()->bind('safe.cfg', ['a' => 1, 'b' => ['c' => 2]]); + + $c->get('unsafe.obj'); + $c->get('safe.cfg'); + + expect($pool->savedValues)->toHaveCount(1) + ->and($pool->savedValues[0])->toBe(['a' => 1, 'b' => ['c' => 2]]); +}); + +it('validates callable parsing for malformed method strings', function () { + $c = Container::instance(uniqid('callable_parse_')); + + expect(fn () => $c->parseCallable('Foo@'))->toThrow(ContainerException::class); + expect(fn () => $c->parseCallable('@bar'))->toThrow(ContainerException::class); + expect(fn () => $c->parseCallable('Foo::'))->toThrow(ContainerException::class); + expect(fn () => $c->parseCallable('::bar'))->toThrow(ContainerException::class); +}); + +it('validates class and method existence in callable parsing', function () { + $c = Container::instance(uniqid('callable_exists_')); + + expect(fn () => $c->parseCallable('Missing\\CallableClass@handle')) + ->toThrow(ContainerException::class, 'does not exist'); + + expect(fn () => $c->parseCallable(CompiledSvc::class . '::missingMethod')) + ->toThrow(ContainerException::class, 'does not exist'); +}); + +it('supports contextual binding per consumer', function () { + $c = Container::instance(uniqid('contextual_')); + $c->when(OrderService::class)->needs(ContextLogger::class)->give(OrderLogger::class); + $c->when(BillingService::class)->needs(ContextLogger::class)->give(BillingLogger::class); + + $order = $c->make(OrderService::class); + $billing = $c->make(BillingService::class); + + expect($order->logger->channel())->toBe('order') + ->and($billing->logger->channel())->toBe('billing'); +}); + +it('runs lifecycle hooks for resolving and resolved services', function () { + $c = Container::instance(uniqid('hooks_')); + $events = []; + + $c->onResolving('hook.svc', function (string $id) use (&$events): void { + $events[] = "resolving:$id"; + }); + $c->onResolved('hook.svc', function (string $id) use (&$events): void { + $events[] = "resolved:$id"; + }); + $c->definitions()->bind('hook.svc', fn () => new stdClass(), LifetimeEnum::Transient); + + $c->get('hook.svc'); + + expect($events)->toBe(['resolving:hook.svc', 'resolved:hook.svc']); +}); + +it('runs scope-leave lifecycle hooks', function () { + $c = Container::instance(uniqid('hook_scope_')); + $events = []; + + $c->onScopeLeave('request', function (string $scope) use (&$events): void { + $events[] = "left:$scope"; + }); + + $c->enterScope('request'); + $c->leaveScope(); + + expect($events)->toBe(['left:request']); +}); + +it('returns lazy tagged resolvers without eager instantiation', function () { + $c = Container::instance(uniqid('tag_lazy_')); + $built = 0; + $c->definitions()->bind('lazy.a', function () use (&$built) { + $built++; + + return new LazyTaggedProbe(1); + }, tags: ['lazy.tag']); + + $lazy = $c->tagged('lazy.tag'); + expect($built)->toBe(0); + + foreach ($lazy as $factory) { + $svc = $factory(); + expect($svc)->toBeInstanceOf(LazyTaggedProbe::class); + } + + expect($built)->toBe(1); +}); + +it('can generate and load compiled resolver maps', function () { + $path = sys_get_temp_dir() . '/intermix_compiled_' . uniqid() . '.php'; + + $source = Container::instance(uniqid('compiled_src_')); + $source->definitions()->bind('compiled.dep', CompiledDep::class); + $source->definitions()->bind('compiled.svc', CompiledSvc::class); + $source->compileTo($path); + expect($source->getRepository()->getCompiledResolver('compiled.svc'))->toBeNull(); + + $source->compileTo($path, load: true); + expect($source->getRepository()->getCompiledResolver('compiled.svc'))->toBeCallable(); + + $target = Container::instance(uniqid('compiled_tgt_')); + $target->definitions()->bind('compiled.dep', CompiledDep::class); + $target->definitions()->bind('compiled.svc', CompiledSvc::class); + $target->useCompiled($path); + + expect($target->get('compiled.svc'))->toBeInstanceOf(CompiledSvc::class); +}); + +it('supports bounded reflection cache controls', function () { + ReflectionResource::clearCache(); + ReflectionResource::setCacheLimit(1); + + ReflectionResource::getClassReflection(stdClass::class); + ReflectionResource::getClassReflection(DateTimeImmutable::class); + $stats = ReflectionResource::cacheStats(); + + expect($stats['classes'])->toBeLessThanOrEqual(1) + ->and($stats['limit'])->toBe(1); + + ReflectionResource::setCacheLimit(0); + ReflectionResource::clearCache(); +}); + +it('provides explicit container validation output', function () { + $c = Container::instance(uniqid('validate_')); + $c->definitions()->bind('broken.target', 'Missing\\NotFoundClass'); + + $issues = $c->validate(); + + expect($issues)->not()->toBeEmpty(); + expect(fn () => $c->validate(strict: true))->toThrow(ContainerException::class); +}); + function newTracer( TraceLevelEnum $level = TraceLevelEnum::Node, bool $captureLocation = false, diff --git a/tests/Container/InvokerTest.php b/tests/Container/InvokerTest.php index 4150119..16cb20c 100644 --- a/tests/Container/InvokerTest.php +++ b/tests/Container/InvokerTest.php @@ -164,6 +164,16 @@ public function __construct() } } +class CachedCallableService +{ + public function __construct(private readonly DateTimeZone $timezone) {} + + public function __invoke(): string + { + return $this->timezone->getName(); + } +} + /* 6. Invoker should autowire an argument-less class ----------------*/ it('invokes a closure and autowires a Request instance', function () { $out = $this->inv->invoke( @@ -173,3 +183,31 @@ public function __construct() // just assert we got **some** non-empty UUID back expect($out)->toBeString()->not->toBe('')->toStartWith('req_'); }); + +it('isolates callableFor cache per container', function () { + $c1 = Container::instance(uniqid('invoker_cache_a_')); + $c1->definitions()->bind(DateTimeZone::class, fn () => new DateTimeZone('UTC')); + + $c2 = Container::instance(uniqid('invoker_cache_b_')); + $c2->definitions()->bind(DateTimeZone::class, fn () => new DateTimeZone('Asia/Dhaka')); + + $inv1 = Invoker::with($c1); + $inv2 = Invoker::with($c2); + + $f1 = $inv1->callableFor(CachedCallableService::class); + $f2 = $inv2->callableFor(CachedCallableService::class); + + expect($f1())->toBe('UTC') + ->and($f2())->toBe('Asia/Dhaka'); +}); + +it('invokes closures directly without storing closure aliases', function () { + $before = count($this->c->getRepository()->getClosureResource()); + + for ($i = 0; $i < 5; $i++) { + expect($this->inv->invoke(fn () => 'ok'))->toBe('ok'); + } + + $after = count($this->c->getRepository()->getClosureResource()); + expect($after)->toBe($before); +}); diff --git a/tests/Fixture/MethodAttrResolver.php b/tests/Fixture/MethodAttrResolver.php index 08b5e52..fd6d779 100644 --- a/tests/Fixture/MethodAttrResolver.php +++ b/tests/Fixture/MethodAttrResolver.php @@ -12,7 +12,7 @@ public function resolve( object $attributeInstance, Reflector $target, Container $container - ): mixed { + ): null { // Log, side-effect, or simply acknowledge fwrite( STDERR, diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..00463d5 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,6 @@ + Date: Fri, 22 May 2026 20:05:19 +0600 Subject: [PATCH 3/3] final polishing --- benchmarks/FenceBench.php | 121 +++++++++++++++++++++ docs/container.rst | 10 +- docs/di/cache.rst | 16 +-- docs/di/definitions.rst | 4 +- docs/di/overview.rst | 7 +- docs/di/tagging.rst | 19 +++- docs/fence.rst | 20 ++-- docs/remix.rst | 4 +- src/DI/Managers/InvocationManager.php | 5 +- src/Fence/Fence.php | 149 ++++++++++++-------------- src/functions.php | 4 +- tests/Container/AdditionalTest.php | 69 ++++++++++-- tests/Fence/FenceTraitTest.php | 11 ++ 13 files changed, 310 insertions(+), 129 deletions(-) create mode 100644 benchmarks/FenceBench.php diff --git a/benchmarks/FenceBench.php b/benchmarks/FenceBench.php new file mode 100644 index 0000000..34faf86 --- /dev/null +++ b/benchmarks/FenceBench.php @@ -0,0 +1,121 @@ +newKeyCounter)); + } + + #[BeforeMethods('setUpRequirementCreationPath')] + public function benchRequirementCreationPath(): void + { + FenceBenchMulti::instance( + 'required-' . (++$this->requiredNewKeyCounter), + ['extensions' => ['Core'], 'classes' => [\stdClass::class]], + ); + } + + #[BeforeMethods('setUpRequirementHitPath')] + public function benchRequirementHitPath(): void + { + FenceBenchMulti::instance( + 'required-hit', + ['extensions' => ['nonexistent_ext'], 'classes' => ['Missing\\NotFound']], + ); + } + + #[BeforeMethods('setUpSingleHotPath')] + public function benchSingleInstanceHotPath(): void + { + FenceBenchSingle::instance(); + } + + public function setUpLimitExceededPath(): void + { + FenceBenchLimit::clearInstances(); + FenceBenchLimit::setLimit(1); + FenceBenchLimit::instance('seed'); + } + + public function setUpMultiExistingKeyHotPath(): void + { + FenceBenchMulti::clearInstances(); + FenceBenchMulti::instance('existing'); + } + + public function setUpMultiNewKeyPath(): void + { + $this->newKeyCounter = 0; + FenceBenchMulti::clearInstances(); + } + + public function setUpRequirementCreationPath(): void + { + $this->requiredNewKeyCounter = 0; + FenceBenchMulti::clearInstances(); + } + + public function setUpRequirementHitPath(): void + { + FenceBenchMulti::clearInstances(); + FenceBenchMulti::instance('required-hit'); + } + + public function setUpSingleHotPath(): void + { + FenceBenchSingle::clearInstances(); + FenceBenchSingle::instance(); + } +} + +final class FenceBenchSingle +{ + use Single; +} + +final class FenceBenchMulti +{ + use Multi; +} + +final class FenceBenchLimit +{ + use Limit; +} diff --git a/docs/container.rst b/docs/container.rst index 74b0823..c9d78ec 100644 --- a/docs/container.rst +++ b/docs/container.rst @@ -11,9 +11,9 @@ Dependency Injection (Container) .. code-block:: php - use function Infocyph\InterMix\container; + use Infocyph\InterMix\DI\Container; - $c = container(); // ① create + $c = Container::instance(); // ① create $c->definitions()->bind('answer', 42); // ② register echo $c->get('answer'); // ③ resolve → 42 @@ -54,7 +54,9 @@ environment-specific overrides and gives you sugar for 1-liners. .. code-block:: php - $c = container() + use Infocyph\InterMix\DI\Container; + + $c = Container::instance() ->definitions()->bind(Clock::class, SystemClock::class)->end(); echo $c->get(Greeter::class)->greet('Alice'); @@ -117,7 +119,7 @@ Common quick patterns (copy-paste as you learn the rest) $c->definitions()->bind('L1', fn()=>new ListenerA, tags:['event']); foreach ($c->findByTag('event') as $id => $listener) { - $listener()->handle(); + $listener->handle(); } *Scope isolation* :: diff --git a/docs/di/cache.rst b/docs/di/cache.rst index 49c8f9c..e206a5a 100644 --- a/docs/di/cache.rst +++ b/docs/di/cache.rst @@ -4,13 +4,14 @@ Definition-level Caching ======================== -InterMix supports caching of resolved singleton definitions through an assigned -PHP-FIG PSR-6 cache pool. +InterMix supports PSR-6 backed definition/cache warmup for safe cacheable definition data. +By default, live runtime objects are not persisted to PSR-6. +Singleton/scoped object reuse remains in the in-memory container lifetime store. Why care? -* **Speed** – skip repeated reflection and factory execution. -* **Predictability** – warm and reuse resolved singleton entries. +* **Speed** – skip repeated reflection and definition work for cache-safe values. +* **Predictability** – warm and reuse cacheable definition data. * **Flexibility** – use any PSR-6 implementation (for example from ``infocyph/cachelayer``). --------------- @@ -33,12 +34,15 @@ Quick Example 🚀 // ② bind as usual $c->definitions()->bind('answer', 42); - // ③ first call resolves + saves + // ③ first call resolves + saves when the value is cache-safe $val = $c->get('answer'); - // ④ next calls are read from cache + // ④ scalar/array-safe definitions may be loaded from PSR-6 echo $c->get('answer'); +Scalar/array-safe definitions may be persisted. +Runtime service objects are kept in container memory unless ``cacheRuntimeObjects`` is explicitly enabled. + Eager warm-up: .. code-block:: php diff --git a/docs/di/definitions.rst b/docs/di/definitions.rst index 09c92da..389c210 100644 --- a/docs/di/definitions.rst +++ b/docs/di/definitions.rst @@ -78,8 +78,8 @@ into internal lazy initialisers. $def->bind('L1', fn () => new ListenerA(), tags: ['event']); $def->bind('L2', fn () => new ListenerB(), tags: ['event']); - foreach ($c->findByTag('event') as $id => $factory) { - $factory()->handle(); + foreach ($c->findByTag('event') as $id => $listener) { + $listener->handle(); } Use tags for plug-in systems, domain events, command buses, etc. diff --git a/docs/di/overview.rst b/docs/di/overview.rst index 1ad6ae6..1793b9b 100644 --- a/docs/di/overview.rst +++ b/docs/di/overview.rst @@ -159,10 +159,9 @@ synchronise that cache manually. Parameter resolution cache note ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -InterMix caches **resolution plans** (reflector metadata such as parameters and -attribute presence) across constructor/method/property paths. -Resolved argument **values** are only memoised for constructor paths, so -non-constructor method calls keep per-invocation behavior. +InterMix caches parameter resolution plans, attribute metadata and reflection-derived lookup data. +It does not cache resolved runtime argument values inside ParameterResolver. +Runtime object reuse is controlled by the container lifetime layer: Singleton, Scoped or Transient. Typical lifecycle ----------------- diff --git a/docs/di/tagging.rst b/docs/di/tagging.rst index 513602d..d2537b6 100644 --- a/docs/di/tagging.rst +++ b/docs/di/tagging.rst @@ -32,17 +32,26 @@ Retrieve All by Tag 📬 .. code-block:: php - foreach ($c->findByTag('event') as $id => $factory) { - $factory()->handle($event); + foreach ($c->findByTag('event') as $id => $listener) { + $listener->handle($event); } * :php:meth:`Infocyph\\InterMix\\DI\\Container::findByTag` returns an **array**: - ``[id => callable|object, …]`` -* Services are resolved **lazily** – if the definition was a class string the - container still honours lazy loading & lifetimes. + ``[id => resolved service, …]`` +* ``findByTag()`` is eager and resolves the tagged services immediately. +* For lazy factories, use :php:meth:`Infocyph\\InterMix\\DI\\Container::tagged` + (or ``findByTagLazy()``). * Tag lookup is **environment-aware**. If you override tags with ``setDefinitionMetaForEnv(...)``, ``findByTag()`` uses the active environment. +Lazy variant: + +.. code-block:: php + + foreach ($c->tagged('event') as $id => $factory) { + $factory()->handle($event); + } + ----------------------- Multiple Tags per ID ----------------------- diff --git a/docs/fence.rst b/docs/fence.rst index b7df132..cf47a31 100644 --- a/docs/fence.rst +++ b/docs/fence.rst @@ -21,23 +21,24 @@ Key Concepts Implements all logic for requirement checks, keyed vs. singleton behavior, and instance‐count limits. You do **not** use ``Fence`` directly; one of the three -wrapper traits sets two class‐constants and the core logic runs on every -``::instance()`` call. +wrapper traits sets two class‐constants and the core logic resolves/retrieves +instances through ``::instance()``. **Singleton (Single)** -Classes define ``FENCE_KEYED = true``. Only one instance can exist. -Key is optional; defaults to ``'__single'``. +Classes define ``FENCE_KEYED = false``. Only one instance can exist. +Key is ignored and stored in the internal ``'__single'`` slot. **Multiton (Multi)** -Classes define ``FENCE_KEYED = false``. Multiple instances allowed, keyed by first argument. -Key is required; defaults to ``'default'``. +Classes define ``FENCE_KEYED = true``. Multiple instances allowed, keyed by first argument. +If omitted, key defaults to ``'default'``. **Limited Multiton (Limit)** Extends Multi with configurable instance limits. -Classes define ``FENCE_LIMIT = ``. +Classes define ``FENCE_KEYED = true`` and ``FENCE_LIMIT = ``. +Caller-provided keys are used until the configured limit is reached. ``setLimit(int)`` changes the limit at runtime. **Constants Available** @@ -49,7 +50,7 @@ Classes define ``FENCE_LIMIT = ``. ``::instance()`` accepts optional constraints array with ``extensions`` and/or ``classes``. If any extension or class is missing, a ``RequirementException`` is thrown -before any instance is created. +before a **new** instance is created. **New Features Added** @@ -125,7 +126,7 @@ before any instance is created. ``::instance()`` accepts an optional constraints array with ``extensions`` and/or ``classes``. If any extension or class is missing, a ``RequirementException`` is thrown -before any instance is created. +before any new instance is created. **Limit Enforcement** @@ -234,3 +235,4 @@ Best Practices * If you need per‐key instances, use ``Multi``. * If you want to cap how many objects can coexist, use ``Limit``. * To enforce startup requirements, pass a constraints array to ``::instance()`` and catch ``RequirementException``. +* Prefer ``countInstances()``, ``hasInstance()`` and ``getKeys()`` for runtime inspection; use ``getInstances()`` mainly for debug/tests. diff --git a/docs/remix.rst b/docs/remix.rst index 6174f52..e9a4453 100644 --- a/docs/remix.rst +++ b/docs/remix.rst @@ -4,8 +4,8 @@ Remix – Fluent PHP Helpers =========================== -``Infocyph\InterMix\Remix`` is a small collection of *opt-in* traits, proxies and -global helper functions that add fluency to any PHP codebase. Each component is purely “opt-in.” +``Infocyph\InterMix\Remix`` is a small collection of *opt-in* optional helper functions +and fluent traits/proxies. Each component is purely “opt-in.” You pick and choose only what you need. .. toctree:: diff --git a/src/DI/Managers/InvocationManager.php b/src/DI/Managers/InvocationManager.php index 11f7b63..e7e4456 100644 --- a/src/DI/Managers/InvocationManager.php +++ b/src/DI/Managers/InvocationManager.php @@ -42,7 +42,8 @@ public function __construct( * does the following: * * 1. If $classOrClosure is a string and exists in the function references, - * the definition is resolved using the RepositoryResolver. + * the definition is resolved through get() so lifetime/scope caches and + * lifecycle hooks are preserved. * * 2. If $classOrClosure is a closure or callable, the closure is invoked * with resolved parameters using the RepositoryResolver. @@ -67,7 +68,7 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $ // 1) If string & in functionReference if (is_string($classOrClosure) && $this->repository->hasFunctionReference($classOrClosure)) { - return $resolver->resolveByDefinition($classOrClosure); + return $this->get($classOrClosure); } // 2) If a closure/callable diff --git a/src/Fence/Fence.php b/src/Fence/Fence.php index 49cbe9b..9b281be 100644 --- a/src/Fence/Fence.php +++ b/src/Fence/Fence.php @@ -11,32 +11,24 @@ trait Fence { - /** @var array|null */ - private static ?array $cachedClasses = null; + private const string SINGLE_SLOT = '__single'; - /** @var array|null */ - private static ?array $cachedExtensions = null; + /** @var array */ + private static array $classExistsCache = []; - /** @var array dynamic overrides of limits per class */ - private static array $classLimits = []; - - private static int $instanceCount = 0; + /** @var array */ + private static array $extensionExistsCache = []; /** @var array */ private static array $instances = []; - /** @var array keyed flag cache */ - private static array $keyedCache = []; - - /** @var array default limits (before setLimit()) */ - private static array $limitCache = []; + private static ?int $limitOverride = null; /** * Resets the internal cache of instances. This is mostly useful for unit tests. */ final public static function clearInstances(): void { - self::$instanceCount = 0; self::$instances = []; } @@ -47,7 +39,7 @@ final public static function clearInstances(): void */ final public static function countInstances(): int { - return self::$instanceCount; + return count(self::$instances); } /** @@ -89,11 +81,7 @@ final public static function getKeys(): array */ final public static function hasInstance(?string $key = 'default'): bool { - $slot = self::isKeyed(static::class) - ? ($key ?? 'default') - : '__single'; - - return isset(self::$instances[$slot]); + return isset(self::$instances[self::slotFor($key)]); } /** @@ -112,25 +100,25 @@ final public static function instance( ?string $key = 'default', ?array $constraints = null, ): static { - self::checkRequirements($constraints); - - $slot = self::isKeyed(static::class) - ? ($key ?? 'default') - : '__single'; + $slot = self::slotFor($key); // Fast path – instance already exists - if (isset(self::$instances[$slot]) && self::$instances[$slot] instanceof static) { - return self::$instances[$slot]; + if (isset(self::$instances[$slot])) { + /** @var static $instance */ + $instance = self::$instances[$slot]; + + return $instance; } - if (self::$instanceCount >= self::getLimit(static::class)) { + self::checkRequirements($constraints); + + $limit = self::getLimit(); + if (count(self::$instances) >= $limit) { throw new LimitExceededException( - 'Instance limit of ' . self::getLimit(static::class) . ' exceeded for ' . static::class, + 'Instance limit of ' . $limit . ' exceeded for ' . static::class, ); } - self::$instanceCount++; - $created = new static(); self::$instances[$slot] = $created; @@ -147,7 +135,7 @@ final public static function setLimit(int $n): void if ($n < 1) { throw new InvalidArgumentException('Limit must be at least 1'); } - self::$classLimits[static::class] = $n; + self::$limitOverride = $n; } /** @@ -158,12 +146,10 @@ final public static function setLimit(int $n): void */ private static function checkRequirements(?array $c): void { - if (self::hasNoRequirements($c)) { + if ($c === null || (($c['extensions'] ?? []) === [] && ($c['classes'] ?? []) === [])) { return; } - self::warmRequirementCaches(); - $missingE = self::findMissingExtensions((array) ($c['extensions'] ?? [])); $missingC = self::findMissingClasses((array) ($c['classes'] ?? [])); @@ -182,7 +168,20 @@ private static function checkRequirements(?array $c): void */ private static function findMissingClasses(array $required): array { - return array_diff($required, self::$cachedClasses ?? []); + $missing = []; + + foreach ($required as $class) { + if ($class === '') { + continue; + } + + self::$classExistsCache[$class] ??= class_exists($class); + if (!self::$classExistsCache[$class]) { + $missing[] = $class; + } + } + + return $missing; } /** @@ -191,7 +190,21 @@ private static function findMissingClasses(array $required): array */ private static function findMissingExtensions(array $required): array { - return array_diff($required, self::$cachedExtensions ?? []); + $missing = []; + + foreach ($required as $extension) { + if ($extension === '') { + continue; + } + + $cacheKey = strtolower($extension); + self::$extensionExistsCache[$cacheKey] ??= extension_loaded($extension); + if (!self::$extensionExistsCache[$cacheKey]) { + $missing[] = $extension; + } + } + + return $missing; } /** @@ -212,66 +225,36 @@ private static function formatMissingRequirements(array $missingE, array $missin } /** - * Returns the instance limit for the given class. - * - * If `$classLimits[$cls]` is set, it takes precedence. Otherwise, if - * the class defines `FENCE_LIMIT`, that value is used. Otherwise, - * the limit is infinite (`PHP_INT_MAX`). + * Returns the instance limit for the current class. */ - private static function getLimit(string $cls): int + private static function getLimit(): int { - if (isset(self::$classLimits[$cls])) { - return self::$classLimits[$cls]; + if (self::$limitOverride !== null) { + return self::$limitOverride; } - if (!defined("$cls::FENCE_LIMIT")) { - self::$classLimits[$cls] = PHP_INT_MAX; - + $className = static::class; + if (!defined("$className::FENCE_LIMIT")) { return PHP_INT_MAX; } - $rawLimit = constant("$cls::FENCE_LIMIT"); - $limit = match (true) { - is_int($rawLimit) => $rawLimit, - is_string($rawLimit) && is_numeric($rawLimit) => (int) $rawLimit, - is_float($rawLimit) => (int) $rawLimit, - default => PHP_INT_MAX, - }; - self::$classLimits[$cls] = $limit; - - return $limit; + return (int) constant("$className::FENCE_LIMIT"); } - /** - * @param array{extensions?: array, classes?: array}|null $c - */ - private static function hasNoRequirements(?array $c): bool + private static function isKeyed(): bool { - if (!$c) { - return true; + $className = static::class; + if (defined("$className::FENCE_KEYED")) { + return (bool) constant("$className::FENCE_KEYED"); } - return ($c['extensions'] ?? []) === [] && ($c['classes'] ?? []) === []; + return true; } - /** - * Check if the given class is keyed. - * - * If the class defines a constant `FENCE_KEYED`, its value is used. - * Otherwise, the default is to be keyed (`true`). - * - * @param string $cls The class to check. - * @return bool Whether the class is keyed. - */ - private static function isKeyed(string $cls): bool + private static function slotFor(?string $key): string { - return self::$keyedCache[$cls] - ??= !defined("$cls::FENCE_KEYED") || (bool) constant("$cls::FENCE_KEYED"); - } - - private static function warmRequirementCaches(): void - { - self::$cachedExtensions ??= get_loaded_extensions(); - self::$cachedClasses ??= get_declared_classes(); + return self::isKeyed() + ? ($key ?? 'default') + : self::SINGLE_SLOT; } } diff --git a/src/functions.php b/src/functions.php index 0c611b0..f5a99dc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -40,7 +40,7 @@ function container( * * @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method" * @param array $parameters Parameters for constructor/method/closure - * @param string $alias Optional container instance alias (default: intermix.di) + * @param string $alias Optional container instance alias (default: Container::DI_ALIAS) * @return mixed Container instance (when $spec is null) or resolved return value * @throws ContainerException|ReflectionException|InvalidArgumentException * @param array{0:string,1:string}|null|string|Closure|callable $spec @@ -64,7 +64,7 @@ function resolve( * * @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method" * @param array $parameters Parameters for constructor/method/closure - * @param string $alias Optional container instance alias (default: intermix.direct) + * @param string $alias Optional container instance alias (default: Container::DIRECT_ALIAS) * @return mixed Container instance (when $spec is null) or resolved return value * @throws ContainerException|ReflectionException|InvalidArgumentException * @param array{0:string,1:string}|null|string|Closure|callable $spec diff --git a/tests/Container/AdditionalTest.php b/tests/Container/AdditionalTest.php index acf1431..ba841a7 100644 --- a/tests/Container/AdditionalTest.php +++ b/tests/Container/AdditionalTest.php @@ -10,8 +10,6 @@ use Infocyph\InterMix\Tests\Fixture\DemoProvider; use Infocyph\InterMix\Tests\Fixture\DummyLogger; use Infocyph\InterMix\Tests\Fixture\FooService; -use Infocyph\InterMix\Tests\Fixture\ListenerA; -use Infocyph\InterMix\Tests\Fixture\ListenerB; use Infocyph\InterMix\Tests\Fixture\PaymentGateway; use Infocyph\InterMix\Tests\Fixture\PaypalGateway; use Infocyph\InterMix\Tests\Fixture\StripeGateway; @@ -62,6 +60,18 @@ class LazyTaggedProbe public function __construct(public int $id) {} } +class TagEvent {} + +class TaggedListener +{ + public function __construct(public string $name) {} + + public function handle(object $event): string + { + return $this->name . ':' . $event::class; + } +} + class CompiledDep {} class CompiledSvc @@ -289,6 +299,23 @@ public function commit(): bool ->and($second)->toBe('scope-2'); }); +it('routes call() definition IDs through lifetime-aware get()', function () { + $c = Container::instance(uniqid('call_lifetime_')); + $c->definitions()->bind('svc.scoped', fn () => new stdClass(), LifetimeEnum::Scoped); + $c->definitions()->bind('svc.transient', fn () => new stdClass(), LifetimeEnum::Transient); + + $c->enterScope('request-a'); + $firstScoped = $c->call('svc.scoped'); + $secondScoped = $c->call('svc.scoped'); + $firstTransient = $c->call('svc.transient'); + $secondTransient = $c->call('svc.transient'); + + expect($firstScoped)->toBe($secondScoped) + ->and($firstTransient)->not->toBe($secondTransient) + ->and($c->getRepository()->hasResolved('svc.scoped@request-a'))->toBeTrue() + ->and($c->getRepository()->hasResolved('svc.transient@request-a'))->toBeFalse(); +}); + it('factory binding is deferred until lifetime selection', function () { $c = Container::instance(uniqid('factory_deferred_')); $repo = $c->getRepository(); @@ -390,16 +417,38 @@ public function commit(): bool ->and($c->cfg)->toBe($c('cfg')); }); -it('collects services by tag', function () { - $c = Container::instance('intermix'); +it('resolves findByTag() eagerly and tagged() lazily', function () { + $c = Container::instance(uniqid('tag_modes_')); + $eagerBuilt = 0; + $lazyBuilt = 0; + $event = new TagEvent(); + + $c->definitions()->bind('event.eager', function () use (&$eagerBuilt) { + $eagerBuilt++; - $c->definitions()->bind('A', fn () => new ListenerA(), tags: ['event']); - $c->definitions()->bind('B', fn () => new ListenerB(), tags: ['event']); + return new TaggedListener('eager'); + }, tags: ['event.eager']); + $c->definitions()->bind('event.lazy', function () use (&$lazyBuilt) { + $lazyBuilt++; + + return new TaggedListener('lazy'); + }, tags: ['event.lazy']); + + $resolved = $c->findByTag('event.eager'); + expect($eagerBuilt)->toBe(1) + ->and($resolved['event.eager'])->toBeInstanceOf(TaggedListener::class) + ->and($resolved['event.eager']->handle($event))->toBe('eager:' . TagEvent::class); + + $lazyResolvers = $c->tagged('event.lazy'); + expect($lazyBuilt)->toBe(0); + + foreach ($lazyResolvers as $factory) { + $listener = $factory(); + expect($listener)->toBeInstanceOf(TaggedListener::class) + ->and($listener->handle($event))->toBe('lazy:' . TagEvent::class); + } - $all = $c->findByTag('event'); // method defined in Container :contentReference[oaicite:0]{index=0} - expect($all)->toHaveCount(2) - ->and($all['A']())->toBe('A') - ->and($all['B']())->toBe('B'); + expect($lazyBuilt)->toBe(1); }); it('lets me wire and use services in one-liners', function () { diff --git a/tests/Fence/FenceTraitTest.php b/tests/Fence/FenceTraitTest.php index 99f15a6..e1d04f5 100644 --- a/tests/Fence/FenceTraitTest.php +++ b/tests/Fence/FenceTraitTest.php @@ -43,6 +43,17 @@ class DummyFence ->not->toThrow(RequirementException::class); // 'json' is typically loaded }); +test('existing instance retrieval bypasses requirement checks', function () { + $first = DummyFence::instance('cached'); + + $second = DummyFence::instance('cached', [ + 'extensions' => ['nonexistent_ext'], + 'classes' => ['NonExistentClass'], + ]); + + expect($second)->toBe($first); +}); + test('introspection methods work', function () { DummyFence::instance('one'); DummyFence::instance('two');