├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── doc ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── components.rs │ ├── components │ │ ├── editable_select.rs │ │ ├── expr.rs │ │ └── modal.rs │ ├── data.rs │ ├── lib.rs │ ├── main.rs │ └── util.rs ├── static │ └── sources.txt └── style.sass ├── dump-infos.php ├── lib ├── api.php ├── ast.php ├── context.php ├── defaults │ ├── bool.php │ ├── format.php │ ├── index.php │ ├── number.php │ ├── player.php │ ├── position.php │ ├── string.php │ └── world.php ├── doc.php ├── ext.php ├── kind-index.php ├── mapping-index.php ├── name.php ├── parser.php ├── pathfind.php ├── reflect.php ├── registry.php ├── template.php └── template │ ├── element.php │ ├── get.php │ └── watch.php ├── phpstan-baseline.neon ├── phpstan.neon.dist ├── phpunit.xml ├── shared ├── display.php ├── kind-help.php ├── mapping.php ├── reflect.php ├── registry.php └── standard-kinds.php └── tests ├── AstTest.php ├── StringParserTest.php ├── TemplateTest.php ├── autoload-bootstrap.php └── defaults └── StringsTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.md] 4 | indent_style = space 5 | indent_size = 2 6 | 7 | [*.php] 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.{neon,neon.dist}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | [*.{json,json.dist}] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.yml] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [*.html] 24 | indent_style = space 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build-php: 7 | name: Prepare PHP 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | matrix: 11 | php: 12 | - "8.2" 13 | steps: 14 | - name: Build and prepare PHP cache 15 | uses: pmmp/setup-php-action@main 16 | with: 17 | php-version: ${{ matrix.php }} 18 | install-path: "../bin" 19 | pm-version-major: "5" 20 | 21 | fmt: 22 | name: code style check 23 | needs: [build-php] 24 | runs-on: ubuntu-20.04 25 | strategy: 26 | matrix: 27 | php: 28 | - "8.2" 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: pmmp/setup-php-action@main 32 | with: 33 | php-version: ${{ matrix.php }} 34 | install-path: "../bin" 35 | pm-version-major: "5" 36 | - name: Install Composer 37 | run: curl -sS https://getcomposer.org/installer | php 38 | - run: composer install 39 | - run: composer fmt 40 | - run: git diff --exit-code 41 | phpstan: 42 | name: phpstan analyze 43 | needs: [build-php] 44 | runs-on: ubuntu-20.04 45 | strategy: 46 | matrix: 47 | php: 48 | - "8.2" 49 | steps: 50 | - uses: actions/checkout@v3 51 | - uses: pmmp/setup-php-action@main 52 | with: 53 | php-version: ${{ matrix.php }} 54 | install-path: "../bin" 55 | pm-version-major: "5" 56 | - name: Install Composer 57 | run: curl -sS https://getcomposer.org/installer | php 58 | - run: composer install 59 | - name: phpstan analyze 60 | run: composer analyze 61 | test: 62 | name: phpunit test 63 | needs: [build-php] 64 | runs-on: ubuntu-20.04 65 | strategy: 66 | matrix: 67 | php: 68 | - "8.2" 69 | steps: 70 | - uses: actions/checkout@v3 71 | - uses: pmmp/setup-php-action@main 72 | with: 73 | php-version: ${{ matrix.php }} 74 | install-path: "../bin" 75 | pm-version-major: "5" 76 | - name: Install Composer 77 | run: curl -sS https://getcomposer.org/installer | php 78 | - run: composer install 79 | - name: run tests 80 | run: composer test 81 | 82 | build-site: 83 | name: build site 84 | needs: [build-php] 85 | runs-on: ubuntu-20.04 86 | permissions: 87 | pages: write 88 | id-token: write 89 | if: github.event_name == 'push' && github.ref_type == 'branch' && (github.ref_name == 'master' || github.ref_name == 'v2-virion') 90 | strategy: 91 | matrix: 92 | php: 93 | - "8.2" 94 | steps: 95 | - uses: actions/checkout@v3 96 | - uses: pmmp/setup-php-action@main 97 | with: 98 | php-version: ${{ matrix.php }} 99 | install-path: "../bin" 100 | pm-version-major: "5" 101 | - name: Install Composer 102 | run: curl -sS https://getcomposer.org/installer | php 103 | - run: composer install 104 | - name: Generate JSON doc 105 | run: composer gen-doc 106 | 107 | - name: Install bulma 108 | run: npm install 109 | working-directory: doc 110 | 111 | - uses: actions-rs/toolchain@v1 112 | with: 113 | toolchain: stable 114 | target: wasm32-unknown-unknown 115 | - uses: actions/cache@v3 116 | with: 117 | path: ~/.cargo/bin 118 | key: trunk 119 | - name: Install Trunk 120 | run: command -v trunk || cargo install trunk 121 | - run: trunk build doc/index.html --release --public-url /InfoAPI/ 122 | 123 | - uses: actions/upload-pages-artifact@v1 124 | with: 125 | path: doc/dist 126 | - uses: actions/deploy-pages@v1 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.cache 3 | /vendor/ 4 | /composer.lock 5 | /pharynx-tmp-src 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | append([$file]); 15 | } 16 | } 17 | } 18 | 19 | 20 | return (new Config) 21 | ->setRiskyAllowed(true) 22 | ->setFinder($finder) 23 | ->setIndent("\t") 24 | ->setRules([ 25 | "align_multiline_comment" => [ 26 | "comment_type" => "phpdocs_only" 27 | ], 28 | "array_indentation" => true, 29 | "array_syntax" => [ 30 | "syntax" => "short" 31 | ], 32 | "binary_operator_spaces" => [ 33 | "default" => "single_space" 34 | ], 35 | "blank_line_after_namespace" => true, 36 | "blank_line_after_opening_tag" => true, 37 | "blank_line_before_statement" => [ 38 | "statements" => [ 39 | "declare" 40 | ] 41 | ], 42 | "braces" => [ 43 | "allow_single_line_closure" => false, 44 | "position_after_anonymous_constructs" => "same", 45 | "position_after_control_structures" => "same", 46 | "position_after_functions_and_oop_constructs" => "same", 47 | ], 48 | "cast_spaces" => [ 49 | "space" => "single" 50 | ], 51 | "concat_space" => [ 52 | "spacing" => "one" 53 | ], 54 | "declare_strict_types" => true, 55 | "elseif" => true, 56 | "global_namespace_import" => [ 57 | "import_constants" => true, 58 | "import_functions" => true, 59 | "import_classes" => null, 60 | ], 61 | "indentation_type" => true, 62 | "native_function_invocation" => [ 63 | "scope" => "namespaced", 64 | "include" => ["@all"], 65 | ], 66 | "no_closing_tag" => true, 67 | "no_empty_phpdoc" => true, 68 | "no_superfluous_phpdoc_tags" => [ 69 | "allow_mixed" => true, 70 | ], 71 | "no_trailing_whitespace" => true, 72 | "no_trailing_whitespace_in_comment" => true, 73 | "no_whitespace_in_blank_line" => true, 74 | "no_unused_imports" => true, 75 | "ordered_imports" => [ 76 | "imports_order" => [ 77 | "class", 78 | "function", 79 | "const", 80 | ], 81 | "sort_algorithm" => "alpha" 82 | ], 83 | "phpdoc_line_span" => [ 84 | "property" => "single", 85 | "method" => null, 86 | "const" => null 87 | ], 88 | "phpdoc_trim" => true, 89 | "phpdoc_trim_consecutive_blank_line_separation" => true, 90 | "return_type_declaration" => [ 91 | "space_before" => "one" 92 | ], 93 | "single_import_per_statement" => true, 94 | "strict_param" => true, 95 | "unary_operator_spaces" => true, 96 | ]); 97 | })(); 98 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 4 | ### Added 5 | - `infoapi.player.uuid` 6 | 7 | ## 1.0.0 8 | Initial release. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InfoAPI 2 | 3 | Extensible templating for PocketMine plugins. 4 | 5 | In a nutshell, InfoAPI provides a simple API to register placeholders between plugins. 6 | But it is more powerful than just that: 7 | 8 | - Object-oriented placeholder expressions 9 | - Continuously update a template when variables change 10 | - Parametric infos — mathematical operations on info expressions 11 | 12 | ## Developer guide: Templating 13 | 14 | If you let users customize messages in a config, 15 | you can consider formatting the message with InfoAPI. 16 | 17 | Pass the config message into InfoAPI: 18 | 19 | ```php 20 | use SOFe\InfoAPI; 21 | 22 | // $this is the plugin main 23 | $player->sendMessage(InfoAPI::render($this, $this->getConfig()->get("format"), [ 24 | "arg" => $arg, 25 | ], $player)); 26 | ``` 27 | 28 | - "message" is the config key for the message template 29 | - The args array are the base variables for the template. 30 | The types of the variables must be one of the default types 31 | or provided by another plugin through `InfoAPI::addKind`. 32 | - `$player` is optional. 33 | It tells InfoAPI how to localize the message better, 34 | e.g. by formatting for the player's language. 35 | 36 | ### Advanced: Continuous templating 37 | 38 | You can create a template and watch for changes using the `renderContinuous` API: 39 | 40 | ```php 41 | use SOFe\AwaitGenerator\Await; 42 | use SOFe\InfoAPI; 43 | 44 | Await::f2c(function() use($player) { 45 | $traverser = InfoAPI::renderContinuous($this, $this->getConfig()->get("format"), [ 46 | "arg" => $arg, 47 | ], $player); 48 | 49 | while(yield from $traverser->next($message)) { 50 | $player->sendPopup($message); 51 | } 52 | }); 53 | ``` 54 | 55 | ## Developer guide: Register mapping 56 | 57 | A mapping converts one info to another, 58 | e.g. `money` converts a player to the amount of money in `{player money}`. 59 | You can register your own mappings 60 | so that your plugin as well as other plugins using InfoAPI 61 | can use this info in the template. 62 | 63 | For example, to provide the money of an online player: 64 | 65 | ```php 66 | InfoAPI::addMapping( 67 | $this, "myplugin.money", 68 | fn(Player $player) : ?int => $this->getMoney($player), 69 | ); 70 | ``` 71 | 72 | The source and return types must be a default or `InfoAPI::addKind` types. 73 | 74 | ### Advanced: Register continuous mapping 75 | 76 | You can additionally provide a `watchChanges` closure, 77 | which returns a [traverser](https://sof3.github.io/await-generator/master/async-iterators.html) 78 | that yields a value when a change is detected. 79 | The [pmevent](https://github.com/SOF3/pmevent) library may help 80 | with building traversers from events: 81 | 82 | ```php 83 | InfoAPI::addMapping( 84 | $this, "myplugin.money", 85 | fn(Player $player) : ?int => $this->getMoney($player), 86 | watchChanges: fn(Player $player) => Events::watch( 87 | $this, MoneyChangeEvent::class, $player->getName(), 88 | fn(MoneyChangeEvent $event) => $event->getPlayer()->getName(), 89 | )->asGenerator(), 90 | ); 91 | ``` 92 | 93 | ## Developer guide: Install InfoAPI 94 | 95 | > If you are not developing a plugin, 96 | > you do **not** need to install InfoAPI yourself. 97 | > Plugins should have included InfoAPI in their phar release. 98 | 99 | InfoAPI v2 is a virion library using virion 3.1. 100 | Virion 3.1 uses composer to install libraries: 101 | 102 | 1. Include the InfoAPI virion by adding sof3/infoapi in your composer.json: 103 | 104 | ```json 105 | { 106 | "require": { 107 | "sof3/infoapi": "^2" 108 | } 109 | } 110 | ``` 111 | 112 | You can place this file next to your plugin.yml. 113 | Installing composer is recommended but not required. 114 | 115 | 2. Build your plugin with the InfoAPI virion using [pharynx](https://github.com/SOF3/pharynx). 116 | You can test it on a server using the custom start.cmd/start.sh provided by pharynx. 117 | 118 | 3. Use the [pharynx GitHub action](https://github.com/SOf3/timer-pmmp/blob/master/.github/workflows/ci.yml) 119 | to integrate with Poggit. 120 | Remember to gitignore your vendor directory so that you don't push it to GitHub. 121 | 122 | ## User guide: Writing a template 123 | 124 | InfoAPI replaces expressions inside `{}` with variables. 125 | For example, if a chat plugin provides two variables: 126 | 127 | - `sender`: the player who sent chat (SOFe) 128 | - `message`: the chat message 129 | the following will become something like ` Hello world` 130 | if the plugin provides `sender` and `message` ("Hello world")for the template: 131 | 132 | ``` 133 | <{sender}> {message} 134 | ``` 135 | 136 | Color codes are default variables. 137 | Instead of writing §1 §b etc, you could also write: 138 | 139 | ``` 140 | {aqua}<{sender}> {white}{message} 141 | ``` 142 | 143 | You can get more detailed info for a variable. 144 | For example, to get the coordinates of a player `player`: 145 | 146 | ``` 147 | {player} is at ({player x}, {player y}, {player z}). 148 | ``` 149 | 150 | Writing `{` directly will cause error without a matching `}`. 151 | If you want to write a `{`/`}` that is not part of an expression, write twice instead: 152 | 153 | ``` 154 | hello {{world}}. 155 | ``` 156 | 157 | This will become 158 | 159 | ``` 160 | hello {world}. 161 | ``` 162 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sof3/infoapi", 3 | "type": "library", 4 | "require": { 5 | "pocketmine/pocketmine-mp": "^5.0.0", 6 | "sof3/await-generator": "^3.6.1", 7 | "sof3/pmevent": "^0.0.2", 8 | "sof3/zleep": "^0.1.0", 9 | "php": "^8.1" 10 | }, 11 | "require-dev": { 12 | "friendsofphp/php-cs-fixer": "^3.20", 13 | "phpstan/extension-installer": "^1.1.0", 14 | "phpstan/phpstan": "^1.10.0", 15 | "phpstan/phpstan-phpunit": "^1.0.0", 16 | "phpunit/phpunit": "^9.5.0", 17 | "sof3/pharynx": "^0.3.4" 18 | }, 19 | "license": "Apache-2.0", 20 | "autoload": { 21 | "classmap": ["shared", "lib"] 22 | }, 23 | "autoload-dev": { 24 | "classmap": ["tests"] 25 | }, 26 | "extra": { 27 | "virion": { 28 | "spec": "3.1", 29 | "namespace-root": "SOFe\\InfoAPI", 30 | "shared-namespace-root": "Shared\\SOFe\\InfoAPI" 31 | } 32 | }, 33 | "scripts": { 34 | "fmt": "vendor/bin/php-cs-fixer fix", 35 | "analyze": "vendor/bin/phpstan", 36 | "baseline": "vendor/bin/phpstan --generate-baseline --allow-empty-baseline", 37 | "test": [ 38 | "vendor/bin/pharynx -s shared -s lib -o pharynx-tmp-src # drop trailing args", 39 | "vendor/bin/phpunit tests --bootstrap tests/autoload-bootstrap.php", 40 | "rm -r pharynx-tmp-src # drop trailing args" 41 | ], 42 | "all": [ 43 | "composer install --ignore-platform-reqs", 44 | "composer fmt", 45 | "composer analyze", 46 | "composer test" 47 | ], 48 | "gen-doc": [ 49 | "mkdir doc/gen || true", 50 | "php dump-infos.php >doc/gen/defaults.json" 51 | ] 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "phpstan/extension-installer": true 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /gen 3 | /target 4 | /node_modules 5 | -------------------------------------------------------------------------------- /doc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "doc" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.69" 8 | console_error_panic_hook = "0.1.7" 9 | defy = "0.1.5" 10 | futures = "0.3.28" 11 | gloo = { version = "0.9.0", features = ["net"] } 12 | log = "0.4.17" 13 | serde = {version = "1.0.181", features = ["derive"]} 14 | serde_json = "1.0.104" 15 | wasm-logger = "0.2.0" 16 | web-sys = "0.3.64" 17 | yew = { version = "0.20.0", features = ["csr"] } 18 | yew-hooks = "0.2.0" 19 | yew-router = "0.17.0" 20 | -------------------------------------------------------------------------------- /doc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /doc/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doc", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "license": "Apache-2.0", 8 | "dependencies": { 9 | "bulma": "^0.9.4" 10 | } 11 | }, 12 | "node_modules/bulma": { 13 | "version": "0.9.4", 14 | "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", 15 | "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /doc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "Apache-2.0", 3 | "dependencies": { 4 | "bulma": "^0.9.4" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /doc/src/components.rs: -------------------------------------------------------------------------------- 1 | mod expr; 2 | pub use expr::Expression; 3 | 4 | mod modal; 5 | pub use modal::Modal; 6 | 7 | mod editable_select; 8 | pub use editable_select::EditableSelect; 9 | -------------------------------------------------------------------------------- /doc/src/components/editable_select.rs: -------------------------------------------------------------------------------- 1 | use defy::defy; 2 | use yew::prelude::*; 3 | 4 | use crate::util::state_callback; 5 | 6 | #[function_component] 7 | pub fn EditableSelect(props: &Props) -> Html { 8 | let is_open = use_state(|| false); 9 | let input_focused = use_state(|| false); 10 | 11 | let selection = use_state(|| props.default); 12 | let set_select = Callback::from({ 13 | let selection = selection.clone(); 14 | let is_open = is_open.clone(); 15 | let key_cb = props.on_change.clone(); 16 | let options = props.options.clone(); 17 | move |i| { 18 | selection.set(i); 19 | is_open.set(false); 20 | key_cb.emit(options.get(i).expect("invalid selection").0.clone()); 21 | } 22 | }); 23 | 24 | let user_input = use_state(String::new); 25 | let input_node_ref = use_node_ref(); 26 | let update_user_input = { 27 | let user_input = user_input.clone(); 28 | let input_node_ref = input_node_ref.clone(); 29 | Callback::from(move |()| { 30 | user_input.set( 31 | input_node_ref 32 | .cast::() 33 | .unwrap() 34 | .value(), 35 | ) 36 | }) 37 | }; 38 | 39 | let selected_value = props 40 | .options 41 | .get(*selection) 42 | .map_or_else(String::new, |(_, s)| s.clone()); 43 | 44 | defy! { 45 | div(class = classes!["dropdown", is_open.then(|| "is-active")]) { 46 | div(class = "dropdown-trigger") { 47 | button( 48 | class = classes![props.button_class.clone(), "button"], 49 | aria-haspopup = "true", 50 | aria-controls = "dropdown-menu", 51 | onfocusin = state_callback(&is_open, true), 52 | onfocusout = state_callback(&is_open, false), 53 | ) { 54 | input( 55 | class = classes![props.input_class.clone(), "input", "is-borderless"], 56 | value = if *input_focused { user_input.to_string() } else { selected_value.clone() }, 57 | placeholder = selected_value.clone(), 58 | ref = input_node_ref.clone(), 59 | onfocus = state_callback(&input_focused, true), 60 | onblur = state_callback(&input_focused, false), 61 | oninput = update_user_input.reform(|_| ()), 62 | onchange = update_user_input.reform(|_| ()), 63 | ); 64 | } 65 | } 66 | 67 | div(class = "dropdown-menu", role = "menu") { 68 | div(class = "dropdown-content") { 69 | for (i, (_, option)) in props.options.iter().enumerate() { 70 | if option.to_lowercase().contains(user_input.to_lowercase().as_str()) { 71 | a(href = "#", class = "dropdown-item", onclick = set_select.reform(move |_| i)) { 72 | + option; 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | #[derive(PartialEq, Properties)] 83 | pub struct Props { 84 | pub options: Vec<(K, String)>, 85 | pub on_change: Callback, 86 | 87 | #[prop_or_default] 88 | pub default: usize, 89 | #[prop_or_default] 90 | pub button_class: Classes, 91 | #[prop_or_default] 92 | pub input_class: Classes, 93 | } 94 | -------------------------------------------------------------------------------- /doc/src/components/expr.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use defy::defy; 4 | use yew::prelude::*; 5 | use yew_hooks::use_clipboard; 6 | 7 | use crate::{ 8 | data::{Data, KindId, MappingDef}, 9 | util::set_state, 10 | PluginFilter, 11 | }; 12 | 13 | #[function_component] 14 | pub fn Expression(props: &Props) -> Html { 15 | let path = use_state(Vec::::new); 16 | let terminal_kind = match path.last() { 17 | Some(step) => &step.mapping.target_kind, 18 | None => &props.source_kind, 19 | }; 20 | 21 | let selected_mapping = use_state(|| None::); 22 | 23 | let push_mapping = Callback::from({ 24 | let path = path.clone(); 25 | let selected_mapping = selected_mapping.clone(); 26 | 27 | move |step| { 28 | let mut path_vec = (*path).clone(); 29 | path_vec.push(step); 30 | path.set(path_vec); 31 | selected_mapping.set(None); 32 | } 33 | }); 34 | 35 | let truncate_steps = Callback::from({ 36 | let path = path.clone(); 37 | let selected_mapping = selected_mapping.clone(); 38 | 39 | move |i: Option| { 40 | let mut path_vec = (*path).clone(); 41 | path_vec.truncate(i.map_or(0, |i| i + 1)); 42 | path.set(path_vec); 43 | selected_mapping.set(None); 44 | } 45 | }); 46 | 47 | let step_strings: Vec<_> = path 48 | .iter() 49 | .map(|step| step.minified_name.as_str()) 50 | .collect(); 51 | let template_string = format!("{{{}}}", step_strings.join(" ")); 52 | 53 | let clipboard = use_clipboard(); 54 | 55 | defy! { 56 | div(class = "level") { 57 | label(class = "label is-medium") { 58 | + "Build your expression"; 59 | } 60 | 61 | div(class = "level") { 62 | input( 63 | class = "input", 64 | type = "text", readonly = true, 65 | value = template_string.clone(), 66 | ); 67 | 68 | button(class = "button", onclick = Callback::from({ 69 | let clipboard = clipboard.clone(); 70 | let template_string = template_string.clone(); 71 | move |_| clipboard.write_text(template_string.clone()) 72 | })) { 73 | span(class = "icon") { 74 | i(class = "mdi mdi-content-copy"); 75 | } 76 | span { 77 | + "Copy"; 78 | } 79 | } 80 | } 81 | } 82 | 83 | div(class = "box") { 84 | nav(class = "breadcrumb", aria-label = "breadcrumbs") { 85 | ul { 86 | StepButton( 87 | name = "", icon = Some(classes!["mdi-play"]), 88 | reset = truncate_steps.reform(|_| None), 89 | ); 90 | 91 | for (i, step) in path.iter().enumerate() { 92 | StepButton( 93 | name = step.minified_name.clone(), 94 | reset = truncate_steps.reform(move |_| Some(i)), 95 | ); 96 | } 97 | } 98 | } 99 | } 100 | 101 | div(class = "box") { 102 | h2(class = "heading") { + "Transform"; } 103 | 104 | div(class = "columns") { 105 | div(class = "column") { 106 | MappingList( 107 | kind = terminal_kind.clone(), 108 | plugins = props.plugins.clone(), 109 | schema = props.schema.clone(), 110 | choose_mapping = set_state(&selected_mapping).reform(Some), 111 | ); 112 | } 113 | 114 | div(class = "column") { 115 | if let Some(mapping) = &*selected_mapping { 116 | article { 117 | div(class = "message-header") { 118 | + mapping.mapping.name.0.clone(); 119 | } 120 | div(class = "message-body") { 121 | p { + &mapping.mapping.help; } 122 | button(class = "button is-primary", onclick = push_mapping.reform({ 123 | let mapping = mapping.clone(); 124 | move |_| mapping.clone() 125 | })) { 126 | span(class = "icon") { 127 | i(class = "mdi mdi-plus"); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } 138 | 139 | #[derive(PartialEq, Properties)] 140 | pub struct Props { 141 | pub source_kind: KindId, 142 | pub schema: Data, 143 | pub plugins: PluginFilter, 144 | } 145 | 146 | #[function_component] 147 | fn StepButton(props: &StepProps) -> Html { 148 | defy! { 149 | li { 150 | button(class = classes!["button", "is-link"], onclick = props.reset.reform(|_| ())) { 151 | if let Some(icon) = &props.icon { 152 | span(class = "icon") { 153 | i(class = classes!["mdi", icon.clone()]); 154 | } 155 | } 156 | 157 | if !props.name.is_empty() { 158 | span { 159 | + &props.name; 160 | } 161 | } 162 | } 163 | } 164 | } 165 | } 166 | 167 | #[derive(PartialEq, Properties)] 168 | struct StepProps { 169 | #[prop_or_default] 170 | name: String, 171 | #[prop_or_default] 172 | icon: Option, 173 | reset: Callback<()>, 174 | } 175 | 176 | #[function_component] 177 | fn MappingList(props: &MappingListProps) -> Html { 178 | let Some(mappings) = props.schema.mappings.get(&props.kind) else { 179 | return defy! { 180 | span { 181 | + "No mappings"; 182 | } 183 | }; 184 | }; 185 | 186 | defy! { 187 | for mapping in mappings.values() { 188 | if mapping.metadata.alias_of.is_none() && props.plugins.contains(mapping.metadata.source_plugin.as_ref()) { 189 | let minified_name = mapping.name.minify(mappings.keys()); 190 | 191 | button(class = "button", onclick = props.choose_mapping.reform({ 192 | let mapping = mapping.clone(); 193 | move |_| Step { 194 | mapping: mapping.clone(), 195 | minified_name: minified_name.clone(), 196 | } 197 | })) { 198 | + mapping.name.last(); 199 | } 200 | } 201 | } 202 | } 203 | } 204 | 205 | #[derive(PartialEq, Properties)] 206 | struct MappingListProps { 207 | kind: KindId, 208 | plugins: PluginFilter, 209 | schema: Data, 210 | choose_mapping: Callback, 211 | } 212 | 213 | #[derive(Clone)] 214 | struct Step { 215 | mapping: Rc, 216 | minified_name: String, 217 | } 218 | -------------------------------------------------------------------------------- /doc/src/components/modal.rs: -------------------------------------------------------------------------------- 1 | use defy::defy; 2 | use yew::prelude::*; 3 | 4 | #[function_component] 5 | pub fn Modal(props: &Props) -> Html { 6 | let is_open = use_state(|| false); 7 | let button_cb = |open| { 8 | Callback::from({ 9 | let is_open = is_open.clone(); 10 | move |_| is_open.set(open) 11 | }) 12 | }; 13 | 14 | defy! { 15 | div(onclick = button_cb(true)) { 16 | +props.button.clone(); 17 | } 18 | 19 | if *is_open { 20 | div(class = "modal is-active") { 21 | div(class = "modal-background", onclick = button_cb(false)); 22 | div(class = "modal-content") { 23 | + props.children.clone(); 24 | } 25 | button(class = "modal-close is-large", aria-label = "close", onclick = button_cb(false)); 26 | } 27 | } 28 | } 29 | } 30 | 31 | #[derive(PartialEq, Properties)] 32 | pub struct Props { 33 | pub button: Html, 34 | pub children: Children, 35 | } 36 | -------------------------------------------------------------------------------- /doc/src/data.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, BTreeSet}, 3 | ops, 4 | rc::Rc, 5 | }; 6 | 7 | use anyhow::Context as _; 8 | use futures::{stream::FuturesUnordered, StreamExt}; 9 | use gloo::net::http; 10 | use serde::Deserialize; 11 | 12 | pub const SOURCE_LIST_HEADER: &str = "=== InfoAPI schema list ==="; 13 | 14 | async fn fetch_sources() -> anyhow::Result> { 15 | let resp = http::Request::get("static/sources.txt") 16 | .send() 17 | .await 18 | .context("HTTP")? 19 | .text() 20 | .await 21 | .context("parse result")?; 22 | 23 | let mut lines = resp.split('\n'); 24 | 25 | let Some(header) = lines.next() else { 26 | anyhow::bail!("invalid empty response") 27 | }; 28 | anyhow::ensure!( 29 | header == SOURCE_LIST_HEADER, 30 | "sources.txt is not a schema list" 31 | ); 32 | 33 | Ok(lines 34 | .filter(|line| !line.is_empty() && !line.starts_with('#')) 35 | .map(str::to_string) 36 | .collect()) 37 | } 38 | 39 | #[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] 40 | pub struct KindId(pub String); 41 | 42 | #[derive(Debug, Deserialize)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct KindDef { 45 | #[serde(default)] 46 | pub help: String, 47 | #[serde(default)] 48 | pub can_display: bool, 49 | #[serde(default)] 50 | pub metadata: KnownKindMetadata, 51 | } 52 | 53 | #[derive(Debug, Default, Deserialize)] 54 | pub struct KnownKindMetadata { 55 | #[serde(default, rename = "infoapi/is-root")] 56 | pub is_root: bool, 57 | #[serde(default, rename = "infoapi:browser/template-name")] 58 | pub template_name: Option, 59 | #[serde(default, rename = "infoapi/source-plugin")] 60 | pub source_plugin: Option, 61 | } 62 | 63 | #[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] 64 | pub struct MappingName(pub String); 65 | impl MappingName { 66 | pub fn last(&self) -> &str { 67 | self.0.split(':').next_back().expect("split is nonempty") 68 | } 69 | 70 | pub fn minify<'t>(&'t self, others: impl Iterator) -> String { 71 | let my_last = self.last(); 72 | let mut collisions: Vec> = others 73 | .filter(|other| other.0 != self.0 && other.last() == my_last) 74 | .map(|other| other.0.split(':').collect()) 75 | .collect(); 76 | 77 | let mut my_pieces: Vec<_> = self.0.split(':').collect(); 78 | _ = my_pieces.pop(); 79 | let mut use_prefixes = 0; 80 | 81 | fn has_subprefix(haystack: &[&str], needle: &[&str]) -> bool { 82 | let mut needle = needle.iter().copied().peekable(); 83 | for &haystack_part in haystack { 84 | let Some(&needle_part) = needle.peek() else { 85 | // needle is fully removed 86 | return true; 87 | }; 88 | if haystack_part == needle_part { 89 | _ = needle.next(); 90 | } 91 | } 92 | 93 | needle.next().is_none() 94 | } 95 | 96 | while !collisions.is_empty() { 97 | use_prefixes += 1; 98 | if use_prefixes > my_pieces.len() { 99 | // subset of another, cannot fully qualify 100 | break; 101 | } 102 | 103 | collisions.retain(|other| { 104 | has_subprefix(&other[..other.len() - 1], &my_pieces[..use_prefixes]) 105 | }) 106 | } 107 | 108 | let mut out = my_pieces; 109 | out.truncate(use_prefixes); 110 | out.push(my_last); 111 | out.join(":") 112 | } 113 | } 114 | 115 | #[derive(Debug, Deserialize)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct MappingDef { 118 | pub source_kind: KindId, 119 | pub target_kind: KindId, 120 | pub name: MappingName, 121 | pub is_implicit: bool, 122 | pub parameters: Vec, 123 | pub mutable: bool, 124 | pub help: String, 125 | #[serde(default)] 126 | pub metadata: KnownMappingMetadata, 127 | } 128 | 129 | #[derive(Debug, Default, Deserialize)] 130 | pub struct KnownMappingMetadata { 131 | #[serde(default, rename = "infoapi/source-plugin")] 132 | pub source_plugin: Option, 133 | #[serde(default, rename = "infoapi/alias-of")] 134 | pub alias_of: Option, 135 | } 136 | 137 | #[derive(Debug, Deserialize)] 138 | pub struct ParamName(pub String); 139 | 140 | #[derive(Debug, Deserialize)] 141 | #[serde(rename_all = "camelCase")] 142 | pub struct ParamDef { 143 | pub name: ParamName, 144 | pub kind: KindId, 145 | pub multi: bool, 146 | pub optional: bool, 147 | } 148 | 149 | #[derive(Deserialize)] 150 | pub struct SourceSchema { 151 | kinds: BTreeMap, 152 | mappings: Vec, 153 | } 154 | 155 | #[derive(Clone)] 156 | pub struct Data(Rc); 157 | 158 | impl PartialEq for Data { 159 | fn eq(&self, other: &Self) -> bool { 160 | Rc::ptr_eq(&self.0, &other.0) 161 | } 162 | } 163 | 164 | impl ops::Deref for Data { 165 | type Target = All; 166 | 167 | fn deref(&self) -> &Self::Target { 168 | &self.0 169 | } 170 | } 171 | 172 | #[derive(Default)] 173 | pub struct All { 174 | pub kinds: BTreeMap, 175 | pub mappings: BTreeMap>>, 176 | pub known_plugins: BTreeSet, 177 | pub errors: Vec, 178 | } 179 | 180 | impl Extend> for All { 181 | fn extend>>(&mut self, iter: T) { 182 | for schema in iter { 183 | match schema { 184 | Ok(schema) => { 185 | self.kinds.extend(schema.kinds); 186 | for mapping in schema.mappings { 187 | if let Some(plugin) = &mapping.metadata.source_plugin { 188 | self.known_plugins.insert(plugin.clone()); 189 | } 190 | 191 | self.mappings 192 | .entry(mapping.source_kind.clone()) 193 | .or_default() 194 | .insert(mapping.name.clone(), Rc::new(mapping)); 195 | } 196 | } 197 | Err(err) => self.errors.push(err), 198 | } 199 | } 200 | } 201 | } 202 | 203 | async fn fetch_source(source_url: &str) -> anyhow::Result { 204 | http::Request::get(source_url) 205 | .send() 206 | .await 207 | .context("HTTP")? 208 | .json::() 209 | .await 210 | .context("parse results as JSON") 211 | } 212 | 213 | pub async fn all() -> anyhow::Result { 214 | let sources = fetch_sources().await.context("fetch sources list")?; 215 | 216 | let futures: FuturesUnordered<_> = sources 217 | .into_iter() 218 | .map(|source| async move { 219 | fetch_source(&source) 220 | .await 221 | .with_context(|| format!("fetch source from {source}")) 222 | }) 223 | .collect(); 224 | let schema: All = futures.collect().await; 225 | anyhow::ensure!(!schema.kinds.is_empty(), "all sources cannot be loaded"); 226 | Ok(Data(Rc::new(schema))) 227 | } 228 | -------------------------------------------------------------------------------- /doc/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow, 3 | cell::RefCell, 4 | collections::{hash_map, HashMap}, 5 | hash::Hash, 6 | ops, 7 | rc::Rc, 8 | }; 9 | 10 | use defy::defy; 11 | use yew::{prelude::*, suspense}; 12 | 13 | mod components; 14 | mod data; 15 | mod util; 16 | 17 | #[function_component] 18 | pub fn App() -> Html { 19 | defy! { 20 | Suspense(fallback = fallback()) { 21 | Main; 22 | } 23 | } 24 | } 25 | 26 | #[function_component] 27 | fn Main() -> HtmlResult { 28 | let schema = suspense::use_future(data::all)?; 29 | let schema = match &*schema { 30 | Ok(schema) => schema, 31 | Err(err) => { 32 | return Ok(defy! { 33 | div(class = "section") { 34 | div(class = "container") { 35 | article(class = "message is-danger") { 36 | div(class = "message-header") { 37 | + "Error"; 38 | } 39 | 40 | div(class = "message-body") { 41 | pre { 42 | + format!("{err:?}"); 43 | } 44 | } 45 | } 46 | } 47 | } 48 | }) 49 | } 50 | }; 51 | 52 | let plugin_filter = use_state(|| { 53 | schema 54 | .known_plugins 55 | .iter() 56 | .cloned() 57 | .collect::() 58 | }); 59 | let toggle_plugin = |plugin: &str| { 60 | let plugin_filter = plugin_filter.clone(); 61 | let plugin = plugin.to_string(); 62 | Callback::from(move |()| { 63 | let mut plugin_filter_map = (*plugin_filter).clone(); 64 | plugin_filter_map.toggle(plugin.clone()); 65 | plugin_filter.set(plugin_filter_map); 66 | }) 67 | }; 68 | 69 | let source_kind = use_state(|| { 70 | let (kind, _) = schema 71 | .kinds 72 | .iter() 73 | .find(|(_, def)| def.metadata.is_root) 74 | .expect("no kinds"); 75 | kind.clone() 76 | }); 77 | 78 | Ok(defy! { 79 | if !schema.errors.is_empty() { 80 | div(class = "fixed-corner is-pulled-right mx-3 my-3") { 81 | components::Modal(button = defy! { 82 | button(class = "button is-borderless") { 83 | span(class = "icon has-text-danger is-large") { 84 | i(class = "mdi mdi-48px mdi-alert-circle"); 85 | } 86 | } 87 | }) { 88 | article(class = "message is-danger") { 89 | div(class = "message-header") { 90 | + "Error fetching schema for some plugins:"; 91 | } 92 | 93 | for err in &schema.errors { 94 | div(class = "message-body") { 95 | pre { 96 | + format!("{err:?}"); 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | div(class = "section") { 106 | div(class = "container") { 107 | h1(class = "title") { 108 | + "InfoAPI template editor"; 109 | } 110 | 111 | div(class = "field") { 112 | } 113 | 114 | div(class = "field") { 115 | label(class = "label is-medium") { 116 | + "Which template are you editing?"; 117 | } 118 | 119 | div(class = "level") { 120 | components::EditableSelect( 121 | options = schema.kinds.iter().filter_map(|(k, v)| { 122 | let k = k.clone(); 123 | 124 | if !v.metadata.is_root { 125 | return None; 126 | } 127 | 128 | if let Some(template) = &v.metadata.template_name { 129 | return Some((k, template.clone())); 130 | } 131 | 132 | Some((k, v.help.clone())) 133 | }).collect::>(), 134 | on_change = Callback::from({ 135 | let source_kind = source_kind.clone(); 136 | move |kind| source_kind.set(kind) 137 | }), 138 | button_class = "is-medium", 139 | input_class = "is-medium", 140 | ); 141 | 142 | components::Modal(button = defy! { 143 | button(class = "button is-info is-small") { 144 | span(class = "icon") { 145 | i(class = "mdi mdi-wrench"); 146 | } 147 | span { + "Select plugins"; } 148 | } 149 | }) { 150 | div(class = "box panel") { 151 | p(class = "panel-heading") { 152 | + "Select plugins"; 153 | } 154 | div(class = "panel-block") { 155 | p(class = "is-size-7") { 156 | + "Uncheck plugins here to hide them from search results."; 157 | } 158 | } 159 | 160 | for plugin in &schema.known_plugins { 161 | a(class = "panel-block", onclick = toggle_plugin(plugin).reform(|_| ())) { 162 | span(class = "icon") { 163 | if plugin_filter.contains(Some(plugin)) { 164 | i(class = "mdi mdi-check"); 165 | } else { 166 | i(class = "mdi mdi-cancel"); 167 | } 168 | } 169 | + plugin.clone(); 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | 177 | div(class = "field") { 178 | components::Expression( 179 | schema = schema.clone(), 180 | source_kind = (*source_kind).clone(), 181 | plugins = (*plugin_filter).clone(), 182 | ); 183 | } 184 | } 185 | } 186 | }) 187 | } 188 | 189 | #[derive(Debug, Clone)] 190 | pub struct PluginFilter(Rc>>); 191 | impl PartialEq for PluginFilter { 192 | fn eq(&self, other: &Self) -> bool { 193 | Rc::ptr_eq(&self.0, &other.0) 194 | } 195 | } 196 | impl FromIterator for PluginFilter { 197 | fn from_iter>(iter: T) -> Self { 198 | let map = iter.into_iter().map(|k| (k, ())).collect(); 199 | Self(Rc::new(RefCell::new(map))) 200 | } 201 | } 202 | impl PluginFilter { 203 | fn contains(&self, source_plugin: Option>) -> bool 204 | where 205 | String: borrow::Borrow, 206 | { 207 | match &source_plugin { 208 | Some(plugin) => self.0.borrow().contains_key(plugin), 209 | None => true, 210 | } 211 | } 212 | 213 | fn toggle(&mut self, plugin: String) { 214 | let mut map = self.0.borrow_mut(); 215 | match map.entry(plugin) { 216 | hash_map::Entry::Occupied(entry) => entry.remove(), 217 | hash_map::Entry::Vacant(entry) => { 218 | entry.insert(()); 219 | } 220 | } 221 | } 222 | } 223 | 224 | fn fallback() -> Html { 225 | defy! { 226 | section(class = "hero is-fullheight") { 227 | div(class = "hero-body") { 228 | p(class = "title has-text") { 229 | + "Loading\u{2026}"; 230 | } 231 | } 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /doc/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | console_error_panic_hook::set_once(); 3 | wasm_logger::init(wasm_logger::Config::new(log::Level::Debug)); 4 | yew::Renderer::::new().render(); 5 | } 6 | -------------------------------------------------------------------------------- /doc/src/util.rs: -------------------------------------------------------------------------------- 1 | use yew::{Callback, UseStateHandle}; 2 | 3 | pub fn state_callback( 4 | handle: &UseStateHandle, 5 | value: T, 6 | ) -> Callback { 7 | let handle = handle.clone(); 8 | Callback::from(move |_| handle.set(value.clone())) 9 | } 10 | 11 | pub fn set_state(handle: &UseStateHandle) -> Callback { 12 | let handle = handle.clone(); 13 | Callback::from(move |value| handle.set(value)) 14 | } 15 | -------------------------------------------------------------------------------- /doc/static/sources.txt: -------------------------------------------------------------------------------- 1 | === InfoAPI schema list === 2 | 3 | # Default InfoAPI 4 | # Run `composer gen-doc` to create this file in dev env. 5 | gen/defaults.json 6 | -------------------------------------------------------------------------------- /doc/style.sass: -------------------------------------------------------------------------------- 1 | @import "node_modules/bulma/bulma.sass" 2 | 3 | .fixed-corner 4 | position: fixed 5 | 6 | &.is-pulled-bottom 7 | bottom: 0 8 | &.is-pulled-right 9 | right: 0 10 | 11 | .is-borderless 12 | border: none 13 | -------------------------------------------------------------------------------- /dump-infos.php: -------------------------------------------------------------------------------- 1 | registries, ...$indices->fallbackRegistries); 12 | echo json_encode($doc, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 13 | -------------------------------------------------------------------------------- /lib/api.php: -------------------------------------------------------------------------------- 1 | $metadata 40 | */ 41 | public static function addKind( 42 | Plugin $plugin, 43 | string $kind, 44 | Closure $display, 45 | ?string $shortName = null, 46 | ?string $help = null, 47 | array $metadata = [], 48 | ) : void { 49 | $metadata[MappingMetadataKeys::SOURCE_PLUGIN] = $plugin->getName(); 50 | 51 | ReflectUtil::addClosureDisplay(self::defaultIndices($plugin), $kind, $display); 52 | 53 | if ($shortName !== null || $help !== null) { 54 | self::defaultIndices($plugin)->registries->kindMetas->register(new KindMeta($kind, $shortName, $help, $metadata)); 55 | } 56 | } 57 | 58 | /** 59 | * @param string|string[] $aliases 60 | * @param array $metadata 61 | */ 62 | public static function addMapping( 63 | Plugin $plugin, 64 | string|array $aliases, 65 | Closure $closure, 66 | ?Closure $watchChanges = null, 67 | bool $isImplicit = false, 68 | string $help = "", 69 | array $metadata = [], 70 | ) : void { 71 | $metadata[MappingMetadataKeys::SOURCE_PLUGIN] = $plugin->getName(); 72 | 73 | ReflectUtil::addClosureMapping( 74 | indices: self::defaultIndices($plugin), 75 | namespace: strtolower($plugin->getName()), 76 | names: is_array($aliases) ? $aliases : [$aliases], 77 | closure: $closure, 78 | watchChanges: $watchChanges, 79 | isImplicit: $isImplicit, 80 | help: $help, 81 | metadata: $metadata, 82 | ); 83 | } 84 | 85 | /** @var array */ 86 | private static array $templates = []; 87 | 88 | public static function parseAst(string $template, bool $cache = true) : Ast\Template { 89 | if ($cache) { 90 | return Ast\Parse::parse($template); 91 | } 92 | 93 | return self::$templates[$template] ??= Ast\Parse::parse($template); 94 | } 95 | 96 | private const ANONYMOUS_KIND = "infoapi/anonymous"; 97 | 98 | /** 99 | * @param array $context 100 | */ 101 | public static function render(Plugin $plugin, string $template, array $context, ?CommandSender $sender = null, bool $cacheTemplate = true) : string { 102 | $group = self::renderTemplate($plugin, new Template\Get, $template, $context, $sender, $cacheTemplate); 103 | return $group->get(); 104 | } 105 | 106 | /** 107 | * @param array $context 108 | * @return Traverser 109 | */ 110 | public static function renderContinuous(Plugin $plugin, string $template, array $context, ?CommandSender $sender = null, bool $cacheTemplate = true) : Traverser { 111 | $group = self::renderTemplate($plugin, new Template\Watch, $template, $context, $sender, $cacheTemplate); 112 | return $group->watch(); 113 | } 114 | 115 | /** 116 | * @template R of Template\RenderedElement 117 | * @template G of Template\RenderedGroup 118 | * @template T of GetOrWatch 119 | * @param T $getOrWatch 120 | * @param array $context 121 | * @return G 122 | */ 123 | private static function renderTemplate(Plugin $plugin, GetOrWatch $getOrWatch, string $template, array $context, ?CommandSender $sender, bool $cacheTemplate) : RenderedGroup { 124 | $ast = self::parseAst($template, cache: $cacheTemplate); 125 | 126 | /** @var Registry $localMappings */ 127 | $localMappings = new RegistryImpl; 128 | $indices = self::defaultIndices($plugin)->readonly(); 129 | $indices->namedMappings = $indices->namedMappings->cloned(); 130 | $indices->namedMappings->addLocalRegistry(0, $localMappings); 131 | $indices->implicitMappings = $indices->implicitMappings->cloned(); 132 | $indices->implicitMappings->addLocalRegistry(0, $localMappings); 133 | 134 | $localMappings->register(new Mapping( 135 | qualifiedName: ["infoapi", "baseContext"], 136 | sourceKind: self::ANONYMOUS_KIND, 137 | targetKind: BaseContext::KIND, 138 | isImplicit: true, 139 | parameters: [], 140 | map: fn($_) => Server::getInstance(), 141 | subscribe: null, 142 | help: "Global functions", 143 | metadata: [], 144 | )); 145 | 146 | foreach ($context as $key => $value) { 147 | $standardType = is_object($value) ? get_class($value) : ReflectUtil::getStandardType($value); 148 | $targetKind = $indices->hints->lookup($standardType); 149 | if ($targetKind === null) { 150 | throw new RuntimeException("Cannot determine kind of $key value, with type $standardType"); 151 | } 152 | 153 | $localMappings->register(new Mapping( 154 | qualifiedName: [$key], 155 | sourceKind: self::ANONYMOUS_KIND, 156 | targetKind: $targetKind, 157 | isImplicit: false, 158 | parameters: [], 159 | map: fn($array) => is_array($array) ? $array[$key] : null, 160 | subscribe: null, 161 | help: "", 162 | metadata: [], 163 | )); 164 | } 165 | 166 | $template = Template\Template::fromAst($ast, $indices, self::ANONYMOUS_KIND); 167 | return $template->render($context, $sender, $getOrWatch); 168 | } 169 | 170 | private static ?Indices $indices = null; 171 | 172 | public static function defaultIndices(Plugin $plugin) : Indices { 173 | if (self::$indices === null) { 174 | self::$indices = Indices::withDefaults(new PluginInitContext($plugin), Registries::singletons()); 175 | } 176 | 177 | return self::$indices; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /lib/ast.php: -------------------------------------------------------------------------------- 1 | eof()) { 101 | $elements[] = self::parseElement($parser); 102 | } 103 | 104 | return new Template($elements); 105 | } 106 | 107 | public static function parseElement(StringParser $parser) : Expr|RawText { 108 | if ($parser->readExactText("{{")) { 109 | return new RawText("{", "{{"); 110 | } 111 | if ($parser->readExactText("}}")) { 112 | return new RawText("}", "}}"); 113 | } 114 | if ($parser->readExactText("}")) { 115 | throw $parser->throwSpan("unmatched `}` should be escaped as `}}`"); 116 | } 117 | 118 | $startExprPos = $parser->pos; 119 | if ($parser->readExactText("{")) { 120 | $expr = self::parseExpr($parser, "}"); 121 | $parser->skipWhitespace(); 122 | if (!$parser->readExactText("}")) { 123 | throw $parser->throwSpan("unclosed `{}` or invalid character in expression", start: $startExprPos, end: $parser->pos + 1); 124 | } 125 | return $expr; 126 | } 127 | 128 | $substr = $parser->readUntil("{", "}") ?? $parser->readAll(); 129 | return new RawText($substr, $substr); 130 | } 131 | 132 | public static function parseExpr(StringParser $parser, string ...$terminators) : Expr { 133 | $main = self::parseInfoExpr($parser, null, "|", ...$terminators); 134 | $parser->skipWhitespace(); 135 | 136 | $else = null; 137 | if ($parser->readExactText("|")) { 138 | $else = self::parseExpr($parser, ...$terminators); 139 | } 140 | 141 | return new Expr($main, $else); 142 | } 143 | 144 | public static function parseInfoExpr(StringParser $parser, ?InfoExpr $parent, string ...$terminators) : InfoExpr { 145 | $call = self::parseCall($parser); 146 | $expr = new InfoExpr($parent, $call); 147 | 148 | $parser->skipWhitespace(); 149 | foreach ($terminators as $terminator) { 150 | if ($parser->peek(strlen($terminator)) === $terminator) { 151 | return $expr; 152 | } 153 | } 154 | 155 | return self::parseInfoExpr($parser, $expr, ...$terminators); 156 | } 157 | 158 | public static function parseCall(StringParser $parser) : MappingCall { 159 | $name = self::parseName($parser); 160 | 161 | $args = null; 162 | $parser->skipWhitespace(); 163 | $startArgsPos = $parser->pos; 164 | if ($parser->readExactText("(")) { 165 | $args = []; 166 | while (!$parser->readExactText(")")) { 167 | $args[] = self::parseArg($parser); 168 | if (!$parser->readExactText(",")) { 169 | if (!$parser->readExactText(")")) { 170 | throw $parser->throwSpan("multiple arguments must be separated by `,` or terminated with `)`", start: $startArgsPos, end: $parser->pos); 171 | } 172 | break; 173 | } 174 | } 175 | } 176 | return new MappingCall($name, $args); 177 | } 178 | 179 | public static function parseName(StringParser $parser) : QualifiedRef { 180 | $tokens = []; 181 | $parser->skipWhitespace(); 182 | 183 | do { 184 | $token = $parser->readRegexCharset(Mapping::FQN_TOKEN_REGEX_CHARSET); 185 | if (strlen($token) === 0) { 186 | throw $parser->throwSpan("expected mapping name"); 187 | } 188 | $tokens[] = $token; 189 | $hasMore = $parser->readExactText(Mapping::FQN_SEPARATOR); 190 | } while ($hasMore); 191 | 192 | return new QualifiedRef($tokens); 193 | } 194 | 195 | public static function parseArg(StringParser $parser) : Arg { 196 | $parser->skipWhitespace(); 197 | 198 | if ($parser->readExactText("true")) { 199 | return new Arg(null, new JsonValue("true", "true")); 200 | } 201 | if ($parser->readExactText("false")) { 202 | return new Arg(null, new JsonValue("false", "false")); 203 | } 204 | 205 | $argName = null; 206 | 207 | $parser->try(function() use ($parser, &$argName) : bool { 208 | $nameToken = $parser->readRegexCharset(Mapping::FQN_TOKEN_REGEX_CHARSET); 209 | $parser->skipWhitespace(); 210 | 211 | if ($parser->readExactText("=")) { 212 | $argName = $nameToken; 213 | return true; 214 | } 215 | return false; 216 | }); 217 | 218 | return new Arg($argName, self::parseValue($parser)); 219 | } 220 | 221 | private const JSON_STRING_REGEX = <<<'EOS' 222 | "(?>\\(?>["\\\/bfnrt]|u[a-fA-F0-9]{4})|[^"\\\0-\x1F\x7F]+)*" 223 | EOS; 224 | 225 | public static function parseValue(StringParser $parser) : JsonValue|Expr { 226 | $parser->skipWhitespace(); 227 | $startPos = $parser->pos; 228 | 229 | if ($parser->readExactText("true")) { 230 | return new JsonValue("true", "true"); 231 | } 232 | if ($parser->readExactText("false")) { 233 | return new JsonValue("false", "false"); 234 | } 235 | 236 | $num = $parser->readRegexCharset('0-9e\.\-\+'); 237 | if (is_numeric($num)) { 238 | return new JsonValue($num, $num); 239 | } 240 | 241 | if ($parser->peek(1) === '"') { 242 | // Source: https://stackoverflow.com/a/32155765/3990767 243 | $string = $parser->readRegex(self::JSON_STRING_REGEX); 244 | if ($string === "") { 245 | throw $parser->throwSpan("expected JSON string"); 246 | } 247 | try { 248 | $parsed = json_decode($string, false, 1, JSON_THROW_ON_ERROR); 249 | if (!is_string($parsed)) { 250 | throw $parser->throwSpan("expected JSON string", start: $startPos, length: strlen($string)); 251 | } 252 | 253 | return new JsonValue(asString: $parsed, json: $string); 254 | } catch(JsonException $e) { 255 | throw $parser->throwSpan("JSON parse error: {$e->getMessage()}", start: $startPos, length: strlen($string)); 256 | } 257 | } 258 | 259 | return self::parseExpr($parser, ",", ")"); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /lib/context.php: -------------------------------------------------------------------------------- 1 | [] $events 22 | * @param Closure(E): string $interpreter 23 | * @return Traverser 24 | */ 25 | public function watchEvent(array $events, string $key, Closure $interpreter) : Traverser; 26 | 27 | /** 28 | * @return Traverser 29 | */ 30 | public function watchBlock(Position $position) : Traverser; 31 | 32 | /** 33 | * @return Generator 34 | */ 35 | public function sleep(int $ticks) : Generator; 36 | } 37 | 38 | final class PluginInitContext implements InitContext { 39 | public function __construct(private Plugin $plugin) { 40 | } 41 | 42 | public function watchEvent(array $events, string $key, Closure $interpreter) : Traverser { 43 | return Events::watch($this->plugin, $events, $key, $interpreter); 44 | } 45 | 46 | public function watchBlock(Position $position) : Traverser { 47 | return Traverser::fromClosure(function() use ($position) { 48 | $traverser = Blocks::watch($position); 49 | try { 50 | while ($traverser->next($_block)) { 51 | yield null => Traverser::VALUE; 52 | } 53 | } finally { 54 | yield from $traverser->interrupt(); 55 | } 56 | }); 57 | } 58 | 59 | public function sleep(int $ticks) : Generator { 60 | return Zleep::sleepTicks($this->plugin, $ticks); 61 | } 62 | } 63 | 64 | final class MockInitContext implements InitContext { 65 | public function watchEvent(array $events, string $key, Closure $interpreter) : Traverser { 66 | return new Traverser(GeneratorUtil::empty()); 67 | } 68 | 69 | public function watchBlock(Position $position) : Traverser { 70 | return new Traverser(GeneratorUtil::empty()); 71 | } 72 | 73 | public function sleep(int $ticks) : Generator { 74 | return GeneratorUtil::pending(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/defaults/bool.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\BoolInfo::KIND, "Boolean", "A condition that is either true or false", [])); 17 | $indices->registries->displays->register(new Display(Standard\BoolInfo::KIND, fn($value) => is_bool($value) ? ($value ? "true" : "false") : Display::INVALID)); 18 | 19 | ReflectUtil::addClosureMapping( 20 | $indices, "infoapi:bool", ["if"], fn(bool $value, string $then, string $else) : string => $value ? $then : $else, 21 | help: "Resolve to the first argument (\"then\") if the condition is true, otherwise to the second argument (\"else\").", 22 | ); 23 | ReflectUtil::addClosureMapping( 24 | $indices, "infoapi:bool", ["and"], fn(bool $v1, bool $v2) : bool => $v1 && $v2, 25 | help: "Check if both conditions are true", 26 | ); 27 | ReflectUtil::addClosureMapping( 28 | $indices, "infoapi:bool", ["or"], fn(bool $v1, bool $v2) : bool => $v1 || $v2, 29 | help: "Check if either condition is true", 30 | ); 31 | ReflectUtil::addClosureMapping( 32 | $indices, "infoapi:bool", ["xor"], fn(bool $v1, bool $v2) : bool => $v1 !== $v2, 33 | help: "Check if exactly one of the conditions is true", 34 | ); 35 | ReflectUtil::addClosureMapping( 36 | $indices, "infoapi:bool", ["not"], fn(bool $value) : bool => !$value, 37 | help: "Negate the condition", 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/defaults/format.php: -------------------------------------------------------------------------------- 1 | registries->displays->register(new Display(self::KIND, fn($value) : string => is_string($value) ? $value : Display::INVALID)); 21 | 22 | foreach ([ 23 | [TextFormat::BLACK, "black"], 24 | [TextFormat::DARK_BLUE, "darkBlue"], 25 | [TextFormat::DARK_GREEN, "darkGreen"], 26 | [TextFormat::DARK_AQUA, "darkAqua"], 27 | [TextFormat::DARK_RED, "darkRed"], 28 | [TextFormat::DARK_PURPLE, "darkPurple"], 29 | [TextFormat::GOLD, "gold"], 30 | [TextFormat::GRAY, "gray"], 31 | [TextFormat::DARK_GRAY, "darkGray"], 32 | [TextFormat::BLUE, "blue"], 33 | [TextFormat::GREEN, "green"], 34 | [TextFormat::AQUA, "aqua"], 35 | [TextFormat::RED, "red"], 36 | [TextFormat::LIGHT_PURPLE, "lightPurple"], 37 | [TextFormat::YELLOW, "yellow"], 38 | [TextFormat::WHITE, "white"], 39 | [TextFormat::MINECOIN_GOLD, "minecoinGold"], 40 | [TextFormat::MATERIAL_QUARTZ, "materialQuartz"], 41 | [TextFormat::MATERIAL_IRON, "materialIron"], 42 | [TextFormat::MATERIAL_NETHERITE, "materialNetherite"], 43 | [TextFormat::MATERIAL_REDSTONE, "materialRedstone"], 44 | [TextFormat::MATERIAL_COPPER, "materialCopper"], 45 | [TextFormat::MATERIAL_GOLD, "materialGold"], 46 | [TextFormat::MATERIAL_EMERALD, "materialEmerald"], 47 | [TextFormat::MATERIAL_DIAMOND, "materialDiamond"], 48 | [TextFormat::MATERIAL_LAPIS, "materialLapis"], 49 | [TextFormat::MATERIAL_AMETHYST, "materialAmethyst"], 50 | [TextFormat::OBFUSCATED, "obfuscated"], 51 | [TextFormat::BOLD, "bold"], 52 | [TextFormat::STRIKETHROUGH, "strikethrough"], 53 | [TextFormat::UNDERLINE, "underline"], 54 | [TextFormat::ITALIC, "italic"], 55 | [TextFormat::RESET, "reset"], 56 | [TextFormat::EOL, "eol"], 57 | ] as [$code, $name]) { 58 | $indices->registries->mappings->register(new Mapping( 59 | qualifiedName: ["infoapi", "formats", $name], 60 | sourceKind: Standard\BaseContext::KIND, 61 | targetKind: self::KIND, 62 | isImplicit: false, 63 | parameters: [], 64 | map: fn($value) => $code, 65 | subscribe: null, 66 | help: "Format the subsequent text as $name.", 67 | metadata: [], 68 | )); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/defaults/index.php: -------------------------------------------------------------------------------- 1 | Standard\StringInfo::KIND, 27 | "int" => Standard\IntInfo::KIND, 28 | "float" => Standard\FloatInfo::KIND, 29 | "bool" => Standard\BoolInfo::KIND, 30 | Server::class => Standard\BaseContext::KIND, 31 | Player::class => Standard\PlayerInfo::KIND, 32 | Position::class => Standard\PositionInfo::KIND, 33 | Vector3::class => Standard\VectorInfo::KIND, 34 | World::class => Standard\WorldInfo::KIND, 35 | Block::class => Standard\BlockTypeInfo::KIND, 36 | ]; 37 | 38 | public static function register(InitContext $initCtx, Indices $indices) : void { 39 | Strings::register($indices); 40 | Ints::register($indices); 41 | Floats::register($indices); 42 | Bools::register($indices); 43 | Formats::register($indices); 44 | 45 | Vectors::register($indices); 46 | Positions::register($indices); 47 | Players::register($initCtx, $indices); 48 | Worlds::register($initCtx, $indices); 49 | Blocks::register($indices); 50 | 51 | $indices->registries->kindMetas->register(new KindMeta(Standard\BaseContext::KIND, "Global functions", "You can use mappings from here", [ 52 | KindMetadataKeys::IS_ROOT => true, 53 | KindMetadataKeys::BROWSER_TEMPLATE_NAME => "(None)", 54 | ])); 55 | } 56 | 57 | /** 58 | * We register all standard kinds due to cyclic dependency between standard mappings. 59 | * 60 | * This should not happen in plugins because they should have a clear dependency relationship. 61 | * 62 | * @param Registry $defaults 63 | */ 64 | public static function registerStandardKinds(Registry $defaults) : void { 65 | foreach (self::STANDARD_KINDS as $class => $kind) { 66 | /** @var class-string $class */ // HACK: $class may be primitive type names instead, but no need to fix it 67 | ReflectUtil::knowKind(hints: $defaults, class: $class, kind: $kind); 68 | } 69 | } 70 | 71 | /** @var ?array{Registry, Registry} */ 72 | public static ?array $reused = null; 73 | } 74 | -------------------------------------------------------------------------------- /lib/defaults/number.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\IntInfo::KIND, "Integer", sprintf("A whole number between %e and %e", PHP_INT_MIN, PHP_INT_MAX), [])); 28 | $indices->registries->displays->register(new Display(Standard\IntInfo::KIND, fn($value) => is_int($value) ? (string) $value : Display::INVALID)); 29 | 30 | ReflectUtil::addClosureMapping( 31 | $indices, "infoapi:number", ["float"], fn(int $value) : float => (float) $value, isImplicit: true, 32 | help: "Convert the integre to a float", 33 | ); 34 | 35 | ReflectUtil::addClosureMapping( 36 | $indices, "infoapi:number", ["abs", "absolute"], fn(int $v) : int => abs($v), 37 | help: "Take the absolute value.", 38 | ); 39 | ReflectUtil::addClosureMapping( 40 | $indices, "infoapi:number", ["neg", "negate"], fn(int $v) : int => -$v, 41 | help: "Flip the positive/negative sign.", 42 | ); 43 | ReflectUtil::addClosureMapping( 44 | $indices, "infoapi:number", ["add", "plus", "sum"], fn(int $v1, int $v2) : int => $v1 + $v2, 45 | help: "Add two numbers.", 46 | ); 47 | ReflectUtil::addClosureMapping( 48 | $indices, "infoapi:number", ["sub", "subtract", "minus"], fn(int $v1, int $v2) : int => $v1 - $v2, 49 | help: "Subtract two numbers.", 50 | ); 51 | ReflectUtil::addClosureMapping( 52 | $indices, "infoapi:number", ["mul", "mult", "multiply", "times", "prod", "product"], fn(int $v1, int $v2) : int => $v1 * $v2, 53 | help: "Multiply two numbers.", 54 | ); 55 | ReflectUtil::addClosureMapping( 56 | $indices, "infoapi:number", ["div", "divide"], fn(int $v1, int $v2) : float => $v1 / $v2, 57 | help: "Divide two numbers.", 58 | ); 59 | ReflectUtil::addClosureMapping( 60 | $indices, "infoapi:number", ["quotient"], fn(int $v1, int $v2) : int => intdiv($v1, $v2), 61 | help: "Divide two numbers and take the integer quotient.", 62 | ); 63 | ReflectUtil::addClosureMapping( 64 | $indices, "infoapi:number", ["remainder", "rem", "modulus", "mod"], fn(int $v1, int $v2) : int => $v1 % $v2, 65 | help: "Divide two numbers and take the remainder.", 66 | ); 67 | ReflectUtil::addClosureMapping( 68 | $indices, "infoapi:number", ["greater", "max", "maximum"], fn(int $v1, int $v2) : int => max($v1, $v2), 69 | help: "Take the greater of two numbers.", 70 | ); 71 | ReflectUtil::addClosureMapping( 72 | $indices, "infoapi:number", ["less", "min", "minimum"], fn(int $v1, int $v2) : int => min($v1, $v2), 73 | help: "Take the less of two numbers.", 74 | ); 75 | } 76 | } 77 | 78 | final class Floats { 79 | public static function register(Indices $indices) : void { 80 | $indices->registries->kindMetas->register(new KindMeta(Standard\FloatInfo::KIND, "Float", sprintf("A whole number between %e and %e", PHP_FLOAT_MIN, PHP_FLOAT_MAX), [])); 81 | $indices->registries->displays->register(new Display(Standard\FloatInfo::KIND, fn($value) => is_int($value) || is_float($value) ? (string) $value : Display::INVALID)); 82 | 83 | ReflectUtil::addClosureMapping( 84 | $indices, "infoapi:number", ["floor"], fn(float $value) : int => (int) floor($value), 85 | help: "Round down the number.", 86 | ); 87 | ReflectUtil::addClosureMapping( 88 | $indices, "infoapi:number", ["ceil", "ceiling"], fn(float $value) : int => (int) ceil($value), 89 | help: "Round up the number.", 90 | ); 91 | ReflectUtil::addClosureMapping( 92 | $indices, "infoapi:number", ["round"], fn(float $value) : int => (int) round($value), 93 | help: "Round the number to the nearest integer.", 94 | ); 95 | 96 | ReflectUtil::addClosureMapping( 97 | $indices, "infoapi:number", ["gt", "greater"], fn(float $v1, float $v2) : bool => $v1 > $v2, 98 | help: "Check if a number is greater than another.", 99 | ); 100 | ReflectUtil::addClosureMapping( 101 | $indices, "infoapi:number", ["ge", "greaterEqual"], fn(float $v1, float $v2) : bool => $v1 >= $v2, 102 | help: "Check if a number is greater than or equal to another.", 103 | ); 104 | ReflectUtil::addClosureMapping( 105 | $indices, "infoapi:number", ["lt", "less"], fn(float $v1, float $v2) : bool => $v1 < $v2, 106 | help: "Check if a number is less than another.", 107 | ); 108 | ReflectUtil::addClosureMapping( 109 | $indices, "infoapi:number", ["le", "lessEqual"], fn(float $v1, float $v2) : bool => $v1 <= $v2, 110 | help: "Check if a number is less than or equal to another.", 111 | ); 112 | ReflectUtil::addClosureMapping( 113 | $indices, "infoapi:number", ["eq", "equal"], fn(float $v1, float $v2) : bool => $v1 === $v2, 114 | help: "Check if two numbers are equal. Note that two floats are almost never equal unless they were converted from the same integer.", 115 | ); 116 | 117 | ReflectUtil::addClosureMapping( 118 | $indices, "infoapi:number", ["abs", "absolute"], fn(float $v) : float => abs($v), 119 | help: "Take the absolute value.", 120 | ); 121 | ReflectUtil::addClosureMapping( 122 | $indices, "infoapi:number", ["neg", "negate"], fn(float $v) : float => -$v, 123 | help: "Flip the positive/negative sign.", 124 | ); 125 | ReflectUtil::addClosureMapping( 126 | $indices, "infoapi:number", ["add", "plus", "sum"], fn(float $v1, float $v2) : float => $v1 + $v2, 127 | help: "Add two numbers.", 128 | ); 129 | ReflectUtil::addClosureMapping( 130 | $indices, "infoapi:number", ["sub", "subtract", "minus"], fn(float $v1, float $v2) : float => $v1 - $v2, 131 | help: "Subtract two numbers.", 132 | ); 133 | ReflectUtil::addClosureMapping( 134 | $indices, "infoapi:number", ["mul", "mult", "multiply", "times", "prod", "product"], fn(float $v1, float $v2) : float => $v1 * $v2, 135 | help: "Multiply two numbers.", 136 | ); 137 | ReflectUtil::addClosureMapping( 138 | $indices, "infoapi:number", ["div", "divide"], fn(float $v1, float $v2) : float => $v1 / $v2, 139 | help: "Divide two numbers.", 140 | ); 141 | ReflectUtil::addClosureMapping( 142 | $indices, "infoapi:number", ["quotient"], fn(float $v1, float $v2) : int => (int) ($v1 / $v2), 143 | help: "Divide two numbers and take the integer quotient.", 144 | ); 145 | ReflectUtil::addClosureMapping( 146 | $indices, "infoapi:number", ["remainder", "rem", "modulus", "mod"], fn(float $v1, float $v2) : float => fmod($v1, $v2), 147 | help: "Divide two numbers and take the remainder.", 148 | ); 149 | ReflectUtil::addClosureMapping( 150 | $indices, "infoapi:number", ["greater", "max", "maximum"], fn(float $v1, float $v2) : float => max($v1, $v2), 151 | help: "Take the greater of two numbers.", 152 | ); 153 | ReflectUtil::addClosureMapping( 154 | $indices, "infoapi:number", ["less", "min", "minimum"], fn(float $v1, float $v2) : float => min($v1, $v2), 155 | help: "Take the less of two numbers.", 156 | ); 157 | 158 | ReflectUtil::addClosureMapping( 159 | $indices, "infoapi:number", ["pow", "power"], fn(float $v, float $exp) : float => pow($v, $exp), 160 | help: "Raise the number to the power \"exp\".", 161 | ); 162 | ReflectUtil::addClosureMapping( 163 | $indices, "infoapi:number", ["rec", "reciprocal", "inv", "inverse"], fn(float $value) : float => 1 / $value, 164 | help: "Take the reciprocal of a number, i.e. 1 divided by the number.", 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /lib/defaults/player.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\PlayerInfo::KIND, "Player", "An online player", [])); 34 | $indices->registries->displays->register(new Display( 35 | Standard\PlayerInfo::KIND, 36 | fn($value) => $value instanceof Player ? $value->getName() : Display::INVALID, 37 | )); 38 | 39 | ReflectUtil::addClosureMapping( 40 | $indices, "infoapi:player", ["name"], fn(Player $v) : string => $v->getName(), 41 | help: "Player username", 42 | ); 43 | ReflectUtil::addClosureMapping( 44 | $indices, "infoapi:player", ["nameTag"], fn(Player $v) : string => $v->getNameTag(), 45 | help: "Player name tag", 46 | ); 47 | ReflectUtil::addClosureMapping( 48 | $indices, "infoapi:player", ["displayName"], fn(Player $v) : string => $v->getDisplayName(), 49 | help: "Player display name", 50 | ); 51 | ReflectUtil::addClosureMapping( 52 | $indices, "infoapi:player", ["pos", "position", "loc", "location"], fn(Player $v) : Position => $v->getPosition(), 53 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerMoveEvent::class), 54 | isImplicit: true, 55 | help: "Player foot position", 56 | ); 57 | ReflectUtil::addClosureMapping( 58 | $indices, "infoapi:player", ["eyePos", "eyePosition"], fn(Player $v) : Position => Position::fromObject($v->getEyePos(), $v->getWorld()), 59 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerMoveEvent::class), 60 | help: "Player eye position", 61 | ); 62 | ReflectUtil::addClosureMapping( 63 | $indices, "infoapi:player", ["standing"], fn(Player $v) : Position => new Position( 64 | $v->getPosition()->getFloorX(), 65 | (int) ceil($v->getPosition()->y), 66 | $v->getPosition()->getFloorZ(), 67 | $v->getWorld(), 68 | ), 69 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerMoveEvent::class), 70 | help: "The position of the block player is standing on", 71 | ); 72 | ReflectUtil::addClosureMapping( 73 | $indices, "infoapi:player", ["sneaking"], fn(Player $v) : bool => $v->isSneaking(), 74 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerToggleSneakEvent::class), 75 | help: "Whether the player is sneaking", 76 | ); 77 | ReflectUtil::addClosureMapping( 78 | $indices, "infoapi:player", ["flying"], fn(Player $v) : bool => $v->isFlying(), 79 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerToggleFlightEvent::class), 80 | help: "Whether the player is flying", 81 | ); 82 | ReflectUtil::addClosureMapping( 83 | $indices, "infoapi:player", ["swimming"], fn(Player $v) : bool => $v->isSwimming(), 84 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerToggleSwimEvent::class), 85 | help: "Whether the player is swimming", 86 | ); 87 | ReflectUtil::addClosureMapping( 88 | $indices, "infoapi:player", ["sprinting"], fn(Player $v) : bool => $v->isSprinting(), 89 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerToggleSprintEvent::class), 90 | help: "Whether the player is sprinting", 91 | ); 92 | ReflectUtil::addClosureMapping( 93 | $indices, "infoapi:player", ["gliding"], fn(Player $v) : bool => $v->isGliding(), 94 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerToggleGlideEvent::class), 95 | help: "Whether the player is gliding", 96 | ); 97 | ReflectUtil::addClosureMapping( 98 | $indices, "infoapi:player", ["alive"], fn(Player $v) : bool => $v->isAlive(), 99 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerDeathEvent::class, PlayerRespawnEvent::class), 100 | help: "Whether the player is alive", 101 | ); 102 | ReflectUtil::addClosureMapping( 103 | $indices, "infoapi:player", ["dead"], fn(Player $v) : bool => !$v->isAlive(), 104 | watchChanges: fn(Player $player) => self::watchPlayer($initCtx, $player, PlayerDeathEvent::class, PlayerRespawnEvent::class), 105 | help: "Whether the player is dead", 106 | ); 107 | ReflectUtil::addClosureMapping( 108 | $indices, "infoapi:player", ["allowFlight", "canFly"], fn(Player $v) : bool => $v->getAllowFlight(), 109 | help: "Whether the player can fly", 110 | ); 111 | 112 | ReflectUtil::addClosureMapping( 113 | $indices, "infoapi:player", ["playerCount"], fn(Server $server) : int => count($server->getOnlinePlayers()), 114 | // TODO watchChanges 115 | help: "Number of online players", 116 | ); 117 | ReflectUtil::addClosureMapping( 118 | $indices, "infoapi:player", ["player"], fn(string $name) : ?Player => Server::getInstance()->getPlayerExact($name), 119 | watchChanges: fn(string $name) => self::watchPlayerName($initCtx, $name, PlayerLoginEvent::class, PlayerQuitEvent::class), 120 | help: "Search information about an online player by name", 121 | ); 122 | } 123 | 124 | /** 125 | * @param class-string $events 126 | */ 127 | private static function watchPlayer(InitContext $initCtx, Player $player, string ...$events) : Generator { 128 | return self::watchPlayerName($initCtx, $player->getName(), ...$events); 129 | } 130 | 131 | /** 132 | * @param class-string $events 133 | */ 134 | private static function watchPlayerName(InitContext $initCtx, string $playerName, string ...$events) : Generator { 135 | return $initCtx->watchEvent( 136 | events: $events, 137 | key: $playerName, 138 | interpreter: fn(PlayerEvent|PlayerDeathEvent $event) => $event->getPlayer()->getName(), 139 | )->asGenerator(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /lib/defaults/position.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\PositionInfo::KIND, "Position", "A physical position in the game world", [])); 20 | $indices->registries->displays->register(new Display( 21 | Standard\PositionInfo::KIND, 22 | fn($value) => $value instanceof Position ? sprintf("(%s, %s, %s) @ %s", $value->x, $value->y, $value->z, $value->world?->getDisplayName() ?? "null") : Display::INVALID, 23 | )); 24 | 25 | ReflectUtil::addClosureMapping( 26 | $indices, "infoapi:position", ["x"], fn(Position $v) : float => $v->x, 27 | help: "X-coordinate of the position", 28 | ); 29 | ReflectUtil::addClosureMapping( 30 | $indices, "infoapi:position", ["y"], fn(Position $v) : float => $v->y, 31 | help: "Y-coordinate of the position", 32 | ); 33 | ReflectUtil::addClosureMapping( 34 | $indices, "infoapi:position", ["z"], fn(Position $v) : float => $v->z, 35 | help: "Z-coordinate of the position", 36 | ); 37 | ReflectUtil::addClosureMapping( 38 | $indices, "infoapi:position", ["world"], fn(Position $v) : ?World => $v->world, 39 | help: "World of the position", 40 | ); 41 | 42 | ReflectUtil::addClosureMapping( 43 | $indices, "infoapi:position", ["add", "plus"], 44 | fn(Position $v, Vector3 $vector) : Position => Position::fromObject($v->addVector($vector), $v->world), 45 | help: "Move along the vector", 46 | ); 47 | ReflectUtil::addClosureMapping( 48 | $indices, "infoapi:position", ["diff", "difference", "sub", "minus"], 49 | fn(Position $v, Position $from) : ?Vector3 => $v->world === $from->world ? $v->subtractVector($from) : null, 50 | help: "The vector from the `from` position to this position", 51 | ); 52 | 53 | ReflectUtil::addClosureMapping($indices, "infoapi:position", ["dist", "distance"], fn(Position $v, Position $other) : float => $other->distance($v), help: "Distance between two positions"); 54 | } 55 | } 56 | 57 | final class Vectors { 58 | public static function register(Indices $indices) : void { 59 | $indices->registries->kindMetas->register(new KindMeta(Standard\VectorInfo::KIND, "Vector", "A relative vector representing a direction and magnitude in 3D", [])); 60 | $indices->registries->displays->register(new Display( 61 | Standard\VectorInfo::KIND, 62 | fn($value) => $value instanceof Vector3 ? sprintf("(%s, %s, %s)", $value->x, $value->y, $value->z) : Display::INVALID, 63 | )); 64 | 65 | ReflectUtil::addClosureMapping( 66 | $indices, "infoapi:position", ["x"], fn(Position $v) : float => $v->x, 67 | help: "X-component of this vector", 68 | ); 69 | ReflectUtil::addClosureMapping( 70 | $indices, "infoapi:position", ["y"], fn(Position $v) : float => $v->y, 71 | help: "Y-component of this vector", 72 | ); 73 | ReflectUtil::addClosureMapping( 74 | $indices, "infoapi:position", ["z"], fn(Position $v) : float => $v->z, 75 | help: "Z-component of this vector", 76 | ); 77 | ReflectUtil::addClosureMapping( 78 | $indices, "infoapi:position", ["add", "plus"], 79 | fn(Vector3 $v, Vector3 $other) : Vector3 => $v->addVector($other), 80 | help: "Sum of two vectors", 81 | ); 82 | ReflectUtil::addClosureMapping( 83 | $indices, "infoapi:position", ["sub", "subtract", "minus"], 84 | fn(Vector3 $v, Vector3 $other) : Vector3 => $v->subtractVector($other), 85 | help: "Subtract two vectors", 86 | ); 87 | ReflectUtil::addClosureMapping( 88 | $indices, "infoapi:position", ["mul", "mult", "multiply", "times", "scale"], 89 | fn(Vector3 $v, float $scale) : Vector3 => $v->multiply($scale), 90 | help: "Multiply a vector", 91 | ); 92 | ReflectUtil::addClosureMapping( 93 | $indices, "infoapi:position", ["div", "divide"], 94 | fn(Vector3 $v, float $scale) : Vector3 => $v->divide($scale), 95 | help: "Divide a vector", 96 | ); 97 | 98 | ReflectUtil::addClosureMapping( 99 | $indices, "infoapi:position", ["len", "length", "mod", "modulus", "mag", "magnitude", "norm"], 100 | fn(Vector3 $v) : float => $v->length(), 101 | help: "Length of a vector", 102 | ); 103 | ReflectUtil::addClosureMapping( 104 | $indices, "infoapi:position", ["unit", "dir", "direction"], fn(Vector3 $v) : Vector3 => $v->normalize(), 105 | help: "A unit vector in the same direction with length 1", 106 | ); 107 | ReflectUtil::addClosureMapping( 108 | $indices, "infoapi:position", ["withLength"], fn(Vector3 $v, float $length) : Vector3 => $v->multiply($length / $v->length()), 109 | help: "A vector in the same direction with the specified length", 110 | ); 111 | 112 | ReflectUtil::addClosureMapping( 113 | $indices, "infoapi:position", ["dot"], 114 | fn(Vector3 $v, Vector3 $other) : float => $v->dot($other), 115 | help: "Compute the dot product of two vectors", 116 | ); 117 | ReflectUtil::addClosureMapping( 118 | $indices, "infoapi:position", ["cross"], 119 | fn(Vector3 $v, Vector3 $other) : Vector3 => $v->cross($other), 120 | help: "Compute the cross product of two vectors", 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/defaults/string.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\StringInfo::KIND, "Text", "A string of characters", [])); 23 | $indices->registries->displays->register(new Display(Standard\StringInfo::KIND, fn($value) => is_string($value) ? $value : Display::INVALID)); 24 | 25 | ReflectUtil::addClosureMapping( 26 | $indices, "infoapi:string", ["upper", "uppercase"], fn(string $string) : string => mb_strtoupper($string), 27 | help: "Converts the entire string to uppercase.", 28 | ); 29 | ReflectUtil::addClosureMapping( 30 | $indices, "infoapi:string", ["lower", "lowercase"], fn(string $string) : string => mb_strtolower($string), 31 | help: "Converts the entire string to lowercase.", 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/defaults/world.php: -------------------------------------------------------------------------------- 1 | registries->kindMetas->register(new KindMeta(Standard\WorldInfo::KIND, "World", "A loaded world", [])); 27 | $indices->registries->displays->register(new Display( 28 | Standard\WorldInfo::KIND, 29 | fn($value) => $value instanceof World ? $value->getFolderName() : Display::INVALID, 30 | )); 31 | 32 | ReflectUtil::addClosureMapping( 33 | $indices, "infoapi:world", ["folderName", "name"], fn(World $v) : string => $v->getFolderName(), 34 | help: "World folder name", 35 | ); 36 | ReflectUtil::addClosureMapping( 37 | $indices, "infoapi:world", ["displayName"], fn(World $v) : string => $v->getDisplayName(), 38 | help: "World display name (the one in level.dat)", 39 | ); 40 | ReflectUtil::addClosureMapping( 41 | $indices, "infoapi:world", ["time"], fn(World $v) : int => $v->getTime(), 42 | help: "Accumulative in-game time of the world in ticks", 43 | watchChanges: fn(World $v) => self::metronome($initCtx), 44 | ); 45 | ReflectUtil::addClosureMapping( 46 | $indices, "infoapi:world", ["timeOfDay"], fn(World $v) : int => $v->getTimeOfDay(), 47 | help: "In-game time-of-day of the world in ticks", 48 | watchChanges: fn(World $v) => self::metronome($initCtx), 49 | ); 50 | ReflectUtil::addClosureMapping( 51 | $indices, "infoapi:world", ["seed"], fn(World $v) : int => $v->getSeed(), 52 | help: "Seed of the world", 53 | ); 54 | ReflectUtil::addClosureMapping( 55 | $indices, "infoapi:world", ["seed"], fn(World $v) : int => $v->getSeed(), 56 | help: "World seed", 57 | ); 58 | ReflectUtil::addClosureMapping( 59 | $indices, "infoapi:world", ["spawn"], fn(World $v) : Position => $v->getSpawnLocation(), 60 | help: "Spawn point set for the world", 61 | ); 62 | 63 | ReflectUtil::addClosureMapping( 64 | $indices, "infoapi:world", ["worldCount"], fn(Server $server) : int => count($server->getWorldManager()->getWorlds()), 65 | help: "Number of loaded worlds", 66 | ); 67 | ReflectUtil::addClosureMapping( 68 | $indices, "infoapi:world", ["world"], fn(string $name) : ?World => Server::getInstance()->getWorldManager()->getWorldByName($name), 69 | help: "Search information about a loaded world by name", 70 | ); 71 | ReflectUtil::addClosureMapping( 72 | $indices, "infoapi:world", ["defaultWorld"], fn(Server $server) : ?World => $server->getWorldManager()->getDefaultWorld(), 73 | help: "The default world", 74 | watchChanges: fn(string $worldName) => self::watchWorldName($initCtx, $worldName, WorldLoadEvent::class, WorldUnloadEvent::class), 75 | ); 76 | 77 | ReflectUtil::addClosureMapping( 78 | $indices, "infoapi:world", ["block"], fn(Position $pos) : ?Block => ( 79 | $pos->getWorld()->isChunkLoaded($pos->getFloorX() >> Chunk::COORD_BIT_SIZE, $pos->getFloorZ() >> Chunk::COORD_BIT_SIZE) ? 80 | $pos->getWorld()->getBlock($pos->asVector3()) : null), 81 | help: "Get the actual block type at the position", 82 | watchChanges: fn(Position $pos) => $initCtx->watchBlock($pos)->asGenerator(), 83 | ); 84 | } 85 | 86 | /** 87 | * @param class-string $events 88 | */ 89 | private static function watchWorldName(InitContext $initCtx, string $worldName, string ...$events) : Generator { 90 | return $initCtx->watchEvent( 91 | events: $events, 92 | key: $worldName, 93 | interpreter: fn(WorldEvent $event) => $event->getWorld()->getFolderName(), 94 | )->asGenerator(); 95 | } 96 | 97 | private static function metronome(InitContext $initCtx, int $period = 1) : Generator { 98 | return (function() use ($initCtx, $period) { 99 | while (true) { 100 | yield from $initCtx->sleep($period); 101 | } 102 | })(); 103 | } 104 | } 105 | 106 | final class Blocks { 107 | public static function register(Indices $indices) : void { 108 | $indices->registries->kindMetas->register(new KindMeta(Standard\BlockTypeInfo::KIND, "Block type", "A type of block", [])); 109 | $indices->registries->displays->register(new Display( 110 | Standard\BlockTypeInfo::KIND, 111 | fn($value) => $value instanceof Block ? $value->getName() : Display::INVALID, 112 | )); 113 | 114 | ReflectUtil::addClosureMapping( 115 | $indices, "infoapi:world", ["name"], fn(Block $v) : string => $v->getName(), 116 | help: "Block name", 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/doc.php: -------------------------------------------------------------------------------- 1 | kindMetas->getAll() as $help) { 14 | $kinds[$help->kind]["help"] = $help->help; 15 | $kinds[$help->kind]["metadata"] = $help->metadata ?: new stdClass; 16 | } 17 | foreach ($registries->displays->getAll() as $display) { 18 | $kinds[$display->kind]["canDisplay"] = true; 19 | } 20 | } 21 | 22 | $mappings = []; 23 | foreach ($registriesList as $registries) { 24 | foreach ($registries->mappings->getAll() as $mapping) { 25 | $params = []; 26 | foreach ($mapping->parameters as $param) { 27 | $params[] = [ 28 | "name" => $param->name, 29 | "kind" => $param->kind, 30 | "multi" => $param->multi, 31 | "optional" => $param->optional, 32 | "metadata" => $param->metadata, 33 | ]; 34 | } 35 | 36 | $mappings[] = [ 37 | "sourceKind" => $mapping->sourceKind, 38 | "targetKind" => $mapping->targetKind, 39 | "name" => (new FullyQualifiedName($mapping->qualifiedName))->toString(), 40 | "isImplicit" => $mapping->isImplicit, 41 | "parameters" => $params, 42 | "mutable" => $mapping->subscribe !== null, 43 | "help" => $mapping->help, 44 | "metadata" => $mapping->metadata, 45 | ]; 46 | } 47 | } 48 | 49 | return [ 50 | "kinds" => $kinds, 51 | "mappings" => $mappings, 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/ext.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class DisplayIndex extends Index { 15 | /** @var array */ 16 | private array $kinds; 17 | 18 | public function reset() : void { 19 | $this->kinds = []; 20 | } 21 | 22 | public function index($display) : void { 23 | $this->kinds[$display->kind] = $display; 24 | } 25 | 26 | public function getDisplay(string $kind) : ?Display { 27 | $this->sync(); 28 | 29 | if (isset($this->kinds[$kind])) { 30 | $display = $this->kinds[$kind]; 31 | return $display; 32 | } else { 33 | return null; 34 | } 35 | } 36 | 37 | public function display(string $kind, mixed $value, ?CommandSender $sender) : string { 38 | $display = $this->getDisplay($kind); 39 | return $display !== null ? ($display->display)($value, $sender) : Display::INVALID; 40 | } 41 | 42 | public function canDisplay(string $kind) : bool { 43 | $this->sync(); 44 | 45 | return isset($this->kinds[$kind]); 46 | } 47 | } 48 | 49 | /** 50 | * @extends Index 51 | */ 52 | final class KindMetaIndex extends Index { 53 | /** @var array */ 54 | private array $kinds; 55 | 56 | public function reset() : void { 57 | $this->kinds = []; 58 | } 59 | 60 | public function index($help) : void { 61 | $this->kinds[$help->kind] = $help; 62 | } 63 | 64 | public function get(string $kind) : ?KindMeta { 65 | $this->sync(); 66 | 67 | if (!isset($this->kinds[$kind])) { 68 | return null; 69 | } 70 | 71 | return $this->kinds[$kind]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/mapping-index.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class NamedMappingIndex extends Index { 17 | /** @var array> */ 18 | private array $namedMappings; 19 | 20 | public function reset() : void { 21 | $this->namedMappings = []; 22 | } 23 | 24 | public function index($mapping) : void { 25 | $source = $mapping->sourceKind; 26 | $this->namedMappings[$source] ??= []; 27 | 28 | $shortName = $mapping->qualifiedName[count($mapping->qualifiedName) - 1]; 29 | $this->namedMappings[$source][$shortName] ??= []; 30 | 31 | $this->namedMappings[$source][$shortName][] = $mapping; 32 | } 33 | 34 | /** 35 | * @return ScoredMapping[] 36 | */ 37 | public function find(string $sourceKind, QualifiedRef $ref) : array { 38 | $this->sync(); 39 | 40 | if (!isset($this->namedMappings[$sourceKind])) { 41 | return []; 42 | } 43 | 44 | $shortName = $ref->tokens[count($ref->tokens) - 1]; 45 | if (!isset($this->namedMappings[$sourceKind][$shortName])) { 46 | return []; 47 | } 48 | 49 | $mappings = array_filter( 50 | $this->namedMappings[$sourceKind][$shortName], 51 | fn(Mapping $mapping) => (new FullyQualifiedName($mapping->qualifiedName))->match($ref) !== null, 52 | ); 53 | 54 | $results = []; 55 | foreach ($mappings as $mapping) { 56 | $score = (new FullyQualifiedName($mapping->qualifiedName))->match($ref); 57 | if ($score !== null) { 58 | $results[] = new ScoredMapping($score, $mapping); 59 | } 60 | } 61 | 62 | return $results; 63 | } 64 | 65 | public function cloned() : self { 66 | // this object is clone-safe 67 | return clone $this; 68 | } 69 | } 70 | 71 | final class ScoredMapping { 72 | public function __construct( 73 | public int $score, 74 | public Mapping $mapping, 75 | ) { 76 | } 77 | } 78 | 79 | /** 80 | * @extends Index 81 | */ 82 | final class ImplicitMappingIndex extends Index { 83 | /** @var array> */ 84 | private array $implicitMappings; 85 | 86 | public function reset() : void { 87 | $this->implicitMappings = []; 88 | } 89 | 90 | public function index($mapping) : void { 91 | $source = $mapping->sourceKind; 92 | 93 | if ($mapping->isImplicit) { 94 | if (!isset($this->implicitMappings[$source])) { 95 | $this->implicitMappings[$source] = []; 96 | } 97 | array_unshift($this->implicitMappings[$source], $mapping); 98 | } 99 | } 100 | 101 | /** 102 | * @return list 103 | */ 104 | public function getImplicit(string $sourceKind) : array { 105 | $this->sync(); 106 | return $this->implicitMappings[$sourceKind] ?? []; 107 | } 108 | 109 | public function cloned() : self { 110 | // this object is clone-safe 111 | return clone $this; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/name.php: -------------------------------------------------------------------------------- 1 | tokens); 24 | } 25 | 26 | public function shortName() : string { 27 | return $this->tokens[count($this->tokens) - 1]; 28 | } 29 | 30 | public static function parse(string $text) : self { 31 | return new self(explode(Mapping::FQN_SEPARATOR, $text)); 32 | } 33 | 34 | /** 35 | * Tests if the input matches this FQN. 36 | * Returns null if it does not match. 37 | * Returns 0 if it is an exact match. 38 | * Returns a positive number that indicates the number of missing tokens if it is a fuzzy match. 39 | */ 40 | public function match(QualifiedRef $ref) : ?int { 41 | $input = $ref->tokens; 42 | if ($input[count($input) - 1] !== $this->tokens[count($this->tokens) - 1]) { 43 | return null; 44 | } 45 | 46 | $missing = 0; 47 | foreach ($this->tokens as $token) { 48 | if ($token === $input[0]) { 49 | array_shift($input); 50 | } else { 51 | $missing += 1; 52 | } 53 | } 54 | 55 | if (count($input) > 0) { 56 | // $input[0] does not match any tokens in this FQN. 57 | return null; 58 | } 59 | 60 | return $missing; 61 | } 62 | } 63 | 64 | final class QualifiedRef { 65 | /** 66 | * @param string[] $tokens 67 | */ 68 | public function __construct( 69 | public array $tokens, 70 | ) { 71 | } 72 | 73 | public function shortName() : string { 74 | return $this->tokens[count($this->tokens) - 1]; 75 | } 76 | 77 | public static function parse(string $text) : self { 78 | return new self(explode(Mapping::FQN_SEPARATOR, $text)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/parser.php: -------------------------------------------------------------------------------- 1 | pos; 31 | 32 | if ($end !== null && $length !== null) { 33 | throw new RuntimeException("\$end and \$length cannot be both specified"); 34 | } 35 | 36 | $end ??= $start + ($length ?? 1); 37 | 38 | throw new ParseException($why, $this->buf, $start, $end); 39 | } 40 | 41 | public function eof() : bool { 42 | return strlen($this->buf) === $this->pos; 43 | } 44 | 45 | public function peek(int $length) : ?string { 46 | if ($this->pos + $length > strlen($this->buf)) { 47 | return null; 48 | } 49 | return substr($this->buf, $this->pos, $length); 50 | } 51 | 52 | public function readExactLength(int $length) : ?string { 53 | if ($this->pos + $length > strlen($this->buf)) { 54 | return null; 55 | } 56 | $ret = substr($this->buf, $this->pos, $length); 57 | $this->pos += $length; 58 | return$ret; 59 | } 60 | 61 | public function readExactText(string $text) : bool { 62 | if ($this->peek(strlen($text)) === $text) { 63 | $this->readExactLength(strlen($text)); 64 | return true; 65 | } 66 | 67 | return false; 68 | } 69 | 70 | public function readUntil(string ...$needles) : ?string { 71 | $minPos = null; 72 | 73 | foreach ($needles as $needle) { 74 | $pos = strpos($this->buf, $needle, $this->pos); 75 | if ($pos !== false && ($minPos === null || $minPos > $pos)) { 76 | $minPos = $pos; 77 | } 78 | } 79 | 80 | if ($minPos !== null) { 81 | return $this->readExactLength($minPos - $this->pos); 82 | } 83 | return null; 84 | } 85 | 86 | /** 87 | * @param string $regexCharset the contents inside `[]` of a regex. 88 | */ 89 | public function readRegexCharset(string $regexCharset) : string { 90 | return $this->readRegex("[{$regexCharset}]+"); 91 | } 92 | 93 | public function readRegex(string $regex) : string { 94 | $matched = preg_match("/^{$regex}/", substr($this->buf, $this->pos), $matches); 95 | if ($matched !== 1) { 96 | return ""; 97 | } 98 | 99 | return $this->readExactLength(strlen($matches[0])) 100 | ?? throw new RuntimeException("preg_match returned more bytes than available"); 101 | } 102 | 103 | public function skipWhitespace() : void { 104 | while (!$this->eof()) { 105 | $replaced = false; 106 | foreach (str_split(" \t\n\r\v") as $char) { 107 | if ($this->readExactText($char)) { 108 | $replaced = true; 109 | break; 110 | } 111 | } 112 | 113 | if (!$replaced) { 114 | return; 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Try executing something and conditionally roll back the parser if the closure returns false. 121 | * 122 | * @param Closure(): bool $run 123 | */ 124 | public function try(Closure $run) : void { 125 | $initial = $this->pos; 126 | if (!$run()) { 127 | $this->pos = $initial; 128 | } 129 | } 130 | 131 | public function peekAll() : string { 132 | return substr($this->buf, $this->pos); 133 | } 134 | 135 | public function readAll() : string { 136 | $ret = substr($this->buf, $this->pos); 137 | $this->pos = strlen($this->buf); 138 | return $ret; 139 | } 140 | } 141 | 142 | final class ParseException extends Exception { 143 | public string $carets; 144 | 145 | public function __construct( 146 | public string $why, 147 | public string $buf, 148 | public int $start, 149 | public int $end, 150 | ) { 151 | $carets = str_repeat(" ", $start) . str_repeat("^", $end - $start); 152 | $this->carets = $carets; 153 | parent::__construct("$why\n$buf\n$carets"); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/pathfind.php: -------------------------------------------------------------------------------- 1 | insertPath(new Path( 27 | unreadCalls: $calls, 28 | tailKind: $sourceKind, 29 | mappings: [], 30 | implicitLoopDetector: [$sourceKind => true], 31 | cost: new Cost(0, 0), 32 | )); 33 | 34 | /** @var Path[] $accepted */ 35 | $accepted = []; 36 | while (!$heap->isEmpty()) { 37 | /** @var Path $path */ 38 | $path = $heap->extract(); 39 | 40 | /** @var Path[] $newPaths */ 41 | $newPaths = []; 42 | 43 | if (count($path->unreadCalls) > 0) { 44 | $shiftedCalls = $path->unreadCalls; 45 | $call = array_shift($shiftedCalls); 46 | $matches = $indices->getNamedMappings()->find($path->tailKind, $call); 47 | foreach ($matches as $match) { 48 | // TODO also check parameter compatibility here? 49 | $newPaths[] = new Path( 50 | unreadCalls: $shiftedCalls, 51 | tailKind: $match->mapping->targetKind, 52 | mappings: array_merge($path->mappings , [$match->mapping]), 53 | implicitLoopDetector: [$match->mapping->targetKind => true], 54 | cost: $path->cost->addMapping($match->score), 55 | ); 56 | } 57 | } 58 | 59 | $implicits = $indices->getImplicitMappings()->getImplicit($path->tailKind); 60 | foreach ($implicits as $implicit) { 61 | if (isset($path->implicitLoopDetector[$implicit->targetKind])) { 62 | continue; 63 | } 64 | 65 | $newPaths[] = new Path( 66 | unreadCalls: $path->unreadCalls, // $call was not consumed, don't shift 67 | tailKind: $implicit->targetKind, 68 | mappings: array_merge($path->mappings, [$implicit]), 69 | implicitLoopDetector: $path->implicitLoopDetector + [$implicit->targetKind => true], 70 | cost: $path->cost->addMapping(0), 71 | ); 72 | } 73 | 74 | foreach ($newPaths as $newPath) { 75 | if (count($newPath->unreadCalls) === 0 && $admitTailKind($newPath->tailKind)) { 76 | $accepted[] = $newPath; 77 | } else { 78 | $heap->insertPath($newPath); 79 | } 80 | } 81 | } 82 | 83 | return $accepted; 84 | } 85 | } 86 | 87 | final class Path { 88 | /** 89 | * @param QualifiedRef[] $unreadCalls 90 | * @param Mapping[] $mappings 91 | * @param array $implicitLoopDetector 92 | */ 93 | public function __construct( 94 | public array $unreadCalls, 95 | public string $tailKind, 96 | public array $mappings, 97 | public array $implicitLoopDetector, 98 | public Cost $cost, 99 | ) { 100 | } 101 | } 102 | 103 | /** 104 | * Cost of a path. 105 | * 106 | * A path with fewer steps is better than a path with more steps. 107 | * If two paths have the same number of steps, 108 | * a path with lower score is better than a path with higher score. 109 | */ 110 | final class Cost { 111 | public function __construct( 112 | public int $sumScore, 113 | public int $numMappings, 114 | ) { 115 | } 116 | 117 | public function addMapping(int $score) : self { 118 | return new self( 119 | sumScore: $this->sumScore + $score, 120 | numMappings: $this->numMappings + 1, 121 | ); 122 | } 123 | 124 | public function compare(Cost $that) : int { 125 | if ($this->numMappings !== $that->numMappings) { 126 | return $this->numMappings <=> $that->numMappings; 127 | } 128 | 129 | return $this->sumScore <=> $that->sumScore; 130 | } 131 | } 132 | 133 | /** 134 | * @extends SplPriorityQueue 135 | */ 136 | final class Heap extends SplPriorityQueue { 137 | public function compare(mixed $priority1, mixed $priority2) : int { 138 | return $priority1->compare($priority2); 139 | } 140 | 141 | public function insertPath(Path $path) : void { 142 | $this->insert($path, $path->cost); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /lib/reflect.php: -------------------------------------------------------------------------------- 1 | getParameters(); 32 | if (count($params) < 1) { 33 | throw new RuntimeException("Closure must have at least one parameter"); 34 | } 35 | 36 | $param = $params[0]; 37 | $type = $param->getType(); 38 | if (!($type instanceof ReflectionNamedType) || $type->isBuiltin()) { 39 | throw new RuntimeException("First parameter of Closure must have a type hint that is a class defined by the plugin"); 40 | } 41 | /** @var class-string $class */ // because $type->isBuiltin() is false 42 | $class = $type->getName(); 43 | 44 | $indices->registries->hints->register(new ReflectHint(class: $class, kind: $kind)); 45 | 46 | $display = new Display( 47 | kind: $kind, 48 | display: function(mixed $value, ?CommandSender $sender) use ($closure, $type) : string { 49 | if (!self::correctType($value, $type)) { 50 | return Display::INVALID; 51 | } 52 | return ($closure)($value, $sender); 53 | }, 54 | ); 55 | $indices->registries->displays->register($display); 56 | } 57 | 58 | /** 59 | * Registers a mapping by detecting the kind from the closure. 60 | * 61 | * The closure must have at least one parameter. 62 | * All parameters must have a type hint, 63 | * and the return type hint must be present. 64 | * Each type hint must map to a known kind through `ReflectUtil::knowKind()`. 65 | * The second parameter onwards may be nullable. 66 | * 67 | * @param string[] $names 68 | * @param array $metadata 69 | */ 70 | public static function addClosureMapping( 71 | Indices $indices, 72 | string $namespace, 73 | array $names, 74 | Closure $closure, 75 | string $help = "", 76 | ?Closure $watchChanges = null, 77 | bool $isImplicit = false, 78 | array $metadata = [], 79 | ) : void { 80 | $reflect = new ReflectionFunction($closure); 81 | $closureParams = $reflect->getParameters(); 82 | 83 | if (count($closureParams) < 1) { 84 | throw new RuntimeException("Closure must have at least one parameter"); 85 | } 86 | 87 | $sourceParam = array_shift($closureParams); 88 | $sourceRawType = $sourceParam->getType() ?? throw new RuntimeException("First parameter of Closure is not typed"); 89 | $sourceTypes = $sourceRawType instanceof ReflectionUnionType ? $sourceRawType->getTypes() : [$sourceRawType]; 90 | 91 | $params = []; 92 | $paramTypes = []; 93 | foreach ($closureParams as $closureParam) { 94 | $paramType = $closureParam->getType() ?? throw new RuntimeException("Parameter of Closure is not typed"); 95 | if ($paramType instanceof ReflectionUnionType) { 96 | throw new RuntimeException("Only the first parameter can be union type"); 97 | } 98 | /** @var ReflectionNamedType $paramType */ 99 | 100 | $param = new Parameter( 101 | name: $closureParam->getName(), 102 | kind: $indices->hints->lookup($paramType->getName()) ?? throw new RuntimeException("Cannot detect info kind for parameter type {$paramType->getName()}"), 103 | multi: $closureParam->isVariadic(), 104 | optional: $paramType->allowsNull(), 105 | metadata: [], 106 | ); 107 | 108 | $params[] = $param; 109 | $paramTypes[] = $paramType; 110 | } 111 | 112 | $returnType = $reflect->getReturnType() ?? throw new RuntimeException("Closure must have explicit return type hint"); 113 | if ($returnType instanceof ReflectionUnionType) { 114 | throw new RuntimeException("Return type must not be union"); 115 | } 116 | 117 | /** @var ReflectionNamedType $returnType */ 118 | $targetKind = $indices->hints->lookup($returnType->getName()) ?? throw new RuntimeException("Cannot detect info kind for return type {$returnType->getName()}"); 119 | 120 | $nsTokens = explode(Mapping::FQN_SEPARATOR, $namespace); 121 | 122 | foreach ($sourceTypes as $sourceType) { 123 | $sourceKind = $indices->hints->lookup($sourceType->getName()) ?? throw new RuntimeException("Cannot detect info kind for source type {$sourceType->getName()}"); 124 | 125 | /** @var ?Closure(mixed, mixed[]): Generator */ 126 | $subscribe = null; 127 | if ($watchChanges !== null) { 128 | $corrected = self::correctClosure($watchChanges, $sourceType, $paramTypes); 129 | $subscribe = function($source, $args) use ($corrected) : Generator { 130 | $gen = $corrected($source, $args); 131 | if ($gen === null) { 132 | return; 133 | } 134 | yield from $gen; 135 | }; 136 | } 137 | 138 | $first = null; 139 | foreach ($names as $name) { 140 | $fqnTokens = $nsTokens; 141 | $fqnTokens[] = $name; 142 | 143 | $metadataCopy = $metadata; 144 | if ($first === null) { 145 | $first = implode(Mapping::FQN_SEPARATOR, $fqnTokens); 146 | } else { 147 | $metadataCopy[MappingMetadataKeys::ALIAS_OF] = $first; 148 | } 149 | 150 | $indices->registries->mappings->register(new Mapping( 151 | qualifiedName: $fqnTokens, 152 | sourceKind: $sourceKind, 153 | targetKind: $targetKind, 154 | isImplicit: $isImplicit, 155 | parameters: $params, 156 | map: self::correctClosure($closure, $sourceType, $paramTypes), 157 | subscribe: $subscribe, 158 | help: $help, 159 | metadata: $metadataCopy, 160 | )); 161 | } 162 | } 163 | } 164 | 165 | /** 166 | * @template T 167 | * 168 | * @param Closure(): T $impl 169 | * @param ReflectionNamedType[] $paramTypes 170 | * @return Closure(mixed, mixed[]): ?T 171 | */ 172 | private static function correctClosure(Closure $impl, ReflectionNamedType $sourceType, array $paramTypes) : Closure { 173 | return function(mixed $source, array $args) use ($impl, $sourceType, $paramTypes) { 174 | $outArgs = []; 175 | 176 | if (!self::correctType($source, $sourceType)) { 177 | return null; 178 | } 179 | $outArgs[] = $source; 180 | 181 | foreach ($args as $i => $arg) { 182 | if (!self::correctType($arg, $paramTypes[$i])) { 183 | return null; 184 | } 185 | $outArgs[] = $arg; 186 | } 187 | 188 | return $impl(...$outArgs); 189 | }; 190 | } 191 | 192 | private static function correctType(mixed &$value, ReflectionNamedType $type) : bool { 193 | if (self::isAssignable($value, $type)) { 194 | return true; 195 | } 196 | if ($type->allowsNull()) { 197 | $value = null; 198 | return true; 199 | } 200 | return false; 201 | } 202 | 203 | private static function isAssignable(mixed $value, ReflectionNamedType $type) : bool { 204 | if ($type->allowsNull() && $value === null) { 205 | return true; 206 | } 207 | 208 | if ($type->isBuiltin()) { 209 | return self::getStandardType($value) === $type->getName(); 210 | } 211 | 212 | if (is_object($value)) { 213 | return get_class($value) === $type->getName(); 214 | } 215 | 216 | return false; 217 | } 218 | 219 | /** 220 | * @param Registry $hints 221 | * @param class-string $class 222 | */ 223 | public static function knowKind(Registry $hints, string $class, string $kind) : void { 224 | $hints->register(new ReflectHint(class: $class, kind: $kind)); 225 | } 226 | 227 | public static function getStandardType(mixed $value) : string { 228 | if (is_float($value)) { 229 | // double -> float 230 | return "float"; 231 | } 232 | if (is_int($value)) { 233 | // integer -> int 234 | return "int"; 235 | } 236 | if (is_bool($value)) { 237 | // boolean -> bool 238 | return "bool"; 239 | } 240 | 241 | return gettype($value); 242 | } 243 | } 244 | 245 | /** 246 | * @extends Index 247 | */ 248 | final class ReflectHintIndex extends Index { 249 | /** @var array */ 250 | private array $map = []; 251 | 252 | public function reset() : void { 253 | $this->map = []; 254 | } 255 | 256 | public function index($object) : void { 257 | $this->map[$object->class] = $object->kind; 258 | } 259 | 260 | public function lookup(string $class) : ?string { 261 | $this->sync(); 262 | 263 | return $this->map[$class] ?? null; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /lib/registry.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class RegistryImpl implements Registry { 25 | /** @var T[] $objects */ 26 | private array $objects = []; 27 | 28 | private int $generation = 0; 29 | 30 | /** 31 | * @param ?Registry $field 32 | * @return Registry 33 | */ 34 | public static function getInstance(?Registry &$field) : Registry { 35 | return $field ??= new self; 36 | } 37 | 38 | public function register($object) : void { 39 | $this->objects[] = $object; 40 | $this->generation += 1; 41 | } 42 | 43 | public function getGeneration() : int { 44 | return $this->generation; 45 | } 46 | 47 | public function getAll() : array { 48 | return $this->objects; 49 | } 50 | } 51 | 52 | /** 53 | * Maintains search indices for objects from multiple registries. 54 | * 55 | * @template T 56 | */ 57 | abstract class Index { 58 | /** @var ?list */ 59 | private ?array $lastSyncGenerations = null; 60 | 61 | /** 62 | * @param Registry[] $registries 63 | */ 64 | public function __construct( 65 | private array $registries, 66 | ) { 67 | } 68 | 69 | /** 70 | * @param Registry $newRegistry 71 | */ 72 | public function addLocalRegistry(int $position, Registry $newRegistry) : void { 73 | array_splice($this->registries, $position, 0, [$newRegistry]); 74 | } 75 | 76 | private function isSynced() : bool { 77 | if ($this->lastSyncGenerations === null || count($this->lastSyncGenerations) !== count($this->registries)) { 78 | return false; 79 | } 80 | 81 | foreach ($this->registries as $i => $registry) { 82 | if ($registry->getGeneration() !== $this->lastSyncGenerations[$i]) { 83 | return false; 84 | } 85 | } 86 | 87 | return true; 88 | } 89 | 90 | public function sync() : void { 91 | if ($this->isSynced()) { 92 | return; 93 | } 94 | 95 | $this->reset(); 96 | $this->lastSyncGenerations = []; 97 | 98 | foreach ($this->registries as $i => $registry) { 99 | $this->lastSyncGenerations[$i] = $registry->getGeneration(); 100 | 101 | foreach ($registry->getAll() as $object) { 102 | $this->index($object); 103 | } 104 | } 105 | } 106 | 107 | public abstract function reset() : void; 108 | 109 | /** 110 | * @param T $object 111 | */ 112 | public abstract function index($object) : void; 113 | } 114 | 115 | final class Registries { 116 | /** 117 | * @param Registry $kindMetas 118 | * @param Registry $displays 119 | * @param Registry $mappings 120 | * @param Registry $hints 121 | */ 122 | public function __construct( 123 | public Registry $kindMetas, 124 | public Registry $displays, 125 | public Registry $mappings, 126 | public Registry $hints, 127 | ) { 128 | } 129 | 130 | public static function empty() : self { 131 | /** @var Registry $kindMetas */ 132 | $kindMetas = new RegistryImpl; 133 | /** @var Registry $displays */ 134 | $displays = new RegistryImpl; 135 | /** @var Registry $mappings */ 136 | $mappings = new RegistryImpl; 137 | /** @var Registry $hints */ 138 | $hints = new RegistryImpl; 139 | 140 | return new self( 141 | kindMetas: $kindMetas, 142 | displays: $displays, 143 | mappings: $mappings, 144 | hints: $hints, 145 | ); 146 | } 147 | 148 | public static function singletons() : self { 149 | /** @var Registry $kindMetas */ 150 | $kindMetas = RegistryImpl::getInstance(KindMeta::$global); 151 | /** @var Registry $displays */ 152 | $displays = RegistryImpl::getInstance(Display::$global); 153 | /** @var Registry $mappings */ 154 | $mappings = RegistryImpl::getInstance(Mapping::$global); 155 | /** @var Registry $hints */ 156 | $hints = RegistryImpl::getInstance(ReflectHint::$global); 157 | 158 | return new self( 159 | kindMetas: $kindMetas, 160 | displays: $displays, 161 | mappings: $mappings, 162 | hints: $hints, 163 | ); 164 | } 165 | } 166 | 167 | final class Indices implements ReadIndices { 168 | /** 169 | * @param Registries[] $fallbackRegistries The non-default registries that this Indices object reads from. 170 | */ 171 | public function __construct( 172 | public Registries $registries, 173 | public DisplayIndex $displays, 174 | public NamedMappingIndex $namedMappings, 175 | public ImplicitMappingIndex $implicitMappings, 176 | public ReflectHintIndex $hints, 177 | public array $fallbackRegistries = [], 178 | ) { 179 | } 180 | 181 | public static function forTest() : Indices { 182 | $registries = Registries::empty(); 183 | return new self( 184 | registries: $registries, 185 | displays: new DisplayIndex([$registries->displays]), 186 | namedMappings: new NamedMappingIndex([$registries->mappings]), 187 | implicitMappings: new ImplicitMappingIndex([$registries->mappings]), 188 | hints: new ReflectHintIndex([$registries->hints]), 189 | fallbackRegistries: [], 190 | ); 191 | } 192 | 193 | public static function withDefaults(InitContext $initCtx, Registries $extension) : Indices { 194 | $defaults = Registries::empty(); 195 | Defaults\Index::registerStandardKinds($defaults->hints); 196 | 197 | $indices = new Indices( 198 | registries: $defaults, 199 | displays: new DisplayIndex([$defaults->displays, $extension->displays]), 200 | namedMappings: new NamedMappingIndex([$defaults->mappings, $extension->mappings]), 201 | implicitMappings: new ImplicitMappingIndex([$defaults->mappings, $extension->mappings]), 202 | hints: new ReflectHintIndex([$defaults->hints, $extension->hints]), 203 | fallbackRegistries: [$defaults], 204 | ); 205 | Defaults\Index::register($initCtx, $indices); 206 | 207 | $indices->registries = $extension; 208 | 209 | return $indices; 210 | } 211 | 212 | public function getDisplays() : DisplayIndex { 213 | return $this->displays; 214 | } 215 | public function getNamedMappings() : NamedMappingIndex { 216 | return $this->namedMappings; 217 | } 218 | public function getImplicitMappings() : ImplicitMappingIndex { 219 | return $this->implicitMappings; 220 | } 221 | public function getReflectHints() : ReflectHintIndex { 222 | return $this->hints; 223 | } 224 | 225 | public function readonly() : ReadonlyIndices { 226 | return new ReadonlyIndices( 227 | displays: $this->displays, 228 | namedMappings: $this->namedMappings, 229 | implicitMappings: $this->implicitMappings, 230 | hints: $this->hints, 231 | ); 232 | } 233 | } 234 | 235 | interface ReadIndices { 236 | public function getDisplays() : DisplayIndex ; 237 | public function getNamedMappings() : NamedMappingIndex ; 238 | public function getImplicitMappings() : ImplicitMappingIndex ; 239 | public function getReflectHints() : ReflectHintIndex ; 240 | } 241 | 242 | final class ReadonlyIndices implements ReadIndices { 243 | public function __construct( 244 | public DisplayIndex $displays, 245 | public NamedMappingIndex $namedMappings, 246 | public ImplicitMappingIndex $implicitMappings, 247 | public ReflectHintIndex $hints, 248 | ) { 249 | } 250 | 251 | public function getDisplays() : DisplayIndex { 252 | return $this->displays; 253 | } 254 | public function getNamedMappings() : NamedMappingIndex { 255 | return $this->namedMappings; 256 | } 257 | public function getImplicitMappings() : ImplicitMappingIndex { 258 | return $this->implicitMappings; 259 | } 260 | public function getReflectHints() : ReflectHintIndex { 261 | return $this->hints; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /lib/template.php: -------------------------------------------------------------------------------- 1 | elements as $element) { 32 | if ($element instanceof Ast\RawText) { 33 | $self->elements[] = new RawText($element->original); 34 | } else { 35 | $self->elements[] = self::toCoalescePath($indices, $sourceKind, $element, requireDisplayable: true, expectTargetKind: null, pathToChoice: function(ResolvedPath $path) use ($indices) : PathWithDisplay { 36 | $display = $indices->getDisplays()->getDisplay($path->getTargetKind()) ?? throw new RuntimeException("canDisplay admitted tail kind"); 37 | return new PathWithDisplay($path, $display); 38 | }); 39 | } 40 | } 41 | 42 | return $self; 43 | } 44 | 45 | /** 46 | * @template ChoiceT of CoalesceChoice 47 | * @param Closure(ResolvedPath): ChoiceT $pathToChoice 48 | * @return CoalescePath 49 | */ 50 | public static function toCoalescePath(ReadIndices $indices, string $sourceKind, Ast\Expr $element, bool $requireDisplayable, ?string $expectTargetKind, Closure $pathToChoice) : CoalescePath { 51 | $choices = []; 52 | for ($expr = $element; $expr !== null; $expr = $expr->else) { 53 | $path = self::resolveInfoPath($indices, $expr->main, $sourceKind, function(string $kind) use ($indices, $requireDisplayable, $expectTargetKind) : bool { 54 | if ($requireDisplayable && !$indices->getDisplays()->canDisplay($kind)) { 55 | return false; 56 | } 57 | 58 | if ($expectTargetKind !== null && $kind !== $expectTargetKind) { 59 | return false; 60 | } 61 | 62 | return true; 63 | }); 64 | if ($path !== null) { 65 | $choices[] = $pathToChoice($path); 66 | } 67 | } 68 | 69 | $raw = self::toRawString($element->main); 70 | return new CoalescePath($raw, $choices); 71 | } 72 | 73 | /** 74 | * @param Closure(string): bool $admitTailKind 75 | */ 76 | private static function resolveInfoPath(ReadIndices $indices, Ast\InfoExpr $infoExpr, string $sourceKind, Closure $admitTailKind) : ?ResolvedPath { 77 | $calls = self::extractMappingCalls($infoExpr); 78 | $paths = Pathfind\Finder::find($indices, array_map(fn(MappingCall $call) => $call->name, $calls), $sourceKind, $admitTailKind); 79 | 80 | if (count($paths) === 0) { 81 | return null; 82 | } 83 | 84 | foreach ($paths as $path) { 85 | $segments = []; 86 | $callIndex = 0; 87 | if (count($path->mappings) === 0) { 88 | throw new RuntimeException("path cannot have no mappings"); 89 | } 90 | foreach ($path->mappings as $mapping) { 91 | $args = []; 92 | if (!$mapping->isImplicit) { 93 | $call = $calls[$callIndex]; 94 | $callIndex += 1; 95 | 96 | $args = self::matchArgsToParams($indices, $sourceKind, $call->args ?? [], $mapping->parameters); 97 | } elseif (count($mapping->parameters) > 0) { 98 | throw new RuntimeException(sprintf("Mapping %s requires parameters and cannot be implicit", implode(Mapping::FQN_SEPARATOR, $mapping->qualifiedName))); 99 | } 100 | 101 | $segments[] = new ResolvedPathSegment($mapping, $args); 102 | } 103 | return new ResolvedPath($segments); 104 | } 105 | } 106 | 107 | private static function toRawString(Ast\InfoExpr $infoExpr) : string { 108 | $calls = self::extractMappingCalls($infoExpr); 109 | $callStrings = array_map(fn(MappingCall $call) => implode(Mapping::FQN_SEPARATOR, $call->name->tokens), $calls); 110 | return implode(" ", $callStrings); 111 | } 112 | 113 | /** 114 | * @return MappingCall[] 115 | */ 116 | private static function extractMappingCalls(Ast\InfoExpr $path) : array { 117 | $calls = []; 118 | if ($path->parent !== null) { 119 | $calls = self::extractmappingCalls($path->parent); 120 | } 121 | $calls[] = $path->call; 122 | return $calls; 123 | } 124 | 125 | /** 126 | * @param Ast\Arg[] $astArgs 127 | * @param Parameter[] $params 128 | * @return ResolvedPathArg[] 129 | */ 130 | private static function matchArgsToParams(ReadIndices $indices, string $sourceKind, array $astArgs, array $params) : array { 131 | $namedParams = []; 132 | $resolved = []; 133 | foreach ($params as $i => $param) { 134 | $namedParams[$param->name] = $i; 135 | $resolved[$i] = ResolvedPathArg::unset($param); 136 | } 137 | $nextPositional = range(0, count($params)); 138 | 139 | foreach ($astArgs as $astArg) { 140 | // TODO support multi-args 141 | 142 | $argName = $astArg->name; 143 | if ($argName !== null) { 144 | if (!isset($namedParams[$argName])) { 145 | // invalid argument, let's drop it for now 146 | // TODO: elegantly pass parsing errors upwards 147 | continue; 148 | } 149 | $index = $namedParams[$argName]; 150 | } else { 151 | if (count($nextPositional) === 0) { 152 | // TODO: elegantly pass parsing errors upwards 153 | continue; 154 | } 155 | $index = array_keys($nextPositional)[0]; 156 | } 157 | $param = $params[$index]; 158 | unset($nextPositional[$index]); 159 | 160 | $resolvedArg = ResolvedPathArg::fromAst($indices, $sourceKind, $astArg, $param); 161 | $resolved[$index] = $resolvedArg; 162 | } 163 | 164 | return $resolved; 165 | } 166 | 167 | private function __construct() { 168 | } 169 | 170 | /** @var TemplateElement[] */ 171 | private array $elements = []; 172 | 173 | /** 174 | * @template R of RenderedElement 175 | * @template G of RenderedGroup 176 | * @template T of GetOrWatch 177 | * @param T $getOrWatch 178 | * @return G 179 | */ 180 | public function render(mixed $context, ?CommandSender $sender, GetOrWatch $getOrWatch) : RenderedGroup { 181 | $elements = []; 182 | 183 | foreach ($this->elements as $element) { 184 | $rendered = $element->render($context, $sender, $getOrWatch); 185 | $elements[] = $rendered; 186 | } 187 | 188 | return $getOrWatch->buildResult($elements); 189 | } 190 | } 191 | 192 | final class ResolvedPath { 193 | /** 194 | * @param non-empty-array $segments 195 | */ 196 | public function __construct( 197 | public array $segments, 198 | ) { 199 | } 200 | 201 | public function getTargetKind() : string { 202 | return $this->segments[count($this->segments) - 1]->mapping->targetKind; 203 | } 204 | } 205 | 206 | final class ResolvedPathSegment { 207 | /** 208 | * @param list $args 209 | */ 210 | public function __construct( 211 | public Mapping $mapping, 212 | public array $args, 213 | ) { 214 | } 215 | } 216 | 217 | final class ResolvedPathArg { 218 | /** 219 | * $path and $constantValue are exclusive. 220 | * 221 | * @param ?CoalescePath $path 222 | */ 223 | private function __construct( 224 | public Parameter $param, 225 | public ?CoalescePath $path, 226 | public mixed $constantValue, 227 | ) { 228 | } 229 | 230 | public static function unset(Parameter $param) : self { 231 | return new self($param, path: null, constantValue: null); 232 | } 233 | public static function fromAst(ReadIndices $indices, string $sourceKind, Ast\Arg $astArg, Parameter $param) : self { 234 | if ($astArg->value instanceof Ast\JsonValue) { 235 | $value = json_decode($astArg->value->json); 236 | return new self($param, path: null, constantValue: $value); 237 | } else { 238 | $expr = $astArg->value; 239 | $path = Template::toCoalescePath($indices, $sourceKind, $expr, requireDisplayable: false, expectTargetKind: $param->kind, pathToChoice: fn(ResolvedPath $path) => new PathOnly($path)); 240 | return new self($param, path: $path, constantValue: null); 241 | } 242 | } 243 | } 244 | 245 | /** 246 | * @template R of RenderedElement 247 | * @template G of RenderedGroup 248 | */ 249 | interface GetOrWatch { 250 | /** 251 | * @param R[] $elements 252 | * @return G 253 | */ 254 | public function buildResult(array $elements) : RenderedGroup; 255 | 256 | /** 257 | * @return EvalChain 258 | */ 259 | public function startEvalChain() : EvalChain; 260 | 261 | /** 262 | * @return R 263 | */ 264 | public function staticElement(string $raw) : RenderedElement; 265 | } 266 | 267 | interface NestedEvalChain { 268 | /** 269 | * Add a step in the chain to map the return value of the previous step. 270 | * The first step receives null. 271 | * 272 | * @param Closure(mixed): mixed $map 273 | * @param ?Closure(mixed): ?Traverser $subscribe 274 | */ 275 | public function then(Closure $map, ?Closure $subscribe) : void; 276 | 277 | /** 278 | * Returns true if the inference is non-watching and the last step returned non-null. 279 | */ 280 | public function breakOnNonNull() : bool; 281 | } 282 | 283 | /** 284 | * @template R of RenderedElement 285 | */ 286 | interface EvalChain extends NestedEvalChain { 287 | /** 288 | * Returns a RenderedElement that performs the steps executed in this chain so far. 289 | * 290 | * @return R 291 | */ 292 | public function getResultAsElement() : RenderedElement; 293 | } 294 | 295 | final class StackedEvalChain implements NestedEvalChain { 296 | public function __construct(private NestedEvalChain $chain) { 297 | // state: [parentState, isChildBroken, childState] 298 | $this->chain->then(function($parentState) { 299 | return [$parentState, false, null]; 300 | }, null); 301 | } 302 | 303 | public function then(Closure $map, ?Closure $subscribe) : void { 304 | $this->chain->then(function($state) use ($map) { 305 | /** @var array{mixed, bool, mixed} $state */ 306 | [$parentState, $isBroken, $myState] = $state; 307 | if ($isBroken) { 308 | return; 309 | } 310 | $myState = $map($myState); 311 | return [$parentState, $isBroken, $myState]; 312 | }, function($state) use ($subscribe) { 313 | /** @var array{mixed, bool, mixed} $state */ 314 | [$_parentState, $isBroken, $myState] = $state; 315 | return ($isBroken || $subscribe === null) ? null : $subscribe($myState); 316 | } ); 317 | } 318 | 319 | public function breakOnNonNull() : bool { 320 | $this->chain->then(function($state) { 321 | /** @var array{mixed, bool, mixed} $state */ 322 | [$parentState, $isBroken, $myState] = $state; 323 | $isBroken = $isBroken || $myState !== null; 324 | return [$parentState, $isBroken, $myState]; 325 | }, null); 326 | return false; 327 | } 328 | 329 | /** 330 | * Complete this stack. Merge the stacked result into the original value. 331 | */ 332 | public function finish(Closure $merge) : void { 333 | $this->chain->then(function($state) use ($merge) { 334 | /** @var array{mixed, bool, mixed} $state */ 335 | [$parentState, $_isBroken, $myState] = $state; 336 | return $merge($parentState, $myState); 337 | }, null); 338 | } 339 | } 340 | 341 | interface RenderedElement { 342 | } 343 | 344 | interface RenderedGroup { 345 | } 346 | -------------------------------------------------------------------------------- /lib/template/element.php: -------------------------------------------------------------------------------- 1 | $getOrWatch 19 | * @return R 20 | */ 21 | public function render(mixed $context, ?CommandSender $sender, GetOrWatch $getOrWatch) : RenderedElement; 22 | } 23 | 24 | final class RawText implements TemplateElement { 25 | public function __construct(public string $raw) { 26 | } 27 | 28 | public function render(mixed $context, ?CommandSender $sender, GetOrWatch $getOrWatch) : RenderedElement { 29 | return $getOrWatch->staticElement($this->raw); 30 | } 31 | } 32 | 33 | final class StaticRenderedElement implements RenderedGetElement, RenderedWatchElement { 34 | public function __construct(private string $raw) { 35 | } 36 | 37 | public function get() : string { 38 | return $this->raw; 39 | } 40 | 41 | public function watch() : Traverser { 42 | return Traverser::fromClosure(function() { 43 | yield $this->raw => Traverser::VALUE; 44 | }); 45 | } 46 | } 47 | 48 | /** 49 | * @template ChoiceT of CoalesceChoice 50 | */ 51 | final class CoalescePath implements TemplateElement { 52 | /** 53 | * @param string $raw Fallback display if the string cannot be resolved 54 | * @param ChoiceT[] $choices 55 | */ 56 | public function __construct( 57 | public string $raw, 58 | public array $choices, 59 | ) { 60 | } 61 | 62 | private function populateChain(mixed $context, NestedEvalChain $chain, bool $display, ?CommandSender $sender) : bool { 63 | foreach ($this->choices as $choice) { 64 | // initialize starting state 65 | $chain->then( 66 | fn($_) => [$context, []], 67 | null, 68 | ); 69 | 70 | foreach ($choice->getPath()->segments as $segment) { 71 | // TODO optimization: make these argument triggers parallel instead of serial 72 | foreach ($segment->args as $arg) { 73 | if ($arg->path !== null) { 74 | $child = new StackedEvalChain($chain); 75 | $arg->path->populateChain($context, $child, false, null); 76 | $child->finish(function($state, $argChainResult) { 77 | /** @var array{mixed, mixed[]} $state */ 78 | [$receiver, $args] = $state; 79 | [$argResult, $_argArgs] = $argChainResult; 80 | $args[] = $argResult; 81 | return [$receiver, $args]; 82 | }); 83 | } else { 84 | $chain->then(function($state) use ($arg) { 85 | /** @var array{mixed, mixed[]} $state */ 86 | [$receiver, $args] = $state; 87 | $args[] = $arg->constantValue; 88 | return [$receiver, $args]; 89 | }, null); 90 | } 91 | } 92 | 93 | $chain->then( 94 | function($state) use ($segment) { 95 | /** @var array{mixed, mixed[]} $state */ 96 | [$receiver, $args] = $state; 97 | return [($segment->mapping->map)($receiver, $args), []]; 98 | }, 99 | $segment->mapping->subscribe === null ? null : fn($state) => new Traverser(($segment->mapping->subscribe)($state[0], $state[1])), 100 | ); 101 | } 102 | 103 | if (($display = $choice->getDisplay()) !== null) { 104 | $chain->then( 105 | function($state) use ($display, $sender) { 106 | /** @var array{mixed, mixed[]} $state */ 107 | return $state[0] !== null ? ($display->display)($state[0], $sender) : null; 108 | }, 109 | null, 110 | ); 111 | 112 | if ($chain->breakOnNonNull()) { 113 | return true; 114 | } 115 | } 116 | } 117 | 118 | return false; 119 | } 120 | 121 | public function render(mixed $context, ?CommandSender $sender, GetOrWatch $getOrWatch) : RenderedElement { 122 | $chain = $getOrWatch->startEvalChain(); // double dispatch 123 | if ($this->populateChain($context, $chain, true, $sender)) { 124 | return $chain->getResultAsElement(); 125 | } 126 | 127 | $chain->then( 128 | fn($_) => sprintf( 129 | "{%s:%s}", 130 | count($this->choices) === 0 ? "unknownPath" : "null", // error message 131 | $this->raw, 132 | ), 133 | null, 134 | ); 135 | return $chain->getResultAsElement(); 136 | } 137 | } 138 | 139 | interface CoalesceChoice { 140 | public function getPath() : ResolvedPath; 141 | public function getDisplay() : ?Display; 142 | } 143 | 144 | final class PathOnly implements CoalesceChoice { 145 | public function __construct( 146 | public ResolvedPath $path, 147 | ) { 148 | } 149 | 150 | public function getPath() : ResolvedPath { 151 | return $this->path; 152 | } 153 | 154 | public function getDisplay() : ?Display { 155 | return null; 156 | } 157 | } 158 | 159 | final class PathWithDisplay implements CoalesceChoice { 160 | public function __construct( 161 | public ResolvedPath $path, 162 | public Display $display, 163 | ) { 164 | } 165 | 166 | public function getPath() : ResolvedPath { 167 | return $this->path; 168 | } 169 | 170 | public function getDisplay() : ?Display { 171 | return $this->display; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /lib/template/get.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class Get implements GetOrWatch { 15 | public function buildResult(array $elements) : RenderedGroup { 16 | $rendered = []; 17 | foreach ($elements as $element) { 18 | $rendered[] = $element; 19 | } 20 | return new RenderedGetGroup($rendered); 21 | } 22 | 23 | public function startEvalChain() : EvalChain { 24 | return new GetEvalChain; 25 | } 26 | 27 | public function staticElement(string $raw) : RenderedElement { 28 | return new StaticRenderedElement($raw); 29 | } 30 | } 31 | 32 | /** 33 | * @implements EvalChain 34 | */ 35 | final class GetEvalChain implements EvalChain { 36 | private mixed $state = null; 37 | 38 | public function then(Closure $map, ?Closure $subscribe) : void { 39 | $this->state = $map($this->state); 40 | } 41 | 42 | public function breakOnNonNull() : bool { 43 | return $this->state !== null; 44 | } 45 | 46 | public function getResultAsElement() : RenderedElement { 47 | if (!is_string($this->state)) { 48 | throw new RuntimeException("Last mapper must return string"); 49 | } 50 | return new StaticRenderedElement($this->state); 51 | } 52 | } 53 | 54 | interface RenderedGetElement extends RenderedElement { 55 | public function get() : string; 56 | } 57 | 58 | final class RenderedGetGroup implements RenderedGroup { 59 | /** 60 | * @param RenderedGetElement[] $elements 61 | */ 62 | public function __construct(private array $elements) { 63 | } 64 | 65 | public function get() : string { 66 | $output = ""; 67 | foreach ($this->elements as $element) { 68 | $output .= $element->get(); 69 | } 70 | return $output; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/template/watch.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class Watch implements GetOrWatch { 21 | public function buildResult(array $elements) : RenderedGroup { 22 | return new RenderedWatchGroup($elements); 23 | } 24 | 25 | public function startEvalChain() : EvalChain { 26 | return new WatchEvalChain; 27 | } 28 | 29 | public function staticElement(string $raw) : RenderedElement { 30 | return new StaticRenderedElement($raw); 31 | } 32 | } 33 | 34 | /** 35 | * @implements EvalChain 36 | */ 37 | final class WatchEvalChain implements EvalChain, RenderedWatchElement { 38 | private int $counter = 0; 39 | 40 | /** @var list */ 41 | private array $maps = []; 42 | /** @var array> */ 43 | private array $subFuncs = []; 44 | 45 | /** @var array */ 46 | private array $breakpoints = []; 47 | 48 | /** @var mixed[] */ 49 | private array $values = []; 50 | /** @var list> */ 51 | private array $traversers = []; 52 | 53 | public function then(Closure $map, ?Closure $subFunc) : void { 54 | $index = $this->counter++; 55 | 56 | $this->maps[$index] = $map; 57 | if ($subFunc !== null) { 58 | $this->subFuncs[$index] = $subFunc; 59 | } 60 | } 61 | 62 | public function breakOnNonNull() : bool { 63 | $this->breakpoints[$this->counter] = true; 64 | return false; 65 | } 66 | 67 | public function getResultAsElement() : RenderedElement { 68 | return $this; 69 | } 70 | 71 | public function watch() : Traverser { 72 | return Traverser::fromClosure(function() { 73 | try { 74 | while (true) { 75 | yield $this->getOnce() => Traverser::VALUE; 76 | 77 | $racers = []; 78 | foreach ($this->traversers as $k => $traverser) { 79 | if ($traverser !== null) { 80 | $racers[$k] = $traverser->next($_); 81 | } 82 | } 83 | if(count($racers) === 0) { 84 | // the entire expression is static 85 | break; 86 | } 87 | 88 | [$k, $running] = yield from Await::safeRace($racers); 89 | if ($running) { 90 | yield from $this->truncateTraversers($k); 91 | } else { 92 | // finalized traverser, no updates 93 | $this->traversers[$k] = null; 94 | } 95 | } 96 | } finally { 97 | yield from $this->truncateTraversers(0); 98 | } 99 | }); 100 | } 101 | 102 | private function truncateTraversers(int $min) : Generator { 103 | for ($index = $min; $index < $this->counter; $index++) { 104 | unset($this->values[$index]); 105 | if (isset($this->traversers[$index])) { 106 | yield from $this->traversers[$index]->interrupt(); 107 | } 108 | unset($this->traversers[$index]); 109 | } 110 | } 111 | 112 | private function getOnce() : string { 113 | for ($index = 0; $index < $this->counter; $index++) { 114 | $prev = $index > 0 ? $this->values[$index - 1] : null; 115 | 116 | if (isset($this->breakpoints[$index])) { 117 | if ($index > 0 && is_string($prev)) { 118 | return $this->values[$index - 1]; 119 | } 120 | } 121 | 122 | if (!isset($this->values[$index])) { 123 | $this->values[$index] = ($this->maps[$index])($prev); 124 | 125 | $trigger = isset($this->subFuncs[$index]) ? $this->subFuncs[$index]($prev) : null; 126 | $this->traversers[$index] = $trigger; 127 | } 128 | } 129 | 130 | $last = $this->values[$this->counter - 1]; 131 | if (!is_string($last)) { 132 | throw new RuntimeException("EvalChain::watch() cannot be called before a final then() to conclude errors"); 133 | } 134 | 135 | return $last; 136 | } 137 | } 138 | 139 | interface RenderedWatchElement extends RenderedElement { 140 | /** 141 | * @return Traverser 142 | */ 143 | public function watch() : Traverser; 144 | } 145 | 146 | final class RenderedWatchGroup implements RenderedGroup { 147 | /** 148 | * @param RenderedWatchElement[] $elements 149 | */ 150 | public function __construct(private array $elements) { 151 | } 152 | 153 | /** 154 | * @return Traverser 155 | */ 156 | public function watch() : Traverser { 157 | return Traverser::fromClosure(function() { 158 | $traversers = []; 159 | try { 160 | foreach ($this->elements as $element) { 161 | $traversers[] = $element->watch(); 162 | } 163 | 164 | /** @var array $strings */ 165 | $strings = []; 166 | while (true) { 167 | /** @var Generator[] $racers */ 168 | $racers = []; 169 | foreach ($traversers as $k => $traverser) { 170 | if ($traverser !== null) { 171 | $racers[$k] = $traverser->next($strings[$k]); 172 | } 173 | } 174 | if(count($racers) === 0) { 175 | // the entire template is static 176 | break; 177 | } 178 | 179 | [$k, $running] = yield from Await::safeRace($racers); 180 | if (!$running) { 181 | // no more updates in this traverser (currently unreachable, but let's support this case anyway) 182 | unset($traversers[$k]); 183 | continue; 184 | } 185 | 186 | if (count($strings) === count($this->elements)) { 187 | yield implode("", $strings) => Traverser::VALUE; 188 | } 189 | } 190 | } finally { 191 | foreach ($traversers as $traverser) { 192 | yield from $traverser->interrupt(); 193 | } 194 | } 195 | }); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Negated boolean expression is always true\\.$#" 5 | count: 1 6 | path: lib/ast.php 7 | 8 | - 9 | message: "#^Unreachable statement \\- code above always terminates\\.$#" 10 | count: 1 11 | path: lib/ast.php 12 | 13 | - 14 | message: "#^While loop condition is always true\\.$#" 15 | count: 1 16 | path: lib/context.php 17 | 18 | - 19 | message: "#^While loop condition is always true\\.$#" 20 | count: 1 21 | path: lib/defaults/world.php 22 | 23 | - 24 | message: "#^Unable to resolve the template type T in call to method static method SOFe\\\\InfoAPI\\\\ReflectUtil\\:\\:correctClosure\\(\\)$#" 25 | count: 2 26 | path: lib/reflect.php 27 | 28 | - 29 | message: "#^While loop condition is always true\\.$#" 30 | count: 2 31 | path: lib/template/watch.php 32 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | parameters: 4 | level: 9 5 | paths: 6 | - shared 7 | - lib 8 | - tests 9 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | lib 11 | 12 | 13 | 14 | tests 15 | 16 | 17 | -------------------------------------------------------------------------------- /shared/display.php: -------------------------------------------------------------------------------- 1 | */ 18 | public static ?Registry $global = null; 19 | 20 | public function __construct( 21 | /** The kind that this display object describes */ 22 | public string $kind, 23 | 24 | /** 25 | * Displays a value for a template. 26 | * 27 | * @var Closure(mixed $value, ?CommandSender $sender): string 28 | */ 29 | public Closure $display, 30 | ) { 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/kind-help.php: -------------------------------------------------------------------------------- 1 | */ 12 | public static ?Registry $global = null; 13 | 14 | public function __construct( 15 | /** The kind that this object describes */ 16 | public string $kind, 17 | 18 | /** A short, human-readable name for this type */ 19 | public ?string $shortName, 20 | 21 | /** Help message for the kind. */ 22 | public ?string $help, 23 | 24 | /** 25 | * Additional non-standard metadata to describe this kind. 26 | * 27 | * @var array 28 | */ 29 | public array $metadata, 30 | ) { 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/mapping.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public static ?Registry $global = null; 21 | 22 | /** The separator for imploding/exploding a fully-qualified name. */ 23 | public const FQN_SEPARATOR = ":"; 24 | 25 | /** The regex charset that tokens must match. */ 26 | public const FQN_TOKEN_REGEX_CHARSET = "A-Za-z0-9_-"; 27 | 28 | public function __construct( 29 | /** 30 | * The fully-qualified name of the mapping. 31 | * 32 | * A fully-qualified name is a linear list of strings, 33 | * starting with the highest-level namespaces and ending with the short name. 34 | * A fully-qualified name should be matched by any subsequence of the linear list that ends with the same short name. 35 | * Each component of the fully-qualified name must match /[A-Za-z0-9_\-]/ and must not be `true` or `false`. 36 | * 37 | * A fully-qualified name must be unique among all mappings for the same source kind. 38 | * 39 | * @var string[] 40 | */ 41 | public array $qualifiedName, 42 | 43 | /** The kind that the mapping accepts. */ 44 | public string $sourceKind, 45 | 46 | /** The kind that the mapping emits. */ 47 | public string $targetKind, 48 | 49 | /** 50 | * Whether this mapping can be automatically executed if no matches are found. 51 | * 52 | * If this is true, $parameters must be empty. 53 | */ 54 | public bool $isImplicit, 55 | 56 | /** 57 | * A linear list of all parameters that the mapping accepts. 58 | * 59 | * @var Parameter[] 60 | */ 61 | public array $parameters, 62 | 63 | /** 64 | * Converts the input value. 65 | * 66 | * The $source parameter is a value reported to be the source kind. 67 | * The $args parameter accepts the arguments for a mapping operation in the same order as $parameters. 68 | * The closure should return a value of the target kind. 69 | * 70 | * The closure should validate the types of source and args. 71 | * Invalid inputs should result in an invalid output. 72 | * A null value is not necessarily short-circuited by the framework. 73 | * The `ReflectUtil` class short-circuits a single mapping to return null if inputs are invalid, 74 | * but subsequent mappings are still executed based on null. 75 | * A null output may be used for coalescence in tepmlating. 76 | * 77 | * @var Closure(mixed $source, mixed[] $args): mixed 78 | */ 79 | public Closure $map, 80 | 81 | /** 82 | * Watches the inputs for changes. 83 | * 84 | * The closure has the same inputs as $map. 85 | * It returns a generator that implements the await-generator protocol with the Traverser extension. 86 | * It should traverse arbitrary values to indicate a change in the mapped value. 87 | * The traversed value is not handled by the framework. 88 | * 89 | * If the target kind is also a mutable object, 90 | * a "change" is defined as the representation of a fundamentally different object 91 | * that would lead to different subscription in transitive mappings from the mapped value. 92 | * For example, if the target kind is a player, 93 | * a "change" should only be indicated when the mapping points to a different player, 94 | * but not when information of the player itself (e.g. player location) changes. 95 | * Detailed semantics could be further specified by the definition of the target kind, 96 | * but in general, "change" does not imply and is not implied by a `!==` in the returned value. 97 | * 98 | * @var ?Closure(mixed $source, mixed[] $args): Generator 99 | */ 100 | public ?Closure $subscribe, 101 | 102 | /** 103 | * Help message of this mapping. 104 | * 105 | * Used in info discovery and documentation. 106 | */ 107 | public string $help, 108 | 109 | /** 110 | * Additional non-standard metadata to describe this mapping. 111 | * 112 | * @var array 113 | */ 114 | public array $metadata, 115 | ) { 116 | } 117 | } 118 | 119 | /** 120 | * Defines a parameter required for a mapping. 121 | */ 122 | final class Parameter { 123 | public function __construct( 124 | /** The name of the parameter. */ 125 | public string $name, 126 | 127 | /** 128 | * The kind of the parameter info. 129 | * Parameters of primitive types may accept literal expressions too. 130 | */ 131 | public string $kind, 132 | 133 | /** Whether this parameter can be required multiple times. */ 134 | public bool $multi, 135 | 136 | /** Whether this parameter is optional. */ 137 | public bool $optional, 138 | 139 | /** 140 | * Additional non-standard metadata to describe this mapping. 141 | * 142 | * @var array 143 | */ 144 | public array $metadata, 145 | ) { 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /shared/reflect.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public static ?Registry $global = null; 16 | 17 | public function __construct( 18 | /** @var class-string The object class */ 19 | public string $class, 20 | /** @var string The kind of this class */ 21 | public string $kind, 22 | ) { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/registry.php: -------------------------------------------------------------------------------- 1 | name); 17 | self::assertInstanceOf(JsonValue::class, $arg->value); 18 | self::assertSame("true", $arg->value->asString); 19 | self::assertSame(", xxx", $parser->peekAll()); 20 | } 21 | 22 | public function testParseNamedFalseArg() : void { 23 | $parser = new StringParser("arg=false) xxx"); 24 | $arg = Parse::parseArg($parser); 25 | self::assertSame("arg", $arg->name); 26 | self::assertInstanceOf(JsonValue::class, $arg->value); 27 | self::assertSame("false", $arg->value->asString); 28 | self::assertSame(") xxx", $parser->peekAll()); 29 | } 30 | 31 | public function testParseUnnamedTrueArg() : void { 32 | $parser = new StringParser("true) xxx"); 33 | $arg = Parse::parseArg($parser); 34 | self::assertNull($arg->name); 35 | self::assertInstanceOf(JsonValue::class, $arg->value); 36 | self::assertSame("true", $arg->value->asString); 37 | self::assertSame(") xxx", $parser->peekAll()); 38 | } 39 | 40 | public function testParseUnnamedFalseArg() : void { 41 | $parser = new StringParser("false, xxx"); 42 | $arg = Parse::parseArg($parser); 43 | self::assertNull($arg->name); 44 | self::assertInstanceOf(JsonValue::class, $arg->value); 45 | self::assertSame("false", $arg->value->asString); 46 | self::assertSame(", xxx", $parser->peekAll()); 47 | } 48 | 49 | public function testParseUnnamedNumericArg() : void { 50 | $parser = new StringParser("-1.23e+45, xxx"); 51 | $arg = Parse::parseArg($parser); 52 | self::assertNull($arg->name); 53 | self::assertInstanceOf(JsonValue::class, $arg->value); 54 | self::assertSame("-1.23e+45", $arg->value->asString); 55 | self::assertSame(", xxx", $parser->peekAll()); 56 | } 57 | 58 | public function testParseUnnamedIntegerArg() : void { 59 | // ensure that `123` doesn't get treated as an arg name. 60 | $parser = new StringParser("123, xxx"); 61 | $arg = Parse::parseArg($parser); 62 | self::assertNull($arg->name); 63 | self::assertInstanceOf(JsonValue::class, $arg->value); 64 | self::assertSame("123", $arg->value->asString); 65 | self::assertSame(", xxx", $parser->peekAll()); 66 | } 67 | 68 | public function testParseUnnamedStringArg() : void { 69 | $input = <<<'EOS' 70 | "a\"b\n\\", xxx 71 | EOS; 72 | $parser = new StringParser($input); 73 | $arg = Parse::parseArg($parser); 74 | self::assertNull($arg->name); 75 | self::assertInstanceOf(JsonValue::class, $arg->value); 76 | self::assertSame("a\"b\n\\", $arg->value->asString); 77 | self::assertSame(strstr($input, ",", true), $arg->value->json); 78 | self::assertSame(", xxx", $parser->peekAll()); 79 | } 80 | 81 | public function testParseCallWithoutArgs() : void { 82 | $parser = new StringParser("foo:bar qux"); 83 | $call = Parse::parseCall($parser); 84 | self::assertSame(["foo", "bar"], $call->name->tokens); 85 | self::assertNull($call->args); 86 | self::assertSame("qux", ltrim($parser->peekAll())); 87 | } 88 | 89 | public function testParseCallWithEmptyArgs() : void { 90 | $parser = new StringParser("foo:bar() qux"); 91 | $call = Parse::parseCall($parser); 92 | self::assertSame(["foo", "bar"], $call->name->tokens); 93 | self::assertSame([], $call->args); 94 | self::assertSame("qux", ltrim($parser->peekAll())); 95 | } 96 | 97 | public function testParseCallWithNamedArgs() : void { 98 | $parser = new StringParser('foo:bar(corge = true, grault = 123e1, baz = "text", waldo = false) qux'); 99 | $call = Parse::parseCall($parser); 100 | self::assertSame(["foo", "bar"], $call->name->tokens); 101 | self::assertNotNull($call->args); 102 | self::assertCount(4, $call->args); 103 | 104 | self::assertSame("corge", $call->args[0]->name); 105 | self::assertInstanceOf(JsonValue::class, $call->args[0]->value); 106 | self::assertSame("true", $call->args[0]->value->json); 107 | 108 | self::assertSame("grault", $call->args[1]->name); 109 | self::assertInstanceOf(JsonValue::class, $call->args[1]->value); 110 | self::assertSame("123e1", $call->args[1]->value->json); 111 | 112 | self::assertSame("baz", $call->args[2]->name); 113 | self::assertInstanceOf(JsonValue::class, $call->args[2]->value); 114 | self::assertSame("text", $call->args[2]->value->asString); 115 | 116 | self::assertSame("waldo", $call->args[3]->name); 117 | self::assertInstanceOf(JsonValue::class, $call->args[3]->value); 118 | self::assertSame("false", $call->args[3]->value->json); 119 | 120 | self::assertSame("qux", ltrim($parser->peekAll())); 121 | } 122 | 123 | public function testParseCallWithUnnamedArgs() : void { 124 | $parser = new StringParser('foo:bar(true, 123e1, "text", false) qux'); 125 | $call = Parse::parseCall($parser); 126 | self::assertSame(["foo", "bar"], $call->name->tokens); 127 | self::assertNotNull($call->args); 128 | self::assertCount(4, $call->args); 129 | 130 | self::assertNull($call->args[0]->name); 131 | self::assertInstanceOf(JsonValue::class, $call->args[0]->value); 132 | self::assertSame("true", $call->args[0]->value->json); 133 | 134 | self::assertNull($call->args[1]->name); 135 | self::assertInstanceOf(JsonValue::class, $call->args[1]->value); 136 | self::assertSame("123e1", $call->args[1]->value->json); 137 | 138 | self::assertNull($call->args[2]->name); 139 | self::assertInstanceOf(JsonValue::class, $call->args[2]->value); 140 | self::assertSame("text", $call->args[2]->value->asString); 141 | 142 | self::assertNull($call->args[3]->name); 143 | self::assertInstanceOf(JsonValue::class, $call->args[3]->value); 144 | self::assertSame("false", $call->args[3]->value->json); 145 | 146 | self::assertSame("qux", ltrim($parser->peekAll())); 147 | } 148 | 149 | public function testParseInfoExprWithoutArgs() : void { 150 | $parser = new StringParser("foo:bar qux |"); 151 | $expr = Parse::parseInfoExpr($parser, null, "|", "}"); 152 | self::assertNotNull($expr->parent); 153 | self::assertSame(["foo", "bar"], $expr->parent->call->name->tokens); 154 | self::assertSame(["qux"], $expr->call->name->tokens); 155 | } 156 | 157 | public function testParseInfoExprWithEmpty() : void { 158 | $parser = new StringParser("foo:bar() qux}"); 159 | $expr = Parse::parseInfoExpr($parser, null, "|", "}"); 160 | self::assertNotNull($expr->parent); 161 | self::assertSame(["foo", "bar"], $expr->parent->call->name->tokens); 162 | self::assertSame(["qux"], $expr->call->name->tokens); 163 | } 164 | 165 | public function testParseExpr() : void { 166 | $parser = new StringParser("foo:bar() qux | corge grault() }"); 167 | $expr = Parse::parseExpr($parser, "}"); 168 | self::assertNotNull($expr->main->parent); 169 | self::assertNull($expr->main->parent->parent); 170 | self::assertSame(["foo", "bar"], $expr->main->parent->call->name->tokens); 171 | self::assertSame(["qux"], $expr->main->call->name->tokens); 172 | self::assertNotNull($expr->else); 173 | self::assertNotNull($expr->else->main->parent); 174 | self::assertNull($expr->else->main->parent->parent); 175 | self::assertSame(["corge"], $expr->else->main->parent->call->name->tokens); 176 | self::assertSame(["grault"], $expr->else->main->call->name->tokens); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /tests/StringParserTest.php: -------------------------------------------------------------------------------- 1 | readExactLength(1)); 14 | 15 | self::assertSame("b", $parser->peek(1)); 16 | self::assertSame("bc", $parser->peek(2)); 17 | 18 | self::assertNull($parser->peek(3)); 19 | } 20 | 21 | public function testReadExactText() : void { 22 | $parser = new StringParser("abc"); 23 | 24 | self::assertTrue($parser->readExactText("a")); 25 | self::assertFalse($parser->readExactText("a")); 26 | self::assertFalse($parser->readExactText("bcd")); 27 | self::assertTrue($parser->readExactText("bc")); 28 | self::assertFalse($parser->readExactText("d")); 29 | } 30 | 31 | public function testReadUntil() : void { 32 | $parser = new StringParser("abcdabcdabcd"); 33 | self::assertSame("", $parser->readUntil("a")); 34 | self::assertSame("a", $parser->readUntil("b")); 35 | self::assertSame("bc", $parser->readUntil("d", "a")); 36 | self::assertSame("d", $parser->readUntil("b", "a")); 37 | } 38 | 39 | public function testSkipWhitespace() : void { 40 | $parser = new StringParser("a b\t \nc"); 41 | $parser->skipWhitespace(); 42 | self::assertSame(0, $parser->pos); 43 | self::assertTrue($parser->readExactText("a")); 44 | $parser->skipWhitespace(); 45 | self::assertSame(4, $parser->pos); 46 | self::assertTrue($parser->readExactText("b")); 47 | $parser->skipWhitespace(); 48 | self::assertSame(8, $parser->pos); 49 | self::assertTrue($parser->readExactText("c")); 50 | $parser->skipWhitespace(); 51 | self::assertSame(9, $parser->pos); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/TemplateTest.php: -------------------------------------------------------------------------------- 1 | registries->displays->register(new Display($kind, function(mixed $s) use ($kind) { 22 | self::assertIsString($s); 23 | return "$s@$kind"; 24 | })); 25 | } 26 | 27 | $indices->registries->mappings->register(new Mapping( 28 | qualifiedName: ["root", "mod1", "dupTest"], 29 | sourceKind: "foo", 30 | targetKind: "bar", 31 | isImplicit: false, 32 | parameters: [], 33 | map: function($v) { 34 | return $v . "+mod1"; 35 | }, 36 | subscribe: null, 37 | help: "", 38 | metadata: [], 39 | )); 40 | $indices->registries->mappings->register(new Mapping( 41 | qualifiedName: ["root", "mod2", "dupTest"], 42 | sourceKind: "foo", 43 | targetKind: "bar", 44 | isImplicit: false, 45 | parameters: [], 46 | map: function($v) { 47 | return $v . "+mod2"; 48 | }, 49 | subscribe: null, 50 | help: "", 51 | metadata: [], 52 | )); 53 | $indices->registries->mappings->register(new Mapping( 54 | qualifiedName: ["root", "mod1", "toNull"], 55 | sourceKind: "foo", 56 | targetKind: "bar", 57 | isImplicit: false, 58 | parameters: [], 59 | map: function($v) { 60 | return null; 61 | }, 62 | subscribe: null, 63 | help: "", 64 | metadata: [], 65 | )); 66 | $indices->registries->mappings->register(new Mapping( 67 | qualifiedName: ["root", "mod1", "toQux"], 68 | sourceKind: "foo", 69 | targetKind: "qux", 70 | isImplicit: true, 71 | parameters: [], 72 | map: function($v) { 73 | return $v . "+toQux"; 74 | }, 75 | subscribe: null, 76 | help: "", 77 | metadata: [], 78 | )); 79 | $indices->registries->mappings->register(new Mapping( 80 | qualifiedName: ["root", "mod1", "toCorge"], 81 | sourceKind: "qux", 82 | targetKind: "corge", 83 | isImplicit: false, 84 | parameters: [], 85 | map: function($v) { 86 | return $v . "+toCorge"; 87 | }, 88 | subscribe: null, 89 | help: "", 90 | metadata: [], 91 | )); 92 | $indices->registries->mappings->register(new Mapping( 93 | qualifiedName: ["root", "mod1", "toGrault"], 94 | sourceKind: "corge", 95 | targetKind: "grault", 96 | isImplicit: true, 97 | parameters: [], 98 | map: function($v) { 99 | return $v . "+toGrault"; 100 | }, 101 | subscribe: null, 102 | help: "", 103 | metadata: [], 104 | )); 105 | $indices->registries->mappings->register(new Mapping( 106 | qualifiedName: ["root", "mod1", "maybeImplicit"], 107 | sourceKind: "qux", 108 | targetKind: "grault", 109 | isImplicit: true, 110 | parameters: [], 111 | map: function($v) { 112 | return $v . "+throughQux"; 113 | }, 114 | subscribe: null, 115 | help: "", 116 | metadata: [], 117 | )); 118 | $indices->registries->mappings->register(new Mapping( 119 | qualifiedName: ["root", "mod1", "maybeImplicit"], 120 | sourceKind: "foo", 121 | targetKind: "bar", 122 | isImplicit: true, 123 | parameters: [], 124 | map: function($v) { 125 | return $v . "+direct"; 126 | }, 127 | subscribe: null, 128 | help: "", 129 | metadata: [], 130 | )); 131 | $indices->registries->mappings->register(new Mapping( 132 | qualifiedName: ["root", "mod1", "graultGrault"], 133 | sourceKind: "grault", 134 | targetKind: "grault", 135 | isImplicit: true, 136 | parameters: [], 137 | map: function($v) { 138 | return $v . "+graultGrault"; 139 | }, 140 | subscribe: null, 141 | help: "", 142 | metadata: [], 143 | )); 144 | $indices->registries->mappings->register(new Mapping( 145 | qualifiedName: ["root", "mod1", "withParams"], 146 | sourceKind: "foo", 147 | targetKind: "grault", 148 | isImplicit: false, 149 | parameters: [ 150 | new Parameter("theRequired", "qux", multi: false, optional: false, metadata: []), 151 | new Parameter("theOptional", "corge", multi: false, optional: true, metadata: []), 152 | ], 153 | map: function($v, $args) { 154 | return $v . ";" . json_encode($args[0]) . ";" . json_encode($args[1]); 155 | }, 156 | subscribe: null, 157 | help: "", 158 | metadata: [], 159 | )); 160 | 161 | return $indices; 162 | } 163 | 164 | private static function assertTemplate(string $template, string $sourceKind, mixed $value, string $expect) : void { 165 | $ast = Ast\Parse::parse($template); 166 | $template = Template::fromAst($ast, self::setupIndices(), $sourceKind); 167 | $result = $template->render($value, null, new Get)->get(); 168 | self::assertSame($expect, $result); 169 | } 170 | 171 | public function testDup() : void { 172 | self::assertTemplate("lorem {mod1:dupTest} ipsum", "foo", "init", "lorem init+mod1@bar ipsum"); 173 | } 174 | 175 | public function testFallback() : void { 176 | self::assertTemplate("lorem {toNull | mod2:dupTest} ipsum", "foo", "init", "lorem init+mod2@bar ipsum"); 177 | } 178 | 179 | public function testImplicit() : void { 180 | self::assertTemplate("lorem {toCorge} ipsum", "foo", "init", "lorem init+toQux+toCorge+toGrault@grault ipsum"); 181 | } 182 | 183 | public function testPreferShort() : void { 184 | self::assertTemplate("lorem {maybeImplicit} ipsum", "foo", "init", "lorem init+direct@bar ipsum"); 185 | } 186 | 187 | public function testLongerPath() : void { 188 | self::assertTemplate("lorem {maybeImplicit graultGrault} ipsum", "foo", "init", "lorem init+toQux+throughQux+graultGrault@grault ipsum"); 189 | } 190 | 191 | public function testOmittedNamedArgs() : void { 192 | self::assertTemplate("lorem {withParams(theRequired = toQux)} ipsum", "foo", "init", 'lorem init;"init+toQux";null@grault ipsum'); 193 | } 194 | 195 | public function testOmittedUnnamedArgs() : void { 196 | self::assertTemplate("lorem {withParams(toQux)} ipsum", "foo", "init", 'lorem init;"init+toQux";null@grault ipsum'); 197 | } 198 | 199 | public function testFullNamedArgs() : void { 200 | self::assertTemplate("lorem {withParams(theRequired = toQux, theOptional = toCorge)} ipsum", "foo", "init", 'lorem init;"init+toQux";"init+toQux+toCorge"@grault ipsum'); 201 | } 202 | 203 | public function testFullUnnamedArgs() : void { 204 | self::assertTemplate("lorem {withParams(toQux, toCorge)} ipsum", "foo", "init", 'lorem init;"init+toQux";"init+toQux+toCorge"@grault ipsum'); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/autoload-bootstrap.php: -------------------------------------------------------------------------------- 1 | namedMappings->find(Standard\StringInfo::KIND, new QualifiedRef($name)); 22 | self::assertCount(1, $mappings); 23 | return $mappings[0]->mapping; 24 | } 25 | 26 | /** 27 | * @param string[] $name 28 | * @param mixed[] $params 29 | */ 30 | private static function map(array $name, string $input, array $params, mixed $expect) : void { 31 | $mapping = self::mapping($name); 32 | $actual = ($mapping->map)($input, $params); 33 | self::assertSame($expect, $actual); 34 | } 35 | 36 | public function testUpper() : void { 37 | self::map(["upper"], "LorEM ipSUm", [], "LOREM IPSUM"); 38 | } 39 | } 40 | --------------------------------------------------------------------------------