Page MenuHomeDevCentral

No OneTemporary

diff --git a/.arcconfig b/.arcconfig
new file mode 100644
index 0000000..e880871
--- /dev/null
+++ b/.arcconfig
@@ -0,0 +1,5 @@
+{
+ "repository.callsign": "KOT",
+ "phabricator.uri": "https://devcentral.nasqueron.org",
+ "unit.engine": "PhpunitTestEngine"
+}
diff --git a/.arclint b/.arclint
new file mode 100644
index 0000000..5b9173a
--- /dev/null
+++ b/.arclint
@@ -0,0 +1,44 @@
+{
+ "exclude": [
+ "(^vendor/)"
+ ],
+ "linters": {
+ "chmod": {
+ "type": "chmod"
+ },
+ "filename": {
+ "type": "filename"
+ },
+ "json": {
+ "type": "json",
+ "include": [
+ "(^\\.arcconfig$)",
+ "(^\\.arclint$)",
+ "(\\.json$)"
+ ]
+ },
+ "merge-conflict": {
+ "type": "merge-conflict"
+ },
+ "php": {
+ "type": "php",
+ "include": "(\\.php$)"
+ },
+ "phpcs": {
+ "type": "phpcs",
+ "bin": "vendor/bin/phpcs",
+ "phpcs.standard": "phpcs.xml",
+ "include": [
+ "(^src/.*\\.php$)",
+ "(^tests/.*\\.php$)"
+ ]
+ },
+ "spelling": {
+ "type": "spelling"
+ },
+ "xml": {
+ "type": "xml",
+ "include": "(\\.xml$)"
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..65f0e32
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+# Composer
+vendor/
+composer.lock
diff --git a/.phan/config.php b/.phan/config.php
new file mode 100644
index 0000000..588cf7b
--- /dev/null
+++ b/.phan/config.php
@@ -0,0 +1,259 @@
+<?php
+
+use Phan\Issue;
+
+return [
+
+ 'target_php_version' => '7.2',
+
+ // If enabled, missing properties will be created when
+ // they are first seen. If false, we'll report an
+ // error message if there is an attempt to write
+ // to a class property that wasn't explicitly
+ // defined.
+ 'allow_missing_properties' => false,
+
+ // If enabled, null can be cast as any type and any
+ // type can be cast to null. Setting this to true
+ // will cut down on false positives.
+ 'null_casts_as_any_type' => false,
+
+ // If enabled, allow null to be cast as any array-like type.
+ // This is an incremental step in migrating away from null_casts_as_any_type.
+ // If null_casts_as_any_type is true, this has no effect.
+ 'null_casts_as_array' => true,
+
+ // If enabled, allow any array-like type to be cast to null.
+ // This is an incremental step in migrating away from null_casts_as_any_type.
+ // If null_casts_as_any_type is true, this has no effect.
+ 'array_casts_as_null' => true,
+
+ // If enabled, scalars (int, float, bool, string, null)
+ // are treated as if they can cast to each other.
+ // This does not affect checks of array keys. See scalar_array_key_cast.
+ 'scalar_implicit_cast' => false,
+
+ // If enabled, any scalar array keys (int, string)
+ // are treated as if they can cast to each other.
+ // E.g. array<int,stdClass> can cast to array<string,stdClass> and vice versa.
+ // Normally, a scalar type such as int could only cast to/from int and mixed.
+ 'scalar_array_key_cast' => true,
+
+ // If this has entries, scalars (int, float, bool, string, null)
+ // are allowed to perform the casts listed.
+ // E.g. ['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]
+ // allows casting null to a string, but not vice versa.
+ // (subset of scalar_implicit_cast)
+ 'scalar_implicit_partial' => [],
+
+ // If true, seemingly undeclared variables in the global
+ // scope will be ignored. This is useful for projects
+ // with complicated cross-file globals that you have no
+ // hope of fixing.
+ 'ignore_undeclared_variables_in_global_scope' => true,
+
+ // Set this to false to emit PhanUndeclaredFunction issues for internal functions that Phan has signatures for,
+ // but aren't available in the codebase, or the internal functions used to run phan
+ // (may lead to false positives if an extension isn't loaded)
+ // If this is true(default), then Phan will not warn.
+ 'ignore_undeclared_functions_with_known_signatures' => true,
+
+ // Backwards Compatibility Checking. This is slow
+ // and expensive, but you should consider running
+ // it before upgrading your version of PHP to a
+ // new version that has backward compatibility
+ // breaks.
+ 'backward_compatibility_checks' => false,
+
+ // If true, check to make sure the return type declared
+ // in the doc-block (if any) matches the return type
+ // declared in the method signature.
+ 'check_docblock_signature_return_type_match' => false,
+
+ // (*Requires check_docblock_signature_param_type_match to be true*)
+ // If true, make narrowed types from phpdoc params override
+ // the real types from the signature, when real types exist.
+ // (E.g. allows specifying desired lists of subclasses,
+ // or to indicate a preference for non-nullable types over nullable types)
+ // Affects analysis of the body of the method and the param types passed in by callers.
+ 'prefer_narrowed_phpdoc_param_type' => true,
+
+ // (*Requires check_docblock_signature_return_type_match to be true*)
+ // If true, make narrowed types from phpdoc returns override
+ // the real types from the signature, when real types exist.
+ // (E.g. allows specifying desired lists of subclasses,
+ // or to indicate a preference for non-nullable types over nullable types)
+ // Affects analysis of return statements in the body of the method and the return types passed in by callers.
+ 'prefer_narrowed_phpdoc_return_type' => true,
+
+ // If enabled, check all methods that override a
+ // parent method to make sure its signature is
+ // compatible with the parent's. This check
+ // can add quite a bit of time to the analysis.
+ // This will also check if final methods are overridden, etc.
+ 'analyze_signature_compatibility' => true,
+
+ // This setting maps case insensitive strings to union types.
+ // This is useful if a project uses phpdoc that differs from the phpdoc2 standard.
+ // If the corresponding value is the empty string, Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`)
+ // If the corresponding value is not empty, Phan will act as though it saw the corresponding unionTypes(s) when the keys show up in a UnionType of @param, @return, @var, @property, etc.
+ //
+ // This matches the **entire string**, not parts of the string.
+ // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting)
+ //
+ // (These are not aliases, this setting is ignored outside of doc comments).
+ // (Phan does not check if classes with these names exist)
+ //
+ // Example setting: ['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']
+ 'phpdoc_type_mapping' => [],
+
+ // Set to true in order to attempt to detect dead
+ // (unreferenced) code. Keep in mind that the
+ // results will only be a guess given that classes,
+ // properties, constants and methods can be referenced
+ // as variables (like `$class->$property` or
+ // `$class->$method()`) in ways that we're unable
+ // to make sense of.
+ 'dead_code_detection' => false,
+
+ // If true, this run a quick version of checks that takes less
+ // time at the cost of not running as thorough
+ // an analysis. You should consider setting this
+ // to true only when you wish you had more **undiagnosed** issues
+ // to fix in your code base.
+ //
+ // In quick-mode the scanner doesn't rescan a function
+ // or a method's code block every time a call is seen.
+ // This means that the problem here won't be detected:
+ //
+ // ```php
+ // <?php
+ // function test($arg):int {
+ // return $arg;
+ // }
+ // test("abc");
+ // ```
+ //
+ // This would normally generate:
+ //
+ // ```sh
+ // test.php:3 TypeError return string but `test()` is declared to return int
+ // ```
+ //
+ // The initial scan of the function's code block has no
+ // type information for `$arg`. It isn't until we see
+ // the call and rescan test()'s code block that we can
+ // detect that it is actually returning the passed in
+ // `string` instead of an `int` as declared.
+ 'quick_mode' => false,
+
+ // If true, then before analysis, try to simplify AST into a form
+ // which improves Phan's type inference in edge cases.
+ //
+ // This may conflict with 'dead_code_detection'.
+ // When this is true, this slows down analysis slightly.
+ //
+ // E.g. rewrites `if ($a = value() && $a > 0) {...}`
+ // into $a = value(); if ($a) { if ($a > 0) {...}}`
+ 'simplify_ast' => true,
+
+ // Enable or disable support for generic templated
+ // class types.
+ 'generic_types_enabled' => true,
+
+ // Override to hardcode existence and types of (non-builtin) globals in the global scope.
+ // Class names should be prefixed with '\\'.
+ // (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int'])
+ 'globals_type_map' => [],
+
+ // The minimum severity level to report on. This can be
+ // set to Issue::SEVERITY_LOW, Issue::SEVERITY_NORMAL or
+ // Issue::SEVERITY_CRITICAL. Setting it to only
+ // critical issues is a good place to start on a big
+ // sloppy mature code base.
+ 'minimum_severity' => Issue::SEVERITY_LOW,
+
+ // Add any issue types (such as 'PhanUndeclaredMethod')
+ // to this black-list to inhibit them from being reported.
+ 'suppress_issue_types' => [],
+
+ // A regular expression to match files to be excluded
+ // from parsing and analysis and will not be read at all.
+ //
+ // This is useful for excluding groups of test or example
+ // directories/files, unanalyzable files, or files that
+ // can't be removed for whatever reason.
+ // (e.g. '@Test\.php$@', or '@vendor/.*/(tests|Tests)/@')
+ 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@',
+
+ // A file list that defines files that will be excluded
+ // from parsing and analysis and will not be read at all.
+ //
+ // This is useful for excluding hopelessly unanalyzable
+ // files that can't be removed for whatever reason.
+ 'exclude_file_list' => [],
+
+ // A directory list that defines files that will be excluded
+ // from static analysis, but whose class and method
+ // information should be included.
+ //
+ // Generally, you'll want to include the directories for
+ // third-party code (such as "vendor/") in this list.
+ //
+ // n.b.: If you'd like to parse but not analyze 3rd
+ // party code, directories containing that code
+ // should be added to the `directory_list` as
+ // to `exclude_analysis_directory_list`.
+ 'exclude_analysis_directory_list' => [
+ 'vendor/',
+ ],
+
+ // The number of processes to fork off during the analysis
+ // phase.
+ 'processes' => 1,
+
+ // List of case-insensitive file extensions supported by Phan.
+ // (e.g. php, html, htm)
+ 'analyzed_file_extensions' => [
+ 'php',
+ ],
+
+ // You can put paths to stubs of internal extensions in this config option.
+ // If the corresponding extension is **not** loaded, then phan will use the stubs instead.
+ // Phan will continue using its detailed type annotations,
+ // but load the constants, classes, functions, and classes (and their Reflection types)
+ // from these stub files (doubling as valid php files).
+ // Use a different extension from php to avoid accidentally loading these.
+ // The 'tools/make_stubs' script can be used to generate your own stubs (compatible with php 7.0+ right now)
+ 'autoload_internal_extension_signatures' => [],
+
+ // A list of plugin files to execute
+ // Plugins which are bundled with Phan can be added here by providing their name (e.g. 'AlwaysReturnPlugin')
+ // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php')
+ 'plugins' => [
+ 'AlwaysReturnPlugin',
+ 'PregRegexCheckerPlugin',
+ 'UnreachableCodePlugin',
+ 'WhitespacePlugin',
+ ],
+
+ // A list of directories that should be parsed for class and
+ // method information. After excluding the directories
+ // defined in exclude_analysis_directory_list, the remaining
+ // files will be statically analyzed for errors.
+ //
+ // Thus, both first-party and third-party code being used by
+ // your application should be included in this list.
+ 'directory_list' => [
+ 'src',
+ 'tests',
+ 'vendor/phan/phan/src/Phan',
+ 'vendor/phpunit/phpunit/src',
+ ],
+
+ // A list of individual files to include in analysis
+ // with a path relative to the root directory of the
+ // project
+ 'file_list' => [],
+
+];
diff --git a/.psysh.php b/.psysh.php
new file mode 100644
index 0000000..347af59
--- /dev/null
+++ b/.psysh.php
@@ -0,0 +1,8 @@
+<?php
+
+return [
+ 'useBracketedPaste' => true,
+ 'defaultIncludes' => [
+ __DIR__ . '/vendor/autoload.php',
+ ],
+];
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 0000000..c93206b
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,13 @@
+The following people have authored code included in this library:
+
+* Sébastien Santoro aka Dereckson<br>
+https://www.dereckson.be<br>
+_Car la connaissance s'accroît quand on la partage._
+
+This library also includes code initially published
+elsewhere by third-party developers:
+
+* Ronald Ulysses Swanson aka Wes<br>
+https://twitter.com/WesNetmo
+
+* Andrew Moore
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f9ea06a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2002, 2010, 2018 Sébastien Santoro aka Dereckson
+Some rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..084cf63
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# Keruald OmniTools library
+
+This utilities library offers convenient functions to solve common problems,
+like parse an URL, generate a random string or validate an IP address.
+
+## Getting started
+
+### With Composer
+
+To use this library in a project, you can require the following package:
+
+```
+$ composer require keruald/omnitools
+```
+
+### As a bundle
+
+The library follows PSR-4 conventions:
+the `src` folder matches the `Keruald\OmniTools` namespace.
+
+If you don't have a PSR-4 loader available:
+
+```lang=php
+<?php
+
+use Keruald\OmniTools\Registration\Autoloader;
+
+require 'path/to/keruald/omnitools/src/Registration/Autoloader.php';
+Autoloader::selfRegister();
+```
+
+## Contribute or report issues
+
+The Nasqueron DevCentral Phabricator instance is used to coordinate
+development. You can fill issues against the #Keruald project.
+
+https://devcentral.nasqueron.org/u/keruald
+
+## Versioning
+
+The library is sorted in namespaces and contains mostly static methods.
+
+The library adheres to semantic versioning.
+The 0.* version will be used to integrate code from the sourcing projects,
+like Keruald/Pluton, Keruald/Xen, Azhàr or Zed.
+
+## Credits
+
+This library is maintained by Sébastien Santoro aka Dereckson.
+
+The Contributors file contains the list of the people who contributed
+to the source code.
+
+## License
+
+This code is available under BSD-2-Clause license.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..a017944
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "keruald/omnitools",
+ "description": "Utilities classes",
+ "type": "library",
+ "license": "BSD-2-Clause",
+ "authors": [
+ {
+ "name": "Sébastien Santoro",
+ "email": "dereckson@espace-win.org"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Keruald\\OmniTools\\": "src/",
+ "Keruald\\OmniTools\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "ext-intl": "*"
+ },
+ "require-dev": {
+ "nasqueron/codestyle": "^0.0.1",
+ "phan/phan": "^5.3.1",
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "^3.6"
+ }
+}
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..021056b
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<ruleset name="Nasqueron">
+ <rule ref="vendor/nasqueron/codestyle/CodeSniffer/ruleset.xml" />
+
+ <!-- Allow dprint_r() and dieprint_r() legacy debug function names -->
+ <rule ref="Generic.NamingConventions.CamelCapsFunctionName.NotCamelCaps">
+ <exclude-pattern>*/_register_to_global_space.php</exclude-pattern>
+ </rule>
+
+ <file>src</file>
+ <file>tests</file>
+</ruleset>
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..f5c1939
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
+ bootstrap="vendor/autoload.php"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ stopOnFailure="false">
+ <php>
+ <ini name="display_errors" value="On" />
+ <ini name="display_startup_errors" value="On" />
+ <ini name="error_reporting" value="On" />
+ </php>
+ <testsuites>
+ <testsuite name="Unit tests">
+ <directory suffix="Test.php">./tests</directory>
+ </testsuite>
+ </testsuites>
+ <coverage processUncoveredFiles="true">
+ <include>
+ <directory suffix=".php">src/</directory>
+ </include>
+ </coverage>
+</phpunit>
diff --git a/src/Collections/ArrayUtilities.php b/src/Collections/ArrayUtilities.php
new file mode 100644
index 0000000..ebb3759
--- /dev/null
+++ b/src/Collections/ArrayUtilities.php
@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+use Closure;
+
+class ArrayUtilities {
+
+ ///
+ /// Methods to transform every member of an array
+ ///
+
+ /**
+ * @return int[]
+ */
+ public static function toIntegers (array $array) : array {
+ $newArray = $array;
+ array_walk($newArray, self::toIntegerCallback());
+ return $newArray;
+ }
+
+ ///
+ /// Helpers to get callbacks for array_walk methods
+ ///
+
+ public static function toIntegerCallback () : Closure {
+ return function (&$item) {
+ $item = (int)$item;
+ };
+ }
+
+}
diff --git a/src/Collections/BaseCollection.php b/src/Collections/BaseCollection.php
new file mode 100644
index 0000000..25b2de4
--- /dev/null
+++ b/src/Collections/BaseCollection.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+abstract class BaseCollection {
+
+ ///
+ /// Constructors
+ ///
+
+ public static abstract function from (iterable $items) : static;
+
+ ///
+ /// Getters
+ ///
+
+ public abstract function toArray () : array;
+
+ ///
+ /// Properties
+ ///
+
+ public abstract function count () : int;
+
+ public abstract function isEmpty () : bool;
+
+}
diff --git a/src/Collections/BaseMap.php b/src/Collections/BaseMap.php
new file mode 100644
index 0000000..8dfd7d6
--- /dev/null
+++ b/src/Collections/BaseMap.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Keruald\OmniTools\Collections;
+
+use ArrayAccess;
+use IteratorAggregate;
+
+abstract class BaseMap extends BaseCollection
+ implements ArrayAccess, IteratorAggregate {
+
+ ///
+ /// Methods to implement
+ ///
+
+ public abstract function get (mixed $key) : mixed;
+
+ public abstract function getOr (mixed $key, mixed $defaultValue): mixed;
+
+ public abstract function set (mixed $key, mixed $value) : static;
+
+ public abstract function unset (mixed $key) : static;
+
+ public abstract function has (mixed $key) : bool;
+
+ public abstract function contains (mixed $value) : bool;
+
+ ///
+ /// ArrayAccess
+ /// Interface to provide accessing objects as arrays.
+ ///
+
+ public function offsetExists (mixed $offset) : bool {
+ return $this->has($offset);
+ }
+
+ public function offsetGet (mixed $offset) : mixed {
+ return $this->get($offset);
+ }
+
+ public function offsetSet (mixed $offset, mixed $value) : void {
+ $this->set($offset, $value);
+ }
+
+ public function offsetUnset (mixed $offset) : void {
+ $this->unset($offset);
+ }
+
+}
diff --git a/src/Collections/Comparable.php b/src/Collections/Comparable.php
new file mode 100644
index 0000000..b70f724
--- /dev/null
+++ b/src/Collections/Comparable.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Keruald\OmniTools\Collections;
+
+interface Comparable {
+
+ public function compareTo (object $other) : int;
+
+}
diff --git a/src/Collections/HashMap.php b/src/Collections/HashMap.php
new file mode 100644
index 0000000..d6c65ec
--- /dev/null
+++ b/src/Collections/HashMap.php
@@ -0,0 +1,191 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+use Keruald\OmniTools\Reflection\CallableElement;
+
+use ArrayIterator;
+use InvalidArgumentException;
+use Traversable;
+
+/**
+ * An associative array allowing the use of chained
+ *
+ *
+ * This class can be used as a service container,
+ * an application context, to store configuration.
+ */
+class HashMap extends BaseMap {
+
+ ///
+ /// Properties
+ ///
+
+ private array $map;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (iterable $iterable = []) {
+ if (is_array($iterable)) {
+ $this->map = (array)$iterable;
+ return;
+ }
+
+ foreach ($iterable as $key => $value) {
+ $this->map[$key] = $value;
+ }
+ }
+
+ public static function from (iterable $items) : static {
+ return new self($items);
+ }
+
+ ///
+ /// Interact with map content at key level
+ ///
+
+ public function get (mixed $key) : mixed {
+ if (!array_key_exists($key, $this->map)) {
+ throw new InvalidArgumentException("Key not found.");
+ }
+
+ return $this->map[$key];
+ }
+
+ public function getOr (mixed $key, mixed $defaultValue) : mixed {
+ return $this->map[$key] ?? $defaultValue;
+ }
+
+ public function set (mixed $key, mixed $value) : static {
+ if ($key === null) {
+ throw new InvalidArgumentException("Key can't be null");
+ }
+
+ $this->map[$key] = $value;
+
+ return $this;
+ }
+
+ public function unset (mixed $key) : static {
+ unset($this->map[$key]);
+
+ return $this;
+ }
+
+ public function has (mixed $key) : bool {
+ return array_key_exists($key, $this->map);
+ }
+
+ public function contains (mixed $value) : bool {
+ return in_array($value, $this->map);
+ }
+
+ ///
+ /// Interact with collection content at collection level
+ ///
+
+ public function count () : int {
+ return count($this->map);
+ }
+
+ public function isEmpty () : bool {
+ return $this->count() === 0;
+ }
+
+ public function clear () : self {
+ $this->map = [];
+
+ return $this;
+ }
+
+ /**
+ * Merge the specified map with the current map.
+ *
+ * If a key already exists, the value already set is kept.
+ *
+ * @see update() when you need to update with the new value.
+ */
+ public function merge (iterable $iterable) : self {
+ foreach ($iterable as $key => $value) {
+ $this->map[$key] ??= $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Merge the specified map with the current bag.
+ *
+ * If a key already exists, the value is updated with the new one.
+ *
+ * @see merge() when you need to keep old value.
+ */
+ public function update (iterable $iterable) : self {
+ foreach ($iterable as $key => $value) {
+ $this->map[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets a copy of the internal map.
+ *
+ * Scalar values (int, strings) are cloned.
+ * Objects are references to a specific objet, not a clone.
+ *
+ * @return array<string, mixed>
+ */
+ public function toArray () : array {
+ return $this->map;
+ }
+
+ ///
+ /// HOF
+ ///
+
+ public function map (callable $callable) : self {
+ return new self(array_map($callable, $this->map));
+ }
+
+ public function filter (callable $callable) : self {
+ $argc = (new CallableElement($callable))->countArguments();
+ if ($argc === 0) {
+ throw new InvalidArgumentException(
+ "Callback should have at least one argument"
+ );
+ }
+ $mode = (int)($argc > 1);
+
+ return new self(
+ array_filter($this->map, $callable, $mode)
+ );
+ }
+
+ public function mapKeys (callable $callable) : self {
+ $mappedMap = [];
+ foreach ($this->map as $key => $value) {
+ $mappedMap[$callable($key)] = $value;
+ }
+
+ return new self($mappedMap);
+ }
+
+ public function filterKeys (callable $callable) : self {
+ return new self(
+ array_filter($this->map, $callable, ARRAY_FILTER_USE_KEY)
+ );
+ }
+
+ ///
+ /// IteratorAggregate
+ ///
+
+ public function getIterator () : Traversable {
+ return new ArrayIterator($this->map);
+ }
+
+}
diff --git a/src/Collections/SharedBag.php b/src/Collections/SharedBag.php
new file mode 100644
index 0000000..0938aa1
--- /dev/null
+++ b/src/Collections/SharedBag.php
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+/**
+ * A shared bag is a collection of key and values, which implements
+ * a monostate pattern, i.e. there is only one bag, which can be accessed
+ * though an arbitrary amount of SharedBag instances.
+ *
+ * The SharedBag class can be used as:
+ * — shared context, to contain the application configuration
+ * — service locator, to contain application dependencies
+ * — a migration path to store global variables of a legacy application
+ * pending the migration to a collection sharing the same interface
+ *
+ * Such patterns can be discouraged and as such used with architectural care,
+ * as they mainly use SharedBag as global variables, or as an antipattern.
+ */
+class SharedBag {
+
+ private static ?HashMap $bag = null;
+
+ public function getBag() : HashMap {
+ if (self::$bag === null) {
+ self::$bag = new HashMap;
+ }
+
+ return self::$bag;
+ }
+
+}
diff --git a/src/Collections/TraversableUtilities.php b/src/Collections/TraversableUtilities.php
new file mode 100644
index 0000000..52364ef
--- /dev/null
+++ b/src/Collections/TraversableUtilities.php
@@ -0,0 +1,62 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+use Countable;
+use InvalidArgumentException;
+use ResourceBundle;
+use SimpleXMLElement;
+use TypeError;
+
+class TraversableUtilities {
+
+ public static function count ($countable) : int {
+ if (is_countable($countable)) {
+ return count($countable);
+ }
+
+ if ($countable === null || $countable === false) {
+ return 0;
+ }
+
+ throw new TypeError;
+ }
+
+ public static function first (iterable $iterable) : mixed {
+ foreach ($iterable as $value) {
+ return $value;
+ }
+
+ throw new InvalidArgumentException(
+ "Can't call first() on an empty iterable."
+ );
+ }
+
+ public static function firstOr (
+ iterable $iterable, mixed $defaultValue = null
+ ) : mixed {
+ foreach ($iterable as $value) {
+ return $value;
+ }
+
+ return $defaultValue;
+ }
+
+ /**
+ * @deprecated Use \is_countable
+ */
+ public static function isCountable ($countable) : bool {
+ if (function_exists('is_countable')) {
+ // PHP 7.3 has is_countable
+ return is_countable($countable);
+ }
+
+ // https://github.com/Ayesh/is_countable-polyfill/blob/master/src/is_countable.php
+ return is_array($countable)
+ || $countable instanceof Countable
+ || $countable instanceof SimpleXMLElement
+ || $countable instanceof ResourceBundle;
+ }
+
+}
diff --git a/src/Collections/Vector.php b/src/Collections/Vector.php
new file mode 100644
index 0000000..ce4ec60
--- /dev/null
+++ b/src/Collections/Vector.php
@@ -0,0 +1,276 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+use Keruald\OmniTools\Reflection\CallableElement;
+use Keruald\OmniTools\Strings\Multibyte\OmniString;
+
+use ArrayAccess;
+use ArrayIterator;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+
+class Vector extends BaseCollection implements ArrayAccess, IteratorAggregate {
+
+ ///
+ /// Properties
+ ///
+
+ private array $items;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (iterable $items = []) {
+ if (is_array($items)) {
+ $this->items = $items;
+ return;
+ }
+
+ foreach ($items as $item) {
+ $this->items[] = $item;
+ }
+ }
+
+ public static function from (iterable $items) : static {
+ return new self($items);
+ }
+
+ ///
+ /// Specialized constructors
+ ///
+
+ /**
+ * Constructs a new instance of a vector by exploding a string
+ * according a specified delimiter.
+ *
+ * @param string $delimiter The substring to find for explosion
+ * @param string $string The string to explode
+ * @param int $limit If specified, the maximum count of vector elements
+ * @return static
+ */
+ public static function explode (string $delimiter, string $string,
+ int $limit = PHP_INT_MAX) : self {
+ // There is some discussion to know if this method belongs
+ // to Vector or OmniString.
+ //
+ // The advantage to keep it here is we can have constructs like:
+ // Vector::explode(",", "1,1,2,3,5,8,13")
+ // ->toIntegers()
+ // >map(function($n) { return $n * $n; })
+ // ->toArray();
+ //
+ // In this chaining, it is clear we manipulate Vector methods.
+
+ return (new OmniString($string))
+ ->explode($delimiter, $limit);
+ }
+
+ ///
+ /// Interact with collection content at key level
+ ///
+
+ public function get (int $key) : mixed {
+ if (!array_key_exists($key, $this->items)) {
+ throw new InvalidArgumentException("Key not found.");
+ }
+
+ return $this->items[$key];
+ }
+
+ public function getOr (int $key, mixed $defaultValue) : mixed {
+ return $this->items[$key] ?? $defaultValue;
+ }
+
+ public function set (int $key, mixed $value) : static {
+ $this->items[$key] = $value;
+
+ return $this;
+ }
+
+ public function unset (int $key) : static {
+ unset($this->items[$key]);
+
+ return $this;
+ }
+
+ public function contains (mixed $value) : bool {
+ return in_array($value, $this->items);
+ }
+
+ ///
+ /// Interact with collection content at collection level
+ ///
+
+ public function count () : int {
+ return count($this->items);
+ }
+
+ public function isEmpty () : bool {
+ return $this->count() === 0;
+ }
+
+ public function clear () : self {
+ $this->items = [];
+
+ return $this;
+ }
+
+ public function push (mixed $item) : self {
+ $this->items[] = $item;
+
+ return $this;
+ }
+
+ /**
+ * Append all elements of the specified iterable
+ * to the current vector.
+ *
+ * If a value already exists, the value is still added
+ * as a duplicate.
+ *
+ * @see update() when you need to only add unique values.
+ */
+ public function append (iterable $iterable) : self {
+ foreach ($iterable as $value) {
+ $this->items[] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Append all elements of the specified iterable
+ * to the current vector.
+ *
+ * If a value already exists, it is skipped.
+ *
+ * @see append() when you need to always add everything.
+ */
+ public function update (iterable $iterable) : self {
+ foreach ($iterable as $value) {
+ if (!$this->contains($value)) {
+ $this->items[] = $value;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets a copy of the internal vector.
+ *
+ * Scalar values (int, strings) are cloned.
+ * Objects are references to a specific objet, not a clone.
+ *
+ * @return array
+ */
+ public function toArray () : array {
+ return $this->items;
+ }
+
+ ///
+ /// HOF :: generic
+ ///
+
+ public function map (callable $callable) : self {
+ return new self(array_map($callable, $this->items));
+ }
+
+ public function filter (callable $callable) : self {
+ $argc = (new CallableElement($callable))->countArguments();
+
+ if ($argc === 0) {
+ throw new InvalidArgumentException(
+ "Callback should have at least one argument"
+ );
+ }
+
+ $mode = (int)($argc > 1);
+ return new self(array_filter($this->items, $callable, $mode));
+ }
+
+ public function mapKeys (callable $callable) : self {
+ $mappedVector = [];
+ foreach ($this->items as $key => $value) {
+ $mappedVector[$callable($key)] = $value;
+ }
+
+ return new self($mappedVector);
+ }
+
+ public function filterKeys (callable $callable) : self {
+ return new self(
+ array_filter($this->items, $callable, ARRAY_FILTER_USE_KEY)
+ );
+ }
+
+ ///
+ /// HOF :: specialized
+ ///
+
+ public function toIntegers () : self {
+ array_walk($this->items, ArrayUtilities::toIntegerCallback());
+
+ return $this;
+ }
+
+ public function implode(string $delimiter) : OmniString {
+ return new OmniString(implode($delimiter, $this->items));
+ }
+
+ ///
+ /// ArrayAccess
+ /// Interface to provide accessing objects as arrays.
+ ///
+
+ private static function ensureOffsetIsInteger (mixed $offset) {
+ if (is_int($offset)) {
+ return;
+ }
+
+ throw new InvalidArgumentException(
+ "Offset of a vector must be an integer."
+ );
+ }
+
+ public function offsetExists (mixed $offset) : bool {
+ self::ensureOffsetIsInteger($offset);
+
+ return array_key_exists($offset, $this->items);
+ }
+
+ public function offsetGet (mixed $offset) : mixed {
+ self::ensureOffsetIsInteger($offset);
+
+ return $this->get($offset);
+ }
+
+ public function offsetSet (mixed $offset, mixed $value) : void {
+ if ($offset === null) {
+ $this->push($value);
+ return;
+ }
+
+ self::ensureOffsetIsInteger($offset);
+
+ $this->set($offset, $value);
+ }
+
+ public function offsetUnset (mixed $offset) : void {
+ self::ensureOffsetIsInteger($offset);
+
+ $this->unset($offset);
+ }
+
+ ///
+ /// IteratorAggregate
+ ///
+
+ public function getIterator () : Traversable {
+ return new ArrayIterator($this->items);
+ }
+}
diff --git a/src/Collections/WeightedList.php b/src/Collections/WeightedList.php
new file mode 100644
index 0000000..ca8d5ef
--- /dev/null
+++ b/src/Collections/WeightedList.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Keruald\OmniTools\Collections;
+
+use ArrayIterator;
+use Countable;
+use Iterator;
+use IteratorAggregate;
+
+class WeightedList implements IteratorAggregate, Countable {
+
+ /**
+ * @var WeightedValue[]
+ */
+ private $list;
+
+ public function __construct () {
+ $this->list = [];
+ }
+
+ /**
+ * @param string $expression A string like "a,b;q=0.1,c;q=0.4"
+ *
+ * @return WeightedList
+ *
+ * @see RFC 7231, section 5.3.1
+ */
+ public static function parse (string $expression) : WeightedList {
+ $list = new WeightedList();
+
+ if ($expression !== "") {
+ $items = explode(',', $expression);
+ foreach ($items as $item) {
+ $list->addFromString($item);
+ }
+ }
+
+ return $list;
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function add ($item, float $weight = 1.0) : void {
+ $this->list[] = new WeightedValue($item, $weight);
+ }
+
+ public function addWeightedValue (WeightedValue $value) : void {
+ $this->list[] = $value;
+ }
+
+ public function addFromString (string $expression) : void {
+ $this->addWeightedValue(WeightedValue::parse($expression));
+ }
+
+ public function clear () : void {
+ $this->list = [];
+ }
+
+ public function getHeaviest () {
+ $value = null;
+
+ foreach ($this->list as $candidate) {
+ if ($value === null || $candidate->compareTo($value) > 0) {
+ $value = $candidate;
+ }
+ }
+
+ return $value;
+ }
+
+ public function toSortedArray () : array {
+ $weights = [];
+ $values = [];
+
+ foreach ($this->list as $item) {
+ $weights[] = $item->getWeight();
+ $values[] = $item->getValue();
+ }
+
+ array_multisort($weights, SORT_DESC, $values, SORT_ASC);
+
+ return $values;
+ }
+
+ ///
+ /// IteratorAggregate implementation
+ ///
+
+ public function getIterator () : Iterator {
+ return new ArrayIterator($this->list);
+ }
+
+ ///
+ /// Countable implementation
+ ///
+
+ public function count () : int {
+ return count($this->list);
+ }
+
+}
diff --git a/src/Collections/WeightedValue.php b/src/Collections/WeightedValue.php
new file mode 100644
index 0000000..a86555a
--- /dev/null
+++ b/src/Collections/WeightedValue.php
@@ -0,0 +1,85 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Collections;
+
+use TypeError;
+
+class WeightedValue implements Comparable {
+
+ ///
+ /// Constants
+ ///
+
+ const DEFAULT_WEIGHT = 1.0;
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var float
+ */
+ private $weight = self::DEFAULT_WEIGHT;
+
+ /**
+ * @var mixed
+ */
+ private $value;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct ($value, float $weight = self::DEFAULT_WEIGHT) {
+ $this->value = $value;
+ $this->weight = $weight;
+ }
+
+ public static function parse (string $expression) : WeightedValue {
+ $pair = explode(';q=', $expression);
+
+ if (count($pair) == 1) {
+ return new WeightedValue($pair[0]);
+ }
+
+ return new WeightedValue($pair[0], (float)$pair[1]);
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function getWeight () : float {
+ return $this->weight;
+ }
+
+ public function setWeight (float $weight) : self {
+ $this->weight = $weight;
+
+ return $this;
+ }
+
+ public function getValue () {
+ return $this->value;
+ }
+
+ public function setValue ($value) : self {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function compareTo (object $other) : int {
+ if (!$other instanceof WeightedValue) {
+ throw new TypeError;
+ }
+
+ return $this->getWeight() <=> $other->getWeight();
+ }
+
+}
diff --git a/src/Culture/Rome/RomanNumerals.php b/src/Culture/Rome/RomanNumerals.php
new file mode 100644
index 0000000..7525ce9
--- /dev/null
+++ b/src/Culture/Rome/RomanNumerals.php
@@ -0,0 +1,96 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Culture\Rome;
+
+use InvalidArgumentException;
+
+final class RomanNumerals {
+
+ public static function fromHinduArabic (int $number) : string {
+ self::assertStrictlyPositiveNumber($number);
+
+ if ($number > 1000) {
+ return self::computeFromKiloHinduArabic($number);
+ }
+
+ $table = self::getHinduArabicTable();
+
+ return $table[$number] ?? self::computeFromHinduArabic($number);
+ }
+
+ /**
+ * Provides a canonical table with hindu arabic numerals as keys,
+ * and Roman numerals as values.
+ */
+ public static function getHinduArabicTable () : array {
+ return [
+ 1 => 'i',
+ 2 => 'ii',
+ 3 => 'iii',
+ 4 => 'iv',
+ 5 => 'v',
+ 6 => 'vi',
+ 7 => 'vii',
+ 8 => 'viii',
+ 9 => 'ix',
+ 10 => 'x',
+ 50 => 'l',
+ 100 => 'c',
+ 500 => 'd',
+ 1000 => 'm',
+ ];
+ }
+
+ private static function getComputeHinduArabicTable () : iterable {
+ // limit => number to subtract (as a [roman, hindu arabic] array)
+ yield 21 => ['x', 10];
+ yield 30 => ['xx', 20];
+ yield 40 => ['xxx', 30];
+ yield 50 => ['xl', 40];
+ yield 60 => ['l', 50];
+ yield 70 => ['lx', 60];
+ yield 80 => ['lxx', 70];
+ yield 90 => ['lxxx', 80];
+ yield 100 => ['xc', 90];
+ yield 200 => ['c', 100];
+ yield 300 => ['cc', 200];
+ yield 400 => ['ccc', 300];
+ yield 500 => ['cd', 400];
+ yield 600 => ['d', 500];
+ yield 700 => ['dc', 600];
+ yield 800 => ['dcc', 700];
+ yield 900 => ['dccc', 800];
+ yield 1000 => ['cm', 900];
+ }
+
+ private static function computeFromHinduArabic (int $number) : string {
+ foreach (self::getComputeHinduArabicTable() as $limit => $term) {
+ if ($number < $limit) {
+ return $term[0] . self::fromHinduArabic($number - $term[1]);
+ }
+ }
+
+ throw new \LogicException("This should be unreachable code.");
+ }
+
+ private static function computeFromKiloHinduArabic (int $number) : string {
+ $thousandAmount = (int)floor($number / 1000);
+ $remainder = $number % 1000;
+
+ $roman = str_repeat('m', $thousandAmount);
+ if ($remainder > 0) {
+ $roman .= self::fromHinduArabic($remainder);
+ }
+
+ return $roman;
+ }
+
+ private static function assertStrictlyPositiveNumber (int $number) : void {
+ if ($number < 1) {
+ throw new InvalidArgumentException(
+ "Can only convert strictly positive numbers"
+ );
+ }
+ }
+}
diff --git a/src/DateTime/DateStamp.php b/src/DateTime/DateStamp.php
new file mode 100644
index 0000000..637e892
--- /dev/null
+++ b/src/DateTime/DateStamp.php
@@ -0,0 +1,89 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\DateTime;
+
+use Keruald\OmniTools\Collections\Vector;
+
+use DateTime;
+use InvalidArgumentException;
+
+class DateStamp {
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var int
+ */
+ private $year;
+
+ /**
+ * @var int
+ */
+ private $month;
+
+ /**
+ * @var int
+ */
+ private $day;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (int $year, int $month, int $day) {
+ $this->year = $year;
+ $this->month = $month;
+ $this->day = $day;
+ }
+
+ public static function fromUnixTime (?int $unixtime = null) : self {
+ $dateStamp = date('Y-m-d', $unixtime ?? time());
+ return self::parse($dateStamp);
+ }
+
+ public static function parse (string $date) : self {
+ if (preg_match("/^[0-9]{4}\-[0-1][0-9]\-[0-3][0-9]$/", $date)) {
+ // YYYY-MM-DD
+ $args = Vector::explode("-", $date)
+ ->toIntegers()
+ ->toArray();
+
+ return new DateStamp(...$args);
+ }
+
+ if (preg_match("/^[0-9]{4}[0-1][0-9][0-3][0-9]$/", $date)) {
+ // YYYYMMDD
+ return new DateStamp(
+ (int)substr($date, 0, 4), // YYYY
+ (int)substr($date, 4, 2), // MM
+ (int)substr($date, 6, 2) // DD
+ );
+ }
+
+ throw new InvalidArgumentException("YYYYMMDD or YYYY-MM-DD format expected, $date received.");
+ }
+
+ ///
+ /// Convert methods
+ ///
+
+ public function toUnixTime () : int {
+ return mktime(0, 0, 0, $this->month, $this->day, $this->year);
+ }
+
+ public function toDateTime () : DateTime {
+ return new DateTime($this->__toString());
+ }
+
+ public function toShortString () : string {
+ return date('Ymd', $this->toUnixTime());
+ }
+
+ public function __toString () : string {
+ return date('Y-m-d', $this->toUnixTime());
+ }
+
+}
diff --git a/src/Debug/Debugger.php b/src/Debug/Debugger.php
new file mode 100644
index 0000000..253c0e5
--- /dev/null
+++ b/src/Debug/Debugger.php
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Debug;
+
+class Debugger {
+
+ /**
+ * Prints human-readable information about a variable, wrapped in a <pre> block
+ *
+ * @param mixed $variable the variable to dump
+ */
+ public static function printVariable ($variable) : void {
+ echo "<pre class='debug'>";
+ print_r($variable);
+ echo "</pre>";
+ }
+
+ public static function printVariableAndDie ($variable) : void {
+ static::printVariable($variable);
+ die;
+ }
+
+ ///
+ /// Comfort debug helper to register debug method in global space
+ ///
+
+ public static function register () : void {
+ require_once '_register_to_global_space.php';
+ }
+
+}
diff --git a/src/Debug/_register_to_global_space.php b/src/Debug/_register_to_global_space.php
new file mode 100644
index 0000000..e0ebd55
--- /dev/null
+++ b/src/Debug/_register_to_global_space.php
@@ -0,0 +1,17 @@
+<?php
+
+/* This code is intentionally left in the global namespace. */
+
+use Keruald\OmniTools\Debug\Debugger;
+
+if (!function_exists("dprint_r")) {
+ function dprint_r ($variable) {
+ Debugger::printVariable($variable);
+ }
+}
+
+if (!function_exists("dieprint_r")) {
+ function dieprint_r ($variable) {
+ Debugger::printVariableAndDie($variable);
+ }
+}
diff --git a/src/HTTP/Requests/AcceptedLanguages.php b/src/HTTP/Requests/AcceptedLanguages.php
new file mode 100644
index 0000000..4cf3222
--- /dev/null
+++ b/src/HTTP/Requests/AcceptedLanguages.php
@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+use Keruald\OmniTools\Collections\WeightedList;
+
+class AcceptedLanguages {
+
+ /**
+ * @var string
+ */
+ private $acceptedLanguages;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (string $acceptedLanguages = '') {
+ $this->acceptedLanguages = $acceptedLanguages;
+ }
+
+ public static function fromServer () : self {
+ return new self(self::extractFromHeaders());
+ }
+
+ ///
+ /// Helper methods to determine the languages
+ ///
+
+ public static function extractFromHeaders () : string {
+ return $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? "";
+ }
+
+ public function getLanguageCodes () : array {
+ return WeightedList::parse($this->acceptedLanguages)
+ ->toSortedArray();
+ }
+
+}
diff --git a/src/HTTP/Requests/RemoteAddress.php b/src/HTTP/Requests/RemoteAddress.php
new file mode 100644
index 0000000..4558223
--- /dev/null
+++ b/src/HTTP/Requests/RemoteAddress.php
@@ -0,0 +1,94 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+use Keruald\OmniTools\Network\IP;
+
+class RemoteAddress {
+
+ /**
+ * @var string
+ */
+ private $remoteAddress;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (string $remoteAddress = '') {
+ $this->remoteAddress = $remoteAddress;
+ }
+
+ public static function fromServer () : self {
+ return new self(self::extractRemoteAddressesFromHeaders());
+ }
+
+ ///
+ /// Format methods
+ ///
+
+ public function getClientAddress () : string {
+ // Header contains 'clientIP, proxyIP, anotherProxyIP'
+ // or 'clientIP proxyIP anotherProxyIP'
+ // The client address to return is then the first value.
+ // See draft-ietf-appsawg-http-forwarded-10.
+ $ips = preg_split("/[\s,]+/", $this->remoteAddress, 2);
+ return trim($ips[0]);
+ }
+
+ public function isFromLocalHost () : bool {
+ return IP::isLoopBack($this->getClientAddress());
+ }
+
+ public function getAll () : string {
+ return $this->remoteAddress;
+ }
+
+
+ public function has () : bool {
+ return $this->remoteAddress !== "";
+ }
+
+ ///
+ /// Information methods
+ ///
+
+ ///
+ /// Helper methods to determine the remote address
+ ///
+
+ /**
+ * Allows to get all the remote addresses from relevant headers
+ */
+ public static function extractRemoteAddressesFromHeaders () : string {
+ foreach (self::listRemoteAddressHeaders() as $candidate) {
+ if (isset($_SERVER[$candidate])) {
+ return $_SERVER[$candidate];
+ }
+ }
+
+ return "";
+ }
+
+ ///
+ /// Data sources
+ ///
+
+ public static function listRemoteAddressHeaders () : array {
+ return [
+ // Standard header provided by draft-ietf-appsawg-http-forwarded-10
+ 'HTTP_X_FORWARDED_FOR',
+
+ // Legacy headers
+ 'HTTP_CLIENT_IP',
+ 'HTTP_FORWARDED',
+ 'HTTP_FORWARDED_FOR',
+ 'HTTP_X_CLUSTER_CLIENT_IP',
+ 'HTTP_X_FORWARDED',
+
+ // Default header if no proxy information could be detected
+ 'REMOTE_ADDR',
+ ];
+ }
+}
diff --git a/src/HTTP/Requests/Request.php b/src/HTTP/Requests/Request.php
new file mode 100644
index 0000000..509055b
--- /dev/null
+++ b/src/HTTP/Requests/Request.php
@@ -0,0 +1,12 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+class Request {
+
+ use WithAcceptedLanguages;
+ use WithRemoteAddress;
+ use WithURL;
+
+}
diff --git a/src/HTTP/Requests/WithAcceptedLanguages.php b/src/HTTP/Requests/WithAcceptedLanguages.php
new file mode 100644
index 0000000..a23a3d4
--- /dev/null
+++ b/src/HTTP/Requests/WithAcceptedLanguages.php
@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+
+trait WithAcceptedLanguages {
+
+ /**
+ * Gets the languages accepted by the browser, by order of priority.
+ *
+ * This will read the HTTP_ACCEPT_LANGUAGE variable sent by the browser in the
+ * HTTP request.
+ *
+ * @return string[] each item a language accepted by browser
+ */
+ public static function getAcceptedLanguages () : array {
+ return AcceptedLanguages::fromServer()->getLanguageCodes();
+ }
+
+}
diff --git a/src/HTTP/Requests/WithRemoteAddress.php b/src/HTTP/Requests/WithRemoteAddress.php
new file mode 100644
index 0000000..97ba082
--- /dev/null
+++ b/src/HTTP/Requests/WithRemoteAddress.php
@@ -0,0 +1,29 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+trait WithRemoteAddress {
+
+ /**
+ * Gets remote IP address.
+ *
+ * This is intended as a drop-in replacement for $_SERVER['REMOTE_ADDR'],
+ * which takes in consideration proxy values, blindly trusted.
+ *
+ * This method should is only for environment where headers are controlled,
+ * like nginx + php_fpm, where HTTP_ headers are reserved for the server
+ * information, and where the headers sent by the web server to nginx are
+ * checked or populated by nginx itself.
+ *
+ * @return string the remote address
+ */
+ public static function getRemoteAddress () : string {
+ return RemoteAddress::fromServer()->getClientAddress();
+ }
+
+ public static function isFromLocalHost () : bool {
+ return RemoteAddress::fromServer()->isFromLocalHost();
+ }
+
+}
diff --git a/src/HTTP/Requests/WithURL.php b/src/HTTP/Requests/WithURL.php
new file mode 100644
index 0000000..5c21db2
--- /dev/null
+++ b/src/HTTP/Requests/WithURL.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Keruald\OmniTools\HTTP\Requests;
+
+use Keruald\OmniTools\HTTP\URL;
+use Keruald\OmniTools\Strings\Multibyte\StringUtilities;
+
+trait WithURL {
+
+ ///
+ /// Main methods
+ ///
+
+ public static function getServerURL () : string {
+ $scheme = self::getScheme();
+ $name = self::getServerName();
+ $port = self::getPort();
+
+ // If we forward for a proxy, trust the scheme instead of standard :80
+ $fixToHTTPS = $port === 80 && $scheme === "https";
+
+ if ($port === 443 || $fixToHTTPS) {
+ return "https://$name";
+ }
+
+ if ($port === 80) {
+ return "http://$name";
+ }
+
+ return "$scheme://$name:$port";
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public static function getPort () : int {
+ return (int)($_SERVER['SERVER_PORT'] ?? 80);
+ }
+
+ public static function getServerName () : string {
+ return $_SERVER['SERVER_NAME'] ?? "localhost";
+ }
+
+ public static function getScheme () : string {
+ return $_SERVER['REQUEST_SCHEME']
+ ?? $_SERVER['HTTP_X_FORWARDED_PROTO']
+ ?? $_SERVER['HTTP_X_FORWARDED_PROTOCOL']
+ ?? $_SERVER['HTTP_X_URL_SCHEME']
+ ?? self::guessScheme();
+ }
+
+ private static function guessScheme () : string {
+ return self::isHTTPS() ? "https" : "http";
+ }
+
+ public static function isHTTPS () : bool {
+ // Legacy headers have been documented at MDN:
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
+
+ $headers = self::getHTTPSHeadersTable();
+ foreach ($headers as $header => $value) {
+ if (isset($_SERVER[$header]) && $_SERVER[$header] === $value) {
+ return true;
+ }
+ }
+
+ if (isset($_SERVER['HTTP_FORWARDED'])) {
+ return StringUtilities::contains($_SERVER['HTTP_FORWARDED'], "proto=https");
+ }
+
+ return false;
+ }
+
+ private static function getHTTPSHeadersTable () : array {
+ return [
+ "HTTPS" => "on",
+ "REQUEST_SCHEME" => "https",
+ "SERVER_PORT" => "443",
+ "HTTP_X_FORWARDED_PROTO" => "https",
+ "HTTP_FRONT_END_HTTPS" => "on",
+ "HTTP_X_FORWARDED_PROTOCOL" => "https",
+ "HTTP_X_FORWARDED_SSL" => "on",
+ "HTTP_X_URL_SCHEME" => "https",
+ ];
+ }
+
+ /**
+ * Create a URL object, using the current request server URL for protocol
+ * and domain name.
+ *
+ * @param string $query The query part of the URL [facultative]
+ * @param int $encodeMode Encoding to use for the query [facultative]
+ */
+ public static function createLocalURL (string $query = "",
+ int $encodeMode = URL::ENCODE_RFC3986_SLASH_EXCEPTED
+ ) : URL {
+ return (new URL(self::getServerURL()))
+ ->setQuery($query, $encodeMode);
+ }
+
+}
diff --git a/src/HTTP/URL.php b/src/HTTP/URL.php
new file mode 100644
index 0000000..b992df9
--- /dev/null
+++ b/src/HTTP/URL.php
@@ -0,0 +1,208 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\HTTP;
+
+use Keruald\OmniTools\Strings\Multibyte\OmniString;
+
+class URL {
+
+ ///
+ /// Constants
+ ///
+
+ /**
+ * Encode the query using RFC 3986, but keep / intact as a separators.
+ * As such, everything will be encoded excepted ~ - _ . / characters.
+ */
+ const ENCODE_RFC3986_SLASH_EXCEPTED = 1;
+
+ /**
+ * Encode the query using RFC 3986, including the /.
+ * As such, everything will be encoded excepted ~ - _ . characters.
+ */
+ const ENCODE_RFC3986_PURE = 2;
+
+ /**
+ * Consider the query already encoded.
+ */
+ const ENCODE_AS_IS = 3;
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var string
+ */
+ private $url;
+
+ /**
+ * @var int
+ */
+ private $queryEncoding;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct ($url,
+ $queryEncoding = self::ENCODE_RFC3986_SLASH_EXCEPTED) {
+ $this->url = $url;
+ $this->queryEncoding = $queryEncoding;
+ }
+
+ public static function compose (string $protocol, string $domain,
+ string $query,
+ $queryEncoding = self::ENCODE_RFC3986_SLASH_EXCEPTED
+ ) : self {
+ return (new URL("", $queryEncoding))
+ ->update($protocol, $domain, $query);
+ }
+
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function getProtocol () : string {
+ if (preg_match("@(.*?)://.*@", $this->url, $matches)) {
+ return $matches[1];
+ }
+
+ return "";
+ }
+
+ private function getUrlParts() : array {
+ preg_match("@://(.*)@", $this->url, $matches);
+ return explode("/", $matches[1], 2);
+ }
+
+ public function getDomain () : string {
+ if (strpos($this->url, "://") === false) {
+ return "";
+ }
+
+ $domain = $this->getUrlParts()[0];
+
+ if ($domain === "") {
+ return "";
+ }
+
+ return self::beautifyDomain($domain);
+ }
+
+ public function getQuery () : string {
+ if (strpos($this->url, "://") === false) {
+ return $this->url;
+ }
+
+ $parts = $this->getUrlParts();
+
+ if (count($parts) < 2 || $parts[1] === "" || $parts[1] === "/") {
+ return "/";
+ }
+
+ return "/" . $this->beautifyQuery($parts[1]);
+ }
+
+ public function setProtocol ($protocol) : self {
+ $this->update($protocol, $this->getDomain(), $this->getQuery());
+
+ return $this;
+ }
+
+ public function setDomain ($domain) : self {
+ $this->update($this->getProtocol(), $domain, $this->getQuery());
+
+ return $this;
+ }
+
+ public function setQuery ($query,
+ $encodeMode = self::ENCODE_RFC3986_SLASH_EXCEPTED
+ ) : self {
+ $this->queryEncoding = $encodeMode;
+ $this->update($this->getProtocol(), $this->getDomain(), $query);
+
+ return $this;
+ }
+
+ private function isRootQuery($query) : bool {
+ return $this->queryEncoding !== self::ENCODE_RFC3986_PURE
+ && $query !== ""
+ && $query[0] === '/';
+ }
+
+ private function update (string $protocol, string $domain, string $query) : self {
+ $url = "";
+
+ if ($domain !== "") {
+ if ($protocol !== "") {
+ $url = $protocol;
+ }
+
+ $url .= "://" . self::normalizeDomain($domain);
+
+ if (!$this->isRootQuery($query)) {
+ $url .= "/";
+ }
+ }
+
+ $url .= $this->normalizeQuery($query);
+
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function normalizeQuery (string $query) : string {
+ switch ($this->queryEncoding) {
+ case self::ENCODE_RFC3986_SLASH_EXCEPTED:
+ return (new OmniString($query))
+ ->explode("/")
+ ->map("rawurlencode")
+ ->implode("/")
+ ->__toString();
+
+ case self::ENCODE_AS_IS:
+ return $query;
+
+ case self::ENCODE_RFC3986_PURE:
+ return rawurlencode($query);
+ }
+
+ throw new \Exception('Unexpected encoding value');
+ }
+
+ public function beautifyQuery (string $query) : string {
+ switch ($this->queryEncoding) {
+ case self::ENCODE_RFC3986_SLASH_EXCEPTED:
+ return (new OmniString($query))
+ ->explode("/")
+ ->map("rawurldecode")
+ ->implode("/")
+ ->__toString();
+
+ case self::ENCODE_AS_IS:
+ return $query;
+
+ case self::ENCODE_RFC3986_PURE:
+ return rawurldecode($query);
+ }
+
+ throw new \Exception('Unexpected encoding value');
+ }
+
+ public static function normalizeDomain (string $domain) : string {
+ return \idn_to_ascii($domain, 0, INTL_IDNA_VARIANT_UTS46);
+ }
+
+ public static function beautifyDomain (string $domain) : string {
+ return \idn_to_utf8($domain, 0, INTL_IDNA_VARIANT_UTS46);
+ }
+
+ public function __toString () {
+ return $this->url;
+ }
+
+}
diff --git a/src/IO/Directory.php b/src/IO/Directory.php
new file mode 100644
index 0000000..b6ffebb
--- /dev/null
+++ b/src/IO/Directory.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Keruald\OmniTools\IO;
+
+class Directory {
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (
+ private string $path,
+ ) {}
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function getPath () : string {
+ return $this->path;
+ }
+
+ public function setPath (string $path) : self {
+ $this->path = $path;
+
+ return $this;
+ }
+
+ ///
+ /// Directory properties methods
+ ///
+
+ public function exists () : bool {
+ return is_dir($this->path);
+ }
+
+ public function isReadable () : bool {
+ return is_readable($this->path);
+ }
+
+ public function isWritable () : bool {
+ return is_writable($this->path);
+ }
+
+ /**
+ * @return array<string, string>
+ */
+ public function getPathInfo () : array {
+ return pathinfo($this->path);
+ }
+
+ public function getParentDirectory () : string {
+ return pathinfo($this->path, PATHINFO_DIRNAME);
+ }
+
+ public function getDirectoryName () : string {
+ return pathinfo($this->path, PATHINFO_BASENAME);
+ }
+
+ ///
+ /// Search files
+ ///
+
+ /**
+ * Gets files in the directory matching a specific pattern,
+ * using the PHP glob function.
+ *
+ * @return File[]
+ */
+ public function glob (string $pattern) : array {
+ return array_map(
+ function ($file) {
+ return new File($file);
+ }, glob("$this->path/$pattern")
+ );
+ }
+
+ /**
+ * @return Directory[]
+ */
+ public function getSubdirectories () : array {
+ return array_map(
+ function ($dir) {
+ return new Directory($dir);
+ }, glob("$this->path/*", GLOB_ONLYDIR)
+ );
+ }
+
+}
diff --git a/src/IO/File.php b/src/IO/File.php
new file mode 100644
index 0000000..96c9f21
--- /dev/null
+++ b/src/IO/File.php
@@ -0,0 +1,74 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\IO;
+
+class File {
+
+ /**
+ * @var string
+ */
+ private $path;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $path) {
+ $this->path = $path;
+ }
+
+ /**
+ * @return static
+ */
+ public static function from (string $path) : self {
+ return new static($path);
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function getPath () : string {
+ return $this->path;
+ }
+
+ public function setPath (string $path) : self {
+ $this->path = $path;
+
+ return $this;
+ }
+
+ ///
+ /// File properties methods
+ ///
+
+ public function exists () : bool {
+ return file_exists($this->path);
+ }
+
+ public function isReadable () : bool {
+ return is_readable($this->path);
+ }
+
+ public function getPathInfo () : array {
+ return pathinfo($this->path);
+ }
+
+ public function getDirectory () : string {
+ return pathinfo($this->path, PATHINFO_DIRNAME);
+ }
+
+ public function getFileName () : string {
+ return pathinfo($this->path, PATHINFO_BASENAME);
+ }
+
+ public function getFileNameWithoutExtension () : string {
+ return pathinfo($this->path, PATHINFO_FILENAME);
+ }
+
+ public function getExtension () : string {
+ return pathinfo($this->path, PATHINFO_EXTENSION);
+ }
+
+}
diff --git a/src/IO/FileUtilities.php b/src/IO/FileUtilities.php
new file mode 100644
index 0000000..b30a4e4
--- /dev/null
+++ b/src/IO/FileUtilities.php
@@ -0,0 +1,12 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\IO;
+
+class FileUtilities {
+
+ public static function getExtension (string $filename) : string {
+ return File::from($filename)->getExtension();
+ }
+
+}
diff --git a/src/Identifiers/Random.php b/src/Identifiers/Random.php
new file mode 100644
index 0000000..c04e5e0
--- /dev/null
+++ b/src/Identifiers/Random.php
@@ -0,0 +1,128 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Identifiers;
+
+use Closure;
+use InvalidArgumentException;
+
+use Keruald\OmniTools\Strings\Multibyte\StringUtilities;
+
+class Random {
+
+ /**
+ * @return string 32 random hexadecimal characters
+ */
+ public static function generateHexHash () : string {
+ return UUID::UUIDv4WithoutHyphens();
+ }
+
+ /**
+ * @param string $format A for letters, 1 for digits, e.g. AAA111
+ *
+ * @return string a random string based on the format e.g. ZCK530
+ */
+ public static function generateString (string $format) : string {
+ $randomString = "";
+
+ $len = strlen($format);
+ for ($i = 0 ; $i < $len ; $i++) {
+ $randomString .= self::generateCharacter($format[$i]);
+ }
+
+ return $randomString;
+ }
+
+ /**
+ * @param string $format A for letters, 1 for digits, e.g. A
+ *
+ * @return string a random string based on the format e.g. Z
+ */
+ public static function generateCharacter (string $format) : string {
+ return self::getPicker(self::normalizeFormat($format))();
+ }
+
+
+ public static function generateIdentifier (int $bytes_count) : string {
+ $bytes = random_bytes($bytes_count);
+
+ return StringUtilities::encodeInBase64($bytes);
+ }
+
+ ///
+ /// Helper methods for pickers
+ ///
+
+ public static function normalizeFormat (string $format) : string {
+ $normalizers = self::getNormalizers();
+
+ foreach ($normalizers as $normalizedFormat => $conditionClosure) {
+ if ($conditionClosure($format)) {
+ return (string)$normalizedFormat;
+ }
+ }
+
+ return $format;
+ }
+
+ private static function getNormalizers () : array {
+ /**
+ * <normalized format> => <method which returns true if format matches>
+ */
+
+ return [
+
+ 'A' => function ($format) : bool {
+ return ctype_upper($format);
+ },
+
+ 'a' => function ($format) : bool {
+ return ctype_lower($format);
+ },
+
+ '1' => function ($format) : bool {
+ return is_numeric($format);
+ },
+
+ ];
+ }
+
+ private static function getPickers () : array {
+ return [
+
+ 'A' => function () : string {
+ return Random::pickLetter();
+ },
+
+ 'a' => function () : string {
+ return strtolower(Random::pickLetter());
+ },
+
+ '1' => function () : string {
+ return (string)Random::pickDigit();
+ },
+
+ ];
+ }
+
+ public static function pickLetter () : string {
+ $asciiCode = 65 + mt_rand() % 26;
+
+ return chr($asciiCode);
+ }
+
+ public static function pickDigit (int $base = 10) : int {
+ return mt_rand() % $base;
+ }
+
+ private static function getPicker (string $format) : Closure {
+ $pickers = self::getPickers();
+
+ if (isset($pickers[$format])) {
+ return $pickers[$format];
+ }
+
+ throw new InvalidArgumentException();
+ }
+
+}
diff --git a/src/Identifiers/UUID.php b/src/Identifiers/UUID.php
new file mode 100644
index 0000000..2f05ac1
--- /dev/null
+++ b/src/Identifiers/UUID.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Identifiers;
+
+class UUID {
+
+ const UUID_REGEXP = "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/";
+
+ /**
+ * @return string An RFC 4122 compliant v4 UUID
+ */
+ public static function UUIDv4 () : string {
+ // Code by Andrew Moore
+ // See http://php.net/manual/en/function.uniqid.php#94959
+ // https://www.ietf.org/rfc/rfc4122.txt
+
+ return sprintf(
+ '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+
+ // 32 bits for "time_low"
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+
+ // 16 bits for "time_mid"
+ mt_rand(0, 0xffff),
+
+ // 16 bits for "time_hi_and_version",
+ // four most significant bits holds version number 4
+ mt_rand(0, 0x0fff) | 0x4000,
+
+ // 16 bits, 8 bits for "clk_seq_hi_res",
+ // 8 bits for "clk_seq_low",
+ // two most significant bits holds zero and one for variant DCE1.1
+ mt_rand(0, 0x3fff) | 0x8000,
+
+ // 48 bits for "node"
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
+ );
+ }
+
+ public static function UUIDv4WithoutHyphens () : string {
+ return str_replace("-", "", self::UUIDv4());
+ }
+
+ public static function isUUID ($string) : bool {
+ return (bool)preg_match(self::UUID_REGEXP, $string);
+ }
+
+}
diff --git a/src/Network/IP.php b/src/Network/IP.php
new file mode 100644
index 0000000..e6d0f39
--- /dev/null
+++ b/src/Network/IP.php
@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Network;
+
+class IP {
+
+ public static function isIP (string $ip) : bool {
+ return self::isIPv4($ip) || self::isIPv6($ip);
+ }
+
+ public static function isIPv4 (string $ip) : bool {
+ return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
+ }
+
+ public static function isIPv6 (string $ip) : bool {
+ return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ }
+
+ public static function isLoopback (string $ip) : bool {
+ $ranges = IPRange::getLoopbackRanges();
+ foreach ($ranges as $range) {
+ if ($range->contains($ip)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/Network/IPRange.php b/src/Network/IPRange.php
new file mode 100644
index 0000000..58f2c99
--- /dev/null
+++ b/src/Network/IPRange.php
@@ -0,0 +1,67 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Network;
+
+use Countable;
+use InvalidArgumentException;
+
+abstract class IPRange implements Countable {
+
+ ///
+ /// Constructors
+ ///
+
+ public static function from (string $format) : self {
+ $data = explode("/", $format, 2);
+
+ if (IP::isIPv4($data[0])) {
+ return new IPv4Range($data[0], (int)$data[1]);
+ }
+
+ if (IP::isIPv6($data[0])) {
+ return new IPv6Range($data[0], (int)$data[1]);
+ }
+
+ throw new InvalidArgumentException();
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ public abstract function getBase () : string;
+ public abstract function setBase (string $base) : void;
+
+ public abstract function getNetworkBits () : int;
+ public abstract function setNetworkBits (int $networkBits) : void;
+
+ ///
+ /// Helper methods
+ ///
+
+ public abstract function contains (string $ip) : bool;
+ public abstract function getFirst () : string;
+ public abstract function getLast () : string;
+
+ ///
+ /// Countable methods
+ ///
+
+ public abstract function count () : int;
+
+ ///
+ /// Data sources
+ ///
+
+ /**
+ * @return IPRange[]
+ */
+ public static function getLoopbackRanges () : array {
+ return [
+ "IPv4" => self::from("127.0.0.0/8"),
+ "IPv6" => self::from("::1/128"),
+ ];
+ }
+
+}
diff --git a/src/Network/IPv4Range.php b/src/Network/IPv4Range.php
new file mode 100644
index 0000000..218302b
--- /dev/null
+++ b/src/Network/IPv4Range.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Keruald\OmniTools\Network;
+
+use InvalidArgumentException;
+
+class IPv4Range extends IPRange {
+
+ /**
+ * @var string
+ */
+ private $base;
+
+ /**
+ * @var int
+ */
+ private $networkBits;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $base, int $networkBits) {
+ $this->setBase($base);
+ $this->setNetworkBits($networkBits);
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ /**
+ * @return string
+ */
+ public function getBase () : string {
+ return $this->base;
+ }
+
+ /**
+ * @param string $base
+ */
+ public function setBase (string $base) : void {
+ if (!IP::isIPv4($base)) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->base = $base;
+ }
+
+ /**
+ * @return int
+ */
+ public function getNetworkBits () : int {
+ return $this->networkBits;
+ }
+
+ /**
+ * @param int $networkBits
+ */
+ public function setNetworkBits (int $networkBits) : void {
+ if ($networkBits < 0 || $networkBits > 32) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->networkBits = $networkBits;
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function getFirst () : string {
+ return $this->base;
+ }
+
+ public function getLast () : string {
+ return long2ip(ip2long($this->base) + 2 ** $this->count() - 1);
+ }
+
+ public function contains (string $ip) : bool {
+ if (!IP::isIP($ip)) {
+ throw new InvalidArgumentException;
+ }
+
+ if (!IP::isIPv4($ip)) {
+ return false;
+ }
+
+ $ipAsLong = ip2long($ip);
+ $baseAsLong = ip2long($this->base);
+
+ return $ipAsLong >= $baseAsLong
+ && $ipAsLong <= $baseAsLong + $this->count() - 1;
+ }
+
+ ///
+ /// Countable interface
+ ///
+
+ public function count () : int {
+ return 32 - $this->networkBits;
+ }
+
+}
diff --git a/src/Network/IPv6.php b/src/Network/IPv6.php
new file mode 100644
index 0000000..faa475c
--- /dev/null
+++ b/src/Network/IPv6.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Keruald\OmniTools\Network;
+
+use InvalidArgumentException;
+
+class IPv6 extends IP {
+
+ /**
+ * @var string
+ */
+ private $ip;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $ip) {
+ $this->ip = $ip;
+ }
+
+ public static function from (string $ip) : self {
+ $ipv6 = new self($ip);
+
+ if (!$ipv6->isValid()) {
+ throw new InvalidArgumentException;
+ }
+
+ $ipv6->normalize();
+
+ return $ipv6;
+ }
+
+ public static function fromBinaryBits (array $bits) : self {
+ $fullBits = $bits + array_fill(0, 128, 0);
+ $hextets = [];
+
+ for ($i = 0 ; $i < 8 ; $i++) {
+ // Read 16 bits
+ $slice = implode("", array_slice($fullBits, $i * 16, 16));
+ $hextets[] = base_convert($slice, 2, 16);
+ }
+
+ return self::from(implode(":", $hextets));
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function isValid () : bool {
+ return IP::isIPv6($this->ip);
+ }
+
+ public function increment (int $increment = 1) : self {
+ if ($increment === 0) {
+ return $this;
+ }
+
+ if ($increment < 0) {
+ throw new InvalidArgumentException("This method doesn't support decrementation.");
+ }
+
+ $ipAsNumericBinary = inet_pton($this->ip);
+
+ // See https://gist.github.com/little-apps/88bbd23576008a84e0b6
+ $i = strlen($ipAsNumericBinary) - 1;
+ $remainder = $increment;
+
+ while ($remainder > 0 && $i >= 0) {
+ $sum = ord($ipAsNumericBinary[$i]) + $remainder;
+ $remainder = $sum / 256;
+ $ipAsNumericBinary[$i] = chr($sum % 256);
+
+ --$i;
+ }
+
+ $this->ip = inet_ntop($ipAsNumericBinary);
+ return $this;
+ }
+
+ public function normalize () : self {
+ $this->ip = inet_ntop(inet_pton($this->ip));
+ return $this;
+ }
+
+ public function isNormalized() : bool {
+ return $this->ip === inet_ntop(inet_pton($this->ip));
+ }
+
+ ///
+ /// Magic methods
+ ///
+
+ public function __toString () : string {
+ return $this->ip;
+ }
+}
diff --git a/src/Network/IPv6Range.php b/src/Network/IPv6Range.php
new file mode 100644
index 0000000..2838c09
--- /dev/null
+++ b/src/Network/IPv6Range.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Keruald\OmniTools\Network;
+
+use InvalidArgumentException;
+
+class IPv6Range extends IPRange {
+
+ /**
+ * @var string
+ */
+ private $base;
+
+ /**
+ * @var int
+ */
+ private $networkBits;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $base, int $networkBits) {
+ $this->setBase($base);
+ $this->setNetworkBits($networkBits);
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ /**
+ * @return string
+ */
+ public function getBase () : string {
+ return $this->base;
+ }
+
+ /**
+ * @param string $base
+ */
+ public function setBase (string $base) : void {
+ if (!IP::isIPv6($base)) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->base = $base;
+ }
+
+ /**
+ * @return int
+ */
+ public function getNetworkBits () : int {
+ return $this->networkBits;
+ }
+
+ /**
+ * @param int $networkBits
+ */
+ public function setNetworkBits (int $networkBits) : void {
+ if ($networkBits < 0 || $networkBits > 128) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->networkBits = $networkBits;
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function getFirst () : string {
+ return $this->base;
+ }
+
+ public function getLast () : string {
+ if ($this->count() === 0) {
+ return $this->base;
+ }
+
+ $base = inet_pton($this->getFirst());
+ $mask = inet_pton($this->getInverseMask());
+ return inet_ntop($base | $mask);
+ }
+
+ private function getInverseMask () : string {
+ $bits = array_fill(0, $this->networkBits, 0) + array_fill(0, 128, 1);
+
+ return (string)IPv6::fromBinaryBits($bits);
+ }
+
+ public function contains (string $ip) : bool {
+ if (!IP::isIP($ip)) {
+ throw new InvalidArgumentException;
+ }
+
+ if (IP::isIPv4($ip)) {
+ $ip = "::ffff:" . $ip; // IPv4-mapped IPv6 address
+ }
+
+ $baseAsNumericBinary = inet_pton($this->getFirst());
+ $lastAsNumericBinary = inet_pton($this->getLast());
+ $ipAsNumericBinary = inet_pton($ip);
+
+ return strlen($ipAsNumericBinary) == strlen($baseAsNumericBinary)
+ && $ipAsNumericBinary >= $baseAsNumericBinary
+ && $ipAsNumericBinary <= $lastAsNumericBinary;
+ }
+
+ ///
+ /// Countable interface
+ ///
+
+ public function count () : int {
+ return 128 - $this->networkBits;
+ }
+
+}
diff --git a/src/OS/CurrentOS.php b/src/OS/CurrentOS.php
new file mode 100644
index 0000000..f957704
--- /dev/null
+++ b/src/OS/CurrentOS.php
@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\OS;
+
+class CurrentOS {
+
+ public static function isWindows () : bool {
+ return PHP_OS === 'CYGWIN' || self::isPureWindows();
+ }
+
+ public static function isPureWindows () : bool {
+ return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
+ }
+
+}
diff --git a/src/OS/CurrentProcess.php b/src/OS/CurrentProcess.php
new file mode 100644
index 0000000..76d2c01
--- /dev/null
+++ b/src/OS/CurrentProcess.php
@@ -0,0 +1,59 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\OS;
+
+class CurrentProcess {
+
+ /**
+ * Determines if the current process, ie the PHP interpreter,
+ * runs as root on UNIX systems or in elevated mode on Windows.
+ *
+ * Cygwin processes are considered as Windows processes.
+ */
+ public static function isPrivileged () : bool {
+ if (CurrentOS::isWindows()) {
+ // `net session` is known to only work as privileged process.
+ // To wrap in cmd allows avoiding /dev/null for Cygwin,
+ // or $null when invoked from PowerShell. NUL: will always be used.
+ exec('cmd /C "net session >NUL 2>&1"', $_, $exitCode);
+
+ return $exitCode === 0;
+ }
+
+ if (!function_exists('posix_geteuid')) {
+ // POSIX PHP functions aren't always available, e.g. on FreeBSD
+ // In such cases, `id` will probably be available.
+ return trim((string)shell_exec('id -u')) === '0';
+ }
+
+ return posix_geteuid() === 0;
+ }
+
+ /**
+ * Determines the username of the current process, ie the PHP interpreter.
+ */
+ public static function getUsername () : string {
+ if (!extension_loaded("posix")) {
+ // POSIX PHP functions aren't always available.
+ // In such cases, `whoami` will probably be available.
+ $username = trim((string)shell_exec("whoami"));
+
+ if (CurrentOS::isWindows() && str_contains('\\', $username)) {
+ // Perhaps the domain information could be useful if the user
+ // can belong to an AD domain, to disambiguate between local
+ // accounts and AD accounts.
+ //
+ // If not attached to a domain, the PC hostname is used.
+ //
+ // We return only the username part to be coherent with UNIX.
+ return explode('\\', $username, 2)[1];
+ }
+
+ return $username;
+ }
+
+ return posix_getpwuid(posix_geteuid())["name"];
+ }
+
+}
diff --git a/src/OS/Environment.php b/src/OS/Environment.php
new file mode 100644
index 0000000..ce0fc86
--- /dev/null
+++ b/src/OS/Environment.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Keruald\OmniTools\OS;
+
+use InvalidArgumentException;
+
+class Environment {
+
+ public static function has (string $key) : bool {
+ return array_key_exists($key, $_ENV)
+ || array_key_exists($key, $_SERVER);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public static function get (string $key) : string {
+ if (!self::has($key)) {
+ throw new InvalidArgumentException("Key not found: $key");
+ }
+
+ return $_ENV[$key] ?? $_SERVER[$key];
+ }
+
+ public static function getOr (string $key, string $default) : string {
+ return $_ENV[$key] ?? $_SERVER[$key] ?? $default;
+ }
+
+}
diff --git a/src/Reflection/CallableElement.php b/src/Reflection/CallableElement.php
new file mode 100644
index 0000000..3a99449
--- /dev/null
+++ b/src/Reflection/CallableElement.php
@@ -0,0 +1,75 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Reflection;
+
+use Closure;
+use http\Exception\InvalidArgumentException;
+use ReflectionException;
+use ReflectionFunction;
+use ReflectionFunctionAbstract;
+use ReflectionMethod;
+
+class CallableElement {
+
+ private ReflectionFunctionAbstract $callable;
+
+ /**
+ * @throws ReflectionException
+ */
+ public function __construct (callable $callable) {
+ $this->callable = self::getReflectionFunction($callable);
+ }
+
+ /**
+ * @throws ReflectionException
+ */
+ private static function getReflectionFunction (callable $callable)
+ : ReflectionFunctionAbstract {
+
+ ///
+ /// Functions
+ ///
+
+ if ($callable instanceof Closure) {
+ return new ReflectionFunction($callable);
+ }
+
+ ///
+ /// Objets and methods
+ ///
+
+ if (is_array($callable)) {
+ return new ReflectionMethod($callable[0], $callable[1]);
+ }
+
+ if (is_object($callable)) {
+ // If __invoke() doesn't exist, the objet isn't a callable.
+ // Calling this method with such object would throw a TypeError
+ // before reaching this par of the code, so it is safe to assume
+ // we can correctly call it.
+ return new ReflectionMethod([$callable, '__invoke']);
+ }
+
+ ///
+ /// Hybrid cases
+ ///
+
+ if (is_string($callable)) {
+ if (!str_contains($callable, "::")) {
+ return new ReflectionFunction($callable);
+ }
+
+ return new ReflectionMethod($callable);
+ }
+
+ throw new InvalidArgumentException(
+ "Callable not recognized: " . gettype($callable)
+ );
+ }
+
+ public function countArguments () : int {
+ return $this->callable->getNumberOfParameters();
+ }
+
+}
diff --git a/src/Reflection/CodeFile.php b/src/Reflection/CodeFile.php
new file mode 100644
index 0000000..bf344ee
--- /dev/null
+++ b/src/Reflection/CodeFile.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Keruald\OmniTools\Reflection;
+
+use Keruald\OmniTools\IO\File;
+
+class CodeFile extends File {
+
+ ///
+ /// Include methods
+ ///
+
+ public function tryInclude () : bool {
+ if (!$this->canBeIncluded()) {
+ return false;
+ }
+
+ include($this->getPath());
+
+ return true;
+ }
+
+ public function canBeIncluded () : bool {
+ return $this->exists() && $this->isReadable();
+ }
+
+}
diff --git a/src/Registration/Autoloader.php b/src/Registration/Autoloader.php
new file mode 100644
index 0000000..c3d6296
--- /dev/null
+++ b/src/Registration/Autoloader.php
@@ -0,0 +1,29 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Registration;
+
+class Autoloader {
+
+ ///
+ /// PSR-4
+ ///
+
+ public static function registerPSR4 (string $namespace, string $path) : void {
+ $loader = new PSR4\Autoloader($namespace, $path);
+ $loader->register();
+ }
+
+ ///
+ /// Methods to register OmniTools library
+ ///
+
+ public static function selfRegister () : void {
+ self::registerPSR4("Keruald\\OmniTools\\", self::getLibraryPath());
+ }
+
+ public static function getLibraryPath () : string {
+ return dirname(__DIR__);
+ }
+
+}
diff --git a/src/Registration/PSR4/Autoloader.php b/src/Registration/PSR4/Autoloader.php
new file mode 100644
index 0000000..641dbe9
--- /dev/null
+++ b/src/Registration/PSR4/Autoloader.php
@@ -0,0 +1,53 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Registration\PSR4;
+
+use Keruald\OmniTools\Reflection\CodeFile;
+
+final class Autoloader {
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var string
+ */
+ private $namespace;
+
+ /**
+ * @var string The base path where files for this namespace are located
+ */
+ private $path;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (string $namespace, string $path) {
+ $this->namespace = $namespace;
+ $this->path = $path;
+ }
+
+ ///
+ /// Public methods
+ ///
+
+ public function getSolver (string $class) : Solver {
+ return new Solver($this->namespace, $this->path, $class);
+ }
+
+ public function register () : void {
+ spl_autoload_register(function ($class) {
+ $solver = $this->getSolver($class);
+
+ if (!$solver->canResolve()) {
+ return;
+ }
+
+ CodeFile::from($solver->resolve())->tryInclude();
+ });
+ }
+
+}
diff --git a/src/Registration/PSR4/PSR4Namespace.php b/src/Registration/PSR4/PSR4Namespace.php
new file mode 100644
index 0000000..dc096df
--- /dev/null
+++ b/src/Registration/PSR4/PSR4Namespace.php
@@ -0,0 +1,76 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Registration\PSR4;
+
+use Keruald\OmniTools\IO\Directory;
+use Keruald\OmniTools\IO\File;
+
+class PSR4Namespace {
+
+ public function __construct (
+ public string $namespacePrefix,
+ public string $baseDirectory,
+ ) {
+ }
+
+ ///
+ /// Auto-discovery
+ ///
+
+ /**
+ * Discover classes in the namespace folder following PSR-4 convention,
+ * directly at top-level, ignoring subdirectories.
+ *
+ * @see discoverRecursive
+ * @return string[]
+ */
+ public function discover () : array {
+ $files = (new Directory($this->baseDirectory))
+ ->glob("*.php");
+
+ return array_map(function (File $file) {
+ return $this->namespacePrefix
+ . "\\" . $file->getFileNameWithoutExtension();
+ }, $files);
+ }
+
+ /**
+ * Discover classes in the namespace folder following PSR-4 convention,
+ * including all subfolders.
+ *
+ * @return string[]
+ */
+ public function discoverRecursive () : array {
+ $classes = $this->discover();
+
+ $subDirectories = (new Directory($this->baseDirectory))
+ ->getSubdirectories();
+
+ foreach ($subDirectories as $dir) {
+ $ns = new PSR4Namespace(
+ $this->namespacePrefix . "\\" . $dir->getDirectoryName(),
+ $dir->getPath(),
+ );
+
+ array_push($classes, ...$ns->discoverRecursive());
+ }
+
+ return $classes;
+ }
+
+ /**
+ * Discover classes for a specific namespace in a specific folder,
+ * following the PSR-4 convention, including all subfolders.
+ *
+ * @return string[]
+ */
+ public static function discoverAllClasses (
+ string $namespacePrefix,
+ string $baseDirectory
+ ) : array {
+ $ns = new PSR4Namespace($namespacePrefix, $baseDirectory);
+ return $ns->discoverRecursive();
+ }
+
+}
diff --git a/src/Registration/PSR4/Solver.php b/src/Registration/PSR4/Solver.php
new file mode 100644
index 0000000..a3d98fa
--- /dev/null
+++ b/src/Registration/PSR4/Solver.php
@@ -0,0 +1,66 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Registration\PSR4;
+
+use Keruald\OmniTools\Strings\Multibyte\StringUtilities;
+
+final class Solver {
+
+ /**
+ * @var string
+ */
+ private $namespace;
+
+ /**
+ * @var string The base path for the namespace
+ */
+ private $path;
+
+ /**
+ * @var string The fully qualified class name
+ */
+ private $class;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $namespace, string $path, string $class) {
+ $this->namespace = $namespace;
+ $this->path = $path;
+ $this->class = $class;
+ }
+
+ ///
+ /// Resolve methods
+ ///
+
+ public function resolve () : string {
+ return $this->path
+ . '/'
+ . $this->getRelativePath();
+ }
+
+ public function canResolve () : bool {
+ return StringUtilities::startsWith($this->class, $this->namespace);
+ }
+
+ public static function getPathFor (string $name) : string {
+ return str_replace("\\", "/", $name) . '.php';
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ private function getRelativePath () : string {
+ return self::getPathFor($this->getLocalClassName());
+ }
+
+ private function getLocalClassName () : string {
+ $len = strlen($this->namespace);
+ return substr($this->class, $len);
+ }
+
+}
diff --git a/src/Strings/Multibyte/OmniString.php b/src/Strings/Multibyte/OmniString.php
new file mode 100644
index 0000000..5e94aa3
--- /dev/null
+++ b/src/Strings/Multibyte/OmniString.php
@@ -0,0 +1,125 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Strings\Multibyte;
+
+use Keruald\OmniTools\Collections\Vector;
+
+class OmniString {
+
+ use WithEncoding;
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var string
+ */
+ private $value;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (string $value = '', string $encoding = '') {
+ $this->value = $value;
+ $this->setEncoding($encoding ?: "UTF-8");
+ }
+
+ ///
+ /// Magic methods
+ ///
+
+ public function __toString() : string {
+ return $this->value;
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ public function pad(
+ int $padLength = 0,
+ string $padString = ' ',
+ int $padType = STR_PAD_RIGHT
+ ) : string {
+ return (new StringPad)
+ ->setInput($this->value)
+ ->setEncoding($this->encoding)
+ ->setPadLength($padLength)
+ ->setPadString($padString)
+ ->setPadType($padType)
+ ->pad();
+ }
+
+ public function startsWith (string $start) : bool {
+ return str_starts_with($this->value, $start);
+ }
+
+ public function endsWith (string $end) : bool {
+ return str_ends_with($this->value, $end);
+ }
+
+ public function len () : int {
+ return mb_strlen($this->value, $this->encoding);
+ }
+
+ public function getChars () : array {
+ $chars = [];
+
+ $len = $this->len();
+ for ($i = 0 ; $i < $len ; $i++) {
+ $chars[] = mb_substr($this->value, $i, 1, $this->encoding);
+ }
+
+ return $chars;
+ }
+
+ public function getBigrams () : array {
+ $bigrams = [];
+
+ $len = $this->len();
+ for ($i = 0 ; $i < $len - 1 ; $i++) {
+ $bigrams[] = mb_substr($this->value, $i, 2, $this->encoding);
+ }
+
+ return $bigrams;
+ }
+
+ ///
+ /// Transformation methods
+ ///
+
+ public function explode (string $delimiter,
+ int $limit = PHP_INT_MAX) : Vector {
+ if ($delimiter === "") {
+ if ($limit < 0) {
+ return new Vector;
+ }
+
+ return new Vector([$this->value]);
+ }
+
+ return new Vector(explode($delimiter, $this->value, $limit));
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ /**
+ * @return string
+ */
+ public function getValue () : string {
+ return $this->value;
+ }
+
+ /**
+ * @param string $value
+ */
+ public function setValue (string $value) : void {
+ $this->value = $value;
+ }
+
+}
diff --git a/src/Strings/Multibyte/StringPad.php b/src/Strings/Multibyte/StringPad.php
new file mode 100644
index 0000000..41c8de5
--- /dev/null
+++ b/src/Strings/Multibyte/StringPad.php
@@ -0,0 +1,213 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Strings\Multibyte;
+
+use InvalidArgumentException;
+
+class StringPad {
+
+ use WithEncoding;
+
+ ///
+ /// Private members for user-defined or default values
+ ///
+
+ /**
+ * @var string
+ */
+ private $input;
+
+ /**
+ * @var int
+ */
+ private $padLength;
+
+ /**
+ * @var string
+ */
+ private $padString;
+
+ /**
+ * @var int
+ */
+ private $padType;
+
+ ///
+ /// Private members for computed values
+ ///
+
+ /**
+ * @var string
+ */
+ private $repeatedString;
+
+ /**
+ * @var float
+ */
+ private $targetLength;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (
+ string $input = '',
+ int $padLength = 0,
+ string $padString = ' ',
+ int $padType = STR_PAD_RIGHT,
+ string $encoding = ''
+ ) {
+ $this->input = $input;
+ $this->padLength = $padLength;
+ $this->padString = $padString;
+
+ $this->setPadType($padType);
+ $this->setEncoding($encoding ?: mb_internal_encoding());
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function getInput () : string {
+ return $this->input;
+ }
+
+ public function setInput (string $input) : StringPad {
+ $this->input = $input;
+
+ return $this;
+ }
+
+ public function getPadLength () : int {
+ return $this->padLength;
+ }
+
+ public function setPadLength (int $padLength) : StringPad {
+ $this->padLength = $padLength;
+
+ return $this;
+ }
+
+ public function getPadString () : string {
+ return $this->padString;
+ }
+
+ public function setPadString (string $padString) : StringPad {
+ $this->padString = $padString;
+
+ return $this;
+ }
+
+ public function getPadType () : int {
+ return $this->padType;
+ }
+
+ public function setPadType (int $padType) : StringPad {
+ if (!self::isValidPadType($padType)) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->padType = $padType;
+
+ return $this;
+ }
+
+ ///
+ /// Helper methods to get and set
+ ///
+
+ public function setBothPad () : StringPad {
+ $this->padType = STR_PAD_BOTH;
+
+ return $this;
+ }
+
+ public function setLeftPad () : StringPad {
+ $this->padType = STR_PAD_LEFT;
+
+ return $this;
+ }
+
+ public function setRightPad () : StringPad {
+ $this->padType = STR_PAD_RIGHT;
+
+ return $this;
+ }
+
+ public static function isValidPadType (int $padType) : bool {
+ return $padType >= 0 && $padType <= 2;
+ }
+
+ ///
+ /// Pad methods
+ ///
+
+ public function pad () : string {
+ $this->computeLengths();
+ return $this->getLeftPad() . $this->input . $this->getRightPad();
+ }
+
+ private function getLeftPad () : string {
+ if (!$this->hasPaddingBefore()) {
+ return '';
+ }
+
+ $length = (int)floor($this->targetLength);
+ return mb_substr($this->repeatedString, 0, $length, $this->encoding);
+ }
+
+ private function getRightPad () : string {
+ if (!$this->hasPaddingAfter()) {
+ return '';
+ }
+
+ $length = (int)ceil($this->targetLength);
+ return mb_substr($this->repeatedString, 0, $length, $this->encoding);
+ }
+
+ private function computeLengths () : void {
+ $this->targetLength = $this->computeNeededPadLength();
+ $this->repeatedString = $this->computeRepeatedString();
+ }
+
+ private function computeRepeatedString () : string {
+ // Inspired by Ronald Ulysses Swanson method
+ // https://stackoverflow.com/a/27194169/1930997
+ // who followed the str_pad PHP implementation.
+
+ $strToRepeatLength = mb_strlen($this->padString, $this->encoding);
+ $repeatTimes = (int)ceil($this->targetLength / $strToRepeatLength);
+
+ // Safe if used with valid Unicode sequences (any charset).
+ return str_repeat($this->padString, max(0, $repeatTimes));
+ }
+
+ private function computeNeededPadLength () : float {
+ $length = $this->padLength - mb_strlen($this->input, $this->encoding);
+
+ if ($this->hasPaddingBeforeAndAfter()) {
+ return $length / 2;
+ }
+
+ return $length;
+ }
+
+ private function hasPaddingBefore () : bool {
+ return $this->padType === STR_PAD_LEFT || $this->padType === STR_PAD_BOTH;
+ }
+
+ private function hasPaddingAfter () : bool {
+ return $this->padType === STR_PAD_RIGHT || $this->padType === STR_PAD_BOTH;
+ }
+
+ private function hasPaddingBeforeAndAfter () : bool {
+ return
+ $this->padType === STR_PAD_BOTH
+ ||
+ ($this->padType === STR_PAD_LEFT && $this->padType === STR_PAD_RIGHT)
+ ;
+ }
+
+}
diff --git a/src/Strings/Multibyte/StringUtilities.php b/src/Strings/Multibyte/StringUtilities.php
new file mode 100644
index 0000000..7702467
--- /dev/null
+++ b/src/Strings/Multibyte/StringUtilities.php
@@ -0,0 +1,97 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Strings\Multibyte;
+
+class StringUtilities {
+
+ /**
+ * Pads a multibyte string to a certain length with another string
+ *
+ * @param string $input the input string
+ * @param int $padLength the target string size
+ * @param string $padString the padding characters (optional, default is space)
+ * @param int $padType STR_PAD_RIGHT, STR_PAD_LEFT, or STR_PAD_BOTH (optional, default is STR_PAD_RIGHT)
+ * @param string $encoding the character encoding (optional)
+ *
+ * @return string the padded string
+ *
+ */
+ public static function pad (
+ string $input,
+ int $padLength,
+ string $padString = ' ',
+ int $padType = STR_PAD_RIGHT,
+ string $encoding = ''
+ ) : string {
+ return (new StringPad)
+ ->setInput($input)
+ ->setPadLength($padLength)
+ ->setPadString($padString)
+ ->setPadType($padType)
+ ->setEncoding($encoding ?: mb_internal_encoding())
+ ->pad();
+ }
+
+ public static function isSupportedEncoding (string $encoding) : bool {
+ foreach (mb_list_encodings() as $supportedEncoding) {
+ if ($encoding === $supportedEncoding) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @deprecated Since PHP 8.0, we can replace by \str_starts_with
+ */
+ public static function startsWith (string $string, string $start) : bool {
+ $length = mb_strlen($start);
+ return mb_substr($string, 0, $length) === $start;
+ }
+
+ /**
+ * @deprecated Since PHP 8.0, we can replace by \str_ends_with
+ */
+ public static function endsWith (string $string, string $end) : bool {
+ $length = mb_strlen($end);
+ return $length === 0 || mb_substr($string, -$length) === $end;
+ }
+
+ /**
+ * @deprecated Since PHP 8.0, we can replace by \str_contains
+ */
+ public static function contains (string $string, string $needle) : bool {
+ return str_contains($string, $needle);
+ }
+
+ /**
+ * Encode a string using a variant of the MIME base64 compatible with URLs.
+ *
+ * The '+' and '/' characters used in base64 are replaced by '-' and '_'.
+ * The '=' padding is removed.
+ *
+ * @param string $string The string to encode
+ * @return string The encoded string
+ */
+ public static function encodeInBase64 (string $string) : string {
+ return str_replace(
+ ['+', '/', '='],
+ ['-', '_', ''],
+ base64_encode($string)
+ );
+ }
+
+ /**
+ * Decode a string encoded with StringUtilities::encodeInBase64
+ *
+ * @param string $string The string to decode
+ * @return string The decoded string
+ */
+ public static function decodeFromBase64 (string $string) : string {
+ $toDecode = str_replace(['-', '_'], ['+', '/'], $string);
+ return base64_decode($toDecode);
+ }
+
+}
diff --git a/src/Strings/Multibyte/WithEncoding.php b/src/Strings/Multibyte/WithEncoding.php
new file mode 100644
index 0000000..979eb2c
--- /dev/null
+++ b/src/Strings/Multibyte/WithEncoding.php
@@ -0,0 +1,29 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Strings\Multibyte;
+
+use InvalidArgumentException;
+
+trait WithEncoding {
+
+ /**
+ * @var string
+ */
+ private $encoding;
+
+ public function getEncoding () : string {
+ return $this->encoding;
+ }
+
+ public function setEncoding (string $encoding) : self {
+ if (!StringUtilities::isSupportedEncoding($encoding)) {
+ throw new InvalidArgumentException;
+ }
+
+ $this->encoding = $encoding;
+
+ return $this;
+ }
+
+}
diff --git a/src/Strings/SorensenDiceCoefficient.php b/src/Strings/SorensenDiceCoefficient.php
new file mode 100644
index 0000000..040f352
--- /dev/null
+++ b/src/Strings/SorensenDiceCoefficient.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Strings;
+
+use Keruald\OmniTools\Strings\Multibyte\OmniString;
+
+class SorensenDiceCoefficient {
+
+ /**
+ * @var string[]
+ */
+ private $x;
+
+ /**
+ * @var string[]
+ */
+ private $y;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (string $left, string $right) {
+ $this->x = (new OmniString($left))->getBigrams();
+ $this->y = (new OmniString($right))->getBigrams();
+ }
+
+ public static function computeFor(string $left, string $right) : float {
+ $instance = new self($left, $right);
+
+ return $instance->compute();
+ }
+
+ ///
+ /// Sørensen formula
+ ///
+
+ public function compute() : float {
+ return 2 * $this->countIntersect()
+ /
+ $this->countCharacters();
+ }
+
+ private function countIntersect () : int {
+ $intersect = array_intersect($this->x, $this->y);
+
+ return count($intersect);
+ }
+
+ private function countCharacters () : int {
+ return count($this->x) + count($this->y);
+ }
+
+}
diff --git a/tests/Collections/ArrayUtilitiesTest.php b/tests/Collections/ArrayUtilitiesTest.php
new file mode 100644
index 0000000..737ea38
--- /dev/null
+++ b/tests/Collections/ArrayUtilitiesTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\ArrayUtilities;
+
+use PHPUnit\Framework\TestCase;
+
+class ArrayUtilitiesTest extends TestCase {
+
+ /**
+ * @dataProvider provideIntegersArray
+ */
+ public function testToIntegers ($expected, $toConvert) {
+ $this->assertEquals($expected, ArrayUtilities::toIntegers($toConvert));
+ }
+
+ public function provideIntegersArray () : iterable {
+ yield [[1, 2, 3], ["1", "2", "3"]];
+
+ yield [[1, 2, 3], [1, 2, 3]];
+ yield [[], []];
+ }
+}
diff --git a/tests/Collections/HashMapTest.php b/tests/Collections/HashMapTest.php
new file mode 100644
index 0000000..8d9e214
--- /dev/null
+++ b/tests/Collections/HashMapTest.php
@@ -0,0 +1,329 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\HashMap;
+
+use PHPUnit\Framework\TestCase;
+
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+
+class HashMapTest extends TestCase {
+
+ ///
+ /// Test set up
+ ///
+
+ private HashMap $map;
+
+ const MAP_CONTENT = [
+ // Some sci-fi civilizations and author
+ "The Culture" => "Iain Banks",
+ "Radchaai Empire" => "Ann Leckie",
+ "Barrayar" => "Lois McMaster Bujold",
+ "Hainish" => "Ursula K. Le Guin",
+ ];
+
+ protected function setUp () : void {
+ $this->map = new HashMap(self::MAP_CONTENT);
+ }
+
+ ///
+ /// Constructors
+ ///
+
+ public function testConstructorWithArray () {
+ $this->assertSame(self::MAP_CONTENT, $this->map->toArray());
+ }
+
+ public function testConstructorWithTraversable () {
+ $expected = [
+ "color" => "blue",
+ "material" => "glass",
+ "shape" => "sphere",
+ ];
+
+ $iterable = new class implements IteratorAggregate {
+ function getIterator () : Traversable {
+ yield "color" => "blue";
+ yield "material" => "glass";
+ yield "shape" => "sphere";
+ }
+ };
+
+ $map = new HashMap($iterable);
+ $this->assertSame($expected, $map->toArray());
+ }
+
+ public function testFrom () {
+ $map = HashMap::from(self::MAP_CONTENT);
+ $this->assertSame(self::MAP_CONTENT, $map->toArray());
+ }
+
+ ///
+ /// Getters and setters
+ ///
+
+ public function testGet () {
+ $this->assertSame("Iain Banks", $this->map->get("The Culture"));
+ }
+
+ public function testGetWhenKeyIsNotFound () {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->map->get("Quuxians");
+ }
+
+ public function testGetOr () {
+ $actual = $this->map
+ ->getOr("The Culture", "Another author");
+
+ $this->assertSame("Iain Banks", $actual);
+ }
+
+ public function testGetOrWhenKeyIsNotFound () {
+ $actual = $this->map
+ ->getOr("Quuxians", "Another author");
+
+ $this->assertSame("Another author", $actual);
+ }
+
+ public function testSetWithNewKey () {
+ $this->map->set("Thélème", "François Rabelais");
+
+ $this->assertSame("François Rabelais",
+ $this->map->get("Thélème"));
+ }
+
+ public function testSetWithExistingKey () {
+ $this->map->set("The Culture", "Iain M. Banks");
+
+ $this->assertSame("Iain M. Banks",
+ $this->map->get("The Culture"));
+ }
+
+ public function testUnset() {
+ $this->map->unset("The Culture");
+ $this->assertFalse($this->map->contains("Iain Banks"));
+ }
+
+ public function testUnsetNotExistingKey() {
+ $this->map->unset("Not existing");
+ $this->assertEquals(4, $this->map->count());
+ }
+
+ public function testHas () {
+ $this->assertTrue($this->map->has("The Culture"));
+ $this->assertFalse($this->map->has("Not existing key"));
+ }
+
+ public function testContains () {
+ $this->assertTrue($this->map->contains("Iain Banks"));
+ $this->assertFalse($this->map->contains("Not existing value"));
+ }
+
+ ///
+ /// Collection method
+ ///
+
+ public function testCount () {
+ $this->assertSame(4, $this->map->count());
+ }
+
+ public function testClear () {
+ $this->map->clear();
+ $this->assertSame(0, $this->map->count());
+ }
+
+ public function testIsEmpty () : void {
+ $this->map->clear();
+
+ $this->assertTrue($this->map->isEmpty());
+ }
+
+ public function testMerge () {
+ $iterable = [
+ "The Culture" => "Iain M. Banks", // existing key
+ "Thélème" => "François Rabelais", // new key
+ ];
+
+ $expected = [
+ // The original map
+ "The Culture" => "Iain Banks", // Old value should be kept
+ "Radchaai Empire" => "Ann Leckie",
+ "Barrayar" => "Lois McMaster Bujold",
+ "Hainish" => "Ursula K. Le Guin",
+
+ // The entries with a new key
+ "Thélème" => "François Rabelais",
+ ];
+
+ $this->map->merge($iterable);
+ $this->assertSame($expected, $this->map->toArray());
+ }
+
+ public function testUpdate () {
+ $iterable = [
+ "The Culture" => "Iain M. Banks", // existing key
+ "Thélème" => "François Rabelais", // new key
+ ];
+
+ $expected = [
+ // The original map
+ "The Culture" => "Iain M. Banks", // Old value should be updated
+ "Radchaai Empire" => "Ann Leckie",
+ "Barrayar" => "Lois McMaster Bujold",
+ "Hainish" => "Ursula K. Le Guin",
+
+ // The entries with a new key
+ "Thélème" => "François Rabelais",
+ ];
+
+ $this->map->update($iterable);
+ $this->assertSame($expected, $this->map->toArray());
+ }
+
+ public function testToArray () {
+ $this->assertEquals(self::MAP_CONTENT, $this->map->toArray());
+ }
+
+ ///
+ /// High order functions
+ ///
+
+ public function testMap () {
+ $callback = function ($value) {
+ return "author='" . $value . "'";
+ };
+
+ $expected = [
+ "The Culture" => "author='Iain Banks'",
+ "Radchaai Empire" => "author='Ann Leckie'",
+ "Barrayar" => "author='Lois McMaster Bujold'",
+ "Hainish" => "author='Ursula K. Le Guin'",
+ ];
+
+ $actual = $this->map->map($callback)->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testMapKeys () {
+ $callback = function ($key) {
+ return "civ::" . $key;
+ };
+
+ $expected = [
+ // Some sci-fi civilizations and author
+ "civ::The Culture" => "Iain Banks",
+ "civ::Radchaai Empire" => "Ann Leckie",
+ "civ::Barrayar" => "Lois McMaster Bujold",
+ "civ::Hainish" => "Ursula K. Le Guin",
+ ];
+
+ $actual = $this->map->mapKeys($callback)->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testFilter () {
+ // Let's filter to keep names with 3 parts or more
+
+ $callback = function ($value) : bool {
+ return str_word_count($value) > 2;
+ };
+
+ $expected = [
+ // Some sci-fi civilizations and author
+ "Barrayar" => "Lois McMaster Bujold",
+ "Hainish" => "Ursula K. Le Guin",
+ ];
+
+ $actual = $this->map->filter($callback)->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testFilterWithKeyValueCallback () {
+ // Let's find civilization AND author with e inside
+
+ $expected = [
+ // Some sci-fi civilizations and author
+ "Radchaai Empire" => "Ann Leckie",
+ ];
+
+ $callback = function ($key, $value) : bool {
+ return str_contains($key, "e") && str_contains($value, "e");
+ };
+
+ $actual = $this->map->filter($callback)->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testFilterWithCallbackWithoutArgument() {
+ $this->expectException(InvalidArgumentException::class);
+
+ $callback = function () : bool { // No argument
+ return true;
+ };
+
+ $this->map->filter($callback);
+ }
+
+ public function testFilterKeys () {
+ // Let's filter to keep short civilization names
+
+ $callback = function ($key) : bool {
+ return str_word_count($key) == 1;
+ };
+
+ $expected = [
+ // Some sci-fi civilizations and author
+ "Barrayar" => "Lois McMaster Bujold",
+ "Hainish" => "Ursula K. Le Guin",
+ ];
+
+ $actual = $this->map->filterKeys($callback)->toArray();
+ $this->assertEquals($expected, $actual);
+ }
+
+ ///
+ /// ArrayAccess
+ ///
+
+ public function testOffsetExists () : void {
+ $this->assertTrue(isset($this->map["The Culture"]));
+ $this->assertFalse(isset($this->map["Not existing"]));
+ }
+
+ public function testOffsetSetWithoutOffset () : void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->map[] = "Another Author";
+ }
+
+ public function testOffsetSet () : void {
+ $this->map["The Culture"] = "Iain M. Banks";
+ $this->assertEquals("Iain M. Banks", $this->map["The Culture"]);
+ }
+
+ public function testOffsetUnset () : void {
+ unset($this->map["Barrayar"]);
+
+ $expected = [
+ "The Culture" => "Iain Banks",
+ "Radchaai Empire" => "Ann Leckie",
+ // "Barrayar" => "Lois McMaster Bujold", UNSET ENTRY
+ "Hainish" => "Ursula K. Le Guin",
+ ];
+
+ $this->assertEquals($expected, $this->map->toArray());
+ }
+
+ ///
+ /// IteratorAggregate
+ ///
+
+ public function testGetIterator () : void {
+ $this->assertEquals(self::MAP_CONTENT, iterator_to_array($this->map));
+ }
+
+}
diff --git a/tests/Collections/TraversableUtilitiesTest.php b/tests/Collections/TraversableUtilitiesTest.php
new file mode 100644
index 0000000..e2056fa
--- /dev/null
+++ b/tests/Collections/TraversableUtilitiesTest.php
@@ -0,0 +1,116 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\TraversableUtilities;
+
+use PHPUnit\Framework\TestCase;
+
+use Countable;
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+
+class TraversableUtilitiesTest extends TestCase {
+
+ /**
+ * @dataProvider provideCountables
+ */
+ public function testCount ($expectedCount, $countable) {
+ $this->assertEquals(
+ $expectedCount, TraversableUtilities::count($countable)
+ );
+ }
+
+ /**
+ * @dataProvider provideNotCountables
+ */
+ public function testCountWithNotCountables ($notCountable) {
+ $this->expectException("TypeError");
+ TraversableUtilities::count($notCountable);
+ }
+
+ /**
+ * @dataProvider providePureCountables
+ */
+ public function testIsCountable ($countable) {
+ $this->assertTrue(TraversableUtilities::isCountable($countable));
+ }
+
+ /**
+ * @dataProvider provideIterableAndFirst
+ */
+ public function testIsFirst($expected, $iterable) {
+ $this->assertEquals($expected, TraversableUtilities::first($iterable));
+ }
+
+ public function testIsFirstWithEmptyCollection() {
+ $this->expectException(InvalidArgumentException::class);
+
+ TraversableUtilities::first([]);
+ }
+
+ /**
+ * @dataProvider provideIterableAndFirst
+ */
+ public function testIsFirstOr($expected, $iterable) {
+ $actual = TraversableUtilities::firstOr($iterable, 666);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testIsFirstOrWithEmptyCollection() {
+ $actual = TraversableUtilities::firstOr([], 666);
+ $this->assertEquals(666, $actual);
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideCountables () : iterable {
+ yield [0, null];
+ yield [0, false];
+ yield [0, []];
+ yield [3, ["a", "b", "c"]];
+ yield [42, new class implements Countable {
+ public function count () : int {
+ return 42;
+ }
+ }
+ ];
+ }
+
+ public function providePureCountables () : iterable {
+ yield [[]];
+ yield [["a", "b", "c"]];
+ yield [new class implements Countable {
+ public function count () : int {
+ return 42;
+ }
+ }
+ ];
+ }
+
+ public function provideNotCountables () : iterable {
+ yield [true];
+ yield [new \stdClass];
+ yield [0];
+ yield [""];
+ yield ["abc"];
+ }
+
+ public function provideIterableAndFirst() : iterable {
+ yield ["a", ["a", "b", "c"]];
+
+ yield ["apple", ["fruit" => "apple", "vegetable" => "leeks"]];
+
+ yield [42, new class implements IteratorAggregate {
+ public function getIterator () : Traversable {
+ yield 42;
+ yield 100;
+ }
+ }];
+ }
+
+}
diff --git a/tests/Collections/VectorTest.php b/tests/Collections/VectorTest.php
new file mode 100644
index 0000000..68bce70
--- /dev/null
+++ b/tests/Collections/VectorTest.php
@@ -0,0 +1,229 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\Vector;
+
+use PHPUnit\Framework\TestCase;
+
+use InvalidArgumentException;
+use IteratorAggregate;
+use Traversable;
+
+class VectorTest extends TestCase {
+
+ private Vector $vector;
+
+ protected function setUp () : void {
+ $this->vector = new Vector([1, 2, 3, 4, 5]);
+ }
+
+ public function testConstructorWithIterable () : void {
+ $iterable = new class implements IteratorAggregate {
+ public function getIterator () : Traversable {
+ yield 42;
+ yield 100;
+ }
+ };
+
+ $vector = new Vector($iterable);
+ $this->assertEquals([42, 100], $vector->toArray());
+ }
+
+ public function testFrom () : void {
+ $this->assertEquals([42, 100], Vector::from([42, 100])->toArray());
+ }
+
+ public function testGet () : void {
+ $vector = new Vector(["a", "b", "c"]);
+
+ $this->assertEquals("b", $vector->get(1));
+ }
+
+ public function testGetOverflow () : void {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->vector->get(800);
+ }
+
+ public function testGetOr () : void {
+ $vector = new Vector(["a", "b", "c"]);
+
+ $this->assertEquals("X", $vector->getOr(800, "X"));
+ }
+
+ public function testSet () : void {
+ $vector = new Vector(["a", "b", "c"]);
+ $vector->set(1, "x"); // should replace "b"
+
+ $this->assertEquals(["a", "x", "c"], $vector->toArray());
+ }
+
+ public function testContains () : void {
+ $this->assertTrue($this->vector->contains(2));
+ $this->assertFalse($this->vector->contains(666));
+ }
+
+ public function testCount () : void {
+ $this->assertEquals(5, $this->vector->count());
+ $this->assertEquals(0, (new Vector)->count());
+ }
+
+ public function testClear () : void {
+ $this->vector->clear();
+
+ $this->assertEquals(0, $this->vector->count());
+ }
+
+ public function testIsEmpty () : void {
+ $this->vector->clear();
+
+ $this->assertTrue($this->vector->isEmpty());
+ }
+
+ public function testPush () : void {
+ $this->vector->push(6);
+
+ $this->assertEquals([1, 2, 3, 4, 5, 6], $this->vector->toArray());
+ }
+
+ public function testAppend () : void {
+ $this->vector->append([6, 7, 8]);
+
+ $this->assertEquals([1, 2, 3, 4, 5, 6, 7 ,8], $this->vector->toArray());
+ }
+
+ public function testUpdate () : void {
+ $this->vector->update([5, 5, 5, 6, 7, 8]); // 5 already exists
+
+ $this->assertEquals([1, 2, 3, 4, 5, 6, 7 ,8], $this->vector->toArray());
+ }
+
+ public function testMap () : void {
+ $actual = $this->vector
+ ->map(function ($x) { return $x * $x; })
+ ->toArray();
+
+ $this->assertEquals([1, 4, 9, 16, 25], $actual);
+ }
+
+ public function testMapKeys () : void {
+ $vector = new Vector(["foo", "bar", "quux", "xizzy"]);
+
+ $filter = function ($key) {
+ return 0; // Let's collapse our array
+ };
+
+ $actual = $vector->mapKeys($filter)->toArray();
+ $this->assertEquals(["xizzy"], $actual);
+
+ }
+
+ public function testFilter () : void {
+ $vector = new Vector(["foo", "bar", "quux", "xizzy"]);
+
+ $filter = function ($item) {
+ return strlen($item) === 3; // Let's keep 3-letters words
+ };
+
+ $actual = $vector->filter($filter)->toArray();
+ $this->assertEquals(["foo", "bar"], $actual);
+ }
+
+ public function testFilterWithBadCallback () : void {
+ $this->expectException(InvalidArgumentException::class);
+
+ $badFilter = function () {};
+
+ $this->vector->filter($badFilter);
+ }
+
+ public function testFilterKeys () : void {
+ $filter = function ($key) {
+ return $key % 2 === 0; // Let's keep even indices
+ };
+
+ $actual = $this->vector
+ ->filterKeys($filter)
+ ->toArray();
+
+ $this->assertEquals([0, 2, 4], array_keys($actual));
+ }
+
+ public function testImplode() : void {
+ $actual = (new Vector(["a", "b", "c"]))
+ ->implode(".")
+ ->__toString();
+
+ $this->assertEquals("a.b.c", $actual);
+ }
+
+ public function testImplodeWithoutDelimiter() : void {
+ $actual = (new Vector(["a", "b", "c"]))
+ ->implode("")
+ ->__toString();
+
+ $this->assertEquals("abc", $actual);
+ }
+
+ public function testExplode() : void {
+ $actual = Vector::explode(".", "a.b.c");
+
+ $this->assertEquals(["a", "b", "c"], $actual->toArray());
+ }
+
+ public function testExplodeWithoutDelimiter() : void {
+ $actual = Vector::explode("", "a.b.c");
+
+ $this->assertEquals(["a.b.c"], $actual->toArray());
+ }
+
+ ///
+ /// ArrayAccess
+ ///
+
+ public function testArrayAccessFailsWithStringKey () : void {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->vector["foo"];
+ }
+
+ public function testOffsetExists () : void {
+ $this->assertTrue(isset($this->vector[0]));
+ $this->assertFalse(isset($this->vector[8]));
+ }
+
+ public function testOffsetSetWithoutOffset () : void {
+ $this->vector[] = 6;
+ $this->assertEquals(6, $this->vector[5]);
+ }
+
+ public function testOffsetSet () : void {
+ $this->vector[0] = 9;
+ $this->assertEquals(9, $this->vector[0]);
+ }
+
+ public function testOffsetUnset () : void {
+ unset($this->vector[2]);
+
+ $expected = [
+ 0 => 1,
+ 1 => 2,
+ // vector[2] has been unset
+ 3 => 4,
+ 4 => 5,
+ ];
+
+ $this->assertEquals($expected, $this->vector->toArray());
+ }
+
+ ///
+ /// IteratorAggregate
+ ///
+
+ public function testGetIterator () : void {
+ $this->assertEquals([1, 2, 3, 4, 5], iterator_to_array($this->vector));
+ }
+
+}
diff --git a/tests/Collections/WeightedListTest.php b/tests/Collections/WeightedListTest.php
new file mode 100644
index 0000000..f600887
--- /dev/null
+++ b/tests/Collections/WeightedListTest.php
@@ -0,0 +1,87 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\WeightedValue;
+use Keruald\OmniTools\Collections\WeightedList;
+use PHPUnit\Framework\TestCase;
+
+class WeightedListTest extends TestCase {
+
+ /**
+ * @var WeightedList
+ */
+ private $list;
+
+ ///
+ /// Fixtures
+ ///
+
+ protected function setUp () : void {
+ $this->list = new WeightedList;
+ $this->list->add("LOW", 0.1);
+ $this->list->add("HIGH", 4);
+ $this->list->add("AVERAGE");
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testAdd () : void {
+ $count = count($this->list);
+
+ $this->list->add("ANOTHER");
+
+ $this->assertEquals($count + 1, count($this->list));
+ }
+
+ public function testAddWeightedValue () : void {
+ $count = count($this->list);
+
+ $this->list->addWeightedValue(new WeightedValue("ANOTHER"));
+
+ $this->assertEquals($count + 1, count($this->list));
+ }
+
+ public function testClear () : void {
+ $this->list->clear();
+ $this->assertEquals(0, count($this->list));
+ }
+
+ public function testGetHeaviest () : void {
+ $this->assertEquals(4, $this->list->getHeaviest()->getWeight());
+ }
+
+ public function testToSortedArray () : void {
+ $array = $this->list->toSortedArray();
+
+ $this->assertEquals(3, count($array));
+ $this->assertEquals(["HIGH", "AVERAGE", "LOW"], $array);
+ }
+
+ public function testToSortedArrayWithDuplicateValues () : void {
+ $this->list->add("AVERAGE");
+ $array = $this->list->toSortedArray();
+
+ $this->assertEquals(4, count($array));
+ $this->assertEquals(["HIGH", "AVERAGE", "AVERAGE", "LOW"], $array);
+ }
+
+ public function testGetIterator () : void {
+ $count = 0;
+
+ foreach ($this->list as $item) {
+ $this->assertInstanceOf(WeightedValue::class, $item);
+ $count++;
+ }
+
+ $this->assertEquals(3, $count);
+ }
+
+ public function testCount() : void {
+ $this->assertEquals(3, $this->list->count());
+ }
+
+}
diff --git a/tests/Collections/WeightedValueTest.php b/tests/Collections/WeightedValueTest.php
new file mode 100644
index 0000000..06daca2
--- /dev/null
+++ b/tests/Collections/WeightedValueTest.php
@@ -0,0 +1,101 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Collections;
+
+use Keruald\OmniTools\Collections\WeightedValue;
+use PHPUnit\Framework\TestCase;
+
+class WeightedValueTest extends TestCase {
+
+ /**
+ * @var WeightedValue
+ */
+ private $lowValue;
+
+ /**
+ * @var WeightedValue
+ */
+ private $highValue;
+
+ ///
+ /// Fixtures
+ ///
+
+ protected function setUp () : void {
+ $this->lowValue = new WeightedValue("LOW", 0.1);
+ $this->highValue = new WeightedValue("HIGH");
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testGetWeight () : void {
+ $this->assertSame(0.1, $this->lowValue->getWeight());
+ $this->assertSame(
+ WeightedValue::DEFAULT_WEIGHT,
+ $this->highValue->getWeight()
+ );
+ }
+
+ public function testSetWeight () : void {
+ $this->lowValue->setWeight(0.2);
+ $this->assertSame(0.2, $this->lowValue->getWeight());
+ }
+
+ public function testGetValue () : void {
+ $this->assertEquals("LOW", $this->lowValue->getValue());
+ $this->assertEquals("HIGH", $this->highValue->getValue());
+ }
+
+ public function testSetValue () : void {
+ $this->lowValue->setValue("FOO");
+ $this->assertEquals("FOO", $this->lowValue->getValue());
+ }
+
+ public function testCompareTo () : void {
+ $this->assertEquals(
+ 0,
+ $this->lowValue->compareTo($this->lowValue)
+ );
+
+ $this->assertEquals(
+ -1,
+ $this->lowValue->compareTo($this->highValue)
+ );
+
+ $this->assertEquals(
+ 1,
+ $this->highValue->compareTo($this->lowValue)
+ );
+ }
+
+ public function testCompareToWithApplesAndPears () : void {
+ $this->expectException("TypeError");
+ $this->highValue->compareTo(new \stdClass);
+ }
+
+ /**
+ * @dataProvider provideExpressionsToParse
+ */
+ public function testParse ($expression, $expectedValue, $expectedWeight) : void {
+ $value = WeightedValue::Parse($expression);
+
+ $this->assertEquals($expectedValue, $value->getValue());
+ $this->assertEquals($expectedWeight, $value->getWeight());
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideExpressionsToParse () : iterable {
+ yield ["", "", 1.0];
+ yield ["de", "de", 1.0];
+ yield ["de;q=1.0", "de", 1.0];
+ yield ["de;q=0.7", "de", 0.7];
+ yield [";;q=0.7", ";", 0.7];
+ }
+
+}
diff --git a/tests/Culture/Rome/RomanNumeralsTest.php b/tests/Culture/Rome/RomanNumeralsTest.php
new file mode 100644
index 0000000..a129e1b
--- /dev/null
+++ b/tests/Culture/Rome/RomanNumeralsTest.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Culture\Rome;
+
+use Keruald\OmniTools\Culture\Rome\RomanNumerals;
+use PHPUnit\Framework\TestCase;
+use InvalidArgumentException;
+
+class RomanNumeralsTest extends TestCase {
+
+ /**
+ * @dataProvider provideRomanAndHinduArabicNumerals
+ */
+ public function testFromHindiArabicNumeral (
+ string $roman,
+ int $hinduArabic
+ ) : void {
+ $this->assertEquals(
+ $roman,
+ RomanNumerals::fromHinduArabic($hinduArabic)
+ );
+ }
+
+ public function provideRomanAndHinduArabicNumerals () : iterable {
+ yield ['i', 1];
+ yield ['xi', 11];
+ yield ['xlii', 42];
+ yield ['mcmxcix', 1999];
+ yield ['mm', 2000];
+ }
+
+ public function testFromHindiArabicNumeralWithNegativeNumbers () : void {
+ $this->expectException(InvalidArgumentException::class);
+ RomanNumerals::fromHinduArabic(-1);
+ }
+
+ public function testFromHindiArabicNumeralWithZero () : void {
+ $this->expectException(InvalidArgumentException::class);
+ RomanNumerals::fromHinduArabic(0);
+ }
+
+}
diff --git a/tests/DateTime/DateStampTest.php b/tests/DateTime/DateStampTest.php
new file mode 100644
index 0000000..1848eb8
--- /dev/null
+++ b/tests/DateTime/DateStampTest.php
@@ -0,0 +1,91 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\DateTime;
+
+use Keruald\OmniTools\DateTime\DateStamp;
+use PHPUnit\Framework\TestCase;
+
+use DateTime;
+
+class DateStampTest extends TestCase {
+
+ ///
+ /// Private members
+ ///
+
+ /**
+ * @var DateStamp
+ */
+ private $dateStamp;
+
+ ///
+ /// Fixtures
+ ///
+
+ protected function setUp () : void {
+ $this->dateStamp = new DateStamp(2010, 11, 25); // 25 November 2010
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testToUnixTime () : void {
+ $this->assertEquals(1290643200, $this->dateStamp->toUnixTime());
+ }
+
+ public function testToDateTime () : void {
+ $expectedDateTime = new DateTime("2010-11-25");
+
+ $this->assertEquals($expectedDateTime, $this->dateStamp->toDateTime());
+ }
+
+ public function testToShortString () : void {
+ $this->assertEquals("20101125", $this->dateStamp->toShortString());
+ }
+
+ public function testToString () : void {
+ $this->assertEquals("2010-11-25", (string)$this->dateStamp);
+ }
+
+ public function testFromUnixTime () : void {
+ $this->assertEquals(
+ $this->dateStamp,
+ DateStamp::fromUnixTime(1290643200)
+ );
+ }
+
+ public function testParse () : void {
+ $this->assertEquals(
+ $this->dateStamp,
+ DateStamp::parse("2010-11-25")
+ );
+
+ $this->assertEquals(
+ $this->dateStamp,
+ DateStamp::parse("20101125")
+ );
+ }
+
+ /**
+ * @dataProvider provideInvalidDateStamps
+ */
+ public function testParseWithInvalidFormat ($dateStamp) : void {
+ $this->expectException("InvalidArgumentException");
+ DateStamp::parse($dateStamp);
+ }
+
+ ///
+ /// Data provider
+ ///
+
+ public function provideInvalidDateStamps () : iterable {
+ yield ["10-11-25"];
+ yield ["2010-41-25"];
+ yield ["2010-11-99"];
+ yield ["20104125"];
+ yield ["20101199"];
+ }
+
+}
diff --git a/tests/Debug/DebuggerTest.php b/tests/Debug/DebuggerTest.php
new file mode 100644
index 0000000..3433b92
--- /dev/null
+++ b/tests/Debug/DebuggerTest.php
@@ -0,0 +1,57 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Debug;
+
+use Keruald\OmniTools\Debug\Debugger;
+use PHPUnit\Framework\TestCase;
+
+class DebuggerTest extends TestCase {
+
+ ///
+ /// Unit tests
+ ///
+
+ public function testRegister () {
+ $this->assertTestSuiteStateIsValid();
+
+ Debugger::register();
+
+ $this->assertTrue(function_exists("dprint_r"));
+ }
+
+ private function assertTestSuiteStateIsValid() : void {
+ $this->assertFalse(
+ function_exists("dprint_r"),
+ "Configure the test suite so dprint_r isn't in global space first."
+ );
+ }
+
+ ///
+ /// Integration tests
+ ///
+
+ /**
+ * @dataProvider provideDebuggerScripts
+ */
+ public function testDebuggerScript ($script, $message) : void {
+ $this->assertProgramMatchesOutput($script, $message);
+ }
+
+ private function assertProgramMatchesOutput(string $script, string $message = "") : void {
+ $filename = __DIR__ . "/testers/$script";
+
+ $expected = file_get_contents($filename . ".txt");
+ $actual = `php $filename.php`;
+
+ $this->assertSame($expected, $actual, $message);
+ }
+
+ public function provideDebuggerScripts () : iterable {
+ yield ["dump_integer", "Can't dump a variable"];
+ yield ["dump_array", "Can't dump an array"];
+ yield ["dump_object", "Can't dump an object"];
+ yield ["check_die", "printVariableAndDie doesn't die"];
+ }
+
+}
diff --git a/tests/Debug/testers/Counter.php b/tests/Debug/testers/Counter.php
new file mode 100644
index 0000000..32c6b1e
--- /dev/null
+++ b/tests/Debug/testers/Counter.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Acme;
+
+/**
+ * Mock class to get an object to dump by dump_object.php
+ */
+class Counter {
+ private $counter = "4";
+}
diff --git a/tests/Debug/testers/check_die.php b/tests/Debug/testers/check_die.php
new file mode 100644
index 0000000..e90a166
--- /dev/null
+++ b/tests/Debug/testers/check_die.php
@@ -0,0 +1,10 @@
+<?php
+
+use Keruald\OmniTools\Debug\Debugger;
+
+// Include Debugger class file
+$libDirectory = dirname(__DIR__, 3);
+require $libDirectory . "/src/Debug/Debugger.php";
+
+Debugger::printVariableAndDie("foo");
+echo "The script didn't die as expected.";
diff --git a/tests/Debug/testers/check_die.txt b/tests/Debug/testers/check_die.txt
new file mode 100644
index 0000000..e60a8aa
--- /dev/null
+++ b/tests/Debug/testers/check_die.txt
@@ -0,0 +1 @@
+<pre class='debug'>foo</pre>
\ No newline at end of file
diff --git a/tests/Debug/testers/dump_array.php b/tests/Debug/testers/dump_array.php
new file mode 100644
index 0000000..2c53467
--- /dev/null
+++ b/tests/Debug/testers/dump_array.php
@@ -0,0 +1,10 @@
+<?php
+
+use Keruald\OmniTools\Debug\Debugger;
+
+// Include Debugger class file
+$libDirectory = dirname(__DIR__, 3);
+require $libDirectory . "/src/Debug/Debugger.php";
+
+Debugger::printVariableAndDie(["foo" => "bar"]);
+echo "The script didn't die as expected.";
diff --git a/tests/Debug/testers/dump_array.txt b/tests/Debug/testers/dump_array.txt
new file mode 100644
index 0000000..ff9e047
--- /dev/null
+++ b/tests/Debug/testers/dump_array.txt
@@ -0,0 +1,5 @@
+<pre class='debug'>Array
+(
+ [foo] => bar
+)
+</pre>
\ No newline at end of file
diff --git a/tests/Debug/testers/dump_integer.php b/tests/Debug/testers/dump_integer.php
new file mode 100644
index 0000000..5512ecd
--- /dev/null
+++ b/tests/Debug/testers/dump_integer.php
@@ -0,0 +1,9 @@
+<?php
+
+use Keruald\OmniTools\Debug\Debugger;
+
+// Include Debugger class file
+$libDirectory = dirname(__DIR__, 3);
+require $libDirectory . "/src/Debug/Debugger.php";
+
+Debugger::printVariable(1 + 1);
diff --git a/tests/Debug/testers/dump_integer.txt b/tests/Debug/testers/dump_integer.txt
new file mode 100644
index 0000000..610cfe4
--- /dev/null
+++ b/tests/Debug/testers/dump_integer.txt
@@ -0,0 +1 @@
+<pre class='debug'>2</pre>
\ No newline at end of file
diff --git a/tests/Debug/testers/dump_object.php b/tests/Debug/testers/dump_object.php
new file mode 100644
index 0000000..6805ece
--- /dev/null
+++ b/tests/Debug/testers/dump_object.php
@@ -0,0 +1,11 @@
+<?php
+
+use Keruald\OmniTools\Debug\Debugger;
+use Acme\Counter;
+
+// Include Debugger class file
+$libDirectory = dirname(__DIR__, 3);
+require $libDirectory . "/src/Debug/Debugger.php";
+require "Counter.php";
+
+Debugger::printVariableAndDie(new Counter);
diff --git a/tests/Debug/testers/dump_object.txt b/tests/Debug/testers/dump_object.txt
new file mode 100644
index 0000000..2caf1d6
--- /dev/null
+++ b/tests/Debug/testers/dump_object.txt
@@ -0,0 +1,5 @@
+<pre class='debug'>Acme\Counter Object
+(
+ [counter:Acme\Counter:private] => 4
+)
+</pre>
\ No newline at end of file
diff --git a/tests/HTTP/Requests/AcceptedLanguagesTest.php b/tests/HTTP/Requests/AcceptedLanguagesTest.php
new file mode 100644
index 0000000..d210050
--- /dev/null
+++ b/tests/HTTP/Requests/AcceptedLanguagesTest.php
@@ -0,0 +1,47 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\HTTP\Requests;
+
+use Keruald\OmniTools\HTTP\Requests\AcceptedLanguages;
+use PHPUnit\Framework\TestCase;
+
+class AcceptedLanguagesTest extends TestCase {
+
+ /**
+ * @var AcceptedLanguages
+ */
+ private $languages;
+
+ protected function setUp () : void {
+ $this->languages = new AcceptedLanguages("en-US,en;q=0.9,fr;q=0.8");
+ }
+
+ public function testExtractFromHeaders () : void {
+ $this->assertEquals("", AcceptedLanguages::extractFromHeaders());
+
+ $_SERVER['HTTP_ACCEPT_LANGUAGE'] = "de";
+ $this->assertEquals("de", AcceptedLanguages::extractFromHeaders());
+ }
+
+ public function testFromServer () : void {
+ $_SERVER['HTTP_ACCEPT_LANGUAGE'] = "de";
+ $languages = AcceptedLanguages::fromServer();
+
+ $this->assertEquals(["de"], $languages->getLanguageCodes());
+ }
+
+ public function testGetLanguageCodes () : void {
+ $this->assertEquals(
+ ["en-US", "en", "fr"],
+ $this->languages->getLanguageCodes()
+ );
+ }
+
+ public function testGetLanguageCodesWithBlankInformation () : void {
+ $languages = new AcceptedLanguages;
+
+ $this->assertEquals([], $languages->getLanguageCodes());
+ }
+
+}
diff --git a/tests/HTTP/Requests/RemoteAddressTest.php b/tests/HTTP/Requests/RemoteAddressTest.php
new file mode 100644
index 0000000..60aa490
--- /dev/null
+++ b/tests/HTTP/Requests/RemoteAddressTest.php
@@ -0,0 +1,72 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\HTTP\Requests;
+
+use Keruald\OmniTools\HTTP\Requests\RemoteAddress;
+use PHPUnit\Framework\TestCase;
+
+class RemoteAddressTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::getClientAddress
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::getAll
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::has
+ */
+ public function testEmptyRequest () : void {
+ $address = new RemoteAddress;
+
+ $this->assertEmpty($address->getClientAddress());
+ $this->assertEmpty($address->getAll());
+ $this->assertFalse($address->has());
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::getClientAddress
+ * @dataProvider provideTenZeroZeroThreeHeaderValues
+ */
+ public function testGetOne (string $value) : void {
+ $address = new RemoteAddress($value);
+
+ $this->assertEquals('10.0.0.3', $address->getClientAddress());
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::getClientAddress
+ * @dataProvider provideTenZeroZeroThreeHeaderValues
+ */
+ public function testGetAll (string $value) : void {
+ $address = new RemoteAddress($value);
+
+ $this->assertEquals($value, $address->getAll());
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\RemoteAddress::has
+ * @dataProvider provideTenZeroZeroThreeHeaderValues
+ */
+ public function testHas (string $value) : void {
+ $address = new RemoteAddress($value);
+
+ $this->assertTrue($address->has());
+ }
+
+ ///
+ /// Data provider
+ ///
+
+ public function provideTenZeroZeroThreeHeaderValues () : iterable {
+ return [
+ // Each value should return 10.0.0.3
+ ['10.0.0.3'],
+ ['10.0.0.3,10.0.0.4'],
+ ['10.0.0.3, 10.0.0.4'],
+ ['10.0.0.3, 10.0.0.4, lorem ipsum dolor'],
+ ];
+ }
+
+}
diff --git a/tests/HTTP/Requests/RequestTest.php b/tests/HTTP/Requests/RequestTest.php
new file mode 100644
index 0000000..672f43f
--- /dev/null
+++ b/tests/HTTP/Requests/RequestTest.php
@@ -0,0 +1,155 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\HTTP\Requests;
+
+use Keruald\OmniTools\HTTP\Requests\Request;
+use PHPUnit\Framework\TestCase;
+
+class RequestTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\Request::getRemoteAddress
+ * @backupGlobals enabled
+ */
+ public function testGetRemoteAddress () : void {
+ $this->assertEmpty(Request::getRemoteAddress());
+
+ $_SERVER = [
+ 'REMOTE_ADDR' => '10.0.0.2',
+ ];
+ $this->assertEquals('10.0.0.2', Request::getRemoteAddress());
+
+ $_SERVER += [
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.3',
+ 'HTTP_CLIENT_IP' => '10.0.0.4',
+ ];
+ $this->assertEquals(
+ '10.0.0.3', Request::getRemoteAddress(),
+ "HTTP_X_FORWARDED_FOR must be prioritized."
+ );
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\Request::getClientAddress
+ * @backupGlobals enabled
+ */
+ public function testGetRemoteAddressWithSeveralAddresses () : void {
+ $_SERVER = [
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.2 10.0.0.3',
+ ];
+ $this->assertEquals('10.0.0.2', Request::getRemoteAddress(),
+ "HTTP_X_FORWARDED_FOR could contain more than one address, the client one is the first"
+ );
+
+ $_SERVER = [
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.2, 10.0.0.3',
+ ];
+ $this->assertEquals('10.0.0.2', Request::getRemoteAddress(),
+ "HTTP_X_FORWARDED_FOR could contain more than one address, the client one is the first"
+ );
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\HTTP\Requests\Request::getAcceptedLanguages
+ * @backupGlobals enabled
+ */
+ public function testGetAcceptedLanguages () : void {
+ $_SERVER = [
+ 'HTTP_ACCEPT_LANGUAGE' => 'fr,en-US;q=0.7,en;q=0.3',
+ ];
+
+ $this->assertEquals(
+ ["fr", "en-US", "en"],
+ Request::getAcceptedLanguages()
+ );
+ }
+
+ /**
+ * @backupGlobals enabled
+ * @dataProvider provideServerURLs
+ */
+ public function testGetServerURL (array $server, string $url) : void {
+ $_SERVER = $server;
+
+ $this->assertEquals($url, Request::getServerURL());
+ }
+
+ /**
+ * @backupGlobals enabled
+ * @dataProvider provideServerURLs
+ */
+ public function testCreateLocalURL (array $server, string $url) : void {
+ $_SERVER = $server;
+
+ $this->assertEquals(
+ $url . "/",
+ Request::createLocalURL()->__toString()
+ );
+
+ $this->assertEquals(
+ $url . "/foo",
+ Request::createLocalURL("foo")->__toString()
+ );
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideServerURLs () : iterable {
+ yield [[], "http://localhost"];
+ yield [["UNRELATED" => "ANYTHING"], "http://localhost"];
+
+ yield [
+ [
+ "SERVER_PORT" => "80",
+ "SERVER_NAME" => "acme.tld",
+ ],
+ "http://acme.tld"
+ ];
+
+ yield [
+ [
+ "SERVER_PORT" => "443",
+ "SERVER_NAME" => "acme.tld",
+ ],
+ "https://acme.tld"
+ ];
+
+ yield [
+ [
+ "SERVER_PORT" => "80",
+ "SERVER_NAME" => "acme.tld",
+ "HTTP_X_FORWARDED_PROTO" => "https",
+ ],
+ "https://acme.tld"
+ ];
+
+ yield [
+ [
+ "SERVER_PORT" => "80",
+ "SERVER_NAME" => "acme.tld",
+ "HTTP_FORWARDED" => "for=192.0.2.43, for=\"[2001:db8:cafe::17]\", proto=https, by=203.0.113.43",
+ ],
+ "https://acme.tld"
+ ];
+
+ yield [
+ [
+ "SERVER_PORT" => "8443",
+ "SERVER_NAME" => "acme.tld",
+ "HTTPS" => "on",
+ ],
+ "https://acme.tld:8443"
+ ];
+
+
+ }
+
+
+}
diff --git a/tests/HTTP/URLTest.php b/tests/HTTP/URLTest.php
new file mode 100644
index 0000000..3b8802c
--- /dev/null
+++ b/tests/HTTP/URLTest.php
@@ -0,0 +1,90 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\HTTP;
+
+use Keruald\OmniTools\HTTP\URL;
+use PHPUnit\Framework\TestCase;
+
+class URLTest extends TestCase {
+
+ /**
+ * @dataProvider provideURLsAndComponents
+ */
+ public function testGetDomain ($url, $expectedDomain) : void {
+ $url = new URL($url);
+
+ $this->assertEquals($expectedDomain, $url->getDomain());
+ }
+
+ /**
+ * @dataProvider provideURLsAndComponents
+ */
+ public function testGetProtocol ($url, $_, $expectedProtocol) : void {
+ $url = new URL($url);
+
+ $this->assertEquals($expectedProtocol, $url->getProtocol());
+ }
+
+ /**
+ * @dataProvider provideURLsAndComponents
+ */
+ public function testGetQuery ($url, $_, $__, $expectedQuery) : void {
+ $url = new URL($url);
+
+ $this->assertEquals($expectedQuery, $url->getQuery());
+ }
+
+ public function testSetProtocol () : void {
+ $url = new URL("https://acme.tld/foo");
+ $url->setProtocol("xizzy");
+
+ $this->assertEquals("xizzy", $url->getProtocol());
+ }
+
+ public function testSetDomain () : void {
+ $url = new URL("https://acme.tld/foo");
+ $url->setDomain("xizzy");
+
+ $this->assertEquals("xizzy", $url->getDomain());
+ }
+
+ public function testSetQuery () : void {
+ $url = new URL("https://acme.tld/foo");
+ $url->setQuery("/xizzy");
+
+ $this->assertEquals("/xizzy", $url->getQuery());
+ }
+
+ public function testSetQueryWithSlashForgotten () : void {
+ $url = new URL("https://acme.tld/foo");
+ $url->setQuery("xizzy");
+
+ $this->assertEquals("/xizzy", $url->getQuery());
+ }
+
+ /**
+ * @dataProvider provideURLsAndComponents
+ */
+ public function testCompose ($url, $domain, $protocol, $query,
+ $expectedUrl = null) {
+ $this->assertEquals(
+ $expectedUrl ?? $url,
+ URL::compose($protocol, $domain, $query)->__toString()
+ );
+ }
+
+ public function provideURLsAndComponents () : iterable {
+ // base URL, domain, protocol, query[, expected URL]
+ // When omitted, the expected URL is the base URL.
+
+ yield ["http://foo/bar", "foo", "http", "/bar"];
+ yield ["https://xn--dghrefn-mxa.nasqueron.org/", "dæghrefn.nasqueron.org", "https", "/"];
+ yield ["://foo/bar", "foo", "", "/bar"];
+ yield ["/bar", "", "", "/bar"];
+ yield ["http://foo/bar%20quux", "foo", "http", "/bar quux"];
+ yield ["https://foo/", "foo", "https", "/"];
+ yield ["https://foo", "foo", "https", "/", "https://foo/"];
+ }
+
+}
diff --git a/tests/IO/DirectoryTest.php b/tests/IO/DirectoryTest.php
new file mode 100644
index 0000000..6fe3b5f
--- /dev/null
+++ b/tests/IO/DirectoryTest.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\IO;
+
+use Keruald\OmniTools\IO\Directory;
+use Keruald\OmniTools\IO\File;
+
+use Keruald\OmniTools\Tests\WithData;
+use PHPUnit\Framework\TestCase;
+
+class DirectoryTest extends TestCase {
+
+ use WithData;
+
+ const TEST_DIRECTORY = "MockLib";
+
+ private string $path;
+ private Directory $directory;
+
+ protected function setUp () : void {
+ $this->path = $this->getDataPath(self::TEST_DIRECTORY);
+ $this->directory = new Directory($this->path);
+ }
+
+ public function testGlob () : void {
+ $expected = [
+ new File($this->path . '/Bar.php'),
+ new File($this->path . '/Foo.php'),
+ ];
+ $actual = $this->directory->glob("*.php");
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testIsReadable () : void {
+ $this->assertTrue($this->directory->isReadable());
+ }
+
+ public function testIsWritable () : void {
+ $result = tempnam($this->path, "test-write-");
+
+ if ($result === false) {
+ $this->markTestSkipped("Can't test write operation.");
+ }
+
+ $canWrite = str_starts_with($result, $this->path);
+ unlink($result);
+
+ $this->assertSame($canWrite, $this->directory->isWritable());
+ }
+
+ public function testExists () : void {
+ $this->assertTrue($this->directory->exists());
+ }
+
+ public function testExistsWhenItDoesNot () : void {
+ $directory = new Directory("/nonexistent");
+ $this->assertFalse($directory->exists());
+ }
+
+ public function testExistsWhenItMatchesFile () : void {
+ $path = $this->getDataPath(self::TEST_DIRECTORY . "/Foo.php");
+ $directory = new Directory($path);
+
+ $this->assertFalse($directory->exists());
+ }
+
+ public function testSetPath () : void {
+ $this->directory->setPath("/bar/foo");
+ $this->assertEquals("/bar/foo", $this->directory->getPath());
+ }
+
+ public function testGetPath () : void {
+ $this->assertEquals($this->path, $this->directory->getPath());
+ }
+
+ public function testGetDirectoryName () : void {
+ $actual = $this->directory->getDirectoryName();
+ $this->assertEquals(self::TEST_DIRECTORY, $actual);
+ }
+
+ public function testGetPathInfo () : void {
+ $expected = [
+ 'dirname' => $this->getDataDirectory(),
+ 'basename' => self::TEST_DIRECTORY,
+ 'filename' => self::TEST_DIRECTORY,
+ ];
+
+ $this->assertEquals($expected, $this->directory->getPathInfo());
+ }
+
+ public function testGetParentDirectory () : void {
+ $actual = $this->directory->getParentDirectory();
+ $this->assertEquals($this->getDataDirectory(), $actual);
+ }
+
+}
diff --git a/tests/IO/FileTest.php b/tests/IO/FileTest.php
new file mode 100644
index 0000000..3d15e50
--- /dev/null
+++ b/tests/IO/FileTest.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\IO;
+
+use Keruald\OmniTools\IO\File;
+use Keruald\OmniTools\OS\CurrentOS;
+use PHPUnit\Framework\TestCase;
+
+use TypeError;
+
+class FileTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ /**
+ * @dataProvider provideFilesAndDirectories
+ */
+ public function testGetDirectory (string $filename, string $expected) : void {
+ if (CurrentOS::isPureWindows()) {
+ $this->markTestSkipped("This test is intended for UNIX systems.");
+ }
+
+ $this->assertSame($expected, File::from($filename)->getDirectory());
+ }
+
+ /**
+ * @dataProvider provideFilesAndFileNames
+ */
+ public function testGetFileName (string $filename, string $expected) : void {
+ $this->assertSame($expected, File::from($filename)->getFileName());
+ }
+
+ /**
+ * @dataProvider provideFilesAndFileNamesWithoutExtension
+ */
+ public function testGetFileNameWithoutExtension (string $filename, string $expected) : void {
+ $this->assertSame($expected, File::from($filename)->getFileNameWithoutExtension());
+ }
+
+ /**
+ * @dataProvider provideFilesAndExtensions
+ */
+ public function testGetExtension (string $filename, string $expected) : void {
+ $this->assertSame($expected, File::from($filename)->getExtension());
+ }
+
+ ///
+ /// Issues
+ ///
+
+ /**
+ * @see https://devcentral.nasqueron.org/D2494
+ */
+ public function testNullPathIsNotAllowed () : void {
+ $this->expectException(TypeError::class);
+
+ $file = new File(null);
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideFilesAndDirectories () : iterable {
+ yield ['', ''];
+ yield ['/', '/'];
+ yield ['/foo', '/'];
+ yield ['foo/bar', 'foo'];
+ yield ['foo/', '.'];
+ yield ['/full/path/to/foo.php', '/full/path/to'];
+ }
+
+ public function provideFilesAndFileNames () : iterable {
+ yield ['', ''];
+ yield ['foo', 'foo'];
+ yield ['foo', 'foo'];
+ yield ['foo.php', 'foo.php'];
+ yield ['/full/path/to/foo.php', 'foo.php'];
+ }
+
+ public function provideFilesAndFileNamesWithoutExtension () : iterable {
+ yield ['', ''];
+ yield ['foo', 'foo'];
+ yield ['foo.php', 'foo'];
+ yield ['/full/path/to/foo.php', 'foo'];
+ yield ['foo.tar.gz', 'foo.tar'];
+
+ }
+
+ public function provideFilesAndExtensions () : iterable {
+ yield ['', ''];
+ yield ['foo', ''];
+ yield ['foo.php', 'php'];
+ yield ['/full/path/to/foo.php', 'php'];
+ yield ['foo.tar.gz', 'gz'];
+
+ }
+
+}
diff --git a/tests/IO/FileUtilitiesTest.php b/tests/IO/FileUtilitiesTest.php
new file mode 100644
index 0000000..f6c012a
--- /dev/null
+++ b/tests/IO/FileUtilitiesTest.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\IO;
+
+use Keruald\OmniTools\IO\FileUtilities;
+use PHPUnit\Framework\TestCase;
+
+class FileUtilitiesTest extends TestCase {
+
+ public function testGetExtension () : void {
+ $this->assertSame("jpg", FileUtilities::getExtension("image.jpg"));
+ }
+
+}
diff --git a/tests/Identifiers/RandomTest.php b/tests/Identifiers/RandomTest.php
new file mode 100644
index 0000000..38a858e
--- /dev/null
+++ b/tests/Identifiers/RandomTest.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Identifiers;
+
+use Keruald\OmniTools\Identifiers\Random;
+use Phpunit\Framework\TestCase;
+
+class RandomTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ public function testGenerateHexadecimalHash () : void {
+ $hash = Random::generateHexHash();
+
+ $this->assertEquals(
+ 32, strlen($hash),
+ "$hash size must be 32 characters"
+ );
+
+ $this->assertMatchesRegularExpression("/[0-9a-f]{32}/", $hash);
+ }
+
+ public function testHexadecimalHashesAreUnique() : void {
+ $this->assertNotEquals(
+ Random::generateHexHash(),
+ Random::generateHexHash()
+ );
+ }
+
+ /**
+ * @dataProvider provideRandomStringFormats
+ */
+ public function testRandomString($format, $re, $len) : void {
+ $string = Random::generateString($format);
+
+ $this->assertEquals($len, strlen($format));
+ $this->assertMatchesRegularExpression($re, $string);
+ }
+
+ public function testGenerateIdentifier() : void {
+ $identifier = Random::generateIdentifier(20);
+
+ $this->assertEquals(27, strlen($identifier));
+ $this->assertMatchesRegularExpression("/^[A-Z0-9\-_]*$/i", $identifier);
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideRandomStringFormats() : iterable {
+ yield ["AAA111", "/^[A-Z]{3}[0-9]{3}$/", 6];
+ yield ["AAA123", "/^[A-Z]{3}[0-9]{3}$/", 6];
+ yield ["ABC123", "/^[A-Z]{3}[0-9]{3}$/", 6];
+ yield ["", "/^$/", 0];
+ }
+
+}
diff --git a/tests/Identifiers/UUIDTest.php b/tests/Identifiers/UUIDTest.php
new file mode 100644
index 0000000..5dd2175
--- /dev/null
+++ b/tests/Identifiers/UUIDTest.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Identifiers;
+
+use Keruald\OmniTools\Identifiers\UUID;
+use Phpunit\Framework\TestCase;
+
+class UUIDTest extends TestCase {
+
+ public function testUUIDv4 () : void {
+ $uuid = UUID::UUIDv4();
+
+ $this->assertEquals(
+ 36, strlen($uuid),
+ "UUID size must be 36 characters."
+ );
+
+ $re = "/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/";
+ $this->assertMatchesRegularExpression($re, $uuid);
+ }
+
+ public function testUUIDv4WithoutHyphens () : void {
+ $uuid = UUID::UUIDv4WithoutHyphens();
+
+ $this->assertEquals(
+ 32, strlen($uuid),
+ "UUID size must be 36 characters, and there are 4 hyphens, so here 32 characters are expected."
+ );
+
+ $re = "/[0-9a-f]/";
+ $this->assertMatchesRegularExpression($re, $uuid);
+ }
+
+ public function testUUIDv4AreUnique () : void {
+ $this->assertNotEquals(UUID::UUIDv4(), UUID::UUIDv4());
+ }
+
+ public function testIsUUID () : void {
+ $this->assertTrue(UUID::isUUID("e14d5045-4959-11e8-a2e6-0007cb03f249"));
+ $this->assertFalse(
+ UUID::isUUID("e14d5045-4959-11e8-a2e6-0007cb03f249c"),
+ "The method shouldn't allow arbitrary size strings."
+ );
+ $this->assertFalse(UUID::isUUID("d825a90a27e7f161a07161c3a37dce8e"));
+
+ }
+
+}
diff --git a/tests/Network/IPTest.php b/tests/Network/IPTest.php
new file mode 100644
index 0000000..e1b3b4e
--- /dev/null
+++ b/tests/Network/IPTest.php
@@ -0,0 +1,148 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Network;
+
+use Keruald\OmniTools\Network\IP;
+use PHPUnit\Framework\TestCase;
+
+class IPTest extends TestCase {
+
+ ///
+ /// Data providers for IP addresses
+ ///
+ /// These data providers methods allow providing IP addresses
+ /// to validate or invalidate.
+ ///
+ /// The advanced IPv6 tests have been curated by Stephen Ryan
+ /// Source: https://web.archive.org/web/20110515134717/http://forums.dartware.com/viewtopic.php?t=452
+ ///
+
+ public function provideValidIP () : iterable {
+ yield ["0.0.0.0"];
+ yield ["17.17.17.17"];
+ yield ["fe80:0000:0000:0000:0204:61ff:fe9d:f156"];
+ }
+
+ public function provideInvalidIP () : iterable {
+ yield [""];
+ yield ["1"];
+ yield ["17.17"];
+ yield ["17.17.17.256"];
+ }
+
+ public function provideValidIPv4 () : iterable {
+ return [["0.0.0.0"], ["17.17.17.17"]];
+ }
+
+ public function provideInvalidIPv4 () : iterable {
+ yield [""];
+ yield ["1"];
+ yield ["17.17"];
+ yield ["17.17.17.256"];
+ yield ["fe80:0000:0000:0000:0204:61ff:fe9d:f156"];
+ }
+
+ public function provideValidIPv6 () : iterable {
+ yield ["::1"];
+ yield ["::ffff:192.0.2.128"];
+ yield ["fe80:0000:0000:0000:0204:61ff:fe9d:f156"];
+
+ yield ["::ffff:192.0.2.128", "IPv4 represented as dotted-quads"];
+ }
+
+ public function provideInvalidIPv6 () : iterable {
+ yield ["0.0.0.0"];
+ yield [""];
+ yield ["1"];
+ yield ["17.17"];
+ yield ["17.17.17.17"];
+ yield ["::fg", "Valid IPv6 digits are 0-f, ie 0-9 and a-f"];
+
+ yield ["02001:0000:1234:0000:0000:C1C0:ABCD:0876", "Extra 0"];
+ yield ["2001:0000:1234:0000:00001:C1C0:ABCD:0876", "Extra 0"];
+ yield ["1.2.3.4:1111:2222:3333:4444::5555"];
+ }
+
+ public function provideValidLoopbackIP () : iterable {
+ yield ["127.0.0.1"];
+ yield ["127.0.0.3"];
+ yield ["::1"];
+ }
+
+ public function provideInvalidLoopbackIP () : iterable {
+ yield ["0.0.0.0"];
+ yield ["1.2.3.4"];
+ yield ["192.168.1.1"];
+ yield ["::2"];
+ }
+
+ ///
+ /// Test cases
+ ///
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIP
+ * @dataProvider provideValidIP
+ */
+ public function testIsIP ($ip) {
+ $this->assertTrue(IP::isIP($ip));
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIP
+ * @dataProvider provideInvalidIP
+ */
+ public function testIsIPWhenItIsNot ($ip) {
+ $this->assertFalse(IP::isIP($ip));
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIPv4
+ * @dataProvider provideValidIPv4
+ */
+ public function testIsIPv4 ($ip) {
+ $this->assertTrue(IP::isIPv4($ip));
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIPv4
+ * @dataProvider provideInvalidIPv4
+ */
+ public function testIsIPv4WhenItIsNot ($ip) {
+ $this->assertFalse(IP::isIPv4($ip));
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIPv6
+ * @dataProvider provideValidIPv6
+ */
+ public function testIsIPv6 (string $ip, string $message = "") {
+ $this->assertTrue(IP::isIPv6($ip), $message);
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isIPv6
+ * @dataProvider provideInvalidIPv6
+ */
+ public function testIsIPv6WhenItIsNot (string $ip, string $message = "") : void {
+ $this->assertFalse(IP::isIPv6($ip), $message);
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isLoopback
+ * @dataProvider provideValidLoopbackIP
+ */
+ public function testIsLoopback (string $ip) : void {
+ $this->assertTrue(IP::isLoopback($ip));
+ }
+
+ /**
+ * @covers \Keruald\OmniTools\Network\IP::isLoopback
+ * @dataProvider provideInvalidLoopbackIP
+ */
+ public function testIsLoopbackWhenItIsNot (string $ip) : void {
+ $this->assertFalse(IP::isLoopback($ip));
+ }
+
+}
diff --git a/tests/Network/IPv4RangeTest.php b/tests/Network/IPv4RangeTest.php
new file mode 100644
index 0000000..ee36916
--- /dev/null
+++ b/tests/Network/IPv4RangeTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\Network;
+
+use Keruald\OmniTools\Network\IPRange;
+use PHPUnit\Framework\TestCase;
+
+class IPv4RangeTest extends TestCase {
+
+ /**
+ * @var IPRange
+ */
+ protected $range;
+
+ ///
+ /// Fixtures
+ ///
+
+ protected function setUp () : void {
+ $this->range = IPRange::from("216.66.0.0/18");
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testGetBase () : void {
+ $this->assertEquals("216.66.0.0", $this->range->getBase());
+ }
+
+ public function testGetNetworkBits () : void {
+ $this->assertEquals(18, $this->range->getNetworkBits());
+ }
+
+ public function testCount () : void {
+ $this->assertEquals(14, $this->range->count()); // 14 + 18 = 32 bits
+ }
+
+ public function testGetFirst () : void {
+ $this->assertEquals("216.66.0.0", $this->range->getFirst());
+ }
+
+ public function testGetLast () : void {
+ $this->assertEquals("216.66.63.255", $this->range->getLast());
+ }
+
+}
diff --git a/tests/Network/IPv6RangeTest.php b/tests/Network/IPv6RangeTest.php
new file mode 100644
index 0000000..86655c8
--- /dev/null
+++ b/tests/Network/IPv6RangeTest.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\Network;
+
+use Keruald\OmniTools\Network\IPRange;
+use PHPUnit\Framework\TestCase;
+
+class IPv6RangeTest extends TestCase {
+
+ /**
+ * @var IPRange
+ */
+ protected $range;
+
+ ///
+ /// Fixtures
+ ///
+
+ protected function setUp () : void {
+ $this->range = IPRange::from("2001:400::/23");
+ }
+
+ ///
+ /// Tests
+ ///
+
+ public function testGetBase () : void {
+ $this->assertEquals("2001:400::", $this->range->getBase());
+ }
+
+ public function testGetNetworkBits () : void {
+ $this->assertEquals(23, $this->range->getNetworkBits());
+ }
+
+ public function testCount () : void {
+ $this->assertEquals(105, $this->range->count()); // 23 + 105 = 128 bits
+ }
+
+ public function testGetFirst () : void {
+ $this->assertEquals("2001:400::", $this->range->getFirst());
+ }
+
+ public function testGetLast () : void {
+ $this->assertEquals("2001:5ff:ffff:ffff:ffff:ffff:ffff:ffff", $this->range->getLast());
+ }
+
+ public function testContains () : void {
+ $this->assertTrue($this->range->contains("2001:431::af"));
+ }
+
+ public function testContainsWorksWithIPv4MappedIPv6Address () : void {
+ $this->assertTrue(IPRange::from("::ffff:0.0.0.0/96")->contains("1.2.3.4"));
+ }
+
+}
diff --git a/tests/OS/CurrentProcessTest.php b/tests/OS/CurrentProcessTest.php
new file mode 100644
index 0000000..16752c6
--- /dev/null
+++ b/tests/OS/CurrentProcessTest.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\OS;
+
+use Keruald\OmniTools\OS\CurrentProcess;
+use PHPUnit\Framework\TestCase;
+
+class CurrentProcessTest extends TestCase {
+
+ // Probably more usernames are valid, but why tests
+ // would run in accounts using spaces or UTF-8 emojis?
+ const USERNAME_REGEXP = "/^([a-zA-Z][a-zA-Z0-9_]*)$/";
+
+ public function testGetUsername () {
+ $actual = CurrentProcess::getUsername();
+
+ $this->assertMatchesRegularExpression(self::USERNAME_REGEXP, $actual);
+ }
+
+}
diff --git a/tests/OS/EnvironmentTest.php b/tests/OS/EnvironmentTest.php
new file mode 100644
index 0000000..9c3f5d4
--- /dev/null
+++ b/tests/OS/EnvironmentTest.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Keruald\OmniTools\Tests\OS;
+
+use InvalidArgumentException;
+use Keruald\OmniTools\OS\Environment;
+use PHPUnit\Framework\TestCase;
+
+class EnvironmentTest extends TestCase {
+
+ protected function setUp () : void {
+ // Keep in sync with provideEnvironment data provider
+
+ $_ENV['foo'] = "bar";
+ $_SERVER['foo'] = "baz";
+
+ $_ENV['bar'] = "lorem";
+ $_SERVER['baz'] = "ipsum";
+
+ // And quux isn't defined.
+ }
+
+ public function provideEnvironment () : iterable {
+ yield ["foo", "bar"];
+ yield ["bar", "lorem"];
+ yield ["baz", "ipsum"];
+ }
+
+ public function provideEnvironmentKeys () : iterable {
+ foreach ($this->provideEnvironment() as $kv) {
+ yield [$kv[0]];
+ }
+ }
+
+ /**
+ * @dataProvider provideEnvironmentKeys
+ */
+ public function testHas (string $key) : void {
+ self::assertTrue(Environment::has($key));
+ }
+
+ /**
+ * @dataProvider provideEnvironment
+ */
+ public function testGet (string $key, string $value) : void {
+ self::assertSame($value, Environment::get($key));
+ }
+
+ /**
+ * @dataProvider provideEnvironment
+ */
+ public function testGetOrWhenKeyExists (string $key, string $value) : void {
+ self::assertSame($value, Environment::getOr($key, "default"));
+ }
+
+ public function testHasWhenKeyDoesNotExist () : void {
+ self::assertFalse(Environment::has("quux"));
+ }
+
+
+ public function testGetWhenKeyDoesNotExist () : void {
+ $this->expectException(InvalidArgumentException::class);
+ Environment::get("quux");
+ }
+
+ public function testGetOrWhenKeyDoesNotExist () : void {
+ self::assertSame("default", Environment::getOr("quux", "default"));
+ }
+
+}
diff --git a/tests/Reflection/CodeFileTest.php b/tests/Reflection/CodeFileTest.php
new file mode 100644
index 0000000..e941b87
--- /dev/null
+++ b/tests/Reflection/CodeFileTest.php
@@ -0,0 +1,94 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Reflection;
+
+use Keruald\OmniTools\OS\CurrentOS;
+use Keruald\OmniTools\OS\CurrentProcess;
+use Keruald\OmniTools\Reflection\CodeFile;
+use Keruald\OmniTools\Tests\WithData;
+use PHPUnit\Framework\TestCase;
+
+use Acme\MockLib\Bar; // a mock class in a mock namespace to test include
+
+class CodeFileTest extends TestCase {
+
+ use WithData;
+
+ /**
+ * @var CodeFile
+ */
+ private $validCodeFile;
+
+ /**
+ * @var CodeFile
+ */
+ private $notExistingCodeFile;
+
+ public function setUp () : void {
+ $file = $this->getDataPath("MockLib/Bar.php");
+ $this->validCodeFile = CodeFile::from($file);
+
+ $this->notExistingCodeFile = CodeFile::from("/notexisting");
+ }
+
+ public function testCanInclude () : void {
+ $this->assertTrue($this->validCodeFile->canBeIncluded());
+ $this->assertFalse($this->notExistingCodeFile->canBeIncluded());
+ }
+
+ public function testCanBeIncludedWhenFileModeForbidsReading () : void {
+ if (CurrentOS::isPureWindows()) {
+ $this->markTestSkipped("This test is intended for UNIX systems.");
+ }
+
+ if (CurrentProcess::isPrivileged()) {
+ $this->markTestSkipped(
+ "This test requires non-root access to run properly."
+ );
+ }
+
+ $file = $this->getNonReadableFile();
+
+ $this->assertFalse(CodeFile::From($file)->canBeIncluded());
+
+ unlink($file);
+ }
+
+ public function testTryInclude () : void {
+ $this->assertTrue($this->validCodeFile->tryInclude());
+ $this->assertTrue(class_exists(Bar::class));
+
+ $this->assertFalse($this->notExistingCodeFile->tryInclude());
+ }
+
+ public function testIsReadable () : void {
+ $this->assertTrue($this->validCodeFile->isReadable());
+ }
+
+ public function testIsReadableWhenFileModeForbidsReading () : void {
+ if (CurrentOS::isPureWindows()) {
+ $this->markTestSkipped("This test is intended for UNIX systems.");
+ }
+
+ if (CurrentProcess::isPrivileged()) {
+ $this->markTestSkipped(
+ "This test requires non-root access to run properly."
+ );
+ }
+
+ $file = $this->getNonReadableFile();
+
+ $this->assertFalse(CodeFile::From($file)->isReadable());
+
+ unlink($file);
+ }
+
+ private function getNonReadableFile () : string {
+ $file = tempnam(sys_get_temp_dir(), "testCodeFile");
+ chmod($file, 0);
+
+ return $file;
+ }
+
+}
diff --git a/tests/Registration/AutoloaderTest.php b/tests/Registration/AutoloaderTest.php
new file mode 100644
index 0000000..a8ee921
--- /dev/null
+++ b/tests/Registration/AutoloaderTest.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Registration;
+
+use Keruald\OmniTools\Registration\Autoloader;
+use Keruald\OmniTools\Tests\WithData;
+use PHPUnit\Framework\TestCase;
+
+use Acme\MockLib\Foo; // a mock class in a mock namespace to test autoload.
+
+class AutoloaderTest extends TestCase {
+
+ use WithData;
+
+ public function testRegisterPSR4 () : void {
+ $class = Foo::class;
+ $this->assertFalse(
+ class_exists($class),
+ "Please reconfigure the test suite not to include the $class class."
+ );
+
+ Autoloader::registerPSR4("Acme\\MockLib\\", $this->getDataPath("MockLib"));
+ $this->assertTrue(class_exists($class));
+ }
+
+ public function testGetLibraryPath () : void {
+ $this->assertStringStartsWith(
+ dirname(Autoloader::getLibraryPath()), // lib is in <root>/src
+ __DIR__ // we are in <root>/tests/…
+ );
+ }
+
+ public function testRegister () : void {
+ $count = count(spl_autoload_functions());
+
+ Autoloader::selfRegister();
+
+ $this->assertEquals(++$count, count(spl_autoload_functions()));
+ }
+
+}
diff --git a/tests/Registration/PSR4/PSR4NamespaceTest.php b/tests/Registration/PSR4/PSR4NamespaceTest.php
new file mode 100644
index 0000000..93dffc5
--- /dev/null
+++ b/tests/Registration/PSR4/PSR4NamespaceTest.php
@@ -0,0 +1,84 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Registration\PSR4;
+
+use Keruald\OmniTools\Registration\PSR4\PSR4Namespace;
+
+use Keruald\OmniTools\Tests\WithData;
+use PHPUnit\Framework\TestCase;
+
+class PSR4NamespaceTest extends TestCase {
+
+ use WithData;
+
+ ///
+ /// Discovery tests
+ ///
+
+ const ALL_CLASSES = [
+ "Acme\\SolarSystemLib\\Sun",
+ "Acme\\SolarSystemLib\\Planets\\Pluton",
+ "Acme\\SolarSystemLib\\Planets\\Inner\\Mercure",
+ "Acme\\SolarSystemLib\\Planets\\Inner\\Venus",
+ ];
+
+ /**
+ * @dataProvider provideClasses
+ */
+ public function testDiscover (
+ string $path, string $prefix, array $expected
+ ) : void {
+ $ns = new PSR4Namespace($prefix, $this->getDataPath($path));
+
+ $this->assertEquals($expected, $ns->discover());
+ }
+
+ public function testDiscoverRecursive () : void {
+ $baseDirectory = $this->getDataPath("SolarSystemLib");
+ $ns = new PSR4Namespace("Acme\\SolarSystemLib", $baseDirectory);
+
+ $this->assertEquals(self::ALL_CLASSES, $ns->discoverRecursive());
+ }
+
+ public function testDiscoverAllClasses () : void {
+ $actual = PSR4Namespace::discoverAllClasses(
+ "Acme\\SolarSystemLib",
+ $this->getDataPath("SolarSystemLib"),
+ );
+
+ $this->assertEquals(self::ALL_CLASSES, $actual);
+
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideClasses () : iterable {
+ // [string $path, string $prefix, string[] $expectedClasses]
+ yield ["MockLib", "Acme\\MockLib", [
+ "Acme\\MockLib\\Bar",
+ "Acme\\MockLib\\Foo",
+ ]];
+
+ yield ["SolarSystemLib", "Acme\\SolarSystemLib", [
+ "Acme\\SolarSystemLib\\Sun",
+ ]];
+
+ yield ["SolarSystemLib/Planets", "Acme\\SolarSystemLib\\Planets", [
+ "Acme\\SolarSystemLib\\Planets\\Pluton",
+ ]];
+
+ yield [
+ "SolarSystemLib/Planets/Inner",
+ "Acme\\SolarSystemLib\\Planets\\Inner",
+ [
+ "Acme\\SolarSystemLib\\Planets\\Inner\\Mercure",
+ "Acme\\SolarSystemLib\\Planets\\Inner\\Venus",
+ ]
+ ];
+
+ yield ["NotExisting", "AnyPrefix", []];
+ }
+}
diff --git a/tests/Registration/PSR4AutoloaderTest.php b/tests/Registration/PSR4AutoloaderTest.php
new file mode 100644
index 0000000..7d21da5
--- /dev/null
+++ b/tests/Registration/PSR4AutoloaderTest.php
@@ -0,0 +1,34 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Registration;
+
+use Keruald\OmniTools\Registration\PSR4\Solver;
+use PHPUnit\Framework\TestCase;
+
+class PSR4AutoloaderTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ /**
+ * @dataProvider providePaths
+ */
+ public function testGetPathFor (string $class, string $expected) : void {
+ $this->assertEquals($expected, Solver::getPathFor($class));
+ }
+
+ ///
+ /// Data provider
+ ///
+
+ public function providePaths () : iterable {
+ // Example from PSR-4 canonical document
+ yield ['File_Writer', 'File_Writer.php'];
+ yield ['Response\Status', 'Response/Status.php'];
+ yield ['Request', 'Request.php'];
+ }
+
+
+}
diff --git a/tests/Strings/Multibyte/OmniStringTest.php b/tests/Strings/Multibyte/OmniStringTest.php
new file mode 100644
index 0000000..cf7d364
--- /dev/null
+++ b/tests/Strings/Multibyte/OmniStringTest.php
@@ -0,0 +1,124 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Strings\Multibyte;
+
+use Keruald\OmniTools\Strings\Multibyte\OmniString;
+use PHPUnit\Framework\TestCase;
+
+class OmniStringTest extends TestCase {
+
+ /**
+ * @var OmniString
+ */
+ private $string;
+
+ protected function setUp () : void {
+ $this->string = new OmniString("foo");
+ }
+
+ public function testToString () : void {
+ $this->assertEquals("foo", (string)$this->string);
+ $this->assertEquals("foo", $this->string->__toString());
+ }
+
+ public function testPad () : void {
+ $paddedString = $this->string->pad(9, '-=-', STR_PAD_BOTH);
+ $this->assertEquals("-=-foo-=-", $paddedString);
+ }
+
+ public function testStartsWith () : void {
+ $this->assertTrue($this->string->startsWith("fo"));
+ $this->assertTrue($this->string->startsWith(""));
+ $this->assertTrue($this->string->startsWith("foo"));
+
+ $this->assertFalse($this->string->startsWith("Fo"));
+ $this->assertFalse($this->string->startsWith("bar"));
+ }
+
+ public function testEndsWith () : void {
+ $this->assertTrue($this->string->endsWith("oo"));
+ $this->assertTrue($this->string->endsWith(""));
+ $this->assertTrue($this->string->endsWith("foo"));
+
+ $this->assertFalse($this->string->endsWith("oO"));
+ $this->assertFalse($this->string->endsWith("bar"));
+ }
+
+ public function testLen () : void {
+ $this->assertEquals(3, $this->string->len());
+ }
+
+ /**
+ * @dataProvider provideCharactersArrays
+ */
+ public function testGetChars (string $string, array $expectedCharacters) : void {
+ $actualCharacters = (new OmniString($string))->getChars();
+
+ $this->assertEquals($expectedCharacters, $actualCharacters);
+ }
+
+ /**
+ * @dataProvider provideCharactersBigrams
+ */
+ public function testBigrams (string $string, array $expectedBigrams) : void {
+ $actualBigrams = (new OmniString($string))->getBigrams();
+
+ $this->assertEquals($expectedBigrams, $actualBigrams);
+ }
+
+ /**
+ * @dataProvider provideExplosions
+ */
+ public function testExplode (string $delimiter, string $imploded, array $exploded) : void {
+ $actual = (new OmniString($imploded))
+ ->explode($delimiter)
+ ->toArray();
+
+ $this->assertEquals($exploded, $actual);
+ }
+
+ public function testExplodeWithEmptyOmniArray () : void {
+ $array = (new OmniString("foo"))
+ ->explode("", -1);
+
+ $this->assertEquals(0, count($array->toArray()));
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function provideCharactersArrays () : iterable {
+ yield ["foo", ['f', 'o', 'o']];
+
+ yield [
+ 'àèòàFOOàèòà',
+ ['à', 'è', 'ò', 'à', 'F', 'O', 'O', 'à', 'è', 'ò', 'à']
+ ];
+
+ yield ["🇩🇪", ["🇩", "🇪"]];
+
+ yield ["", []];
+ }
+
+ public function provideCharactersBigrams () : iterable {
+ yield ["foo", ['fo', 'oo']];
+
+ yield ["night", ['ni', 'ig', 'gh', 'ht']];
+
+ yield ["🇩🇪", ["🇩🇪"]];
+
+ yield ["", []];
+ }
+ public function provideExplosions () : iterable {
+ yield ["/", "a/b/c", ['a', 'b', 'c']];
+ yield ["/", "abc", ['abc']];
+ yield ["/", "/b/c", ['', 'b', 'c']];
+ yield ["/", "a/b/", ['a', 'b', '']];
+
+ yield ["", "a/b/c", ['a/b/c']];
+ yield ["x", "a/b/c", ['a/b/c']];
+ }
+
+}
diff --git a/tests/Strings/Multibyte/StringPadTest.php b/tests/Strings/Multibyte/StringPadTest.php
new file mode 100644
index 0000000..b1ca814
--- /dev/null
+++ b/tests/Strings/Multibyte/StringPadTest.php
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Strings\Multibyte;
+
+use Keruald\OmniTools\Strings\Multibyte\StringPad as Pad;
+use PHPUnit\Framework\TestCase;
+
+use InvalidArgumentException;
+
+class StringPadTest extends TestCase {
+
+ public function testSetPadTypeWithBogusValue () : void {
+ $this->expectException(InvalidArgumentException::class);
+
+ $pad = new Pad;
+ $pad->setPadType(7);
+ }
+
+ public function testIsValidPadType () : void {
+ $this->assertTrue(Pad::isValidPadType(STR_PAD_LEFT));
+ $this->assertTrue(Pad::isValidPadType(STR_PAD_RIGHT));
+ $this->assertTrue(Pad::isValidPadType(STR_PAD_BOTH));
+
+ $this->assertFalse(Pad::isValidPadType(7));
+ }
+
+ public function testSetPadTypeWithBogusEncoding () : void {
+ $this->expectException(InvalidArgumentException::class);
+
+ $pad = new Pad;
+ $pad->setEncoding("notexisting");
+ }
+
+ public function testSetLeftPad () : void {
+ $pad = new Pad;
+ $pad->setLeftPad();
+
+ $this->assertEquals(STR_PAD_LEFT, $pad->getPadType());
+ }
+
+ public function testSetRightPad () : void {
+ $pad = new Pad;
+ $pad->setRightPad();
+
+ $this->assertEquals(STR_PAD_RIGHT, $pad->getPadType());
+ }
+
+ public function testSetBothPad () : void {
+ $pad = new Pad;
+ $pad->setBothPad();
+
+ $this->assertEquals(STR_PAD_BOTH, $pad->getPadType());
+ }
+
+}
diff --git a/tests/Strings/Multibyte/StringUtilitiesTest.php b/tests/Strings/Multibyte/StringUtilitiesTest.php
new file mode 100644
index 0000000..1bbdffe
--- /dev/null
+++ b/tests/Strings/Multibyte/StringUtilitiesTest.php
@@ -0,0 +1,122 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Strings\Multibyte;
+
+use Keruald\OmniTools\Strings\Multibyte\StringUtilities;
+use PHPUnit\Framework\TestCase;
+
+class StringUtilitiesTest extends TestCase {
+
+ ///
+ /// Tests
+ ///
+
+ /**
+ * @dataProvider providePadding
+ */
+ public function testPad (
+ string $expected,
+ string $input, int $padLength, string $padString, int $padType
+ ) : void {
+ $paddedString = StringUtilities::pad(
+ $input, $padLength, $padString, $padType, "UTF-8"
+ );
+
+ $this->assertEquals($expected, $paddedString);
+ }
+
+ public function testPadWithDefaultArguments () : void {
+ $this->assertEquals("foo ", StringUtilities::pad("foo", 4));
+ $this->assertEquals("foo_", StringUtilities::pad("foo", 4, '_'));
+ $this->assertEquals("_foo", StringUtilities::pad("foo", 4, '_', STR_PAD_LEFT));
+ }
+
+ public function testSupportedEncoding () : void {
+ $this->assertTrue(StringUtilities::isSupportedEncoding("UTF-8"));
+ $this->assertFalse(StringUtilities::isSupportedEncoding("notexisting"));
+ }
+
+ public function testStartsWith () : void {
+ $this->assertTrue(StringUtilities::startsWith("foo", "fo"));
+ $this->assertTrue(StringUtilities::startsWith("foo", ""));
+ $this->assertTrue(StringUtilities::startsWith("foo", "foo"));
+
+ $this->assertFalse(StringUtilities::startsWith("foo", "bar"));
+ }
+
+ public function testEndsWith () : void {
+ $this->assertTrue(StringUtilities::endsWith("foo", "oo"));
+ $this->assertTrue(StringUtilities::endsWith("foo", ""));
+ $this->assertTrue(StringUtilities::endsWith("foo", "foo"));
+
+ $this->assertFalse(StringUtilities::endsWith("foo", "oO"));
+ $this->assertFalse(StringUtilities::endsWith("foo", "bar"));
+ }
+
+ /**
+ * @dataProvider provideBase64
+ */
+ public function testEncodeInBase64 (string $decoded, string $encoded) : void {
+ $actual = StringUtilities::encodeInBase64($decoded);
+ $this->assertEquals($encoded, $actual);
+ }
+
+ /**
+ * @dataProvider provideBase64
+ */
+ public function testDecodeFromBase64 (string $decoded, string $encoded) : void {
+ $actual = StringUtilities::decodeFromBase64($encoded);
+ $this->assertEquals($decoded, $actual);
+ }
+
+ ///
+ /// Data providers
+ ///
+
+ public function providePadding () : iterable {
+ // Tests from http://3v4l.org/UnXTF
+ // http://web.archive.org/web/20150711100913/http://3v4l.org/UnXTF
+
+ yield ['àèòàFOOàèòà', "FOO", 11, "àèò", STR_PAD_BOTH];
+ yield ['àèòFOOàèòà', "FOO", 10, "àèò", STR_PAD_BOTH];
+ yield ['àèòBAAZàèòà', "BAAZ", 11, "àèò", STR_PAD_BOTH];
+ yield ['àèòBAAZàèò', "BAAZ", 10, "àèò", STR_PAD_BOTH];
+ yield ['FOOBAR', "FOOBAR", 6, "àèò", STR_PAD_BOTH];
+ yield ['FOOBAR', "FOOBAR", 1, "àèò", STR_PAD_BOTH];
+ yield ['FOOBAR', "FOOBAR", 0, "àèò", STR_PAD_BOTH];
+ yield ['FOOBAR', "FOOBAR", -10, "àèò", STR_PAD_BOTH];
+
+ yield ['àèòàèòàèFOO', "FOO", 11, "àèò", STR_PAD_LEFT];
+ yield ['àèòàèòàFOO', "FOO", 10, "àèò", STR_PAD_LEFT];
+ yield ['àèòàèòàBAAZ', "BAAZ", 11, "àèò", STR_PAD_LEFT];
+ yield ['àèòàèòBAAZ', "BAAZ", 10, "àèò", STR_PAD_LEFT];
+ yield ['FOOBAR', "FOOBAR", 6, "àèò", STR_PAD_LEFT];
+ yield ['FOOBAR', "FOOBAR", 1, "àèò", STR_PAD_LEFT];
+ yield ['FOOBAR', "FOOBAR", 0, "àèò", STR_PAD_LEFT];
+ yield ['FOOBAR', "FOOBAR", -10, "àèò", STR_PAD_LEFT];
+
+ yield ['FOOàèòàèòàè', "FOO", 11, "àèò", STR_PAD_RIGHT];
+ yield ['FOOàèòàèòà', "FOO", 10, "àèò", STR_PAD_RIGHT];
+ yield ['BAAZàèòàèòà', "BAAZ", 11, "àèò", STR_PAD_RIGHT];
+ yield ['BAAZàèòàèò', "BAAZ", 10, "àèò", STR_PAD_RIGHT];
+ yield ['FOOBAR', "FOOBAR", 6, "àèò", STR_PAD_RIGHT];
+ yield ['FOOBAR', "FOOBAR", 1, "àèò", STR_PAD_RIGHT];
+ yield ['FOOBAR', "FOOBAR", 0, "àèò", STR_PAD_RIGHT];
+ yield ['FOOBAR', "FOOBAR", -10, "àèò", STR_PAD_RIGHT];
+ }
+
+ public function provideBase64 () : iterable {
+ yield ['foo', 'Zm9v', "This is the regular base test without any exception."];
+ yield ['', '', "An empty string should remain an empty string."];
+ yield [
+ "J'ai fait mes 60 prières par terre dans la poudrière.",
+ 'SidhaSBmYWl0IG1lcyA2MCBwcmnDqHJlcyBwYXIgdGVycmUgZGFucyBsYSBwb3VkcmnDqHJlLg',
+ "No padding should be used."
+ ];
+ yield [
+ "àèòàFOOàèòà", "w6DDqMOyw6BGT0_DoMOow7LDoA",
+ "Slashes / should be replaced by underscores _."
+ ];
+ }
+}
diff --git a/tests/Strings/SorensenDiceCoefficientTest.php b/tests/Strings/SorensenDiceCoefficientTest.php
new file mode 100644
index 0000000..2e7d17c
--- /dev/null
+++ b/tests/Strings/SorensenDiceCoefficientTest.php
@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests\Strings;
+
+use Keruald\OmniTools\Strings\SorensenDiceCoefficient;
+use PHPUnit\Framework\TestCase;
+
+class SorensenDiceCoefficientTest extends TestCase {
+
+ public function testCoefficient () : void {
+ $actual = new SorensenDiceCoefficient('night', 'nacht');
+
+ $this->assertEquals(0.25, $actual->compute());
+ }
+
+}
diff --git a/tests/WithData.php b/tests/WithData.php
new file mode 100644
index 0000000..a316deb
--- /dev/null
+++ b/tests/WithData.php
@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\OmniTools\Tests;
+
+trait WithData {
+
+ protected function getDataPath (string $file) : string {
+ return $this->getDataDirectory() . "/" . $file;
+ }
+
+ protected function getDataDirectory () : string {
+ return __DIR__ . "/data";
+ }
+
+}
diff --git a/tests/data/MockLib/Bar.php b/tests/data/MockLib/Bar.php
new file mode 100644
index 0000000..55f803b
--- /dev/null
+++ b/tests/data/MockLib/Bar.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Acme\MockLib;
+
+/**
+ * Class Bar
+ *
+ * This class allows checking if the autoloader can register
+ * the Acme\MockLib namespace and include this file.
+ *
+ * @package Acme\MockLib
+ */
+class Bar {
+
+}
diff --git a/tests/data/MockLib/Foo.php b/tests/data/MockLib/Foo.php
new file mode 100644
index 0000000..c33d6a6
--- /dev/null
+++ b/tests/data/MockLib/Foo.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Acme\MockLib;
+
+/**
+ * Class Foo
+ *
+ * This class allows checking if the autoloader can register
+ * the Acme\MockLib namespace and include this file.
+ *
+ * @package Acme\MockLib
+ */
+class Foo {
+
+}
diff --git a/tests/data/SolarSystemLib/Planets/Inner/Mercure.php b/tests/data/SolarSystemLib/Planets/Inner/Mercure.php
new file mode 100644
index 0000000..73763dd
--- /dev/null
+++ b/tests/data/SolarSystemLib/Planets/Inner/Mercure.php
@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+
+namespace Acme\SolarSystemLib\Planets\Inner;
+
+class Mercure {
+
+}
diff --git a/tests/data/SolarSystemLib/Planets/Inner/Venus.php b/tests/data/SolarSystemLib/Planets/Inner/Venus.php
new file mode 100644
index 0000000..0546152
--- /dev/null
+++ b/tests/data/SolarSystemLib/Planets/Inner/Venus.php
@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+
+namespace Acme\SolarSystemLib\Planets\Inner;
+
+class Venus {
+
+}
diff --git a/tests/data/SolarSystemLib/Planets/Pluton.php b/tests/data/SolarSystemLib/Planets/Pluton.php
new file mode 100644
index 0000000..5e7c5ec
--- /dev/null
+++ b/tests/data/SolarSystemLib/Planets/Pluton.php
@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+
+namespace Acme\SolarSystemLib\Planets;
+
+class Pluton {
+
+}
diff --git a/tests/data/SolarSystemLib/Sun.php b/tests/data/SolarSystemLib/Sun.php
new file mode 100644
index 0000000..aa283f1
--- /dev/null
+++ b/tests/data/SolarSystemLib/Sun.php
@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+
+namespace Acme\SolarSystemLib;
+
+class Sun {
+
+}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Nov 24, 23:08 (15 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2259124
Default Alt Text
(190 KB)

Event Timeline