From 9758ab69115f046c914c6f016b7614c84d374984 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 6 Apr 2026 21:11:29 +0600 Subject: [PATCH 1/3] docs --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8acc24a..855976a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # ArrayKit [![Security & Standards](https://github.com/infocyph/arraykit/actions/workflows/build.yml/badge.svg)](https://github.com/infocyph/arraykit/actions/workflows/build.yml) -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/955ce7fb105f4243a018e701f76ebf44)](https://app.codacy.com/gh/infocyph/ArrayKit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) ![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/arraykit?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2Farraykit) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/arraykit) From 7ac703cc0eefe7f9ab9135d667772df872117d32 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 12 Apr 2026 15:17:38 +0600 Subject: [PATCH 2/3] test --- .gitattributes | 20 ++- .github/scripts/composer-audit-guard.php | 85 +++++++++++ .github/scripts/phpstan-sarif.php | 178 +++++++++++++++++++++++ .github/scripts/syntax.php | 109 ++++++++++++++ .github/workflows/build.yml | 26 +++- .gitignore | 26 ++-- captainhook.json | 2 +- composer.json | 93 ++++++++---- phpbench.json | 26 ++++ phpcs.xml.dist | 52 +++++++ phpstan.neon.dist | 2 +- pint.json | 2 +- psalm.xml | 9 +- rector.php | 23 ++- src/Array/ArrayMulti.php | 24 +-- src/Array/ArraySingle.php | 14 +- src/Array/BaseArrayHelper.php | 6 +- src/Array/DotNotation.php | 28 ++-- src/Collection/BaseCollectionTrait.php | 15 +- src/Collection/Pipeline.php | 12 +- src/Config/BaseConfigTrait.php | 4 - src/functions.php | 8 +- src/traits/DTOTrait.php | 4 - src/traits/HookTrait.php | 4 - 24 files changed, 625 insertions(+), 147 deletions(-) create mode 100644 .github/scripts/composer-audit-guard.php create mode 100644 .github/scripts/phpstan-sarif.php create mode 100644 .github/scripts/syntax.php create mode 100644 phpbench.json create mode 100644 phpcs.xml.dist diff --git a/.gitattributes b/.gitattributes index c2f634c..2755315 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,15 +1,21 @@ +.github export-ignore +benchmarks export-ignore +docs export-ignore +examples export-ignore +tests export-ignore + .editorconfig export-ignore +.gitattributes export-ignore .gitignore export-ignore -tests export-ignore -docs export-ignore -.github export-ignore .readthedocs.yaml export-ignore captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore phpunit.xml export-ignore pint.json export-ignore -rector.php export-ignore -.gitattributes export-ignore psalm.xml export-ignore -pest.xml export-ignore +rector.php export-ignore -* text eol=lf +* text eol=lf diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php new file mode 100644 index 0000000..a1b1cdb --- /dev/null +++ b/.github/scripts/composer-audit-guard.php @@ -0,0 +1,85 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], +]; + +$process = proc_open($command, $descriptorSpec, $pipes); + +if (! \is_resource($process)) { + fwrite(STDERR, "Failed to start composer audit process.\n"); + exit(1); +} + +fclose($pipes[0]); +$stdout = stream_get_contents($pipes[1]) ?: ''; +$stderr = stream_get_contents($pipes[2]) ?: ''; +fclose($pipes[1]); +fclose($pipes[2]); + +$exitCode = proc_close($process); + +/** @var array|null $decoded */ +$decoded = json_decode($stdout, true); + +if (! \is_array($decoded)) { + fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); + if (trim($stdout) !== '') { + fwrite(STDERR, $stdout . "\n"); + } + if (trim($stderr) !== '') { + fwrite(STDERR, $stderr . "\n"); + } + + exit($exitCode !== 0 ? $exitCode : 1); +} + +$advisories = $decoded['advisories'] ?? []; +$abandoned = $decoded['abandoned'] ?? []; + +$advisoryCount = 0; + +if (\is_array($advisories)) { + foreach ($advisories as $entries) { + if (\is_array($entries)) { + $advisoryCount += \count($entries); + } + } +} + +$abandonedPackages = []; + +if (\is_array($abandoned)) { + foreach ($abandoned as $package => $replacement) { + if (\is_string($package) && $package !== '') { + $abandonedPackages[$package] = $replacement; + } + } +} + +echo sprintf( + "Composer audit summary: %d advisories, %d abandoned packages.\n", + $advisoryCount, + \count($abandonedPackages), +); + +if ($abandonedPackages !== []) { + fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); + foreach ($abandonedPackages as $package => $replacement) { + $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; + fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); + } +} + +if ($advisoryCount > 0) { + fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); + exit(1); +} + +exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php new file mode 100644 index 0000000..2b01b26 --- /dev/null +++ b/.github/scripts/phpstan-sarif.php @@ -0,0 +1,178 @@ + [sarif-output] + */ + +$argv = $_SERVER['argv'] ?? []; +$input = $argv[1] ?? ''; +$output = $argv[2] ?? 'phpstan-results.sarif'; + +if (! is_string($input) || $input === '') { + fwrite(STDERR, "Error: missing input file.\n"); + fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); + exit(2); +} + +if (! is_file($input) || ! is_readable($input)) { + fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); + exit(2); +} + +$raw = file_get_contents($input); +if ($raw === false) { + fwrite(STDERR, "Error: failed to read input file: {$input}\n"); + exit(2); +} + +$decoded = json_decode($raw, true); +if (! is_array($decoded)) { + fwrite(STDERR, "Error: input is not valid JSON.\n"); + exit(2); +} + +/** + * @return non-empty-string + */ +function normalizeUri(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $cwd = getcwd(); + + if (is_string($cwd) && $cwd !== '') { + $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); + + if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { + if (stripos($normalized, $cwd . '/') === 0) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } elseif (str_starts_with($normalized, '/')) { + if (str_starts_with($normalized, $cwd . '/')) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } + } + + $normalized = ltrim($normalized, './'); + + return $normalized === '' ? 'unknown.php' : $normalized; +} + +$results = []; +$rules = []; + +$globalErrors = $decoded['errors'] ?? []; +if (is_array($globalErrors)) { + foreach ($globalErrors as $error) { + if (! is_string($error) || $error === '') { + continue; + } + + $ruleId = 'phpstan.internal'; + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $error, + ], + ]; + } +} + +$files = $decoded['files'] ?? []; +if (is_array($files)) { + foreach ($files as $filePath => $fileData) { + if (! is_string($filePath) || ! is_array($fileData)) { + continue; + } + + $messages = $fileData['messages'] ?? []; + if (! is_array($messages)) { + continue; + } + + foreach ($messages as $messageData) { + if (! is_array($messageData)) { + continue; + } + + $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); + $line = (int) ($messageData['line'] ?? 1); + $identifier = (string) ($messageData['identifier'] ?? ''); + $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; + + if ($line < 1) { + $line = 1; + } + + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $messageText, + ], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => normalizeUri($filePath), + ], + 'region' => [ + 'startLine' => $line, + ], + ], + ]], + ]; + } + } +} + +$ruleDescriptors = []; +$ruleIds = array_keys($rules); +sort($ruleIds); + +foreach ($ruleIds as $ruleId) { + $ruleDescriptors[] = [ + 'id' => $ruleId, + 'name' => $ruleId, + 'shortDescription' => [ + 'text' => $ruleId, + ], + ]; +} + +$sarif = [ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPStan', + 'informationUri' => 'https://phpstan.org/', + 'rules' => $ruleDescriptors, + ], + ], + 'results' => $results, + ]], +]; + +$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +if (! is_string($encoded)) { + fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); + exit(2); +} + +$written = file_put_contents($output, $encoded . PHP_EOL); +if ($written === false) { + fwrite(STDERR, "Error: failed to write output file: {$output}\n"); + exit(2); +} + +fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); +exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php new file mode 100644 index 0000000..043bf53 --- /dev/null +++ b/.github/scripts/syntax.php @@ -0,0 +1,109 @@ +isFile()) { + continue; + } + + $filename = $entry->getFilename(); + if (! str_ends_with($filename, '.php')) { + continue; + } + + $files[] = $entry->getPathname(); + } +} + +$files = array_values(array_unique($files)); +sort($files); + +if ($files === []) { + fwrite(STDOUT, "No PHP files found.\n"); + exit(0); +} + +$failed = []; + +foreach ($files as $file) { + $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + + if (! is_resource($process)) { + $failed[] = [$file, 'Could not start PHP lint process']; + continue; + } + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + $output = trim((string) $stdout . "\n" . (string) $stderr); + $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; + } +} + +if ($failed === []) { + fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); + exit(0); +} + +fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); + +foreach ($failed as [$file, $error]) { + fwrite(STDERR, "- {$file}\n{$error}\n"); +} + +exit(1); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0261ce7..001aea0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,8 +54,10 @@ jobs: - name: Test run: | + composer test:syntax composer test:code composer test:lint + composer test:sniff composer test:refactor if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then composer test:static @@ -85,12 +87,32 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: composer:v2 + coverage: xdebug - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Composer Audit (CVE check) - run: composer audit --no-interaction + - name: Composer Audit (Release Guard) + run: composer release:audit + + - name: Quality Gate (PHPStan) + run: composer test:static + + - name: Security Gate (Psalm) + run: composer test:security + + - name: Run PHPStan (Code Scanning) + run: | + php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true + php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif + continue-on-error: true + + - name: Upload PHPStan Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: phpstan-results.sarif + category: "phpstan-${{ matrix.php-versions }}" + if: always() && hashFiles('phpstan-results.sarif') != '' # Run Psalm (Deep Taint Analysis) - name: Run Psalm Security Scan diff --git a/.gitignore b/.gitignore index 5cba62c..961285c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,18 @@ -vendor -example .idea -example.php -test.php -composer.lock -git-story_media +.psalm-cache +.phpunit.cache +.vscode +.windsurf *~ +*.patch *.txt !docs/requirements.txt -.windsurf -.vscode -.phpunit.cache -var -*.patch -patch.php -.psalm-cache AI_CONTEXT.md +composer.lock +example +example.php +git-story_media +patch.php +test.php +var +vendor diff --git a/captainhook.json b/captainhook.json index 8ef4f97..fa19900 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,7 +15,7 @@ "options": [] }, { - "action": "composer audit", + "action": "composer release:audit", "options": [] }, { diff --git a/composer.json b/composer.json index 56afbaf..28ce5ad 100644 --- a/composer.json +++ b/composer.json @@ -30,43 +30,76 @@ "require": { "php": ">=8.3" }, - "minimum-stability": "stable", - "prefer-stable": true, - "config": { - "sort-packages": true, - "optimize-autoloader": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } + "require-dev": { + "captainhook/captainhook": "^5.29.2", + "laravel/pint": "^1.29", + "pestphp/pest": "^4.5", + "pestphp/pest-plugin-drift": "^4.1", + "phpbench/phpbench": "^1.6", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.4.1", + "squizlabs/php_codesniffer": "^4.0.1", + "symfony/var-dumper": "^7.3 || ^8.0.8", + "tomasvotruba/cognitive-complexity": "^1.1", + "vimeo/psalm": "^6.16.1" }, "scripts": { - "test:code": "@php -d error_reporting=24575 vendor/bin/pest --parallel --processes=10", - "test:security": "@php -d error_reporting=24575 vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", - "test:static": "@php -d error_reporting=24575 vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", - "test:refactor": "@php -d error_reporting=24575 vendor/bin/rector process --dry-run", - "test:lint": "@php -d error_reporting=24575 vendor/bin/pint --test", - "tests": [ + "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", + "test:code": "@php vendor/bin/pest", + "test:lint": "@php vendor/bin/pint --test", + "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G", + "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", + "test:refactor": "@php vendor/bin/rector process --dry-run --debug", + "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "test:details": [ + "@test:syntax", "@test:code", "@test:lint", + "@test:sniff", "@test:static", - "@test:refactor", - "@test:security" + "@test:security", + "@test:refactor" + ], + "test:all": [ + "@test:syntax", + "@php vendor/bin/pest --parallel --processes=10", + "@php vendor/bin/pint --test", + "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", + "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", + "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --no-progress --no-cache", + "@php vendor/bin/rector process --dry-run" + ], + "release:audit": "@php .github/scripts/composer-audit-guard.php", + "release:guard": [ + "@composer validate --strict", + "@release:audit", + "@tests" ], - "git:hook": "captainhook install --only-enabled -nf", - "test": "@php vendor/bin/pest", - "refactor": "@php vendor/bin/rector process", - "lint": "@php vendor/bin/pint", - "security:scan": "@test:security", + "process:lint": "@php vendor/bin/pint", + "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", + "process:refactor": "@php vendor/bin/rector process", + "process:all": [ + "@process:refactor", + "@process:lint", + "@process:sniff:fix" + ], + "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", + "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", + "tests": "@test:all", + "process": "@process:all", + "benchmark": "@bench:run", "post-autoload-dump": "captainhook install --only-enabled -nf" }, - "require-dev": { - "captainhook/captainhook": "^5.29.2", - "laravel/pint": "^1.29", - "pestphp/pest": "^4.4.3", - "pestphp/pest-plugin-drift": "^4.1", - "rector/rector": "^2.3.9", - "symfony/var-dumper": "^7.3 || ^8.0.8", - "vimeo/psalm": "^6.16.1", - "tomasvotruba/cognitive-complexity": "^1.1" + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true, + "optimize-autoloader": true, + "classmap-authoritative": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..fff7e05 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,26 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.attributes": true, + "runner.annotations": false, + "runner.progress": "dots", + "runner.retry_threshold": 8, + "report.generators": { + "chart": { + "title": "Benchmark Chart", + "description": "Console bar chart grouped by benchmark subject", + "generator": "component", + "components": [ + { + "component": "bar_chart_aggregate", + "x_partition": ["subject_name"], + "bar_partition": ["benchmark_name"], + "y_expr": "mode(partition['result_time_avg'])", + "y_axes_label": "yValue as time precision 1" + } + ] + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..666c061 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,52 @@ + + + Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. + + + + + + + ./src + ./tests + + */vendor/* + */.git/* + */.idea/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 068e2a6..0adc1df 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,6 @@ parameters: maximumNumberOfProcesses: 1 cognitive_complexity: class: 150 - function: 15 + function: 14 dependency_tree: 150 dependency_tree_types: [] diff --git a/pint.json b/pint.json index e6b7949..46529c3 100644 --- a/pint.json +++ b/pint.json @@ -1,5 +1,5 @@ { - "preset": "psr12", + "preset": "per", "exclude": [ "tests" ], diff --git a/psalm.xml b/psalm.xml index 37695ab..49a4a35 100644 --- a/psalm.xml +++ b/psalm.xml @@ -3,7 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorLevel="3" + errorLevel="2" > @@ -28,6 +28,13 @@ + + + + + + + diff --git a/rector.php b/rector.php index 8a280ec..e30ddf8 100644 --- a/rector.php +++ b/rector.php @@ -3,17 +3,12 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Set\ValueObject\SetList; - -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->paths([__DIR__.'/src']); - - $setConstant = SetList::class.'::PHP_'.PHP_MAJOR_VERSION.PHP_MINOR_VERSION; - if (! defined($setConstant)) { - $setConstant = SetList::class.'::PHP_84'; - } - - $rectorConfig->sets([ - constant($setConstant), - ]); -}; +use Rector\ValueObject\PhpVersion; + +return RectorConfig::configure() + ->withPaths([__DIR__ . '/src']) + ->withPreparedSets(deadCode: true) + ->withPhpVersion( + constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), + ) + ->withPhpSets(); diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 5d293c7..f193268 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -20,7 +20,7 @@ class ArrayMulti */ public static function between(array $array, string $key, float|int $from, float|int $to): array { - return array_filter($array, fn ($item) => ArraySingle::exists($item, $key) + return array_filter($array, fn($item) => ArraySingle::exists($item, $key) && compare($item[$key], $from, '>=') && compare($item[$key], $to, '<=')); } @@ -326,7 +326,7 @@ public static function map(array $array, callable $callback): array public static function only(array $array, array|string $keys): array { $result = []; - $pick = array_flip((array)$keys); + $pick = array_flip((array) $keys); foreach ($array as $item) { if (is_array($item)) { @@ -424,9 +424,9 @@ public static function reject(array $array, mixed $callback = true): array // Could unify via BaseArrayHelper::doReject($array, $callback). // Or keep local logic: if (is_callable($callback)) { - return array_filter($array, fn ($row, $key) => !$callback($row, $key), \ARRAY_FILTER_USE_BOTH); + return array_filter($array, fn($row, $key) => !$callback($row, $key), \ARRAY_FILTER_USE_BOTH); } - return array_filter($array, fn ($row) => $row != $callback); + return array_filter($array, fn($row) => $row != $callback); } /** @@ -460,7 +460,7 @@ public static function skip(array $array, int $count): array */ public static function skipUntil(array $array, callable $callback): array { - return static::skipWhile($array, fn ($row, $key) => !$callback($row, $key)); + return static::skipWhile($array, fn($row, $key) => !$callback($row, $key)); } /** @@ -528,7 +528,7 @@ public static function sortBy( bool $desc = false, int $options = \SORT_REGULAR, ): array { - uasort($array, function ($a, $b) use ($by, $desc, $options) { + uasort($array, function ($a, $b) use ($by, $desc) { $valA = is_callable($by) ? $by($a) : ($a[$by] ?? null); $valB = is_callable($by) ? $by($b) : ($b[$by] ?? null); @@ -696,7 +696,7 @@ public static function where(array $array, string $key, mixed $operator = null, $operator = null; } - return array_filter($array, fn ($item) => ArraySingle::exists($item, $key) && compare($item[$key], $value, $operator)); + return array_filter($array, fn($item) => ArraySingle::exists($item, $key) && compare($item[$key], $value, $operator)); } /** @@ -716,7 +716,7 @@ public static function whereCallback(array $array, ?callable $callback = null, m if ($callback === null) { return empty($array) ? $default : $array; } - return array_filter($array, fn ($item, $index) => $callback($item, $index), \ARRAY_FILTER_USE_BOTH); + return array_filter($array, fn($item, $index) => $callback($item, $index), \ARRAY_FILTER_USE_BOTH); } /** @@ -732,7 +732,7 @@ public static function whereIn(array $array, string $key, array $values, bool $s { return array_filter( $array, - fn ($row) + fn($row) => isset($row[$key]) && in_array($row[$key], $values, $strict), ); } @@ -750,7 +750,7 @@ public static function whereNotIn(array $array, string $key, array $values, bool { return array_filter( $array, - fn ($row) + fn($row) => !isset($row[$key]) || !in_array($row[$key], $values, $strict), ); } @@ -767,7 +767,7 @@ public static function whereNotIn(array $array, string $key, array $values, bool */ public static function whereNotNull(array $array, string $key): array { - return array_filter($array, fn ($row) => isset($row[$key])); + return array_filter($array, fn($row) => isset($row[$key])); } /** @@ -784,7 +784,7 @@ public static function whereNull(array $array, string $key): array { return array_filter( $array, - fn ($row) + fn($row) => !empty($row) && array_key_exists($key, $row) && $row[$key] === null, ); } diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index ac5f317..a6c8512 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -64,7 +64,7 @@ public static function combine(array $keys, array $values): array $values = array_slice($values, 0, $size); } - return array_combine($keys, $values) ?: []; + return array_combine($keys, $values); } /** @@ -316,7 +316,7 @@ public static function mode(array $array): array } $freq = array_count_values($array); $max = max($freq); - return array_keys(array_filter($freq, fn ($c) => $c === $max)); + return array_keys(array_filter($freq, fn($c) => $c === $max)); } /** @@ -327,7 +327,7 @@ public static function mode(array $array): array */ public static function negative(array $array): array { - return static::where($array, static fn ($value) => is_numeric($value) && $value < 0); + return static::where($array, static fn($value) => is_numeric($value) && $value < 0); } /** @@ -378,7 +378,7 @@ public static function nth(array $array, int $step, int $offset = 0): array */ public static function only(array $array, array|string $keys): array { - return array_intersect_key($array, array_flip((array)$keys)); + return array_intersect_key($array, array_flip((array) $keys)); } /** @@ -437,7 +437,7 @@ public static function partition(array $array, callable $callback): array */ public static function positive(array $array): array { - return static::where($array, static fn ($value) => is_numeric($value) && $value > 0); + return static::where($array, static fn($value) => is_numeric($value) && $value > 0); } /** @@ -608,7 +608,7 @@ public static function skip(array $array, int $count): array */ public static function skipUntil(array $array, callable $callback): array { - return static::skipWhile($array, fn ($value, $key) => !$callback($value, $key)); + return static::skipWhile($array, fn($value, $key) => !$callback($value, $key)); } /** @@ -747,6 +747,6 @@ public static function unique(array $array, bool $strict = false): array public static function where(array $array, ?callable $callback = null): array { $flag = ($callback !== null) ? \ARRAY_FILTER_USE_BOTH : 0; - return array_filter($array, $callback ?? fn ($val) => (bool)$val, $flag); + return array_filter($array, $callback ?? fn($val) => (bool) $val, $flag); } } diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 895f78d..4eca3e1 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -73,11 +73,11 @@ public static function doReject(array $array, mixed $callback): array if (is_callable($callback)) { return array_filter( $array, - fn ($val, $key) => !$callback($val, $key), - ARRAY_FILTER_USE_BOTH + fn($val, $key) => !$callback($val, $key), + ARRAY_FILTER_USE_BOTH, ); } - return array_filter($array, fn ($val) => $val != $callback); + return array_filter($array, fn($val) => $val != $callback); } diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index 1a9cd37..db230be 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -34,7 +34,7 @@ public static function arrayValue(array $array, string $key, mixed $default = nu { $value = static::get($array, $key, $default); if (! is_array($value)) { - throw new InvalidArgumentException('Expected array, got '.get_debug_type($value)); + throw new InvalidArgumentException('Expected array, got ' . get_debug_type($value)); } return $value; @@ -58,7 +58,7 @@ public static function boolean(array $array, string $key, mixed $default = null) { $value = static::get($array, $key, $default); if (! is_bool($value)) { - throw new InvalidArgumentException('Expected bool, got '.get_debug_type($value)); + throw new InvalidArgumentException('Expected bool, got ' . get_debug_type($value)); } return $value; @@ -109,10 +109,10 @@ public static function flatten(array $array, string $prepend = ''): array if (is_array($value) && ! empty($value)) { $results = array_merge( $results, - static::flatten($value, $prepend.$key.'.') + static::flatten($value, $prepend . $key . '.'), ); } else { - $results[$prepend.$key] = $value; + $results[$prepend . $key] = $value; } } @@ -137,7 +137,7 @@ public static function float(array $array, string $key, mixed $default = null): { $value = static::get($array, $key, $default); if (! is_float($value)) { - throw new InvalidArgumentException('Expected float, got '.get_debug_type($value)); + throw new InvalidArgumentException('Expected float, got ' . get_debug_type($value)); } return $value; @@ -291,7 +291,7 @@ public static function integer(array $array, string $key, mixed $default = null) { $value = static::get($array, $key, $default); if (! is_int($value)) { - throw new InvalidArgumentException('Expected int, got '.get_debug_type($value)); + throw new InvalidArgumentException('Expected int, got ' . get_debug_type($value)); } return $value; @@ -437,7 +437,7 @@ public static function string(array $array, string $key, mixed $default = null): { $value = static::get($array, $key, $default); if (! is_string($value)) { - throw new InvalidArgumentException('Expected string, got '.get_debug_type($value)); + throw new InvalidArgumentException('Expected string, got ' . get_debug_type($value)); } return $value; @@ -516,7 +516,7 @@ private static function getValue(mixed $target, int|string $key, mixed $default) // Return top-level or integer index return $target[$key] ?? static::value($default); } - if (! is_string($key) || ! str_contains($key, '.')) { + if (! str_contains($key, '.')) { // If no dot path return static::value($default); } @@ -583,8 +583,8 @@ private static function normalizeSegment(string $segment, mixed $target): mixed private static function resolveFirst(mixed $target): string|int|null { if (( - is_object($target) || - (is_string($target) && class_exists($target)) + is_object($target) + || (is_string($target) && class_exists($target)) ) && method_exists($target, 'all')) { $arr = $target->all(); @@ -605,8 +605,8 @@ private static function resolveFirst(mixed $target): string|int|null private static function resolveLast(mixed $target): string|int|null { if (( - is_object($target) || - (is_string($target) && class_exists($target)) + is_object($target) + || (is_string($target) && class_exists($target)) ) && method_exists($target, 'all')) { $arr = $target->all(); @@ -813,8 +813,8 @@ private static function traverseGet(mixed $target, array $segments, mixed $defau private static function traverseWildcard(mixed $target, array $segments, mixed $default): mixed { $target = ( - is_object($target) || - (is_string($target) && class_exists($target)) + is_object($target) + || (is_string($target) && class_exists($target)) ) && method_exists($target, 'all') ? $target->all() : $target; if (! BaseArrayHelper::accessible($target)) { diff --git a/src/Collection/BaseCollectionTrait.php b/src/Collection/BaseCollectionTrait.php index 8b0dc7f..9a3bfb8 100644 --- a/src/Collection/BaseCollectionTrait.php +++ b/src/Collection/BaseCollectionTrait.php @@ -49,7 +49,7 @@ public function __call(string $method, array $arguments): mixed if (method_exists($pipeline, $method)) { return $pipeline->$method(...$arguments); } - throw new BadMethodCallException("Method $method does not exist in ".static::class); + throw new BadMethodCallException("Method $method does not exist in " . static::class); } /** @@ -115,7 +115,6 @@ public function __isset(string $key): bool * * @param string $key The key of the item to set. * @param mixed $value The value to set. - * @return void */ public function __set(string $key, mixed $value): void { @@ -138,7 +137,6 @@ public function __toString(): string * It internally calls the offsetUnset method to remove the value. * * @param string $key The key of the item to remove. - * @return void */ public function __unset(string $key): void { @@ -167,7 +165,6 @@ public static function from(mixed $data): static * If the given data is not an array, it will be converted to an array. * * @param mixed $data The data to initialize the collection with. - * @return static */ public static function make(mixed $data): static { @@ -348,7 +345,7 @@ public function items(): array public function jsonSerialize(): array { return array_map( - static fn ($value) => $value instanceof JsonSerializable ? $value->jsonSerialize() : $value, + static fn($value) => $value instanceof JsonSerializable ? $value->jsonSerialize() : $value, $this->data, ); } @@ -378,9 +375,6 @@ public function keys(): array * Merge additional items into the collection. * * Numeric keys are appended; string keys are overwritten by incoming items. - * - * @param mixed $items - * @return static */ public function merge(mixed $items): static { @@ -453,15 +447,14 @@ public function offsetGet(mixed $offset): mixed * * @param mixed $offset The key of the item to set, or null to append. * @param mixed $value The value of the item to set. - * @return void */ public function offsetSet(mixed $offset, mixed $value): void { match (true) { $offset === null => $this->data[] = $value, - is_string($offset) && str_contains($offset, '.') => - DotNotation::set($this->data, $offset, $value), + is_string($offset) && str_contains($offset, '.') + => DotNotation::set($this->data, $offset, $value), default => $this->data[$offset] = $value, }; diff --git a/src/Collection/Pipeline.php b/src/Collection/Pipeline.php index faad170..f314830 100644 --- a/src/Collection/Pipeline.php +++ b/src/Collection/Pipeline.php @@ -16,8 +16,7 @@ class Pipeline public function __construct( protected array &$working, private readonly Collection $collection, - ) { - } + ) {} /** * Quick example: Check if at least one item passes a truth test, from ArraySingle::some or ArrayMulti::some @@ -230,7 +229,6 @@ public function partition(callable $callback): Collection * Pipe the working array through a callback, replacing it with whatever you return. * * @param callable $callback fn(array $working): array - * @return Collection */ public function pipe(callable $callback): Collection { @@ -344,7 +342,6 @@ public function sum(?callable $callback = null): float|int * Tap into the current working array for side-effects (debug/log), then continue. * * @param callable $callback fn(array $working): void - * @return Collection */ public function tap(callable $callback): Collection { @@ -370,11 +367,6 @@ public function unique(bool $strict = false): Collection /** * Inverse of when(): only run if $condition is false. - * - * @param bool $condition - * @param callable $callback - * @param callable|null $default - * @return Collection */ public function unless(bool $condition, callable $callback, ?callable $default = null): Collection { @@ -396,10 +388,8 @@ public function unWrap(): Collection /** * Conditionally apply one of two callbacks based on $condition. * - * @param bool $condition * @param callable $callback fn(array $working): array * @param callable|null $default fn(array $working): array - * @return Collection */ public function when(bool $condition, callable $callback, ?callable $default = null): Collection { diff --git a/src/Config/BaseConfigTrait.php b/src/Config/BaseConfigTrait.php index d381d95..6636192 100644 --- a/src/Config/BaseConfigTrait.php +++ b/src/Config/BaseConfigTrait.php @@ -42,7 +42,6 @@ public function append(string $key, mixed $value): bool * * @param string|array $key Dot-notation key or multiple [key => value] * @param mixed|null $value The value to set if missing - * @return bool */ public function fill(string|array $key, mixed $value = null): bool { @@ -52,9 +51,6 @@ public function fill(string|array $key, mixed $value = null): bool /** * Remove/unset a key (or keys) from configuration using dot notation + wildcard expansions. - * - * @param string|int|array $key - * @return bool */ public function forget(string|int|array $key): bool { diff --git a/src/functions.php b/src/functions.php index 69a27ff..55ab54a 100644 --- a/src/functions.php +++ b/src/functions.php @@ -35,10 +35,7 @@ function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool if (!function_exists('isCallable')) { /** - * Determine if the given value is callable (but not a string). - * - * @param mixed $value - * @return bool + * Determine if the given value is callable(but not a string). */ function isCallable(mixed $value): bool { @@ -75,7 +72,6 @@ function array_get(array $array, int|string|array|null $key = null, mixed $defau * If a single key is provided, the value is set directly. * * @param array $array The array to set items in. - * @param string|array|null $key * @param mixed $value The value to set. * @param bool $overwrite If true, overwrite existing values. If false, existing values are preserved. * @return bool True on success @@ -90,7 +86,6 @@ function array_set(array &$array, string|array|null $key, mixed $value = null, b * Wrap the given value in an {@see Collection}. * * @param mixed $data Anything “array-able”: array, Traversable, scalar, etc. - * @return Collection */ function collect(mixed $data = []): Collection { @@ -102,7 +97,6 @@ function collect(mixed $data = []): Collection * Start a chainable pipeline on any “array-able” value. * * @param mixed $data Array, Traversable, scalar, etc. - * @return Pipeline */ function chain(mixed $data): Pipeline { diff --git a/src/traits/DTOTrait.php b/src/traits/DTOTrait.php index b237ae0..492c569 100644 --- a/src/traits/DTOTrait.php +++ b/src/traits/DTOTrait.php @@ -30,7 +30,6 @@ trait DTOTrait * class property names will be set. * * @param array $values Key-value pairs matching property names - * @return static */ public static function create(array $values): static { @@ -43,7 +42,6 @@ public static function create(array $values): static * Unknown keys are ignored. * * @param array $values Key-value pairs matching property names - * @return static */ public function fromArray(array $values): static { @@ -58,8 +56,6 @@ public function fromArray(array $values): static /** * Convert the current object’s public properties into an array. - * - * @return array */ public function toArray(): array { diff --git a/src/traits/HookTrait.php b/src/traits/HookTrait.php index 98816a6..188ea16 100644 --- a/src/traits/HookTrait.php +++ b/src/traits/HookTrait.php @@ -32,7 +32,6 @@ trait HookTrait * * @param string $offset The key or offset * @param callable $callback A transformation: fn($value) => $newValue - * @return static */ public function onGet(string $offset, callable $callback): static { @@ -44,7 +43,6 @@ public function onGet(string $offset, callable $callback): static * * @param string $offset The key or offset * @param callable $callback A transformation: fn($value) => $newValue - * @return static */ public function onSet(string $offset, callable $callback): static { @@ -57,7 +55,6 @@ public function onSet(string $offset, callable $callback): static * @param mixed $offset The key or offset (string recommended) * @param string $direction Either "get" or "set" * @param callable $callback The transformation function - * @return static */ protected function addHook(mixed $offset, string $direction, callable $callback): static { @@ -75,7 +72,6 @@ protected function addHook(mixed $offset, string $direction, callable $callback) * * @param string $hook The offset or key * @param string $direction Either "get" or "set" - * @return string */ protected function getHookName(string $hook, string $direction): string { From 0671eefe4f89ec08345cf2f705318029cf3a1c2f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 12 Apr 2026 16:35:30 +0600 Subject: [PATCH 3/3] lazy+docs --- README.md | 1 + composer.json | 8 +- docs/config.rst | 44 ++++ docs/rule-reference.rst | 18 ++ src/Array/ArrayMulti.php | 50 ++-- src/Array/ArraySingle.php | 61 +++-- src/Array/BaseArrayHelper.php | 30 +-- src/Array/DotNotation.php | 10 +- src/Config/LazyFileConfig.php | 349 +++++++++++++++++++++++++++ src/traits/DTOTrait.php | 2 +- tests/Feature/ArrayMultiTest.php | 38 ++- tests/Feature/ArraySingleTest.php | 29 +++ tests/Feature/DotNotationTest.php | 5 + tests/Feature/LazyFileConfigTest.php | 180 ++++++++++++++ 14 files changed, 736 insertions(+), 89 deletions(-) create mode 100644 src/Config/LazyFileConfig.php create mode 100644 tests/Feature/LazyFileConfigTest.php diff --git a/README.md b/README.md index 855976a..2a8fb0f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ real-world PHP projects. | Class | Description | |---------------------|---------------------------------------------------------------------------------------------------------------------| | **Config** | Dot-access configuration loader. | +| **LazyFileConfig** | First-segment lazy loader (`db.host` loads `db.php` on demand) for lower memory usage on large config trees. | | **DynamicConfig** | Extends `Config` with **on-get/on-set hooks** to transform values dynamically (e.g., encrypt/decrypt, auto-format). | | **BaseConfigTrait** | Shared config logic. | diff --git a/composer.json b/composer.json index 28ce5ad..91ea47d 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ } }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { "captainhook/captainhook": "^5.29.2", @@ -66,9 +66,9 @@ "@php vendor/bin/pest --parallel --processes=10", "@php vendor/bin/pint --test", "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", - "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", - "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --no-progress --no-cache", - "@php vendor/bin/rector process --dry-run" + "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", + "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", + "@php vendor/bin/rector process --dry-run --debug" ], "release:audit": "@php .github/scripts/composer-audit-guard.php", "release:guard": [ diff --git a/docs/config.rst b/docs/config.rst index d507401..385f3f7 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -6,9 +6,11 @@ ArrayKit configuration objects provide dot-notation access to nested settings. Classes: - ``Infocyph\ArrayKit\Config\Config`` +- ``Infocyph\ArrayKit\Config\LazyFileConfig`` - ``Infocyph\ArrayKit\Config\DynamicConfig`` ``DynamicConfig`` extends ``Config`` by adding value hooks. +``LazyFileConfig`` loads namespace files only on first keyed access. Loading Configuration --------------------- @@ -177,6 +179,40 @@ Use config as a mutable runtime container for app setup: $config->set('app.timezone', ' utc '); $tz = $config->get('app.timezone'); // UTC +LazyFileConfig +-------------- + +Use ``LazyFileConfig`` when configuration is split into top-level namespace files +like ``db.php``, ``cache.php``, ``queue.php``. + +Rules: + +- Key format is ``namespace.path.to.key``. +- On first access, only ``{directory}/{namespace}.php`` is loaded. +- Remaining key segments are resolved using dot notation. + +.. code-block:: php + + get('db.host', '127.0.0.1'); + + // Optional warm-up: + $config->preload(['db', 'cache']); + + $loaded = $config->loadedNamespaces(); // ['db', 'cache'] + +Important behavior: + +- ``get()`` requires at least one key. +- ``all()`` is intentionally disabled and throws. +- Namespace file must return an array. +- Missing namespace file returns the provided default. + Method Summary -------------- @@ -187,6 +223,14 @@ Config methods: - ``set()``, ``fill()``, ``forget()`` - ``prepend()``, ``append()`` +LazyFileConfig methods: + +- ``get()`` (requires key) +- ``has()``, ``hasAny()`` +- ``set()``, ``fill()``, ``forget()`` +- ``preload()``, ``isLoaded()``, ``loadedNamespaces()`` +- ``all()`` (throws by design) + DynamicConfig methods: - ``get()`` (hook-aware override) diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index e0b44bd..03050f0 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -358,6 +358,24 @@ Config uses ``BaseConfigTrait``. Public API: public function prepend(string $key, mixed $value): bool public function append(string $key, mixed $value): bool +LazyFileConfig +-------------------------------------- + +LazyFileConfig loads top-level config files on first keyed access: + +.. code-block:: php + + public function get(string|int|array|null $key = null, mixed $default = null): mixed + public function has(string|array $keys): bool + public function hasAny(string|array $keys): bool + public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool + public function fill(string|array $key, mixed $value = null): bool + public function forget(string|int|array $key): bool + public function preload(string|array $namespaces): static + public function isLoaded(string $namespace): bool + public function loadedNamespaces(): array + public function all(): array // throws (design choice) + DynamicConfig -------------------------------------- diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index f193268..2523cda 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -154,12 +154,7 @@ public static function each(array $array, callable $callback): array */ public static function every(array $array, callable $callback): bool { - foreach ($array as $key => $row) { - if (!$callback($row, $key)) { - return false; - } - } - return true; + return array_all($array, fn($row, $key) => $callback($row, $key)); } /** @@ -501,12 +496,7 @@ public static function skipWhile(array $array, callable $callback): array */ public static function some(array $array, callable $callback): bool { - foreach ($array as $key => $row) { - if ($callback($row, $key)) { - return true; - } - } - return false; + return array_any($array, fn($row, $key) => $callback($row, $key)); } /** @@ -528,14 +518,12 @@ public static function sortBy( bool $desc = false, int $options = \SORT_REGULAR, ): array { - uasort($array, function ($a, $b) use ($by, $desc) { + uasort($array, function ($a, $b) use ($by, $desc, $options) { $valA = is_callable($by) ? $by($a) : ($a[$by] ?? null); $valB = is_callable($by) ? $by($b) : ($b[$by] ?? null); - if ($valA === $valB) { - return 0; - } - $comparison = ($valA < $valB) ? -1 : 1; + $comparison = static::compareSortValues($valA, $valB, $options); + return $desc ? -$comparison : $comparison; }); return $array; @@ -788,4 +776,32 @@ public static function whereNull(array $array, string $key): array => !empty($row) && array_key_exists($key, $row) && $row[$key] === null, ); } + + /** + * Compare two values according to PHP sort options. + * + * Supports SORT_REGULAR, SORT_NUMERIC, SORT_STRING, SORT_NATURAL, + * SORT_LOCALE_STRING, and SORT_FLAG_CASE (for string/natural sorts). + */ + private static function compareSortValues(mixed $left, mixed $right, int $options): int + { + if ($left === $right) { + return 0; + } + + $caseInsensitive = (bool) ($options & \SORT_FLAG_CASE); + $baseOption = $options & ~\SORT_FLAG_CASE; + + return match ($baseOption) { + \SORT_NUMERIC => (float) $left <=> (float) $right, + \SORT_STRING => $caseInsensitive + ? strcasecmp((string) $left, (string) $right) + : strcmp((string) $left, (string) $right), + \SORT_NATURAL => $caseInsensitive + ? strnatcasecmp((string) $left, (string) $right) + : strnatcmp((string) $left, (string) $right), + \SORT_LOCALE_STRING => strcoll((string) $left, (string) $right), + default => $left <=> $right, + }; + } } diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index a6c8512..b8b3121 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -4,6 +4,8 @@ namespace Infocyph\ArrayKit\Array; +use InvalidArgumentException; + class ArraySingle { /** @@ -92,6 +94,32 @@ public static function contains(array $array, mixed $valueOrCallback, bool $stri return in_array($valueOrCallback, $array, $strict); } + /** + * Determine if all given values exist in the array. + * + * @param array $array The array to search. + * @param array $needles The values to verify. + * @param bool $strict Whether to use strict comparison. + * @return bool True if every value exists, false otherwise. + */ + public static function containsAll(array $array, array $needles, bool $strict = false): bool + { + return array_all($needles, fn($needle) => in_array($needle, $array, $strict)); + } + + /** + * Determine if any of the given values exist in the array. + * + * @param array $array The array to search. + * @param array $needles The values to verify. + * @param bool $strict Whether to use strict comparison. + * @return bool True if at least one value exists, false otherwise. + */ + public static function containsAny(array $array, array $needles, bool $strict = false): bool + { + return array_any($needles, fn($needle) => in_array($needle, $array, $strict)); + } + /** * Retrieve duplicate values from an array. * @@ -143,12 +171,7 @@ public static function each(array $array, callable $callback): array */ public static function every(array $array, callable $callback): bool { - foreach ($array as $key => $value) { - if (!$callback($value, $key)) { - return false; - } - } - return true; + return array_all($array, fn($value, $key) => $callback($value, $key)); } /** @@ -199,12 +222,7 @@ public static function isAssoc(array $array): bool */ public static function isInt(array $array): bool { - foreach ($array as $v) { - if (!is_int($v)) { - return false; - } - } - return true; + return array_all($array, fn($v) => is_int($v)); } /** @@ -336,11 +354,14 @@ public static function negative(array $array): array * A value is considered non-empty if it is not an empty string. * * @param array $array The array to check. + * @param bool $preserveKeys Whether to preserve original keys. * @return array The non-empty values. */ - public static function nonEmpty(array $array): array + public static function nonEmpty(array $array, bool $preserveKeys = false): array { - return array_values(static::where($array, 'strlen')); + $filtered = array_filter($array, static fn(mixed $value): bool => $value !== ''); + + return $preserveKeys ? $filtered : array_values($filtered); } /** @@ -351,9 +372,14 @@ public static function nonEmpty(array $array): array * @param int $offset The offset from which to begin selecting elements. * * @return array The sliced array. + * @throws InvalidArgumentException If step is less than 1. */ public static function nth(array $array, int $step, int $offset = 0): array { + if ($step <= 0) { + throw new InvalidArgumentException('Step must be greater than 0.'); + } + $results = []; $position = 0; @@ -670,12 +696,7 @@ public static function slice(array $array, int $offset, ?int $length = null): ar */ public static function some(array $array, callable $callback): bool { - foreach ($array as $key => $value) { - if ($callback($value, $key)) { - return true; - } - } - return false; + return array_any($array, fn($value, $key) => $callback($value, $key)); } /** diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 4eca3e1..df42292 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -132,13 +132,7 @@ public static function has(array $array, int|string|array $keys): bool if (empty($keys)) { return false; } - - foreach ($keys as $key) { - if (!array_key_exists($key, $array)) { - return false; - } - } - return true; + return array_all($keys, fn($key) => array_key_exists($key, $array)); } @@ -160,13 +154,7 @@ public static function hasAny(array $array, int|string|array $keys): bool if (empty($keys)) { return false; } - - foreach ($keys as $key) { - if (array_key_exists($key, $array)) { - return true; - } - } - return false; + return array_any($keys, fn($key) => array_key_exists($key, $array)); } @@ -179,12 +167,7 @@ public static function hasAny(array $array, int|string|array $keys): bool */ public static function haveAny(array $array, callable $callback): bool { - foreach ($array as $key => $value) { - if ($callback($value, $key) === true) { - return true; - } - } - return false; + return array_any($array, fn($value, $key) => $callback($value, $key) === true); } @@ -197,12 +180,7 @@ public static function haveAny(array $array, callable $callback): bool */ public static function isAll(array $array, callable $callback): bool { - foreach ($array as $key => $value) { - if ($callback($value, $key) === false) { - return false; - } - } - return true; + return array_all($array, fn($value, $key) => !($callback($value, $key) === false)); } /** * Check if an array is multi-dimensional. diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index db230be..fd1212b 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -264,13 +264,7 @@ public static function hasAny(array $array, array|string $keys): bool } $keys = (array) $keys; - foreach ($keys as $key) { - if (static::has($array, $key)) { - return true; - } - } - - return false; + return array_any($keys, fn($key) => static::has($array, $key)); } /** @@ -852,6 +846,6 @@ private static function unsetProperty(object &$object, string $property): void */ private static function value(mixed $val): mixed { - return is_callable($val) ? $val() : $val; + return \isCallable($val) ? $val() : $val; } } diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php new file mode 100644 index 0000000..37ee7bd --- /dev/null +++ b/src/Config/LazyFileConfig.php @@ -0,0 +1,349 @@ +directory = rtrim($directory, DIRECTORY_SEPARATOR); + $this->extension = ltrim($extension, '.'); + $this->items = $items; + } + + #[\Override] + public function all(): array + { + throw new RuntimeException('LazyFileConfig does not support full config retrieval. At least one key is required.'); + } + + #[\Override] + public function fill(string|array $key, mixed $value = null): bool + { + if (is_array($key)) { + foreach ($key as $path => $entry) { + if (!is_string($path)) { + throw new InvalidArgumentException('Fill keys must be dot-notation strings.'); + } + + $this->setPath($path, $entry, false); + } + } else { + $this->setPath($key, $value, false); + } + + return true; + } + + #[\Override] + public function forget(string|int|array $key): bool + { + if (is_array($key)) { + foreach ($key as $path) { + if (!is_string($path) && !is_int($path)) { + throw new InvalidArgumentException('Forget keys must be dot-notation strings.'); + } + + $this->forgetPath((string) $path); + } + } else { + $this->forgetPath((string) $key); + } + + return true; + } + + #[\Override] + public function get(string|int|array|null $key = null, mixed $default = null): mixed + { + if ($key === null) { + throw new RuntimeException('At least one key is required for LazyFileConfig::get().'); + } + + if (is_int($key)) { + throw new InvalidArgumentException('Config key must be a dot-notation string.'); + } + + if (is_array($key)) { + if ($key === []) { + throw new RuntimeException('At least one key is required for LazyFileConfig::get().'); + } + + $results = []; + foreach ($key as $path) { + if (!is_string($path) && !is_int($path)) { + throw new InvalidArgumentException('Config keys must be dot-notation strings.'); + } + + $results[(string) $path] = $this->getPath((string) $path, $default); + } + + return $results; + } + + return $this->getPath($key, $default); + } + + #[\Override] + public function has(string|array $keys): bool + { + $keys = (array) $keys; + if ($keys === []) { + return false; + } + + foreach ($keys as $path) { + if (!is_string($path)) { + return false; + } + + if (!$this->hasPath($path)) { + return false; + } + } + + return true; + } + + #[\Override] + public function hasAny(string|array $keys): bool + { + $keys = (array) $keys; + if ($keys === []) { + return false; + } + + foreach ($keys as $path) { + if (!is_string($path)) { + continue; + } + + if ($this->hasPath($path)) { + return true; + } + } + + return false; + } + + /** + * Returns true if the namespace has already been resolved (file found or missing). + */ + public function isLoaded(string $namespace): bool + { + return isset($this->loadedNamespaces[$this->normalizeNamespace($namespace)]); + } + + /** + * @return string[] List of namespaces already resolved. + */ + public function loadedNamespaces(): array + { + return array_keys($this->loadedNamespaces); + } + + /** + * Preload one or multiple top-level config namespaces. + * + * @param string|array $namespaces Namespace (e.g. "db") or list of namespaces. + */ + public function preload(string|array $namespaces): static + { + foreach ((array) $namespaces as $namespace) { + if (!is_string($namespace)) { + throw new InvalidArgumentException('Preload namespaces must be strings.'); + } + + $this->loadNamespace($this->normalizeNamespace($namespace)); + } + + return $this; + } + + #[\Override] + public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool + { + if ($key === null) { + throw new RuntimeException('At least one key is required for LazyFileConfig::set().'); + } + + if (is_array($key)) { + foreach ($key as $path => $entry) { + if (!is_string($path)) { + throw new InvalidArgumentException('Set keys must be dot-notation strings.'); + } + + $this->setPath($path, $entry, $overwrite); + } + } else { + $this->setPath($key, $value, $overwrite); + } + + return true; + } + + protected function forgetPath(string $path): void + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return; + } + + if ($rest === null || $rest === '') { + unset($this->items[$namespace]); + + return; + } + + if (!is_array($this->items[$namespace])) { + return; + } + + DotNotation::forget($this->items[$namespace], $rest); + } + + protected function getPath(string $path, mixed $default): mixed + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return \isCallable($default) ? $default() : $default; + } + + if ($rest === null || $rest === '') { + return $this->items[$namespace]; + } + + return DotNotation::get($this->items[$namespace], $rest, $default); + } + + protected function hasPath(string $path): bool + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return false; + } + + if ($rest === null || $rest === '') { + return true; + } + + if (!is_array($this->items[$namespace])) { + return false; + } + + return DotNotation::has($this->items[$namespace], $rest); + } + + protected function loadNamespace(string $namespace): void + { + if (isset($this->loadedNamespaces[$namespace])) { + return; + } + + $this->loadedNamespaces[$namespace] = true; + + $file = $this->resolveNamespaceFile($namespace); + if ($file === null) { + return; + } + + $loaded = include $file; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$file}] must return an array."); + } + + if (!array_key_exists($namespace, $this->items)) { + $this->items[$namespace] = $loaded; + + return; + } + + if (is_array($this->items[$namespace])) { + $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + } + } + + protected function normalizeNamespace(string $namespace): string + { + $trimmed = trim($namespace); + if ($trimmed === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $trimmed)) { + throw new InvalidArgumentException("Invalid config namespace [{$namespace}]."); + } + + return $trimmed; + } + + protected function resolveNamespaceFile(string $namespace): ?string + { + $file = $this->directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + + if (!is_file($file) || !is_readable($file)) { + return null; + } + + return $file; + } + + protected function setPath(string $path, mixed $value, bool $overwrite): void + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if ($rest === null || $rest === '') { + if ($overwrite || !array_key_exists($namespace, $this->items)) { + $this->items[$namespace] = $value; + } + + return; + } + + $namespaceConfig = $this->items[$namespace] ?? []; + if (!is_array($namespaceConfig)) { + $namespaceConfig = []; + } + + DotNotation::set($namespaceConfig, $rest, $value, $overwrite); + $this->items[$namespace] = $namespaceConfig; + } + + /** + * @return array{0: string, 1: string|null} + */ + protected function splitPath(string $path): array + { + $trimmed = trim($path); + if ($trimmed === '') { + throw new InvalidArgumentException('Config key must not be empty.'); + } + + $dotPosition = strpos($trimmed, '.'); + if ($dotPosition === false) { + $namespace = $this->normalizeNamespace($trimmed); + + return [$namespace, null]; + } + + $namespace = $this->normalizeNamespace(substr($trimmed, 0, $dotPosition)); + $rest = substr($trimmed, $dotPosition + 1); + + return [$namespace, $rest === '' ? null : $rest]; + } +} diff --git a/src/traits/DTOTrait.php b/src/traits/DTOTrait.php index 492c569..487dfb0 100644 --- a/src/traits/DTOTrait.php +++ b/src/traits/DTOTrait.php @@ -33,7 +33,7 @@ trait DTOTrait */ public static function create(array $values): static { - return (new static())->fromArray($values); + return new static()->fromArray($values); } /** diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index bd78de9..a817178 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -119,20 +119,32 @@ expect($result)->toBe($expected); }); +it('sorts by column using numeric sort options', function () { + $data = [ + ['score' => '10'], + ['score' => '2'], + ['score' => '1'], + ]; + + $sorted = ArrayMulti::sortBy($data, 'score', false, SORT_NUMERIC); + + expect(array_values(array_column($sorted, 'score')))->toBe(['1', '2', '10']); +}); + // sortRecursive() descending -//it('recursively sorts a multidimensional array descending', function () { -// $data = [ -// 'a' => [1, 3, 2], -// 'b' => [4, 6, 5], -// ]; -// $result = ArrayMulti::sortRecursive($data, SORT_REGULAR, true); -// // For sequential arrays, descending sort is used: -// $expected = [ -// 'a' => [3, 2, 1], -// 'b' => [6, 5, 4], -// ]; -// expect($result)->toBe($expected); -//}); +it('recursively sorts a multidimensional array descending', function () { + $data = [ + 'a' => [1, 3, 2], + 'b' => [4, 6, 5], + ]; + $result = ArrayMulti::sortRecursive($data, SORT_REGULAR, true); + // For sequential arrays, descending sort is used: + $expected = [ + 'b' => [6, 5, 4], + 'a' => [3, 2, 1], + ]; + expect($result)->toBe($expected); +}); // first() it('returns the first item from an array without callback', function () { diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index deb1003..ffc5937 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -39,6 +39,17 @@ $key = ArraySingle::search($data, fn ($value) => $value === 3); expect($key)->toBe(2); }); + +it('checks containsAll and containsAny with strict and loose modes', function () { + $data = [1, 2, 3, '3']; + + expect(ArraySingle::containsAll($data, [1, '2'])) + ->toBeTrue() + ->and(ArraySingle::containsAll($data, [1, '2'], true))->toBeFalse() + ->and(ArraySingle::containsAny($data, ['x', 2]))->toBeTrue() + ->and(ArraySingle::containsAny($data, ['x', '2'], true))->toBeFalse(); +}); + it('sums the array using sum()', function () { $arr = [1, 2, 3]; expect(ArraySingle::sum($arr)) @@ -46,6 +57,20 @@ ->and(ArraySingle::sum($arr, fn ($v) => $v * 2)) ->toBe(12); }); + +it('filters non-empty values without crashing on mixed data', function () { + $arr = [1, '', 0, '0', null, false, 'hello']; + + expect(ArraySingle::nonEmpty($arr))->toBe([1, 0, '0', null, false, 'hello']) + ->and(ArraySingle::nonEmpty($arr, true))->toBe([ + 0 => 1, + 2 => 0, + 3 => '0', + 4 => null, + 5 => false, + 6 => 'hello', + ]); +}); it('removes duplicates from the array using unique()', function () { $arr = [1, 2, 2, 3, 3, 4]; expect(ArraySingle::unique($arr)) @@ -85,3 +110,7 @@ $arr = [1, 2, 3, 4, 5]; expect(array_values(ArraySingle::skipUntil($arr, fn ($v) => $v === 3)))->toBe([3, 4, 5]); }); + +it('throws for invalid nth step values', function () { + expect(fn () => ArraySingle::nth([1, 2, 3], 0))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index cc9a5ad..5fb5507 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -138,6 +138,11 @@ expect(DotNotation::get($data, 'b', 'default'))->toBe('default'); }); +it('returns string defaults as-is when key is not found', function () { + $data = []; + expect(DotNotation::get($data, 'missing.key', 'file'))->toBe('file'); +}); + it('retrieves multiple keys when passed an array', function () { $data = [ 'user' => ['name' => 'Carol', 'email' => 'carol@example.com'], diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php new file mode 100644 index 0000000..8cdc2fd --- /dev/null +++ b/tests/Feature/LazyFileConfigTest.php @@ -0,0 +1,180 @@ + + */ +function lazyConfigItems(LazyFileConfig $config): array +{ + return (fn (): array => $this->items)->call($config); +} + +beforeEach(function () { + $this->configPath = sys_get_temp_dir() + . DIRECTORY_SEPARATOR + . 'arraykit-lazy-config-' + . uniqid('', true); + + mkdir($this->configPath, 0777, true); +}); + +afterEach(function () { + lazyConfigDeleteDirectory($this->configPath); +}); + +it('loads only the first namespace file on first dot-path access', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost', 'port' => 3306]); + lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'ArrayKit']); + + $config = new LazyFileConfig($this->configPath); + + expect($config->get('db.host'))->toBe('localhost') + ->and(array_keys(lazyConfigItems($config)))->toBe(['db']); +}); + +it('returns full namespace array when key contains only the first segment', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost', 'port' => 3306]); + + $config = new LazyFileConfig($this->configPath); + + expect($config->get('db'))->toBe(['host' => 'localhost', 'port' => 3306]); +}); + +it('can preload selected namespaces for faster first access', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); + lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'ArrayKit']); + + $config = new LazyFileConfig($this->configPath); + $config->preload('db'); + + expect($config->isLoaded('db'))->toBeTrue() + ->and($config->isLoaded('app'))->toBeFalse() + ->and($config->get('db.host'))->toBe('localhost'); +}); + +it('tracks resolved namespaces for observability', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); + lazyConfigWriteArrayFile($this->configPath, 'cache', ['driver' => 'file']); + + $config = new LazyFileConfig($this->configPath); + $config->preload(['db', 'cache']); + + $loaded = $config->loadedNamespaces(); + sort($loaded); + + expect($loaded)->toBe(['cache', 'db']); +}); + +it('loads all requested namespaces for multi-key lookup', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); + lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'ArrayKit']); + + $config = new LazyFileConfig($this->configPath); + + expect($config->get(['db.host', 'app.name']))->toBe([ + 'db.host' => 'localhost', + 'app.name' => 'ArrayKit', + ]); + + $keys = array_keys(lazyConfigItems($config)); + sort($keys); + expect($keys)->toBe(['app', 'db']); +}); + +it('merges lazy-loaded defaults with runtime overrides', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', [ + 'host' => 'localhost', + 'port' => 3306, + 'options' => [ + 'ssl' => false, + 'timeout' => 5, + ], + ]); + + $config = new LazyFileConfig($this->configPath); + $config->set('db.host', 'db.internal'); + $config->set('db.options.timeout', 10); + + expect($config->get('db'))->toBe([ + 'host' => 'db.internal', + 'port' => 3306, + 'options' => [ + 'ssl' => false, + 'timeout' => 10, + ], + ]); +}); + +it('throws if full config retrieval is requested without a key', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'ArrayKit']); + + $config = new LazyFileConfig($this->configPath); + + expect(fn () => $config->get())->toThrow(RuntimeException::class) + ->and(fn () => $config->all())->toThrow(RuntimeException::class); +}); + +it('returns default for missing namespace file without side effects', function () { + $config = new LazyFileConfig($this->configPath); + + expect($config->get('cache.driver', 'file'))->toBe('file') + ->and(lazyConfigItems($config))->toBe([]); +}); + +it('throws when a namespace file does not return an array', function () { + file_put_contents( + $this->configPath . DIRECTORY_SEPARATOR . 'db.php', + "configPath); + + expect(fn () => $config->get('db.host'))->toThrow(UnexpectedValueException::class); +}); + +it('throws for invalid preload namespaces', function () { + $config = new LazyFileConfig($this->configPath); + + expect(fn () => $config->preload('invalid.namespace'))->toThrow(InvalidArgumentException::class); +});