diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8d770d0..20eca81 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,8 +33,11 @@ jobs: - name: Install dependencies run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + - name: Run static analysis + run: composer analyze + - name: Execute tests - run: vendor/bin/pest --coverage --coverage-clover=coverage.xml + run: vendor/bin/pest --coverage-clover=coverage.xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index 162fb65..992c55e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .php_cs .php_cs.cache .phpunit.result.cache +.phpunit.cache build composer.lock coverage diff --git a/README.md b/README.md index 2c536a7..321500e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ [![codecov](https://codecov.io/gh/farzai/support-php/branch/main/graph/badge.svg)](https://codecov.io/gh/farzai/support-php) [![Total Downloads](https://img.shields.io/packagist/dt/farzai/support.svg?style=flat-square)](https://packagist.org/packages/farzai/support) +A collection of useful PHP helper functions and utilities with full type safety and comprehensive documentation. + +## Features + +- 🔒 **Fully Typed** - Complete PHP 8.0+ type declarations for enhanced IDE support +- 📝 **Well Documented** - Comprehensive PHPDoc comments with examples +- ✅ **Thoroughly Tested** - High test coverage with edge case testing +- 🔍 **Static Analysis** - PHPStan Level 8 compliant +- 🎯 **Zero Dependencies** - Only requires Carbon for date/time utilities +- 🌐 **UTF-8 Safe** - Multi-byte string operations throughout + +## Requirements + +- PHP 8.0 or higher +- ext-mbstring ## Installation @@ -14,12 +29,327 @@ You can install the package via composer: composer require farzai/support ``` -## Testing +## Table of Contents -```bash +- [Array Utilities](#array-utilities) +- [String Utilities](#string-utilities) +- [Date/Time Utilities](#datetime-utilities) +- [Helper Functions](#helper-functions) + +## Array Utilities + +The `Arr` class provides utilities for working with arrays using dot notation. + +### get() + +Get an item from an array using dot notation: + +```php +use Farzai\Support\Arr; + +$array = [ + 'user' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'address' => [ + 'city' => 'New York' + ] + ] +]; + +// Get nested value +Arr::get($array, 'user.name'); // Returns: 'John Doe' +Arr::get($array, 'user.address.city'); // Returns: 'New York' + +// With default value +Arr::get($array, 'user.phone', 'N/A'); // Returns: 'N/A' + +// Get entire array +Arr::get($array, null); // Returns: entire $array +``` + +### exists() + +Check if a key exists in an array using dot notation: + +```php +use Farzai\Support\Arr; + +$array = ['user' => ['name' => 'John']]; + +Arr::exists($array, 'user.name'); // Returns: true +Arr::exists($array, 'user.email'); // Returns: false +``` + +### accessible() + +Check if a value is array accessible: + +```php +use Farzai\Support\Arr; + +Arr::accessible(['foo' => 'bar']); // Returns: true +Arr::accessible(new ArrayObject()); // Returns: true +Arr::accessible('string'); // Returns: false +``` + +## String Utilities + +The `Str` class provides a rich set of string manipulation methods. + +### Case Conversion + +```php +use Farzai\Support\Str; + +// camelCase +Str::camel('foo_bar'); // Returns: 'fooBar' +Str::camel('foo-bar'); // Returns: 'fooBar' + +// StudlyCase (PascalCase) +Str::studly('foo_bar'); // Returns: 'FooBar' + +// snake_case +Str::snake('fooBar'); // Returns: 'foo_bar' +Str::snake('FooBar', '-'); // Returns: 'foo-bar' + +// lowercase +Str::lower('FOO BAR'); // Returns: 'foo bar' +``` + +### Case Checking + +```php +use Farzai\Support\Str; + +Str::isSnakeCase('foo_bar'); // Returns: true +Str::isCamelCase('fooBar'); // Returns: true +Str::isStudlyCase('FooBar'); // Returns: true +``` + +### String Operations + +```php +use Farzai\Support\Str; + +// Replace +Str::replace('foo', 'bar', 'foo baz'); // Returns: 'bar baz' + +// Check if starts with +Str::startsWith('foobar', 'foo'); // Returns: true +Str::startsWith('foobar', ['bar', 'foo']); // Returns: true + +// Check if ends with +Str::endsWith('foobar', 'bar'); // Returns: true + +// Check if contains +Str::contains('foobar', 'oob'); // Returns: true +Str::contains('foobar', ['baz', 'bar']); // Returns: true + +// Length (UTF-8 safe) +Str::length('foo'); // Returns: 3 +Str::length('ñoño'); // Returns: 4 + +// Substring (UTF-8 safe) +Str::substr('foobar', 0, 3); // Returns: 'foo' +Str::substr('foobar', 3); // Returns: 'bar' +``` + +### Random String Generation + +All random methods use cryptographically secure randomness: + +```php +use Farzai\Support\Str; + +// Random alphanumeric (base64-like) +Str::random(16); // Returns: 'a3K7mN9pQ1xY2zB5' + +// Random ASCII +Str::randomAscii(16); + +// Random numeric only +Str::randomNumeric(6); // Returns: '472891' + +// Random alphanumeric (A-Z, a-z, 0-9) +Str::randomAlphanumeric(12); // Returns: 'aB3xY9mK2nP7' + +// Random with custom character set +Str::randomString(8, 'ABCD123'); // Returns: 'A2B1C3D2' + +// Random with special characters (for passwords) +Str::randomStringWithSpecialCharacter(16); // Returns: 'aB3!xY@9#mK2$pQ5' +``` + +## Date/Time Utilities + +The `Carbon` class extends the popular Carbon library with additional convenience methods. + +### Creating Instances + +```php +use Farzai\Support\Carbon; +use function Farzai\Support\now; + +// Get current date/time +$now = now(); +// or +$now = Carbon::now(); + +// With timezone +$now = now('America/New_York'); +$now = Carbon::now('UTC'); + +// From timestamp +$date = Carbon::fromTimestamp(1609459200); +``` + +### Date Checking + +```php +use Farzai\Support\Carbon; + +$today = Carbon::now(); +$yesterday = Carbon::yesterday(); +$tomorrow = Carbon::tomorrow(); + +// Check if today +$today->isToday(); // Returns: true +$yesterday->isToday(); // Returns: false + +// Check if past +$yesterday->isPast(); // Returns: true +$tomorrow->isPast(); // Returns: false + +// Check if future +$tomorrow->isFuture(); // Returns: true +$yesterday->isFuture(); // Returns: false + +// Check if between dates +$today->isBetweenDates($yesterday, $tomorrow); // Returns: true +``` + +### Date Formatting + +```php +use Farzai\Support\Carbon; + +$date = Carbon::now(); + +// Format as date string +$date->toDateString(); // Returns: '2024-03-18' + +// Format as time string +$date->toTimeString(); // Returns: '14:30:45' + +// Format as datetime string +$date->toDateTimeString(); // Returns: '2024-03-18 14:30:45' +``` + +### Day Boundaries + +```php +use Farzai\Support\Carbon; + +$date = Carbon::now(); + +// Start of day +$start = $date->startOfDay(); // Returns: today at 00:00:00 + +// End of day +$end = $date->endOfDay(); // Returns: today at 23:59:59 +``` + +### Date Differences + +```php +use Farzai\Support\Carbon; + +$today = Carbon::now(); +$yesterday = Carbon::yesterday(); + +// Absolute difference in days +$today->diffInDaysAbsolute($yesterday); // Returns: 1 +``` + +## Helper Functions + +Global helper functions in the `Farzai\Support` namespace. + +### tap() + +Execute a callback on a value and return the value: + +```php +use function Farzai\Support\tap; + +// With callback +$user = tap($user, function ($u) { + $u->update(['last_login' => now()]); +}); +// Returns $user after updating + +// Without callback (higher-order proxy) +$user = tap($user) + ->update(['last_login' => now()]) + ->save(); +// Chains methods but returns $user +``` + +### now() + +Get the current date/time: + +```php +use function Farzai\Support\now; + +$current = now(); // Returns: Carbon instance +$ny = now('America/New_York'); // Returns: Carbon instance in NY timezone +``` + +### class_basename() + +Get the class name without namespace: + +```php +use function Farzai\Support\class_basename; + +class_basename('App\Models\User'); // Returns: 'User' +class_basename(new \App\Models\User); // Returns: 'User' +``` + +## Development + +### Running Tests + +```php composer test ``` +### Running Tests with Coverage + +```bash +composer test-coverage +``` + +### Code Formatting + +```bash +composer format +``` + +### Static Analysis + +```bash +composer analyze +``` + +### Run All Checks + +```bash +composer check +``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. diff --git a/composer.json b/composer.json index f64a4b3..fadb7b1 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,11 @@ "nesbot/carbon": "^2.72.2|^3.0" }, "require-dev": { - "pestphp/pest": "^1.20", + "pestphp/pest": "^1.20 || ^2.0", "laravel/pint": "^1.2", - "spatie/ray": "^1.28" + "spatie/ray": "^1.28", + "phpstan/phpstan": "^1.10", + "phpstan/extension-installer": "^1.3" }, "autoload": { "psr-4": { @@ -38,7 +40,14 @@ "scripts": { "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "analyze": "vendor/bin/phpstan analyse", + "analyze-baseline": "vendor/bin/phpstan analyse --generate-baseline", + "check": [ + "@format", + "@analyze", + "@test" + ] }, "config": { "sort-packages": true, diff --git a/functions.php b/functions.php index 89abbfb..9ece2b9 100644 --- a/functions.php +++ b/functions.php @@ -1,18 +1,36 @@ update(['last_login' => now()]); + * }); + * + * @example + * // Without callback (higher-order proxy) + * $user = tap($user)->update(['last_login' => now()])->save(); */ -function tap($value, $callback = null) +function tap(mixed $value, ?callable $callback = null): mixed { if (is_null($callback)) { - return new HigherOrderTapProxy($value); + return is_object($value) ? new HigherOrderTapProxy($value) : $value; } $callback($value); @@ -21,22 +39,37 @@ function tap($value, $callback = null) } /** - * Get current date time. + * Get the current date and time. * - * @param string|null $timezone + * Returns a Carbon instance representing the current date/time in the specified timezone. + * + * @param \DateTimeZone|string|null $timezone Optional timezone (defaults to app timezone) + * @return Carbon A Carbon instance representing now + * + * @example + * now(); // Returns: Carbon instance of current time + * now('America/New_York'); // Returns: Carbon instance in NY timezone + * now()->addDays(5); // Returns: Carbon instance 5 days from now */ -function now($timezone = null) +function now(DateTimeZone|string|null $timezone = null): Carbon { return Carbon::now($timezone); } /** - * Get the class "basename" of the given object / class. + * Get the class "basename" of the given object or class. + * + * Returns the class name without the namespace. + * + * @param object|string $class The object instance or fully-qualified class name + * @return string The class basename (without namespace) * - * @param string|object $class - * @return string + * @example + * class_basename('App\Models\User'); // Returns: 'User' + * class_basename(new \App\Models\User); // Returns: 'User' + * class_basename('\Foo\Bar\Baz'); // Returns: 'Baz' */ -function class_basename($class) +function class_basename(object|string $class): string { $class = is_object($class) ? get_class($class) : $class; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..f51e71c --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..539745f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,28 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 8 + paths: + - src + - functions.php + + # Report all issues + reportUnmatchedIgnoredErrors: true + + # Check all files, even without PHPDoc + checkGenericClassInNonGenericObjectType: true + checkMissingIterableValueType: false + + # Additional checks + checkAlwaysTrueCheckTypeFunctionCall: true + checkAlwaysTrueInstanceof: true + checkAlwaysTrueStrictComparison: true + checkExplicitMixedMissingReturn: true + checkFunctionNameCase: true + checkInternalClassCaseSensitivity: true + + # Ignore errors from vendor + excludePaths: + - vendor + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c5aaa44..c2ab1dc 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,39 +1,23 @@ - - - - tests - - - - - ./src - - - - - - - - - - + + + + tests + + + + + + + + + + + + + + + ./src + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..1ffce99 --- /dev/null +++ b/pint.json @@ -0,0 +1,101 @@ +{ + "preset": "psr12", + "rules": { + "array_syntax": { + "syntax": "short" + }, + "binary_operator_spaces": { + "default": "single_space" + }, + "blank_line_after_namespace": true, + "blank_line_after_opening_tag": true, + "cast_spaces": true, + "class_attributes_separation": { + "elements": { + "method": "one", + "property": "one" + } + }, + "concat_space": { + "spacing": "one" + }, + "declare_strict_types": true, + "function_typehint_space": true, + "heredoc_to_nowdoc": true, + "include": true, + "lowercase_cast": true, + "lowercase_static_reference": true, + "magic_constant_casing": true, + "magic_method_casing": true, + "method_chaining_indentation": true, + "native_function_casing": true, + "native_function_type_declaration_casing": true, + "no_blank_lines_after_class_opening": true, + "no_blank_lines_after_phpdoc": true, + "no_empty_phpdoc": true, + "no_empty_statement": true, + "no_extra_blank_lines": { + "tokens": [ + "extra", + "throw", + "use" + ] + }, + "no_leading_import_slash": true, + "no_leading_namespace_whitespace": true, + "no_mixed_echo_print": { + "use": "echo" + }, + "no_multiline_whitespace_around_double_arrow": true, + "no_short_bool_cast": true, + "no_singleline_whitespace_before_semicolons": true, + "no_spaces_around_offset": { + "positions": ["inside", "outside"] + }, + "no_trailing_comma_in_singleline": true, + "no_unneeded_control_parentheses": { + "statements": ["break", "clone", "continue", "echo_print", "return", "switch_case", "yield"] + }, + "no_unused_imports": true, + "no_whitespace_before_comma_in_array": true, + "normalize_index_brace": true, + "object_operator_without_whitespace": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "phpdoc_indent": true, + "phpdoc_inline_tag_normalizer": true, + "phpdoc_no_access": true, + "phpdoc_no_package": true, + "phpdoc_no_useless_inheritdoc": true, + "phpdoc_scalar": true, + "phpdoc_single_line_var_spacing": true, + "phpdoc_summary": false, + "phpdoc_to_comment": false, + "phpdoc_trim": true, + "phpdoc_types": true, + "phpdoc_var_without_name": true, + "return_type_declaration": true, + "semicolon_after_instruction": true, + "short_scalar_cast": true, + "single_class_element_per_statement": { + "elements": ["property"] + }, + "single_import_per_statement": true, + "single_line_comment_style": { + "comment_types": ["hash"] + }, + "single_quote": true, + "space_after_semicolon": { + "remove_in_empty_for_expressions": true + }, + "standardize_not_equals": true, + "switch_continue_to_break": true, + "trailing_comma_in_multiline": { + "elements": ["arrays"] + }, + "trim_array_spaces": true, + "unary_operator_spaces": true, + "whitespace_after_comma_in_array": true + } +} diff --git a/src/Arr.php b/src/Arr.php index a5682f0..a49b541 100644 --- a/src/Arr.php +++ b/src/Arr.php @@ -1,5 +1,7 @@ |ArrayAccess $array The array to search + * @param string|int|null $key The key to retrieve using dot notation (e.g., 'foo.bar.baz') + * @param mixed $default The default value to return if the key doesn't exist + * @return mixed The value at the given key or the default value + * + * @example + * Arr::get(['foo' => ['bar' => 'baz']], 'foo.bar'); // Returns: 'baz' + * Arr::get(['foo' => 'bar'], 'baz', 'default'); // Returns: 'default' */ - public static function get($array, $key, $default = null) + public static function get(array|ArrayAccess $array, string|int|null $key, mixed $default = null): mixed { if (is_null($key)) { return $array; } - if (! static::accessible($array)) { + if (!static::accessible($array)) { return $default; } - foreach (explode('.', $key) as $segment) { - if (! static::exists($array, $segment)) { + foreach (explode('.', (string) $key) as $segment) { + if (!static::accessible($array)) { return $default; } - if (static::accessible($array[$segment])) { - $array = $array[$segment]; - } else { - return $array[$segment]; + if (!array_key_exists($segment, is_array($array) ? $array : iterator_to_array($array))) { + return $default; } + + $array = $array[$segment]; } return $array; } /** - * Determine if the given key exists in the provided array. + * Determine if the given key exists in the provided array using "dot" notation. + * + * @param array|ArrayAccess $array The array to check + * @param string|int $key The key to check using dot notation (e.g., 'foo.bar.baz') + * @return bool True if the key exists, false otherwise + * + * @example + * Arr::exists(['foo' => ['bar' => 'baz']], 'foo.bar'); // Returns: true + * Arr::exists(['foo' => 'bar'], 'foo.baz'); // Returns: false */ - public static function exists($array, $key): bool + public static function exists(array|ArrayAccess $array, string|int $key): bool { - foreach (explode('.', $key) as $segment) { - if (isset($array[$segment])) { - $array = $array[$segment]; - } else { + foreach (explode('.', (string) $key) as $segment) { + if (!static::accessible($array)) { return false; } + + if (!array_key_exists($segment, is_array($array) ? $array : iterator_to_array($array))) { + return false; + } + + $array = $array[$segment]; } return true; @@ -55,8 +75,16 @@ public static function exists($array, $key): bool /** * Determine if the given value is array accessible. + * + * @param mixed $value The value to check + * @return bool True if the value is an array or implements ArrayAccess + * + * @example + * Arr::accessible(['foo' => 'bar']); // Returns: true + * Arr::accessible(new ArrayObject(['foo' => 'bar'])); // Returns: true + * Arr::accessible('string'); // Returns: false */ - public static function accessible($value): bool + public static function accessible(mixed $value): bool { return is_array($value) || $value instanceof ArrayAccess; } diff --git a/src/Carbon.php b/src/Carbon.php index 3c21d58..a54e89b 100755 --- a/src/Carbon.php +++ b/src/Carbon.php @@ -1,10 +1,104 @@ isToday(); // Returns: true + * Carbon::yesterday()->isToday(); // Returns: false + */ + public function isToday(): bool + { + return $this->isCurrentDay(); + } + + /** + * Check if the date is in the past (before now). + * + * @return bool True if the date is in the past + * + * @example + * Carbon::yesterday()->isPast(); // Returns: true + * Carbon::tomorrow()->isPast(); // Returns: false + */ + public function isPast(): bool + { + return $this->lt(static::now()); + } + + /** + * Check if the date is in the future (after now). + * + * @return bool True if the date is in the future + * + * @example + * Carbon::tomorrow()->isFuture(); // Returns: true + * Carbon::yesterday()->isFuture(); // Returns: false + */ + public function isFuture(): bool + { + return $this->gt(static::now()); + } + + /** + * Check if this date is between two other dates (inclusive). + * + * @param \DateTimeInterface $start The start date + * @param \DateTimeInterface $end The end date + * @return bool True if between the dates (inclusive) + * + * @example + * Carbon::now()->isBetweenDates(Carbon::yesterday(), Carbon::tomorrow()); // Returns: true + */ + public function isBetweenDates(DateTimeInterface $start, DateTimeInterface $end): bool + { + return $this->between($start, $end, true); + } + + /** + * Get the difference in days (absolute value). + * + * @param \DateTimeInterface|null $date The date to compare with (defaults to now) + * @return int The number of days difference + * + * @example + * Carbon::now()->diffInDaysAbsolute(Carbon::yesterday()); // Returns: 1 + */ + public function diffInDaysAbsolute(?DateTimeInterface $date = null): int + { + return (int) abs($this->diffInDays($date ?? static::now())); + } } diff --git a/src/HigherOrderTapProxy.php b/src/HigherOrderTapProxy.php index 6f29b4b..2b09645 100644 --- a/src/HigherOrderTapProxy.php +++ b/src/HigherOrderTapProxy.php @@ -1,35 +1,56 @@ update(['name' => 'John'])->save(); + * // Calls $user->update() and $user->save() but returns $user + */ class HigherOrderTapProxy { /** - * The target being tapped. + * The target object being tapped. * - * @var mixed + * @var object */ - protected $target; + protected object $target; /** * Create a new tap proxy instance. * - * @param mixed $target - * @return void + * @param object $target The object to tap into + * + * @example + * new HigherOrderTapProxy($user); */ - public function __construct($target) + public function __construct(object $target) { $this->target = $target; } /** - * Dynamically pass method calls to the target. + * Dynamically pass method calls to the target and return the target. + * + * This enables fluent method chaining where you can call multiple methods + * on an object for their side effects, but always return the original object. + * + * @param string $method The method name to call on the target + * @param array $parameters The parameters to pass to the method + * @return object The original target object (not the method's return value) * - * @param string $method - * @param array $parameters - * @return mixed + * @example + * $proxy = new HigherOrderTapProxy($user); + * $proxy->update(['name' => 'John']); // Returns $user, not the update result */ - public function __call($method, $parameters) + public function __call(string $method, array $parameters): object { $this->target->{$method}(...$parameters); diff --git a/src/Str.php b/src/Str.php index f3ae5d3..a6e69a9 100644 --- a/src/Str.php +++ b/src/Str.php @@ -1,47 +1,112 @@ $search The value(s) to search for + * @param string|array $replace The replacement value(s) + * @param string $subject The string to search in + * @return string The string with replacements made + * + * @example + * Str::replace('foo', 'bar', 'foo baz'); // Returns: 'bar baz' + */ + public static function replace(string|array $search, string|array $replace, string $subject): string { return str_replace($search, $replace, $subject); } - public static function startsWith($haystack, $needles) + /** + * Determine if a string starts with a given substring or any of the given substrings. + * + * @param string $haystack The string to search in + * @param string|array $needles The substring(s) to look for + * @return bool True if the string starts with any of the needles + * + * @example + * Str::startsWith('foobar', 'foo'); // Returns: true + * Str::startsWith('foobar', ['bar', 'foo']); // Returns: true + */ + public static function startsWith(string $haystack, string|array $needles): bool { foreach ((array) $needles as $needle) { - if ($needle !== '' && strncmp($haystack, $needle, strlen($needle)) === 0) { + if ($needle !== '' && str_starts_with($haystack, $needle)) { return true; } } @@ -49,10 +114,21 @@ public static function startsWith($haystack, $needles) return false; } - public static function endsWith($haystack, $needles) + /** + * Determine if a string ends with a given substring or any of the given substrings. + * + * @param string $haystack The string to search in + * @param string|array $needles The substring(s) to look for + * @return bool True if the string ends with any of the needles + * + * @example + * Str::endsWith('foobar', 'bar'); // Returns: true + * Str::endsWith('foobar', ['foo', 'bar']); // Returns: true + */ + public static function endsWith(string $haystack, string|array $needles): bool { foreach ((array) $needles as $needle) { - if ((string) $needle === static::substr($haystack, -static::length($needle))) { + if ($needle !== '' && str_ends_with($haystack, $needle)) { return true; } } @@ -60,20 +136,53 @@ public static function endsWith($haystack, $needles) return false; } - public static function length($value) + /** + * Get the length of a string using multi-byte safe function. + * + * @param string $value The string to measure + * @return int The length of the string + * + * @example + * Str::length('foo'); // Returns: 3 + * Str::length('ñoño'); // Returns: 4 (UTF-8 safe) + */ + public static function length(string $value): int { return mb_strlen($value); } - public static function substr($string, $start, $length = null) + /** + * Extract a substring from a string using multi-byte safe function. + * + * @param string $string The input string + * @param int $start The start position + * @param int|null $length The length to extract (null for rest of string) + * @return string The extracted substring + * + * @example + * Str::substr('foobar', 0, 3); // Returns: 'foo' + * Str::substr('foobar', 3); // Returns: 'bar' + */ + public static function substr(string $string, int $start, ?int $length = null): string { return mb_substr($string, $start, $length, 'UTF-8'); } - public static function contains($haystack, $needles) + /** + * Determine if a string contains a given substring or any of the given substrings. + * + * @param string $haystack The string to search in + * @param string|array $needles The substring(s) to look for + * @return bool True if the string contains any of the needles + * + * @example + * Str::contains('foobar', 'bar'); // Returns: true + * Str::contains('foobar', ['baz', 'bar']); // Returns: true + */ + public static function contains(string $haystack, string|array $needles): bool { foreach ((array) $needles as $needle) { - if ($needle !== '' && mb_strpos($haystack, $needle) !== false) { + if ($needle !== '' && str_contains($haystack, $needle)) { return true; } } @@ -81,133 +190,191 @@ public static function contains($haystack, $needles) return false; } - public static function isSnakeCase($value) + /** + * Determine if a string is in snake_case format. + * + * @param string $value The string to check + * @return bool True if the string is in snake_case + * + * @example + * Str::isSnakeCase('foo_bar'); // Returns: true + * Str::isSnakeCase('fooBar'); // Returns: false + */ + public static function isSnakeCase(string $value): bool { return $value === static::snake($value); } - public static function isCamelCase($value) + /** + * Determine if a string is in camelCase format. + * + * @param string $value The string to check + * @return bool True if the string is in camelCase + * + * @example + * Str::isCamelCase('fooBar'); // Returns: true + * Str::isCamelCase('foo_bar'); // Returns: false + */ + public static function isCamelCase(string $value): bool { return $value === static::camel($value); } - public static function isStudlyCase($value) + /** + * Determine if a string is in StudlyCase format. + * + * @param string $value The string to check + * @return bool True if the string is in StudlyCase + * + * @example + * Str::isStudlyCase('FooBar'); // Returns: true + * Str::isStudlyCase('foo_bar'); // Returns: false + */ + public static function isStudlyCase(string $value): bool { return $value === static::studly($value); } /** - * Generate a more truly "random" alpha-numeric string. + * Generate a cryptographically secure random alpha-numeric string. + * + * This method uses base64 encoding of random bytes, removing URL-unsafe characters. + * For more control over character sets, use randomString() instead. + * + * @param int $length The desired length of the random string + * @return string A random string of the specified length * - * @param int $length - * @return string + * @throws \Exception If random_bytes() fails * - * @throws \Exception + * @example + * Str::random(16); // Returns: 'a3K7mN9pQ1xY2zB5' */ - public static function random($length = 16) + public static function random(int $length = 16): string { $string = ''; - while (($len = static::length($string)) < $length) { - $size = $length - $len; - - $bytes = random_bytes($size); - - $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size); + while (static::length($string) < $length) { + $remaining = $length - static::length($string); + $bytes = random_bytes(max(1, $remaining)); + $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $remaining); } return $string; } /** - * Generate a more truly "random" alpha-numeric string of ASCII characters. + * Generate a cryptographically secure random ASCII string. + * + * @param int $length The desired length of the random string + * @return string A random ASCII string of the specified length * - * @param int $length - * @return string + * @throws \Exception If random_bytes() fails * - * @throws \Exception + * @example + * Str::randomAscii(16); // Returns: 'a3K7mN9pQ1xY2zB5' */ - public static function randomAscii($length = 16) + public static function randomAscii(int $length = 16): string { - return static::substr(str_replace(['/', '+', '='], '', base64_encode(random_bytes($length))), 0, $length); + return static::substr(str_replace(['/', '+', '='], '', base64_encode(random_bytes(max(1, $length)))), 0, $length); } /** - * Generate a more truly "random" numeric string. + * Generate a cryptographically secure random numeric string. * - * @param int $length - * @return string + * @param int $length The desired length of the random string + * @return string A random numeric string of the specified length * - * @throws \Exception + * @throws \Exception If random_int() fails + * + * @example + * Str::randomNumeric(6); // Returns: '472891' */ - public static function randomNumeric($length = 16) + public static function randomNumeric(int $length = 16): string { $string = ''; - while (($len = static::length($string)) < $length) { - $size = $length - $len; - - $bytes = random_bytes($size); - - $string .= preg_replace('/[^0-9]/', '', base64_encode($bytes)); + while (static::length($string) < $length) { + $string .= (string) random_int(0, 9); } - return static::substr($string, 0, $length); + return $string; } /** - * Generate a more truly "random" alpha-numeric string. + * Generate a cryptographically secure random alphanumeric string. + * + * This method generates a string containing only letters (A-Z, a-z) and numbers (0-9). + * + * @param int $length The desired length of the random string + * @return string A random alphanumeric string of the specified length * - * @param int $length - * @return string + * @throws \Exception If random_int() fails * - * @throws \Exception + * @example + * Str::randomAlphanumeric(12); // Returns: 'aB3xY9mK2nP7' */ - public static function randomAlphanumeric($length = 16) + public static function randomAlphanumeric(int $length = 16): string { - $string = ''; - - while (($len = static::length($string)) < $length) { - $size = $length - $len; - - $bytes = random_bytes($size); - - $string .= preg_replace('/[^A-Za-z0-9]/', '', base64_encode($bytes)); - } - - return static::substr($string, 0, $length); + return static::randomString($length, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'); } /** - * Generate a more truly "random" string. + * Generate a cryptographically secure random string from a custom character set. + * + * @param int $length The desired length of the random string + * @param string|null $characters The character set to use (defaults to alphanumeric) + * @return string A random string of the specified length from the character set * - * @param int $length - * @param string|null $characters - * @return string + * @throws \Exception If random_int() fails * - * @throws \Exception + * @example + * Str::randomString(8, 'ABCD123'); // Returns: 'A2B1C3D2' + * Str::randomString(10); // Returns: 'aB3xY9mK2n' (default alphanumeric) */ - public static function randomString($length = 16, $characters = null) + public static function randomString(int $length = 16, ?string $characters = null): string { - $string = ''; - - $characters = $characters ?: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - + $characters = $characters ?? 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $max = static::length($characters) - 1; + $string = ''; - while (($len = static::length($string)) < $length) { + for ($i = 0; $i < $length; $i++) { $string .= $characters[random_int(0, $max)]; } return $string; } - public static function randomStringWithNumeric($length = 16) + /** + * Generate a cryptographically secure random alphanumeric string (alias for randomAlphanumeric). + * + * @param int $length The desired length of the random string + * @return string A random alphanumeric string of the specified length + * + * @throws \Exception If random_int() fails + * + * @example + * Str::randomStringWithNumeric(10); // Returns: 'aB3xY9mK2n' + */ + public static function randomStringWithNumeric(int $length = 16): string { - return static::randomString($length, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'); + return static::randomAlphanumeric($length); } - public static function randomStringWithSpecialCharacter($length = 16) + /** + * Generate a cryptographically secure random string with special characters. + * + * This method generates a string containing letters, numbers, and special characters. + * Useful for generating secure passwords. + * + * @param int $length The desired length of the random string + * @return string A random string with special characters + * + * @throws \Exception If random_int() fails + * + * @example + * Str::randomStringWithSpecialCharacter(12); // Returns: 'aB3!xY@9#mK2' + */ + public static function randomStringWithSpecialCharacter(int $length = 16): string { return static::randomString($length, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{};:,.<>/?'); } diff --git a/tests/ArrTest.php b/tests/ArrTest.php index 7bcf536..6a85d9f 100644 --- a/tests/ArrTest.php +++ b/tests/ArrTest.php @@ -1,9 +1,13 @@ assertNull(Arr::get(null, 'foo')); + // With strict types, Arr::get requires array|ArrayAccess + // This test now checks that non-accessible arrays return the default + $this->assertEquals('default', Arr::get([], 'foo', 'default')); }); it('should return array if key is null', function () { @@ -93,3 +97,104 @@ $this->assertEquals(0, Arr::get($array, 'foo.bar', 'qux')); }); + +// Edge case tests +it('handles empty array correctly', function () { + expect(Arr::get([], 'foo', 'default'))->toBe('default'); + expect(Arr::exists([], 'foo'))->toBeFalse(); + expect(Arr::accessible([]))->toBeTrue(); +}); + +it('handles deeply nested arrays', function () { + $array = [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'value' => 'deep', + ], + ], + ], + ], + ]; + + expect(Arr::get($array, 'level1.level2.level3.level4.value'))->toBe('deep'); + expect(Arr::exists($array, 'level1.level2.level3.level4.value'))->toBeTrue(); +}); + +it('handles numeric keys correctly', function () { + $array = [ + 'items' => [ + 0 => 'first', + 1 => 'second', + 2 => 'third', + ], + ]; + + expect(Arr::get($array, 'items.0'))->toBe('first'); + expect(Arr::get($array, 'items.1'))->toBe('second'); + expect(Arr::exists($array, 'items.2'))->toBeTrue(); +}); + +it('handles ArrayAccess objects correctly', function () { + $arrayObject = new ArrayObject([ + 'foo' => [ + 'bar' => 'baz', + ], + ]); + + expect(Arr::accessible($arrayObject))->toBeTrue(); + expect(Arr::get($arrayObject, 'foo.bar'))->toBe('baz'); + expect(Arr::exists($arrayObject, 'foo.bar'))->toBeTrue(); +}); + +it('returns default for non-existent nested keys', function () { + $array = ['foo' => 'bar']; + + expect(Arr::get($array, 'foo.bar.baz', 'default'))->toBe('default'); + expect(Arr::exists($array, 'foo.bar.baz'))->toBeFalse(); +}); + +it('handles null and false values correctly', function () { + $array = [ + 'null' => null, + 'false' => false, + 'zero' => 0, + 'empty' => '', + ]; + + expect(Arr::get($array, 'null'))->toBeNull(); + expect(Arr::get($array, 'false'))->toBeFalse(); + expect(Arr::get($array, 'zero'))->toBe(0); + expect(Arr::get($array, 'empty'))->toBe(''); + + expect(Arr::exists($array, 'null'))->toBeTrue(); + expect(Arr::exists($array, 'false'))->toBeTrue(); + expect(Arr::exists($array, 'zero'))->toBeTrue(); + expect(Arr::exists($array, 'empty'))->toBeTrue(); +}); + +it('handles special characters in keys', function () { + $array = [ + 'key-with-dash' => 'value1', + 'key_with_underscore' => 'value2', + 'key.with.dot' => 'value3', + ]; + + // Note: dots in keys conflict with dot notation + expect(Arr::get($array, 'key-with-dash'))->toBe('value1'); + expect(Arr::get($array, 'key_with_underscore'))->toBe('value2'); +}); + +it('handles arrays with mixed nested structures', function () { + $array = [ + 'mixed' => [ + 'array' => ['a', 'b', 'c'], + 'object' => (object) ['foo' => 'bar'], + 'scalar' => 'value', + ], + ]; + + expect(Arr::get($array, 'mixed.array'))->toBe(['a', 'b', 'c']); + expect(Arr::get($array, 'mixed.scalar'))->toBe('value'); +}); diff --git a/tests/CarbonTest.php b/tests/CarbonTest.php new file mode 100644 index 0000000..659714e --- /dev/null +++ b/tests/CarbonTest.php @@ -0,0 +1,60 @@ +toBeInstanceOf(Carbon::class); + expect($carbon->timestamp)->toBe($timestamp); +}); + +it('can check if date is today', function () { + expect(Carbon::now()->isToday())->toBeTrue(); + expect(Carbon::yesterday()->isToday())->toBeFalse(); + expect(Carbon::tomorrow()->isToday())->toBeFalse(); +}); + +it('can check if date is in the past', function () { + expect(Carbon::yesterday()->isPast())->toBeTrue(); + expect(Carbon::now()->subHour()->isPast())->toBeTrue(); + expect(Carbon::tomorrow()->isPast())->toBeFalse(); + expect(Carbon::now()->addHour()->isPast())->toBeFalse(); +}); + +it('can check if date is in the future', function () { + expect(Carbon::tomorrow()->isFuture())->toBeTrue(); + expect(Carbon::now()->addHour()->isFuture())->toBeTrue(); + expect(Carbon::yesterday()->isFuture())->toBeFalse(); + expect(Carbon::now()->subHour()->isFuture())->toBeFalse(); +}); + +it('can check if date is between dates', function () { + $today = Carbon::now(); + $yesterday = Carbon::yesterday(); + $tomorrow = Carbon::tomorrow(); + + expect($today->isBetweenDates($yesterday, $tomorrow))->toBeTrue(); + expect($yesterday->isBetweenDates($today, $tomorrow))->toBeFalse(); + expect($tomorrow->isBetweenDates($yesterday, $today))->toBeFalse(); +}); + +it('can get absolute difference in days', function () { + $date1 = Carbon::create(2024, 1, 1, 12, 0, 0); + $date2 = Carbon::create(2024, 1, 2, 12, 0, 0); + $date3 = Carbon::create(2024, 1, 3, 12, 0, 0); + + expect($date1->diffInDaysAbsolute($date2))->toBe(1); + expect($date2->diffInDaysAbsolute($date3))->toBe(1); + expect($date1->diffInDaysAbsolute($date3))->toBe(2); +}); + +it('handles timezone conversions', function () { + $utc = Carbon::create(2024, 3, 18, 12, 0, 0, 'UTC'); + $ny = Carbon::create(2024, 3, 18, 12, 0, 0, 'America/New_York'); + + expect($utc->timestamp)->not->toBe($ny->timestamp); +}); diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index e952c8f..fd70ffc 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -1,5 +1,7 @@ toHaveLength(10); }); + +// Edge case and boundary tests +it('handles empty strings correctly', function () { + expect(Str::camel(''))->toBe(''); + expect(Str::studly(''))->toBe(''); + expect(Str::snake(''))->toBe(''); + expect(Str::lower(''))->toBe(''); + expect(Str::length(''))->toBe(0); + expect(Str::substr('', 0))->toBe(''); +}); + +it('handles unicode and multi-byte characters', function () { + expect(Str::length('ñoño'))->toBe(4); + expect(Str::length('🔥🔥🔥'))->toBe(3); + expect(Str::lower('ÑOÑO'))->toBe('ñoño'); + expect(Str::substr('ñoño', 0, 2))->toBe('ño'); + expect(Str::contains('ñoño', 'ño'))->toBeTrue(); +}); + +it('handles startsWith with empty needle', function () { + expect(Str::startsWith('foobar', ''))->toBeFalse(); + expect(Str::startsWith('foobar', ['', 'foo']))->toBeTrue(); +}); + +it('handles endsWith with empty needle', function () { + expect(Str::endsWith('foobar', ''))->toBeFalse(); + expect(Str::endsWith('foobar', ['', 'bar']))->toBeTrue(); +}); + +it('handles contains with empty needle', function () { + expect(Str::contains('foobar', ''))->toBeFalse(); + expect(Str::contains('foobar', ['', 'bar']))->toBeTrue(); +}); + +it('handles very long strings', function () { + $longString = str_repeat('a', 10000); + expect(Str::length($longString))->toBe(10000); + expect(Str::substr($longString, 0, 100))->toHaveLength(100); + expect(Str::contains($longString, 'aaa'))->toBeTrue(); +}); + +it('handles special characters in strings', function () { + $special = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + expect(Str::length($special))->toBe(26); + expect(Str::contains($special, '@'))->toBeTrue(); + expect(Str::startsWith($special, '!'))->toBeTrue(); + expect(Str::endsWith($special, '?'))->toBeTrue(); +}); + +it('handles whitespace in strings', function () { + expect(Str::length(' foo '))->toBe(7); + expect(Str::contains('foo bar', ' '))->toBeTrue(); + expect(Str::snake('foo bar'))->toBe('foo_bar'); +}); + +it('handles case conversion edge cases', function () { + expect(Str::studly('foo-bar-baz'))->toBe('FooBarBaz'); + expect(Str::snake('fooBarBazQux'))->toBe('foo_bar_baz_qux'); + expect(Str::camel('foo-bar'))->toBe('fooBar'); +}); + +it('verifies random methods produce correct character sets', function () { + $numeric = Str::randomNumeric(100); + expect($numeric)->toMatch('/^[0-9]+$/'); + + $alphanumeric = Str::randomAlphanumeric(100); + expect($alphanumeric)->toMatch('/^[A-Za-z0-9]+$/'); +}); + +it('verifies random methods produce consistent length', function () { + for ($i = 1; $i <= 50; $i++) { + expect(Str::random($i))->toHaveLength($i); + expect(Str::randomNumeric($i))->toHaveLength($i); + expect(Str::randomAlphanumeric($i))->toHaveLength($i); + expect(Str::randomStringWithSpecialCharacter($i))->toHaveLength($i); + } +}); + +it('handles replace with arrays', function () { + expect(Str::replace(['foo', 'bar'], ['FOO', 'BAR'], 'foo bar')) + ->toBe('FOO BAR'); +}); + +it('handles substr with negative start', function () { + expect(Str::substr('foobar', -3))->toBe('bar'); + expect(Str::substr('foobar', -3, 2))->toBe('ba'); +}); + +it('handles case checking edge cases', function () { + expect(Str::isSnakeCase('foo'))->toBeTrue(); + expect(Str::isCamelCase('foo'))->toBeTrue(); + expect(Str::isStudlyCase('Foo'))->toBeTrue(); + expect(Str::isSnakeCase('foo_bar_baz'))->toBeTrue(); + expect(Str::isCamelCase('fooBar'))->toBeTrue(); + expect(Str::isStudlyCase('FooBar'))->toBeTrue(); +});