├── CHANGELOG.md ├── LICENSE ├── README.rst ├── bin ├── jp.php └── perf.php ├── composer.json └── src ├── AstRuntime.php ├── CompilerRuntime.php ├── DebugRuntime.php ├── Env.php ├── FnDispatcher.php ├── JmesPath.php ├── Lexer.php ├── Parser.php ├── SyntaxErrorException.php ├── TreeCompiler.php ├── TreeInterpreter.php └── Utils.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.8.0 - 2024-09-04 4 | 5 | * Add support for PHP 8.4. 6 | 7 | ## 2.7.0 - 2023-08-15 8 | 9 | * Fixed flattening in arrays starting with null. 10 | * Drop support for HHVM and PHP earlier than 7.2.5. 11 | * Add support for PHP 8.1, 8.2, and 8.3. 12 | 13 | ## 2.6.0 - 2020-07-31 14 | 15 | * Support for PHP 8.0. 16 | 17 | ## 2.5.0 - 2019-12-30 18 | 19 | * Full support for PHP 7.0-7.4. 20 | * Fixed autoloading when run from within vendor folder. 21 | * Full multibyte (UTF-8) string support. 22 | 23 | ## 2.4.0 - 2016-12-03 24 | 25 | * Added support for floats when interpreting data. 26 | * Added a function_exists check to work around redeclaration issues. 27 | 28 | ## 2.3.0 - 2016-01-05 29 | 30 | * Added support for [JEP-9](https://github.com/jmespath/jmespath.site/blob/master/docs/proposals/improved-filters.rst), 31 | including unary filter expressions, and `&&` filter expressions. 32 | * Fixed various parsing issues, including not removing escaped single quotes 33 | from raw string literals. 34 | * Added support for the `map` function. 35 | * Fixed several issues with code generation. 36 | 37 | ## 2.2.0 - 2015-05-27 38 | 39 | * Added support for [JEP-12](https://github.com/jmespath/jmespath.site/blob/master/docs/proposals/raw-string-literals.rst) 40 | and raw string literals (e.g., `'foo'`). 41 | 42 | ## 2.1.0 - 2014-01-13 43 | 44 | * Added `JmesPath\Env::cleanCompileDir()` to delete any previously compiled 45 | JMESPath expressions. 46 | 47 | ## 2.0.0 - 2014-01-11 48 | 49 | * Moving to a flattened namespace structure. 50 | * Runtimes are now only PHP callables. 51 | * Fixed an error in the way empty JSON literals are parsed so that they now 52 | return an empty string to match the Python and JavaScript implementations. 53 | * Removed functions from runtimes. Instead there is now a function dispatcher 54 | class, FnDispatcher, that provides function implementations behind a single 55 | dispatch function. 56 | * Removed ExprNode in lieu of just using a PHP callable with bound variables. 57 | * Removed debug methods from runtimes and instead into a new Debugger class. 58 | * Heavily cleaned up function argument validation. 59 | * Slice syntax is now properly validated (i.e., colons are followed by the 60 | appropriate value). 61 | * Lots of code cleanup and performance improvements. 62 | * Added a convenient `JmesPath\search()` function. 63 | * **IMPORTANT**: Relocating the project to https://github.com/jmespath/jmespath.php 64 | 65 | ## 1.1.1 - 2014-10-08 66 | 67 | * Added support for using ArrayAccess and Countable as arrays and objects. 68 | 69 | ## 1.1.0 - 2014-08-06 70 | 71 | * Added the ability to search data returned from json_decode() where JSON 72 | objects are returned as stdClass objects. 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Michael Dowling, https://github.com/mtdowling 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | jmespath.php 3 | ============ 4 | 5 | JMESPath (pronounced "jaymz path") allows you to declaratively specify how to 6 | extract elements from a JSON document. *jmespath.php* allows you to use 7 | JMESPath in PHP applications with PHP data structures. It requires PHP 7.2.5 or 8 | greater and can be installed through `Composer `_ 9 | using the ``mtdowling/jmespath.php`` package. 10 | 11 | .. code-block:: php 12 | 13 | require 'vendor/autoload.php'; 14 | 15 | $expression = 'foo.*.baz'; 16 | 17 | $data = [ 18 | 'foo' => [ 19 | 'bar' => ['baz' => 1], 20 | 'bam' => ['baz' => 2], 21 | 'boo' => ['baz' => 3] 22 | ] 23 | ]; 24 | 25 | JmesPath\search($expression, $data); 26 | // Returns: [1, 2, 3] 27 | 28 | - `JMESPath Tutorial `_ 29 | - `JMESPath Grammar `_ 30 | - `JMESPath Python library `_ 31 | 32 | PHP Usage 33 | ========= 34 | 35 | The ``JmesPath\search`` function can be used in most cases when using the 36 | library. This function utilizes a JMESPath runtime based on your environment. 37 | The runtime utilized can be configured using environment variables and may at 38 | some point in the future automatically utilize a C extension if available. 39 | 40 | .. code-block:: php 41 | 42 | $result = JmesPath\search($expression, $data); 43 | 44 | // or, if you require PSR-4 compliance. 45 | $result = JmesPath\Env::search($expression, $data); 46 | 47 | Runtimes 48 | -------- 49 | 50 | jmespath.php utilizes *runtimes*. There are currently two runtimes: 51 | AstRuntime and CompilerRuntime. 52 | 53 | AstRuntime is utilized by ``JmesPath\search()`` and ``JmesPath\Env::search()`` 54 | by default. 55 | 56 | AstRuntime 57 | ~~~~~~~~~~ 58 | 59 | The AstRuntime will parse an expression, cache the resulting AST in memory, 60 | and interpret the AST using an external tree visitor. AstRuntime provides a 61 | good general approach for interpreting JMESPath expressions that have a low to 62 | moderate level of reuse. 63 | 64 | .. code-block:: php 65 | 66 | $runtime = new JmesPath\AstRuntime(); 67 | $runtime('foo.bar', ['foo' => ['bar' => 'baz']]); 68 | // > 'baz' 69 | 70 | CompilerRuntime 71 | ~~~~~~~~~~~~~~~ 72 | 73 | ``JmesPath\CompilerRuntime`` provides the most performance for 74 | applications that have a moderate to high level of reuse of JMESPath 75 | expressions. The CompilerRuntime will walk a JMESPath AST and emit PHP source 76 | code, resulting in anywhere from 7x to 60x speed improvements. 77 | 78 | Compiling JMESPath expressions to source code is a slower process than just 79 | walking and interpreting a JMESPath AST (via the AstRuntime). However, 80 | running the compiled JMESPath code results in much better performance than 81 | walking an AST. This essentially means that there is a warm-up period when 82 | using the ``CompilerRuntime``, but after the warm-up period, it will provide 83 | much better performance. 84 | 85 | Use the CompilerRuntime if you know that you will be executing JMESPath 86 | expressions more than once or if you can pre-compile JMESPath expressions 87 | before executing them (for example, server-side applications). 88 | 89 | .. code-block:: php 90 | 91 | // Note: The cache directory argument is optional. 92 | $runtime = new JmesPath\CompilerRuntime('/path/to/compile/folder'); 93 | $runtime('foo.bar', ['foo' => ['bar' => 'baz']]); 94 | // > 'baz' 95 | 96 | Environment Variables 97 | ^^^^^^^^^^^^^^^^^^^^^ 98 | 99 | You can utilize the CompilerRuntime in ``JmesPath\search()`` by setting 100 | the ``JP_PHP_COMPILE`` environment variable to "on" or to a directory 101 | on disk used to store cached expressions. 102 | 103 | Testing 104 | ======= 105 | 106 | A comprehensive list of test cases can be found at 107 | https://github.com/jmespath/jmespath.php/tree/master/tests/compliance. 108 | These compliance tests are utilized by jmespath.php to ensure consistency with 109 | other implementations, and can serve as examples of the language. 110 | 111 | jmespath.php is tested using PHPUnit. In order to run the tests, you need to 112 | first install the dependencies using Composer as described in the *Installation* 113 | section. Next you just need to run the tests via make: 114 | 115 | .. code-block:: bash 116 | 117 | make test 118 | 119 | You can run a suite of performance tests as well: 120 | 121 | .. code-block:: bash 122 | 123 | make perf 124 | -------------------------------------------------------------------------------- /bin/jp.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | check(); 14 | unset($xdebug); 15 | 16 | $dir = isset($argv[1]) ? $argv[1] : __DIR__ . '/../tests/compliance/perf'; 17 | is_dir($dir) or die('Dir not found: ' . $dir); 18 | // Warm up the runner 19 | \JmesPath\Env::search('foo', []); 20 | 21 | $total = 0; 22 | foreach (glob($dir . '/*.json') as $file) { 23 | $total += runSuite($file); 24 | } 25 | echo "\nTotal time: {$total}\n"; 26 | 27 | function runSuite($file) 28 | { 29 | $contents = file_get_contents($file); 30 | $json = json_decode($contents, true); 31 | $total = 0; 32 | foreach ($json as $suite) { 33 | foreach ($suite['cases'] as $case) { 34 | $total += runCase( 35 | $suite['given'], 36 | $case['expression'], 37 | $case['name'] 38 | ); 39 | } 40 | } 41 | return $total; 42 | } 43 | 44 | function runCase($given, $expression, $name) 45 | { 46 | $best = 99999; 47 | $runtime = \JmesPath\Env::createRuntime(); 48 | 49 | for ($i = 0; $i < 100; $i++) { 50 | $t = microtime(true); 51 | $runtime($expression, $given); 52 | $tryTime = (microtime(true) - $t) * 1000; 53 | if ($tryTime < $best) { 54 | $best = $tryTime; 55 | } 56 | if (!getenv('CACHE')) { 57 | $runtime = \JmesPath\Env::createRuntime(); 58 | // Delete compiled scripts if not caching. 59 | if ($runtime instanceof \JmesPath\CompilerRuntime) { 60 | array_map('unlink', glob(sys_get_temp_dir() . '/jmespath_*.php')); 61 | } 62 | } 63 | } 64 | 65 | printf("time: %07.4fms name: %s\n", $best, $name); 66 | 67 | return $best; 68 | } 69 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mtdowling/jmespath.php", 3 | "description": "Declaratively specify how to extract elements from a JSON document", 4 | "keywords": ["json", "jsonpath"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Graham Campbell", 9 | "email": "hello@gjcampbell.co.uk", 10 | "homepage": "https://github.com/GrahamCampbell" 11 | }, 12 | { 13 | "name": "Michael Dowling", 14 | "email": "mtdowling@gmail.com", 15 | "homepage": "https://github.com/mtdowling" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.2.5 || ^8.0", 20 | "symfony/polyfill-mbstring": "^1.17" 21 | }, 22 | "require-dev": { 23 | "composer/xdebug-handler": "^3.0.3", 24 | "phpunit/phpunit": "^8.5.33" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "JmesPath\\": "src/" 29 | }, 30 | "files": ["src/JmesPath.php"] 31 | }, 32 | "bin": ["bin/jp.php"], 33 | "extra": { 34 | "branch-alias": { 35 | "dev-master": "2.8-dev" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/AstRuntime.php: -------------------------------------------------------------------------------- 1 | interpreter = new TreeInterpreter($fnDispatcher); 20 | $this->parser = $parser ?: new Parser(); 21 | } 22 | 23 | /** 24 | * Returns data from the provided input that matches a given JMESPath 25 | * expression. 26 | * 27 | * @param string $expression JMESPath expression to evaluate 28 | * @param mixed $data Data to search. This data should be data that 29 | * is similar to data returned from json_decode 30 | * using associative arrays rather than objects. 31 | * 32 | * @return mixed Returns the matching data or null 33 | */ 34 | public function __invoke($expression, $data) 35 | { 36 | if (!isset($this->cache[$expression])) { 37 | // Clear the AST cache when it hits 1024 entries 38 | if (++$this->cachedCount > 1024) { 39 | $this->cache = []; 40 | $this->cachedCount = 0; 41 | } 42 | $this->cache[$expression] = $this->parser->parse($expression); 43 | } 44 | 45 | return $this->interpreter->visit($this->cache[$expression], $data); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/CompilerRuntime.php: -------------------------------------------------------------------------------- 1 | parser = $parser ?: new Parser(); 29 | $this->compiler = new TreeCompiler(); 30 | $dir = $dir ?: sys_get_temp_dir(); 31 | 32 | if (!is_dir($dir) && !mkdir($dir, 0755, true)) { 33 | throw new \RuntimeException("Unable to create cache directory: $dir"); 34 | } 35 | 36 | $this->cacheDir = realpath($dir); 37 | $this->interpreter = new TreeInterpreter(); 38 | } 39 | 40 | /** 41 | * Returns data from the provided input that matches a given JMESPath 42 | * expression. 43 | * 44 | * @param string $expression JMESPath expression to evaluate 45 | * @param mixed $data Data to search. This data should be data that 46 | * is similar to data returned from json_decode 47 | * using associative arrays rather than objects. 48 | * 49 | * @return mixed Returns the matching data or null 50 | * @throws \RuntimeException 51 | */ 52 | public function __invoke($expression, $data) 53 | { 54 | $functionName = 'jmespath_' . md5($expression); 55 | 56 | if (!function_exists($functionName)) { 57 | $filename = "{$this->cacheDir}/{$functionName}.php"; 58 | if (!file_exists($filename)) { 59 | $this->compile($filename, $expression, $functionName); 60 | } 61 | require $filename; 62 | } 63 | 64 | return $functionName($this->interpreter, $data); 65 | } 66 | 67 | private function compile($filename, $expression, $functionName) 68 | { 69 | $code = $this->compiler->visit( 70 | $this->parser->parse($expression), 71 | $functionName, 72 | $expression 73 | ); 74 | 75 | if (!file_put_contents($filename, $code)) { 76 | throw new \RuntimeException(sprintf( 77 | 'Unable to write the compiled PHP code to: %s (%s)', 78 | $filename, 79 | var_export(error_get_last(), true) 80 | )); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DebugRuntime.php: -------------------------------------------------------------------------------- 1 | runtime = $runtime; 17 | $this->out = $output ?: STDOUT; 18 | $this->lexer = new Lexer(); 19 | $this->parser = new Parser($this->lexer); 20 | } 21 | 22 | public function __invoke($expression, $data) 23 | { 24 | if ($this->runtime instanceof CompilerRuntime) { 25 | return $this->debugCompiled($expression, $data); 26 | } 27 | 28 | return $this->debugInterpreted($expression, $data); 29 | } 30 | 31 | private function debugInterpreted($expression, $data) 32 | { 33 | return $this->debugCallback( 34 | function () use ($expression, $data) { 35 | $runtime = $this->runtime; 36 | return $runtime($expression, $data); 37 | }, 38 | $expression, 39 | $data 40 | ); 41 | } 42 | 43 | private function debugCompiled($expression, $data) 44 | { 45 | $result = $this->debugCallback( 46 | function () use ($expression, $data) { 47 | $runtime = $this->runtime; 48 | return $runtime($expression, $data); 49 | }, 50 | $expression, 51 | $data 52 | ); 53 | $this->dumpCompiledCode($expression); 54 | 55 | return $result; 56 | } 57 | 58 | private function dumpTokens($expression) 59 | { 60 | $lexer = new Lexer(); 61 | fwrite($this->out, "Tokens\n======\n\n"); 62 | $tokens = $lexer->tokenize($expression); 63 | 64 | foreach ($tokens as $t) { 65 | fprintf( 66 | $this->out, 67 | "%3d %-13s %s\n", $t['pos'], $t['type'], 68 | json_encode($t['value']) 69 | ); 70 | } 71 | 72 | fwrite($this->out, "\n"); 73 | } 74 | 75 | private function dumpAst($expression) 76 | { 77 | $parser = new Parser(); 78 | $ast = $parser->parse($expression); 79 | fwrite($this->out, "AST\n========\n\n"); 80 | fwrite($this->out, json_encode($ast, JSON_PRETTY_PRINT) . "\n"); 81 | } 82 | 83 | private function dumpCompiledCode($expression) 84 | { 85 | fwrite($this->out, "Code\n========\n\n"); 86 | $dir = sys_get_temp_dir(); 87 | $hash = md5($expression); 88 | $functionName = "jmespath_{$hash}"; 89 | $filename = "{$dir}/{$functionName}.php"; 90 | fwrite($this->out, "File: {$filename}\n\n"); 91 | fprintf($this->out, file_get_contents($filename)); 92 | } 93 | 94 | private function debugCallback(callable $debugFn, $expression, $data) 95 | { 96 | fprintf($this->out, "Expression\n==========\n\n%s\n\n", $expression); 97 | $this->dumpTokens($expression); 98 | $this->dumpAst($expression); 99 | fprintf($this->out, "\nData\n====\n\n%s\n\n", json_encode($data, JSON_PRETTY_PRINT)); 100 | $startTime = microtime(true); 101 | $result = $debugFn(); 102 | $total = microtime(true) - $startTime; 103 | fprintf($this->out, "\nResult\n======\n\n%s\n\n", json_encode($result, JSON_PRETTY_PRINT)); 104 | fwrite($this->out, "Time\n====\n\n"); 105 | fprintf($this->out, "Total time: %f ms\n\n", $total); 106 | 107 | return $result; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Env.php: -------------------------------------------------------------------------------- 1 | {'fn_' . $fn}($args); 36 | } 37 | 38 | private function fn_abs(array $args) 39 | { 40 | $this->validate('abs', $args, [['number']]); 41 | return abs($args[0]); 42 | } 43 | 44 | private function fn_avg(array $args) 45 | { 46 | $this->validate('avg', $args, [['array']]); 47 | $sum = $this->reduce('avg:0', $args[0], ['number'], function ($a, $b) { 48 | return Utils::add($a, $b); 49 | }); 50 | return $args[0] ? ($sum / count($args[0])) : null; 51 | } 52 | 53 | private function fn_ceil(array $args) 54 | { 55 | $this->validate('ceil', $args, [['number']]); 56 | return ceil($args[0]); 57 | } 58 | 59 | private function fn_contains(array $args) 60 | { 61 | $this->validate('contains', $args, [['string', 'array'], ['any']]); 62 | if (is_array($args[0])) { 63 | return in_array($args[1], $args[0]); 64 | } elseif (is_string($args[1])) { 65 | return mb_strpos($args[0], $args[1], 0, 'UTF-8') !== false; 66 | } else { 67 | return null; 68 | } 69 | } 70 | 71 | private function fn_ends_with(array $args) 72 | { 73 | $this->validate('ends_with', $args, [['string'], ['string']]); 74 | list($search, $suffix) = $args; 75 | return $suffix === '' || mb_substr($search, -mb_strlen($suffix, 'UTF-8'), null, 'UTF-8') === $suffix; 76 | } 77 | 78 | private function fn_floor(array $args) 79 | { 80 | $this->validate('floor', $args, [['number']]); 81 | return floor($args[0]); 82 | } 83 | 84 | private function fn_not_null(array $args) 85 | { 86 | if (!$args) { 87 | throw new \RuntimeException( 88 | "not_null() expects 1 or more arguments, 0 were provided" 89 | ); 90 | } 91 | 92 | return array_reduce($args, function ($carry, $item) { 93 | return $carry !== null ? $carry : $item; 94 | }); 95 | } 96 | 97 | private function fn_join(array $args) 98 | { 99 | $this->validate('join', $args, [['string'], ['array']]); 100 | $fn = function ($a, $b, $i) use ($args) { 101 | return $i ? ($a . $args[0] . $b) : $b; 102 | }; 103 | return $this->reduce('join:0', $args[1], ['string'], $fn); 104 | } 105 | 106 | private function fn_keys(array $args) 107 | { 108 | $this->validate('keys', $args, [['object']]); 109 | return array_keys((array) $args[0]); 110 | } 111 | 112 | private function fn_length(array $args) 113 | { 114 | $this->validate('length', $args, [['string', 'array', 'object']]); 115 | return is_string($args[0]) ? mb_strlen($args[0], 'UTF-8') : count((array) $args[0]); 116 | } 117 | 118 | private function fn_max(array $args) 119 | { 120 | $this->validate('max', $args, [['array']]); 121 | $fn = function ($a, $b) { 122 | return $a >= $b ? $a : $b; 123 | }; 124 | return $this->reduce('max:0', $args[0], ['number', 'string'], $fn); 125 | } 126 | 127 | private function fn_max_by(array $args) 128 | { 129 | $this->validate('max_by', $args, [['array'], ['expression']]); 130 | $expr = $this->wrapExpression('max_by:1', $args[1], ['number', 'string']); 131 | $fn = function ($carry, $item, $index) use ($expr) { 132 | return $index 133 | ? ($expr($carry) >= $expr($item) ? $carry : $item) 134 | : $item; 135 | }; 136 | return $this->reduce('max_by:1', $args[0], ['any'], $fn); 137 | } 138 | 139 | private function fn_min(array $args) 140 | { 141 | $this->validate('min', $args, [['array']]); 142 | $fn = function ($a, $b, $i) { 143 | return $i && $a <= $b ? $a : $b; 144 | }; 145 | return $this->reduce('min:0', $args[0], ['number', 'string'], $fn); 146 | } 147 | 148 | private function fn_min_by(array $args) 149 | { 150 | $this->validate('min_by', $args, [['array'], ['expression']]); 151 | $expr = $this->wrapExpression('min_by:1', $args[1], ['number', 'string']); 152 | $i = -1; 153 | $fn = function ($a, $b) use ($expr, &$i) { 154 | return ++$i ? ($expr($a) <= $expr($b) ? $a : $b) : $b; 155 | }; 156 | return $this->reduce('min_by:1', $args[0], ['any'], $fn); 157 | } 158 | 159 | private function fn_reverse(array $args) 160 | { 161 | $this->validate('reverse', $args, [['array', 'string']]); 162 | if (is_array($args[0])) { 163 | return array_reverse($args[0]); 164 | } elseif (is_string($args[0])) { 165 | return strrev($args[0]); 166 | } else { 167 | throw new \RuntimeException('Cannot reverse provided argument'); 168 | } 169 | } 170 | 171 | private function fn_sum(array $args) 172 | { 173 | $this->validate('sum', $args, [['array']]); 174 | $fn = function ($a, $b) { 175 | return Utils::add($a, $b); 176 | }; 177 | return $this->reduce('sum:0', $args[0], ['number'], $fn); 178 | } 179 | 180 | private function fn_sort(array $args) 181 | { 182 | $this->validate('sort', $args, [['array']]); 183 | $valid = ['string', 'number']; 184 | return Utils::stableSort($args[0], function ($a, $b) use ($valid) { 185 | $this->validateSeq('sort:0', $valid, $a, $b); 186 | return strnatcmp($a, $b); 187 | }); 188 | } 189 | 190 | private function fn_sort_by(array $args) 191 | { 192 | $this->validate('sort_by', $args, [['array'], ['expression']]); 193 | $expr = $args[1]; 194 | $valid = ['string', 'number']; 195 | return Utils::stableSort( 196 | $args[0], 197 | function ($a, $b) use ($expr, $valid) { 198 | $va = $expr($a); 199 | $vb = $expr($b); 200 | $this->validateSeq('sort_by:0', $valid, $va, $vb); 201 | return strnatcmp($va, $vb); 202 | } 203 | ); 204 | } 205 | 206 | private function fn_starts_with(array $args) 207 | { 208 | $this->validate('starts_with', $args, [['string'], ['string']]); 209 | list($search, $prefix) = $args; 210 | return $prefix === '' || mb_strpos($search, $prefix, 0, 'UTF-8') === 0; 211 | } 212 | 213 | private function fn_type(array $args) 214 | { 215 | $this->validateArity('type', count($args), 1); 216 | return Utils::type($args[0]); 217 | } 218 | 219 | private function fn_to_string(array $args) 220 | { 221 | $this->validateArity('to_string', count($args), 1); 222 | $v = $args[0]; 223 | if (is_string($v)) { 224 | return $v; 225 | } elseif (is_object($v) 226 | && !($v instanceof \JsonSerializable) 227 | && method_exists($v, '__toString') 228 | ) { 229 | return (string) $v; 230 | } 231 | 232 | return json_encode($v); 233 | } 234 | 235 | private function fn_to_number(array $args) 236 | { 237 | $this->validateArity('to_number', count($args), 1); 238 | $value = $args[0]; 239 | $type = Utils::type($value); 240 | if ($type == 'number') { 241 | return $value; 242 | } elseif ($type == 'string' && is_numeric($value)) { 243 | return mb_strpos($value, '.', 0, 'UTF-8') ? (float) $value : (int) $value; 244 | } else { 245 | return null; 246 | } 247 | } 248 | 249 | private function fn_values(array $args) 250 | { 251 | $this->validate('values', $args, [['array', 'object']]); 252 | return array_values((array) $args[0]); 253 | } 254 | 255 | private function fn_merge(array $args) 256 | { 257 | if (!$args) { 258 | throw new \RuntimeException( 259 | "merge() expects 1 or more arguments, 0 were provided" 260 | ); 261 | } 262 | 263 | return call_user_func_array('array_replace', $args); 264 | } 265 | 266 | private function fn_to_array(array $args) 267 | { 268 | $this->validate('to_array', $args, [['any']]); 269 | 270 | return Utils::isArray($args[0]) ? $args[0] : [$args[0]]; 271 | } 272 | 273 | private function fn_map(array $args) 274 | { 275 | $this->validate('map', $args, [['expression'], ['any']]); 276 | $result = []; 277 | foreach ($args[1] as $a) { 278 | $result[] = $args[0]($a); 279 | } 280 | return $result; 281 | } 282 | 283 | private function typeError($from, $msg) 284 | { 285 | if (mb_strpos($from, ':', 0, 'UTF-8')) { 286 | list($fn, $pos) = explode(':', $from); 287 | throw new \RuntimeException( 288 | sprintf('Argument %d of %s %s', $pos, $fn, $msg) 289 | ); 290 | } else { 291 | throw new \RuntimeException( 292 | sprintf('Type error: %s %s', $from, $msg) 293 | ); 294 | } 295 | } 296 | 297 | private function validateArity($from, $given, $expected) 298 | { 299 | if ($given != $expected) { 300 | $err = "%s() expects {$expected} arguments, {$given} were provided"; 301 | throw new \RuntimeException(sprintf($err, $from)); 302 | } 303 | } 304 | 305 | private function validate($from, $args, $types = []) 306 | { 307 | $this->validateArity($from, count($args), count($types)); 308 | foreach ($args as $index => $value) { 309 | if (!isset($types[$index]) || !$types[$index]) { 310 | continue; 311 | } 312 | $this->validateType("{$from}:{$index}", $value, $types[$index]); 313 | } 314 | } 315 | 316 | private function validateType($from, $value, array $types) 317 | { 318 | if ($types[0] == 'any' 319 | || in_array(Utils::type($value), $types) 320 | || ($value === [] && in_array('object', $types)) 321 | ) { 322 | return; 323 | } 324 | $msg = 'must be one of the following types: ' . implode(', ', $types) 325 | . '. ' . Utils::type($value) . ' found'; 326 | $this->typeError($from, $msg); 327 | } 328 | 329 | /** 330 | * Validates value A and B, ensures they both are correctly typed, and of 331 | * the same type. 332 | * 333 | * @param string $from String of function:argument_position 334 | * @param array $types Array of valid value types. 335 | * @param mixed $a Value A 336 | * @param mixed $b Value B 337 | */ 338 | private function validateSeq($from, array $types, $a, $b) 339 | { 340 | $ta = Utils::type($a); 341 | $tb = Utils::type($b); 342 | 343 | if ($ta !== $tb) { 344 | $msg = "encountered a type mismatch in sequence: {$ta}, {$tb}"; 345 | $this->typeError($from, $msg); 346 | } 347 | 348 | $typeMatch = ($types && $types[0] == 'any') || in_array($ta, $types); 349 | if (!$typeMatch) { 350 | $msg = 'encountered a type error in sequence. The argument must be ' 351 | . 'an array of ' . implode('|', $types) . ' types. ' 352 | . "Found {$ta}, {$tb}."; 353 | $this->typeError($from, $msg); 354 | } 355 | } 356 | 357 | /** 358 | * Reduces and validates an array of values to a single value using a fn. 359 | * 360 | * @param string $from String of function:argument_position 361 | * @param array $values Values to reduce. 362 | * @param array $types Array of valid value types. 363 | * @param callable $reduce Reduce function that accepts ($carry, $item). 364 | * 365 | * @return mixed 366 | */ 367 | private function reduce($from, array $values, array $types, callable $reduce) 368 | { 369 | $i = -1; 370 | return array_reduce( 371 | $values, 372 | function ($carry, $item) use ($from, $types, $reduce, &$i) { 373 | if (++$i > 0) { 374 | $this->validateSeq($from, $types, $carry, $item); 375 | } 376 | return $reduce($carry, $item, $i); 377 | } 378 | ); 379 | } 380 | 381 | /** 382 | * Validates the return values of expressions as they are applied. 383 | * 384 | * @param string $from Function name : position 385 | * @param callable $expr Expression function to validate. 386 | * @param array $types Array of acceptable return type values. 387 | * 388 | * @return callable Returns a wrapped function 389 | */ 390 | private function wrapExpression($from, callable $expr, array $types) 391 | { 392 | list($fn, $pos) = explode(':', $from); 393 | $from = "The expression return value of argument {$pos} of {$fn}"; 394 | return function ($value) use ($from, $expr, $types) { 395 | $value = $expr($value); 396 | $this->validateType($from, $value, $types); 397 | return $value; 398 | }; 399 | } 400 | 401 | /** @internal Pass function name validation off to runtime */ 402 | public function __call($name, $args) 403 | { 404 | $name = str_replace('fn_', '', $name); 405 | throw new \RuntimeException("Call to undefined function {$name}"); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/JmesPath.php: -------------------------------------------------------------------------------- 1 | self::STATE_LT, 53 | '>' => self::STATE_GT, 54 | '=' => self::STATE_EQ, 55 | '!' => self::STATE_NOT, 56 | '[' => self::STATE_LBRACKET, 57 | '|' => self::STATE_PIPE, 58 | '&' => self::STATE_AND, 59 | '`' => self::STATE_JSON_LITERAL, 60 | '"' => self::STATE_QUOTED_STRING, 61 | "'" => self::STATE_STRING_LITERAL, 62 | '-' => self::STATE_NUMBER, 63 | '0' => self::STATE_NUMBER, 64 | '1' => self::STATE_NUMBER, 65 | '2' => self::STATE_NUMBER, 66 | '3' => self::STATE_NUMBER, 67 | '4' => self::STATE_NUMBER, 68 | '5' => self::STATE_NUMBER, 69 | '6' => self::STATE_NUMBER, 70 | '7' => self::STATE_NUMBER, 71 | '8' => self::STATE_NUMBER, 72 | '9' => self::STATE_NUMBER, 73 | ' ' => self::STATE_WHITESPACE, 74 | "\t" => self::STATE_WHITESPACE, 75 | "\n" => self::STATE_WHITESPACE, 76 | "\r" => self::STATE_WHITESPACE, 77 | '.' => self::STATE_SINGLE_CHAR, 78 | '*' => self::STATE_SINGLE_CHAR, 79 | ']' => self::STATE_SINGLE_CHAR, 80 | ',' => self::STATE_SINGLE_CHAR, 81 | ':' => self::STATE_SINGLE_CHAR, 82 | '@' => self::STATE_SINGLE_CHAR, 83 | '(' => self::STATE_SINGLE_CHAR, 84 | ')' => self::STATE_SINGLE_CHAR, 85 | '{' => self::STATE_SINGLE_CHAR, 86 | '}' => self::STATE_SINGLE_CHAR, 87 | '_' => self::STATE_IDENTIFIER, 88 | 'A' => self::STATE_IDENTIFIER, 89 | 'B' => self::STATE_IDENTIFIER, 90 | 'C' => self::STATE_IDENTIFIER, 91 | 'D' => self::STATE_IDENTIFIER, 92 | 'E' => self::STATE_IDENTIFIER, 93 | 'F' => self::STATE_IDENTIFIER, 94 | 'G' => self::STATE_IDENTIFIER, 95 | 'H' => self::STATE_IDENTIFIER, 96 | 'I' => self::STATE_IDENTIFIER, 97 | 'J' => self::STATE_IDENTIFIER, 98 | 'K' => self::STATE_IDENTIFIER, 99 | 'L' => self::STATE_IDENTIFIER, 100 | 'M' => self::STATE_IDENTIFIER, 101 | 'N' => self::STATE_IDENTIFIER, 102 | 'O' => self::STATE_IDENTIFIER, 103 | 'P' => self::STATE_IDENTIFIER, 104 | 'Q' => self::STATE_IDENTIFIER, 105 | 'R' => self::STATE_IDENTIFIER, 106 | 'S' => self::STATE_IDENTIFIER, 107 | 'T' => self::STATE_IDENTIFIER, 108 | 'U' => self::STATE_IDENTIFIER, 109 | 'V' => self::STATE_IDENTIFIER, 110 | 'W' => self::STATE_IDENTIFIER, 111 | 'X' => self::STATE_IDENTIFIER, 112 | 'Y' => self::STATE_IDENTIFIER, 113 | 'Z' => self::STATE_IDENTIFIER, 114 | 'a' => self::STATE_IDENTIFIER, 115 | 'b' => self::STATE_IDENTIFIER, 116 | 'c' => self::STATE_IDENTIFIER, 117 | 'd' => self::STATE_IDENTIFIER, 118 | 'e' => self::STATE_IDENTIFIER, 119 | 'f' => self::STATE_IDENTIFIER, 120 | 'g' => self::STATE_IDENTIFIER, 121 | 'h' => self::STATE_IDENTIFIER, 122 | 'i' => self::STATE_IDENTIFIER, 123 | 'j' => self::STATE_IDENTIFIER, 124 | 'k' => self::STATE_IDENTIFIER, 125 | 'l' => self::STATE_IDENTIFIER, 126 | 'm' => self::STATE_IDENTIFIER, 127 | 'n' => self::STATE_IDENTIFIER, 128 | 'o' => self::STATE_IDENTIFIER, 129 | 'p' => self::STATE_IDENTIFIER, 130 | 'q' => self::STATE_IDENTIFIER, 131 | 'r' => self::STATE_IDENTIFIER, 132 | 's' => self::STATE_IDENTIFIER, 133 | 't' => self::STATE_IDENTIFIER, 134 | 'u' => self::STATE_IDENTIFIER, 135 | 'v' => self::STATE_IDENTIFIER, 136 | 'w' => self::STATE_IDENTIFIER, 137 | 'x' => self::STATE_IDENTIFIER, 138 | 'y' => self::STATE_IDENTIFIER, 139 | 'z' => self::STATE_IDENTIFIER, 140 | ]; 141 | 142 | /** @var array Valid identifier characters after first character */ 143 | private $validIdentifier = [ 144 | 'A' => true, 'B' => true, 'C' => true, 'D' => true, 'E' => true, 145 | 'F' => true, 'G' => true, 'H' => true, 'I' => true, 'J' => true, 146 | 'K' => true, 'L' => true, 'M' => true, 'N' => true, 'O' => true, 147 | 'P' => true, 'Q' => true, 'R' => true, 'S' => true, 'T' => true, 148 | 'U' => true, 'V' => true, 'W' => true, 'X' => true, 'Y' => true, 149 | 'Z' => true, 'a' => true, 'b' => true, 'c' => true, 'd' => true, 150 | 'e' => true, 'f' => true, 'g' => true, 'h' => true, 'i' => true, 151 | 'j' => true, 'k' => true, 'l' => true, 'm' => true, 'n' => true, 152 | 'o' => true, 'p' => true, 'q' => true, 'r' => true, 's' => true, 153 | 't' => true, 'u' => true, 'v' => true, 'w' => true, 'x' => true, 154 | 'y' => true, 'z' => true, '_' => true, '0' => true, '1' => true, 155 | '2' => true, '3' => true, '4' => true, '5' => true, '6' => true, 156 | '7' => true, '8' => true, '9' => true, 157 | ]; 158 | 159 | /** @var array Valid number characters after the first character */ 160 | private $numbers = [ 161 | '0' => true, '1' => true, '2' => true, '3' => true, '4' => true, 162 | '5' => true, '6' => true, '7' => true, '8' => true, '9' => true 163 | ]; 164 | 165 | /** @var array Map of simple single character tokens */ 166 | private $simpleTokens = [ 167 | '.' => self::T_DOT, 168 | '*' => self::T_STAR, 169 | ']' => self::T_RBRACKET, 170 | ',' => self::T_COMMA, 171 | ':' => self::T_COLON, 172 | '@' => self::T_CURRENT, 173 | '(' => self::T_LPAREN, 174 | ')' => self::T_RPAREN, 175 | '{' => self::T_LBRACE, 176 | '}' => self::T_RBRACE, 177 | ]; 178 | 179 | /** 180 | * Tokenize the JMESPath expression into an array of tokens hashes that 181 | * contain a 'type', 'value', and 'key'. 182 | * 183 | * @param string $input JMESPath input 184 | * 185 | * @return array 186 | * @throws SyntaxErrorException 187 | */ 188 | public function tokenize($input) 189 | { 190 | $tokens = []; 191 | 192 | if ($input === '') { 193 | goto eof; 194 | } 195 | 196 | $chars = str_split($input); 197 | 198 | while (false !== ($current = current($chars))) { 199 | 200 | // Every character must be in the transition character table. 201 | if (!isset(self::$transitionTable[$current])) { 202 | $tokens[] = [ 203 | 'type' => self::T_UNKNOWN, 204 | 'pos' => key($chars), 205 | 'value' => $current 206 | ]; 207 | next($chars); 208 | continue; 209 | } 210 | 211 | $state = self::$transitionTable[$current]; 212 | 213 | if ($state === self::STATE_SINGLE_CHAR) { 214 | 215 | // Consume simple tokens like ".", ",", "@", etc. 216 | $tokens[] = [ 217 | 'type' => $this->simpleTokens[$current], 218 | 'pos' => key($chars), 219 | 'value' => $current 220 | ]; 221 | next($chars); 222 | 223 | } elseif ($state === self::STATE_IDENTIFIER) { 224 | 225 | // Consume identifiers 226 | $start = key($chars); 227 | $buffer = ''; 228 | do { 229 | $buffer .= $current; 230 | $current = next($chars); 231 | } while ($current !== false && isset($this->validIdentifier[$current])); 232 | $tokens[] = [ 233 | 'type' => self::T_IDENTIFIER, 234 | 'value' => $buffer, 235 | 'pos' => $start 236 | ]; 237 | 238 | } elseif ($state === self::STATE_WHITESPACE) { 239 | 240 | // Skip whitespace 241 | next($chars); 242 | 243 | } elseif ($state === self::STATE_LBRACKET) { 244 | 245 | // Consume "[", "[?", and "[]" 246 | $position = key($chars); 247 | $actual = next($chars); 248 | if ($actual === ']') { 249 | next($chars); 250 | $tokens[] = [ 251 | 'type' => self::T_FLATTEN, 252 | 'pos' => $position, 253 | 'value' => '[]' 254 | ]; 255 | } elseif ($actual === '?') { 256 | next($chars); 257 | $tokens[] = [ 258 | 'type' => self::T_FILTER, 259 | 'pos' => $position, 260 | 'value' => '[?' 261 | ]; 262 | } else { 263 | $tokens[] = [ 264 | 'type' => self::T_LBRACKET, 265 | 'pos' => $position, 266 | 'value' => '[' 267 | ]; 268 | } 269 | 270 | } elseif ($state === self::STATE_STRING_LITERAL) { 271 | 272 | // Consume raw string literals 273 | $t = $this->inside($chars, "'", self::T_LITERAL); 274 | $t['value'] = str_replace("\\'", "'", $t['value']); 275 | $tokens[] = $t; 276 | 277 | } elseif ($state === self::STATE_PIPE) { 278 | 279 | // Consume pipe and OR 280 | $tokens[] = $this->matchOr($chars, '|', '|', self::T_OR, self::T_PIPE); 281 | 282 | } elseif ($state == self::STATE_JSON_LITERAL) { 283 | 284 | // Consume JSON literals 285 | $token = $this->inside($chars, '`', self::T_LITERAL); 286 | if ($token['type'] === self::T_LITERAL) { 287 | $token['value'] = str_replace('\\`', '`', $token['value']); 288 | $token = $this->parseJson($token); 289 | } 290 | $tokens[] = $token; 291 | 292 | } elseif ($state == self::STATE_NUMBER) { 293 | 294 | // Consume numbers 295 | $start = key($chars); 296 | $buffer = ''; 297 | do { 298 | $buffer .= $current; 299 | $current = next($chars); 300 | } while ($current !== false && isset($this->numbers[$current])); 301 | $tokens[] = [ 302 | 'type' => self::T_NUMBER, 303 | 'value' => (int)$buffer, 304 | 'pos' => $start 305 | ]; 306 | 307 | } elseif ($state === self::STATE_QUOTED_STRING) { 308 | 309 | // Consume quoted identifiers 310 | $token = $this->inside($chars, '"', self::T_QUOTED_IDENTIFIER); 311 | if ($token['type'] === self::T_QUOTED_IDENTIFIER) { 312 | $token['value'] = '"' . $token['value'] . '"'; 313 | $token = $this->parseJson($token); 314 | } 315 | $tokens[] = $token; 316 | 317 | } elseif ($state === self::STATE_EQ) { 318 | 319 | // Consume equals 320 | $tokens[] = $this->matchOr($chars, '=', '=', self::T_COMPARATOR, self::T_UNKNOWN); 321 | 322 | } elseif ($state == self::STATE_AND) { 323 | 324 | $tokens[] = $this->matchOr($chars, '&', '&', self::T_AND, self::T_EXPREF); 325 | 326 | } elseif ($state === self::STATE_NOT) { 327 | 328 | // Consume not equal 329 | $tokens[] = $this->matchOr($chars, '!', '=', self::T_COMPARATOR, self::T_NOT); 330 | 331 | } else { 332 | 333 | // either '<' or '>' 334 | // Consume less than and greater than 335 | $tokens[] = $this->matchOr($chars, $current, '=', self::T_COMPARATOR, self::T_COMPARATOR); 336 | 337 | } 338 | } 339 | 340 | eof: 341 | $tokens[] = [ 342 | 'type' => self::T_EOF, 343 | 'pos' => mb_strlen($input, 'UTF-8'), 344 | 'value' => null 345 | ]; 346 | 347 | return $tokens; 348 | } 349 | 350 | /** 351 | * Returns a token based on whether or not the next token matches the 352 | * expected value. If it does, a token of "$type" is returned. Otherwise, 353 | * a token of "$orElse" type is returned. 354 | * 355 | * @param array $chars Array of characters by reference. 356 | * @param string $current The current character. 357 | * @param string $expected Expected character. 358 | * @param string $type Expected result type. 359 | * @param string $orElse Otherwise return a token of this type. 360 | * 361 | * @return array Returns a conditional token. 362 | */ 363 | private function matchOr(array &$chars, $current, $expected, $type, $orElse) 364 | { 365 | if (next($chars) === $expected) { 366 | next($chars); 367 | return [ 368 | 'type' => $type, 369 | 'pos' => key($chars) - 1, 370 | 'value' => $current . $expected 371 | ]; 372 | } 373 | 374 | return [ 375 | 'type' => $orElse, 376 | 'pos' => key($chars) - 1, 377 | 'value' => $current 378 | ]; 379 | } 380 | 381 | /** 382 | * Returns a token the is the result of consuming inside of delimiter 383 | * characters. Escaped delimiters will be adjusted before returning a 384 | * value. If the token is not closed, "unknown" is returned. 385 | * 386 | * @param array $chars Array of characters by reference. 387 | * @param string $delim The delimiter character. 388 | * @param string $type Token type. 389 | * 390 | * @return array Returns the consumed token. 391 | */ 392 | private function inside(array &$chars, $delim, $type) 393 | { 394 | $position = key($chars); 395 | $current = next($chars); 396 | $buffer = ''; 397 | 398 | while ($current !== $delim) { 399 | if ($current === '\\') { 400 | $buffer .= '\\'; 401 | $current = next($chars); 402 | } 403 | if ($current === false) { 404 | // Unclosed delimiter 405 | return [ 406 | 'type' => self::T_UNKNOWN, 407 | 'value' => $buffer, 408 | 'pos' => $position 409 | ]; 410 | } 411 | $buffer .= $current; 412 | $current = next($chars); 413 | } 414 | 415 | next($chars); 416 | 417 | return ['type' => $type, 'value' => $buffer, 'pos' => $position]; 418 | } 419 | 420 | /** 421 | * Parses a JSON token or sets the token type to "unknown" on error. 422 | * 423 | * @param array $token Token that needs parsing. 424 | * 425 | * @return array Returns a token with a parsed value. 426 | */ 427 | private function parseJson(array $token) 428 | { 429 | $value = json_decode($token['value'], true); 430 | 431 | if ($error = json_last_error()) { 432 | // Legacy support for elided quotes. Try to parse again by adding 433 | // quotes around the bad input value. 434 | $value = json_decode('"' . $token['value'] . '"', true); 435 | if ($error = json_last_error()) { 436 | $token['type'] = self::T_UNKNOWN; 437 | return $token; 438 | } 439 | } 440 | 441 | $token['value'] = $value; 442 | return $token; 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | T::T_EOF]; 19 | private static $currentNode = ['type' => T::T_CURRENT]; 20 | 21 | private static $bp = [ 22 | T::T_EOF => 0, 23 | T::T_QUOTED_IDENTIFIER => 0, 24 | T::T_IDENTIFIER => 0, 25 | T::T_RBRACKET => 0, 26 | T::T_RPAREN => 0, 27 | T::T_COMMA => 0, 28 | T::T_RBRACE => 0, 29 | T::T_NUMBER => 0, 30 | T::T_CURRENT => 0, 31 | T::T_EXPREF => 0, 32 | T::T_COLON => 0, 33 | T::T_PIPE => 1, 34 | T::T_OR => 2, 35 | T::T_AND => 3, 36 | T::T_COMPARATOR => 5, 37 | T::T_FLATTEN => 9, 38 | T::T_STAR => 20, 39 | T::T_FILTER => 21, 40 | T::T_DOT => 40, 41 | T::T_NOT => 45, 42 | T::T_LBRACE => 50, 43 | T::T_LBRACKET => 55, 44 | T::T_LPAREN => 60, 45 | ]; 46 | 47 | /** @var array Acceptable tokens after a dot token */ 48 | private static $afterDot = [ 49 | T::T_IDENTIFIER => true, // foo.bar 50 | T::T_QUOTED_IDENTIFIER => true, // foo."bar" 51 | T::T_STAR => true, // foo.* 52 | T::T_LBRACE => true, // foo[1] 53 | T::T_LBRACKET => true, // foo{a: 0} 54 | T::T_FILTER => true, // foo.[?bar==10] 55 | ]; 56 | 57 | /** 58 | * @param Lexer|null $lexer Lexer used to tokenize expressions 59 | */ 60 | public function __construct(?Lexer $lexer = null) 61 | { 62 | $this->lexer = $lexer ?: new Lexer(); 63 | } 64 | 65 | /** 66 | * Parses a JMESPath expression into an AST 67 | * 68 | * @param string $expression JMESPath expression to compile 69 | * 70 | * @return array Returns an array based AST 71 | * @throws SyntaxErrorException 72 | */ 73 | public function parse($expression) 74 | { 75 | $this->expression = $expression; 76 | $this->tokens = $this->lexer->tokenize($expression); 77 | $this->tpos = -1; 78 | $this->next(); 79 | $result = $this->expr(); 80 | 81 | if ($this->token['type'] === T::T_EOF) { 82 | return $result; 83 | } 84 | 85 | throw $this->syntax('Did not reach the end of the token stream'); 86 | } 87 | 88 | /** 89 | * Parses an expression while rbp < lbp. 90 | * 91 | * @param int $rbp Right bound precedence 92 | * 93 | * @return array 94 | */ 95 | private function expr($rbp = 0) 96 | { 97 | $left = $this->{"nud_{$this->token['type']}"}(); 98 | while ($rbp < self::$bp[$this->token['type']]) { 99 | $left = $this->{"led_{$this->token['type']}"}($left); 100 | } 101 | 102 | return $left; 103 | } 104 | 105 | private function nud_identifier() 106 | { 107 | $token = $this->token; 108 | $this->next(); 109 | return ['type' => 'field', 'value' => $token['value']]; 110 | } 111 | 112 | private function nud_quoted_identifier() 113 | { 114 | $token = $this->token; 115 | $this->next(); 116 | $this->assertNotToken(T::T_LPAREN); 117 | return ['type' => 'field', 'value' => $token['value']]; 118 | } 119 | 120 | private function nud_current() 121 | { 122 | $this->next(); 123 | return self::$currentNode; 124 | } 125 | 126 | private function nud_literal() 127 | { 128 | $token = $this->token; 129 | $this->next(); 130 | return ['type' => 'literal', 'value' => $token['value']]; 131 | } 132 | 133 | private function nud_expref() 134 | { 135 | $this->next(); 136 | return ['type' => T::T_EXPREF, 'children' => [$this->expr(self::$bp[T::T_EXPREF])]]; 137 | } 138 | 139 | private function nud_not() 140 | { 141 | $this->next(); 142 | return ['type' => T::T_NOT, 'children' => [$this->expr(self::$bp[T::T_NOT])]]; 143 | } 144 | 145 | private function nud_lparen() 146 | { 147 | $this->next(); 148 | $result = $this->expr(0); 149 | if ($this->token['type'] !== T::T_RPAREN) { 150 | throw $this->syntax('Unclosed `(`'); 151 | } 152 | $this->next(); 153 | return $result; 154 | } 155 | 156 | private function nud_lbrace() 157 | { 158 | static $validKeys = [T::T_QUOTED_IDENTIFIER => true, T::T_IDENTIFIER => true]; 159 | $this->next($validKeys); 160 | $pairs = []; 161 | 162 | do { 163 | $pairs[] = $this->parseKeyValuePair(); 164 | if ($this->token['type'] == T::T_COMMA) { 165 | $this->next($validKeys); 166 | } 167 | } while ($this->token['type'] !== T::T_RBRACE); 168 | 169 | $this->next(); 170 | 171 | return['type' => 'multi_select_hash', 'children' => $pairs]; 172 | } 173 | 174 | private function nud_flatten() 175 | { 176 | return $this->led_flatten(self::$currentNode); 177 | } 178 | 179 | private function nud_filter() 180 | { 181 | return $this->led_filter(self::$currentNode); 182 | } 183 | 184 | private function nud_star() 185 | { 186 | return $this->parseWildcardObject(self::$currentNode); 187 | } 188 | 189 | private function nud_lbracket() 190 | { 191 | $this->next(); 192 | $type = $this->token['type']; 193 | if ($type == T::T_NUMBER || $type == T::T_COLON) { 194 | return $this->parseArrayIndexExpression(); 195 | } elseif ($type == T::T_STAR && $this->lookahead() == T::T_RBRACKET) { 196 | return $this->parseWildcardArray(); 197 | } else { 198 | return $this->parseMultiSelectList(); 199 | } 200 | } 201 | 202 | private function led_lbracket(array $left) 203 | { 204 | static $nextTypes = [T::T_NUMBER => true, T::T_COLON => true, T::T_STAR => true]; 205 | $this->next($nextTypes); 206 | switch ($this->token['type']) { 207 | case T::T_NUMBER: 208 | case T::T_COLON: 209 | return [ 210 | 'type' => 'subexpression', 211 | 'children' => [$left, $this->parseArrayIndexExpression()] 212 | ]; 213 | default: 214 | return $this->parseWildcardArray($left); 215 | } 216 | } 217 | 218 | private function led_flatten(array $left) 219 | { 220 | $this->next(); 221 | 222 | return [ 223 | 'type' => 'projection', 224 | 'from' => 'array', 225 | 'children' => [ 226 | ['type' => T::T_FLATTEN, 'children' => [$left]], 227 | $this->parseProjection(self::$bp[T::T_FLATTEN]) 228 | ] 229 | ]; 230 | } 231 | 232 | private function led_dot(array $left) 233 | { 234 | $this->next(self::$afterDot); 235 | 236 | if ($this->token['type'] == T::T_STAR) { 237 | return $this->parseWildcardObject($left); 238 | } 239 | 240 | return [ 241 | 'type' => 'subexpression', 242 | 'children' => [$left, $this->parseDot(self::$bp[T::T_DOT])] 243 | ]; 244 | } 245 | 246 | private function led_or(array $left) 247 | { 248 | $this->next(); 249 | return [ 250 | 'type' => T::T_OR, 251 | 'children' => [$left, $this->expr(self::$bp[T::T_OR])] 252 | ]; 253 | } 254 | 255 | private function led_and(array $left) 256 | { 257 | $this->next(); 258 | return [ 259 | 'type' => T::T_AND, 260 | 'children' => [$left, $this->expr(self::$bp[T::T_AND])] 261 | ]; 262 | } 263 | 264 | private function led_pipe(array $left) 265 | { 266 | $this->next(); 267 | return [ 268 | 'type' => T::T_PIPE, 269 | 'children' => [$left, $this->expr(self::$bp[T::T_PIPE])] 270 | ]; 271 | } 272 | 273 | private function led_lparen(array $left) 274 | { 275 | $args = []; 276 | $this->next(); 277 | 278 | while ($this->token['type'] != T::T_RPAREN) { 279 | $args[] = $this->expr(0); 280 | if ($this->token['type'] == T::T_COMMA) { 281 | $this->next(); 282 | } 283 | } 284 | 285 | $this->next(); 286 | 287 | return [ 288 | 'type' => 'function', 289 | 'value' => $left['value'], 290 | 'children' => $args 291 | ]; 292 | } 293 | 294 | private function led_filter(array $left) 295 | { 296 | $this->next(); 297 | $expression = $this->expr(); 298 | if ($this->token['type'] != T::T_RBRACKET) { 299 | throw $this->syntax('Expected a closing rbracket for the filter'); 300 | } 301 | 302 | $this->next(); 303 | $rhs = $this->parseProjection(self::$bp[T::T_FILTER]); 304 | 305 | return [ 306 | 'type' => 'projection', 307 | 'from' => 'array', 308 | 'children' => [ 309 | $left ?: self::$currentNode, 310 | [ 311 | 'type' => 'condition', 312 | 'children' => [$expression, $rhs] 313 | ] 314 | ] 315 | ]; 316 | } 317 | 318 | private function led_comparator(array $left) 319 | { 320 | $token = $this->token; 321 | $this->next(); 322 | 323 | return [ 324 | 'type' => T::T_COMPARATOR, 325 | 'value' => $token['value'], 326 | 'children' => [$left, $this->expr(self::$bp[T::T_COMPARATOR])] 327 | ]; 328 | } 329 | 330 | private function parseProjection($bp) 331 | { 332 | $type = $this->token['type']; 333 | if (self::$bp[$type] < 10) { 334 | return self::$currentNode; 335 | } elseif ($type == T::T_DOT) { 336 | $this->next(self::$afterDot); 337 | return $this->parseDot($bp); 338 | } elseif ($type == T::T_LBRACKET || $type == T::T_FILTER) { 339 | return $this->expr($bp); 340 | } 341 | 342 | throw $this->syntax('Syntax error after projection'); 343 | } 344 | 345 | private function parseDot($bp) 346 | { 347 | if ($this->token['type'] == T::T_LBRACKET) { 348 | $this->next(); 349 | return $this->parseMultiSelectList(); 350 | } 351 | 352 | return $this->expr($bp); 353 | } 354 | 355 | private function parseKeyValuePair() 356 | { 357 | static $validColon = [T::T_COLON => true]; 358 | $key = $this->token['value']; 359 | $this->next($validColon); 360 | $this->next(); 361 | 362 | return [ 363 | 'type' => 'key_val_pair', 364 | 'value' => $key, 365 | 'children' => [$this->expr()] 366 | ]; 367 | } 368 | 369 | private function parseWildcardObject(?array $left = null) 370 | { 371 | $this->next(); 372 | 373 | return [ 374 | 'type' => 'projection', 375 | 'from' => 'object', 376 | 'children' => [ 377 | $left ?: self::$currentNode, 378 | $this->parseProjection(self::$bp[T::T_STAR]) 379 | ] 380 | ]; 381 | } 382 | 383 | private function parseWildcardArray(?array $left = null) 384 | { 385 | static $getRbracket = [T::T_RBRACKET => true]; 386 | $this->next($getRbracket); 387 | $this->next(); 388 | 389 | return [ 390 | 'type' => 'projection', 391 | 'from' => 'array', 392 | 'children' => [ 393 | $left ?: self::$currentNode, 394 | $this->parseProjection(self::$bp[T::T_STAR]) 395 | ] 396 | ]; 397 | } 398 | 399 | /** 400 | * Parses an array index expression (e.g., [0], [1:2:3] 401 | */ 402 | private function parseArrayIndexExpression() 403 | { 404 | static $matchNext = [ 405 | T::T_NUMBER => true, 406 | T::T_COLON => true, 407 | T::T_RBRACKET => true 408 | ]; 409 | 410 | $pos = 0; 411 | $parts = [null, null, null]; 412 | $expected = $matchNext; 413 | 414 | do { 415 | if ($this->token['type'] == T::T_COLON) { 416 | $pos++; 417 | $expected = $matchNext; 418 | } elseif ($this->token['type'] == T::T_NUMBER) { 419 | $parts[$pos] = $this->token['value']; 420 | $expected = [T::T_COLON => true, T::T_RBRACKET => true]; 421 | } 422 | $this->next($expected); 423 | } while ($this->token['type'] != T::T_RBRACKET); 424 | 425 | // Consume the closing bracket 426 | $this->next(); 427 | 428 | if ($pos === 0) { 429 | // No colons were found so this is a simple index extraction 430 | return ['type' => 'index', 'value' => $parts[0]]; 431 | } 432 | 433 | if ($pos > 2) { 434 | throw $this->syntax('Invalid array slice syntax: too many colons'); 435 | } 436 | 437 | // Sliced array from start (e.g., [2:]) 438 | return [ 439 | 'type' => 'projection', 440 | 'from' => 'array', 441 | 'children' => [ 442 | ['type' => 'slice', 'value' => $parts], 443 | $this->parseProjection(self::$bp[T::T_STAR]) 444 | ] 445 | ]; 446 | } 447 | 448 | private function parseMultiSelectList() 449 | { 450 | $nodes = []; 451 | 452 | do { 453 | $nodes[] = $this->expr(); 454 | if ($this->token['type'] == T::T_COMMA) { 455 | $this->next(); 456 | $this->assertNotToken(T::T_RBRACKET); 457 | } 458 | } while ($this->token['type'] !== T::T_RBRACKET); 459 | $this->next(); 460 | 461 | return ['type' => 'multi_select_list', 'children' => $nodes]; 462 | } 463 | 464 | private function syntax($msg) 465 | { 466 | return new SyntaxErrorException($msg, $this->token, $this->expression); 467 | } 468 | 469 | private function lookahead() 470 | { 471 | return (!isset($this->tokens[$this->tpos + 1])) 472 | ? T::T_EOF 473 | : $this->tokens[$this->tpos + 1]['type']; 474 | } 475 | 476 | private function next(?array $match = null) 477 | { 478 | if (!isset($this->tokens[$this->tpos + 1])) { 479 | $this->token = self::$nullToken; 480 | } else { 481 | $this->token = $this->tokens[++$this->tpos]; 482 | } 483 | 484 | if ($match && !isset($match[$this->token['type']])) { 485 | throw $this->syntax($match); 486 | } 487 | } 488 | 489 | private function assertNotToken($type) 490 | { 491 | if ($this->token['type'] == $type) { 492 | throw $this->syntax("Token {$this->tpos} not allowed to be $type"); 493 | } 494 | } 495 | 496 | /** 497 | * @internal Handles undefined tokens without paying the cost of validation 498 | */ 499 | public function __call($method, $args) 500 | { 501 | $prefix = substr($method, 0, 4); 502 | if ($prefix == 'nud_' || $prefix == 'led_') { 503 | $token = substr($method, 4); 504 | $message = "Unexpected \"$token\" token ($method). Expected one of" 505 | . " the following tokens: " 506 | . implode(', ', array_map(function ($i) { 507 | return '"' . substr($i, 4) . '"'; 508 | }, array_filter( 509 | get_class_methods($this), 510 | function ($i) use ($prefix) { 511 | return strpos($i, $prefix) === 0; 512 | } 513 | ))); 514 | throw $this->syntax($message); 515 | } 516 | 517 | throw new \BadMethodCallException("Call to undefined method $method"); 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /src/SyntaxErrorException.php: -------------------------------------------------------------------------------- 1 | createTokenMessage($token, $expectedTypesOrMessage); 24 | parent::__construct($message); 25 | } 26 | 27 | private function createTokenMessage(array $token, array $valid) 28 | { 29 | return sprintf( 30 | 'Expected one of the following: %s; found %s "%s"', 31 | implode(', ', array_keys($valid)), 32 | $token['type'], 33 | $token['value'] 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/TreeCompiler.php: -------------------------------------------------------------------------------- 1 | vars = []; 23 | $this->source = $this->indentation = ''; 24 | $this->write("write('use JmesPath\\TreeInterpreter as Ti;') 26 | ->write('use JmesPath\\FnDispatcher as Fd;') 27 | ->write('use JmesPath\\Utils;') 28 | ->write('') 29 | ->write('function %s(Ti $interpreter, $value) {', $fnName) 30 | ->indent() 31 | ->dispatch($ast) 32 | ->write('') 33 | ->write('return $value;') 34 | ->outdent() 35 | ->write('}'); 36 | 37 | return $this->source; 38 | } 39 | 40 | /** 41 | * @param array $node 42 | * @return mixed 43 | */ 44 | private function dispatch(array $node) 45 | { 46 | return $this->{"visit_{$node['type']}"}($node); 47 | } 48 | 49 | /** 50 | * Creates a monotonically incrementing unique variable name by prefix. 51 | * 52 | * @param string $prefix Variable name prefix 53 | * 54 | * @return string 55 | */ 56 | private function makeVar($prefix) 57 | { 58 | if (!isset($this->vars[$prefix])) { 59 | $this->vars[$prefix] = 0; 60 | return '$' . $prefix; 61 | } 62 | 63 | return '$' . $prefix . ++$this->vars[$prefix]; 64 | } 65 | 66 | /** 67 | * Writes the given line of source code. Pass positional arguments to write 68 | * that match the format of sprintf. 69 | * 70 | * @param string $str String to write 71 | * @return $this 72 | */ 73 | private function write($str) 74 | { 75 | $this->source .= $this->indentation; 76 | if (func_num_args() == 1) { 77 | $this->source .= $str . "\n"; 78 | return $this; 79 | } 80 | $this->source .= vsprintf($str, array_slice(func_get_args(), 1)) . "\n"; 81 | return $this; 82 | } 83 | 84 | /** 85 | * Decreases the indentation level of code being written 86 | * @return $this 87 | */ 88 | private function outdent() 89 | { 90 | $this->indentation = substr($this->indentation, 0, -4); 91 | return $this; 92 | } 93 | 94 | /** 95 | * Increases the indentation level of code being written 96 | * @return $this 97 | */ 98 | private function indent() 99 | { 100 | $this->indentation .= ' '; 101 | return $this; 102 | } 103 | 104 | private function visit_or(array $node) 105 | { 106 | $a = $this->makeVar('beforeOr'); 107 | return $this 108 | ->write('%s = $value;', $a) 109 | ->dispatch($node['children'][0]) 110 | ->write('if (!$value && $value !== "0" && $value !== 0) {') 111 | ->indent() 112 | ->write('$value = %s;', $a) 113 | ->dispatch($node['children'][1]) 114 | ->outdent() 115 | ->write('}'); 116 | } 117 | 118 | private function visit_and(array $node) 119 | { 120 | $a = $this->makeVar('beforeAnd'); 121 | return $this 122 | ->write('%s = $value;', $a) 123 | ->dispatch($node['children'][0]) 124 | ->write('if ($value || $value === "0" || $value === 0) {') 125 | ->indent() 126 | ->write('$value = %s;', $a) 127 | ->dispatch($node['children'][1]) 128 | ->outdent() 129 | ->write('}'); 130 | } 131 | 132 | private function visit_not(array $node) 133 | { 134 | return $this 135 | ->write('// Visiting not node') 136 | ->dispatch($node['children'][0]) 137 | ->write('// Applying boolean not to result of not node') 138 | ->write('$value = !Utils::isTruthy($value);'); 139 | } 140 | 141 | private function visit_subexpression(array $node) 142 | { 143 | return $this 144 | ->dispatch($node['children'][0]) 145 | ->write('if ($value !== null) {') 146 | ->indent() 147 | ->dispatch($node['children'][1]) 148 | ->outdent() 149 | ->write('}'); 150 | } 151 | 152 | private function visit_field(array $node) 153 | { 154 | $arr = '$value[' . var_export($node['value'], true) . ']'; 155 | $obj = '$value->{' . var_export($node['value'], true) . '}'; 156 | $this->write('if (is_array($value) || $value instanceof \\ArrayAccess) {') 157 | ->indent() 158 | ->write('$value = isset(%s) ? %s : null;', $arr, $arr) 159 | ->outdent() 160 | ->write('} elseif ($value instanceof \\stdClass) {') 161 | ->indent() 162 | ->write('$value = isset(%s) ? %s : null;', $obj, $obj) 163 | ->outdent() 164 | ->write("} else {") 165 | ->indent() 166 | ->write('$value = null;') 167 | ->outdent() 168 | ->write("}"); 169 | 170 | return $this; 171 | } 172 | 173 | private function visit_index(array $node) 174 | { 175 | if ($node['value'] >= 0) { 176 | $check = '$value[' . $node['value'] . ']'; 177 | return $this->write( 178 | '$value = (is_array($value) || $value instanceof \\ArrayAccess)' 179 | . ' && isset(%s) ? %s : null;', 180 | $check, $check 181 | ); 182 | } 183 | 184 | $a = $this->makeVar('count'); 185 | return $this 186 | ->write('if (is_array($value) || ($value instanceof \\ArrayAccess && $value instanceof \\Countable)) {') 187 | ->indent() 188 | ->write('%s = count($value) + %s;', $a, $node['value']) 189 | ->write('$value = isset($value[%s]) ? $value[%s] : null;', $a, $a) 190 | ->outdent() 191 | ->write('} else {') 192 | ->indent() 193 | ->write('$value = null;') 194 | ->outdent() 195 | ->write('}'); 196 | } 197 | 198 | private function visit_literal(array $node) 199 | { 200 | return $this->write('$value = %s;', var_export($node['value'], true)); 201 | } 202 | 203 | private function visit_pipe(array $node) 204 | { 205 | return $this 206 | ->dispatch($node['children'][0]) 207 | ->dispatch($node['children'][1]); 208 | } 209 | 210 | private function visit_multi_select_list(array $node) 211 | { 212 | return $this->visit_multi_select_hash($node); 213 | } 214 | 215 | private function visit_multi_select_hash(array $node) 216 | { 217 | $listVal = $this->makeVar('list'); 218 | $value = $this->makeVar('prev'); 219 | $this->write('if ($value !== null) {') 220 | ->indent() 221 | ->write('%s = [];', $listVal) 222 | ->write('%s = $value;', $value); 223 | 224 | $first = true; 225 | foreach ($node['children'] as $child) { 226 | if (!$first) { 227 | $this->write('$value = %s;', $value); 228 | } 229 | $first = false; 230 | if ($node['type'] == 'multi_select_hash') { 231 | $this->dispatch($child['children'][0]); 232 | $key = var_export($child['value'], true); 233 | $this->write('%s[%s] = $value;', $listVal, $key); 234 | } else { 235 | $this->dispatch($child); 236 | $this->write('%s[] = $value;', $listVal); 237 | } 238 | } 239 | 240 | return $this 241 | ->write('$value = %s;', $listVal) 242 | ->outdent() 243 | ->write('}'); 244 | } 245 | 246 | private function visit_function(array $node) 247 | { 248 | $value = $this->makeVar('val'); 249 | $args = $this->makeVar('args'); 250 | $this->write('%s = $value;', $value) 251 | ->write('%s = [];', $args); 252 | 253 | foreach ($node['children'] as $arg) { 254 | $this->dispatch($arg); 255 | $this->write('%s[] = $value;', $args) 256 | ->write('$value = %s;', $value); 257 | } 258 | 259 | return $this->write( 260 | '$value = Fd::getInstance()->__invoke("%s", %s);', 261 | $node['value'], $args 262 | ); 263 | } 264 | 265 | private function visit_slice(array $node) 266 | { 267 | return $this 268 | ->write('$value = !is_string($value) && !Utils::isArray($value)') 269 | ->write(' ? null : Utils::slice($value, %s, %s, %s);', 270 | var_export($node['value'][0], true), 271 | var_export($node['value'][1], true), 272 | var_export($node['value'][2], true) 273 | ); 274 | } 275 | 276 | private function visit_current(array $node) 277 | { 278 | return $this->write('// Visiting current node (no-op)'); 279 | } 280 | 281 | private function visit_expref(array $node) 282 | { 283 | $child = var_export($node['children'][0], true); 284 | return $this->write('$value = function ($value) use ($interpreter) {') 285 | ->indent() 286 | ->write('return $interpreter->visit(%s, $value);', $child) 287 | ->outdent() 288 | ->write('};'); 289 | } 290 | 291 | private function visit_flatten(array $node) 292 | { 293 | $this->dispatch($node['children'][0]); 294 | $merged = $this->makeVar('merged'); 295 | $val = $this->makeVar('val'); 296 | 297 | $this 298 | ->write('// Visiting merge node') 299 | ->write('if (!Utils::isArray($value)) {') 300 | ->indent() 301 | ->write('$value = null;') 302 | ->outdent() 303 | ->write('} else {') 304 | ->indent() 305 | ->write('%s = [];', $merged) 306 | ->write('foreach ($value as %s) {', $val) 307 | ->indent() 308 | ->write('if (is_array(%s) && array_key_exists(0, %s)) {', $val, $val) 309 | ->indent() 310 | ->write('%s = array_merge(%s, %s);', $merged, $merged, $val) 311 | ->outdent() 312 | ->write('} elseif (%s !== []) {', $val) 313 | ->indent() 314 | ->write('%s[] = %s;', $merged, $val) 315 | ->outdent() 316 | ->write('}') 317 | ->outdent() 318 | ->write('}') 319 | ->write('$value = %s;', $merged) 320 | ->outdent() 321 | ->write('}'); 322 | 323 | return $this; 324 | } 325 | 326 | private function visit_projection(array $node) 327 | { 328 | $val = $this->makeVar('val'); 329 | $collected = $this->makeVar('collected'); 330 | $this->write('// Visiting projection node') 331 | ->dispatch($node['children'][0]) 332 | ->write(''); 333 | 334 | if (!isset($node['from'])) { 335 | $this->write('if (!is_array($value) || !($value instanceof \stdClass)) { $value = null; }'); 336 | } elseif ($node['from'] == 'object') { 337 | $this->write('if (!Utils::isObject($value)) { $value = null; }'); 338 | } elseif ($node['from'] == 'array') { 339 | $this->write('if (!Utils::isArray($value)) { $value = null; }'); 340 | } 341 | 342 | $this->write('if ($value !== null) {') 343 | ->indent() 344 | ->write('%s = [];', $collected) 345 | ->write('foreach ((array) $value as %s) {', $val) 346 | ->indent() 347 | ->write('$value = %s;', $val) 348 | ->dispatch($node['children'][1]) 349 | ->write('if ($value !== null) {') 350 | ->indent() 351 | ->write('%s[] = $value;', $collected) 352 | ->outdent() 353 | ->write('}') 354 | ->outdent() 355 | ->write('}') 356 | ->write('$value = %s;', $collected) 357 | ->outdent() 358 | ->write('}'); 359 | 360 | return $this; 361 | } 362 | 363 | private function visit_condition(array $node) 364 | { 365 | $value = $this->makeVar('beforeCondition'); 366 | return $this 367 | ->write('%s = $value;', $value) 368 | ->write('// Visiting condition node') 369 | ->dispatch($node['children'][0]) 370 | ->write('// Checking result of condition node') 371 | ->write('if (Utils::isTruthy($value)) {') 372 | ->indent() 373 | ->write('$value = %s;', $value) 374 | ->dispatch($node['children'][1]) 375 | ->outdent() 376 | ->write('} else {') 377 | ->indent() 378 | ->write('$value = null;') 379 | ->outdent() 380 | ->write('}'); 381 | } 382 | 383 | private function visit_comparator(array $node) 384 | { 385 | $value = $this->makeVar('val'); 386 | $a = $this->makeVar('left'); 387 | $b = $this->makeVar('right'); 388 | 389 | $this 390 | ->write('// Visiting comparator node') 391 | ->write('%s = $value;', $value) 392 | ->dispatch($node['children'][0]) 393 | ->write('%s = $value;', $a) 394 | ->write('$value = %s;', $value) 395 | ->dispatch($node['children'][1]) 396 | ->write('%s = $value;', $b); 397 | 398 | if ($node['value'] == '==') { 399 | $this->write('$value = Utils::isEqual(%s, %s);', $a, $b); 400 | } elseif ($node['value'] == '!=') { 401 | $this->write('$value = !Utils::isEqual(%s, %s);', $a, $b); 402 | } else { 403 | $this->write( 404 | '$value = (is_int(%s) || is_float(%s)) && (is_int(%s) || is_float(%s)) && %s %s %s;', 405 | $a, $a, $b, $b, $a, $node['value'], $b 406 | ); 407 | } 408 | 409 | return $this; 410 | } 411 | 412 | /** @internal */ 413 | public function __call($method, $args) 414 | { 415 | throw new \RuntimeException( 416 | sprintf('Invalid node encountered: %s', json_encode($args[0])) 417 | ); 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/TreeInterpreter.php: -------------------------------------------------------------------------------- 1 | fnDispatcher = $fnDispatcher ?: FnDispatcher::getInstance(); 20 | } 21 | 22 | /** 23 | * Visits each node in a JMESPath AST and returns the evaluated result. 24 | * 25 | * @param array $node JMESPath AST node 26 | * @param mixed $data Data to evaluate 27 | * 28 | * @return mixed 29 | */ 30 | public function visit(array $node, $data) 31 | { 32 | return $this->dispatch($node, $data); 33 | } 34 | 35 | /** 36 | * Recursively traverses an AST using depth-first, pre-order traversal. 37 | * The evaluation logic for each node type is embedded into a large switch 38 | * statement to avoid the cost of "double dispatch". 39 | * @return mixed 40 | */ 41 | private function dispatch(array $node, $value) 42 | { 43 | $dispatcher = $this->fnDispatcher; 44 | 45 | switch ($node['type']) { 46 | 47 | case 'field': 48 | if (is_array($value) || $value instanceof \ArrayAccess) { 49 | return isset($value[$node['value']]) ? $value[$node['value']] : null; 50 | } elseif ($value instanceof \stdClass) { 51 | return isset($value->{$node['value']}) ? $value->{$node['value']} : null; 52 | } 53 | return null; 54 | 55 | case 'subexpression': 56 | return $this->dispatch( 57 | $node['children'][1], 58 | $this->dispatch($node['children'][0], $value) 59 | ); 60 | 61 | case 'index': 62 | if (!Utils::isArray($value)) { 63 | return null; 64 | } 65 | $idx = $node['value'] >= 0 66 | ? $node['value'] 67 | : $node['value'] + count($value); 68 | return isset($value[$idx]) ? $value[$idx] : null; 69 | 70 | case 'projection': 71 | $left = $this->dispatch($node['children'][0], $value); 72 | switch ($node['from']) { 73 | case 'object': 74 | if (!Utils::isObject($left)) { 75 | return null; 76 | } 77 | break; 78 | case 'array': 79 | if (!Utils::isArray($left)) { 80 | return null; 81 | } 82 | break; 83 | default: 84 | if (!is_array($left) || !($left instanceof \stdClass)) { 85 | return null; 86 | } 87 | } 88 | 89 | $collected = []; 90 | foreach ((array) $left as $val) { 91 | $result = $this->dispatch($node['children'][1], $val); 92 | if ($result !== null) { 93 | $collected[] = $result; 94 | } 95 | } 96 | 97 | return $collected; 98 | 99 | case 'flatten': 100 | static $skipElement = []; 101 | $value = $this->dispatch($node['children'][0], $value); 102 | 103 | if (!Utils::isArray($value)) { 104 | return null; 105 | } 106 | 107 | $merged = []; 108 | foreach ($value as $values) { 109 | // Only merge up arrays lists and not hashes 110 | if (is_array($values) && array_key_exists(0, $values)) { 111 | $merged = array_merge($merged, $values); 112 | } elseif ($values !== $skipElement) { 113 | $merged[] = $values; 114 | } 115 | } 116 | 117 | return $merged; 118 | 119 | case 'literal': 120 | return $node['value']; 121 | 122 | case 'current': 123 | return $value; 124 | 125 | case 'or': 126 | $result = $this->dispatch($node['children'][0], $value); 127 | return Utils::isTruthy($result) 128 | ? $result 129 | : $this->dispatch($node['children'][1], $value); 130 | 131 | case 'and': 132 | $result = $this->dispatch($node['children'][0], $value); 133 | return Utils::isTruthy($result) 134 | ? $this->dispatch($node['children'][1], $value) 135 | : $result; 136 | 137 | case 'not': 138 | return !Utils::isTruthy( 139 | $this->dispatch($node['children'][0], $value) 140 | ); 141 | 142 | case 'pipe': 143 | return $this->dispatch( 144 | $node['children'][1], 145 | $this->dispatch($node['children'][0], $value) 146 | ); 147 | 148 | case 'multi_select_list': 149 | if ($value === null) { 150 | return null; 151 | } 152 | 153 | $collected = []; 154 | foreach ($node['children'] as $node) { 155 | $collected[] = $this->dispatch($node, $value); 156 | } 157 | 158 | return $collected; 159 | 160 | case 'multi_select_hash': 161 | if ($value === null) { 162 | return null; 163 | } 164 | 165 | $collected = []; 166 | foreach ($node['children'] as $node) { 167 | $collected[$node['value']] = $this->dispatch( 168 | $node['children'][0], 169 | $value 170 | ); 171 | } 172 | 173 | return $collected; 174 | 175 | case 'comparator': 176 | $left = $this->dispatch($node['children'][0], $value); 177 | $right = $this->dispatch($node['children'][1], $value); 178 | if ($node['value'] == '==') { 179 | return Utils::isEqual($left, $right); 180 | } elseif ($node['value'] == '!=') { 181 | return !Utils::isEqual($left, $right); 182 | } else { 183 | return self::relativeCmp($left, $right, $node['value']); 184 | } 185 | 186 | case 'condition': 187 | return Utils::isTruthy($this->dispatch($node['children'][0], $value)) 188 | ? $this->dispatch($node['children'][1], $value) 189 | : null; 190 | 191 | case 'function': 192 | $args = []; 193 | foreach ($node['children'] as $arg) { 194 | $args[] = $this->dispatch($arg, $value); 195 | } 196 | return $dispatcher($node['value'], $args); 197 | 198 | case 'slice': 199 | return is_string($value) || Utils::isArray($value) 200 | ? Utils::slice( 201 | $value, 202 | $node['value'][0], 203 | $node['value'][1], 204 | $node['value'][2] 205 | ) : null; 206 | 207 | case 'expref': 208 | $apply = $node['children'][0]; 209 | return function ($value) use ($apply) { 210 | return $this->visit($apply, $value); 211 | }; 212 | 213 | default: 214 | throw new \RuntimeException("Unknown node type: {$node['type']}"); 215 | } 216 | } 217 | 218 | /** 219 | * @return bool 220 | */ 221 | private static function relativeCmp($left, $right, $cmp) 222 | { 223 | if (!(is_int($left) || is_float($left)) || !(is_int($right) || is_float($right))) { 224 | return false; 225 | } 226 | 227 | switch ($cmp) { 228 | case '>': return $left > $right; 229 | case '>=': return $left >= $right; 230 | case '<': return $left < $right; 231 | case '<=': return $left <= $right; 232 | default: throw new \RuntimeException("Invalid comparison: $cmp"); 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | 'boolean', 8 | 'string' => 'string', 9 | 'NULL' => 'null', 10 | 'double' => 'number', 11 | 'float' => 'number', 12 | 'integer' => 'number' 13 | ]; 14 | 15 | /** 16 | * Returns true if the value is truthy 17 | * 18 | * @param mixed $value Value to check 19 | * 20 | * @return bool 21 | */ 22 | public static function isTruthy($value) 23 | { 24 | if (!$value) { 25 | return $value === 0 || $value === '0'; 26 | } elseif ($value instanceof \stdClass) { 27 | return (bool) get_object_vars($value); 28 | } else { 29 | return true; 30 | } 31 | } 32 | 33 | /** 34 | * Gets the JMESPath type equivalent of a PHP variable. 35 | * 36 | * @param mixed $arg PHP variable 37 | * @return string Returns the JSON data type 38 | * @throws \InvalidArgumentException when an unknown type is given. 39 | */ 40 | public static function type($arg) 41 | { 42 | $type = gettype($arg); 43 | if (isset(self::$typeMap[$type])) { 44 | return self::$typeMap[$type]; 45 | } elseif ($type === 'array') { 46 | if (empty($arg)) { 47 | return 'array'; 48 | } 49 | reset($arg); 50 | return key($arg) === 0 ? 'array' : 'object'; 51 | } elseif ($arg instanceof \stdClass) { 52 | return 'object'; 53 | } elseif ($arg instanceof \Closure) { 54 | return 'expression'; 55 | } elseif ($arg instanceof \ArrayAccess 56 | && $arg instanceof \Countable 57 | ) { 58 | return count($arg) == 0 || $arg->offsetExists(0) 59 | ? 'array' 60 | : 'object'; 61 | } elseif (method_exists($arg, '__toString')) { 62 | return 'string'; 63 | } 64 | 65 | throw new \InvalidArgumentException( 66 | 'Unable to determine JMESPath type from ' . get_class($arg) 67 | ); 68 | } 69 | 70 | /** 71 | * Determine if the provided value is a JMESPath compatible object. 72 | * 73 | * @param mixed $value 74 | * 75 | * @return bool 76 | */ 77 | public static function isObject($value) 78 | { 79 | if (is_array($value)) { 80 | return !$value || array_keys($value)[0] !== 0; 81 | } 82 | 83 | // Handle array-like values. Must be empty or offset 0 does not exist 84 | return $value instanceof \Countable && $value instanceof \ArrayAccess 85 | ? count($value) == 0 || !$value->offsetExists(0) 86 | : $value instanceof \stdClass; 87 | } 88 | 89 | /** 90 | * Determine if the provided value is a JMESPath compatible array. 91 | * 92 | * @param mixed $value 93 | * 94 | * @return bool 95 | */ 96 | public static function isArray($value) 97 | { 98 | if (is_array($value)) { 99 | return !$value || array_keys($value)[0] === 0; 100 | } 101 | 102 | // Handle array-like values. Must be empty or offset 0 exists. 103 | return $value instanceof \Countable && $value instanceof \ArrayAccess 104 | ? count($value) == 0 || $value->offsetExists(0) 105 | : false; 106 | } 107 | 108 | /** 109 | * JSON aware value comparison function. 110 | * 111 | * @param mixed $a First value to compare 112 | * @param mixed $b Second value to compare 113 | * 114 | * @return bool 115 | */ 116 | public static function isEqual($a, $b) 117 | { 118 | if ($a === $b) { 119 | return true; 120 | } elseif ($a instanceof \stdClass) { 121 | return self::isEqual((array) $a, $b); 122 | } elseif ($b instanceof \stdClass) { 123 | return self::isEqual($a, (array) $b); 124 | } else { 125 | return false; 126 | } 127 | } 128 | 129 | /** 130 | * Safely add together two values. 131 | * 132 | * @param mixed $a First value to add 133 | * @param mixed $b Second value to add 134 | * 135 | * @return int|float 136 | */ 137 | public static function add($a, $b) 138 | { 139 | if (is_numeric($a)) { 140 | if (is_numeric($b)) { 141 | return $a + $b; 142 | } else { 143 | return $a; 144 | } 145 | } else { 146 | if (is_numeric($b)) { 147 | return $b; 148 | } else { 149 | return 0; 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * JMESPath requires a stable sorting algorithm, so here we'll implement 156 | * a simple Schwartzian transform that uses array index positions as tie 157 | * breakers. 158 | * 159 | * @param array $data List or map of data to sort 160 | * @param callable $sortFn Callable used to sort values 161 | * 162 | * @return array Returns the sorted array 163 | * @link http://en.wikipedia.org/wiki/Schwartzian_transform 164 | */ 165 | public static function stableSort(array $data, callable $sortFn) 166 | { 167 | // Decorate each item by creating an array of [value, index] 168 | array_walk($data, function (&$v, $k) { 169 | $v = [$v, $k]; 170 | }); 171 | // Sort by the sort function and use the index as a tie-breaker 172 | uasort($data, function ($a, $b) use ($sortFn) { 173 | return $sortFn($a[0], $b[0]) ?: ($a[1] < $b[1] ? -1 : 1); 174 | }); 175 | 176 | // Undecorate each item and return the resulting sorted array 177 | return array_map(function ($v) { 178 | return $v[0]; 179 | }, array_values($data)); 180 | } 181 | 182 | /** 183 | * Creates a Python-style slice of a string or array. 184 | * 185 | * @param array|string $value Value to slice 186 | * @param int|null $start Starting position 187 | * @param int|null $stop Stop position 188 | * @param int $step Step (1, 2, -1, -2, etc.) 189 | * 190 | * @return array|string 191 | * @throws \InvalidArgumentException 192 | */ 193 | public static function slice($value, $start = null, $stop = null, $step = 1) 194 | { 195 | if (!is_array($value) && !is_string($value)) { 196 | throw new \InvalidArgumentException('Expects string or array'); 197 | } 198 | 199 | return self::sliceIndices($value, $start, $stop, $step); 200 | } 201 | 202 | private static function adjustEndpoint($length, $endpoint, $step) 203 | { 204 | if ($endpoint < 0) { 205 | $endpoint += $length; 206 | if ($endpoint < 0) { 207 | $endpoint = $step < 0 ? -1 : 0; 208 | } 209 | } elseif ($endpoint >= $length) { 210 | $endpoint = $step < 0 ? $length - 1 : $length; 211 | } 212 | 213 | return $endpoint; 214 | } 215 | 216 | private static function adjustSlice($length, $start, $stop, $step) 217 | { 218 | if ($step === null) { 219 | $step = 1; 220 | } elseif ($step === 0) { 221 | throw new \RuntimeException('step cannot be 0'); 222 | } 223 | 224 | if ($start === null) { 225 | $start = $step < 0 ? $length - 1 : 0; 226 | } else { 227 | $start = self::adjustEndpoint($length, $start, $step); 228 | } 229 | 230 | if ($stop === null) { 231 | $stop = $step < 0 ? -1 : $length; 232 | } else { 233 | $stop = self::adjustEndpoint($length, $stop, $step); 234 | } 235 | 236 | return [$start, $stop, $step]; 237 | } 238 | 239 | private static function sliceIndices($subject, $start, $stop, $step) 240 | { 241 | $type = gettype($subject); 242 | $len = $type == 'string' ? mb_strlen($subject, 'UTF-8') : count($subject); 243 | list($start, $stop, $step) = self::adjustSlice($len, $start, $stop, $step); 244 | 245 | $result = []; 246 | if ($step > 0) { 247 | for ($i = $start; $i < $stop; $i += $step) { 248 | $result[] = $subject[$i]; 249 | } 250 | } else { 251 | for ($i = $start; $i > $stop; $i += $step) { 252 | $result[] = $subject[$i]; 253 | } 254 | } 255 | 256 | return $type == 'string' ? implode('', $result) : $result; 257 | } 258 | } 259 | --------------------------------------------------------------------------------