├── .nix ├── shell │ ├── .gitignore │ └── starship.toml.dist ├── php │ └── lib │ │ ├── xdebug.ini.dist │ │ ├── .gitignore │ │ ├── pcov.ini.dist │ │ ├── php.ini.dist │ │ └── blackfire.ini.dist └── pkgs │ └── php │ └── package.nix ├── src ├── AST │ ├── Node.php │ ├── Type.php │ ├── Expander.php │ └── Pattern.php ├── Exception │ ├── Exception.php │ ├── InvalidArgumentException.php │ ├── UnknownExpanderException.php │ ├── InvalidExpanderTypeException.php │ ├── UnknownExpanderClassException.php │ ├── PatternException.php │ └── UnknownTypeException.php ├── Factory.php ├── Matcher │ ├── ArrayMatcher │ │ ├── Difference.php │ │ ├── StringDifference.php │ │ ├── ValuePatternDifference.php │ │ └── Diff.php │ ├── Pattern │ │ ├── Pattern.php │ │ ├── Expander │ │ │ ├── BacktraceBehavior.php │ │ │ ├── Optional.php │ │ │ ├── Before.php │ │ │ ├── After.php │ │ │ ├── IsEmpty.php │ │ │ ├── IsNotEmpty.php │ │ │ ├── IsIp.php │ │ │ ├── IsUrl.php │ │ │ ├── ExpanderMatch.php │ │ │ ├── IsEmail.php │ │ │ ├── Count.php │ │ │ ├── InArray.php │ │ │ ├── IsDateTime.php │ │ │ ├── MatchRegex.php │ │ │ ├── NotContains.php │ │ │ ├── Contains.php │ │ │ ├── OneOf.php │ │ │ ├── IsInDateFormat.php │ │ │ ├── IsTzOffset.php │ │ │ ├── IsTzIdentifier.php │ │ │ ├── IsTzAbbreviation.php │ │ │ ├── LowerThan.php │ │ │ ├── GreaterThan.php │ │ │ ├── StartsWith.php │ │ │ ├── EndsWith.php │ │ │ ├── DateTimeComparisonTrait.php │ │ │ ├── HasProperty.php │ │ │ └── Repeat.php │ │ ├── Assert │ │ │ ├── Xml.php │ │ │ └── Json.php │ │ ├── PatternExpander.php │ │ ├── RegexConverter.php │ │ └── TypePattern.php │ ├── Matcher.php │ ├── ValueMatcher.php │ ├── WildcardMatcher.php │ ├── CallbackMatcher.php │ ├── ScalarMatcher.php │ ├── NullMatcher.php │ ├── ExpressionMatcher.php │ ├── BooleanMatcher.php │ ├── NumberMatcher.php │ ├── OrMatcher.php │ ├── DoubleMatcher.php │ ├── StringMatcher.php │ ├── IntegerMatcher.php │ ├── XmlMatcher.php │ ├── JsonMatcher.php │ ├── DateMatcher.php │ ├── TimeMatcher.php │ ├── UuidMatcher.php │ ├── DateTimeMatcher.php │ ├── JsonObjectMatcher.php │ ├── TimeZoneMatcher.php │ ├── ChainMatcher.php │ ├── UlidMatcher.php │ ├── TextMatcher.php │ └── ArrayMatcher.php ├── PHPUnit │ ├── PHPMatcherTestCase.php │ ├── PHPMatcherAssertions.php │ └── PHPMatcherConstraint.php ├── Value │ └── SingleLineString.php ├── Backtrace.php ├── Matcher.php ├── Backtrace │ ├── VoidBacktrace.php │ └── InMemoryBacktrace.php ├── PHPMatcher.php ├── Factory │ └── MatcherFactory.php ├── Lexer.php ├── Parser │ └── ExpanderInitializer.php └── Parser.php ├── infection.json ├── phpbench.json ├── phpunit.xml.dist ├── LICENCE ├── shell.nix ├── UPGRADE.md ├── CONTRIBUTING.md ├── composer.json ├── CODE_OF_CONDUCT.md ├── .php-cs-fixer.php └── README.md /.nix/shell/.gitignore: -------------------------------------------------------------------------------- 1 | starship.toml -------------------------------------------------------------------------------- /.nix/php/lib/xdebug.ini.dist: -------------------------------------------------------------------------------- 1 | [xdebug] 2 | xdebug.mode=debug -------------------------------------------------------------------------------- /.nix/php/lib/.gitignore: -------------------------------------------------------------------------------- 1 | php.ini 2 | blackfire.ini 3 | xdebug.ini -------------------------------------------------------------------------------- /src/AST/Node.php: -------------------------------------------------------------------------------- 1 | type = $type; 14 | } 15 | 16 | public function __toString() : string 17 | { 18 | return $this->type; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Pattern.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Assert/Xml.php: -------------------------------------------------------------------------------- 1 | description = $description; 14 | } 15 | 16 | public function format() : string 17 | { 18 | return $this->description; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /infection.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 1000, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "var/logs/infection.log", 10 | "summary": "var/logs/infection_summary.log", 11 | "debug": "var/logs/infection_summary.log" 12 | }, 13 | "minMsi": 60, 14 | "minCoveredMsi": 65, 15 | "mutators": { 16 | "@default": true 17 | }, 18 | "testFramework": "phpunit", 19 | "phpUnit": { 20 | "customPath": "vendor\/bin\/phpunit" 21 | }, 22 | "bootstrap": "./vendor/autoload.php" 23 | } 24 | -------------------------------------------------------------------------------- /src/Exception/UnknownTypeException.php: -------------------------------------------------------------------------------- 1 | type = '@' . $type . '@'; 14 | parent::__construct(\sprintf('Type pattern "%s" is not supported.', $this->type), 0, null); 15 | } 16 | 17 | public function getType() : string 18 | { 19 | return $this->type; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "runner.bootstrap": "./vendor/autoload.php", 3 | "runner.path": "benchmark", 4 | "runner.retry_threshold": 5, 5 | "runner.progress": "dots", 6 | "report.generators": { 7 | "matcher": { 8 | "generator": "expression", 9 | "cols": { 10 | "benchmark": null, 11 | "subject": null, 12 | "revs": null, 13 | "its": null, 14 | "mem_peak": null, 15 | "mode": null, 16 | "rstdev": null 17 | } 18 | } 19 | }, 20 | "storage.xml_storage_path": "./var/phpbench" 21 | } -------------------------------------------------------------------------------- /src/PHPUnit/PHPMatcherTestCase.php: -------------------------------------------------------------------------------- 1 | assertThat($value, self::matchesPattern($pattern), $message); 14 | } 15 | 16 | protected static function matchesPattern($pattern) : PHPMatcherConstraint 17 | { 18 | return new PHPMatcherConstraint($pattern); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Matcher/Matcher.php: -------------------------------------------------------------------------------- 1 | error; 17 | } 18 | 19 | /** 20 | * @inheritdoc 21 | */ 22 | public function match($value, $pattern) : bool 23 | { 24 | return $value === $pattern; 25 | } 26 | 27 | public function clearError() : void 28 | { 29 | $this->error = null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Matcher/ValueMatcher.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | $this->pattern = $pattern; 19 | $this->path = $path; 20 | } 21 | 22 | public function format() : string 23 | { 24 | return "Value \"{$this->value}\" does not match pattern \"{$this->pattern}\" at path: \"{$this->path}\""; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Value/SingleLineString.php: -------------------------------------------------------------------------------- 1 | raw = $raw; 16 | } 17 | 18 | public function __toString() : string 19 | { 20 | $normalized = $this->raw; 21 | 22 | if (Json::isValid($this->raw)) { 23 | $normalized = Json::reformat($this->raw); 24 | } elseif (Json::isValid(Json::transformPattern($this->raw))) { 25 | $normalized = Json::reformat(Json::transformPattern($this->raw)); 26 | } 27 | 28 | return \str_replace(["\r\n", "\r", "\n"], ' ', $normalized); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Matcher/ArrayMatcher/Diff.php: -------------------------------------------------------------------------------- 1 | differences = $difference; 17 | } 18 | 19 | public function add(Difference $difference) : self 20 | { 21 | return new self(...\array_merge($this->differences, [$difference])); 22 | } 23 | 24 | /** 25 | * @return Difference[] 26 | */ 27 | public function all() : array 28 | { 29 | return $this->differences; 30 | } 31 | 32 | public function count() : int 33 | { 34 | return \count($this->differences); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/Optional.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 26 | $this->backtrace->expanderSucceed(self::NAME, $value); 27 | 28 | return true; 29 | } 30 | 31 | public function getError() : ?string 32 | { 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PHPUnit/PHPMatcherAssertions.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 18 | } 19 | 20 | protected function assertMatchesPattern($pattern, $value, string $message = '') : void 21 | { 22 | TestCase::assertThat($value, self::matchesPattern($pattern, $this->backtrace), $message); 23 | } 24 | 25 | protected static function matchesPattern($pattern, ?Backtrace $backtrace = null) : PHPMatcherConstraint 26 | { 27 | return new PHPMatcherConstraint($pattern, $backtrace); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Backtrace.php: -------------------------------------------------------------------------------- 1 | name = $name; 19 | $this->arguments = []; 20 | } 21 | 22 | public function getName() : string 23 | { 24 | return $this->name; 25 | } 26 | 27 | public function addArgument($argument) : void 28 | { 29 | $this->arguments[] = $argument; 30 | } 31 | 32 | public function hasArguments() : bool 33 | { 34 | return (bool) \count($this->arguments); 35 | } 36 | 37 | /** 38 | * @return mixed[] 39 | */ 40 | public function getArguments() : array 41 | { 42 | return $this->arguments; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/AST/Pattern.php: -------------------------------------------------------------------------------- 1 | expanders = []; 19 | $this->type = $type; 20 | } 21 | 22 | public function getType() : Type 23 | { 24 | return $this->type; 25 | } 26 | 27 | public function hasExpanders() : bool 28 | { 29 | return (bool) \count($this->expanders); 30 | } 31 | 32 | /** 33 | * @return Expander[] 34 | */ 35 | public function getExpanders() : array 36 | { 37 | return $this->expanders; 38 | } 39 | 40 | public function addExpander(Expander $expander) : void 41 | { 42 | $this->expanders[] = $expander; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Matcher/WildcardMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 21 | } 22 | 23 | public function match($value, $pattern) : bool 24 | { 25 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 26 | 27 | return true; 28 | } 29 | 30 | public function canMatch($pattern) : bool 31 | { 32 | $result = \is_string($pattern) && 0 !== \preg_match(self::MATCH_PATTERN, $pattern); 33 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 34 | 35 | return $result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/Before.php: -------------------------------------------------------------------------------- 1 | isAfterOrEqualTo($this->boundary)) { 23 | $this->error = \sprintf('Value "%s" is after or equal to "%s".', $value, new StringConverter($this->boundary)); 24 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 25 | 26 | return false; 27 | } 28 | 29 | $this->backtrace->expanderSucceed(self::NAME, $value); 30 | 31 | return true; 32 | } 33 | 34 | protected static function getName() : string 35 | { 36 | return self::NAME; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/After.php: -------------------------------------------------------------------------------- 1 | isBeforeOrEqualTo($this->boundary)) { 23 | $this->error = \sprintf('Value "%s" is before or equal to "%s".', new StringConverter($value), new StringConverter($this->boundary)); 24 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 25 | 26 | return false; 27 | } 28 | 29 | $this->backtrace->expanderSucceed(self::NAME, $value); 30 | 31 | return true; 32 | } 33 | 34 | protected static function getName() : string 35 | { 36 | return self::NAME; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/RegexConverter.php: -------------------------------------------------------------------------------- 1 | getType()) { 16 | case 'string': 17 | case 'wildcard': 18 | case '*': 19 | return '(.+)'; 20 | case 'number': 21 | return '(\\-?[0-9]*[\\.|\\,]?[0-9]*)'; 22 | case 'integer': 23 | return '(\\-?[0-9]*)'; 24 | case 'double': 25 | return '(\\-?[0-9]*[\\.|\\,][0-9]*)'; 26 | case 'uuid': 27 | return '(' . UuidMatcher::UUID_PATTERN . ')'; 28 | case 'ulid': 29 | return '(' . UlidMatcher::ULID_PATTERN . ')'; 30 | 31 | default: 32 | throw new UnknownTypeException($typePattern->getType()); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2021 Michal Dabrowski, Norbert Orzechowicz 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsEmpty.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (!empty($value)) { 31 | $this->error = \sprintf('Value %s is not empty.', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | $this->backtrace->expanderSucceed(self::NAME, $value); 38 | 39 | return true; 40 | } 41 | 42 | public function getError() : ?string 43 | { 44 | return $this->error; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsNotEmpty.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (false === $value || (empty($value) && '0' != $value)) { 31 | $this->error = \sprintf('Value %s is not blank.', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | $this->backtrace->expanderSucceed(self::NAME, $value); 38 | 39 | return true; 40 | } 41 | 42 | public function getError() : ?string 43 | { 44 | return $this->error; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Matcher/CallbackMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 17 | } 18 | 19 | public function match($value, $pattern) : bool 20 | { 21 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 22 | $result = (bool) $pattern->__invoke($value); 23 | 24 | if ($result) { 25 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 26 | } else { 27 | $this->error = \sprintf('Callback matcher failed for value %s', new StringConverter($value)); 28 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 29 | } 30 | 31 | return $result; 32 | } 33 | 34 | public function canMatch($pattern) : bool 35 | { 36 | $result = \is_object($pattern) && \is_callable($pattern); 37 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 38 | 39 | return $result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Matcher.php: -------------------------------------------------------------------------------- 1 | valueMatcher = $valueMatcher; 18 | $this->backtrace = $backtrace; 19 | } 20 | 21 | public function match($value, $pattern) : bool 22 | { 23 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 24 | 25 | $result = $this->valueMatcher->match($value, $pattern); 26 | 27 | if ($result) { 28 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 29 | $this->valueMatcher->clearError(); 30 | } else { 31 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->valueMatcher->getError()); 32 | } 33 | 34 | return $result; 35 | } 36 | 37 | /** 38 | * @return null|string 39 | */ 40 | public function getError() : ?string 41 | { 42 | return $this->valueMatcher->getError(); 43 | } 44 | 45 | public function backtrace() : Backtrace 46 | { 47 | return $this->backtrace; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Backtrace/VoidBacktrace.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 18 | } 19 | 20 | public function match($value, $pattern) : bool 21 | { 22 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 23 | 24 | if ($value !== $pattern) { 25 | $this->error = \sprintf( 26 | '"%s" does not match "%s".', 27 | new SingleLineString((string) new StringConverter($value)), 28 | new SingleLineString((string) new StringConverter($pattern)) 29 | ); 30 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 31 | 32 | return false; 33 | } 34 | 35 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 36 | 37 | return true; 38 | } 39 | 40 | public function canMatch($pattern) : bool 41 | { 42 | $result = \is_scalar($pattern); 43 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 44 | 45 | return $result; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.nix/php/lib/blackfire.ini.dist: -------------------------------------------------------------------------------- 1 | [blackfire] 2 | ; On Windows use the following configuration: 3 | ; extension=php_blackfire.dll 4 | 5 | ; Sets fine-grained configuration for Probe. 6 | ; This should be left blank in most cases. For most installs, 7 | ; the server credentials should only be set in the agent. 8 | ;blackfire.server_id = 9 | 10 | ; Sets fine-grained configuration for Probe. 11 | ; This should be left blank in most cases. For most installs, 12 | ; the server credentials should only be set in the agent. 13 | ;blackfire.server_token = 14 | 15 | ; Log verbosity level: 16 | ; 4: debug 17 | ; 3: info 18 | ; 2: warning; 19 | ; 1: error 20 | ;blackfire.log_level = 1 21 | 22 | ; Log file (STDERR by default) 23 | ;blackfire.log_file = /tmp/blackfire.log 24 | 25 | ; Add the stacktrace to the probe logs when a segmentation fault occurs. 26 | ; Debug option inactive on Windows and Alpine. 27 | ;blackfire.debug.sigsegv_handler = 0 28 | 29 | ; Sets the socket where the agent is listening. 30 | ; Possible value can be a unix socket or a TCP address. 31 | ; Defaults values are: 32 | ; - Linux: unix:///var/run/blackfire/agent.sock 33 | ; - macOS amd64: unix:///usr/local/var/run/blackfire-agent.sock 34 | ; - macOS arm64 (M1): unix:///opt/homebrew/var/run/blackfire-agent.sock 35 | ; - Windows: tcp://127.0.0.1:8307 36 | ;blackfire.agent_socket = unix:///var/run/blackfire/agent.sock 37 | 38 | ; Enables Blackfire Monitoring 39 | ; Enabled by default since version 1.61.0 40 | ;blackfire.apm_enabled = 1 -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | nixpkgs = fetchTarball { 3 | # Oct 31, 2025 4 | url = "https://github.com/NixOS/nixpkgs/archive/66a437ebcf6160152336e801a7ec289ba2aba3c5.tar.gz"; 5 | }; 6 | 7 | lockedPkgs = import nixpkgs { 8 | config = { 9 | allowUnfree = true; 10 | }; 11 | }; 12 | in 13 | { 14 | pkgs ? lockedPkgs, 15 | php-version ? 8.3, 16 | with-blackfire ? false, 17 | with-xdebug ? false, 18 | with-pcov ? !with-blackfire 19 | }: 20 | 21 | let 22 | base-php = if php-version == 8.3 then 23 | pkgs.php83 24 | else if php-version == 8.4 then 25 | pkgs.php84 26 | else 27 | throw "Unknown php version ${php-version}"; 28 | 29 | php = pkgs.callPackage ./.nix/pkgs/php/package.nix { 30 | php = base-php; 31 | inherit with-pcov with-xdebug with-blackfire; 32 | }; 33 | in 34 | pkgs.mkShell { 35 | buildInputs = [ 36 | php 37 | php.packages.composer 38 | pkgs.starship 39 | pkgs.figlet 40 | pkgs.act 41 | ] 42 | ++ pkgs.lib.optional with-blackfire pkgs.blackfire 43 | ; 44 | 45 | shellHook = '' 46 | if [ -f "$PWD/.nix/shell/starship.toml" ]; then 47 | export STARSHIP_CONFIG="$PWD/.nix/shell/starship.toml" 48 | else 49 | export STARSHIP_CONFIG="$PWD/.nix/shell/starship.toml.dist" 50 | fi 51 | 52 | eval "$(${pkgs.starship}/bin/starship init bash)" 53 | 54 | clear 55 | figlet "PHP Matcher" 56 | ''; 57 | } -------------------------------------------------------------------------------- /src/Matcher/NullMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 22 | } 23 | 24 | /** 25 | * {@inheritDoc} 26 | */ 27 | public function match($value, $pattern) : bool 28 | { 29 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 30 | 31 | if (null !== $value) { 32 | $this->error = \sprintf('%s "%s" does not match null.', \gettype($value), new StringConverter($value)); 33 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 34 | 35 | return false; 36 | } 37 | 38 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 39 | 40 | return true; 41 | } 42 | 43 | /** 44 | * {@inheritDoc} 45 | */ 46 | public function canMatch($pattern) : bool 47 | { 48 | $result = null === $pattern || (\is_string($pattern) && 0 !== \preg_match(self::MATCH_PATTERN, $pattern)); 49 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 50 | 51 | return $result; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # 4.0 -> 5.0 2 | 3 | **Backtrace** 4 | 5 | In order to improve performance of matcher `Backtrace` class was replaced `InMemoryBacktrace` 6 | that implements `Backtrace` interface. 7 | 8 | In order to use backtrace provide it directly to `MatcherFactory` or `PHPMatcher` class. 9 | 10 | PHPUnit tests require `setBacktrace` method to be used before test: 11 | 12 | ```php 13 | $this->setBacktrace($backtrace = new InMemoryBacktrace()); 14 | $this->assertMatchesPattern('{"foo": "@integer@"}', json_encode(['foo' => 'bar'])); 15 | ``` 16 | 17 | **Optional Matchers** 18 | 19 | XML and Expression matchers are now optional, in order to use them add following 20 | dependencies to your composer.json file: 21 | 22 | XMLMatcher 23 | 24 | ``` 25 | "openlss/lib-array2xml": "^1.0" 26 | ``` 27 | 28 | ExpressionMatcher 29 | 30 | ``` 31 | symfony/expression-language 32 | ``` 33 | 34 | # 3.x -> 4.0 35 | 36 | Below you can find list of changes between `3.x` and `4.0` versions of PHPMatcher. 37 | 38 | **Creating Matcher:** 39 | ```diff 40 | -$factory = new MatcherFactory(); 41 | -$matcher = $factory->createMatcher(); 42 | +$matcher = new PHPMatcher(); 43 | ``` 44 | 45 | **Using Matcher** 46 | ```diff 47 | -PHPMatcher::match($value, $pattern, $error) 48 | +$matcher = (new PHPMatcher())->match($value, $pattern);; 49 | ``` 50 | 51 | **Accessing last error/backtrace** 52 | ```diff 53 | +$matcher = new PHPMatcher(); 54 | +$matcher->match($value, $pattern); 55 | +echo $matcher->error(); 56 | +echo $matcher->backtrace(); 57 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # A quick guide to contribute to the project: 2 | 3 | ## Installing the dev environment 4 | 5 | 1. Fork the repo 6 | 2. Clone the repo to local 7 | 3. Install dependencies: `composer update` (this assumes you have 'composer' aliased to wherever your composer.phar lives) 8 | 4. Run the tests. We only take pull requests with passing tests, and it's great to know that you have a clean slate: 9 | `composer test` 10 | 11 | ## Adding new features 12 | 13 | Pull requests with new features needs to be created against master branch. 14 | 15 | If new feature require BC Break please note that in your PR comment, it will added in next major version. 16 | New features that does not have any BC Breaks are going to be added in next minor version. 17 | 18 | ## Codding standards 19 | 20 | In order to fix codding standards please exeecute: 21 | 22 | ``` 23 | composer cs:php:fix 24 | ``` 25 | 26 | ## Patches and bugfixes 27 | 28 | 1. Check the oldest version that patch/bug fix can be applied. 29 | 2. Create PR against that version 30 | 31 | For example if you are fixing pattern expander that was introduced in version 1.1 make sure that PR with fix 32 | is created against version 1.1, not master or 2.0 33 | 34 | ## The actual contribution 35 | 36 | 1. Make the changes/additions to the code, committing often and making clear what you've done 37 | 2. Make sure you write tests for your code, located in the folder structure `tests/Coduo/PHPMatcher/...` 38 | 3. Run your tests (often and while coding): `./bin/phpunit` 39 | 4. Create Pull Request on github to against proper branch 40 | -------------------------------------------------------------------------------- /src/PHPMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace !== null 19 | ? $backtrace 20 | : new VoidBacktrace(); 21 | } 22 | 23 | public function match($value, $pattern) : bool 24 | { 25 | $this->matcher = null; 26 | 27 | return $this->getMatcher()->match($value, $pattern); 28 | } 29 | 30 | /** 31 | * Returns backtrace from last matching. 32 | * When called before PHPMatcher::match() function it will return instance where Backtrace::isEmpty() will return true. 33 | * 34 | * @return Backtrace 35 | */ 36 | public function backtrace() : Backtrace 37 | { 38 | return $this->backtrace; 39 | } 40 | 41 | /** 42 | * Returns error from last matching. 43 | * If last matching was successful this function will return null. 44 | * 45 | * @return null|string 46 | */ 47 | public function error() : ?string 48 | { 49 | return $this->getMatcher()->getError(); 50 | } 51 | 52 | private function getMatcher() : Matcher 53 | { 54 | if (null === $this->matcher) { 55 | $this->matcher = (new MatcherFactory())->createMatcher($this->backtrace()); 56 | } 57 | 58 | return $this->matcher; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/TypePattern.php: -------------------------------------------------------------------------------- 1 | type = $type; 21 | $this->expanders = []; 22 | } 23 | 24 | public function is(string $type) : bool 25 | { 26 | return \strtolower($this->type) === \strtolower($type); 27 | } 28 | 29 | public function getType() : string 30 | { 31 | return \strtolower($this->type); 32 | } 33 | 34 | public function addExpander(PatternExpander $expander) : void 35 | { 36 | $this->expanders[] = $expander; 37 | } 38 | 39 | public function matchExpanders($value) : bool 40 | { 41 | foreach ($this->expanders as $expander) { 42 | if (!$expander->match($value)) { 43 | $this->error = $expander->getError(); 44 | 45 | return false; 46 | } 47 | } 48 | 49 | return true; 50 | } 51 | 52 | public function getError() : ?string 53 | { 54 | return $this->error; 55 | } 56 | 57 | public function hasExpander(string $expanderName) : bool 58 | { 59 | foreach ($this->expanders as $expander) { 60 | if ($expander::is($expanderName)) { 61 | return true; 62 | } 63 | } 64 | 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsIp.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (!\is_string($value)) { 31 | $this->error = \sprintf('IsIp expander require "string", got "%s".', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | if (!$this->matchValue($value)) { 38 | $this->error = \sprintf('string "%s" is not a valid IP address.', $value); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | $this->backtrace->expanderSucceed(self::NAME, $value); 45 | 46 | return true; 47 | } 48 | 49 | public function getError() : ?string 50 | { 51 | return $this->error; 52 | } 53 | 54 | private function matchValue(string $value) : bool 55 | { 56 | return false !== \filter_var($value, FILTER_VALIDATE_IP); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsUrl.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (!\is_string($value)) { 31 | $this->error = \sprintf('IsUrl expander require "string", got "%s".', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | if (!$this->matchValue($value)) { 38 | $this->error = \sprintf('string "%s" is not a valid URL.', $value); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | $this->backtrace->expanderSucceed(self::NAME, $value); 45 | 46 | return true; 47 | } 48 | 49 | public function getError() : ?string 50 | { 51 | return $this->error; 52 | } 53 | 54 | private function matchValue(string $value) : bool 55 | { 56 | return false !== \filter_var($value, FILTER_VALIDATE_URL); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Matcher/ExpressionMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 23 | } 24 | 25 | public function match($value, $pattern) : bool 26 | { 27 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 28 | 29 | $language = new ExpressionLanguage(); 30 | \preg_match(self::MATCH_PATTERN, $pattern, $matches); 31 | $expressionResult = $language->evaluate($matches[1], ['value' => $value]); 32 | 33 | if (!$expressionResult) { 34 | $this->error = \sprintf('"%s" expression fails for value "%s".', $pattern, new StringConverter($value)); 35 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 36 | 37 | return false; 38 | } 39 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 40 | 41 | return true; 42 | } 43 | 44 | public function canMatch($pattern) : bool 45 | { 46 | $result = \is_string($pattern) && 0 !== \preg_match(self::MATCH_PATTERN, $pattern); 47 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 48 | 49 | return $result; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/ExpanderMatch.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 32 | } 33 | 34 | public static function is(string $name) : bool 35 | { 36 | return self::NAME === $name; 37 | } 38 | 39 | /** 40 | * @param mixed $value 41 | */ 42 | public function match($value) : bool 43 | { 44 | $this->backtrace->expanderEntrance(self::NAME, $value); 45 | 46 | if ($this->matcher === null) { 47 | $this->matcher = (new MatcherFactory())->createMatcher($this->backtrace); 48 | } 49 | 50 | $result = $this->matcher->match($value, $this->pattern); 51 | 52 | if ($result) { 53 | $this->backtrace->expanderSucceed(self::NAME, $value); 54 | } else { 55 | $this->backtrace->expanderFailed(self::NAME, $value, ''); 56 | } 57 | 58 | return $result; 59 | } 60 | 61 | public function getError() : ?string 62 | { 63 | return $this->matcher->getError(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsEmail.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (!\is_string($value)) { 31 | $this->error = \sprintf('IsEmail expander require "string", got "%s".', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | if (!$this->matchValue($value)) { 38 | $this->error = \sprintf('string "%s" is not a valid e-mail address.', $value); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | $this->backtrace->expanderSucceed(self::NAME, $value); 45 | 46 | return true; 47 | } 48 | 49 | public function getError() : ?string 50 | { 51 | return $this->error; 52 | } 53 | 54 | private function matchValue(string $value) : bool 55 | { 56 | return false !== \filter_var($value, FILTER_VALIDATE_EMAIL); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.nix/pkgs/php/package.nix: -------------------------------------------------------------------------------- 1 | { 2 | php, 3 | with-pcov ? true, 4 | with-xdebug ? false, 5 | with-blackfire ? false 6 | }: 7 | 8 | let 9 | flowPHP = php.withExtensions ( 10 | { enabled, all }: 11 | with all; 12 | enabled 13 | ++ [ 14 | simplexml 15 | ] 16 | ++ (if with-xdebug then [xdebug] else []) 17 | ++ (if with-pcov then [pcov] else []) 18 | ++ (if with-blackfire then [blackfire] else []) 19 | ); 20 | in 21 | flowPHP.buildEnv { 22 | extraConfig = "" 23 | + ( 24 | if builtins.pathExists ./../../php/lib/php.ini 25 | then builtins.readFile ./../../php/lib/php.ini 26 | else builtins.readFile ./../../php/lib/php.ini.dist 27 | ) 28 | + "\n" 29 | + ( 30 | if with-xdebug 31 | then 32 | if builtins.pathExists ./../../php/lib/xdebug.ini 33 | then builtins.readFile ./../../php/lib/xdebug.ini 34 | else builtins.readFile ./../../php/lib/xdebug.ini.dist 35 | else "" 36 | ) 37 | + "\n" 38 | + ( 39 | if with-blackfire 40 | then 41 | if builtins.pathExists ./../../php/lib/blackfire.ini 42 | then builtins.readFile ./../../php/lib/blackfire.ini 43 | else builtins.readFile ./../../php/lib/blackfire.ini.dist 44 | else "" 45 | ) 46 | + "\n" 47 | + ( 48 | if with-pcov 49 | then 50 | if builtins.pathExists ./../../php/lib/pcov.ini 51 | then builtins.readFile ./../../php/lib/pcov.ini 52 | else builtins.readFile ./../../php/lib/pcov.ini.dist 53 | else "" 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/Matcher/BooleanMatcher.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 25 | $this->backtrace = $backtrace; 26 | } 27 | 28 | public function match($value, $pattern) : bool 29 | { 30 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 31 | 32 | if (!\is_bool($value)) { 33 | $this->error = \sprintf('%s "%s" is not a valid boolean.', \gettype($value), new StringConverter($value)); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 40 | 41 | return true; 42 | } 43 | 44 | public function canMatch($pattern) : bool 45 | { 46 | if (!\is_string($pattern)) { 47 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 48 | 49 | return false; 50 | } 51 | 52 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 53 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 54 | 55 | return $result; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Matcher/NumberMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 25 | $this->parser = $parser; 26 | } 27 | 28 | public function match($value, $pattern) : bool 29 | { 30 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 31 | 32 | if (!\is_numeric($value)) { 33 | $this->error = \sprintf('%s "%s" is not a valid number.', \gettype($value), new StringConverter($value)); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 40 | 41 | return true; 42 | } 43 | 44 | public function canMatch($pattern) : bool 45 | { 46 | if (!\is_string($pattern)) { 47 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 48 | 49 | return false; 50 | } 51 | 52 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 53 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 54 | 55 | return $result; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/Count.php: -------------------------------------------------------------------------------- 1 | value = $value; 26 | } 27 | 28 | public static function is(string $name) : bool 29 | { 30 | return self::NAME === $name; 31 | } 32 | 33 | public function match($value) : bool 34 | { 35 | $this->backtrace->expanderEntrance(self::NAME, $value); 36 | 37 | if (!\is_array($value)) { 38 | $this->error = \sprintf('Count expander require "array", got "%s".', new StringConverter($value)); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | if (\count($value) !== $this->value) { 45 | $this->error = \sprintf('Expected count of %s is %s.', new StringConverter($value), new StringConverter($this->value)); 46 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 47 | 48 | return false; 49 | } 50 | 51 | $this->backtrace->expanderSucceed(self::NAME, $value); 52 | 53 | return true; 54 | } 55 | 56 | public function getError() : ?string 57 | { 58 | return $this->error; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/InArray.php: -------------------------------------------------------------------------------- 1 | value = $value; 26 | } 27 | 28 | public static function is(string $name) : bool 29 | { 30 | return self::NAME === $name; 31 | } 32 | 33 | public function match($value) : bool 34 | { 35 | $this->backtrace->expanderEntrance(self::NAME, $value); 36 | 37 | if (!\is_array($value)) { 38 | $this->error = \sprintf('InArray expander require "array", got "%s".', new StringConverter($value)); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | if (!\in_array($this->value, $value, true)) { 45 | $this->error = \sprintf("%s doesn't have \"%s\" element.", new StringConverter($value), new StringConverter($this->value)); 46 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 47 | 48 | return false; 49 | } 50 | 51 | $this->backtrace->expanderSucceed(self::NAME, $value); 52 | 53 | return true; 54 | } 55 | 56 | public function getError() : ?string 57 | { 58 | return $this->error; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsDateTime.php: -------------------------------------------------------------------------------- 1 | backtrace->expanderEntrance(self::NAME, $value); 29 | 30 | if (!\is_string($value)) { 31 | $this->error = \sprintf('IsDateTime expander require "string", got "%s".', new StringConverter($value)); 32 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 33 | 34 | return false; 35 | } 36 | 37 | if (!$this->matchValue($value)) { 38 | $this->error = \sprintf('string "%s" is not a valid date.', $value); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | $this->backtrace->expanderSucceed(self::NAME, $value); 45 | 46 | return true; 47 | } 48 | 49 | public function getError() : ?string 50 | { 51 | return $this->error; 52 | } 53 | 54 | private function matchValue(string $value) : bool 55 | { 56 | try { 57 | new \DateTime($value); 58 | 59 | return true; 60 | } catch (\Exception $exception) { 61 | return false; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Matcher/OrMatcher.php: -------------------------------------------------------------------------------- 1 | chainMatcher = $chainMatcher; 23 | $this->backtrace = $backtrace; 24 | } 25 | 26 | public function match($value, $pattern) : bool 27 | { 28 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 29 | 30 | $patterns = \explode('||', $pattern); 31 | $patterns = \array_map('trim', $patterns); 32 | 33 | foreach ($patterns as $childPattern) { 34 | if ($this->matchChild($value, $childPattern)) { 35 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 36 | 37 | return true; 38 | } 39 | } 40 | 41 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 42 | 43 | return false; 44 | } 45 | 46 | public function canMatch($pattern) : bool 47 | { 48 | $result = \is_string($pattern) && 0 !== \preg_match_all(self::MATCH_PATTERN, $pattern, $matches); 49 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 50 | 51 | return $result; 52 | } 53 | 54 | private function matchChild($value, $pattern) : bool 55 | { 56 | if (!$this->chainMatcher->canMatch($pattern)) { 57 | return false; 58 | } 59 | 60 | return $this->chainMatcher->match($value, $pattern); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/MatchRegex.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 30 | } 31 | 32 | public static function is(string $name) : bool 33 | { 34 | return self::NAME === $name; 35 | } 36 | 37 | public function match($value) : bool 38 | { 39 | $this->backtrace->expanderEntrance(self::NAME, $value); 40 | 41 | if (!\is_string($value)) { 42 | $this->error = \sprintf('Match expander require "string", got "%s".', new StringConverter($value)); 43 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 44 | 45 | return false; 46 | } 47 | 48 | if (1 !== \preg_match($this->pattern, $value)) { 49 | $this->error = \sprintf("string \"%s\" don't match pattern %s.", $value, $this->pattern); 50 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 51 | 52 | return false; 53 | } 54 | 55 | $this->backtrace->expanderSucceed(self::NAME, $value); 56 | 57 | return true; 58 | } 59 | 60 | public function getError() : ?string 61 | { 62 | return $this->error; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/NotContains.php: -------------------------------------------------------------------------------- 1 | string = $string; 28 | $this->ignoreCase = $ignoreCase; 29 | } 30 | 31 | public static function is(string $name) : bool 32 | { 33 | return self::NAME === $name; 34 | } 35 | 36 | public function match($value) : bool 37 | { 38 | $this->backtrace->expanderEntrance(self::NAME, $value); 39 | 40 | if (!\is_string($value)) { 41 | $this->error = \sprintf('Not contains expander require "string", got "%s".', new StringConverter($value)); 42 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 43 | 44 | return false; 45 | } 46 | 47 | $contains = $this->ignoreCase 48 | ? \mb_stripos($value, $this->string) 49 | : \mb_strpos($value, $this->string); 50 | 51 | if ($contains !== false) { 52 | $this->error = \sprintf('String "%s" contains "%s".', $value, $this->string); 53 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $this->backtrace->expanderSucceed(self::NAME, $value); 59 | 60 | return true; 61 | } 62 | 63 | public function getError() : ?string 64 | { 65 | return $this->error; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/Contains.php: -------------------------------------------------------------------------------- 1 | string = $string; 28 | $this->ignoreCase = $ignoreCase; 29 | } 30 | 31 | public static function is(string $name) : bool 32 | { 33 | return self::NAME === $name; 34 | } 35 | 36 | public function match($value) : bool 37 | { 38 | $this->backtrace->expanderEntrance(self::NAME, $value); 39 | 40 | if (!\is_string($value)) { 41 | $this->error = \sprintf('Contains expander require "string", got "%s".', new StringConverter($value)); 42 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 43 | 44 | return false; 45 | } 46 | 47 | $contains = $this->ignoreCase 48 | ? \mb_stripos($value, $this->string) 49 | : \mb_strpos($value, $this->string); 50 | 51 | if ($contains === false) { 52 | $this->error = \sprintf("String \"%s\" doesn't contains \"%s\".", $value, $this->string); 53 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $this->backtrace->expanderSucceed(self::NAME, $value); 59 | 60 | return true; 61 | } 62 | 63 | public function getError() : ?string 64 | { 65 | return $this->error; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/OneOf.php: -------------------------------------------------------------------------------- 1 | expanders[] = $argument; 38 | } 39 | } 40 | 41 | public static function is(string $name) : bool 42 | { 43 | return self::NAME === $name; 44 | } 45 | 46 | public function match($value) : bool 47 | { 48 | $this->backtrace->expanderEntrance(self::NAME, $value); 49 | 50 | foreach ($this->expanders as $expander) { 51 | if ($expander->match($value)) { 52 | $this->backtrace->expanderSucceed(self::NAME, $value); 53 | 54 | return true; 55 | } 56 | } 57 | 58 | $this->error = \sprintf('Any expander available in OneOf expander does not match "%s".', new StringConverter($value)); 59 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 60 | 61 | return false; 62 | } 63 | 64 | public function getError() : ?string 65 | { 66 | return $this->error; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Matcher/DoubleMatcher.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 25 | $this->backtrace = $backtrace; 26 | } 27 | 28 | public function match($value, $pattern) : bool 29 | { 30 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 31 | 32 | if (!\is_float($value)) { 33 | $this->error = \sprintf('%s "%s" is not a valid double.', \gettype($value), new StringConverter($value)); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | $typePattern = $this->parser->parse($pattern); 40 | 41 | if (!$typePattern->matchExpanders($value)) { 42 | $this->error = $typePattern->getError(); 43 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 44 | 45 | return false; 46 | } 47 | 48 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 49 | 50 | return true; 51 | } 52 | 53 | public function canMatch($pattern) : bool 54 | { 55 | if (!\is_string($pattern)) { 56 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 57 | 58 | return false; 59 | } 60 | 61 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 62 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 63 | 64 | return $result; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/StringMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 25 | $this->parser = $parser; 26 | } 27 | 28 | public function match($value, $pattern) : bool 29 | { 30 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 31 | 32 | if (!\is_string($value)) { 33 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | $typePattern = $this->parser->parse($pattern); 40 | 41 | if (!$typePattern->matchExpanders($value)) { 42 | $this->error = $typePattern->getError(); 43 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 44 | 45 | return false; 46 | } 47 | 48 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 49 | 50 | return true; 51 | } 52 | 53 | public function canMatch($pattern) : bool 54 | { 55 | if (!\is_string($pattern)) { 56 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 57 | 58 | return false; 59 | } 60 | 61 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 62 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 63 | 64 | return $result; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/IntegerMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 25 | $this->parser = $parser; 26 | } 27 | 28 | public function match($value, $pattern) : bool 29 | { 30 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 31 | 32 | if (!\is_int($value)) { 33 | $this->error = \sprintf('%s "%s" is not a valid integer.', \gettype($value), new StringConverter($value)); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | $typePattern = $this->parser->parse($pattern); 40 | 41 | if (!$typePattern->matchExpanders($value)) { 42 | $this->error = $typePattern->getError(); 43 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 44 | 45 | return false; 46 | } 47 | 48 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 49 | 50 | return true; 51 | } 52 | 53 | public function canMatch($pattern) : bool 54 | { 55 | if (!\is_string($pattern)) { 56 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 57 | 58 | return false; 59 | } 60 | 61 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 62 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 63 | 64 | return $result; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsInDateFormat.php: -------------------------------------------------------------------------------- 1 | format = $format; 26 | } 27 | 28 | public static function is(string $name) : bool 29 | { 30 | return self::NAME === $name; 31 | } 32 | 33 | public function match($value) : bool 34 | { 35 | $this->backtrace->expanderEntrance(self::NAME, $value); 36 | 37 | if (!\is_string($value)) { 38 | $this->error = \sprintf('IsDateTime expander require "string", got "%s".', new StringConverter($value)); 39 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 40 | 41 | return false; 42 | } 43 | 44 | if (!$this->matchValue($value)) { 45 | $this->error = \sprintf('string "%s" is not a valid date.', $value); 46 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 47 | 48 | return false; 49 | } 50 | 51 | $this->backtrace->expanderSucceed(self::NAME, $value); 52 | 53 | return true; 54 | } 55 | 56 | public function getError() : ?string 57 | { 58 | return $this->error; 59 | } 60 | 61 | private function matchValue(string $value) : bool 62 | { 63 | try { 64 | $date = \DateTime::createFromFormat($this->format, $value); 65 | 66 | return $date && ($date->format($this->format) === $value); 67 | } catch (\Exception $exception) { 68 | return false; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsTzOffset.php: -------------------------------------------------------------------------------- 1 | error = null; 25 | } 26 | 27 | public static function is(string $name) : bool 28 | { 29 | return self::NAME === $name; 30 | } 31 | 32 | public function match($value) : bool 33 | { 34 | $this->backtrace->expanderEntrance(self::NAME, $value); 35 | 36 | if (!\is_string($value)) { 37 | $this->error = \sprintf('Match expander require "string", got "%s".', new StringConverter($value)); 38 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 39 | 40 | return false; 41 | } 42 | 43 | try { 44 | $timezone = TimeZone::fromString($value); 45 | 46 | if ($result = $timezone->isOffset()) { 47 | $this->backtrace->expanderSucceed(self::NAME, $value); 48 | } else { 49 | $this->error = \sprintf('Timezone "%s" is not an offset type.', $value); 50 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 51 | } 52 | 53 | return $result; 54 | } catch (\Exception $exception) { 55 | $this->error = \sprintf('Timezone expander require valid timezone, got "%s".', new StringConverter($value)); 56 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 57 | 58 | return false; 59 | } 60 | } 61 | 62 | public function getError() : ?string 63 | { 64 | return $this->error; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsTzIdentifier.php: -------------------------------------------------------------------------------- 1 | error = null; 25 | } 26 | 27 | public static function is(string $name) : bool 28 | { 29 | return self::NAME === $name; 30 | } 31 | 32 | public function match($value) : bool 33 | { 34 | $this->backtrace->expanderEntrance(self::NAME, $value); 35 | 36 | if (!\is_string($value)) { 37 | $this->error = \sprintf('Match expander require "string", got "%s".', new StringConverter($value)); 38 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 39 | 40 | return false; 41 | } 42 | 43 | try { 44 | $timezone = TimeZone::fromString($value); 45 | 46 | if ($result = $timezone->isIdentifier()) { 47 | $this->backtrace->expanderSucceed(self::NAME, $value); 48 | } else { 49 | $this->error = \sprintf('Timezone "%s" is not an identifier type.', $value); 50 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 51 | } 52 | 53 | return $result; 54 | } catch (\Exception $exception) { 55 | $this->error = \sprintf('Timezone expander require valid timezone, got "%s".', new StringConverter($value)); 56 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 57 | 58 | return false; 59 | } 60 | } 61 | 62 | public function getError() : ?string 63 | { 64 | return $this->error; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/IsTzAbbreviation.php: -------------------------------------------------------------------------------- 1 | error = null; 25 | } 26 | 27 | public static function is(string $name) : bool 28 | { 29 | return self::NAME === $name; 30 | } 31 | 32 | public function match($value) : bool 33 | { 34 | $this->backtrace->expanderEntrance(self::NAME, $value); 35 | 36 | if (!\is_string($value)) { 37 | $this->error = \sprintf('Match expander require "string", got "%s".', new StringConverter($value)); 38 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 39 | 40 | return false; 41 | } 42 | 43 | try { 44 | $timezone = TimeZone::fromString($value); 45 | 46 | if ($result = $timezone->isAbbreviation()) { 47 | $this->backtrace->expanderSucceed(self::NAME, $value); 48 | } else { 49 | $this->error = \sprintf('Timezone "%s" is not an abbreviation type.', $value); 50 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 51 | } 52 | 53 | return $result; 54 | } catch (\Exception $exception) { 55 | $this->error = \sprintf('Timezone expander require valid timezone, got "%s".', new StringConverter($value)); 56 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 57 | 58 | return false; 59 | } 60 | } 61 | 62 | public function getError() : ?string 63 | { 64 | return $this->error; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/LowerThan.php: -------------------------------------------------------------------------------- 1 | boundary = $boundary; 33 | } 34 | 35 | public static function is(string $name) : bool 36 | { 37 | return self::NAME === $name; 38 | } 39 | 40 | public function match($value) : bool 41 | { 42 | $this->backtrace->expanderEntrance(self::NAME, $value); 43 | 44 | if (!\is_float($value) && !\is_int($value)) { 45 | $this->error = \sprintf('Value "%s" is not a valid number.', new StringConverter($value)); 46 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 47 | 48 | return false; 49 | } 50 | 51 | if ($value >= $this->boundary) { 52 | $this->error = \sprintf('Value "%s" is not lower than "%s".', new StringConverter($value), new StringConverter($this->boundary)); 53 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $result = $value < $this->boundary; 59 | 60 | if ($result) { 61 | $this->backtrace->expanderSucceed(self::NAME, $value); 62 | } else { 63 | $this->backtrace->expanderFailed(self::NAME, $value, ''); 64 | } 65 | 66 | return $result; 67 | } 68 | 69 | public function getError() : ?string 70 | { 71 | return $this->error; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Matcher/XmlMatcher.php: -------------------------------------------------------------------------------- 1 | arrayMatcher = $arrayMatcher; 21 | $this->backtrace = $backtrace; 22 | } 23 | 24 | public function match($value, $pattern) : bool 25 | { 26 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 27 | 28 | if (parent::match($value, $pattern)) { 29 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 30 | 31 | return true; 32 | } 33 | 34 | if (!Xml::isValid($value) || !Xml::isValid($pattern)) { 35 | $this->error = \sprintf("Value or pattern are not valid XML's"); 36 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 37 | 38 | return false; 39 | } 40 | 41 | $arrayValue = XML2Array::createArray($value); 42 | $arrayPattern = XML2Array::createArray($pattern); 43 | 44 | $match = $this->arrayMatcher->match($arrayValue, $arrayPattern); 45 | 46 | if (!$match) { 47 | $this->error = \sprintf( 48 | 'Value %s does not match pattern %s', 49 | new StringConverter($value), 50 | new StringConverter($pattern) 51 | ); 52 | 53 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 59 | 60 | return true; 61 | } 62 | 63 | public function canMatch($pattern) : bool 64 | { 65 | $result = Xml::isValid($pattern); 66 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 67 | 68 | return $result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/GreaterThan.php: -------------------------------------------------------------------------------- 1 | boundary = $boundary; 33 | } 34 | 35 | public static function is(string $name) : bool 36 | { 37 | return self::NAME === $name; 38 | } 39 | 40 | public function match($value) : bool 41 | { 42 | $this->backtrace->expanderEntrance(self::NAME, $value); 43 | 44 | if (!\is_float($value) && !\is_int($value) && !\is_numeric($value)) { 45 | $this->error = \sprintf('Value "%s" is not a valid number.', new StringConverter($value)); 46 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 47 | 48 | return false; 49 | } 50 | 51 | if ($value <= $this->boundary) { 52 | $this->error = \sprintf('Value "%s" is not greater than "%s".', new StringConverter($value), new StringConverter($this->boundary)); 53 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $result = $value > $this->boundary; 59 | 60 | if ($result) { 61 | $this->backtrace->expanderSucceed(self::NAME, $value); 62 | } else { 63 | $this->backtrace->expanderFailed(self::NAME, $value, ''); 64 | } 65 | 66 | return $result; 67 | } 68 | 69 | public function getError() : ?string 70 | { 71 | return $this->error; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/StartsWith.php: -------------------------------------------------------------------------------- 1 | stringBeginning = $stringBeginning; 28 | $this->ignoreCase = $ignoreCase; 29 | } 30 | 31 | public static function is(string $name) : bool 32 | { 33 | return self::NAME === $name; 34 | } 35 | 36 | public function match($value) : bool 37 | { 38 | $this->backtrace->expanderEntrance(self::NAME, $value); 39 | 40 | if (!\is_string($value)) { 41 | $this->error = \sprintf('StartsWith expander require "string", got "%s".', new StringConverter($value)); 42 | $this->backtrace->expanderSucceed(self::NAME, $value); 43 | 44 | return false; 45 | } 46 | 47 | if (empty($this->stringBeginning)) { 48 | $this->backtrace->expanderSucceed(self::NAME, $value); 49 | 50 | return true; 51 | } 52 | 53 | if ($this->matchValue($value)) { 54 | $this->error = \sprintf("string \"%s\" doesn't starts with string \"%s\".", $value, $this->stringBeginning); 55 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 56 | 57 | return false; 58 | } 59 | 60 | $this->backtrace->expanderSucceed(self::NAME, $value); 61 | 62 | return true; 63 | } 64 | 65 | public function getError() : ?string 66 | { 67 | return $this->error; 68 | } 69 | 70 | private function matchValue(string $value) : bool 71 | { 72 | return $this->ignoreCase 73 | ? \mb_stripos($value, $this->stringBeginning) !== 0 74 | : \mb_strpos($value, $this->stringBeginning) !== 0; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Matcher/JsonMatcher.php: -------------------------------------------------------------------------------- 1 | arrayMatcher = $arrayMatcher; 19 | $this->backtrace = $backtrace; 20 | } 21 | 22 | public function match($value, $pattern) : bool 23 | { 24 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 25 | 26 | if (parent::match($value, $pattern)) { 27 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 28 | 29 | return true; 30 | } 31 | 32 | if (!Json::isValid($value)) { 33 | $this->error = \sprintf('Invalid given JSON of value. %s', Json::getErrorMessage()); 34 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 35 | 36 | return false; 37 | } 38 | 39 | if (!Json::isValidPattern($pattern)) { 40 | $this->error = \sprintf('Invalid given JSON of pattern. %s', Json::getErrorMessage()); 41 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 42 | 43 | return false; 44 | } 45 | 46 | $transformedPattern = Json::isValid($pattern) ? $pattern : Json::transformPattern($pattern); 47 | 48 | $match = $this->arrayMatcher->match(\json_decode($value, true), \json_decode($transformedPattern, true)); 49 | 50 | if (!$match) { 51 | $this->error = $this->arrayMatcher->getError(); 52 | 53 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 54 | 55 | return false; 56 | } 57 | 58 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 59 | 60 | return true; 61 | } 62 | 63 | public function canMatch($pattern) : bool 64 | { 65 | $result = Json::isValidPattern($pattern); 66 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 67 | 68 | return $result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/EndsWith.php: -------------------------------------------------------------------------------- 1 | stringEnding = $stringEnding; 28 | $this->ignoreCase = $ignoreCase; 29 | } 30 | 31 | public static function is(string $name) : bool 32 | { 33 | return self::NAME === $name; 34 | } 35 | 36 | public function match($value) : bool 37 | { 38 | $this->backtrace->expanderEntrance(self::NAME, $value); 39 | 40 | if (!\is_string($value)) { 41 | $this->error = \sprintf('EndsWith expander require "string", got "%s".', new StringConverter($value)); 42 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 43 | 44 | return false; 45 | } 46 | 47 | if (empty($this->stringEnding)) { 48 | $this->backtrace->expanderSucceed(self::NAME, $value); 49 | 50 | return true; 51 | } 52 | 53 | if (!$this->matchValue($value)) { 54 | $this->error = \sprintf("string \"%s\" doesn't ends with string \"%s\".", $value, $this->stringEnding); 55 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 56 | 57 | return false; 58 | } 59 | 60 | $this->backtrace->expanderSucceed(self::NAME, $value); 61 | 62 | return true; 63 | } 64 | 65 | public function getError() : ?string 66 | { 67 | return $this->error; 68 | } 69 | 70 | private function matchValue(string $value) : bool 71 | { 72 | return $this->ignoreCase 73 | ? \mb_substr(\mb_strtolower($value), -\mb_strlen(\mb_strtolower($this->stringEnding))) === \mb_strtolower($this->stringEnding) 74 | : \mb_substr($value, -\mb_strlen($this->stringEnding)) === $this->stringEnding; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/DateTimeComparisonTrait.php: -------------------------------------------------------------------------------- 1 | boundary = DateTime::fromString($boundary); 22 | } catch (\Exception $exception) { 23 | throw new \InvalidArgumentException(\sprintf('Boundary value "%s" is not a valid date, date time or time.', new StringConverter($boundary))); 24 | } 25 | } 26 | 27 | public static function is(string $name) : bool 28 | { 29 | return static::getName() === $name; 30 | } 31 | 32 | public function match($value) : bool 33 | { 34 | $this->backtrace->expanderEntrance(static::getName(), $value); 35 | 36 | if (!\is_string($value)) { 37 | $this->error = \sprintf('%s expander require "string", got "%s".', static::getName(), new StringConverter($value)); 38 | $this->backtrace->expanderFailed(static::getName(), $value, $this->error); 39 | 40 | return false; 41 | } 42 | 43 | return $this->compare($value); 44 | } 45 | 46 | public function getError() : ?string 47 | { 48 | return $this->error; 49 | } 50 | 51 | abstract protected static function getName() : string; 52 | 53 | /** 54 | * @param string $value raw value 55 | * @param DateTime $datetime value converted in DateTime object 56 | */ 57 | abstract protected function handleComparison(string $value, DateTime $datetime) : bool; 58 | 59 | private function compare(string $value) : bool 60 | { 61 | try { 62 | $datetime = DateTime::fromString($value); 63 | } catch (\Exception $e) { 64 | $this->error = \sprintf('Value "%s" is not a valid date, date time or time.', new StringConverter($value)); 65 | $this->backtrace->expanderFailed(static::getName(), $value, $this->error); 66 | 67 | return false; 68 | } 69 | 70 | return $this->handleComparison($value, $datetime); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Matcher/DateMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 26 | $this->parser = $parser; 27 | } 28 | 29 | public function match($value, $pattern) : bool 30 | { 31 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 32 | 33 | if (!\is_string($value)) { 34 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 35 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 36 | 37 | return false; 38 | } 39 | 40 | try { 41 | /** @phpstan-ignore-next-line */ 42 | Day::fromString($value); 43 | } catch (\Exception $exception) { 44 | $this->error = \sprintf('%s "%s" is not a valid date.', $value, new StringConverter($value)); 45 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 46 | 47 | return false; 48 | } 49 | 50 | $typePattern = $this->parser->parse($pattern); 51 | 52 | if (!$typePattern->matchExpanders($value)) { 53 | $this->error = $typePattern->getError(); 54 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 55 | 56 | return false; 57 | } 58 | 59 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 60 | 61 | return true; 62 | } 63 | 64 | public function canMatch($pattern) : bool 65 | { 66 | if (!\is_string($pattern)) { 67 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 68 | 69 | return false; 70 | } 71 | 72 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 73 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 74 | 75 | return $result; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Matcher/TimeMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 26 | $this->parser = $parser; 27 | } 28 | 29 | public function match($value, $pattern) : bool 30 | { 31 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 32 | 33 | if (!\is_string($value)) { 34 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 35 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 36 | 37 | return false; 38 | } 39 | 40 | try { 41 | /** @phpstan-ignore-next-line */ 42 | Time::fromString($value); 43 | } catch (\Exception $exception) { 44 | $this->error = \sprintf('%s "%s" is not a valid time.', $value, new StringConverter($value)); 45 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 46 | 47 | return false; 48 | } 49 | 50 | $typePattern = $this->parser->parse($pattern); 51 | 52 | if (!$typePattern->matchExpanders($value)) { 53 | $this->error = $typePattern->getError(); 54 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 55 | 56 | return false; 57 | } 58 | 59 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 60 | 61 | return true; 62 | } 63 | 64 | public function canMatch($pattern) : bool 65 | { 66 | if (!\is_string($pattern)) { 67 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 68 | 69 | return false; 70 | } 71 | 72 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 73 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 74 | 75 | return $result; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Matcher/UuidMatcher.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 35 | $this->backtrace = $backtrace; 36 | } 37 | 38 | public function match($value, $pattern) : bool 39 | { 40 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 41 | 42 | if (!\is_string($value)) { 43 | $this->error = \sprintf( 44 | '%s "%s" is not a valid UUID: not a string.', 45 | \gettype($value), 46 | new StringConverter($value) 47 | ); 48 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 49 | 50 | return false; 51 | } 52 | 53 | if (1 !== \preg_match(self::UUID_FORMAT_PATTERN, $value)) { 54 | $this->error = \sprintf( 55 | '%s "%s" is not a valid UUID: invalid format.', 56 | \gettype($value), 57 | $value 58 | ); 59 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 60 | 61 | return false; 62 | } 63 | 64 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 65 | 66 | return true; 67 | } 68 | 69 | public function canMatch($pattern) : bool 70 | { 71 | if (!\is_string($pattern)) { 72 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 73 | 74 | return false; 75 | } 76 | 77 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 78 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 79 | 80 | return $result; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Matcher/DateTimeMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 26 | $this->parser = $parser; 27 | } 28 | 29 | public function match($value, $pattern) : bool 30 | { 31 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 32 | 33 | if (!\is_string($value)) { 34 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 35 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 36 | 37 | return false; 38 | } 39 | 40 | try { 41 | /** @phpstan-ignore-next-line */ 42 | DateTime::fromString($value); 43 | } catch (\Exception $exception) { 44 | $this->error = \sprintf('%s "%s" is not a valid date time.', $value, new StringConverter($value)); 45 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 46 | 47 | return false; 48 | } 49 | 50 | $typePattern = $this->parser->parse($pattern); 51 | 52 | if (!$typePattern->matchExpanders($value)) { 53 | $this->error = $typePattern->getError(); 54 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 55 | 56 | return false; 57 | } 58 | 59 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 60 | 61 | return true; 62 | } 63 | 64 | public function canMatch($pattern) : bool 65 | { 66 | if (!\is_string($pattern)) { 67 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 68 | 69 | return false; 70 | } 71 | 72 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 73 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 74 | 75 | return $result; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Matcher/JsonObjectMatcher.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 26 | $this->parser = $parser; 27 | } 28 | 29 | public function match($value, $pattern) : bool 30 | { 31 | if (!$this->isJsonPattern($pattern)) { 32 | $this->error = \sprintf('%s "%s" is not a valid json.', \gettype($value), new StringConverter($value)); 33 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 34 | 35 | return false; 36 | } 37 | 38 | if (!Json::isValid($value) && null !== $value && !\is_array($value)) { 39 | $this->error = \sprintf('Invalid given JSON of value. %s', Json::getErrorMessage()); 40 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 41 | 42 | return false; 43 | } 44 | 45 | return $this->allExpandersMatch($value, $pattern); 46 | } 47 | 48 | public function canMatch($pattern) : bool 49 | { 50 | $result = \is_string($pattern) && $this->isJsonPattern($pattern); 51 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 52 | 53 | return $result; 54 | } 55 | 56 | private function isJsonPattern($pattern) : bool 57 | { 58 | if (!\is_string($pattern)) { 59 | return false; 60 | } 61 | 62 | return $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::JSON_PATTERN); 63 | } 64 | 65 | private function allExpandersMatch($value, $pattern) : bool 66 | { 67 | $typePattern = $this->parser->parse($pattern); 68 | 69 | if (!$typePattern->matchExpanders($value)) { 70 | $this->error = $typePattern->getError(); 71 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 72 | 73 | return false; 74 | } 75 | 76 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 77 | 78 | return true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/HasProperty.php: -------------------------------------------------------------------------------- 1 | propertyName = $propertyName; 27 | } 28 | 29 | public static function is(string $name) : bool 30 | { 31 | return self::NAME === $name; 32 | } 33 | 34 | public function match($value) : bool 35 | { 36 | $this->backtrace->expanderEntrance(self::NAME, $value); 37 | 38 | if (\is_array($value)) { 39 | $hasProperty = \array_key_exists($this->propertyName, $value); 40 | 41 | if (!$hasProperty) { 42 | $this->error = \sprintf('"json" object "%s" does not have "%s" propety.', new StringConverter($value), new StringConverter($this->propertyName)); 43 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 44 | 45 | return false; 46 | } 47 | 48 | $this->backtrace->expanderSucceed(self::NAME, $value); 49 | 50 | return true; 51 | } 52 | 53 | if (!Json::isValid($value)) { 54 | $this->error = \sprintf('HasProperty expander require valid "json" string, got "%s".', new StringConverter($value)); 55 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 56 | 57 | return false; 58 | } 59 | 60 | $jsonArray = \json_decode(Json::reformat($value), true); 61 | 62 | $hasProperty = \array_key_exists($this->propertyName, $jsonArray); 63 | 64 | if (!$hasProperty) { 65 | $this->error = \sprintf('"json" object "%s" does not have "%s" propety.', new StringConverter($value), new StringConverter($this->propertyName)); 66 | $this->backtrace->expanderFailed(self::NAME, $value, $this->error); 67 | 68 | return false; 69 | } 70 | 71 | $this->backtrace->expanderSucceed(self::NAME, $value); 72 | 73 | return true; 74 | } 75 | 76 | public function getError() : ?string 77 | { 78 | return $this->error; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Assert/Json.php: -------------------------------------------------------------------------------- 1 | backtrace = $backtrace; 31 | $this->parser = $parser; 32 | } 33 | 34 | public function match($value, $pattern) : bool 35 | { 36 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 37 | 38 | if (!\is_string($value)) { 39 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 40 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 41 | 42 | return false; 43 | } 44 | 45 | try { 46 | /** @phpstan-ignore-next-line */ 47 | TimeZone::fromString($value); 48 | } catch (\Exception $exception) { 49 | $this->error = \sprintf('%s "%s" is not a valid timezone.', $value, new StringConverter($value)); 50 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 51 | 52 | return false; 53 | } 54 | 55 | $typePattern = $this->parser->parse($pattern); 56 | 57 | if (!$typePattern->matchExpanders($value)) { 58 | $this->error = $typePattern->getError(); 59 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 60 | 61 | return false; 62 | } 63 | 64 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 65 | 66 | return true; 67 | } 68 | 69 | public function canMatch($pattern) : bool 70 | { 71 | if (!\is_string($pattern)) { 72 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 73 | 74 | return false; 75 | } 76 | 77 | $result = $this->parser->hasValidSyntax($pattern) && ($this->parser->parse($pattern)->is(self::PATTERN) || $this->parser->parse($pattern)->is(self::PATTERN_SHORT)); 78 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 79 | 80 | return $result; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/PHPUnit/PHPMatcherConstraint.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 31 | $this->matcher = new PHPMatcher($backtrace); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function toString() : string 38 | { 39 | return 'matches given pattern.'; 40 | } 41 | 42 | protected function failureDescription($other) : string 43 | { 44 | $errorDescription = $this->matcher->error() ?: 'Value does not match given pattern'; 45 | $backtrace = $this->matcher->backtrace(); 46 | 47 | return $backtrace instanceof VoidBacktrace 48 | ? $errorDescription 49 | : $errorDescription 50 | . "\nBacktrace:\n" . $this->matcher->backtrace(); 51 | } 52 | 53 | protected function matches($other) : bool 54 | { 55 | return $this->matcher->match($other, $this->pattern); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | protected function fail($other, $description, ?ComparisonFailure $comparisonFailure = null) : never 62 | { 63 | parent::fail($other, $description, $comparisonFailure ?? $this->createComparisonFailure($other)); 64 | } 65 | 66 | private function createComparisonFailure($other) : ?ComparisonFailure 67 | { 68 | if (!\is_string($other) || !\is_string($this->pattern) || !\class_exists(Json::class)) { 69 | return null; 70 | } 71 | 72 | [$error, $otherJson] = Json::canonicalize($other); 73 | 74 | if ($error) { 75 | return null; 76 | } 77 | 78 | [$error, $patternJson] = Json::canonicalize($this->pattern); 79 | 80 | if ($error) { 81 | return null; 82 | } 83 | 84 | return new ComparisonFailure( 85 | \json_decode($this->pattern), 86 | \json_decode($other), 87 | Json::prettify($patternJson), 88 | Json::prettify($otherJson), 89 | 'Failed asserting that the pattern matches the given value.' 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Matcher/ChainMatcher.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $matcherErrors; 27 | 28 | /** 29 | * @param Backtrace $backtrace 30 | * @param ValueMatcher[] $matchers 31 | */ 32 | public function __construct(string $name, Backtrace $backtrace, array $matchers = []) 33 | { 34 | $this->backtrace = $backtrace; 35 | $this->matchers = $matchers; 36 | $this->name = $name; 37 | } 38 | 39 | public function registerMatcher(ValueMatcher $matcher) : void 40 | { 41 | $this->matchers[] = $matcher; 42 | } 43 | 44 | public function match($value, $pattern) : bool 45 | { 46 | $this->backtrace->matcherEntrance($this->matcherName(), $value, $pattern); 47 | 48 | foreach ($this->matchers as $propertyMatcher) { 49 | if ($propertyMatcher->canMatch($pattern)) { 50 | if ($propertyMatcher->match($value, $pattern)) { 51 | $this->backtrace->matcherSucceed($this->matcherName(), $value, $pattern); 52 | 53 | return true; 54 | } 55 | 56 | $this->matcherErrors[\get_class($propertyMatcher)] = (string) $propertyMatcher->getError(); 57 | $this->error = $propertyMatcher->getError(); 58 | } 59 | } 60 | 61 | if (!isset($this->error)) { 62 | if (\is_array($value) && isset($this->matcherErrors[ArrayMatcher::class])) { 63 | $this->error = $this->matcherErrors[ArrayMatcher::class]; 64 | } elseif (Json::isValidPattern($pattern) && isset($this->matcherErrors[JsonMatcher::class])) { 65 | $this->error = $this->matcherErrors[JsonMatcher::class]; 66 | } else { 67 | $this->error = \sprintf( 68 | 'Any matcher from chain can\'t match value "%s" to pattern "%s"', 69 | new SingleLineString((string) new StringConverter($value)), 70 | new SingleLineString((string) new StringConverter($pattern)) 71 | ); 72 | } 73 | } 74 | 75 | $this->backtrace->matcherFailed($this->matcherName(), $value, $pattern, $this->error); 76 | 77 | return false; 78 | } 79 | 80 | public function canMatch($pattern) : bool 81 | { 82 | $this->backtrace->matcherCanMatch($this->matcherName(), $pattern, true); 83 | 84 | return true; 85 | } 86 | 87 | /** 88 | * @return string 89 | */ 90 | private function matcherName() : string 91 | { 92 | return \sprintf('%s (%s)', self::class, $this->name); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coduo/php-matcher", 3 | "type": "library", 4 | "description": "PHP Matcher enables you to match values with patterns", 5 | "keywords": ["json", "matcher", "tests", "match"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Michał Dąbrowski", 10 | "email": "dabrowski@brillante.pl" 11 | }, 12 | { 13 | "name": "Norbert Orzechowicz", 14 | "email": "norbert@orzechowicz.pl" 15 | } 16 | ], 17 | "require": { 18 | "php": "~8.3 || ~8.4 || ~8.5", 19 | "ext-filter": "*", 20 | "ext-json": "*", 21 | "ext-simplexml": "*", 22 | "aeon-php/calendar": "^1.0.6", 23 | "coduo/php-to-string": "^3", 24 | "doctrine/lexer": "^3.0" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^10.5", 28 | "openlss/lib-array2xml": "^1.0", 29 | "symfony/expression-language": "^5.4 || ^6.4 || ^7.3 || ^8.0" 30 | }, 31 | "suggest": { 32 | "openlss/lib-array2xml": "In order ot use Coduo\\PHPMatcher\\Matcher\\XmlMatcher", 33 | "symfony/expression-language" : "In order to use Coduo\\PHPMatcher\\Matcher\\ExpressionMatcher" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Coduo\\PHPMatcher\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Coduo\\PHPMatcher\\Tests\\": "tests/", 43 | "Coduo\\PHPMatcher\\Benchmark\\": "benchmark/Coduo/PHPMatcher/Benchmark/" 44 | } 45 | }, 46 | "config": { 47 | "sort-packages": true 48 | }, 49 | "scripts": { 50 | "benchmark": [ 51 | "Composer\\Config::disableProcessTimeout", 52 | "tools\/phpbench\/vendor\/bin\/phpbench run --report=matcher" 53 | ], 54 | "build": [ 55 | "@static:analyze", 56 | "@test", 57 | "@test:mutation" 58 | ], 59 | "cs:php:fix": [ 60 | "tools\/cs-fixer\/vendor\/bin\/php-cs-fixer fix --using-cache=no" 61 | ], 62 | "test" : [ 63 | "vendor\/bin\/phpunit --coverage-html var/phpunit/coverage/html --coverage-filter src" 64 | ], 65 | "test:mutation": [ 66 | "Composer\\Config::disableProcessTimeout", 67 | "tools\/infection\/vendor\/bin\/infection --threads=4" 68 | ], 69 | "static:analyze": [ 70 | "tools\/cs-fixer\/vendor\/bin\/php-cs-fixer fix --dry-run", 71 | "tools\/phpstan\/vendor\/bin\/phpstan analyze -c phpstan.neon" 72 | ], 73 | "tools:install": [ 74 | "composer install --working-dir=./tools/cs-fixer", 75 | "composer install --working-dir=./tools/infection", 76 | "composer install --working-dir=./tools/phpbench", 77 | "composer install --working-dir=./tools/phpstan" 78 | ], 79 | "tools:update": [ 80 | "composer update --working-dir=./tools/cs-fixer", 81 | "composer update --working-dir=./tools/infection", 82 | "composer update --working-dir=./tools/phpbench", 83 | "composer update --working-dir=./tools/phpstan" 84 | ], 85 | "post-install-cmd": [ 86 | "@tools:install" 87 | ], 88 | "post-update-cmd": [ 89 | "@tools:update" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at norbert+coduo@orzechowicz.pl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/Matcher/UlidMatcher.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 34 | $this->backtrace = $backtrace; 35 | } 36 | 37 | public function match($value, $pattern) : bool 38 | { 39 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 40 | 41 | if (!\is_string($value)) { 42 | $this->error = \sprintf( 43 | '%s "%s" is not a valid ULID: not a string.', 44 | \gettype($value), 45 | new StringConverter($value) 46 | ); 47 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 48 | 49 | return false; 50 | } 51 | 52 | if (\strlen($value) !== \strspn($value, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz')) { 53 | $this->error = \sprintf( 54 | '%s "%s" is not a valid ULID: invalid characters.', 55 | \gettype($value), 56 | new StringConverter($value) 57 | ); 58 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 59 | 60 | return false; 61 | } 62 | 63 | if (26 < \strlen($value)) { 64 | $this->error = \sprintf( 65 | '%s "%s" is not a valid ULID: too long.', 66 | \gettype($value), 67 | new StringConverter($value) 68 | ); 69 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 70 | 71 | return false; 72 | } 73 | 74 | if (26 > \strlen($value)) { 75 | $this->error = \sprintf( 76 | '%s "%s" is not a valid ULID: too short.', 77 | \gettype($value), 78 | new StringConverter($value) 79 | ); 80 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 81 | 82 | return false; 83 | } 84 | 85 | // Largest valid ULID is '7ZZZZZZZZZZZZZZZZZZZZZZZZZ' 86 | // Cf https://github.com/ulid/spec#overflow-errors-when-parsing-base32-strings 87 | if ($value[0] > '7') { 88 | $this->error = \sprintf( 89 | '%s "%s" is not a valid ULID: overflow.', 90 | \gettype($value), 91 | new StringConverter($value) 92 | ); 93 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->error); 94 | 95 | return false; 96 | } 97 | 98 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 99 | 100 | return true; 101 | } 102 | 103 | public function canMatch($pattern) : bool 104 | { 105 | if (!\is_string($pattern)) { 106 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 107 | 108 | return false; 109 | } 110 | 111 | $result = $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 112 | $this->backtrace->matcherCanMatch(self::class, $pattern, $result); 113 | 114 | return $result; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Factory/MatcherFactory.php: -------------------------------------------------------------------------------- 1 | buildMatchers($this->buildParser($backtrace), $backtrace), $backtrace); 18 | } 19 | 20 | private function buildMatchers(Parser $parser, Backtrace $backtrace) : Matcher\ChainMatcher 21 | { 22 | $scalarMatchers = $this->buildScalarMatchers($parser, $backtrace); 23 | $arrayMatcher = $this->buildArrayMatcher($scalarMatchers, $parser, $backtrace); 24 | 25 | // Matchers are registered in order of matching 26 | // 1) all scalars 27 | // 2) json/xml 28 | // 3) array 29 | // 4) or "||" 30 | // 5) full text 31 | 32 | $matchers = [$scalarMatchers]; 33 | $matchers[] = new Matcher\JsonMatcher($arrayMatcher, $backtrace); 34 | 35 | if (\class_exists(\LSS\XML2Array::class)) { 36 | $matchers[] = new Matcher\XmlMatcher($arrayMatcher, $backtrace); 37 | } 38 | 39 | $matchers[] = $arrayMatcher; 40 | $matchers[] = $this->buildOrMatcher($backtrace, $matchers); 41 | $matchers[] = new Matcher\TextMatcher($backtrace, $parser); 42 | 43 | return new Matcher\ChainMatcher( 44 | 'all', 45 | $backtrace, 46 | $matchers 47 | ); 48 | } 49 | 50 | private function buildArrayMatcher(Matcher\ChainMatcher $scalarMatchers, Parser $parser, Backtrace $backtrace) : Matcher\ArrayMatcher 51 | { 52 | $arrayMatcher = new Matcher\ArrayMatcher( 53 | new Matcher\ChainMatcher( 54 | 'array', 55 | $backtrace, 56 | [ 57 | new Matcher\OrMatcher($backtrace, $orMatchers = clone $scalarMatchers), 58 | $scalarMatchers, 59 | new Matcher\TextMatcher($backtrace, $parser), 60 | ] 61 | ), 62 | $backtrace, 63 | $parser 64 | ); 65 | $orMatchers->registerMatcher($arrayMatcher); 66 | 67 | return $arrayMatcher; 68 | } 69 | 70 | private function buildScalarMatchers(Parser $parser, Backtrace $backtrace) : Matcher\ChainMatcher 71 | { 72 | return new Matcher\ChainMatcher( 73 | 'scalars', 74 | $backtrace, 75 | [ 76 | new Matcher\CallbackMatcher($backtrace), 77 | new Matcher\ExpressionMatcher($backtrace), 78 | new Matcher\NullMatcher($backtrace), 79 | new Matcher\StringMatcher($backtrace, $parser), 80 | new Matcher\IntegerMatcher($backtrace, $parser), 81 | new Matcher\BooleanMatcher($backtrace, $parser), 82 | new Matcher\DoubleMatcher($backtrace, $parser), 83 | new Matcher\NumberMatcher($backtrace, $parser), 84 | new Matcher\TimeMatcher($backtrace, $parser), 85 | new Matcher\DateMatcher($backtrace, $parser), 86 | new Matcher\DateTimeMatcher($backtrace, $parser), 87 | new Matcher\TimeZoneMatcher($backtrace, $parser), 88 | new Matcher\ScalarMatcher($backtrace), 89 | new Matcher\WildcardMatcher($backtrace), 90 | new Matcher\UuidMatcher($backtrace, $parser), 91 | new Matcher\UlidMatcher($backtrace, $parser), 92 | new Matcher\JsonObjectMatcher($backtrace, $parser), 93 | ] 94 | ); 95 | } 96 | 97 | private function buildOrMatcher(Backtrace $backtrace, array $orMatchers) : Matcher\OrMatcher 98 | { 99 | return new Matcher\OrMatcher( 100 | $backtrace, 101 | new Matcher\ChainMatcher( 102 | 'or', 103 | $backtrace, 104 | $orMatchers 105 | ) 106 | ); 107 | } 108 | 109 | private function buildParser(Backtrace $backtrace) : Parser 110 | { 111 | return new Parser(new Lexer(), new Parser\ExpanderInitializer($backtrace)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Backtrace/InMemoryBacktrace.php: -------------------------------------------------------------------------------- 1 | trace = []; 21 | } 22 | 23 | public function __toString() : string 24 | { 25 | return \implode("\n", $this->trace); 26 | } 27 | 28 | public function matcherCanMatch(string $name, $value, bool $result) : void 29 | { 30 | $this->trace[] = \sprintf( 31 | '#%d Matcher %s %s match pattern "%s"', 32 | $this->entriesCount(), 33 | $name, 34 | $result ? 'can' : "can't", 35 | new SingleLineString((string) new StringConverter($value)) 36 | ); 37 | } 38 | 39 | public function matcherEntrance(string $name, $value, $pattern) : void 40 | { 41 | $this->trace[] = \sprintf( 42 | '#%d Matcher %s matching value "%s" with "%s" pattern', 43 | $this->entriesCount(), 44 | $name, 45 | new SingleLineString((string) new StringConverter($value)), 46 | new SingleLineString((string) new StringConverter($pattern)) 47 | ); 48 | } 49 | 50 | public function matcherSucceed(string $name, $value, $pattern) : void 51 | { 52 | $this->trace[] = \sprintf( 53 | '#%d Matcher %s successfully matched value "%s" with "%s" pattern', 54 | $this->entriesCount(), 55 | $name, 56 | new SingleLineString((string) new StringConverter($value)), 57 | new SingleLineString((string) new StringConverter($pattern)) 58 | ); 59 | } 60 | 61 | public function matcherFailed(string $name, $value, $pattern, string $error) : void 62 | { 63 | $this->trace[] = \sprintf( 64 | '#%d Matcher %s failed to match value "%s" with "%s" pattern', 65 | $this->entriesCount(), 66 | $name, 67 | new SingleLineString((string) new StringConverter($value)), 68 | new SingleLineString((string) new StringConverter($pattern)) 69 | ); 70 | 71 | $this->trace[] = \sprintf( 72 | '#%d Matcher %s error: %s', 73 | $this->entriesCount(), 74 | $name, 75 | new SingleLineString($error) 76 | ); 77 | } 78 | 79 | public function expanderEntrance(string $name, $value) : void 80 | { 81 | $this->trace[] = \sprintf( 82 | '#%d Expander %s matching value "%s"', 83 | $this->entriesCount(), 84 | $name, 85 | new SingleLineString((string) new StringConverter($value)) 86 | ); 87 | } 88 | 89 | public function expanderSucceed(string $name, $value) : void 90 | { 91 | $this->trace[] = \sprintf( 92 | '#%d Expander %s successfully matched value "%s"', 93 | $this->entriesCount(), 94 | $name, 95 | new SingleLineString((string) new StringConverter($value)) 96 | ); 97 | } 98 | 99 | public function expanderFailed(string $name, $value, string $error) : void 100 | { 101 | $this->trace[] = \sprintf( 102 | '#%d Expander %s failed to match value "%s"', 103 | $this->entriesCount(), 104 | $name, 105 | new SingleLineString((string) new StringConverter($value)) 106 | ); 107 | 108 | $this->trace[] = \sprintf( 109 | '#%d Expander %s error: %s', 110 | $this->entriesCount(), 111 | $name, 112 | new SingleLineString($error) 113 | ); 114 | } 115 | 116 | public function isEmpty() : bool 117 | { 118 | return \count($this->trace) === 0; 119 | } 120 | 121 | /** 122 | * @return mixed[] 123 | */ 124 | public function raw() : array 125 | { 126 | return $this->trace; 127 | } 128 | 129 | public function last() : ?string 130 | { 131 | if ($this->entriesCount()) { 132 | return \end($this->trace); 133 | } 134 | 135 | return null; 136 | } 137 | 138 | private function entriesCount() : int 139 | { 140 | return \count($this->trace) + 1; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Lexer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class Lexer extends AbstractLexer 13 | { 14 | /** 15 | * @var int 16 | */ 17 | public const T_NONE = 1; 18 | 19 | /** 20 | * @var int 21 | */ 22 | public const T_EXPANDER_NAME = 2; 23 | 24 | /** 25 | * @var int 26 | */ 27 | public const T_CLOSE_PARENTHESIS = 3; 28 | 29 | /** 30 | * @var int 31 | */ 32 | public const T_OPEN_CURLY_BRACE = 4; 33 | 34 | /** 35 | * @var int 36 | */ 37 | public const T_CLOSE_CURLY_BRACE = 5; 38 | 39 | /** 40 | * @var int 41 | */ 42 | public const T_STRING = 6; 43 | 44 | /** 45 | * @var int 46 | */ 47 | public const T_NUMBER = 7; 48 | 49 | /** 50 | * @var int 51 | */ 52 | public const T_BOOLEAN = 8; 53 | 54 | /** 55 | * @var int 56 | */ 57 | public const T_NULL = 9; 58 | 59 | /** 60 | * @var int 61 | */ 62 | public const T_COMMA = 10; 63 | 64 | /** 65 | * @var int 66 | */ 67 | public const T_COLON = 11; 68 | 69 | /** 70 | * @var int 71 | */ 72 | public const T_TYPE_PATTERN = 12; 73 | 74 | /** 75 | * Lexical catchable patterns. 76 | */ 77 | protected function getCatchablePatterns() : array 78 | { 79 | return [ 80 | '\\.?[a-zA-Z0-9_]+\\(', // expander name 81 | '[a-zA-Z0-9.]*', // words 82 | '\\-?[0-9]*\\.?[0-9]*', // numbers 83 | "'(?:[^']|'')*'", // string between ' character 84 | '"(?:[^"]|"")*"', // string between " character, 85 | '@[a-zA-Z0-9\\*.]+@', // type pattern 86 | ]; 87 | } 88 | 89 | /** 90 | * Lexical non-catchable patterns. 91 | * 92 | * @return string[] 93 | */ 94 | protected function getNonCatchablePatterns() : array 95 | { 96 | return [ 97 | '\\s+', 98 | ]; 99 | } 100 | 101 | /** 102 | * Retrieve token type. Also processes the token value if necessary. 103 | */ 104 | protected function getType(&$value) : int 105 | { 106 | $type = self::T_NONE; 107 | 108 | if (')' === $value) { 109 | return self::T_CLOSE_PARENTHESIS; 110 | } 111 | 112 | if ('{' === $value) { 113 | return self::T_OPEN_CURLY_BRACE; 114 | } 115 | 116 | if ('}' === $value) { 117 | return self::T_CLOSE_CURLY_BRACE; 118 | } 119 | 120 | if (':' === $value) { 121 | return self::T_COLON; 122 | } 123 | 124 | if (',' === $value) { 125 | return self::T_COMMA; 126 | } 127 | 128 | if ($this->isTypePatternToken($value)) { 129 | $value = \trim($value, '@'); 130 | 131 | return self::T_TYPE_PATTERN; 132 | } 133 | 134 | if ($this->isStringToken($value)) { 135 | $value = $this->extractStringValue($value); 136 | 137 | return self::T_STRING; 138 | } 139 | 140 | if ($this->isBooleanToken($value)) { 141 | $value = \strtolower($value); 142 | 143 | return self::T_BOOLEAN; 144 | } 145 | 146 | if ($this->isNullToken($value)) { 147 | $value = \strtolower($value); 148 | 149 | return self::T_NULL; 150 | } 151 | 152 | if (\is_numeric($value)) { 153 | return self::T_NUMBER; 154 | } 155 | 156 | if ($this->isExpanderNameToken($value)) { 157 | $value = \rtrim(\ltrim($value, '.'), '('); 158 | 159 | return self::T_EXPANDER_NAME; 160 | } 161 | 162 | return $type; 163 | } 164 | 165 | protected function isStringToken(string $value) : bool 166 | { 167 | return \in_array(\substr($value, 0, 1), ['"', "'"], true); 168 | } 169 | 170 | protected function isBooleanToken(string $value) : bool 171 | { 172 | return \in_array(\strtolower($value), ['true', 'false'], true); 173 | } 174 | 175 | protected function isNullToken(string $value) : bool 176 | { 177 | return \strtolower($value) === 'null'; 178 | } 179 | 180 | protected function extractStringValue(string $value) : string 181 | { 182 | return \trim(\trim($value, "'"), '"'); 183 | } 184 | 185 | protected function isExpanderNameToken(string $value) : bool 186 | { 187 | return \substr($value, -1) === '(' && \strlen($value) > 1; 188 | } 189 | 190 | protected function isTypePatternToken(string $value) : bool 191 | { 192 | return \substr($value, 0, 1) === '@' && \substr($value, \strlen($value) - 1, 1) === '@' && \strlen($value) > 1; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Matcher/Pattern/Expander/Repeat.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 38 | $this->isStrict = $isStrict; 39 | $this->isScalar = true; 40 | 41 | if (\is_string($pattern)) { 42 | $json = \json_decode($pattern, true); 43 | 44 | if ($json !== null && \json_last_error() === JSON_ERROR_NONE) { 45 | $this->pattern = $json; 46 | $this->isScalar = false; 47 | } 48 | } elseif (\is_array($pattern)) { 49 | $this->isScalar = false; 50 | } else { 51 | throw new \InvalidArgumentException('Repeat pattern must be a string or an array.'); 52 | } 53 | } 54 | 55 | public static function is(string $name) : bool 56 | { 57 | return self::NAME === $name; 58 | } 59 | 60 | public function match($value) : bool 61 | { 62 | $this->backtrace->expanderEntrance(self::NAME, $value); 63 | 64 | if (!\is_array($value)) { 65 | $this->error = \sprintf('Repeat expander require "array", got "%s".', new StringConverter($value)); 66 | $this->backtrace->expanderSucceed(self::NAME, $value); 67 | 68 | return false; 69 | } 70 | 71 | $factory = new MatcherFactory(); 72 | $matcher = $factory->createMatcher($this->backtrace); 73 | 74 | if ($this->isScalar) { 75 | $result = $this->matchScalar($value, $matcher); 76 | 77 | if ($result) { 78 | $this->backtrace->expanderSucceed(self::NAME, $value); 79 | } else { 80 | $this->backtrace->expanderFailed(self::NAME, $value, ''); 81 | } 82 | 83 | return $result; 84 | } 85 | 86 | $result = $this->matchJson($value, $matcher); 87 | 88 | if ($result) { 89 | $this->backtrace->expanderSucceed(self::NAME, $value); 90 | } else { 91 | $this->backtrace->expanderFailed(self::NAME, $value, ''); 92 | } 93 | 94 | return $result; 95 | } 96 | 97 | public function getError() : ?string 98 | { 99 | return $this->error; 100 | } 101 | 102 | private function matchScalar(array $values, Matcher $matcher) : bool 103 | { 104 | foreach ($values as $index => $value) { 105 | $match = $matcher->match($value, $this->pattern); 106 | 107 | if (!$match) { 108 | $this->error = \sprintf('Repeat expander, entry n°%d, find error : %s', $index, $matcher->getError()); 109 | 110 | return false; 111 | } 112 | } 113 | 114 | return true; 115 | } 116 | 117 | private function matchJson(array $values, Matcher $matcher) : bool 118 | { 119 | $patternKeys = \array_keys($this->pattern); 120 | $patternKeysLength = \count($patternKeys); 121 | 122 | foreach ($values as $index => $value) { 123 | $valueKeys = \array_keys($value); 124 | $valueKeysLength = \count($valueKeys); 125 | 126 | if ($this->isStrict && $patternKeysLength !== $valueKeysLength) { 127 | $this->error = \sprintf('Repeat expander expect to have %d keys in array but get : %d', $patternKeysLength, $valueKeysLength); 128 | 129 | return false; 130 | } 131 | 132 | foreach ($patternKeys as $key) { 133 | if (!\array_key_exists($key, $value)) { 134 | $this->error = \sprintf('Repeat expander, entry n°%d, require "array" to have key "%s".', $index, $key); 135 | 136 | return false; 137 | } 138 | 139 | $match = $matcher->match($value[$key], $this->pattern[$key]); 140 | 141 | if (!$match) { 142 | $this->error = \sprintf('Repeat expander, entry n°%d, key "%s", find error : %s', $index, $key, $matcher->getError()); 143 | 144 | return false; 145 | } 146 | } 147 | } 148 | 149 | return true; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Parser/ExpanderInitializer.php: -------------------------------------------------------------------------------- 1 | Pattern\Expander\After::class, 23 | Pattern\Expander\Before::NAME => Pattern\Expander\Before::class, 24 | Pattern\Expander\Contains::NAME => Pattern\Expander\Contains::class, 25 | Pattern\Expander\NotContains::NAME => Pattern\Expander\NotContains::class, 26 | Pattern\Expander\Count::NAME => Pattern\Expander\Count::class, 27 | Pattern\Expander\EndsWith::NAME => Pattern\Expander\EndsWith::class, 28 | Pattern\Expander\GreaterThan::NAME => Pattern\Expander\GreaterThan::class, 29 | Pattern\Expander\InArray::NAME => Pattern\Expander\InArray::class, 30 | Pattern\Expander\IsDateTime::NAME => Pattern\Expander\IsDateTime::class, 31 | Pattern\Expander\IsInDateFormat::NAME => Pattern\Expander\IsInDateFormat::class, 32 | Pattern\Expander\IsEmail::NAME => Pattern\Expander\IsEmail::class, 33 | Pattern\Expander\IsEmpty::NAME => Pattern\Expander\IsEmpty::class, 34 | Pattern\Expander\IsNotEmpty::NAME => Pattern\Expander\IsNotEmpty::class, 35 | Pattern\Expander\IsUrl::NAME => Pattern\Expander\IsUrl::class, 36 | Pattern\Expander\IsIp::NAME => Pattern\Expander\IsIp::class, 37 | Pattern\Expander\IsTzOffset::NAME => Pattern\Expander\IsTzOffset::class, 38 | Pattern\Expander\IsTzAbbreviation::NAME => Pattern\Expander\IsTzAbbreviation::class, 39 | Pattern\Expander\IsTzIdentifier::NAME => Pattern\Expander\IsTzIdentifier::class, 40 | Pattern\Expander\LowerThan::NAME => Pattern\Expander\LowerThan::class, 41 | Pattern\Expander\MatchRegex::NAME => Pattern\Expander\MatchRegex::class, 42 | Pattern\Expander\OneOf::NAME => Pattern\Expander\OneOf::class, 43 | Pattern\Expander\Optional::NAME => Pattern\Expander\Optional::class, 44 | Pattern\Expander\StartsWith::NAME => Pattern\Expander\StartsWith::class, 45 | Pattern\Expander\Repeat::NAME => Pattern\Expander\Repeat::class, 46 | Pattern\Expander\ExpanderMatch::NAME => Pattern\Expander\ExpanderMatch::class, 47 | Pattern\Expander\HasProperty::NAME => Pattern\Expander\HasProperty::class, 48 | ]; 49 | 50 | private Backtrace $backtrace; 51 | 52 | public function __construct(Backtrace $backtrace) 53 | { 54 | $this->backtrace = $backtrace; 55 | } 56 | 57 | public function setExpanderDefinition(string $expanderName, string $expanderFQCN) : void 58 | { 59 | if (!\class_exists($expanderFQCN)) { 60 | throw new UnknownExpanderClassException(\sprintf('Class "%s" does not exists.', $expanderFQCN)); 61 | } 62 | 63 | $this->expanderDefinitions[$expanderName] = $expanderFQCN; 64 | } 65 | 66 | public function hasExpanderDefinition(string $expanderName) : bool 67 | { 68 | return \array_key_exists($expanderName, $this->expanderDefinitions); 69 | } 70 | 71 | public function getExpanderDefinition(string $expanderName) : string 72 | { 73 | if (!$this->hasExpanderDefinition($expanderName)) { 74 | throw new InvalidArgumentException(\sprintf('Definition for "%s" expander does not exists.', $expanderName)); 75 | } 76 | 77 | return $this->expanderDefinitions[$expanderName]; 78 | } 79 | 80 | public function initialize(Expander $expanderNode) : PatternExpander 81 | { 82 | if (!\array_key_exists($expanderNode->getName(), $this->expanderDefinitions)) { 83 | throw new UnknownExpanderException(\sprintf('Unknown expander "%s"', $expanderNode->getName())); 84 | } 85 | 86 | $reflection = new \ReflectionClass($this->expanderDefinitions[$expanderNode->getName()]); 87 | 88 | if ($expanderNode->hasArguments()) { 89 | $arguments = []; 90 | 91 | foreach ($expanderNode->getArguments() as $argument) { 92 | $arguments[] = ($argument instanceof Expander) 93 | ? $this->initialize($argument) 94 | : $argument; 95 | } 96 | 97 | $expander = $reflection->newInstanceArgs($arguments); 98 | } else { 99 | $expander = $reflection->newInstance(); 100 | } 101 | 102 | if (!$expander instanceof PatternExpander) { 103 | throw new InvalidExpanderTypeException(); 104 | } 105 | 106 | $expander->setBacktrace($this->backtrace); 107 | 108 | return $expander; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Matcher/TextMatcher.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 36 | $this->backtrace = $backtrace; 37 | } 38 | 39 | /** 40 | * {@inheritDoc} 41 | */ 42 | public function match($value, $pattern) : bool 43 | { 44 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 45 | 46 | if (!\is_string($value)) { 47 | $this->error = \sprintf('%s "%s" is not a valid string.', \gettype($value), new StringConverter($value)); 48 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 49 | 50 | return false; 51 | } 52 | 53 | $patternRegex = $pattern; 54 | $patternsReplacedWithRegex = $this->replaceTypePatternsWithPlaceholders($patternRegex); 55 | $patternRegex = $this->prepareRegex($patternRegex); 56 | 57 | try { 58 | $patternRegex = $this->replacePlaceholderWithPatternRegexes($patternRegex, $patternsReplacedWithRegex); 59 | } catch (UnknownTypeException $unknownTypeException) { 60 | $this->error = \sprintf('Type pattern "%s" is not supported by TextMatcher.', $unknownTypeException->getType()); 61 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 62 | 63 | return false; 64 | } 65 | 66 | if (!\preg_match($patternRegex, $value, $matchedValues)) { 67 | $this->error = \sprintf('"%s" does not match "%s" pattern', $value, $pattern); 68 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 69 | 70 | return false; 71 | } 72 | 73 | \array_shift($matchedValues); // remove matched string 74 | 75 | if (\count($patternsReplacedWithRegex) !== \count($matchedValues)) { 76 | $this->error = 'Unexpected TextMatcher error.'; 77 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 78 | 79 | return false; 80 | } 81 | 82 | foreach ($patternsReplacedWithRegex as $index => $typePattern) { 83 | if (!$typePattern->matchExpanders($matchedValues[$index])) { 84 | $this->error = $typePattern->getError(); 85 | $this->backtrace->matcherFailed(self::class, $value, $pattern, (string) $this->error); 86 | 87 | return false; 88 | } 89 | } 90 | 91 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 92 | 93 | return true; 94 | } 95 | 96 | /** 97 | * {@inheritDoc} 98 | */ 99 | public function canMatch($pattern) : bool 100 | { 101 | if (!\is_string($pattern)) { 102 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 103 | 104 | return false; 105 | } 106 | 107 | if (Json::isValidPattern($pattern)) { 108 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 109 | 110 | return false; 111 | } 112 | 113 | if (Xml::isValid($pattern)) { 114 | $this->backtrace->matcherCanMatch(self::class, $pattern, false); 115 | 116 | return false; 117 | } 118 | 119 | $this->backtrace->matcherCanMatch(self::class, $pattern, true); 120 | 121 | return true; 122 | } 123 | 124 | /** 125 | * Replace each type pattern (@string@.startsWith("lorem")) with placeholder, in order 126 | * to use preg_quote without destroying pattern & expanders. 127 | * 128 | * before replacement: "/users/@integer@.greaterThan(200)/active" 129 | * after replacement: "/users/__PLACEHOLDER0__/active" 130 | * 131 | * @param string $patternRegex 132 | * 133 | * @return array|TypePattern[] 134 | */ 135 | private function replaceTypePatternsWithPlaceholders(string &$patternRegex) : array 136 | { 137 | $patternsReplacedWithRegex = []; 138 | \preg_match_all(self::PATTERN_REGEXP, $patternRegex, $matches); 139 | 140 | foreach ($matches[0] as $index => $typePatternString) { 141 | $typePattern = $this->parser->parse($typePatternString); 142 | $patternsReplacedWithRegex[] = $typePattern; 143 | $patternRegex = \str_replace( 144 | $typePatternString, 145 | \sprintf(self::PATTERN_REGEXP_PLACEHOLDER_TEMPLATE, $index), 146 | $patternRegex 147 | ); 148 | } 149 | 150 | return $patternsReplacedWithRegex; 151 | } 152 | 153 | /** 154 | * Replace placeholders with type pattern regular expressions 155 | * before replacement: "/users/__PLACEHOLDER0__/active" 156 | * after replacement: "/^\/users\/(\-?[0-9]*)\/active$/". 157 | * 158 | * @param string $patternRegex 159 | * 160 | * @throws UnknownTypeException 161 | * 162 | * @return string 163 | */ 164 | private function replacePlaceholderWithPatternRegexes(string $patternRegex, array $patternsReplacedWithRegex) : string 165 | { 166 | $regexConverter = new RegexConverter(); 167 | 168 | foreach ($patternsReplacedWithRegex as $index => $typePattern) { 169 | $patternRegex = \str_replace( 170 | \sprintf(self::PATTERN_REGEXP_PLACEHOLDER_TEMPLATE, $index), 171 | $regexConverter->toRegex($typePattern), 172 | $patternRegex 173 | ); 174 | } 175 | 176 | return $patternRegex; 177 | } 178 | 179 | /** 180 | * Prepare regular expression. 181 | * 182 | * @param string $patternRegex 183 | * 184 | * @return string 185 | */ 186 | private function prepareRegex(string $patternRegex) : string 187 | { 188 | return '/^' . \preg_quote($patternRegex, '/') . '$/'; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | files() 5 | ->in([ 6 | __DIR__ . '/src', 7 | __DIR__ . '/tests', 8 | ]); 9 | 10 | if (!\file_exists(__DIR__ . '/var')) { 11 | \mkdir(__DIR__ . '/var'); 12 | } 13 | 14 | if (!\file_exists(__DIR__ . '/var')) { 15 | \mkdir(__DIR__ . '/var'); 16 | } 17 | 18 | /** 19 | * This configuration was taken from https://github.com/sebastianbergmann/phpunit/blob/master/.php_cs.dist 20 | * and slightly adjusted. 21 | */ 22 | $config = new PhpCsFixer\Config(); 23 | 24 | return $config 25 | ->setRiskyAllowed(true) 26 | ->setCacheFile(__DIR__.'/var/.php_cs.cache') 27 | ->setRules([ 28 | 'align_multiline_comment' => true, 29 | 'array_indentation' => true, 30 | 'array_syntax' => ['syntax' => 'short'], 31 | 'blank_line_after_namespace' => true, 32 | 'blank_line_before_statement' => [ 33 | 'statements' => [ 34 | 'break', 35 | 'continue', 36 | 'declare', 37 | 'default', 38 | 'do', 39 | 'exit', 40 | 'for', 41 | 'foreach', 42 | 'goto', 43 | 'if', 44 | 'include', 45 | 'include_once', 46 | 'require', 47 | 'require_once', 48 | 'return', 49 | 'switch', 50 | 'throw', 51 | 'try', 52 | 'while', 53 | ], 54 | ], 55 | 'braces' => true, 56 | 'cast_spaces' => true, 57 | 'class_attributes_separation' => ['elements' => ['const'=>'one', 'method'=>'one', 'property'=>'one']], 58 | 'combine_consecutive_issets' => true, 59 | 'combine_consecutive_unsets' => true, 60 | 'compact_nullable_typehint' => true, 61 | 'concat_space' => ['spacing' => 'one'], 62 | 'constant_case' => true, 63 | 'declare_equal_normalize' => ['space' => 'none'], 64 | 'declare_strict_types' => true, 65 | 'dir_constant' => true, 66 | 'elseif' => true, 67 | 'encoding' => true, 68 | 'echo_tag_syntax' => true, 69 | 'explicit_indirect_variable' => true, 70 | 'explicit_string_variable' => true, 71 | 'full_opening_tag' => true, 72 | 'fully_qualified_strict_types' => true, 73 | 'function_typehint_space' => true, 74 | 'function_declaration' => true, 75 | 'global_namespace_import' => [ 76 | 'import_classes' => false, 77 | 'import_constants' => false, 78 | 'import_functions' => false, 79 | ], 80 | 'heredoc_to_nowdoc' => true, 81 | 'increment_style' => [ 82 | 'style' => PhpCsFixer\Fixer\Operator\IncrementStyleFixer::STYLE_POST, 83 | ], 84 | 'indentation_type' => true, 85 | 'is_null' => true, 86 | 'line_ending' => true, 87 | 'list_syntax' => ['syntax' => 'short'], 88 | 'logical_operators' => true, 89 | 'lowercase_keywords' => true, 90 | 'lowercase_static_reference' => true, 91 | 'magic_constant_casing' => true, 92 | 'magic_method_casing' => true, 93 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 94 | 'modernize_types_casting' => false, 95 | 'multiline_comment_opening_closing' => true, 96 | 'multiline_whitespace_before_semicolons' => true, 97 | 'native_constant_invocation' => false, 98 | 'native_function_casing' => false, 99 | 'native_function_invocation' => ['include'=>['@all']], 100 | 'native_function_type_declaration_casing' => true, 101 | 'new_with_braces' => false, 102 | 'no_alias_functions' => true, 103 | 'no_alternative_syntax' => true, 104 | 'no_blank_lines_after_class_opening' => true, 105 | 'no_blank_lines_after_phpdoc' => true, 106 | 'no_blank_lines_before_namespace' => false, 107 | 'no_closing_tag' => true, 108 | 'no_empty_comment' => true, 109 | 'no_empty_phpdoc' => true, 110 | 'no_empty_statement' => true, 111 | 'no_extra_blank_lines' => true, 112 | 'no_homoglyph_names' => true, 113 | 'no_leading_import_slash' => true, 114 | 'no_leading_namespace_whitespace' => true, 115 | 'no_mixed_echo_print' => ['use' => 'print'], 116 | 'no_multiline_whitespace_around_double_arrow' => true, 117 | 'no_null_property_initialization' => true, 118 | 'no_php4_constructor' => true, 119 | 'no_short_bool_cast' => true, 120 | 'no_singleline_whitespace_before_semicolons' => true, 121 | 'no_spaces_after_function_name' => true, 122 | 'no_spaces_around_offset' => true, 123 | 'no_spaces_inside_parenthesis' => true, 124 | 'no_superfluous_elseif' => true, 125 | 'no_superfluous_phpdoc_tags' => false, 126 | 'no_trailing_comma_in_list_call' => true, 127 | 'no_trailing_comma_in_singleline_array' => true, 128 | 'no_trailing_whitespace' => true, 129 | 'no_trailing_whitespace_in_comment' => true, 130 | 'no_unneeded_control_parentheses' => true, 131 | 'no_unneeded_curly_braces' => true, 132 | 'no_unneeded_final_method' => true, 133 | 'no_unreachable_default_argument_value' => true, 134 | 'no_unset_on_property' => true, 135 | 'no_unused_imports' => true, 136 | 'no_useless_else' => true, 137 | 'no_useless_return' => true, 138 | 'no_whitespace_before_comma_in_array' => true, 139 | 'no_whitespace_in_blank_line' => true, 140 | 'non_printable_character' => true, 141 | 'normalize_index_brace' => true, 142 | 'object_operator_without_whitespace' => true, 143 | 'ordered_class_elements' => [ 144 | 'order' => [ 145 | 'use_trait', 146 | 'constant_public', 147 | 'constant_protected', 148 | 'constant_private', 149 | 'property_public_static', 150 | 'property_protected_static', 151 | 'property_private_static', 152 | 'property_public', 153 | 'property_protected', 154 | 'property_private', 155 | 'construct', 156 | 'method_public_static', 157 | 'destruct', 158 | 'magic', 159 | 'phpunit', 160 | 'method_public', 161 | 'method_protected', 162 | 'method_private', 163 | 'method_protected_static', 164 | 'method_private_static', 165 | ], 166 | ], 167 | 'ordered_imports' => [ 168 | 'imports_order' => [ 169 | PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_CONST, 170 | PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_FUNCTION, 171 | PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_CLASS, 172 | ] 173 | ], 174 | 'ordered_interfaces' => [ 175 | 'direction' => 'ascend', 176 | 'order' => 'alpha', 177 | ], 178 | 'phpdoc_add_missing_param_annotation' => false, 179 | 'phpdoc_align' => ['align' => 'left'], 180 | 'phpdoc_annotation_without_dot' => true, 181 | 'phpdoc_indent' => true, 182 | 'phpdoc_no_access' => true, 183 | 'phpdoc_no_empty_return' => true, 184 | 'phpdoc_no_package' => true, 185 | 'phpdoc_order' => true, 186 | 'phpdoc_return_self_reference' => true, 187 | 'phpdoc_scalar' => true, 188 | 'phpdoc_separation' => true, 189 | 'phpdoc_single_line_var_spacing' => true, 190 | 'phpdoc_summary' => true, 191 | 'phpdoc_to_comment' => false, 192 | 'phpdoc_trim' => true, 193 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 194 | 'phpdoc_types' => ['groups' => ['simple', 'meta']], 195 | 'phpdoc_types_order' => true, 196 | 'phpdoc_var_without_name' => true, 197 | 'pow_to_exponentiation' => true, 198 | 'protected_to_private' => true, 199 | 'return_assignment' => true, 200 | 'return_type_declaration' => ['space_before' => 'one'], 201 | 'self_accessor' => true, 202 | 'self_static_accessor' => true, 203 | 'semicolon_after_instruction' => true, 204 | 'set_type_to_cast' => true, 205 | 'short_scalar_cast' => true, 206 | 'simple_to_complex_string_variable' => true, 207 | 'simplified_null_return' => false, 208 | 'single_blank_line_at_eof' => true, 209 | 'single_import_per_statement' => true, 210 | 'single_line_after_imports' => true, 211 | 'single_quote' => true, 212 | 'standardize_not_equals' => true, 213 | 'strict_param' => true, 214 | 'ternary_to_null_coalescing' => true, 215 | 'trailing_comma_in_multiline' => true, 216 | 'trim_array_spaces' => true, 217 | 'unary_operator_spaces' => true, 218 | 'visibility_required' => [ 219 | 'elements' => [ 220 | 'const', 221 | 'method', 222 | 'property', 223 | ], 224 | ], 225 | 'void_return' => true, 226 | 'whitespace_after_comma_in_array' => true, 227 | ]) 228 | ->setFinder($finder); -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | lexer = $lexer; 27 | $this->expanderInitializer = $expanderInitializer; 28 | } 29 | 30 | public function hasValidSyntax(string $pattern) : bool 31 | { 32 | try { 33 | $this->getAST($pattern); 34 | 35 | return true; 36 | } catch (Exception $exception) { 37 | return false; 38 | } 39 | } 40 | 41 | public function parse(string $pattern) : Pattern\TypePattern 42 | { 43 | $ast = $this->getAST($pattern); 44 | $typePattern = new Pattern\TypePattern((string) $ast->getType()); 45 | 46 | foreach ($ast->getExpanders() as $expander) { 47 | $typePattern->addExpander($this->expanderInitializer->initialize($expander)); 48 | } 49 | 50 | return $typePattern; 51 | } 52 | 53 | public function getAST(string $pattern) : AST\Pattern 54 | { 55 | if ($pattern === '') { 56 | return new AST\Pattern(new AST\Type('')); 57 | } 58 | 59 | $this->lexer->setInput($pattern); 60 | 61 | return $this->getPattern(); 62 | } 63 | 64 | private function getPattern() : AST\Pattern 65 | { 66 | $this->lexer->moveNext(); 67 | 68 | if ($this->lexer->lookahead->type !== Lexer::T_TYPE_PATTERN) { 69 | throw PatternException::syntaxError( 70 | $this->unexpectedSyntaxError($this->lexer->lookahead, '@type@ pattern') 71 | ); 72 | } 73 | 74 | $pattern = new AST\Pattern(new AST\Type($this->lexer->lookahead->value)); 75 | 76 | $this->lexer->moveNext(); 77 | 78 | if (!$this->endOfPattern()) { 79 | $this->addExpanderNodes($pattern); 80 | } 81 | 82 | return $pattern; 83 | } 84 | 85 | private function addExpanderNodes(AST\Pattern $pattern) : void 86 | { 87 | while (($expander = $this->getNextExpanderNode()) !== null) { 88 | $pattern->addExpander($expander); 89 | } 90 | } 91 | 92 | /** 93 | * Try to get next expander, return null if there is no expander left. 94 | */ 95 | private function getNextExpanderNode() : ?AST\Expander 96 | { 97 | if ($this->endOfPattern()) { 98 | return null; 99 | } 100 | 101 | $expander = new AST\Expander($this->getExpanderName()); 102 | 103 | /* @phpstan-ignore-next-line */ 104 | if ($this->endOfPattern()) { 105 | $this->unexpectedEndOfString(')'); 106 | } 107 | 108 | $this->addArgumentValues($expander); 109 | 110 | if ($this->endOfPattern()) { 111 | $this->unexpectedEndOfString(')'); 112 | } 113 | 114 | if (!$this->isNextCloseParenthesis()) { 115 | throw PatternException::syntaxError($this->unexpectedSyntaxError($this->lexer->lookahead, ')')); 116 | } 117 | 118 | return $expander; 119 | } 120 | 121 | private function getExpanderName() : string 122 | { 123 | $lookahead = $this->lexer->lookahead; 124 | 125 | if ($lookahead === null) { 126 | throw PatternException::syntaxError($this->unexpectedSyntaxError($lookahead, '.expanderName(args) definition')); 127 | } 128 | 129 | if ($lookahead->type !== Lexer::T_EXPANDER_NAME) { 130 | throw PatternException::syntaxError($this->unexpectedSyntaxError($lookahead, '.expanderName(args) definition')); 131 | } 132 | 133 | $this->lexer->moveNext(); 134 | 135 | return $lookahead->value; 136 | } 137 | 138 | /** 139 | * Add arguments to expander. 140 | */ 141 | private function addArgumentValues(AST\Expander $expander) : void 142 | { 143 | while (($argument = $this->getNextArgumentValue()) !== null) { 144 | $coercedArgument = $this->coerceArgumentValue($argument); 145 | 146 | $expander->addArgument($coercedArgument); 147 | 148 | if (!$this->lexer->isNextToken(Lexer::T_COMMA)) { 149 | break; 150 | } 151 | 152 | $this->lexer->moveNext(); 153 | 154 | if ($this->lexer->isNextToken(Lexer::T_CLOSE_PARENTHESIS)) { 155 | throw PatternException::syntaxError($this->unexpectedSyntaxError($this->lexer->lookahead, 'string, number, boolean or null argument')); 156 | } 157 | } 158 | } 159 | 160 | private function coerceArgumentValue(mixed $argument) : mixed 161 | { 162 | $coercedArgument = $argument; 163 | 164 | if (\is_string($argument)) { 165 | $lowercaseArgument = \strtolower($argument); 166 | 167 | if ($lowercaseArgument === self::NULL_VALUE) { 168 | $coercedArgument = null; 169 | } elseif ($lowercaseArgument === 'true') { 170 | $coercedArgument = true; 171 | } elseif ($lowercaseArgument === 'false') { 172 | $coercedArgument = false; 173 | } elseif (\is_numeric($argument)) { 174 | $coercedArgument = (\strpos($argument, '.') === false) ? (int) $argument : (float) $argument; 175 | } 176 | } elseif (\is_array($argument)) { 177 | $coercedArgument = []; 178 | 179 | foreach ($argument as $key => $arg) { 180 | $coercedArgument[$key] = $this->coerceArgumentValue($arg); 181 | } 182 | } 183 | 184 | return $coercedArgument; 185 | } 186 | 187 | /** 188 | * Try to get next argument. Return false if there are no arguments left before ")". 189 | */ 190 | private function getNextArgumentValue() 191 | { 192 | $validArgumentTypes = [ 193 | Lexer::T_STRING, 194 | Lexer::T_NUMBER, 195 | Lexer::T_BOOLEAN, 196 | Lexer::T_NULL, 197 | ]; 198 | 199 | if ($this->lexer->isNextToken(Lexer::T_CLOSE_PARENTHESIS)) { 200 | return; 201 | } 202 | 203 | if ($this->lexer->isNextToken(Lexer::T_OPEN_CURLY_BRACE)) { 204 | return $this->getArrayArgument(); 205 | } 206 | 207 | if ($this->lexer->isNextToken(Lexer::T_EXPANDER_NAME)) { 208 | return $this->getNextExpanderNode(); 209 | } 210 | 211 | if (!$this->lexer->isNextTokenAny($validArgumentTypes)) { 212 | throw PatternException::syntaxError($this->unexpectedSyntaxError($this->lexer->lookahead, 'string, number, boolean or null argument')); 213 | } 214 | 215 | $tokenType = $this->lexer->lookahead->type; 216 | $argument = $this->lexer->lookahead->value; 217 | $this->lexer->moveNext(); 218 | 219 | if ($tokenType === Lexer::T_NULL) { 220 | $argument = self::NULL_VALUE; 221 | } 222 | 223 | return $argument; 224 | } 225 | 226 | private function getArrayArgument() : array 227 | { 228 | $arrayArgument = []; 229 | $this->lexer->moveNext(); 230 | 231 | while ($this->getNextArrayElement($arrayArgument) !== null) { 232 | $this->lexer->moveNext(); 233 | } 234 | 235 | if (!$this->lexer->isNextToken(Lexer::T_CLOSE_CURLY_BRACE)) { 236 | throw PatternException::syntaxError($this->unexpectedSyntaxError($this->lexer->lookahead, '}')); 237 | } 238 | 239 | $this->lexer->moveNext(); 240 | 241 | return $arrayArgument; 242 | } 243 | 244 | private function getNextArrayElement(array &$array) : ?bool 245 | { 246 | if ($this->lexer->isNextToken(Lexer::T_CLOSE_CURLY_BRACE)) { 247 | return null; 248 | } 249 | 250 | $key = $this->getNextArgumentValue(); 251 | 252 | if ($key === self::NULL_VALUE) { 253 | $key = ''; 254 | } 255 | 256 | if (!$this->lexer->isNextToken(Lexer::T_COLON)) { 257 | throw PatternException::syntaxError($this->unexpectedSyntaxError($this->lexer->lookahead, ':')); 258 | } 259 | 260 | $this->lexer->moveNext(); 261 | 262 | $value = $this->getNextArgumentValue(); 263 | 264 | if ($value === self::NULL_VALUE) { 265 | $value = null; 266 | } 267 | 268 | $array[$key] = $value; 269 | 270 | if (!$this->lexer->isNextToken(Lexer::T_COMMA)) { 271 | return null; 272 | } 273 | 274 | return true; 275 | } 276 | 277 | private function isNextCloseParenthesis() : bool 278 | { 279 | $isCloseParenthesis = $this->lexer->isNextToken(Lexer::T_CLOSE_PARENTHESIS); 280 | $this->lexer->moveNext(); 281 | 282 | return $isCloseParenthesis; 283 | } 284 | 285 | /** 286 | * @param ?Token $unexpectedToken 287 | * @param string $expected 288 | */ 289 | private function unexpectedSyntaxError($unexpectedToken, ?string $expected = null) : string 290 | { 291 | $tokenPos = $unexpectedToken !== null ? $unexpectedToken->position : '-1'; 292 | $message = \sprintf('line 0, col %d: Error: ', $tokenPos); 293 | $message .= (isset($expected)) ? \sprintf('Expected "%s", got ', $expected) : 'Unexpected'; 294 | $message .= \sprintf('"%s"', $unexpectedToken->value); 295 | 296 | return $message; 297 | } 298 | 299 | /** 300 | * @param string $expected 301 | * 302 | * @throws PatternException 303 | */ 304 | private function unexpectedEndOfString(?string $expected = null) : void 305 | { 306 | $tokenPos = (isset($this->lexer->token->position)) 307 | ? $this->lexer->token->position + \strlen((string) $this->lexer->token->value) : '-1'; 308 | $message = \sprintf('line 0, col %d: Error: ', $tokenPos); 309 | $message .= (isset($expected)) ? \sprintf('Expected "%s", got end of string.', $expected) : 'Unexpected'; 310 | $message .= 'end of string'; 311 | 312 | throw PatternException::syntaxError($message); 313 | } 314 | 315 | private function endOfPattern() : bool 316 | { 317 | return null === $this->lexer->lookahead; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Matcher/ArrayMatcher.php: -------------------------------------------------------------------------------- 1 | diff = new Diff(); 47 | $this->propertyMatcher = $propertyMatcher; 48 | $this->parser = $parser; 49 | $this->backtrace = $backtrace; 50 | } 51 | 52 | public function match($value, $pattern) : bool 53 | { 54 | $this->backtrace->matcherEntrance(self::class, $value, $pattern); 55 | 56 | if (parent::match($value, $pattern)) { 57 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 58 | 59 | return true; 60 | } 61 | 62 | if (!\is_array($value)) { 63 | $this->addValuePatternDifference($value, $pattern); 64 | 65 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->getError()); 66 | 67 | return false; 68 | } 69 | 70 | if ($this->isArrayPattern($pattern)) { 71 | return $this->allExpandersMatch($value, $pattern); 72 | } 73 | 74 | if (!$this->iterateMatch($value, $pattern)) { 75 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->getError()); 76 | 77 | return false; 78 | } 79 | 80 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 81 | 82 | return true; 83 | } 84 | 85 | public function canMatch($pattern) : bool 86 | { 87 | return \is_array($pattern) || $this->isArrayPattern($pattern); 88 | } 89 | 90 | public function getError() : ?string 91 | { 92 | if (!$this->diff->count()) { 93 | return null; 94 | } 95 | 96 | return \implode("\n", \array_map(fn (Difference $difference) : string => $difference->format(), $this->diff->all())); 97 | } 98 | 99 | public function clearError() : void 100 | { 101 | $this->diff = new Diff(); 102 | } 103 | 104 | private function isArrayPattern($pattern) : bool 105 | { 106 | if (!\is_string($pattern)) { 107 | return false; 108 | } 109 | 110 | return $this->parser->hasValidSyntax($pattern) && $this->parser->parse($pattern)->is(self::PATTERN); 111 | } 112 | 113 | private function iterateMatch(array $values, array $patterns, string $parentPath = '') : bool 114 | { 115 | $pattern = null; 116 | $previousPattern = null; 117 | 118 | if (\in_array(self::ARRAY_PREVIOUS_PATTERN_REPEAT, $patterns, true)) { 119 | $patterns = \array_merge( 120 | \array_replace($patterns, [\array_search(self::ARRAY_PREVIOUS_PATTERN_REPEAT, $patterns, true) => self::ARRAY_PREVIOUS_PATTERN]), 121 | \array_fill(0, \count($values) - \count($patterns), self::ARRAY_PREVIOUS_PATTERN) 122 | ); 123 | } 124 | 125 | foreach ($values as $key => $value) { 126 | $path = $this->formatAccessPath($key); 127 | 128 | if ($this->shouldSkipValueMatchingFor($pattern)) { 129 | continue; 130 | } 131 | 132 | if ($this->valueExist($path, $patterns)) { 133 | $pattern = $this->getValueByPath($patterns, $path); 134 | } elseif (isset($patterns[self::UNIVERSAL_KEY])) { 135 | $pattern = $patterns[self::UNIVERSAL_KEY]; 136 | } else { 137 | $this->setMissingElementInError('pattern', $this->formatFullPath($parentPath, $path)); 138 | 139 | return false; 140 | } 141 | 142 | if ($pattern === self::ARRAY_PREVIOUS_PATTERN) { 143 | $pattern = $previousPattern; 144 | } 145 | 146 | if ($this->shouldSkipValueMatchingFor($pattern)) { 147 | continue; 148 | } 149 | 150 | if ($this->valueMatchPattern($value, $pattern, $this->formatFullPath($parentPath, $path))) { 151 | continue; 152 | } 153 | 154 | if (!\is_array($value)) { 155 | return false; 156 | } 157 | 158 | if (!$this->canMatch($pattern)) { 159 | $this->addValuePatternDifference($value, $parentPath, $this->formatFullPath($parentPath, $path)); 160 | 161 | return false; 162 | } 163 | 164 | if ($this->isArrayPattern($pattern)) { 165 | if (!$this->allExpandersMatch($value, $pattern, $parentPath)) { 166 | $this->addValuePatternDifference($value, $parentPath, $this->formatFullPath($parentPath, $path)); 167 | 168 | return false; 169 | } 170 | 171 | continue; 172 | } 173 | 174 | if (!$this->iterateMatch($value, $pattern, $this->formatFullPath($parentPath, $path))) { 175 | return false; 176 | } 177 | 178 | $previousPattern = $pattern; 179 | } 180 | 181 | return $this->isPatternValid($patterns, $values, $parentPath); 182 | } 183 | 184 | private function isPatternValid(array $pattern, array $values, string $parentPath) : bool 185 | { 186 | $skipPattern = self::UNBOUNDED_PATTERN; 187 | 188 | $pattern = \array_filter( 189 | $pattern, 190 | fn ($item) => $item !== $skipPattern 191 | ); 192 | 193 | $notExistingKeys = $this->findNotExistingKeys($pattern, $values); 194 | 195 | if (\count($notExistingKeys) > 0) { 196 | $keyNames = \array_keys($notExistingKeys); 197 | $path = $this->formatFullPath($parentPath, $this->formatAccessPath($keyNames[0])); 198 | $this->setMissingElementInError('value', $path); 199 | 200 | return false; 201 | } 202 | 203 | return true; 204 | } 205 | 206 | /** 207 | * @return mixed[] 208 | */ 209 | private function findNotExistingKeys(array $patterns, array $values) : array 210 | { 211 | $notExistingKeys = \array_diff_key($patterns, $values); 212 | 213 | return \array_filter($notExistingKeys, function ($pattern, $key) use ($values) : bool { 214 | if ($key === self::UNIVERSAL_KEY) { 215 | return false; 216 | } 217 | 218 | if (\is_array($pattern)) { 219 | return empty($pattern) || !$this->match($values, $pattern); 220 | } 221 | 222 | try { 223 | $typePattern = $this->parser->parse($pattern); 224 | } catch (\Throwable) { 225 | return true; 226 | } 227 | 228 | return !$typePattern->hasExpander('optional'); 229 | }, ARRAY_FILTER_USE_BOTH); 230 | } 231 | 232 | private function valueMatchPattern($value, $pattern, $parentPath) : bool 233 | { 234 | $match = $this->propertyMatcher->canMatch($pattern) && 235 | $this->propertyMatcher->match($value, $pattern); 236 | 237 | if (!$match) { 238 | if (!\is_array($value)) { 239 | $this->addValuePatternDifference($value, $pattern, $parentPath); 240 | } 241 | } 242 | 243 | return $match; 244 | } 245 | 246 | private function valueExist(string $path, array $haystack) : bool 247 | { 248 | return $this->arrayPropertyExists($this->getKeyFromAccessPath($path), $haystack); 249 | } 250 | 251 | private function arrayPropertyExists(string $property, array $objectOrArray) : bool 252 | { 253 | return isset($objectOrArray[$property]) || 254 | \array_key_exists($property, $objectOrArray); 255 | } 256 | 257 | private function getValueByPath(array $array, string $path) 258 | { 259 | return $array[$this->getKeyFromAccessPath($path)]; 260 | } 261 | 262 | private function setMissingElementInError(string $place, string $path) : void 263 | { 264 | $this->diff = $this->diff->add(new StringDifference(\sprintf('There is no element under path %s in %s.', $path, $place))); 265 | } 266 | 267 | private function formatAccessPath($key) : string 268 | { 269 | return \sprintf('[%s]', $key); 270 | } 271 | 272 | private function getKeyFromAccessPath(string $path) : string 273 | { 274 | return \substr($path, 1, -1); 275 | } 276 | 277 | private function formatFullPath(string $parentPath, string $path) : string 278 | { 279 | return \sprintf('%s%s', $parentPath, $path); 280 | } 281 | 282 | private function shouldSkipValueMatchingFor($lastPattern) : bool 283 | { 284 | return $lastPattern === self::UNBOUNDED_PATTERN; 285 | } 286 | 287 | private function allExpandersMatch($value, $pattern, $parentPath = '') : bool 288 | { 289 | $typePattern = $this->parser->parse($pattern); 290 | 291 | if (!$typePattern->matchExpanders($value)) { 292 | $this->addValuePatternDifference($value, $pattern, $parentPath); 293 | 294 | $this->backtrace->matcherFailed(self::class, $value, $pattern, $this->getError()); 295 | 296 | return false; 297 | } 298 | 299 | $this->backtrace->matcherSucceed(self::class, $value, $pattern); 300 | 301 | return true; 302 | } 303 | 304 | private function addValuePatternDifference($value, $pattern, string $path = '') : void 305 | { 306 | $this->diff = $this->diff->add(new ValuePatternDifference( 307 | (string) new StringConverter($value), 308 | (string) new StringConverter($pattern), 309 | $path ? $path : 'root' 310 | )); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Matcher 2 | 3 | [![Type Coverage](https://shepherd.dev/github/coduo/php-matcher/coverage.svg)](https://shepherd.dev/coduo/php-matcher) 4 | 5 | Library created for testing all kinds of JSON/XML/TXT/Scalar values against patterns. 6 | 7 | API: 8 | 9 | ```php 10 | PHPMatcher::match($value = '{"foo": "bar"}', $pattern = '{"foo": "@string@"}') : bool; 11 | PHPMatcher::backtrace() : Backtrace; 12 | PHPMatcher::error() : ?string; 13 | ``` 14 | 15 | It was built to simplify API's functional testing. 16 | 17 | * [![Test Suite](https://github.com/coduo/php-matcher/actions/workflows/test-suite.yml/badge.svg?branch=6.x)](https://github.com/coduo/php-matcher/actions/workflows/test-suite.yml) - [6.x README](https://github.com/coduo/php-matcher/tree/6.x/README.md) PHP >= 8.3 <= 8.5 18 | * [5.x README](https://github.com/coduo/php-matcher/tree/5.x/README.md) PHP >= 7.2 < 8.0 19 | * [5.0 README](https://github.com/coduo/php-matcher/tree/5.0/README.md) PHP >= 7.2 < 8.0 20 | * [4.0.* README](https://github.com/coduo/php-matcher/tree/4.0/README.md) PHP >= 7.2 < 8.0 21 | * [3.2.* README](https://github.com/coduo/php-matcher/tree/3.2/README.md) PHP >= 7.0 < 8.0 22 | * [3.1.* README](https://github.com/coduo/php-matcher/tree/3.1/README.md) PHP >= 7.0 < 8.0 23 | 24 | [![Latest Stable Version](https://poser.pugx.org/coduo/php-matcher/v/stable)](https://packagist.org/packages/coduo/php-matcher) 25 | [![Total Downloads](https://poser.pugx.org/coduo/php-matcher/downloads)](https://packagist.org/packages/coduo/php-matcher) 26 | [![Latest Unstable Version](https://poser.pugx.org/coduo/php-matcher/v/unstable)](https://packagist.org/packages/coduo/php-matcher) 27 | [![License](https://poser.pugx.org/coduo/php-matcher/license)](https://packagist.org/packages/coduo/php-matcher) 28 | 29 | ## Sandbox 30 | 31 | Feel free to play first with [Sandbox](https://php-matcher.norbert.tech/) 32 | 33 | ## Installation 34 | 35 | Require new dev dependency using composer: 36 | 37 | ``` 38 | composer require --dev "coduo/php-matcher" 39 | ``` 40 | 41 | ## Basic usage 42 | 43 | ### Direct PHPMatcher usage 44 | 45 | ```php 46 | match("lorem ipsum dolor", "@string@"); 52 | 53 | if (!$match) { 54 | echo "Error: " . $matcher->error(); 55 | echo "Backtrace: \n"; 56 | echo (string) $matcher->backtrace(); 57 | } 58 | ``` 59 | 60 | ### PHPUnit extending PHPMatcherTestCase 61 | 62 | ```php 63 | assertMatchesPattern('{"name": "@string@"}', '{"name": "Norbert"}'); 72 | } 73 | } 74 | ``` 75 | 76 | ### PHPUnit using PHPMatcherAssertions trait 77 | 78 | ```php 79 | assertMatchesPattern('{"name": "@string@"}', '{"name": "Norbert"}'); 91 | } 92 | } 93 | ``` 94 | 95 | ### Available patterns 96 | 97 | * ``@string@`` 98 | * ``@integer@`` 99 | * ``@number@`` 100 | * ``@double@`` 101 | * ``@boolean@`` 102 | * ``@time@`` 103 | * ``@date@`` 104 | * ``@datetime@`` 105 | * ``@timezone@`` || ``@tz`` 106 | * ``@array@`` 107 | * ``@array_previous@`` - match next array element using pattern from previous element 108 | * ``@array_previous_repeat@`` - match all remaining array elements using pattern from previous element 109 | * ``@...@`` - *unbounded array*, once used matcher will skip any further array elements 110 | * ``@null@`` 111 | * ``@*@`` || ``@wildcard@`` 112 | * ``expr(expression)`` - **optional**, requires `symfony/expression-language: ^2.3|^3.0|^4.0|^5.0` to be present 113 | * ``@uuid@`` 114 | * ``@ulid@`` 115 | * ``@json@`` 116 | * ``@string@||@integer@`` - string OR integer 117 | 118 | ### Available pattern expanders 119 | 120 | * ``startsWith($stringBeginning, $ignoreCase = false)`` 121 | * ``endsWith($stringEnding, $ignoreCase = false)`` 122 | * ``contains($string, $ignoreCase = false)`` 123 | * ``notContains($string, $ignoreCase = false)`` 124 | * ``isDateTime()`` 125 | * ``isInDateFormat($format)`` - example `"@datetime@.isInDateFormat('Y-m-d H:i:s')` 126 | * ``before(string $date)`` - example ``"@string@.isDateTime().before(\"2020-01-01 00:00:00\")"`` 127 | * ``after(string $date)`` - example ``"@string@.isDateTime().after(\"2020-01-01 00:00:00\")"`` 128 | * ``isTzOffset()`` 129 | * ``isTzIdentifier()`` 130 | * ``isTzAbbreviation()`` 131 | * ``isEmail()`` 132 | * ``isUrl()`` 133 | * ``isIp()`` 134 | * ``isEmpty()`` 135 | * ``isNotEmpty()`` 136 | * ``lowerThan($boundry)`` 137 | * ``greaterThan($boundry)`` 138 | * ``inArray($value)`` - example ``"@array@.inArray(\"ROLE_USER\")"`` 139 | * ``hasProperty($propertyName)`` - example ``"@json@.hasProperty(\"property_name\")"`` 140 | * ``oneOf(...$expanders)`` - example ``"@string@.oneOf(contains('foo'), contains('bar'), contains('baz'))"`` 141 | * ``matchRegex($regex)`` - example ``"@string@.matchRegex('/^lorem.+/')"`` 142 | * ``optional()`` - work's only with ``ArrayMatcher``, ``JsonMatcher`` and ``XmlMatcher`` 143 | * ``count()`` - work's only with ``ArrayMatcher`` - example ``"@array@.count(5)"`` 144 | * ``repeat($pattern, $isStrict = true)`` - example ``'@array@.repeat({"name": "foe"})'`` or ``"@array@.repeat('@string@')"`` 145 | * ``match($pattern)`` - example ``{"image":"@json@.match({\"url\":\"@string@.isUrl()\"})"}`` 146 | 147 | ## Example usage 148 | 149 | ### Scalar matching 150 | 151 | ```php 152 | match(1, 1); 159 | $matcher->match('string', 'string'); 160 | ``` 161 | 162 | ### String matching 163 | 164 | ```php 165 | match('Norbert', '@string@'); 172 | $matcher->match("lorem ipsum dolor", "@string@.startsWith('lorem').contains('ipsum').endsWith('dolor')"); 173 | 174 | ``` 175 | 176 | ### Time matching 177 | 178 | ```php 179 | match('00:00:00', '@time@'); 186 | $matcher->match('00:01:00.000000', '@time@'); 187 | $matcher->match('00:01:00', '@time@.after("00:00:00")'); 188 | $matcher->match('00:00:00', '@time@.before("01:00:00")'); 189 | 190 | ``` 191 | 192 | ### Date matching 193 | 194 | ```php 195 | match('2014-08-19', '@date@'); 202 | $matcher->match('2020-01-11', '@date@'); 203 | $matcher->match('2014-08-19', '@date@.before("2016-08-19")'); 204 | $matcher->match('2014-08-19', '@date@.before("today").after("+ 100year")'); 205 | 206 | ``` 207 | 208 | ### DateTime matching 209 | 210 | ```php 211 | match('2014-08-19', '@datetime@'); 218 | $matcher->match('2020-01-11 00:00:00', '@datetime@'); 219 | $matcher->match('2014-08-19', '@datetime@.before("2016-08-19")'); 220 | $matcher->match('2014-08-19', '@datetime@.before("today").after("+ 100year")'); 221 | 222 | ``` 223 | 224 | ### TimeZone matching 225 | 226 | ```php 227 | match('Europe/Warsaw', '@timezone@'); 234 | $matcher->match('Europe/Warsaw', '@tz@'); 235 | $matcher->match('GMT', '@tz@'); 236 | $matcher->match('01:00', '@tz@'); 237 | $matcher->match('01:00', '@tz@.isTzOffset()'); 238 | $matcher->match('GMT', '@tz@.isTzAbbreviation()'); 239 | $matcher->match('Europe/Warsaw', '@tz@.isTzIdentifier()'); 240 | ``` 241 | 242 | ### Integer matching 243 | 244 | ```php 245 | match(100, '@integer@'); 252 | $matcher->match(100, '@integer@.lowerThan(200).greaterThan(10)'); 253 | 254 | ``` 255 | 256 | ### Number matching 257 | 258 | ```php 259 | match(100, '@number@'); 266 | $matcher->match('200', '@number@'); 267 | $matcher->match(1.25, '@number@'); 268 | $matcher->match('1.25', '@number@'); 269 | $matcher->match(0b10100111001, '@number@'); 270 | ``` 271 | 272 | ### Double matching 273 | 274 | ```php 275 | match(10.1, "@double@"); 282 | $matcher->match(10.1, "@double@.lowerThan(50.12).greaterThan(10)"); 283 | ``` 284 | 285 | ### Boolean matching 286 | 287 | ```php 288 | match(true, "@boolean@"); 295 | $matcher->match(false, "@boolean@"); 296 | ``` 297 | 298 | ### Wildcard matching 299 | 300 | ```php 301 | match("@integer@", "@*@"); 308 | $matcher->match("foobar", "@*@"); 309 | $matcher->match(true, "@*@"); 310 | $matcher->match(6.66, "@*@"); 311 | $matcher->match(array("bar"), "@wildcard@"); 312 | $matcher->match(new \stdClass, "@wildcard@"); 313 | ``` 314 | 315 | ### Expression matching 316 | 317 | ```php 318 | match(new \DateTime('2014-04-01'), "expr(value.format('Y-m-d') == '2014-04-01'"); 325 | $matcher->match("Norbert", "expr(value === 'Norbert')"); 326 | ``` 327 | 328 | ### UUID matching 329 | 330 | ```php 331 | match('9f4db639-0e87-4367-9beb-d64e3f42ae18', '@uuid@'); 338 | ``` 339 | 340 | ### ULID matching 341 | 342 | ```php 343 | match('01BX5ZZKBKACTAV9WEVGEMMVS0', '@ulid@'); 350 | ``` 351 | 352 | ### Array matching 353 | 354 | ```php 355 | match( 362 | array( 363 | 'users' => array( 364 | array( 365 | 'id' => 1, 366 | 'firstName' => 'Norbert', 367 | 'lastName' => 'Orzechowicz', 368 | 'roles' => array('ROLE_USER'), 369 | 'position' => 'Developer', 370 | ), 371 | array( 372 | 'id' => 2, 373 | 'firstName' => 'Michał', 374 | 'lastName' => 'Dąbrowski', 375 | 'roles' => array('ROLE_USER') 376 | ), 377 | array( 378 | 'id' => 3, 379 | 'firstName' => 'Johnny', 380 | 'lastName' => 'DąbrowsBravoki', 381 | 'roles' => array('ROLE_HANDSOME_GUY') 382 | ) 383 | ), 384 | true, 385 | 6.66 386 | ), 387 | array( 388 | 'users' => array( 389 | array( 390 | 'id' => '@integer@.greaterThan(0)', 391 | 'firstName' => '@string@', 392 | 'lastName' => 'Orzechowicz', 393 | 'roles' => '@array@', 394 | 'position' => '@string@.optional()' 395 | ), 396 | array( 397 | 'id' => '@integer@', 398 | 'firstName' => '@string@', 399 | 'lastName' => 'Dąbrowski', 400 | 'roles' => '@array@' 401 | ), 402 | '@...@' 403 | ), 404 | '@boolean@', 405 | '@double@' 406 | ) 407 | ); 408 | ``` 409 | 410 | ### Array Previous 411 | 412 | > @array_previous@ can also be used when matching JSON's and XML's 413 | 414 | ```php 415 | match( 422 | array( 423 | 'users' => array( 424 | array( 425 | 'id' => 1, 426 | 'firstName' => 'Norbert', 427 | 'lastName' => 'Orzechowicz', 428 | 'roles' => array('ROLE_USER'), 429 | 'position' => 'Developer', 430 | ), 431 | array( 432 | 'id' => 2, 433 | 'firstName' => 'Michał', 434 | 'lastName' => 'Dąbrowski', 435 | 'roles' => array('ROLE_USER') 436 | ), 437 | array( 438 | 'id' => 3, 439 | 'firstName' => 'Johnny', 440 | 'lastName' => 'DąbrowsBravoki', 441 | 'roles' => array('ROLE_HANDSOME_GUY') 442 | ) 443 | ), 444 | true, 445 | 6.66 446 | ), 447 | array( 448 | 'users' => array( 449 | array( 450 | 'id' => '@integer@.greaterThan(0)', 451 | 'firstName' => '@string@', 452 | 'lastName' => 'Orzechowicz', 453 | 'roles' => '@array@', 454 | 'position' => '@string@.optional()' 455 | ), 456 | '@array_previous@', 457 | '@array_previous@' 458 | ), 459 | '@boolean@', 460 | '@double@' 461 | ) 462 | ); 463 | ``` 464 | 465 | ### Array Previous Repeat 466 | 467 | > @array_previous_repeat@ can also be used when matching JSON's and XML's 468 | 469 | ```php 470 | match( 477 | array( 478 | 'users' => array( 479 | array( 480 | 'id' => 1, 481 | 'firstName' => 'Norbert', 482 | 'lastName' => 'Orzechowicz', 483 | 'roles' => array('ROLE_USER'), 484 | 'position' => 'Developer', 485 | ), 486 | array( 487 | 'id' => 2, 488 | 'firstName' => 'Michał', 489 | 'lastName' => 'Dąbrowski', 490 | 'roles' => array('ROLE_USER') 491 | ), 492 | array( 493 | 'id' => 3, 494 | 'firstName' => 'Johnny', 495 | 'lastName' => 'DąbrowsBravoki', 496 | 'roles' => array('ROLE_HANDSOME_GUY') 497 | ) 498 | ), 499 | true, 500 | 6.66 501 | ), 502 | array( 503 | 'users' => array( 504 | array( 505 | 'id' => '@integer@.greaterThan(0)', 506 | 'firstName' => '@string@', 507 | 'lastName' => 'Orzechowicz', 508 | 'roles' => '@array@', 509 | 'position' => '@string@.optional()' 510 | ), 511 | '@array_previous_repeat@' 512 | ), 513 | '@boolean@', 514 | '@double@' 515 | ) 516 | ); 517 | ``` 518 | 519 | ### Json matching 520 | 521 | ```php 522 | match( 529 | '{ 530 | "users":[ 531 | { 532 | "firstName": "Norbert", 533 | "lastName": "Orzechowicz", 534 | "created": "2014-01-01", 535 | "roles":["ROLE_USER", "ROLE_DEVELOPER"] 536 | } 537 | ] 538 | }', 539 | '{ 540 | "users":[ 541 | { 542 | "firstName": "@string@", 543 | "lastName": "@string@", 544 | "created": "@string@.isDateTime()", 545 | "roles": "@array@", 546 | "position": "@string@.optional()" 547 | } 548 | ] 549 | }' 550 | ); 551 | ``` 552 | 553 | ### Json matching with unbounded arrays and objects 554 | 555 | ```php 556 | match( 563 | '{ 564 | "users":[ 565 | { 566 | "firstName": "Norbert", 567 | "lastName": "Orzechowicz", 568 | "created": "2014-01-01", 569 | "roles":["ROLE_USER", "ROLE_DEVELOPER"], 570 | "attributes": { 571 | "isAdmin": false, 572 | "dateOfBirth": null, 573 | "hasEmailVerified": true 574 | }, 575 | "avatar": { 576 | "url": "http://avatar-image.com/avatar.png" 577 | } 578 | }, 579 | { 580 | "firstName": "Michał", 581 | "lastName": "Dąbrowski", 582 | "created": "2014-01-01", 583 | "roles":["ROLE_USER", "ROLE_DEVELOPER", "ROLE_ADMIN"], 584 | "attributes": { 585 | "isAdmin": true, 586 | "dateOfBirth": null, 587 | "hasEmailVerified": true 588 | }, 589 | "avatar": null 590 | } 591 | ] 592 | }', 593 | '{ 594 | "users":[ 595 | { 596 | "firstName": "@string@", 597 | "lastName": "@string@", 598 | "created": "@string@.isDateTime()", 599 | "roles": [ 600 | "ROLE_USER", 601 | "@...@" 602 | ], 603 | "attributes": { 604 | "isAdmin": @boolean@, 605 | "@*@": "@*@" 606 | }, 607 | "avatar": "@json@.match({\"url\":\"@string@.isUrl()\"})" 608 | } 609 | , 610 | @...@ 611 | ] 612 | }' 613 | ); 614 | ``` 615 | 616 | ### Xml matching 617 | 618 | **Optional** - requires `openlss/lib-array2xml: ^1.0` to be present. 619 | 620 | ```php 621 | match(<< 629 | 632 | 633 | 634 | 635 | IBM 636 | Any Value 637 | 638 | 639 | 640 | 641 | XML 642 | , 643 | << 645 | 648 | 649 | 650 | 651 | @string@ 652 | @string@ 653 | @integer@.optional() 654 | 655 | 656 | 657 | 658 | XML 659 | ); 660 | ``` 661 | 662 | Example scenario for api in behat using mongo. 663 | --- 664 | ``` cucumber 665 | @profile, @user 666 | Feature: Listing user toys 667 | 668 | As a user 669 | I want to list my toys 670 | 671 | Background: 672 | Given I send and accept JSON 673 | 674 | Scenario: Listing toys 675 | Given the following users exist: 676 | | firstName | lastName | 677 | | Chuck | Norris | 678 | 679 | And the following toys user "Chuck Norris" exist: 680 | | name | 681 | | Barbie | 682 | | GI Joe | 683 | | Optimus Prime | 684 | 685 | When I set valid authorization code oauth header for user "Chuck Norris" 686 | And I send a GET request on "/api/toys" 687 | Then the response status code should be 200 688 | And the JSON response should match: 689 | """ 690 | [ 691 | { 692 | "id": "@string@", 693 | "name": "Barbie", 694 | "_links: "@*@" 695 | }, 696 | { 697 | "id": "@string@", 698 | "name": "GI Joe", 699 | "_links": "@*@" 700 | }, 701 | { 702 | "id": "@string@", 703 | "name": "Optimus Prime", 704 | "_links": "@*@" 705 | } 706 | ] 707 | """ 708 | ``` 709 | 710 | ## PHPUnit integration 711 | 712 | The `assertMatchesPattern()` is a handy assertion that matches values in PHPUnit tests. 713 | To use it either include the `Coduo\PHPMatcher\PHPUnit\PHPMatcherAssertions` trait, 714 | or extend the `Coduo\PHPMatcher\PHPUnit\PHPMatcherTestCase`: 715 | 716 | ```php 717 | namespace Coduo\PHPMatcher\Tests\PHPUnit; 718 | 719 | use Coduo\PHPMatcher\PHPUnit\PHPMatcherAssertions; 720 | use PHPUnit\Framework\TestCase; 721 | 722 | class PHPMatcherAssertionsTest extends TestCase 723 | { 724 | use PHPMatcherAssertions; 725 | 726 | public function test_it_asserts_if_a_value_matches_the_pattern() 727 | { 728 | $this->assertMatchesPattern('@string@', 'foo'); 729 | } 730 | } 731 | ``` 732 | 733 | The `matchesPattern()` method can be used in PHPUnit stubs or mocks: 734 | 735 | ```php 736 | $mock = $this->createMock(Foo::class); 737 | $mock->method('bar') 738 | ->with($this->matchesPattern('@string@')) 739 | ->willReturn('foo'); 740 | ``` 741 | 742 | ## License 743 | 744 | This library is distributed under the MIT license. Please see the LICENSE file. 745 | 746 | ## Credits 747 | 748 | This lib was inspired by [JSON Expressions gem](https://github.com/chancancode/json_expressions) && 749 | [Behat RestExtension ](https://github.com/jakzal/RestExtension) 750 | --------------------------------------------------------------------------------