diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..ee3ce82 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,52 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +early_access: false +tone_instructions: >- + Expert PHP code reviewer. Focus on type safety, PSR-12 compliance, + PHP 8.2 compatibility, and security. This is a utility library + supporting PHP 8.2 through 8.5. + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + path_instructions: + - path: "src/**/*.php" + instructions: >- + Review for PHP 8.2-8.5 compatibility. PHP 8.0 features (union types, + named arguments, match expressions, constructor promotion, nullsafe + operator) are allowed. PHP 8.1 features (enums, readonly properties, + intersection types, fibers, first-class callable syntax) are allowed. + PHP 8.2 features (readonly classes, DNF types, standalone true/false/null + types, constants in traits) are allowed. Avoid PHP 8.3+ features. + Check for proper PSR-12 code style. + Check for SQL injection risks — all queries must use parameterized + statements via XOOPS database handlers. + - path: "tests/**/*.php" + instructions: >- + Review test code for proper assertions, test isolation, and edge + case coverage. Tests must work across PHPUnit 9.6, 10, and 11. + +chat: + auto_reply: true + +tools: + phpcs: + enabled: true + phpstan: + enabled: true + gitleaks: + enabled: true + markdownlint: + enabled: true + yamllint: + enabled: true + github-checks: + enabled: true + timeout_ms: 90000 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a30ab5c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[*.json] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c2ea351 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,36 @@ +# Auto-detect text files and normalize line endings +* text=auto eol=lf + +# PHP files +*.php text eol=lf + +# Documentation +*.md text eol=lf +*.txt text eol=lf + +# Config files +*.json text eol=lf +*.xml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.neon text eol=lf + +# Exclude from distribution archives (composer --prefer-dist) +/.github/ export-ignore +/tests/ export-ignore +/docs export-ignore +.github/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.editorconfig export-ignore +.scrutinizer.yml export-ignore +phpunit.xml export-ignore +phpstan.neon export-ignore +stubs/ export-ignore +qodana.yaml export-ignore +renovate.json export-ignore +.coderabbit.yaml export-ignore +sonar-project.properties export-ignore +CHANGELOG.md export-ignore +TUTORIAL.md export-ignore +CLAUDE.md export-ignore \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d7056da --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: Report a defect or regression in XOOPS Helpers. +title: "[Bug]: " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Describe the bug and the expected behavior. + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: "8.2.30" + validations: + required: true + - type: input + id: xoops_version + attributes: + label: XOOPS version + placeholder: "2.5.x / 2.6.x / custom" + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Include a minimal code sample or exact steps. + render: php + validations: + required: true + - type: textarea + id: logs + attributes: + label: Errors or logs + description: Paste stack traces, warnings, or screenshots if available. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cf6c684 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/xoops/xoops-helpers/security/policy + about: Report security issues privately through the repository security policy. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..691861a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Propose a new helper, adapter, or integration. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Describe the gap or developer pain point. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API or behavior you want. + validations: + required: true + - type: textarea + id: compatibility + attributes: + label: Compatibility notes + description: Note any XOOPS, PHP, or BC concerns. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..540852c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +- Explain what changed. +- Link any related issue or discussion. + +## Validation + +- [ ] `composer validate --strict` +- [ ] `composer test` +- [ ] `composer analyse` + +## Notes + +- Mention any compatibility, migration, or follow-up work. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..138a95b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e1f3472 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} - ${{ matrix.stability }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + stability: [prefer-stable] + include: + - php: '8.2' + stability: prefer-lowest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, zip + coverage: none + + - name: Install dependencies + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Validate composer.json + run: composer validate --strict + + - name: Run tests + run: composer test + + static-analysis: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Validate composer.json + run: composer validate --strict + + - name: Run PHPStan + run: composer analyse -- --no-progress diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000..a0cdb12 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,38 @@ +name: Code Coverage + +on: + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + coverage: + name: Coverage Report + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Generate coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..716a78f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: '30 3 * * 1' + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: php + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..68468c0 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,19 @@ +name: Dependency Review + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..764706f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ +/coverage/ +/.php-cs-fixer.cache +/.phpstan-result-cache/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..dd43372 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,20 @@ +build: + environment: + php: 8.2 + nodes: + analysis: + tests: + override: + - php-scrutinizer-run + +filter: + excluded_paths: + - '_archive/*' + - 'tests/*' + - 'vendor/*' + - 'docs/*' + dependency_paths: + - 'stubs/' + +tools: + php_analyzer: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83efbee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog and this project follows Semantic Versioning where practical. + +## [Unreleased] + +### Fixed + +- Resolved a fatal inheritance conflict in `XoopsCollection`. +- Hardened `Optional` so non-object method calls return `null` instead of throwing. +- Corrected `Arr::isAssoc([])` to return `false`. +- Added integration coverage for XOOPS collection helpers. + +### Added + +- Added PHPStan configuration and XOOPS stubs for static analysis. +- Added repository health files, issue forms, PR template, and dependency/security workflows. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..6f2256e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @MAMBAX7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d54bd5d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +## Scope + +XOOPS Helpers aims to stay small, dependency-light, and compatible with PHP 8.2 through 8.5. New helpers should be broadly reusable and justified by repeated XOOPS development needs. + +## Development workflow + +1. Create a feature branch from `master`. +2. Keep changes focused on one bugfix or feature. +3. Add or update tests for every behavior change. +4. Run the local checks before opening a pull request. + +## Local checks + +```bash +composer validate --strict +composer test +composer analyse +``` + +## Coding expectations + +- Use `declare(strict_types=1)` in PHP files. +- Prefer explicit types and small, composable methods. +- Keep pure utilities framework-agnostic when possible. +- Avoid breaking public APIs without documenting the change in `CHANGELOG.md`. +- Add integration tests for XOOPS- or Smarty-specific behavior. + +## Pull requests + +- Explain the problem, the approach, and any compatibility impact. +- Link related issues when available. +- Call out BC breaks explicitly. diff --git a/LICENSE b/LICENSE index d159169..b037395 100644 --- a/LICENSE +++ b/LICENSE @@ -1,339 +1,15 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 +XOOPS Helpers +Copyright (C) XOOPS Development Team - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. - Preamble +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md index d44fdb5..fa7d7bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,285 @@ -# helpers \ No newline at end of file +# XOOPS Helpers + +Convention-over-configuration utility and service helpers for XOOPS CMS development. + +[![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) +[![PHP 8.2+](https://img.shields.io/badge/PHP-8.2%2B-777BB4.svg)](https://www.php.net/) + +**41 source files. 151 tests. Zero configuration. One `composer require`.** + +## What Is This? + +XOOPS Helpers is a standalone utility library that replaces the boilerplate code every XOOPS module developer writes over and over: + +```php +// Before — scattered across every XOOPS module +$url = XOOPS_URL . '/modules/' . $dirname . '/article.php?id=' . $id; +$path = XOOPS_ROOT_PATH . '/modules/' . $dirname . '/language/' . $language . '/blocks.php'; +$escaped = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); +$sitename = $GLOBALS['xoopsConfig']['sitename']; + +// After +$url = Url::module($dirname, 'article.php', ['id' => $id]); +$path = Path::module($dirname, "language/{$language}/blocks.php"); +$escaped = HtmlBuilder::escape($value); +$sitename = Config::get('system.sitename'); +``` + +## Requirements + +- PHP 8.2 or later with the `ext-mbstring` extension +- No other runtime dependencies + +Optional extensions for enhanced functionality: +- `ext-intl` — locale-aware number and date formatting +- `ext-apcu` — APCu caching backend +- `ext-zip` — zip/unzip filesystem operations + +## Installation + +```bash +composer require xoops/helpers +``` + +## Quick Start + +```php +use Xoops\Helpers\Service\Url; +use Xoops\Helpers\Service\Path; +use Xoops\Helpers\Service\Config; +use Xoops\Helpers\Service\Cache; +use Xoops\Helpers\Utility\Arr; +use Xoops\Helpers\Utility\Str; +use Xoops\Helpers\Utility\Number; +use Xoops\Helpers\Utility\HtmlBuilder; +use Xoops\Helpers\Utility\Collection; + +// URLs — zero concatenation +Url::module('news', 'article.php', ['id' => 42]); +Url::asset('themes/starter/css/style.css'); +Url::theme('starter', 'images/logo.png'); + +// Paths — cross-platform, always correct +Path::module('news', 'language/english/main.php'); +Path::storage('caches/xmf'); +Path::uploads('images/avatars'); + +// Config — dot notation, auto-cached +Config::get('system.sitename', 'XOOPS'); +Config::get('news.items_per_page', 10); + +// Cache — compute-and-cache in one call +$articles = Cache::remember('news_latest', 3600, fn() => loadArticles()); + +// Arrays — dot notation, pluck, group, filter +$value = Arr::get($config, 'database.host', 'localhost'); +$names = Arr::pluck($users, 'uname', 'uid'); +$grouped = Arr::groupBy($articles, 'category_id'); + +// Strings — slug, validation, case conversion +Str::slug('Hello World'); // "hello-world" +Str::isEmail('a@example.com'); // true +Str::camel('module_config'); // "moduleConfig" +Str::limit($body, 150); // "First 150 chars..." +Str::random(32); // cryptographically secure + +// Numbers — human-readable formatting +Number::fileSize(1572864); // "1.50 MB" +Number::forHumans(2300000); // "2.3M" +Number::ordinal(21); // "21st" +Number::currency(99.99, 'EUR', 'de_DE'); + +// HTML — XSS-safe by design +HtmlBuilder::attributes(['class' => 'btn', 'disabled' => true, 'data-id' => $userInput]); +HtmlBuilder::classes(['btn', 'btn-primary' => $isPrimary, 'disabled' => false]); +HtmlBuilder::tag('div', ['class' => 'alert'], $message); + +// Collections — fluent data transformation +Collection::make($items) + ->filter(fn($item) => $item['active']) + ->sortBy('name') + ->pluck('title', 'id') + ->all(); +``` + +## Library Contents + +### Tier 0 — Utility (Pure PHP, zero XOOPS dependency) + +These work anywhere — CLI scripts, cron jobs, unit tests — no XOOPS boot required. + +| Class | Purpose | +|-------|---------| +| [`Arr`](src/Utility/Arr.php) | Array helpers with dot notation: `get`, `set`, `has`, `pluck`, `groupBy`, `sortBy`, `where`, `flatten`, `dot`/`undot`, `only`/`except`, `first`/`last`, `wrap`, `collapse` | +| [`Str`](src/Utility/Str.php) | String helpers: `slug`, `camel`/`snake`/`studly`/`kebab`, `limit`, `random`, `contains`/`startsWith`/`endsWith`, `between`, `mask`, `isEmail`/`isUrl`/`isIp`/`isJson`/`isHexColor` | +| [`Number`](src/Utility/Number.php) | Number formatting: `format`, `fileSize`, `forHumans`, `percentage`, `ordinal`, `currency`, `clamp` | +| [`Date`](src/Utility/Date.php) | Date helpers with injectable time source: `now`, `range`, `diff`, `isValid`, `addDays`/`subDays`, `isWeekend`/`isToday`/`isPast`/`isFuture`, `reformat`, `age` | +| [`Value`](src/Utility/Value.php) | Value resolution: `value` (Closure resolver), `blank`/`filled`, `optional` (null-safe access), `once` (memoization), `missing` (sentinel) | +| [`Collection`](src/Utility/Collection.php) | Fluent array wrapper: `map`, `filter`, `reject`, `reduce`, `pluck`, `groupBy`, `sortBy`, `first`/`last`, `chunk`, `take`/`skip`, `sum`/`avg`/`min`/`max`, `when`, `pipe`, `tap` | +| [`Pipeline`](src/Utility/Pipeline.php) | Data transformation chains: `Pipeline::send($v)->pipe(fn)->pipe(fn)->thenReturn()` | +| [`Stringable`](src/Utility/Stringable.php) | Fluent string builder: `Stringable::of($s)->trim()->lower()->slug()->toString()` | +| [`HtmlBuilder`](src/Utility/HtmlBuilder.php) | XSS-safe HTML: `attributes`, `classes`, `tag`, `escape`, `stylesheet`, `script`, `meta` | +| [`Filesystem`](src/Utility/Filesystem.php) | File operations: `readJson`/`putJson`, `mimeType`, `isImage`, `mkdir`, `deleteDirectory`, `copyDirectory`, `zip`/`unzip`, `readChunked` | +| [`Environment`](src/Utility/Environment.php) | Runtime detection: `isProduction`/`isDevelopment`/`isTesting`, `get`/`require`/`has` | +| [`Benchmark`](src/Utility/Benchmark.php) | Profiling: `measure` (time + memory), `time`, `average` (multi-iteration) | +| [`Encoding`](src/Utility/Encoding.php) | URL-safe base64: `base64UrlEncode`/`base64UrlDecode` | +| [`Data`](src/Utility/Data.php) | Conversion: `toArray`, `toObject`, `toQueryString`, `fromQueryString` | +| [`Retry`](src/Utility/Retry.php) | Error recovery: `retry` (with backoff), `rescue` (with fallback) | +| [`ThrowHelper`](src/Utility/ThrowHelper.php) | Guard clauses: `throwIf`, `throwUnless` | +| [`Transform`](src/Utility/Transform.php) | Conditional transforms: `transform` (if filled), `when` (predicate-based) | +| [`Tap`](src/Utility/Tap.php) | Side-effect helper: call callback, return original value | + +### Tier 1 — Contracts (Interfaces) + +| Interface | Purpose | +|-----------|---------| +| [`PathLocatorInterface`](src/Contracts/PathLocatorInterface.php) | Filesystem path resolution | +| [`UrlGeneratorInterface`](src/Contracts/UrlGeneratorInterface.php) | URL generation | +| [`CacheInterface`](src/Contracts/CacheInterface.php) | Cache operations | +| [`ConfigProviderInterface`](src/Contracts/ConfigProviderInterface.php) | Configuration loading | +| [`DateTimeProviderInterface`](src/Contracts/DateTimeProviderInterface.php) | Clock abstraction for testing | + +### Tier 2 — Service Facades (Zero-config, XOOPS-aware) + +| Facade | Purpose | Override | +|--------|---------|----------| +| [`Path`](src/Service/Path.php) | `Path::base()`, `module()`, `storage()`, `uploads()`, `themes()` | `Path::use($locator)` | +| [`Url`](src/Service/Url.php) | `Url::to()`, `asset()`, `module()`, `theme()` | `Url::use($generator)` | +| [`Config`](src/Service/Config.php) | `Config::get()`, `set()`, `has()`, `all()`, `registerLoader()` | `Config::setProvider($p)` | +| [`Cache`](src/Service/Cache.php) | `Cache::get()`, `set()`, `forget()`, `remember()`, `flush()` | `Cache::use($adapter)` | + +All facades work immediately using XOOPS constants (`XOOPS_ROOT_PATH`, `XOOPS_URL`, etc.). Override with `::use()` for testing or custom installations. Reset with `::reset()`. + +### Tier 3 — Providers (Default implementations) + +| Provider | Purpose | +|----------|---------| +| [`DefaultPathLocator`](src/Provider/DefaultPathLocator.php) | Maps to XOOPS constants | +| [`DefaultUrlGenerator`](src/Provider/DefaultUrlGenerator.php) | Uses `XOOPS_URL`, falls back to `$_SERVER` | +| [`XoopsCacheAdapter`](src/Provider/XoopsCacheAdapter.php) | Auto-detects: XoopsCache, APCu, or file cache | +| [`ArrayCache`](src/Provider/ArrayCache.php) | In-memory cache for testing | +| [`SystemDateTimeProvider`](src/Provider/SystemDateTimeProvider.php) | System clock | + +### Tier 4 — Integration (XOOPS-specific) + +| Component | Purpose | +|-----------|---------| +| [`XoopsCollection`](src/Integration/XoopsCollection.php) | `XoopsCollection::fromHandler($handler, $criteria)` with `pluckVar()` for `getVar()` | +| [`AssetUrlPlugin`](src/Integration/Smarty/AssetUrlPlugin.php) | Smarty: `<{asset_url path="css/style.css"}>` | +| [`FormatNumberPlugin`](src/Integration/Smarty/FormatNumberPlugin.php) | Smarty: `<{format_number value=$size type="filesize"}>` | +| [`CssClassesPlugin`](src/Integration/Smarty/CssClassesPlugin.php) | Smarty: `<{css_classes classes=$classArray}>` | +| [`PluginRegistrar`](src/Integration/Smarty/PluginRegistrar.php) | Register all Smarty plugins at once | + +### Cross-cutting + +| Component | Purpose | +|-----------|---------| +| [`Tappable`](src/Traits/Tappable.php) | Trait adding `tap()` to any class | +| [`functions.php`](src/functions.php) | Optional global function wrappers (not auto-loaded) | + +## Optional Global Functions + +The file `src/functions.php` provides short function wrappers like `collect()`, `str()`, `pipeline()`, `tap()`, `retry()`, `env()`, etc. It is **not auto-loaded** — opt in explicitly: + +```php +require_once 'vendor/xoops/helpers/src/functions.php'; + +$slug = str('Hello World')->slug()->toString(); +$data = collect($items)->filter(fn($i) => $i['active'])->pluck('name')->all(); +$value = retry(3, fn() => fetchFromApi(), sleepMs: 500); +``` + +All functions are guarded with `function_exists()` to prevent conflicts. + +## Compatibility + +### XOOPS 2.5.x + +Fully compatible. Designed for inclusion in XOOPS 2.5.12+. + +### XMF 1.x (`xoops/xmf`) + +No conflicts. Different namespace (`Xoops\Helpers\` vs `Xmf\`), no shared class names, no shared global functions. Both can be loaded simultaneously via Composer. + +Where both libraries offer related functionality, they serve different scopes: + +| Area | XMF 1.x | XOOPS Helpers | +|------|---------|---------------| +| URL/Path | `$helper->url()` — module-scoped | `Url::module()` — global, works without module context | +| Config | `$helper->getConfig()` — per-module handler | `Config::get('mod.key')` — dot notation, cached | +| Cache | `Helper\Cache::cacheRead()` — module-prefixed | `Cache::remember()` — global, auto-backend | +| Random | `Random::generateKey()` — SHA512 hash tokens | `Str::random()` — URL-safe strings, configurable length | +| SEO | `Metagen::generateSeoTitle()` — full meta tags | `Str::slug()` — pure string transformation | + +### XMF 2.0 (`xoops/xmf` next generation) + +Designed as a companion. XMF 2.0 provides the architectural framework (Repository, EventBus, Container, QueryBuilder); XOOPS Helpers provides the day-to-day utilities (Arr, Str, Number, HtmlBuilder, Collection). XMF 2.0 will declare `xoops/helpers` as a dependency. + +## Testing + +```bash +composer install +vendor/bin/phpunit +``` + +All services are mockable for testing: + +```php +use Xoops\Helpers\Service\{Path, Url, Config, Cache}; +use Xoops\Helpers\Provider\ArrayCache; + +// Inject test implementations +Cache::use(new ArrayCache()); +Config::registerLoader('mymod', fn() => ['key' => 'value']); + +// Reset after tests +Cache::reset(); +Config::reset(); +Path::reset(); +Url::reset(); +``` + +The `Date` utility accepts an injectable time provider: + +```php +use Xoops\Helpers\Utility\Date; +use Xoops\Helpers\Contracts\DateTimeProviderInterface; + +Date::setProvider(new class implements DateTimeProviderInterface { + public function now(): \DateTimeImmutable { + return new \DateTimeImmutable('2025-06-15 12:00:00'); + } +}); + +Date::isToday('2025-06-15'); // true — deterministic in tests +Date::resetProvider(); +``` + +## Architecture + +```text +Tier 0: Utility/ Pure PHP. Zero dependencies. Works anywhere. +Tier 1: Contracts/ Interfaces only. No implementation. +Tier 2: Service/ Static facades. Depend on XOOPS constants. +Tier 3: Provider/ Default implementations. XOOPS-aware. +Tier 4: Integration/ Depend on XOOPS classes (XoopsObject, Smarty). +``` + +Dependencies flow downward only. Tier 0 classes can be used in any PHP 8.2+ project without XOOPS. + +## Documentation + +See [TUTORIAL.md](TUTORIAL.md) for a comprehensive guide with before/after comparisons from real XOOPS Core and module code. + +## Contributing + +Contributions are welcome. Please follow XOOPS coding standards: +- `declare(strict_types=1)` in every file +- PHP 8.2+ features (readonly, match, named arguments, union types) +- Final classes for utility classes +- Full type hints on all methods +- PHPUnit tests for all new functionality + +## License + +[GNU GPL v2](LICENSE) or later. See [LICENSE](LICENSE) for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e3bb9d4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest development branch and the latest published release line when feasible. + +## Reporting a vulnerability + +Please use GitHub's private vulnerability reporting for this repository. + +If private reporting is not available, contact the maintainers through a private channel and include: + +- a short description of the issue +- affected versions +- a minimal proof of concept +- any suggested mitigation + +Please do not open public issues for security vulnerabilities before a fix is available. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..f3f1aca --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +## Getting help + +- Use GitHub Discussions if enabled for questions and design discussion. +- Use GitHub Issues for confirmed bugs or concrete feature requests. +- Use the security policy for vulnerability reports. + +## What to include + +- XOOPS version +- PHP version +- minimal reproduction code +- exact error output diff --git a/TUTORIAL.md b/TUTORIAL.md new file mode 100644 index 0000000..a41d9b7 --- /dev/null +++ b/TUTORIAL.md @@ -0,0 +1,998 @@ +# XOOPS Helpers: The Developer's Toolkit + +## Stop Writing Boilerplate. Start Building Features. + +Every XOOPS module developer knows the drill. You need a URL to a module page, so you write: + +```php +// kernel/notification.php:741 +$tags['X_MODULE_URL'] = XOOPS_URL . '/modules/' . $module->getVar('dirname') . '/'; +``` + +You need a file path with a language fallback: + +```php +// kernel/block.php:602-608 +if (file_exists(XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/blocks.php')) { + include_once XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/blocks.php'; +} elseif (file_exists(XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/english/blocks.php')) { + include_once XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/english/blocks.php'; +} +``` + +You need to safely output a user-provided value in an HTML attribute: + +```php +// banners.php:204 +htmlspecialchars($xoopsConfig['sitename'], ENT_QUOTES | ENT_HTML5, 'UTF-8') +htmlspecialchars($imageurl, ENT_QUOTES | ENT_HTML5, 'UTF-8') +htmlspecialchars($clickurl, ENT_QUOTES | ENT_HTML5, 'UTF-8') +``` + +And you do it again. And again. Across every module, every admin page, every block. + +**XOOPS Helpers eliminates all of that.** One library. Zero configuration. Convention over configuration. + +```php +use Xoops\Helpers\Service\Url; +use Xoops\Helpers\Service\Path; +use Xoops\Helpers\Utility\HtmlBuilder; + +$url = Url::module($module->getVar('dirname')); +$path = Path::module($dirname, 'language/' . $language . '/blocks.php'); +$html = HtmlBuilder::attributes(['data-name' => $sitename, 'href' => $clickurl]); +``` + +--- + +## Relationship with XMF 2.0 + +XOOPS Helpers is designed to be a **companion** to XMF 2.0 (`xoops/xmf`), not a replacement. They occupy different layers: + +| Concern | XOOPS Helpers (`xoops/helpers`) | XMF 2.0 (`xoops/xmf`) | +|---------|--------------------------------|------------------------| +| **Purpose** | Low-level developer utilities | Architectural framework | +| **Namespace** | `Xoops\Helpers` | `Xmf` | +| **Dependency direction** | XMF depends on Helpers | Helpers never import XMF | +| **Array manipulation** | `Arr::get()`, `pluck()`, `groupBy()` | None (no utility classes) | +| **String processing** | `Str::slug()`, `camel()`, `isEmail()` | `FilterInput` (XSS only) | +| **Number formatting** | `Number::fileSize()`, `forHumans()`, `ordinal()` | None | +| **Collections** | `Collection` (fluent array wrapper) | None | +| **HTML construction** | `HtmlBuilder::attributes()`, `classes()` | `Presentation` (object-oriented UI) | +| **Pipeline** | Data transformation chains | HTTP middleware chains | +| **Cache** | Simple static facade, auto-detection | `CacheManager` with tags, backends, module scoping | +| **Config** | Static `Config::get('module.key')` | `ConfigManager` with schema validation | +| **Value objects** | None | `Slug`, `Email`, `Money`, `DateRange`, etc. | +| **DI Container** | Injectable facades via `use()`/`reset()` | Full `Container` (Symfony-style) | +| **Database** | None | Repository, QueryBuilder, Migrations | +| **Events** | None | EventBus, PSR-14 dispatch | + +**There are only two minor overlaps:** + +1. **JSON validation** — `Str::isJson()` and `XmfJsonHelper::isValid()` use the same logic. The Helpers version is a convenience wrapper; XMF's is the authoritative implementation. + +2. **Random string generation** — `Str::random()` produces URL-safe random strings (for tokens, API keys). XMF's `Random::generateKey()` produces hash-based tokens (for CSRF). Different use cases, both needed. + +**Everything else is complementary.** XMF 2.0 builds the architecture; Helpers provide the day-to-day conveniences that make XOOPS code shorter and safer. + +--- + +## Table of Contents + +1. [Installation](#1-installation) +2. [URL Generation — Never Concatenate Again](#2-url-generation) +3. [Path Resolution — Cross-Platform, Always Correct](#3-path-resolution) +4. [Configuration — Dot Notation, Zero Globals](#4-configuration) +5. [Array Superpowers — Dot Notation for Everything](#5-array-superpowers) +6. [String Utilities — Slugs, Validation, Case Conversion](#6-string-utilities) +7. [Number Formatting — Human-Readable Everything](#7-number-formatting) +8. [HTML Builder — XSS Eliminated by Design](#8-html-builder) +9. [Collections — Fluent Data Transformation](#9-collections) +10. [Pipeline — Clean Data Processing](#10-pipeline) +11. [Fluent Strings — Chain Everything](#11-fluent-strings) +12. [Date Utilities — Testable Time](#12-date-utilities) +13. [File Operations — JSON, MIME, Zip in One Line](#13-file-operations) +14. [Caching — Multi-Tier, Zero Config](#14-caching) +15. [Error Recovery — Retry and Rescue](#15-error-recovery) +16. [Environment Detection](#16-environment-detection) +17. [Smarty Template Plugins](#17-smarty-template-plugins) +18. [Benchmarking — Know What's Slow](#18-benchmarking) +19. [Testing — Everything is Mockable](#19-testing) + +--- + +## 1. Installation + +```bash +composer require xoops/helpers +``` + +That's it. No configuration files. No bootstrap calls. No service registration. It just works. + +```php +use Xoops\Helpers\Service\Path; + +echo Path::base(); // Outputs XOOPS_ROOT_PATH immediately +``` + +**Optional:** If you want global function shortcuts like `collect()`, `str()`, `pipeline()`: + +```php +require_once 'vendor/xoops/helpers/src/functions.php'; +``` + +--- + +## 2. URL Generation + +### The Old Way + +Real code from the XOOPS Core and modules: + +```php +// XoopsCore25/htdocs/kernel/module.php:225 +$ret = '' + . $this->getVar('name') . ''; + +// XoopsCore25/htdocs/include/comment_post.php:490 (116 characters of concatenation!) +$comment_tags['X_COMMENT_URL'] = XOOPS_URL . '/modules/' . $not_module->getVar('dirname') + . '/' . $comment_url . '=' . $com_itemid . '&com_id=' . $newcid + . '&com_rootid=' . $com_rootid . '&com_mode=' . $com_mode + . '&com_order=' . $com_order . '#comment' . $newcid; + +// yogurt module - config/paths.php:9-13 +'modPath' => XOOPS_ROOT_PATH . '/modules/' . $moduleDirName, +'modUrl' => XOOPS_URL . '/modules/' . $moduleDirName, +'uploadPath' => XOOPS_UPLOAD_PATH . '/' . $moduleDirName, +'uploadUrl' => XOOPS_UPLOAD_URL . '/' . $moduleDirName, + +// XoopsCore25/htdocs/Frameworks/art/functions.admin.php:72 +$adminmenu_text .= '
  • ' + . _PREFERENCES . '
  • '; +``` + +**Problems:** +- `rtrim()` / `ltrim()` everywhere to handle trailing slashes +- Query string building by hand — no encoding, easy to miss `&` +- The same pattern repeated 25+ times across the XOOPS Core alone + +### The New Way + +```php +use Xoops\Helpers\Service\Url; + +// Module page with query parameters +$commentUrl = Url::module($dirname, $comment_url, [ + 'com_itemid' => $com_itemid, + 'com_id' => $newcid, + 'com_rootid' => $com_rootid, + 'com_mode' => $com_mode, + 'com_order' => $com_order, +]); + +// Static asset +$css = Url::asset('themes/starter/css/style.css'); + +// Theme resource +$logo = Url::theme('starter', 'images/logo.png'); + +// Admin preferences link +$prefsUrl = Url::to('modules/system/admin.php', [ + 'fct' => 'preferences', + 'op' => 'showmod', + 'mod' => $xoopsModule->getVar('mid'), +]); + +// Redirect — clean and readable +redirect_header(Url::module($dirname, 'error.php'), 3, $e->getMessage()); +``` + +**Lines saved per module:** 25-50 URL concatenations replaced with one-liners. + +--- + +## 3. Path Resolution + +### The Old Way + +```php +// XoopsCore25/htdocs/kernel/block.php:602-608 — 7 path concatenations just to load a block +if (file_exists(XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/blocks/' . $this->getVar('func_file'))) { + if (file_exists(XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/blocks.php')) { + include_once XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/blocks.php'; + } elseif (file_exists(XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/english/blocks.php')) { + include_once XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/language/english/blocks.php'; + } + include_once XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname') . '/blocks/' . $this->getVar('func_file'); +} + +// XoopsCore25/htdocs/Frameworks/moduleclasses/moduleadmin/moduleadmin.php:573-581 — language file with 3 fallback paths +$file = XOOPS_ROOT_PATH . "/modules/{$module_dir}/language/{$language}/changelog.txt"; +if (!is_file($file) && ('english' !== $language)) { + $file = XOOPS_ROOT_PATH . "/modules/{$module_dir}/language/english/changelog.txt"; +} +if (!is_readable($file)) { + $file = XOOPS_ROOT_PATH . "/modules/{$module_dir}/docs/changelog.txt"; +} + +// XoopsCore25/htdocs/include/notification_functions.php:176-178 +if (!is_dir($dir = XOOPS_ROOT_PATH . '/modules/' . $module->getVar('dirname') . '/language/' . $xoopsConfig['language'] . '/mail_template/')) { + $dir = XOOPS_ROOT_PATH . '/modules/' . $module->getVar('dirname') . '/language/english/mail_template/'; +} +``` + +**Problems:** +- The string `XOOPS_ROOT_PATH . '/modules/' . $this->getVar('dirname')` appears **7 times** in one code block +- Forward slash vs `DIRECTORY_SEPARATOR` inconsistency across the codebase +- Every developer re-invents path joining with different slash-trimming strategies + +### The New Way + +```php +use Xoops\Helpers\Service\Path; + +$dirname = $this->getVar('dirname'); + +// Block file +if (file_exists(Path::module($dirname, 'blocks/' . $funcFile))) { + // Language file with fallback + $langFile = Path::module($dirname, "language/{$language}/blocks.php"); + if (!file_exists($langFile)) { + $langFile = Path::module($dirname, 'language/english/blocks.php'); + } + if (file_exists($langFile)) { + include_once $langFile; + } + include_once Path::module($dirname, 'blocks/' . $funcFile); +} + +// All the standard paths — zero thinking required +Path::base(); // XOOPS_ROOT_PATH +Path::storage(); // XOOPS_VAR_PATH +Path::uploads(); // XOOPS_UPLOAD_PATH +Path::modules('news'); // XOOPS_ROOT_PATH/modules/news +Path::themes('starter'); // XOOPS_ROOT_PATH/themes/starter +Path::module('news', 'language'); // XOOPS_ROOT_PATH/modules/news/language +Path::theme('starter', 'css'); // XOOPS_ROOT_PATH/themes/starter/css +``` + +Slashes, separators, trailing slashes — all handled automatically. + +--- + +## 4. Configuration + +### The Old Way + +```php +// XoopsCore25/htdocs/include/common.php:222-243 — $GLOBALS['xoopsConfig'] 4 times in 20 lines +if (!empty($GLOBALS['xoopsConfig']['usercookie'])) { + // ... + xoops_setcookie($GLOBALS['xoopsConfig']['usercookie'], null, time() - 3600, '/', XOOPS_COOKIE_DOMAIN, 0, true); + xoops_setcookie($GLOBALS['xoopsConfig']['usercookie'], null, time() - 3600); +} + +// Inconsistent access patterns across the codebase: +$language = $GLOBALS['xoopsConfig']['language']; // no empty check +$language = empty($GLOBALS['xoopsConfig']['language']) ? 'english' : $GLOBALS['xoopsConfig']['language']; // with fallback + +// XoopsCore25/htdocs/include/comment_view.php — $xoopsModuleConfig 8+ times +if (XOOPS_COMMENT_APPROVENONE != $xoopsModuleConfig['com_rule']) { ... } +if (!empty($xoopsModuleConfig['com_anonpost']) || is_object($xoopsUser)) { ... } + +// wggallery module — helper->getConfig() repeated per-line +$GLOBALS['xoopsTpl']->assign('panel_type', $helper->getConfig('panel_type')); +$GLOBALS['xoopsTpl']->assign('show_breadcrumbs', $helper->getConfig('show_breadcrumbs')); +$GLOBALS['xoopsTpl']->assign('displayButtonText', $helper->getConfig('displayButtonText')); +``` + +### The New Way + +```php +use Xoops\Helpers\Service\Config; + +// System config — just works, with safe defaults +$language = Config::get('system.language', 'english'); +$debug = Config::get('system.debug_mode'); +$cookie = Config::get('system.usercookie'); + +// Module config — same API, auto-loaded from DB +$comRule = Config::get('comments.com_rule'); +$anonPost = Config::get('comments.com_anonpost'); + +// Bulk assign to template +foreach (['panel_type', 'show_breadcrumbs', 'displayButtonText'] as $key) { + $xoopsTpl->assign($key, Config::get("wggallery.{$key}")); +} + +// Check existence +if (Config::has('news.custom_template')) { ... } + +// Get all module config +$allConfig = Config::all('news'); +``` + +One API. Dot notation. Auto-cached. No globals. + +--- + +## 5. Array Superpowers + +### The Old Way + +```php +// XMF-Final xmfblog module — admin/category.php:50-58 +// Building lookup maps from handler results +$allCategories = $categoryRepo->findAll(); +$categoryMap = []; +foreach ($allCategories as $cat) { + $categoryMap[(int) $cat->getVar('category_id')] = [ + 'name' => (string) $cat->getVar('name'), + 'parent_id' => (int) $cat->getVar('parent_id'), + 'weight' => (int) $cat->getVar('weight'), + ]; +} + +// XoopsCore25/htdocs/include/findusers.php:289 — array_map + implode for SQL +$sql = 'SELECT u.* FROM ' . $this->db->prefix('users') . ' AS u' + . ' LEFT JOIN ' . $this->db->prefix('groups_users_link') . ' AS g ON g.uid = u.uid' + . ' WHERE g.groupid IN (' . implode(', ', array_map('intval', $groups)) . ')'; + +// Deep nested access with fallback — common in module settings +$value = isset($config['section']['subsection']['key']) + ? $config['section']['subsection']['key'] + : 'default'; +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Arr; + +// Deep nested access — one line +$value = Arr::get($config, 'section.subsection.key', 'default'); + +// Build option arrays from data +$names = Arr::pluck($users, 'uname', 'uid'); +// => [1 => 'admin', 2 => 'john', 3 => 'jane'] + +// Filter, group, sort +$activeUsers = Arr::where($users, 'status', 'active'); +$byRole = Arr::groupBy($users, 'role'); +Arr::sortBy($articles, 'date_created'); + +// Whitelist / blacklist keys +$safeData = Arr::only($_POST, ['title', 'body', 'category_id']); +$public = Arr::except($userData, ['password', 'email', 'ip']); + +// Check multiple keys at once +if (Arr::has($formData, ['title', 'body', 'author_id'])) { + // all required fields present +} + +// Flatten nested config to dot notation (and back) +$flat = Arr::dot($nestedConfig); // ['db.host' => 'localhost', 'db.port' => 3306] +$nested = Arr::undot($flat); // ['db' => ['host' => 'localhost', 'port' => 3306]] +``` + +--- + +## 6. String Utilities + +### The Old Way + +```php +// wgtransifex module — admin/resources.php:118-124 (manual slug, 4 lines) +$slug = \preg_replace('~[^\pL\d]+~u', '', $res_name); +$slug = iconv('utf-8', 'us-ascii//TRANSLIT', $slug); +$slug = \preg_replace('~[^-\w]+~', '', $slug); +$slug = strtolower($slug); + +// XoopsCore25/htdocs/class/xoopsform/formselectuser.php:157 — URL building inside onclick +$searchUsers->setExtra(' onclick="openWithSelfMain(\'' + . XOOPS_URL . '/include/findusers.php?target=' . $name + . '&multiple=' . $multiple . '&token=' . $token + . '\', \'userselect\', 800, 600, null); return false;" '); + +// XoopsCore25 — manual base64 URL encoding (Utility.php) +public static function string_base64_url_encode($input) { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($input)); +} +``` + +**4 lines for a slug. 6 concatenations for a URL in an onclick. 3 lines for base64url.** + +### The New Way + +```php +use Xoops\Helpers\Utility\Str; +use Xoops\Helpers\Utility\Encoding; + +// Slug — one line, handles Unicode via intl extension +$slug = Str::slug('Willkommen bei XOOPS'); // => "willkommen-bei-xoops" + +// Base64 URL encoding — for tokens, JWT, etc. +$token = Encoding::base64UrlEncode($data); +$data = Encoding::base64UrlDecode($token); + +// Validation — clear method names +Str::isEmail('user@example.com'); // true +Str::isUrl('https://xoops.org'); // true +Str::isIp('192.168.1.1'); // true +Str::isJson('{"valid": true}'); // true +Str::isHexColor('#FF5733'); // true + +// Case conversion +Str::camel('module_config'); // "moduleConfig" +Str::snake('moduleConfig'); // "module_config" +Str::studly('module_config'); // "ModuleConfig" +Str::kebab('moduleConfig'); // "module-config" + +// String inspection +Str::contains($body, ['spam', 'phishing'], ignoreCase: true); +Str::startsWith($path, '/admin/'); +Str::endsWith($filename, ['.jpg', '.png', '.gif']); + +// Truncate safely (UTF-8) +Str::limit($article->getVar('body'), 150); // "First 150 chars..." + +// Mask sensitive data +Str::mask('user@example.com', '*', 4, 7); // "user*******le.com" + +// Cryptographically secure random strings +$apiKey = Str::random(32); +``` + +--- + +## 7. Number Formatting + +### The Old Way + +There's no standard file size formatter in XOOPS Core. Every module that needs one writes its own: + +```php +// Common pattern across XOOPS modules (10 lines per module) +function formatFileSize($bytes) { + $units = ['B', 'KB', 'MB', 'GB']; + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + return round($bytes, 2) . ' ' . $units[$i]; +} + +// wgtimelines module — class/RatingsHandler.php:115 +$ItemRating['avg_rate_value'] = number_format($current_rating / $count, 2); + +// No ordinal formatting, no human-readable numbers, no locale-aware currency +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Number; + +// File sizes — one line +Number::fileSize(1572864); // "1.50 MB" +Number::fileSize(2147483648); // "2.00 GB" + +// Human-readable large numbers +Number::forHumans(1500); // "1.5K" +Number::forHumans(2300000); // "2.3M" +Number::forHumans(1000000000); // "1.0B" + +// Ordinals (handles the 11th/12th/13th edge case correctly) +Number::ordinal(1); // "1st" +Number::ordinal(2); // "2nd" +Number::ordinal(3); // "3rd" +Number::ordinal(11); // "11th" +Number::ordinal(21); // "21st" + +// Locale-aware formatting (with intl extension) +Number::format(1234567, locale: 'de_DE'); // "1.234.567" +Number::percentage(75.5, 1, 'en_US'); // "75.5%" +Number::currency(99.99, 'EUR', 'de_DE'); // "99,99 EUR" + +// Clamp to range +Number::clamp($userInput, min: 1, max: 100); +``` + +--- + +## 8. HTML Builder + +This is the one that prevents security bugs. + +### The Old Way + +```php +// XoopsCore25/htdocs/banners.php — htmlspecialchars repeated 5 times in one file +htmlspecialchars($xoopsConfig['sitename'], ENT_QUOTES | ENT_HTML5, 'UTF-8') +htmlspecialchars($imageurl, ENT_QUOTES | ENT_HTML5, 'UTF-8') +htmlspecialchars($clickurl, ENT_QUOTES | ENT_HTML5, 'UTF-8') + +// XoopsCore25/htdocs/custom_blocks/example_welcome.php:39 +$uname = htmlspecialchars($xoopsUser->getVar('uname', 'n'), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + +// XoopsCore25/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php:176 +// 235-character inline concatenation mixing URL, JS, and security token: +$button = ""; + +// wgtimelines module — class/Items.php:128 (inline JS with escaped quotes) +$imageSelect->setExtra("onchange='showImgSelected(\"image1\", \"item_image\", \"" + . $imageDirectory . '", "", "' . \XOOPS_URL . "\")'"); +``` + +**Every attribute, every value, every time — `htmlspecialchars($x, ENT_QUOTES | ENT_HTML5, 'UTF-8')`.** Miss it once and you have an XSS vulnerability. The `htmlspecialchars` call appears **30+ times** across the XOOPS Core alone. + +### The New Way + +```php +use Xoops\Helpers\Utility\HtmlBuilder; + +// Attributes — auto-escaped, boolean support, null filtering +echo HtmlBuilder::attributes([ + 'class' => 'btn btn-primary', + 'data-id' => $userInput, // auto-escaped + 'disabled' => $isDisabled, // true => "disabled", false => omitted + 'data-config' => null, // null => omitted + 'title' => 'Click "here"', // quotes escaped automatically +]); +// => class="btn btn-primary" data-id="safe&value" disabled title="Click "here"" + +// Conditional CSS classes — the pattern every Bootstrap module needs +echo HtmlBuilder::classes([ + 'btn', // always included + 'btn-primary' => $isPrimary, // included when true + 'btn-lg' => $isLarge, // included when true + 'disabled' => $isDisabled, // included when true +]); +// => "btn btn-primary btn-lg" + +// Complete tags +echo HtmlBuilder::tag('div', ['class' => 'alert alert-info'], $message); +// =>
    Your message here
    + +// Self-closing tags +echo HtmlBuilder::tag('input', ['type' => 'text', 'name' => 'q', 'value' => $query], selfClose: true); +// => + +// Convenience methods +echo HtmlBuilder::stylesheet('/css/style.css'); +echo HtmlBuilder::script('/js/app.js', ['defer' => true]); +echo HtmlBuilder::meta(['name' => 'description', 'content' => $description]); +``` + +**XSS eliminated by design.** You cannot forget to escape — HtmlBuilder does it automatically. + +--- + +## 9. Collections + +### The Old Way + +```php +// XMF-Final xmfblog — blocks/blog_blocks.php:54-62 +// The same foreach-getVar-build-array pattern, repeated hundreds of times across XOOPS +$block = ['posts' => []]; +foreach ($posts as $post) { + $block['posts'][] = [ + 'id' => $post->getVar('post_id'), + 'title' => $post->getVar('title'), + 'excerpt' => $post->getVar('excerpt') + ?: mb_substr((string) $post->getVar('body'), 0, 100) . '...', + 'date' => formatTimestamp((int) $post->getVar('date_created'), 's'), + 'views' => $post->getVar('view_count'), + ]; +} + +// wggallery module — index.php:43-50 +foreach ($atoptions as $atoption) { + $GLOBALS['xoopsTpl']->assign($atoption['name'], $atoption['value']); + if ('number_cols_album' === $atoption['name']) { + $number_cols_album = $atoption['value']; + } + if ('number_cols_cat' === $atoption['name']) { + $number_cols_cat = $atoption['value']; + } +} +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Collection; +use Xoops\Helpers\Integration\XoopsCollection; + +// From handler results — fluent transformation +$block['posts'] = XoopsCollection::fromHandler($postHandler, $criteria) + ->map(fn($post) => [ + 'id' => $post->getVar('post_id'), + 'title' => $post->getVar('title'), + 'excerpt' => $post->getVar('excerpt') + ?: Str::limit((string) $post->getVar('body'), 100), + 'date' => formatTimestamp((int) $post->getVar('date_created'), 's'), + 'views' => $post->getVar('view_count'), + ]) + ->toArray(); + +// Extract a config lookup in one line +$configValues = Collection::make($atoptions)->pluck('value', 'name')->all(); +$number_cols_album = $configValues['number_cols_album'] ?? null; + +// Chain operations fluently +$topAuthors = Collection::make($articles) + ->groupBy('author_id') + ->map(fn($group) => count($group)) + ->sortBy(fn($count) => $count, descending: true) + ->take(10) + ->all(); + +// Aggregation +$stats = [ + 'total' => $orders->count(), + 'revenue' => $orders->sum('amount'), + 'average' => $orders->avg('amount'), +]; +``` + +--- + +## 10. Pipeline + +Note: This is a **data transformation pipeline**, completely separate from XMF 2.0's `Xmf\Http\Pipeline` which is an HTTP middleware chain. Different purpose, no overlap. + +### The Old Way + +```php +// Typical input processing — nested, hard to read +$clean = htmlspecialchars(strip_tags(trim($rawInput)), ENT_QUOTES, 'UTF-8'); + +// Multi-step data transformation +$result = $data; +$result = array_filter($result, fn($item) => $item['active']); +$result = array_map(fn($item) => $item['name'], $result); +$result = array_unique($result); +sort($result); +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Pipeline; + +// Reads top to bottom instead of inside-out +$clean = Pipeline::send($rawInput) + ->pipe(fn($v) => trim($v)) + ->pipe(fn($v) => strip_tags($v)) + ->pipe(fn($v) => htmlspecialchars($v, ENT_QUOTES, 'UTF-8')) + ->thenReturn(); + +// Batch processing +$result = Pipeline::send($rawFormData) + ->through([ + [$validator, 'sanitize'], + [$transformer, 'normalize'], + [$formatter, 'format'], + ]) + ->thenReturn(); +``` + +--- + +## 11. Fluent Strings + +```php +use Xoops\Helpers\Utility\Stringable; + +// Reads left to right, each step crystal clear +$slug = Stringable::of($title)->trim()->slug()->toString(); + +// With the global helper (opt-in) +$slug = str($title)->trim()->slug()->toString(); + +// Conditional operations +$display = Stringable::of($username) + ->trim() + ->when($showUppercase, fn($s) => $s->upper()) + ->limit(20) + ->toString(); +``` + +--- + +## 12. Date Utilities + +### The Old Way + +```php +// wgtimelines module — class/Items.php:153 +$itemDate = $this->isNew() + ? \mktime(0, 0, 0, (int)date("m"), (int)date("d"), (int)date("Y")) + : $this->getVar('item_date'); + +// wgtimelines module — rss.php:60 +$tpl->assign('channel_lastbuild', \formatTimestamp(\time(), 'rss')); +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Date; + +// Date ranges — for reports, calendars +$days = Date::range('2025-01-01', '2025-01-31'); + +// Validation +Date::isValid('2025-13-01'); // false +Date::isValid('not-a-date'); // false + +// Date math +Date::addDays('2025-01-01', 30); // "2025-01-31" +Date::subDays('2025-03-01', 1); // "2025-02-28" + +// Quick checks +Date::isWeekend('2025-03-15'); // true (Saturday) +Date::isPast('2020-01-01'); // true +Date::isFuture('2030-01-01'); // true + +// Reformat between formats +Date::reformat('15/06/2025', 'd/m/Y', 'Y-m-d'); // "2025-06-15" + +// Age calculation +Date::age('1990-05-15'); // 35 +``` + +--- + +## 13. File Operations + +### The Old Way + +```php +// XoopsCore25 — common pattern (no error handling) +$config = json_decode(file_get_contents($configFile), true); + +// XoopsCore25/htdocs/class/zipdownloader.php:61 +$data = fread($fp, filesize($filepath)); + +// XoopsCore25/htdocs/class/xoopsmailer.php:298 +$this->setBody(fread($fd, filesize($path))); +``` + +### The New Way + +```php +use Xoops\Helpers\Utility\Filesystem; + +// JSON I/O — one line each, with error handling built in +$config = Filesystem::readJson($configFile); // null on failure +Filesystem::putJson($path, $data); // false on failure + +// MIME detection +Filesystem::mimeType($uploadedFile); // "image/jpeg" +Filesystem::isImage('photo.webp'); // true + +// Directory operations +Filesystem::mkdir(Path::storage('caches/mymod')); +Filesystem::copyDirectory($source, $destination); +Filesystem::deleteDirectory($tempDir); +Filesystem::isWritableRecursive(Path::uploads()); + +// Zip operations — for module exports +Filesystem::zip(Path::module('news', 'data'), '/tmp/news-export.zip'); +Filesystem::unzip('/tmp/import.zip', Path::storage('imports')); +``` + +--- + +## 14. Caching + +Note: For simple per-request caching, use XOOPS Helpers' `Cache` facade. For production multi-backend caching with tag invalidation, use XMF 2.0's `CacheManager`. They complement each other. + +### The Old Way + +```php +// XoopsCore25/htdocs/admin.php:105-137 — the classic null-check pattern +if (!$items = XoopsCache::read($rssfile)) { + // ... fetch from network ... + XoopsCache::write($rssfile, $items, 86400); +} + +// Frameworks/art/functions.config.php:38-40 — module config caching +if (!$moduleConfig = XoopsCache::read("{$dirname}_config")) { + $moduleConfig = xoops_getModuleConfig($dirname); + XoopsCache::write("{$dirname}_config", $moduleConfig); +} +``` + +### The New Way + +```php +use Xoops\Helpers\Service\Cache; + +// Compute-and-cache in one call +$items = Cache::remember('rss_feed', 86400, function () { + return fetchRssFeed(); +}); + +$moduleConfig = Cache::remember("{$dirname}_config", 3600, function () use ($dirname) { + return xoops_getModuleConfig($dirname); +}); + +// Basic operations +Cache::set('key', $value, 3600); +$value = Cache::get('key'); +Cache::forget('key'); +``` + +--- + +## 15. Error Recovery + +```php +use Xoops\Helpers\Utility\Retry; + +// Retry with exponential backoff +$result = Retry::retry( + times: 3, + callback: fn($attempt) => callExternalApi($url), + sleepMs: fn($attempt) => 100 * (2 ** ($attempt - 1)), +); + +// Graceful fallback +$result = Retry::rescue( + callback: fn() => riskyOperation(), + default: 'safe fallback value', +); + +// Guard clauses +use Xoops\Helpers\Utility\ThrowHelper; +ThrowHelper::throwIf($id < 1, \InvalidArgumentException::class, 'ID must be positive'); +ThrowHelper::throwUnless($user->isAdmin(), \RuntimeException::class, 'Admin required'); +``` + +--- + +## 16. Environment Detection + +```php +use Xoops\Helpers\Utility\Environment; + +if (Environment::isDevelopment()) { + // show debug toolbar +} + +$dbHost = Environment::get('XOOPS_DB_HOST', 'localhost'); +$apiKey = Environment::require('STRIPE_SECRET_KEY'); // throws if missing +``` + +--- + +## 17. Smarty Template Plugins + +Register all helper plugins with one call: + +```php +use Xoops\Helpers\Integration\Smarty\PluginRegistrar; +PluginRegistrar::register($xoopsTpl); +``` + +Then in your templates (with XOOPS delimiters): + +```smarty + +

    File size: <{format_number value=$filesize type="filesize"}>

    +

    Downloads: <{format_number value=$downloads type="human"}>

    +
    +``` + +--- + +## 18. Benchmarking + +```php +use Xoops\Helpers\Utility\Benchmark; + +$result = Benchmark::measure(function () use ($handler, $criteria) { + return $handler->getObjects($criteria); +}); +echo "Query took {$result['time_ms']}ms, used {$result['memory_bytes']} bytes"; + +// Compare approaches +$avg = Benchmark::average(fn() => directDbQuery(), iterations: 100); +echo "Average: {$avg['avg_ms']}ms (min: {$avg['min_ms']}ms, max: {$avg['max_ms']}ms)"; +``` + +--- + +## 19. Testing + +Every service is mockable. No globals required. + +```php +use Xoops\Helpers\Service\{Path, Url, Config, Cache}; +use Xoops\Helpers\Provider\ArrayCache; + +class MyModuleTest extends TestCase +{ + protected function setUp(): void + { + Cache::use(new ArrayCache()); + Config::registerLoader('mymod', fn() => ['items_per_page' => 10]); + } + + protected function tearDown(): void + { + Path::reset(); + Url::reset(); + Cache::reset(); + Config::reset(); + } +} +``` + +--- + +## Quick Reference Card + +| Task | Old Way | New Way | +|------|---------|---------| +| Module URL | `XOOPS_URL.'/modules/'.$dir.'/page.php?id='.$id` | `Url::module($dir, 'page.php', ['id' => $id])` | +| Module path | `XOOPS_ROOT_PATH.'/modules/'.$dir.'/class'` | `Path::module($dir, 'class')` | +| Deep array access | `isset($a['x']['y']) ? $a['x']['y'] : 'def'` | `Arr::get($a, 'x.y', 'def')` | +| Module config | `global $xoopsModuleConfig; $xoopsModuleConfig['key']` | `Config::get('module.key')` | +| System config | `$GLOBALS['xoopsConfig']['sitename']` | `Config::get('system.sitename')` | +| Generate slug | 4-line preg_replace chain | `Str::slug($title)` | +| File size display | 10-line loop function | `Number::fileSize($bytes)` | +| HTML attributes | Manual htmlspecialchars per attribute | `HtmlBuilder::attributes([...])` | +| CSS classes | Ternary concatenation | `HtmlBuilder::classes([...])` | +| JSON file read | `json_decode(file_get_contents(...), true)` | `Filesystem::readJson($path)` | +| Extract column | `foreach ($items as $i) { $out[] = $i['name']; }` | `Arr::pluck($items, 'name')` | +| Cache with fallback | 5-line if/read/write pattern | `Cache::remember($key, $ttl, $fn)` | +| Retry operation | 15-line while/try/catch loop | `Retry::retry(3, $callback, 500)` | +| Random string | `bin2hex(random_bytes($n / 2))` | `Str::random(32)` | +| Escape HTML | `htmlspecialchars($v, ENT_QUOTES\|ENT_HTML5, 'UTF-8')` | `HtmlBuilder::escape($v)` | + +--- + +## Architecture at a Glance + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your Module │ +│ │ +│ use Xoops\Helpers\Service\{Path, Url, Config, Cache}; │ +│ use Xoops\Helpers\Utility\{Arr, Str, Number, Collection}; │ +│ use Xoops\Helpers\Utility\{Pipeline, Stringable, HtmlBuilder}; │ +└───────────┬──────────────────────────────┬──────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────────┐ +│ xoops/helpers │ │ xoops/xmf (2.0) │ +│ │ │ │ +│ Tier 0: Utility/ │◄──│ "requires" xoops/helpers │ +│ Tier 1: Contracts/ │ │ │ +│ Tier 2: Service/ │ │ Repository, EventBus, Container, │ +│ Tier 3: Provider/ │ │ QueryBuilder, CacheManager, │ +│ Tier 4: Integration/│ │ ConfigManager, Presentation... │ +└──────────────────────┘ └──────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ XOOPS Core │ +│ (XOOPS_ROOT_PATH, XOOPS_URL, XoopsCache, XoopsObject) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Tier 0 utilities work everywhere** — CLI scripts, cron jobs, migrations, standalone tools. No XOOPS boot required. + +--- + +*All "Old Way" examples are from real XOOPS Core 2.5 and production module code — not experiments or prototypes.* + +*XOOPS Helpers: 151 tests. 233 assertions. 43 source files. One `composer require`.* diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f65f75f --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "xoops/helpers", + "description": "Convention-over-configuration utility and service helpers for XOOPS CMS development", + "type": "library", + "license": "GPL-2.0-or-later", + "keywords": ["xoops", "helpers", "utility", "cms"], + "authors": [ + { + "name": "XOOPS Development Team", + "homepage": "https://xoops.org" + } + ], + "require": { + "php": ">=8.2", + "ext-mbstring": "*" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-intl": "Required for locale-aware number/date formatting", + "ext-apcu": "Enables APCu caching backend", + "ext-zip": "Required for zip/unzip filesystem operations" + }, + "autoload": { + "psr-4": { + "Xoops\\Helpers\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Xoops\\Helpers\\Tests\\": "tests/" + } + }, + "extra": { + "xoops-helpers": { + "global-functions": false + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "analyse": "phpstan analyse", + "check:composer": "composer validate --strict", + "test": "phpunit", + "check": [ + "@check:composer", + "@test", + "@analyse" + ] + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5b26747 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: 6 + paths: + - src + tmpDir: .phpstan-result-cache + bootstrapFiles: + - tests/bootstrap.php + scanFiles: + - stubs/xoops-stubs.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9560704 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + tests/Unit/Utility + + + tests/Unit/Service + + + + tests/Unit/Integration + + + + + src + + + diff --git a/src/Contracts/CacheInterface.php b/src/Contracts/CacheInterface.php new file mode 100644 index 0000000..96fa77f --- /dev/null +++ b/src/Contracts/CacheInterface.php @@ -0,0 +1,80 @@ + $keys Cache keys + * @param mixed $default Default value for missing keys + * @return array + */ + public function many(array $keys, mixed $default = null): array; + + /** + * Store multiple items at once. + * + * @param array $values Key-value pairs + * @param int $ttl Time to live in seconds + */ + public function putMany(array $values, int $ttl = 3600): bool; +} diff --git a/src/Contracts/ConfigProviderInterface.php b/src/Contracts/ConfigProviderInterface.php new file mode 100644 index 0000000..9fb56f5 --- /dev/null +++ b/src/Contracts/ConfigProviderInterface.php @@ -0,0 +1,51 @@ + Configuration key-value pairs + */ + public function load(string $module): array; + + /** + * Save configuration values for a module. + * + * @param string $module Module directory name + * @param array $config Configuration to save + */ + public function save(string $module, array $config): bool; + + /** + * Check if this provider supports loading config for the given module. + */ + public function supports(string $module): bool; +} diff --git a/src/Contracts/DateTimeProviderInterface.php b/src/Contracts/DateTimeProviderInterface.php new file mode 100644 index 0000000..856bd16 --- /dev/null +++ b/src/Contracts/DateTimeProviderInterface.php @@ -0,0 +1,37 @@ + $query Query parameters + * @param bool $secure Force HTTPS + */ + public function generate(string $path = '', array $query = [], bool $secure = false): string; + + /** + * Generate a URL to a static asset. + * + * @param string $path Asset path relative to site root + * @param bool $secure Force HTTPS + */ + public function asset(string $path, bool $secure = false): string; + + /** + * Generate a URL to a module path. + * + * @param string $dirname Module directory name + * @param string $path Relative path within the module + * @param array $query Query parameters + */ + public function module(string $dirname, string $path = '', array $query = []): string; + + /** + * Generate a URL to a theme asset. + * + * @param string $name Theme name + * @param string $path Relative path within the theme + */ + public function theme(string $name, string $path = ''): string; +} diff --git a/src/Integration/Smarty/AssetUrlPlugin.php b/src/Integration/Smarty/AssetUrlPlugin.php new file mode 100644 index 0000000..0f21063 --- /dev/null +++ b/src/Integration/Smarty/AssetUrlPlugin.php @@ -0,0 +1,63 @@ + + * <{asset_url path="css/style.css" secure=true}> + * + * Register in module bootstrap: + * AssetUrlPlugin::register($smarty); + */ +final class AssetUrlPlugin +{ + /** + * Register this plugin with a Smarty instance. + */ + public static function register(object $smarty): void + { + if (method_exists($smarty, 'registerPlugin')) { + $smarty->registerPlugin('function', 'asset_url', [self::class, 'render']); + } + } + + /** + * Smarty function callback. + * + * @param array $params Template parameters + * @param object $smarty Smarty instance + */ + public static function render(array $params, object $smarty): string + { + $path = (string) ($params['path'] ?? ''); + $secureParam = $params['secure'] ?? false; + $secure = is_bool($secureParam) + ? $secureParam + : (filter_var($secureParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false); + + return Url::asset($path, $secure); + } +} diff --git a/src/Integration/Smarty/CssClassesPlugin.php b/src/Integration/Smarty/CssClassesPlugin.php new file mode 100644 index 0000000..0124215 --- /dev/null +++ b/src/Integration/Smarty/CssClassesPlugin.php @@ -0,0 +1,69 @@ + + * + * Where $classArray is assigned in the controller as: + * $tpl->assign('classArray', [ + * 'btn', + * 'btn-primary' => $isPrimary, + * 'disabled' => $isDisabled, + * ]); + * + * Register: + * CssClassesPlugin::register($smarty); + */ +final class CssClassesPlugin +{ + /** + * Register this plugin with a Smarty instance. + */ + public static function register(object $smarty): void + { + if (method_exists($smarty, 'registerPlugin')) { + $smarty->registerPlugin('function', 'css_classes', [self::class, 'render']); + } + } + + /** + * Smarty function callback. + * + * @param array $params Template parameters + * @param object $smarty Smarty instance + */ + public static function render(array $params, object $smarty): string + { + $classes = $params['classes'] ?? []; + + if (!is_array($classes)) { + return HtmlBuilder::escape((string) $classes); + } + + return HtmlBuilder::classes($classes); + } +} diff --git a/src/Integration/Smarty/FormatNumberPlugin.php b/src/Integration/Smarty/FormatNumberPlugin.php new file mode 100644 index 0000000..15c5f27 --- /dev/null +++ b/src/Integration/Smarty/FormatNumberPlugin.php @@ -0,0 +1,76 @@ + + * <{format_number value=$filesize type="filesize"}> + * <{format_number value=$count type="human"}> + * <{format_number value=$pct type="percentage"}> + * + * Register: + * FormatNumberPlugin::register($smarty); + */ +final class FormatNumberPlugin +{ + /** + * Register this plugin with a Smarty instance. + */ + public static function register(object $smarty): void + { + if (method_exists($smarty, 'registerPlugin')) { + $smarty->registerPlugin('function', 'format_number', [self::class, 'render']); + } + } + + /** + * Smarty function callback. + * + * @param array $params Template parameters + * @param object $smarty Smarty instance + */ + public static function render(array $params, object $smarty): string + { + $value = $params['value'] ?? 0; + $type = (string) ($params['type'] ?? 'decimal'); + $hasDecimals = array_key_exists('decimals', $params); + $decimals = (int) ($params['decimals'] ?? 0); + $locale = isset($params['locale']) ? (string) $params['locale'] : null; + + return match ($type) { + 'filesize' => Number::fileSize((int) $value, $hasDecimals ? $decimals : 2), + 'human' => Number::forHumans((float) $value, $hasDecimals ? $decimals : 1), + 'percentage' => Number::percentage((float) $value, $decimals, $locale), + 'ordinal' => Number::ordinal((int) $value, $locale), + 'currency' => Number::currency( + (float) $value, + (string) ($params['currency'] ?? 'USD'), + $locale ?? 'en_US', + ), + default => Number::format((float) $value, $decimals, $locale), + }; + } +} diff --git a/src/Integration/Smarty/PluginRegistrar.php b/src/Integration/Smarty/PluginRegistrar.php new file mode 100644 index 0000000..f6c55fa --- /dev/null +++ b/src/Integration/Smarty/PluginRegistrar.php @@ -0,0 +1,40 @@ +pluck('title'); + * $byAuthor = $articles->groupBy('author_id'); + * + * @extends Collection + */ +final class XoopsCollection extends Collection +{ + /** + * Create a collection from a XOOPS object handler. + * + * @param \XoopsObjectHandler $handler The object handler + * @param \CriteriaElement|null $criteria Optional filter criteria + * @return self + */ + public static function fromHandler(object $handler, ?object $criteria = null): self + { + $objects = []; + + if (method_exists($handler, 'getObjects')) { + $objects = $handler->getObjects($criteria) ?: []; + } + + return new self($objects); + } + + /** + * Extract values from XOOPS objects using their getVar() method. + * + * Overrides the base pluck() to support XoopsObject's getVar() API. + * + * @return self + */ + public function pluckVar(string $valueKey, ?string $keyKey = null): self + { + $results = []; + + foreach ($this->all() as $item) { + $value = self::getObjectVar($item, $valueKey); + + if ($keyKey === null) { + $results[] = $value; + } else { + $resolvedKey = self::getObjectVar($item, $keyKey); + + if (is_int($resolvedKey) || is_string($resolvedKey)) { + $results[$resolvedKey] = $value; + } else { + $results[] = $value; + } + } + } + + return new self($results); + } + + /** + * Convert all XOOPS objects to arrays. + * + * @return array> + */ + public function toArray(): array + { + return array_map(static function (mixed $item): array { + if (is_object($item) && method_exists($item, 'getValues')) { + return $item->getValues(); + } + + if (is_object($item) && method_exists($item, 'toArray')) { + return $item->toArray(); + } + + if (is_array($item)) { + return $item; + } + + return (array) $item; + }, $this->all()); + } + + /** + * Get a variable from a XOOPS object or generic object/array. + */ + private static function getObjectVar(mixed $item, string $key): mixed + { + // XoopsObject::getVar() + if (is_object($item) && method_exists($item, 'getVar')) { + return $item->getVar($key); + } + + // Generic object — use get_object_vars() to avoid private/protected access errors + if (is_object($item)) { + $publicProps = get_object_vars($item); + + return $publicProps[$key] ?? null; + } + + // Array access + if (is_array($item)) { + return $item[$key] ?? null; + } + + return null; + } +} diff --git a/src/Provider/ArrayCache.php b/src/Provider/ArrayCache.php new file mode 100644 index 0000000..ce73c52 --- /dev/null +++ b/src/Provider/ArrayCache.php @@ -0,0 +1,113 @@ + */ + private array $store = []; + + public function get(string $key): mixed + { + if (!isset($this->store[$key])) { + return null; + } + + $entry = $this->store[$key]; + + if ($entry['expires'] !== 0 && $entry['expires'] < time()) { + unset($this->store[$key]); + + return null; + } + + return $entry['value']; + } + + public function set(string $key, mixed $value, int $ttl = 3600): bool + { + $this->store[$key] = [ + 'value' => $value, + 'expires' => $ttl > 0 ? time() + $ttl : 0, + ]; + + return true; + } + + public function forget(string $key): bool + { + unset($this->store[$key]); + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function has(string $key): bool + { + if (!isset($this->store[$key])) { + return false; + } + + $entry = $this->store[$key]; + + if ($entry['expires'] !== 0 && $entry['expires'] < time()) { + unset($this->store[$key]); + + return false; + } + + return true; + } + + public function many(array $keys, mixed $default = null): array + { + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->get($key) ?? $default; + } + + return $result; + } + + public function putMany(array $values, int $ttl = 3600): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + + return true; + } +} diff --git a/src/Provider/DefaultPathLocator.php b/src/Provider/DefaultPathLocator.php new file mode 100644 index 0000000..3f97003 --- /dev/null +++ b/src/Provider/DefaultPathLocator.php @@ -0,0 +1,102 @@ + XOOPS_ROOT_PATH + * - public -> XOOPS_ROOT_PATH (same as base in standard XOOPS) + * - storage -> XOOPS_VAR_PATH (or fallback to XOOPS_ROOT_PATH/xoops_data) + * - uploads -> XOOPS_UPLOAD_PATH (or fallback to XOOPS_ROOT_PATH/uploads) + * - modules -> XOOPS_ROOT_PATH/modules + * - themes -> XOOPS_ROOT_PATH/themes + */ +class DefaultPathLocator implements PathLocatorInterface +{ + public function basePath(string $path = ''): string + { + return self::join(self::rootPath(), $path); + } + + public function publicPath(string $path = ''): string + { + return self::join(self::rootPath(), $path); + } + + public function storagePath(string $path = ''): string + { + $base = defined('XOOPS_VAR_PATH') + ? XOOPS_VAR_PATH + : self::rootPath() . DIRECTORY_SEPARATOR . 'xoops_data'; + + return self::join($base, $path); + } + + public function uploadsPath(string $path = ''): string + { + $base = defined('XOOPS_UPLOAD_PATH') + ? XOOPS_UPLOAD_PATH + : self::rootPath() . DIRECTORY_SEPARATOR . 'uploads'; + + return self::join($base, $path); + } + + public function modulesPath(string $path = ''): string + { + return self::join(self::rootPath() . DIRECTORY_SEPARATOR . 'modules', $path); + } + + public function themesPath(string $path = ''): string + { + return self::join(self::rootPath() . DIRECTORY_SEPARATOR . 'themes', $path); + } + + public function modulePath(string $dirname, string $path = ''): string + { + return self::join($this->modulesPath($dirname), $path); + } + + public function themePath(string $name, string $path = ''): string + { + return self::join($this->themesPath($name), $path); + } + + private static function rootPath(): string + { + return defined('XOOPS_ROOT_PATH') ? XOOPS_ROOT_PATH : ''; + } + + /** + * Join a base path with a relative path, handling separators. + */ + private static function join(string $base, string $path): string + { + if ($path === '') { + return $base; + } + + return rtrim($base, '/\\') . DIRECTORY_SEPARATOR . ltrim($path, '/\\'); + } +} diff --git a/src/Provider/DefaultUrlGenerator.php b/src/Provider/DefaultUrlGenerator.php new file mode 100644 index 0000000..221caf7 --- /dev/null +++ b/src/Provider/DefaultUrlGenerator.php @@ -0,0 +1,111 @@ +getBaseUrl($secure); + $url = rtrim($base, '/'); + + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + if ($query !== []) { + $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + + return $url; + } + + public function asset(string $path, bool $secure = false): string + { + return $this->generate($path, [], $secure); + } + + public function module(string $dirname, string $path = '', array $query = []): string + { + $modulePath = 'modules/' . $dirname; + + if ($path !== '') { + $modulePath .= '/' . ltrim($path, '/'); + } + + return $this->generate($modulePath, $query); + } + + public function theme(string $name, string $path = ''): string + { + $themePath = 'themes/' . $name; + + if ($path !== '') { + $themePath .= '/' . ltrim($path, '/'); + } + + return $this->generate($themePath); + } + + private function getBaseUrl(bool $secure): string + { + if (defined('XOOPS_URL')) { + $url = (string) XOOPS_URL; + + if ($secure) { + /** @var array|false $parts */ + $parts = parse_url($url); + + if (is_array($parts) && isset($parts['host']) && is_string($parts['host'])) { + $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; + $path = isset($parts['path']) && is_string($parts['path']) ? $parts['path'] : ''; + + return 'https://' . $parts['host'] . $port . $path; + } + } + + return $url; + } + + $scheme = $secure + ? 'https' + : ((($_SERVER['HTTPS'] ?? 'off') !== 'off') ? 'https' : 'http'); + + // Validate host to prevent host-header injection + $host = trim((string) ($_SERVER['HTTP_HOST'] ?? 'localhost')); + + if (!preg_match('/^[A-Za-z0-9.\-]+(?::(\d{1,5}))?$/', $host, $matches)) { + $host = 'localhost'; + } elseif (isset($matches[1]) && ((int) $matches[1] < 1 || (int) $matches[1] > 65535)) { + $host = 'localhost'; + } + + return $scheme . '://' . $host; + } +} diff --git a/src/Provider/SystemDateTimeProvider.php b/src/Provider/SystemDateTimeProvider.php new file mode 100644 index 0000000..423095a --- /dev/null +++ b/src/Provider/SystemDateTimeProvider.php @@ -0,0 +1,35 @@ +prefix . $key; + + if (class_exists('XoopsCache', false)) { + $payload = \XoopsCache::read($prefixed); + + if ( + $payload === false + || !is_array($payload) + || !array_key_exists('value', $payload) + || ($payload['__xmf_hit'] ?? false) !== true + ) { + return null; + } + + return $payload['value']; + } + + if ($this->apcuAvailable()) { + $value = apcu_fetch($prefixed, $success); + + return $success ? $value : null; + } + + return $this->fileGet($prefixed); + } + + public function set(string $key, mixed $value, int $ttl = 3600): bool + { + $prefixed = $this->prefix . $key; + + if (class_exists('XoopsCache', false)) { + return \XoopsCache::write($prefixed, ['__xmf_hit' => true, 'value' => $value], $ttl); + } + + if ($this->apcuAvailable()) { + return apcu_store($prefixed, $value, $ttl); + } + + return $this->fileSet($prefixed, $value, $ttl); + } + + public function forget(string $key): bool + { + $prefixed = $this->prefix . $key; + + if (class_exists('XoopsCache', false)) { + return \XoopsCache::delete($prefixed); + } + + if ($this->apcuAvailable()) { + return apcu_delete($prefixed); + } + + return $this->fileForget($prefixed); + } + + public function flush(): bool + { + if (class_exists('XoopsCache', false)) { + return \XoopsCache::clear(); + } + + if ($this->apcuAvailable()) { + return apcu_clear_cache(); + } + + return $this->fileFlush(); + } + + public function has(string $key): bool + { + return $this->get($key) !== null; + } + + public function many(array $keys, mixed $default = null): array + { + $result = []; + + foreach ($keys as $key) { + $value = $this->get($key); + $result[$key] = $value ?? $default; + } + + return $result; + } + + public function putMany(array $values, int $ttl = 3600): bool + { + $success = true; + + foreach ($values as $key => $value) { + if (!$this->set($key, $value, $ttl)) { + $success = false; + } + } + + return $success; + } + + // ── File cache backend ───────────────────────────────── + + private function fileGet(string $key): mixed + { + $file = $this->cacheFilePath($key); + + if (!file_exists($file)) { + return null; + } + + $content = @file_get_contents($file); + + if ($content === false) { + return null; + } + + // File backend only supports scalar/array payloads — disallow object instantiation + $data = @unserialize($content, ['allowed_classes' => false]); + + if (!is_array($data) || !array_key_exists('value', $data)) { + return null; + } + + if ($data['expires'] !== 0 && $data['expires'] < time()) { + @unlink($file); + + return null; + } + + return $data['value']; + } + + private function fileSet(string $key, mixed $value, int $ttl): bool + { + $file = $this->cacheFilePath($key); + $dir = dirname($file); + + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + $data = [ + 'value' => $value, + 'expires' => $ttl > 0 ? time() + $ttl : 0, + ]; + + return @file_put_contents($file, serialize($data), LOCK_EX) !== false; + } + + private function fileForget(string $key): bool + { + $file = $this->cacheFilePath($key); + + if (file_exists($file)) { + return @unlink($file); + } + + return true; + } + + private function fileFlush(): bool + { + $dir = $this->cacheDir(); + + if (!is_dir($dir)) { + return true; + } + + $files = glob($dir . '/*.cache'); + + if ($files === false) { + return true; + } + + foreach ($files as $file) { + @unlink($file); + } + + return true; + } + + private function cacheDir(): string + { + if (defined('XOOPS_VAR_PATH')) { + return XOOPS_VAR_PATH . '/caches/xmf'; + } + + return sys_get_temp_dir() . '/xmf_cache'; + } + + private function cacheFilePath(string $key): string + { + return $this->cacheDir() . '/' . md5($key) . '.cache'; + } + + private function apcuAvailable(): bool + { + return extension_loaded('apcu') && function_exists('apcu_enabled') && apcu_enabled(); + } +} diff --git a/src/Service/Cache.php b/src/Service/Cache.php new file mode 100644 index 0000000..76a0738 --- /dev/null +++ b/src/Service/Cache.php @@ -0,0 +1,130 @@ + APCu -> file). + * + * Usage: + * Cache::set('key', $value, 3600); + * $value = Cache::get('key'); + * Cache::forget('key'); + */ +final class Cache +{ + private static ?CacheInterface $adapter = null; + + /** + * Inject a custom cache adapter. + */ + public static function use(CacheInterface $adapter): void + { + self::$adapter = $adapter; + } + + /** + * Reset to auto-detected adapter. + */ + public static function reset(): void + { + self::$adapter = null; + } + + public static function get(string $key): mixed + { + return self::adapter()->get($key); + } + + public static function set(string $key, mixed $value, int $ttl = 3600): bool + { + return self::adapter()->set($key, $value, $ttl); + } + + public static function forget(string $key): bool + { + return self::adapter()->forget($key); + } + + public static function flush(): bool + { + return self::adapter()->flush(); + } + + public static function has(string $key): bool + { + return self::adapter()->has($key); + } + + /** + * Get a value from cache, or compute and store it. + * + * @param string $key Cache key + * @param int $ttl Time to live in seconds + * @param callable $callback Callback to compute value on cache miss + */ + public static function remember(string $key, int $ttl, callable $callback): mixed + { + // Single backend lookup — avoid has()+get() double read + $value = self::get($key); + + if ($value !== null) { + return $value; + } + + // Distinguish stored-null from miss only when needed + if (self::has($key)) { + return null; + } + + $value = $callback(); + self::set($key, $value, $ttl); + + return $value; + } + + /** + * @param array $keys + * @return array + */ + public static function many(array $keys, mixed $default = null): array + { + return self::adapter()->many($keys, $default); + } + + /** + * @param array $values + */ + public static function putMany(array $values, int $ttl = 3600): bool + { + return self::adapter()->putMany($values, $ttl); + } + + private static function adapter(): CacheInterface + { + return self::$adapter ??= new XoopsCacheAdapter(); + } +} diff --git a/src/Service/Config.php b/src/Service/Config.php new file mode 100644 index 0000000..52d70c5 --- /dev/null +++ b/src/Service/Config.php @@ -0,0 +1,290 @@ + ['key' => 'value']); + */ +final class Config +{ + /** @var array> Loaded configuration per module */ + private static array $loaded = []; + + /** @var array Custom loaders per module */ + private static array $loaders = []; + + /** @var ConfigProviderInterface|null Primary configuration provider */ + private static ?ConfigProviderInterface $provider = null; + + /** @var CacheInterface|null Optional cache adapter */ + private static ?CacheInterface $cache = null; + + /** + * Get a configuration value using dot notation. + * + * The first segment of the key is the module name. + * Example: 'system.sitename' loads the 'system' module config, then gets 'sitename'. + * + * @param string $key Dot-notated config key (e.g. "module.setting") + * @param mixed $default Default value if not found + */ + public static function get(string $key, mixed $default = null): mixed + { + $module = self::extractModule($key); + self::ensureLoaded($module); + + return Arr::get(self::$loaded, $key, $default); + } + + /** + * Set a configuration value in memory (does not persist). + */ + public static function set(string $key, mixed $value): void + { + $module = self::extractModule($key); + self::ensureLoaded($module); + + Arr::set(self::$loaded, $key, $value); + + self::invalidateCache($module); + } + + /** + * Check if a configuration key exists. + */ + public static function has(string $key): bool + { + $module = self::extractModule($key); + self::ensureLoaded($module); + + return Arr::has(self::$loaded, $key); + } + + /** + * Remove a configuration key from memory. + */ + public static function forget(string $key): void + { + Arr::forget(self::$loaded, $key); + self::invalidateCache(self::extractModule($key)); + } + + /** + * Get all configuration for a module. + * + * @return array + */ + public static function all(string $module = 'system'): array + { + self::ensureLoaded($module); + + return self::$loaded[$module] ?? []; + } + + /** + * Register a custom configuration loader for a module. + * + * @param string $module Module directory name + * @param callable $loader Callback receiving module name, returning array + */ + public static function registerLoader(string $module, callable $loader): void + { + self::$loaders[$module] = $loader; + } + + /** + * Set the primary configuration provider. + */ + public static function setProvider(ConfigProviderInterface $provider): void + { + self::$provider = $provider; + } + + /** + * Set the cache adapter for configuration data. + */ + public static function setCache(?CacheInterface $cache): void + { + self::$cache = $cache; + } + + /** + * Reload configuration for a module (clears cache). + */ + public static function reload(string $module): void + { + unset(self::$loaded[$module]); + self::invalidateCache($module); + self::ensureLoaded($module); + } + + /** + * Clear all loaded configuration from memory. + */ + public static function clear(): void + { + self::$loaded = []; + } + + /** + * Save configuration for a module (delegates to provider). + * + * @param string $module Module directory name + * @param array $config Configuration to save + */ + public static function save(string $module, array $config): bool + { + if (self::$provider === null) { + return false; + } + + if (!self::$provider->save($module, $config)) { + return false; + } + + self::$loaded[$module] = $config; + self::invalidateCache($module); + + return true; + } + + /** + * Reset all state (for testing). + */ + public static function reset(): void + { + self::$loaded = []; + self::$loaders = []; + self::$provider = null; + self::$cache = null; + } + + private static function ensureLoaded(string $module): void + { + if (isset(self::$loaded[$module])) { + return; + } + + $cacheKey = "config.{$module}"; + + if (self::$cache !== null) { + $cached = self::$cache->get($cacheKey); + + if (is_array($cached)) { + self::$loaded[$module] = $cached; + + return; + } + } + + $config = self::loadFromSource($module); + self::$loaded[$module] = $config; + + if (self::$cache !== null && $config !== []) { + self::$cache->set($cacheKey, $config, 3600); + } + } + + /** + * @return array + */ + private static function loadFromSource(string $module): array + { + // 1. Custom loader + if (isset(self::$loaders[$module])) { + return (self::$loaders[$module])($module); + } + + // 2. Provider + if (self::$provider !== null && self::$provider->supports($module)) { + return self::$provider->load($module); + } + + // 3. XOOPS global config for system module + if ($module === 'system' && isset($GLOBALS['xoopsConfig']) && is_array($GLOBALS['xoopsConfig'])) { + return $GLOBALS['xoopsConfig']; + } + + // 4. File-based fallback + return self::loadFromFile($module); + } + + /** + * @return array + */ + private static function loadFromFile(string $module): array + { + // Prevent path traversal attacks + if (preg_match('/[\/\\\\]|\.\./', $module)) { + return []; + } + + $candidates = []; + + if (defined('XOOPS_VAR_PATH')) { + $candidates[] = XOOPS_VAR_PATH . "/configs/{$module}.php"; + } + + if (defined('XOOPS_ROOT_PATH')) { + $candidates[] = XOOPS_ROOT_PATH . "/modules/{$module}/config.php"; + } + + foreach ($candidates as $path) { + if (file_exists($path)) { + $config = include $path; + + if (is_array($config)) { + return $config; + } + } + } + + return []; + } + + private static function extractModule(string $key): string + { + $dotPos = strpos($key, '.'); + + return $dotPos !== false ? substr($key, 0, $dotPos) : $key; + } + + private static function invalidateCache(string $module): void + { + self::$cache?->forget("config.{$module}"); + } +} diff --git a/src/Service/Path.php b/src/Service/Path.php new file mode 100644 index 0000000..42bbad3 --- /dev/null +++ b/src/Service/Path.php @@ -0,0 +1,102 @@ +basePath($path); + } + + public static function public(string $path = ''): string + { + return self::locator()->publicPath($path); + } + + public static function storage(string $path = ''): string + { + return self::locator()->storagePath($path); + } + + public static function uploads(string $path = ''): string + { + return self::locator()->uploadsPath($path); + } + + public static function modules(string $path = ''): string + { + return self::locator()->modulesPath($path); + } + + public static function themes(string $path = ''): string + { + return self::locator()->themesPath($path); + } + + public static function module(string $dirname, string $path = ''): string + { + return self::locator()->modulePath($dirname, $path); + } + + public static function theme(string $name, string $path = ''): string + { + return self::locator()->themePath($name, $path); + } + + private static function locator(): PathLocatorInterface + { + return self::$locator ??= new DefaultPathLocator(); + } +} diff --git a/src/Service/Url.php b/src/Service/Url.php new file mode 100644 index 0000000..257d80f --- /dev/null +++ b/src/Service/Url.php @@ -0,0 +1,102 @@ + 42]); + * Url::theme('starter', 'css/style.css'); + */ +final class Url +{ + private static ?UrlGeneratorInterface $generator = null; + + /** + * Inject a custom URL generator (useful for testing). + */ + public static function use(UrlGeneratorInterface $generator): void + { + self::$generator = $generator; + } + + /** + * Reset to the default generator. + */ + public static function reset(): void + { + self::$generator = null; + } + + /** + * Generate a URL to a path. + * + * @param string $path Relative path + * @param array $query Query parameters + * @param bool $secure Force HTTPS + */ + public static function to(string $path = '', array $query = [], bool $secure = false): string + { + return self::generator()->generate($path, $query, $secure); + } + + /** + * Generate a URL to a static asset. + */ + public static function asset(string $path, bool $secure = false): string + { + return self::generator()->asset($path, $secure); + } + + /** + * Generate a URL to a module path. + * + * @param string $dirname Module directory name + * @param string $path Relative path within the module + * @param array $query Query parameters + */ + public static function module(string $dirname, string $path = '', array $query = []): string + { + return self::generator()->module($dirname, $path, $query); + } + + /** + * Generate a URL to a theme asset. + */ + public static function theme(string $name, string $path = ''): string + { + return self::generator()->theme($name, $path); + } + + private static function generator(): UrlGeneratorInterface + { + return self::$generator ??= new DefaultUrlGenerator(); + } +} diff --git a/src/Traits/Tappable.php b/src/Traits/Tappable.php new file mode 100644 index 0000000..085e14b --- /dev/null +++ b/src/Traits/Tappable.php @@ -0,0 +1,51 @@ +setOption('key', 'value') + * ->tap(fn($b) => $logger->info('Building with options')) + * ->build(); + */ +trait Tappable +{ + /** + * Call a callback with $this for side effects, then return $this. + * + * @return $this + */ + public function tap(callable $callback): static + { + $callback($this); + + return $this; + } +} diff --git a/src/Utility/Arr.php b/src/Utility/Arr.php new file mode 100644 index 0000000..e38bd9a --- /dev/null +++ b/src/Utility/Arr.php @@ -0,0 +1,459 @@ +{$segment})) { + $target = $target->{$segment}; + } else { + return Value::value($default); + } + } + + return $target; + } + + /** + * Check if one or more keys exist using dot notation. + * + * @param mixed $target Array, ArrayAccess, or object + * @param string|array $keys Key(s) to check + */ + public static function has(mixed $target, string|array $keys): bool + { + $keys = (array) $keys; + + if ($keys === []) { + return false; + } + + $sentinel = Value::missing(); + + foreach ($keys as $key) { + $result = self::get($target, $key, $sentinel); + if ($result instanceof MissingValue) { + return false; + } + } + + return true; + } + + /** + * Set a value in a nested array using dot notation. + * + * @param array $array Array to modify (by reference) + * @param string $key Dot-notated key path + * @param mixed $value Value to set + * @return array + */ + public static function set(array &$array, string $key, mixed $value): array + { + if ($key === '') { + return $array = (array) $value; + } + + $segments = explode('.', $key); + $current = &$array; + + foreach ($segments as $i => $segment) { + if ($i === count($segments) - 1) { + break; + } + + if (!isset($current[$segment]) || !is_array($current[$segment])) { + $current[$segment] = []; + } + + $current = &$current[$segment]; + } + + $current[end($segments)] = $value; + + return $array; + } + + /** + * Remove one or more keys from an array using dot notation. + * + * @param array $array Array to modify (by reference) + * @param string|array $keys Key(s) to remove + */ + public static function forget(array &$array, string|array $keys): void + { + foreach ((array) $keys as $key) { + if (array_key_exists($key, $array)) { + unset($array[$key]); + continue; + } + + $segments = explode('.', $key); + $lastSegment = array_pop($segments); + $target = &$array; + + foreach ($segments as $segment) { + if (!isset($target[$segment]) || !is_array($target[$segment])) { + continue 2; + } + $target = &$target[$segment]; + } + + unset($target[$lastSegment]); + } + } + + /** + * Extract a list of values from a nested array. + * + * @param iterable $array Source data + * @param string $valueKey Key to extract as value + * @param string|null $keyKey Optional key to use as array key + * @return array + */ + public static function pluck(iterable $array, string $valueKey, ?string $keyKey = null): array + { + $results = []; + + foreach ($array as $item) { + $itemValue = self::get($item, $valueKey); + + if ($keyKey === null) { + $results[] = $itemValue; + } else { + $itemKey = self::get($item, $keyKey); + + if (is_int($itemKey) || is_string($itemKey)) { + $results[$itemKey] = $itemValue; + } else { + $results[] = $itemValue; + } + } + } + + return $results; + } + + /** + * Return a subset of the array with only the specified keys. + * + * @param array $array Source array + * @param string|array $keys Keys to keep + * @return array + */ + public static function only(array $array, string|array $keys): array + { + return array_intersect_key($array, array_flip((array) $keys)); + } + + /** + * Return the array without the specified keys. + * + * @param array $array Source array + * @param string|array $keys Keys to exclude + * @return array + */ + public static function except(array $array, string|array $keys): array + { + return array_diff_key($array, array_flip((array) $keys)); + } + + /** + * Flatten a multi-dimensional array to a single level. + * + * @param array $array Source array + * @param int $depth Maximum depth to flatten (default: infinite) + * @return array + */ + public static function flatten(array $array, int $depth = PHP_INT_MAX): array + { + $result = []; + + foreach ($array as $item) { + if (!is_array($item)) { + $result[] = $item; + } elseif ($depth === 1) { + $result = array_merge($result, array_values($item)); + } else { + $result = array_merge($result, self::flatten($item, $depth - 1)); + } + } + + return $result; + } + + /** + * Sort an array by a key or callback. + * + * @param array $array Array to sort (by reference) + * @param string|callable $callback Key name or comparator callback + * @param int $options Sort flags (SORT_REGULAR, etc.) + * @param bool $descending Sort in descending order + * @return array + */ + public static function sortBy(array &$array, string|callable $callback, int $options = SORT_REGULAR, bool $descending = false): array + { + $results = []; + $retriever = self::valueRetriever($callback); + + foreach ($array as $key => $value) { + $results[$key] = $retriever($value, $key); + } + + $descending ? arsort($results, $options) : asort($results, $options); + + foreach (array_keys($results) as $key) { + $results[$key] = $array[$key]; + } + + return $array = $results; + } + + /** + * Group array items by a key or callback. + * + * @template TKey of array-key + * @template TValue + * @param array $array Source array + * @param string|callable(TValue, TKey): array-key $groupBy Key name or grouping callback + * @return array> + */ + public static function groupBy(array $array, string|callable $groupBy): array + { + $retriever = self::valueRetriever($groupBy); + $result = []; + + foreach ($array as $key => $value) { + $groupKey = $retriever($value, $key); + $result[$groupKey][] = $value; + } + + return $result; + } + + /** + * Convert a multi-dimensional array to dot notation. + * + * @param array $array Source array + * @param string $prepend Key prefix + * @return array + */ + public static function dot(array $array, string $prepend = ''): array + { + $results = []; + + foreach ($array as $key => $value) { + if (is_array($value) && $value !== []) { + $results = array_merge($results, self::dot($value, $prepend . $key . '.')); + } else { + $results[$prepend . $key] = $value; + } + } + + return $results; + } + + /** + * Convert a dot-notated array back to multi-dimensional. + * + * @param array $array Dot-notated array + * @return array + */ + public static function undot(array $array): array + { + $results = []; + + foreach ($array as $key => $value) { + self::set($results, (string) $key, $value); + } + + return $results; + } + + /** + * Wrap a value in an array if it isn't already one. + * + * @return array + */ + public static function wrap(mixed $value): array + { + if ($value === null) { + return []; + } + + return is_array($value) ? $value : [$value]; + } + + /** + * Return the first element of an array. + * + * @param array $array + */ + public static function first(array $array, ?callable $callback = null, mixed $default = null): mixed + { + if ($callback === null) { + if ($array === []) { + return Value::value($default); + } + + foreach ($array as $item) { + return $item; + } + } + + foreach ($array as $key => $value) { + if ($callback($value, $key)) { + return $value; + } + } + + return Value::value($default); + } + + /** + * Return the last element of an array. + * + * @param array $array + */ + public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed + { + if ($callback === null) { + return $array === [] ? Value::value($default) : end($array); + } + + return self::first(array_reverse($array, true), $callback, $default); + } + + /** + * Filter array items where a key matches a value. + * + * Two-argument form: where($array, 'key', $value) — uses '=' operator + * Three-argument form: where($array, 'key', '>=', 10) — explicit operator + * + * Supports: =, ==, ===, !=, !==, <>, <, >, <=, >= + * + * @param array $array Source array + * @param string $key Key to compare + * @param mixed ...$operatorAndValue Operator and value, or just value + * @return array + */ + public static function where(array $array, string $key, mixed ...$operatorAndValue): array + { + if (count($operatorAndValue) === 1) { + $operator = '='; + $value = $operatorAndValue[0]; + } else { + $operator = $operatorAndValue[0] ?? '='; + $value = $operatorAndValue[1] ?? null; + } + + return array_filter($array, static function ($item) use ($key, $operator, $value) { + $retrieved = Arr::get($item, $key); + + return match ($operator) { + '=', '==' => $retrieved == $value, + '===' => $retrieved === $value, + '!=', '<>' => $retrieved != $value, + '!==' => $retrieved !== $value, + '<' => $retrieved < $value, + '>' => $retrieved > $value, + '<=' => $retrieved <= $value, + '>=' => $retrieved >= $value, + default => $retrieved == $value, + }; + }); + } + + /** + * Determine if an array is associative (non-sequential keys). + * + * @param array $array + */ + public static function isAssoc(array $array): bool + { + if ($array === []) { + return false; + } + + return array_keys($array) !== range(0, count($array) - 1); + } + + /** + * Collapse an array of arrays into a single array. + * + * @param iterable> $array Array of arrays + * @return array + */ + public static function collapse(iterable $array): array + { + $results = []; + + foreach ($array as $values) { + if (is_array($values)) { + $results = array_merge($results, $values); + } + } + + return $results; + } + + /** + * Create a value retriever callback from a string key or callable. + */ + private static function valueRetriever(string|callable $value): callable + { + if (is_callable($value)) { + return $value; + } + + return static fn(mixed $item) => self::get($item, $value); + } +} diff --git a/src/Utility/Benchmark.php b/src/Utility/Benchmark.php new file mode 100644 index 0000000..6f96346 --- /dev/null +++ b/src/Utility/Benchmark.php @@ -0,0 +1,104 @@ + ..., 'time_ms' => 42.5, 'memory_bytes' => 1024, 'memory_peak_bytes' => 2048] + */ +final class Benchmark +{ + /** + * Measure execution time and memory usage of a callback. + * + * @return array{result: mixed, time_ms: float, memory_bytes: int, memory_peak_bytes: int} + */ + public static function measure(callable $callback): array + { + $memoryBefore = memory_get_usage(true); + $peakBefore = memory_get_peak_usage(true); + $startTime = hrtime(true); + + $result = $callback(); + + $endTime = hrtime(true); + $memoryAfter = memory_get_usage(true); + $peakAfter = memory_get_peak_usage(true); + + return [ + 'result' => $result, + 'time_ms' => ($endTime - $startTime) / 1_000_000, + 'memory_bytes' => max(0, $memoryAfter - $memoryBefore), + 'memory_peak_bytes' => max(0, $peakAfter - $peakBefore), + ]; + } + + /** + * Measure only the execution time of a callback in milliseconds. + * + * @return array{result: mixed, time_ms: float} + */ + public static function time(callable $callback): array + { + $start = hrtime(true); + $result = $callback(); + $end = hrtime(true); + + return [ + 'result' => $result, + 'time_ms' => ($end - $start) / 1_000_000, + ]; + } + + /** + * Run a callback multiple times and return average execution time. + * + * @return array{avg_ms: float, min_ms: float, max_ms: float, iterations: int} + */ + public static function average(callable $callback, int $iterations = 100): array + { + $times = []; + + for ($i = 0; $i < $iterations; $i++) { + $start = hrtime(true); + $callback(); + $end = hrtime(true); + + $times[] = ($end - $start) / 1_000_000; + } + + return [ + 'avg_ms' => array_sum($times) / count($times), + 'min_ms' => min($times), + 'max_ms' => max($times), + 'iterations' => $iterations, + ]; + } +} diff --git a/src/Utility/Collection.php b/src/Utility/Collection.php new file mode 100644 index 0000000..b757559 --- /dev/null +++ b/src/Utility/Collection.php @@ -0,0 +1,508 @@ + + * @implements ArrayAccess + */ +class Collection implements IteratorAggregate, Countable, ArrayAccess, JsonSerializable +{ + /** + * @param array $items + */ + public function __construct( + private array $items = [], + ) {} + + /** + * Create a new collection from items. + * + * @template T + * @param T|array $items + * @return self + */ + public static function make(mixed $items = []): self + { + if ($items instanceof self) { + return new self($items->all()); + } + + return new self(Arr::wrap($items)); + } + + /** + * Create a collection from a range of numbers. + * + * @return self + */ + public static function range(int $from, int $to, int $step = 1): self + { + return new self(range($from, $to, $step)); + } + + /** + * Get all items as a plain array. + * + * @return array + */ + public function all(): array + { + return $this->items; + } + + /** + * Get a value by key using dot notation. + */ + public function get(string $key, mixed $default = null): mixed + { + return Arr::get($this->items, $key, $default); + } + + /** + * Set a value by key using dot notation (returns new collection). + * + * @return self + */ + public function set(string $key, mixed $value): self + { + $items = $this->items; + Arr::set($items, $key, $value); + + return new self($items); + } + + /** + * Check if a key exists. + */ + public function has(string $key): bool + { + return Arr::has($this->items, $key); + } + + /** + * Apply a callback to each item and return a new collection. + * + * @template TNewValue + * @param callable(TValue, TKey): TNewValue $callback + * @return self + */ + public function map(callable $callback): self + { + $results = []; + + foreach ($this->items as $key => $value) { + $results[$key] = $callback($value, $key); + } + + return new self($results); + } + + /** + * Filter items using a callback and return a new collection. + * + * @param callable(TValue, TKey): bool $callback + * @return self + */ + public function filter(callable $callback): self + { + return new self(array_filter($this->items, $callback, ARRAY_FILTER_USE_BOTH)); + } + + /** + * Reject items that match the callback. + * + * @param callable(TValue, TKey): bool $callback + * @return self + */ + public function reject(callable $callback): self + { + return $this->filter(static fn($value, $key) => !$callback($value, $key)); + } + + /** + * Execute a callback on each item (for side effects). + * + * @param callable(TValue, TKey): (bool|void) $callback + * @return self + */ + public function each(callable $callback): self + { + foreach ($this->items as $key => $value) { + if ($callback($value, $key) === false) { + break; + } + } + + return $this; + } + + /** + * Reduce the collection to a single value. + * + * @template TResult + * @param callable(TResult, TValue, TKey): TResult $callback + * @param TResult $initial + * @return TResult + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + $result = $initial; + + foreach ($this->items as $key => $value) { + $result = $callback($result, $value, $key); + } + + return $result; + } + + /** + * Extract values by key from nested items. + * + * @return self + */ + public function pluck(string $valueKey, ?string $keyKey = null): self + { + return new self(Arr::pluck($this->items, $valueKey, $keyKey)); + } + + /** + * Group items by a key or callback. + * + * @param string|callable(TValue, TKey): array-key $groupBy + * @return self> + */ + public function groupBy(string|callable $groupBy): self + { + return new self(Arr::groupBy($this->items, $groupBy)); + } + + /** + * Sort items by a key or callback. + * + * @param string|callable $callback + * @return self + */ + public function sortBy(string|callable $callback, bool $descending = false): self + { + $items = $this->items; + Arr::sortBy($items, $callback, SORT_REGULAR, $descending); + + return new self($items); + } + + /** + * Sort items by a key or callback in descending order. + * + * @return self + */ + public function sortByDesc(string|callable $callback): self + { + return $this->sortBy($callback, descending: true); + } + + /** + * Get the first item, optionally matching a callback. + */ + public function first(?callable $callback = null, mixed $default = null): mixed + { + return Arr::first($this->items, $callback, $default); + } + + /** + * Get the last item, optionally matching a callback. + */ + public function last(?callable $callback = null, mixed $default = null): mixed + { + return Arr::last($this->items, $callback, $default); + } + + /** + * Flatten the collection to a single level. + * + * @return self + */ + public function flatten(int $depth = PHP_INT_MAX): self + { + return new self(Arr::flatten($this->items, $depth)); + } + + /** + * Get unique items. + * + * @return self + */ + public function unique(): self + { + return new self(array_unique($this->items, SORT_REGULAR)); + } + + /** + * Get only the values (re-indexed). + * + * @return self + */ + public function values(): self + { + return new self(array_values($this->items)); + } + + /** + * Get only the keys. + * + * @return self + */ + public function keys(): self + { + return new self(array_keys($this->items)); + } + + /** + * Chunk the collection into smaller collections. + * + * @return self> + */ + public function chunk(int $size): self + { + $chunks = []; + + foreach (array_chunk($this->items, $size, true) as $chunk) { + $chunks[] = new self($chunk); + } + + return new self($chunks); + } + + /** + * Take the first N items (or last N if negative). + * + * @return self + */ + public function take(int $limit): self + { + if ($limit < 0) { + return new self(array_slice($this->items, $limit)); + } + + return new self(array_slice($this->items, 0, $limit)); + } + + /** + * Skip the first N items. + * + * @return self + */ + public function skip(int $count): self + { + return new self(array_slice($this->items, $count)); + } + + /** + * Get a subset with only the specified keys. + * + * @param string|array $keys + * @return self + */ + public function only(string|array $keys): self + { + return new self(Arr::only($this->items, $keys)); + } + + /** + * Get a subset excluding the specified keys. + * + * @param string|array $keys + * @return self + */ + public function except(string|array $keys): self + { + return new self(Arr::except($this->items, $keys)); + } + + /** + * Conditionally apply a callback. + * + * @return self + */ + public function when(mixed $value, callable $callback, ?callable $default = null): self + { + if (Value::filled($value)) { + return $callback($this, $value); + } + + if ($default !== null) { + return $default($this, $value); + } + + return $this; + } + + /** + * Apply a callback for side effects, return self. + * + * @return self + */ + public function tap(callable $callback): self + { + $callback($this); + + return $this; + } + + /** + * Pass the collection through a callback and return the result. + */ + public function pipe(callable $callback): mixed + { + return $callback($this); + } + + /** + * Get the sum of items or a key within items. + */ + public function sum(?string $key = null): int|float + { + if ($key === null) { + return array_sum($this->items); + } + + return array_sum(Arr::pluck($this->items, $key)); + } + + /** + * Get the average value. + */ + public function avg(?string $key = null): int|float + { + $count = $this->count(); + + return $count > 0 ? $this->sum($key) / $count : 0; + } + + /** + * Get the minimum value. + */ + public function min(?string $key = null): mixed + { + $values = $key !== null ? Arr::pluck($this->items, $key) : $this->items; + + return $values !== [] ? min($values) : null; + } + + /** + * Get the maximum value. + */ + public function max(?string $key = null): mixed + { + $values = $key !== null ? Arr::pluck($this->items, $key) : $this->items; + + return $values !== [] ? max($values) : null; + } + + public function isEmpty(): bool + { + return $this->items === []; + } + + public function isNotEmpty(): bool + { + return !$this->isEmpty(); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_map( + static fn(mixed $value) => $value instanceof self ? $value->toArray() : Data::toArray($value), + $this->items, + ); + } + + public function toJson(int $options = 0): string + { + return (string) json_encode($this->jsonSerialize(), $options); + } + + // ── Interface implementations ────────────────────────── + + public function count(): int + { + return count($this->items); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->items[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($offset === null) { + $this->items[] = $value; + } else { + $this->items[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void + { + unset($this->items[$offset]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Utility/Data.php b/src/Utility/Data.php new file mode 100644 index 0000000..b6c94a2 --- /dev/null +++ b/src/Utility/Data.php @@ -0,0 +1,107 @@ +|mixed + */ + public static function toArray(mixed $value): mixed + { + if (is_array($value)) { + $result = []; + + foreach ($value as $k => $v) { + $result[$k] = self::toArray($v); + } + + return $result; + } + + if (is_object($value)) { + $result = []; + + foreach (get_object_vars($value) as $k => $v) { + $result[$k] = self::toArray($v); + } + + return $result; + } + + return $value; + } + + /** + * Recursively convert a value to a stdClass object. + */ + public static function toObject(mixed $value): object + { + if (is_object($value)) { + return $value; + } + + if (!is_array($value)) { + return (object) ['value' => $value]; + } + + $obj = new stdClass(); + + foreach ($value as $k => $v) { + $obj->{$k} = is_array($v) ? self::toObject($v) : $v; + } + + return $obj; + } + + /** + * Build a URL query string from an array. + * + * @param array $data Query parameters + */ + public static function toQueryString(array $data): string + { + return http_build_query($data, '', '&', PHP_QUERY_RFC3986); + } + + /** + * Parse a query string into an associative array. + * + * @return array + */ + public static function fromQueryString(string $queryString): array + { + $result = []; + parse_str($queryString, $result); + + return $result; + } +} diff --git a/src/Utility/Date.php b/src/Utility/Date.php new file mode 100644 index 0000000..b0e723b --- /dev/null +++ b/src/Utility/Date.php @@ -0,0 +1,186 @@ +now(); + } + + /** + * Generate a range of dates between start and end (inclusive). + * + * @param string $start Start date string + * @param string $end End date string + * @param string $step DateInterval spec (default: "P1D" = 1 day) + * @param string|null $format Output format, or null for DateTimeImmutable objects + * @return array + */ + public static function range(string $start, string $end, string $step = 'P1D', ?string $format = 'Y-m-d'): array + { + $startDate = new DateTimeImmutable($start); + $endDate = new DateTimeImmutable($end); + $interval = new DateInterval($step); + + $period = new DatePeriod($startDate, $interval, $endDate->add(new DateInterval('P1D'))); + $result = []; + + foreach ($period as $date) { + $result[] = $format !== null ? $date->format($format) : $date; + } + + return $result; + } + + /** + * Calculate the difference between two dates. + */ + public static function diff(string $start, string $end): DateInterval + { + return (new DateTimeImmutable($start))->diff(new DateTimeImmutable($end)); + } + + /** + * Validate a date string against a format. + */ + public static function isValid(string $date, string $format = 'Y-m-d'): bool + { + $parsed = DateTimeImmutable::createFromFormat($format, $date); + + return $parsed !== false && $parsed->format($format) === $date; + } + + /** + * Add days to a date and return formatted result. + */ + public static function addDays(string $date, int $days, string $format = 'Y-m-d'): string + { + $modifier = ($days >= 0 ? '+' : '') . $days . ' days'; + + return (new DateTimeImmutable($date))->modify($modifier)->format($format); + } + + /** + * Subtract days from a date and return formatted result. + */ + public static function subDays(string $date, int $days, string $format = 'Y-m-d'): string + { + return self::addDays($date, -$days, $format); + } + + /** + * Check if a date falls on a weekend (Saturday or Sunday). + */ + public static function isWeekend(string $date): bool + { + $dayOfWeek = (int) (new DateTimeImmutable($date))->format('N'); + + return $dayOfWeek >= 6; + } + + /** + * Check if a date is today. + */ + public static function isToday(string $date): bool + { + return (new DateTimeImmutable($date))->format('Y-m-d') === self::now()->format('Y-m-d'); + } + + /** + * Check if a date is in the past. + */ + public static function isPast(string $date): bool + { + return new DateTimeImmutable($date) < self::now(); + } + + /** + * Check if a date is in the future. + */ + public static function isFuture(string $date): bool + { + return new DateTimeImmutable($date) > self::now(); + } + + /** + * Reformat a date string from one format to another. + */ + public static function reformat(string $date, string $fromFormat, string $toFormat): string + { + $parsed = DateTimeImmutable::createFromFormat($fromFormat, $date); + + if ($parsed === false) { + return $date; + } + + return $parsed->format($toFormat); + } + + /** + * Get the age in years from a birth date. + */ + public static function age(string $birthDate): int + { + return (int) (new DateTimeImmutable($birthDate))->diff(self::now())->y; + } + + private static function getProvider(): DateTimeProviderInterface + { + return self::$provider ??= new SystemDateTimeProvider(); + } +} diff --git a/src/Utility/Encoding.php b/src/Utility/Encoding.php new file mode 100644 index 0000000..c101fae --- /dev/null +++ b/src/Utility/Encoding.php @@ -0,0 +1,59 @@ + 0) { + $standard .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode($standard, true); + + return $decoded !== false ? $decoded : ''; + } +} diff --git a/src/Utility/Environment.php b/src/Utility/Environment.php new file mode 100644 index 0000000..3a9fdea --- /dev/null +++ b/src/Utility/Environment.php @@ -0,0 +1,144 @@ +|null Decoded data or null on failure + */ + public static function readJson(string $path): ?array + { + $json = @file_get_contents($path); + + if ($json === false) { + return null; + } + + $data = json_decode($json, true); + + return is_array($data) ? $data : null; + } + + /** + * Encode data and write it to a JSON file. + * + * @param string $path File path + * @param mixed $data Data to encode + * @param int $flags JSON encoding flags + */ + public static function putJson(string $path, mixed $data, int $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES): bool + { + $json = json_encode($data, $flags); + + if ($json === false) { + return false; + } + + return @file_put_contents($path, $json . "\n") !== false; + } + + /** + * Detect the MIME type of a file using finfo. + */ + public static function mimeType(string $path): ?string + { + if (!file_exists($path) || !function_exists('finfo_open')) { + return null; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + if ($finfo === false) { + return null; + } + + $mime = finfo_file($finfo, $path); + finfo_close($finfo); + + return $mime !== false ? $mime : null; + } + + /** + * Get the file extension (lowercase). + */ + public static function extension(string $path): string + { + return strtolower(pathinfo($path, PATHINFO_EXTENSION)); + } + + /** + * Check if a file is an image by extension or MIME type. + */ + public static function isImage(string $filename): bool + { + $imageExtensions = ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp', 'avif', 'svg']; + + if (in_array(self::extension($filename), $imageExtensions, true)) { + return true; + } + + $mime = self::mimeType($filename); + + return is_string($mime) && str_starts_with($mime, 'image/'); + } + + /** + * Check if a directory and all its contents are writable. + */ + public static function isWritableRecursive(string $directory): bool + { + if (!is_dir($directory) || !is_writable($directory)) { + return false; + } + + $entries = scandir($directory); + + if ($entries === false) { + return false; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + + if (is_dir($path)) { + if (!self::isWritableRecursive($path)) { + return false; + } + } elseif (!is_writable($path)) { + return false; + } + } + + return true; + } + + /** + * Create a directory, optionally recursively. + */ + public static function mkdir(string $directory, int $mode = 0775, bool $recursive = true): bool + { + if (is_dir($directory)) { + return true; + } + + return @mkdir($directory, $mode, $recursive); + } + + /** + * Recursively delete a directory and all its contents. + */ + public static function deleteDirectory(string $directory): bool + { + if (!is_dir($directory)) { + return false; + } + + $entries = scandir($directory); + + if ($entries === false) { + return false; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + + // Remove symlinks as files — never follow into external trees + if (is_link($path)) { + if (!@unlink($path)) { + return false; + } + } elseif (is_dir($path)) { + if (!self::deleteDirectory($path)) { + return false; + } + } elseif (!@unlink($path)) { + return false; + } + } + + return @rmdir($directory); + } + + /** + * Recursively copy a directory. + */ + public static function copyDirectory(string $source, string $destination): bool + { + if (!is_dir($source)) { + return false; + } + + self::mkdir($destination); + + $entries = scandir($source); + + if ($entries === false) { + return false; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $srcPath = $source . DIRECTORY_SEPARATOR . $entry; + $dstPath = $destination . DIRECTORY_SEPARATOR . $entry; + + if (is_dir($srcPath)) { + if (!self::copyDirectory($srcPath, $dstPath)) { + return false; + } + } elseif (!@copy($srcPath, $dstPath)) { + return false; + } + } + + return true; + } + + /** + * Move (copy then delete) a directory. + */ + public static function moveDirectory(string $source, string $destination): bool + { + return self::copyDirectory($source, $destination) && self::deleteDirectory($source); + } + + /** + * Create a zip archive from a directory. + * + * @param string $directory Source directory + * @param string $zipPath Output zip file path + * @param array|null $include Glob patterns to include (null = all) + * @param array|null $exclude Glob patterns to exclude (null = none) + */ + public static function zip(string $directory, string $zipPath, ?array $include = null, ?array $exclude = null): bool + { + if (!is_dir($directory) || !class_exists(ZipArchive::class)) { + return false; + } + + $zip = new ZipArchive(); + + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + ); + + $basePath = realpath($directory) ?: $directory; + + foreach ($iterator as $file) { + if ($file->isDir()) { + continue; + } + + $realPath = $file->getRealPath(); + + if ($realPath === false) { + continue; + } + + $relativePath = ltrim(str_replace('\\', '/', substr($realPath, strlen($basePath))), '/'); + + if (!self::matchesGlobs($relativePath, $include, $exclude)) { + continue; + } + + $zip->addFile($realPath, $relativePath); + } + + return $zip->close(); + } + + /** + * Extract a zip archive to a directory. + */ + public static function unzip(string $zipPath, string $destination): bool + { + if (!class_exists(ZipArchive::class)) { + return false; + } + + $zip = new ZipArchive(); + + if ($zip->open($zipPath) !== true) { + return false; + } + + self::mkdir($destination); + $realDest = realpath($destination); + + if ($realDest === false) { + $zip->close(); + + return false; + } + + $realDest = rtrim($realDest, '/\\') . DIRECTORY_SEPARATOR; + + // Validate each entry to prevent Zip Slip (path traversal) + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + + if ($entryName === false) { + continue; + } + + // Reject entries with traversal sequences or absolute paths before any filesystem operation + $normalized = str_replace('\\', '/', $entryName); + + if ( + str_starts_with($normalized, '/') + || str_contains($normalized, '../') + || preg_match('/^[A-Za-z]:/', $normalized) + ) { + $zip->close(); + + return false; + } + } + + $result = $zip->extractTo($destination); + $zip->close(); + + return $result; + } + + /** + * Get the size of a file in bytes. + */ + public static function size(string $path): int|false + { + return @filesize($path); + } + + /** + * Check if a relative path matches include/exclude glob patterns. + * + * @param string $relativePath Path to check + * @param array|null $include Include patterns (null = match all) + * @param array|null $exclude Exclude patterns (null = exclude none) + */ + private static function matchesGlobs(string $relativePath, ?array $include, ?array $exclude): bool + { + $relativePath = ltrim($relativePath, '/'); + + if ($include !== null && $include !== []) { + $matched = false; + + foreach ($include as $pattern) { + if (fnmatch($pattern, $relativePath, FNM_PATHNAME | FNM_CASEFOLD)) { + $matched = true; + break; + } + } + + if (!$matched) { + return false; + } + } + + if ($exclude !== null) { + foreach ($exclude as $pattern) { + if (fnmatch($pattern, $relativePath, FNM_PATHNAME | FNM_CASEFOLD)) { + return false; + } + } + } + + return true; + } +} diff --git a/src/Utility/HtmlBuilder.php b/src/Utility/HtmlBuilder.php new file mode 100644 index 0000000..fc29a03 --- /dev/null +++ b/src/Utility/HtmlBuilder.php @@ -0,0 +1,185 @@ + 'btn btn-primary', + * 'disabled' => true, // renders as just "disabled" + * 'data-id' => $userInput, // auto-escaped + * 'hidden' => false, // omitted entirely + * ]); + * // class="btn btn-primary" disabled data-id="safe&value" + */ +final class HtmlBuilder +{ + /** + * Build an HTML attribute string from an associative array. + * + * Boolean true renders a valueless attribute (e.g. "disabled"). + * Boolean false or null omits the attribute entirely. + * All string values are escaped via htmlspecialchars. + * + * @param array $attributes + */ + public static function attributes(array $attributes): string + { + $parts = []; + + foreach ($attributes as $key => $value) { + if ($value === false || $value === null) { + continue; + } + + if (!self::isValidName($key)) { + continue; + } + + if ($value === true) { + $parts[] = $key; + continue; + } + + $parts[] = $key . '="' . self::escape((string) $value) . '"'; + } + + return implode(' ', $parts); + } + + /** + * Build a conditional CSS class string. + * + * Accepts a mix of strings (always included) and + * string => bool pairs (included when true). + * + * @param array $classes + * + * Usage: + * HtmlBuilder::classes([ + * 'btn', + * 'btn-primary' => $isPrimary, + * 'btn-lg' => $isLarge, + * 'disabled' => false, + * ]); + * // "btn btn-primary" (if $isPrimary is true) + */ + public static function classes(array $classes): string + { + $result = []; + + foreach ($classes as $class => $condition) { + if (is_int($class)) { + // Numeric key: $condition is the class name (always included) + $result[] = self::escape((string) $condition); + } elseif ($condition) { + // String key: include class only if condition is truthy + $result[] = self::escape($class); + } + } + + return implode(' ', $result); + } + + /** + * Build a complete HTML tag. + * + * @param string $tag Tag name (e.g. "div", "span", "input") + * @param array $attributes Tag attributes + * @param string|null $content Inner HTML (NOT escaped — pass pre-escaped content) + * @param bool $selfClose Self-closing tag (e.g. ) + */ + public static function tag(string $tag, array $attributes = [], ?string $content = null, bool $selfClose = false): string + { + if (!self::isValidName($tag)) { + $tag = 'div'; + } + + $attrs = self::attributes($attributes); + $attrStr = $attrs !== '' ? ' ' . $attrs : ''; + + if ($selfClose) { + return '<' . $tag . $attrStr . ' />'; + } + + return '<' . $tag . $attrStr . '>' . ($content ?? '') . ''; + } + + /** + * Escape a string for safe HTML output. + */ + public static function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Validate an HTML tag or attribute name. + * + * Rejects names containing spaces, quotes, angle brackets, or other + * characters that could break out of the tag/attribute context. + */ + private static function isValidName(string $name): bool + { + return $name !== '' && (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9\-_:.]*$/', $name); + } + + /** + * Build a tag for a stylesheet. + * + * @param array $attributes + */ + public static function stylesheet(string $href, array $attributes = []): string + { + return self::tag('link', array_merge([ + 'rel' => 'stylesheet', + 'href' => $href, + ], $attributes), selfClose: true); + } + + /** + * Build a ']); + self::assertStringContainsString('"><script>', $result); + } + + public function testClassesWithUnconditional(): void + { + $result = HtmlBuilder::classes(['btn', 'btn-lg']); + self::assertSame('btn btn-lg', $result); + } + + public function testClassesWithConditional(): void + { + $result = HtmlBuilder::classes([ + 'btn', + 'btn-primary' => true, + 'btn-disabled' => false, + ]); + self::assertSame('btn btn-primary', $result); + } + + public function testTagSelfClosing(): void + { + $result = HtmlBuilder::tag('input', ['type' => 'text', 'name' => 'q'], selfClose: true); + self::assertSame('', $result); + } + + public function testTagWithContent(): void + { + $result = HtmlBuilder::tag('span', ['class' => 'label'], 'Hello'); + self::assertSame('Hello', $result); + } + + public function testEscape(): void + { + self::assertSame('<script>', HtmlBuilder::escape('', $result); + } +} diff --git a/tests/Unit/Utility/NumberTest.php b/tests/Unit/Utility/NumberTest.php new file mode 100644 index 0000000..3a41f3b --- /dev/null +++ b/tests/Unit/Utility/NumberTest.php @@ -0,0 +1,73 @@ +pipe(fn($v) => $v + 5) + ->pipe(fn($v) => $v * 2) + ->thenReturn(); + + self::assertSame(30, $result); + } + + public function testThrough(): void + { + $result = Pipeline::send(' Hello World ') + ->through([ + fn($v) => trim($v), + fn($v) => strtolower($v), + fn($v) => str_replace(' ', '-', $v), + ]) + ->thenReturn(); + + self::assertSame('hello-world', $result); + } + + public function testThen(): void + { + $result = Pipeline::send(5) + ->pipe(fn($v) => $v * 3) + ->then(fn($v) => "Result: {$v}"); + + self::assertSame('Result: 15', $result); + } + + public function testEmptyPipelineReturnsOriginal(): void + { + $result = Pipeline::send('unchanged')->thenReturn(); + self::assertSame('unchanged', $result); + } +} diff --git a/tests/Unit/Utility/StrTest.php b/tests/Unit/Utility/StrTest.php new file mode 100644 index 0000000..84c0e83 --- /dev/null +++ b/tests/Unit/Utility/StrTest.php @@ -0,0 +1,150 @@ + 42)); + } + + public function testValuePassesArgsToClosure(): void + { + $result = Value::value(fn($a, $b) => $a + $b, 3, 4); + self::assertSame(7, $result); + } + + public function testBlank(): void + { + self::assertTrue(Value::blank(null)); + self::assertTrue(Value::blank('')); + self::assertTrue(Value::blank(' ')); + self::assertTrue(Value::blank([])); + + self::assertFalse(Value::blank(0)); + self::assertFalse(Value::blank(false)); + self::assertFalse(Value::blank('text')); + self::assertFalse(Value::blank([1])); + } + + public function testFilled(): void + { + self::assertTrue(Value::filled('text')); + self::assertTrue(Value::filled(0)); + self::assertFalse(Value::filled(null)); + self::assertFalse(Value::filled('')); + } + + public function testOptionalReturnsOptionalInstance(): void + { + $opt = Value::optional(null); + self::assertInstanceOf(Optional::class, $opt); + } + + public function testOptionalPropertyAccessOnNull(): void + { + self::assertNull(Value::optional(null)->name); + } + + public function testOptionalPropertyAccessOnObject(): void + { + $obj = (object) ['name' => 'XOOPS']; + self::assertSame('XOOPS', Value::optional($obj)->name); + } + + public function testOptionalMethodCallOnNull(): void + { + self::assertNull(Value::optional(null)->someMethod()); + } + + public function testOptionalMethodCallOnNonObjectReturnsNull(): void + { + self::assertNull(Value::optional([])->someMethod()); + self::assertNull(Value::optional(123)->someMethod()); + } + + public function testMissingReturnsSentinel(): void + { + self::assertInstanceOf(MissingValue::class, Value::missing()); + } + + public function testMissingIsSingleton(): void + { + self::assertSame(Value::missing(), Value::missing()); + } + + public function testOnce(): void + { + $counter = 0; + $callback = function () use (&$counter) { + $counter++; + return 'result'; + }; + + self::assertSame('result', Value::once($callback)); + self::assertSame('result', Value::once($callback)); + self::assertSame(1, $counter); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..15bc397 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,43 @@ +