├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── phpstan.dist.neon ├── rector.php └── src ├── Comments.php ├── Flags.php ├── Generator ├── Generator.php ├── GeneratorInterface.php ├── MoGenerator.php └── PoGenerator.php ├── Headers.php ├── Loader ├── Loader.php ├── LoaderInterface.php ├── MoLoader.php ├── PoLoader.php └── StrictPoLoader.php ├── Merge.php ├── References.php ├── Scanner ├── CodeScanner.php ├── FunctionsHandlersTrait.php ├── FunctionsScannerInterface.php ├── ParsedFunction.php ├── Scanner.php └── ScannerInterface.php ├── Translation.php └── Translations.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | Previous releases are documented in [github releases](https://github.com/oscarotero/Gettext/releases) 8 | 9 | ## [5.7.3] - 2024-12-01 10 | ### Fixed 11 | - Php 8.4 support [#302]. 12 | 13 | ## [5.7.2] - 2024-11-24 14 | ### Fixed 15 | - Php 8.4 support [#300]. 16 | 17 | ## [5.7.1] - 2024-07-24 18 | ### Fixed 19 | - Parsing of PO files with empty comments instead of empty lines [#296] 20 | - Support for PHP >= 8.2 [#299] 21 | 22 | ## [5.7.0] - 2022-07-27 23 | ### Added 24 | - StrictPoLoader, a stricter PO loader more aligned with the syntax of the GNU gettext tooling [#282]. 25 | - Previous attributes (msgctxt, msgid, msgid_plural) to the Translation class and the PO generator [#282]. 26 | 27 | ### Changed 28 | - Minor performance improvements to the Translations class [#282]. 29 | 30 | ## [5.6.1] - 2021-12-04 31 | ### Fixed 32 | - PHP 8.1 support [#278]. 33 | 34 | ## [5.6.0] - 2021-11-05 35 | ### Added 36 | - New method `addFlag` to `ParsedFunction`, that allows to assign flags by scanners. 37 | - The `FunctionsHandlersTrait` has an abstract `addFlags` method. 38 | 39 | ### Fixed 40 | - Subsequent load file fails [#257] [#276] 41 | - Upgraded some dependencies in `dev`. 42 | 43 | ## [5.5.4] - 2020-12-20 44 | ### Fixed 45 | - TypeError in which numeric entries were converted to integers [#265] 46 | 47 | ## [5.5.3] - 2020-12-01 48 | ### Fixed 49 | - Add PHP 8 to composer.json 50 | 51 | ## [5.5.2] - 2020-11-17 52 | ### Fixed 53 | - Parse of multiline disabled translations [#262] [#263] 54 | 55 | ## [5.5.1] - 2020-06-08 56 | ### Fixed 57 | - Type error in which numeric filenames were converted to integers [#260] 58 | 59 | ## [5.5.0] - 2020-05-23 60 | ### Added 61 | - New option `addReferences()` to configure the code scanners whether add or not references [#258] 62 | 63 | ### Changed 64 | - BREAKING: Moved some code from `CodeScanner` to the new `FunctionsHandlersTrait` in order to better reuse. 65 | 66 | ## [5.4.1] - 2020-03-15 67 | ### Fixed 68 | - PoGenerator includes the description and flags of the translations [#253] 69 | 70 | ## [5.4.0] - 2020-03-07 71 | ### Added 72 | - Added `_` function to the list of functions scanned by default 73 | - Added `Translations::setDescription()` and `Translations::getDescription()` methods [#251] 74 | - Added `Translations::getFlags()` that returns a `Flags` object to assign flags to the entire po file [#251] 75 | 76 | ## [5.3.0] - 2020-02-18 77 | ### Added 78 | - `Comments::delete()` and `Flags::delete()` methods [#247] 79 | 80 | ## [5.2.2] - 2020-02-09 81 | ### Fixed 82 | - MoLoader with plurals [#246] 83 | 84 | ## [5.2.1] - 2019-12-08 85 | ### Fixed 86 | - Multiline string in PoGenerator [#244] 87 | 88 | ## [5.2.0] - 2019-11-25 89 | ### Added 90 | - New function `CodeScanner::extractCommentsStartingWith()` to extract comments from the code. 91 | 92 | ## [5.1.0] - 2019-11-11 93 | ### Added 94 | - New function `CodeScanner::ignoreInvalidFunctions()` to ignore invalid functions instead throw an exception 95 | 96 | ## [5.0.0] - 2019-11-04 97 | ### Added 98 | - New interfaces: `ScannerInterface` and `FunctionsScannerInterface`. 99 | 100 | ### Changed 101 | - Moved the package and dependencies to [php-gettext](https://github.com/php-gettext) organization 102 | - Minimum PHP version supported is 7.2 103 | - Added php7 strict typing 104 | - Extractors have been split into two different types of classes to import translations: 105 | - Scanners: To scan code files (like php, javascript, twig, etc) in order to collect gettext entries from many domains at the same time. 106 | - Loaders: To load a translation format such po, mo, json, xliff, etc 107 | - Split the `Translation` and `Translations` classes in different sub-classes to handle comments, flags, references, etc. For example, instead `$translation->addComment('foo')` now it's `$translation->getComments()->add('foo')`. 108 | - Simplified the options to merge translations with pre-configured options like `Merged::SCAN_AND_LOAD`. 109 | - The headers of translations are always sorted alphabetically. 110 | - Changed the signature of all classes and interfaces. 111 | 112 | ### Removed 113 | - Extractors (now scanners and loaders), generators and translators were removed from this package and published as external packages, allowing to install only those that you need. Only Po and Mo formats are included by default. 114 | - Removed magic classes like `Translations::fromPoFile` or `$translation->toMoFile()`. Now, the scanners, loaders and generators are independent classes that have to be instantiated. 115 | - Removed `Merge::LANGUAGE_OVERRIDE` and `Merge::DOMAIN_OVERRIDE` constants 116 | 117 | ### Fixed 118 | - Improved code quality 119 | - The library is easier to extend 120 | - Translation id can be independent of the context + original values, in order to be more compatible with Xliff format. 121 | 122 | [#244]: https://github.com/php-gettext/Gettext/issues/244 123 | [#246]: https://github.com/php-gettext/Gettext/issues/246 124 | [#247]: https://github.com/php-gettext/Gettext/issues/247 125 | [#251]: https://github.com/php-gettext/Gettext/issues/251 126 | [#253]: https://github.com/php-gettext/Gettext/issues/253 127 | [#257]: https://github.com/php-gettext/Gettext/issues/257 128 | [#258]: https://github.com/php-gettext/Gettext/issues/258 129 | [#260]: https://github.com/php-gettext/Gettext/issues/260 130 | [#262]: https://github.com/php-gettext/Gettext/issues/262 131 | [#263]: https://github.com/php-gettext/Gettext/issues/263 132 | [#265]: https://github.com/php-gettext/Gettext/issues/265 133 | [#276]: https://github.com/php-gettext/Gettext/issues/276 134 | [#278]: https://github.com/php-gettext/Gettext/issues/278 135 | [#282]: https://github.com/php-gettext/Gettext/issues/282 136 | [#296]: https://github.com/php-gettext/Gettext/issues/296 137 | [#299]: https://github.com/php-gettext/Gettext/issues/299 138 | [#300]: https://github.com/php-gettext/Gettext/issues/300 139 | [#302]: https://github.com/php-gettext/Gettext/issues/302 140 | 141 | [5.7.3]: https://github.com/php-gettext/Gettext/compare/v5.7.2...v5.7.3 142 | [5.7.2]: https://github.com/php-gettext/Gettext/compare/v5.7.1...v5.7.2 143 | [5.7.1]: https://github.com/php-gettext/Gettext/compare/v5.7.0...v5.7.1 144 | [5.7.0]: https://github.com/php-gettext/Gettext/compare/v5.6.1...v5.7.0 145 | [5.6.1]: https://github.com/php-gettext/Gettext/compare/v5.6.0...v5.6.1 146 | [5.6.0]: https://github.com/php-gettext/Gettext/compare/v5.5.4...v5.6.0 147 | [5.5.4]: https://github.com/php-gettext/Gettext/compare/v5.5.3...v5.5.4 148 | [5.5.3]: https://github.com/php-gettext/Gettext/compare/v5.5.2...v5.5.3 149 | [5.5.2]: https://github.com/php-gettext/Gettext/compare/v5.5.1...v5.5.2 150 | [5.5.1]: https://github.com/php-gettext/Gettext/compare/v5.5.0...v5.5.1 151 | [5.5.0]: https://github.com/php-gettext/Gettext/compare/v5.4.1...v5.5.0 152 | [5.4.1]: https://github.com/php-gettext/Gettext/compare/v5.4.0...v5.4.1 153 | [5.4.0]: https://github.com/php-gettext/Gettext/compare/v5.3.0...v5.4.0 154 | [5.3.0]: https://github.com/php-gettext/Gettext/compare/v5.2.2...v5.3.0 155 | [5.2.2]: https://github.com/php-gettext/Gettext/compare/v5.2.1...v5.2.2 156 | [5.2.1]: https://github.com/php-gettext/Gettext/compare/v5.2.0...v5.2.1 157 | [5.2.0]: https://github.com/php-gettext/Gettext/compare/v5.1.0...v5.2.0 158 | [5.1.0]: https://github.com/php-gettext/Gettext/compare/v5.0.0...v5.1.0 159 | [5.0.0]: https://github.com/php-gettext/Gettext/releases/tag/v5.0.0 160 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to Gettext 2 | ======================= 3 | 4 | Looking to contribute something to this library? Here's how you can help. 5 | 6 | ## Bugs 7 | 8 | A bug is a demonstrable problem that is caused by the code in the repository. Good bug reports are extremely helpful – thank you! 9 | 10 | Please try to be as detailed as possible in your report. Include specific information about the environment – version of PHP, version of gettext, etc, and steps required to reproduce the issue. 11 | 12 | ## Pull Requests 13 | 14 | Good pull requests – patches, improvements, new features – are a fantastic help. New extractors or generator are welcome. Before create a pull request, please follow these instructions: 15 | 16 | * The code must be PSR-2 compliant 17 | * Write some tests 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Oscar Otero Marzoa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gettext 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE) 5 | ![ico-ga] 6 | [![Total Downloads][ico-downloads]][link-downloads] 7 | 8 | > Note: this is the documentation of the new 5.x version. Go to [4.x branch](https://github.com/php-gettext/Gettext/tree/4.x) if you're looking for the old 4.x version 9 | 10 | Created by Oscar Otero (MIT License) 11 | 12 | Gettext is a PHP (^7.2) library to import/export/edit gettext from PO, MO, PHP, JS files, etc. 13 | 14 | ## Installation 15 | 16 | ``` 17 | composer require gettext/gettext 18 | ``` 19 | 20 | ## Classes and functions 21 | 22 | This package contains the following classes: 23 | 24 | * `Gettext\Translation` - A translation definition 25 | * `Gettext\Translations` - A collection of translations (under the same domain) 26 | * `Gettext\Scanner\*` - Scan files to extract translations (php, js, twig templates, ...) 27 | * `Gettext\Loader\*` - Load translations from different formats (po, mo, json, ...) 28 | * `Gettext\Generator\*` - Export translations to various formats (po, mo, json, ...) 29 | 30 | ## Usage example 31 | 32 | ```php 33 | use Gettext\Loader\PoLoader; 34 | use Gettext\Generator\MoGenerator; 35 | 36 | //import from a .po file: 37 | $loader = new PoLoader(); 38 | $translations = $loader->loadFile('locales/gl.po'); 39 | 40 | //edit some translations: 41 | $translation = $translations->find(null, 'apple'); 42 | 43 | if ($translation) { 44 | $translation->translate('Mazá'); 45 | } 46 | 47 | //export to a .mo file: 48 | $generator = new MoGenerator(); 49 | $generator->generateFile($translations, 'Locale/gl/LC_MESSAGES/messages.mo'); 50 | ``` 51 | 52 | ## Translation 53 | 54 | The `Gettext\Translation` class stores all information about a translation: the original text, the translated text, source references, comments, etc. 55 | 56 | ```php 57 | use Gettext\Translation; 58 | 59 | $translation = Translation::create('comments', 'One comment', '%s comments'); 60 | 61 | $translation->translate('Un comentario'); 62 | $translation->translatePlural('%s comentarios'); 63 | 64 | $translation->getReferences()->add('templates/comments/comment.php', 34); 65 | $translation->getComments()->add('To display the amount of comments in a post'); 66 | 67 | echo $translation->getContext(); // comments 68 | echo $translation->getOriginal(); // One comment 69 | echo $translation->getTranslation(); // Un comentario 70 | 71 | // etc... 72 | ``` 73 | 74 | ## Translations 75 | 76 | The `Gettext\Translations` class stores a collection of translations: 77 | 78 | ```php 79 | use Gettext\Translations; 80 | 81 | $translations = Translations::create('my-domain'); 82 | 83 | //You can add new translations: 84 | $translation = Translation::create('comments', 'One comment', '%s comments'); 85 | $translations->add($translation); 86 | 87 | //Find a specific translation 88 | $translation = $translations->find('comments', 'One comment'); 89 | 90 | //Edit headers, domain, etc 91 | $translations->getHeaders()->set('Last-Translator', 'Oscar Otero'); 92 | $translations->setDomain('my-blog'); 93 | ``` 94 | 95 | ## Loaders 96 | 97 | The loaders allow to get gettext values from multiple formats. For example, to load a .po file: 98 | 99 | ```php 100 | use Gettext\Loader\PoLoader; 101 | 102 | $loader = new PoLoader(); 103 | 104 | //From a file 105 | $translations = $loader->loadFile('locales/en.po'); 106 | 107 | //From a string 108 | $string = file_get_contents('locales2/en.po'); 109 | $translations = $loader->loadString($string); 110 | ``` 111 | 112 | As of version 5.7.0, a `StrictPoLoader` has been included, with a parser more aligned to the GNU gettext tooling with the same expectations and failures (see the tests for more details). 113 | - It will fail with an exception when there's anything wrong with the syntax, and display the reason together with the line/byte where it happened. 114 | - It might also emit useful warnings, e.g. when there are more/less plural translations than needed, missing translation header, dangling comments not associated with any translation, etc. 115 | - Due to its strictness and speed (about 50% slower than the `PoLoader`), it might be interesting to be used as a kind of `.po` linter in a build system. 116 | - It also implements the previous translation comment (e.g. `#| msgid "previous"`) and extra escapes (16-bit unicode `\u`, 32-bit unicode `\U`, hexadecimal `\xFF` and octal `\77`). 117 | 118 | The usage is basically the same as the `PoLoader`: 119 | 120 | ```php 121 | use Gettext\Loader\StrictPoLoader; 122 | 123 | $loader = new StrictPoLoader(); 124 | 125 | //From a file 126 | $translations = $loader->loadFile('locales/en.po'); 127 | 128 | //From a string 129 | $string = file_get_contents('locales2/en.po'); 130 | $translations = $loader->loadString($string); 131 | 132 | //Display error messages using "at line X column Y" instead of "at byte X" 133 | $loader->displayErrorLine = true; 134 | //Throw an exception when a warning happens 135 | $loader->throwOnWarning = true; 136 | //Retrieve the warnings 137 | $loader->getWarnings(); 138 | ``` 139 | 140 | This package includes the following loaders: 141 | 142 | - `MoLoader` 143 | - `PoLoader` 144 | - `StrictPoLoader` 145 | 146 | And you can install other formats with loaders and generators: 147 | 148 | - [Json](https://github.com/php-gettext/Json) 149 | 150 | ## Generators 151 | 152 | The generators export a `Gettext\Translations` instance to any format (po, mo, etc). 153 | 154 | ```php 155 | use Gettext\Loader\PoLoader; 156 | use Gettext\Generator\MoGenerator; 157 | 158 | //Load a PO file 159 | $poLoader = new PoLoader(); 160 | 161 | $translations = $poLoader->loadFile('locales/en.po'); 162 | 163 | //Save to MO file 164 | $moGenerator = new MoGenerator(); 165 | 166 | $moGenerator->generateFile($translations, 'locales/en.mo'); 167 | 168 | //Or return as a string 169 | $content = $moGenerator->generateString($translations); 170 | file_put_contents('locales/en.mo', $content); 171 | ``` 172 | 173 | This package includes the following generators: 174 | 175 | - `MoGenerator` 176 | - `PoGenerator` 177 | 178 | And you can install other formats with loaders and generators: 179 | 180 | - [Json](https://github.com/php-gettext/Json) 181 | 182 | 183 | ## Scanners 184 | 185 | Scanners allow to search and extract new gettext entries from different sources like php files, twig templates, blade templates, etc. Unlike loaders, scanners allows to extract gettext entries with different domains at the same time: 186 | 187 | ```php 188 | use Gettext\Scanner\PhpScanner; 189 | use Gettext\Translations; 190 | 191 | //Create a new scanner, adding a translation for each domain we want to get: 192 | $phpScanner = new PhpScanner( 193 | Translations::create('domain1'), 194 | Translations::create('domain2'), 195 | Translations::create('domain3') 196 | ); 197 | 198 | //Set a default domain, so any translations with no domain specified, will be added to that domain 199 | $phpScanner->setDefaultDomain('domain1'); 200 | 201 | //Extract all comments starting with 'i18n:' and 'Translators:' 202 | $phpScanner->extractCommentsStartingWith('i18n:', 'Translators:'); 203 | 204 | //Scan files 205 | foreach (glob('*.php') as $file) { 206 | $phpScanner->scanFile($file); 207 | } 208 | 209 | //Get the translations 210 | list('domain1' => $domain1, 'domain2' => $domain2, 'domain3' => $domain3) = $phpScanner->getTranslations(); 211 | ``` 212 | 213 | This package does not include any scanner by default. But there are some that you can install: 214 | 215 | - [PHP Scanner](https://github.com/php-gettext/PHP-Scanner) 216 | - [JS Scanner](https://github.com/php-gettext/JS-Scanner) 217 | 218 | ## Merging translations 219 | 220 | You will want to update or merge translations. The function `mergeWith` create a new `Translations` instance with other translations merged: 221 | 222 | ```php 223 | $translations3 = $translations1->mergeWith($translations2); 224 | ``` 225 | 226 | But sometimes this is not enough, and this is why we have merging options, allowing to configure how two translations will be merged. These options are defined as constants in the `Gettext\Merge` class, and are the following: 227 | 228 | Constant | Description 229 | --------- | ----------- 230 | `Merge::TRANSLATIONS_OURS` | Use only the translations present in `$translations1` 231 | `Merge::TRANSLATIONS_THEIRS` | Use only the translations present in `$translations2` 232 | `Merge::TRANSLATIONS_OVERRIDE` | Override the translation and plural translations with the value of `$translation2` 233 | `Merge::HEADERS_OURS` | Use only the headers of `$translations1` 234 | `Merge::HEADERS_REMOVE` | Use only the headers of `$translations2` 235 | `Merge::HEADERS_OVERRIDE` | Overrides the headers with the values of `$translations2` 236 | `Merge::COMMENTS_OURS` | Use only the comments of `$translation1` 237 | `Merge::COMMENTS_THEIRS` | Use only the comments of `$translation2` 238 | `Merge::EXTRACTED_COMMENTS_OURS` | Use only the extracted comments of `$translation1` 239 | `Merge::EXTRACTED_COMMENTS_THEIRS` | Use only the extracted comments of `$translation2` 240 | `Merge::FLAGS_OURS` | Use only the flags of `$translation1` 241 | `Merge::FLAGS_THEIRS` | Use only the flags of `$translation2` 242 | `Merge::REFERENCES_OURS` | Use only the references of `$translation1` 243 | `Merge::REFERENCES_THEIRS` | Use only the references of `$translation2` 244 | 245 | Use the second argument to configure the merging strategy: 246 | 247 | ```php 248 | $strategy = Merge::TRANSLATIONS_OURS | Merge::HEADERS_OURS; 249 | 250 | $translations3 = $translations1->mergeWith($translations2, $strategy); 251 | ``` 252 | 253 | There are some typical scenarios, one of the most common: 254 | 255 | - Scan php templates searching for entries to translate 256 | - Complete these entries with the translations stored in a .po file 257 | - You may want to add new entries to the .po file 258 | - And also remove those entries present in the .po file but not in the templates (because they were removed) 259 | - But you want to update some translations with new references and extracted comments 260 | - And keep the translations, comments and flags defined in .po file 261 | 262 | For this scenario, you can use the option `Merge::SCAN_AND_LOAD` with the combination of options to fit this needs (SCAN new entries and LOAD a .po file). 263 | 264 | ```php 265 | $newEntries = $scanner->scanFile('template.php'); 266 | $previousEntries = $loader->loadFile('translations.po'); 267 | 268 | $updatedEntries = $newEntries->mergeWith($previousEntries); 269 | ``` 270 | 271 | More common scenarios may be added in a future. 272 | 273 | ## Related projects 274 | 275 | - [gettext-wp-scanner](https://github.com/10quality/gettext-wp-scanner) WordPress code scanner to use with this library. 276 | 277 | ## Contributors 278 | 279 | Thanks to all [contributors](https://github.com/oscarotero/Gettext/graphs/contributors) specially to [@mlocati](https://github.com/mlocati). 280 | 281 | --- 282 | 283 | Please see [CHANGELOG](CHANGELOG.md) for more information about recent changes and [CONTRIBUTING](CONTRIBUTING.md) for contributing details. 284 | 285 | The MIT License (MIT). Please see [LICENSE](LICENSE) for more information. 286 | 287 | [ico-version]: https://img.shields.io/packagist/v/gettext/gettext.svg?style=flat-square 288 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 289 | [ico-ga]: https://github.com/php-gettext/Gettext/workflows/testing/badge.svg 290 | [ico-downloads]: https://img.shields.io/packagist/dt/gettext/gettext.svg?style=flat-square 291 | 292 | [link-packagist]: https://packagist.org/packages/gettext/gettext 293 | [link-downloads]: https://packagist.org/packages/gettext/gettext 294 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gettext/gettext", 3 | "type": "library", 4 | "description": "PHP gettext manager", 5 | "keywords": ["js", "gettext", "i18n", "translation", "po", "mo"], 6 | "homepage": "https://github.com/php-gettext/Gettext", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Oscar Otero", 11 | "email": "oom@oscarotero.com", 12 | "homepage": "http://oscarotero.com", 13 | "role": "Developer" 14 | } 15 | ], 16 | "support": { 17 | "email": "oom@oscarotero.com", 18 | "issues": "https://github.com/php-gettext/Gettext/issues" 19 | }, 20 | "require": { 21 | "php": "^7.2|^8.0", 22 | "gettext/languages": "^2.3" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^8.0|^9.0", 26 | "squizlabs/php_codesniffer": "^3.0", 27 | "brick/varexporter": "^0.3.5", 28 | "friendsofphp/php-cs-fixer": "^3.2", 29 | "oscarotero/php-cs-fixer-config": "^2.0", 30 | "phpstan/phpstan": "^1|^2", 31 | "rector/rector": "^1|^2" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Gettext\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Gettext\\Tests\\": "tests" 41 | } 42 | }, 43 | "scripts": { 44 | "test": [ 45 | "phpunit", 46 | "phpcs", 47 | "phpstan" 48 | ], 49 | "cs-fix": "php-cs-fixer fix", 50 | "rector": "rector process" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 5 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 10 | __DIR__ . '/src', 11 | __DIR__ . '/tests', 12 | ]) 13 | ->withPhpSets() 14 | ->withPreparedSets(typeDeclarations: true) 15 | ->withDeadCodeLevel(2) 16 | ->withCodeQualityLevel(10) 17 | ->withCodingStyleLevel(0) 18 | ; 19 | -------------------------------------------------------------------------------- /src/Comments.php: -------------------------------------------------------------------------------- 1 | add(...$comments); 30 | } 31 | } 32 | 33 | public function __debugInfo() 34 | { 35 | return $this->toArray(); 36 | } 37 | 38 | public function add(string ...$comments): self 39 | { 40 | foreach ($comments as $comment) { 41 | if (!in_array($comment, $this->comments)) { 42 | $this->comments[] = $comment; 43 | } 44 | } 45 | 46 | return $this; 47 | } 48 | 49 | public function delete(string ...$comments): self 50 | { 51 | foreach ($comments as $comment) { 52 | $key = array_search($comment, $this->comments); 53 | 54 | if (is_int($key)) { 55 | array_splice($this->comments, $key, 1); 56 | } 57 | } 58 | 59 | return $this; 60 | } 61 | 62 | #[ReturnTypeWillChange] 63 | public function jsonSerialize() 64 | { 65 | return $this->toArray(); 66 | } 67 | 68 | #[ReturnTypeWillChange] 69 | public function getIterator() 70 | { 71 | return new ArrayIterator($this->comments); 72 | } 73 | 74 | public function count(): int 75 | { 76 | return count($this->comments); 77 | } 78 | 79 | public function toArray(): array 80 | { 81 | return $this->comments; 82 | } 83 | 84 | public function mergeWith(Comments $comments): Comments 85 | { 86 | $merged = clone $this; 87 | $merged->add(...$comments->comments); 88 | 89 | return $merged; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Flags.php: -------------------------------------------------------------------------------- 1 | add(...$flags); 30 | } 31 | } 32 | 33 | public function __debugInfo() 34 | { 35 | return $this->toArray(); 36 | } 37 | 38 | public function add(string ...$flags): self 39 | { 40 | foreach ($flags as $flag) { 41 | if (!$this->has($flag)) { 42 | $this->flags[] = $flag; 43 | } 44 | } 45 | 46 | sort($this->flags); 47 | 48 | return $this; 49 | } 50 | 51 | public function delete(string ...$flags): self 52 | { 53 | foreach ($flags as $flag) { 54 | $key = array_search($flag, $this->flags); 55 | 56 | if (is_int($key)) { 57 | array_splice($this->flags, $key, 1); 58 | } 59 | } 60 | 61 | return $this; 62 | } 63 | 64 | public function has(string $flag): bool 65 | { 66 | return in_array($flag, $this->flags, true); 67 | } 68 | 69 | #[ReturnTypeWillChange] 70 | public function jsonSerialize() 71 | { 72 | return $this->toArray(); 73 | } 74 | 75 | #[ReturnTypeWillChange] 76 | public function getIterator() 77 | { 78 | return new ArrayIterator($this->flags); 79 | } 80 | 81 | public function count(): int 82 | { 83 | return count($this->flags); 84 | } 85 | 86 | public function toArray(): array 87 | { 88 | return $this->flags; 89 | } 90 | 91 | public function mergeWith(Flags $flags): Flags 92 | { 93 | $merged = clone $this; 94 | $merged->add(...$flags->flags); 95 | 96 | return $merged; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Generator/Generator.php: -------------------------------------------------------------------------------- 1 | generateString($translations); 13 | 14 | return file_put_contents($filename, $content) !== false; 15 | } 16 | 17 | abstract public function generateString(Translations $translations): string; 18 | } 19 | -------------------------------------------------------------------------------- /src/Generator/GeneratorInterface.php: -------------------------------------------------------------------------------- 1 | includeHeaders = $includeHeaders; 16 | 17 | return $this; 18 | } 19 | 20 | public function generateString(Translations $translations): string 21 | { 22 | $messages = []; 23 | 24 | if ($this->includeHeaders) { 25 | $lines = []; 26 | 27 | foreach ($translations->getHeaders() as $name => $value) { 28 | $lines[] = sprintf('%s: %s', $name, $value); 29 | } 30 | 31 | $messages[''] = implode("\n", $lines); 32 | } 33 | 34 | foreach ($translations as $translation) { 35 | if (!$translation->getTranslation() || $translation->isDisabled()) { 36 | continue; 37 | } 38 | 39 | if ($context = $translation->getContext()) { 40 | $originalString = "{$context}\x04{$translation->getOriginal()}"; 41 | } else { 42 | $originalString = $translation->getOriginal(); 43 | } 44 | 45 | $messages[$originalString] = $translation; 46 | } 47 | 48 | ksort($messages, SORT_STRING); 49 | $numEntries = count($messages); 50 | $originalsTable = ''; 51 | $translationsTable = ''; 52 | $originalsIndex = []; 53 | $translationsIndex = []; 54 | $pluralForm = $translations->getHeaders()->getPluralForm(); 55 | $pluralSize = is_array($pluralForm) ? ($pluralForm[0] - 1) : null; 56 | 57 | foreach ($messages as $originalString => $translation) { 58 | if (is_string($translation)) { 59 | $translationString = $translation; 60 | } elseif (self::hasPluralTranslations($translation)) { 61 | $originalString .= "\x00{$translation->getPlural()}"; 62 | $translationString = "{$translation->getTranslation()}\x00" 63 | .implode("\x00", $translation->getPluralTranslations($pluralSize)); 64 | } else { 65 | $translationString = $translation->getTranslation(); 66 | } 67 | 68 | $originalsIndex[] = [ 69 | 'relativeOffset' => strlen($originalsTable), 70 | 'length' => strlen((string) $originalString), 71 | ]; 72 | $originalsTable .= $originalString."\x00"; 73 | $translationsIndex[] = [ 74 | 'relativeOffset' => strlen($translationsTable), 75 | 'length' => strlen($translationString), 76 | ]; 77 | $translationsTable .= $translationString."\x00"; 78 | } 79 | 80 | // Offset of table with the original strings index: right after the header (which is 7 words) 81 | $originalsIndexOffset = 7 * 4; 82 | 83 | // Size of table with the original strings index 84 | $originalsIndexSize = $numEntries * (4 + 4); 85 | 86 | // Offset of table with the translation strings index: right after the original strings index table 87 | $translationsIndexOffset = $originalsIndexOffset + $originalsIndexSize; 88 | 89 | // Size of table with the translation strings index 90 | $translationsIndexSize = $numEntries * (4 + 4); 91 | 92 | // Hashing table starts after the header and after the index table 93 | $originalsStringsOffset = $translationsIndexOffset + $translationsIndexSize; 94 | 95 | // Translations start after the keys 96 | $translationsStringsOffset = $originalsStringsOffset + strlen($originalsTable); 97 | 98 | // Let's generate the .mo file binary data 99 | $mo = ''; 100 | 101 | // Magic number 102 | $mo .= pack('L', 0x950412de); 103 | 104 | // File format revision 105 | $mo .= pack('L', 0); 106 | 107 | // Number of strings 108 | $mo .= pack('L', $numEntries); 109 | 110 | // Offset of table with original strings 111 | $mo .= pack('L', $originalsIndexOffset); 112 | 113 | // Offset of table with translation strings 114 | $mo .= pack('L', $translationsIndexOffset); 115 | 116 | // Size of hashing table: we don't use it. 117 | $mo .= pack('L', 0); 118 | 119 | // Offset of hashing table: it would start right after the translations index table 120 | $mo .= pack('L', $translationsIndexOffset + $translationsIndexSize); 121 | 122 | // Write the lengths & offsets of the original strings 123 | foreach ($originalsIndex as $info) { 124 | $mo .= pack('L', $info['length']); 125 | $mo .= pack('L', $originalsStringsOffset + $info['relativeOffset']); 126 | } 127 | 128 | // Write the lengths & offsets of the translated strings 129 | foreach ($translationsIndex as $info) { 130 | $mo .= pack('L', $info['length']); 131 | $mo .= pack('L', $translationsStringsOffset + $info['relativeOffset']); 132 | } 133 | 134 | // Write original strings 135 | $mo .= $originalsTable; 136 | 137 | // Write translation strings 138 | $mo .= $translationsTable; 139 | 140 | return $mo; 141 | } 142 | 143 | private static function hasPluralTranslations(Translation $translation): bool 144 | { 145 | if (!$translation->getPlural()) { 146 | return false; 147 | } 148 | 149 | return implode('', $translation->getPluralTranslations()) !== ''; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Generator/PoGenerator.php: -------------------------------------------------------------------------------- 1 | getHeaders()->getPluralForm(); 13 | $pluralSize = is_array($pluralForm) ? ($pluralForm[0] - 1) : null; 14 | $lines = []; 15 | 16 | //Description and flags 17 | if ($translations->getDescription()) { 18 | $description = explode("\n", $translations->getDescription()); 19 | 20 | foreach ($description as $line) { 21 | $lines[] = sprintf('# %s', $line); 22 | } 23 | 24 | $lines[] = '#'; 25 | } 26 | 27 | if (count($translations->getFlags())) { 28 | $lines[] = sprintf('#, %s', implode(',', $translations->getFlags()->toArray())); 29 | } 30 | 31 | //Headers 32 | $lines[] = 'msgid ""'; 33 | $lines[] = 'msgstr ""'; 34 | 35 | foreach ($translations->getHeaders() as $name => $value) { 36 | $lines[] = sprintf('"%s: %s\\n"', $name, $value); 37 | } 38 | 39 | $lines[] = ''; 40 | 41 | //Translations 42 | foreach ($translations as $translation) { 43 | foreach ($translation->getComments() as $comment) { 44 | $lines[] = sprintf('# %s', $comment); 45 | } 46 | 47 | foreach ($translation->getExtractedComments() as $comment) { 48 | $lines[] = sprintf('#. %s', $comment); 49 | } 50 | 51 | foreach ($translation->getReferences() as $filename => $lineNumbers) { 52 | if (empty($lineNumbers)) { 53 | $lines[] = sprintf('#: %s', $filename); 54 | continue; 55 | } 56 | 57 | foreach ($lineNumbers as $number) { 58 | $lines[] = sprintf('#: %s:%d', $filename, $number); 59 | } 60 | } 61 | 62 | if (count($translation->getFlags())) { 63 | $lines[] = sprintf('#, %s', implode(',', $translation->getFlags()->toArray())); 64 | } 65 | 66 | $prefix = $translation->isDisabled() ? '#~ ' : ''; 67 | 68 | if ($context = $translation->getPreviousContext()) { 69 | $lines[] = sprintf('%s#| msgctxt %s', $prefix, self::encode($context)); 70 | } 71 | 72 | if ($original = $translation->getPreviousOriginal()) { 73 | $lines[] = sprintf('%s#| msgid %s', $prefix, self::encode($original)); 74 | } 75 | 76 | if ($plural = $translation->getPreviousPlural()) { 77 | $lines[] = sprintf('%s#| msgid_plural %s', $prefix, self::encode($plural)); 78 | } 79 | 80 | if ($context = $translation->getContext()) { 81 | $lines[] = sprintf('%smsgctxt %s', $prefix, self::encode($context)); 82 | } 83 | 84 | self::appendLines($lines, $prefix, 'msgid', $translation->getOriginal()); 85 | 86 | if ($plural = $translation->getPlural()) { 87 | self::appendLines($lines, $prefix, 'msgid_plural', $plural); 88 | self::appendLines($lines, $prefix, 'msgstr[0]', $translation->getTranslation() ?: ''); 89 | 90 | foreach ($translation->getPluralTranslations($pluralSize) as $k => $v) { 91 | self::appendLines($lines, $prefix, sprintf('msgstr[%d]', $k + 1), $v); 92 | } 93 | } else { 94 | self::appendLines($lines, $prefix, 'msgstr', $translation->getTranslation() ?: ''); 95 | } 96 | 97 | $lines[] = ''; 98 | } 99 | 100 | return implode("\n", $lines); 101 | } 102 | 103 | /** 104 | * Add one or more lines depending whether the string is multiline or not. 105 | */ 106 | private static function appendLines(array &$lines, string $prefix, string $name, string $value): void 107 | { 108 | $newLines = explode("\n", $value); 109 | $total = count($newLines); 110 | 111 | if ($total === 1) { 112 | $lines[] = sprintf('%s%s %s', $prefix, $name, self::encode($newLines[0])); 113 | 114 | return; 115 | } 116 | 117 | $lines[] = sprintf('%s%s ""', $prefix, $name); 118 | 119 | $last = $total - 1; 120 | foreach ($newLines as $k => $line) { 121 | if ($k < $last) { 122 | $line .= "\n"; 123 | } 124 | 125 | $lines[] = self::encode($line); 126 | } 127 | } 128 | 129 | /** 130 | * Convert a string to its PO representation. 131 | */ 132 | public static function encode(string $value): string 133 | { 134 | return '"'.strtr( 135 | $value, 136 | [ 137 | "\x00" => '', 138 | '\\' => '\\\\', 139 | "\t" => '\t', 140 | "\r" => '\r', 141 | "\n" => '\n', 142 | '"' => '\\"', 143 | ] 144 | ).'"'; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Headers.php: -------------------------------------------------------------------------------- 1 | headers = $headers; 34 | ksort($this->headers); 35 | } 36 | 37 | public function __debugInfo() 38 | { 39 | return $this->toArray(); 40 | } 41 | 42 | public function set(string $name, string $value): self 43 | { 44 | $this->headers[$name] = trim($value); 45 | ksort($this->headers); 46 | 47 | return $this; 48 | } 49 | 50 | public function get(string $name): ?string 51 | { 52 | return $this->headers[$name] ?? null; 53 | } 54 | 55 | public function delete(string $name): self 56 | { 57 | unset($this->headers[$name]); 58 | 59 | return $this; 60 | } 61 | 62 | public function clear(): self 63 | { 64 | $this->headers = []; 65 | 66 | return $this; 67 | } 68 | 69 | #[ReturnTypeWillChange] 70 | public function jsonSerialize() 71 | { 72 | return $this->toArray(); 73 | } 74 | 75 | #[ReturnTypeWillChange] 76 | public function getIterator() 77 | { 78 | return new ArrayIterator($this->toArray()); 79 | } 80 | 81 | public function count(): int 82 | { 83 | return count($this->headers); 84 | } 85 | 86 | public function setLanguage(string $language): self 87 | { 88 | return $this->set(self::HEADER_LANGUAGE, $language); 89 | } 90 | 91 | public function getLanguage(): ?string 92 | { 93 | return $this->get(self::HEADER_LANGUAGE); 94 | } 95 | 96 | public function setDomain(string $domain): self 97 | { 98 | return $this->set(self::HEADER_DOMAIN, $domain); 99 | } 100 | 101 | public function getDomain(): ?string 102 | { 103 | return $this->get(self::HEADER_DOMAIN); 104 | } 105 | 106 | public function setPluralForm(int $count, string $rule): self 107 | { 108 | if (preg_match('/[a-z]/i', str_replace('n', '', $rule))) { 109 | throw new InvalidArgumentException(sprintf('Invalid Plural form: "%s"', $rule)); 110 | } 111 | 112 | return $this->set(self::HEADER_PLURAL, sprintf('nplurals=%d; plural=%s;', $count, $rule)); 113 | } 114 | 115 | /** 116 | * Returns the parsed plural definition. 117 | * 118 | * @return array|null [count, rule] 119 | */ 120 | public function getPluralForm(): ?array 121 | { 122 | $header = $this->get(self::HEADER_PLURAL); 123 | 124 | if (!empty($header) && 125 | preg_match('/^nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*([^;]+)\s*;$/', $header, $matches) 126 | ) { 127 | return [intval($matches[1]), $matches[2]]; 128 | } 129 | 130 | return null; 131 | } 132 | 133 | public function toArray(): array 134 | { 135 | return $this->headers; 136 | } 137 | 138 | public function mergeWith(Headers $headers): Headers 139 | { 140 | $merged = clone $this; 141 | $merged->headers = $headers->headers + $merged->headers; 142 | ksort($merged->headers); 143 | 144 | return $merged; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Loader/Loader.php: -------------------------------------------------------------------------------- 1 | loadString($string, $translations); 20 | } 21 | 22 | public function loadString(string $string, ?Translations $translations = null): Translations 23 | { 24 | return $translations ?: $this->createTranslations(); 25 | } 26 | 27 | protected function createTranslations(): Translations 28 | { 29 | return Translations::create(); 30 | } 31 | 32 | protected function createTranslation(?string $context, string $original, ?string $plural = null): ?Translation 33 | { 34 | $translation = Translation::create($context, $original); 35 | 36 | if (isset($plural)) { 37 | $translation->setPlural($plural); 38 | } 39 | 40 | return $translation; 41 | } 42 | 43 | /** 44 | * Reads and returns the content of a file. 45 | */ 46 | protected static function readFile(string $file): string 47 | { 48 | $content = @file_get_contents($file); 49 | 50 | if (false === $content) { 51 | throw new Exception("Cannot read the file '$file', probably permissions"); 52 | } 53 | 54 | return $content; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Loader/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | init($string); 26 | 27 | $magic = $this->readInt('V'); 28 | 29 | if (($magic === self::MAGIC1) || ($magic === self::MAGIC3)) { //to make sure it works for 64-bit platforms 30 | $byteOrder = 'V'; //low endian 31 | } elseif ($magic === (self::MAGIC2 & 0xFFFFFFFF)) { 32 | $byteOrder = 'N'; //big endian 33 | } else { 34 | throw new Exception('Not MO file'); 35 | } 36 | 37 | $this->readInt($byteOrder); 38 | 39 | $total = $this->readInt($byteOrder); //total string count 40 | $originals = $this->readInt($byteOrder); //offset of original table 41 | $tran = $this->readInt($byteOrder); //offset of translation table 42 | 43 | $this->seekto($originals); 44 | $table_originals = $this->readIntArray($byteOrder, $total * 2); 45 | 46 | $this->seekto($tran); 47 | $table_translations = $this->readIntArray($byteOrder, $total * 2); 48 | 49 | for ($i = 0; $i < $total; ++$i) { 50 | $next = $i * 2; 51 | 52 | $this->seekto($table_originals[$next + 2]); 53 | $original = $this->read($table_originals[$next + 1]); 54 | 55 | $this->seekto($table_translations[$next + 2]); 56 | $translated = $this->read($table_translations[$next + 1]); 57 | 58 | // Headers 59 | if ($original === '') { 60 | foreach (explode("\n", $translated) as $headerLine) { 61 | if ($headerLine === '') { 62 | continue; 63 | } 64 | 65 | $headerChunks = preg_split('/:\s*/', $headerLine, 2); 66 | $translations->getHeaders()->set($headerChunks[0], $headerChunks[1] ?? ''); 67 | } 68 | 69 | continue; 70 | } 71 | 72 | $context = $plural = null; 73 | $chunks = explode("\x04", $original, 2); 74 | 75 | if (isset($chunks[1])) { 76 | [$context, $original] = $chunks; 77 | } 78 | 79 | $chunks = explode("\x00", $original, 2); 80 | 81 | if (isset($chunks[1])) { 82 | [$original, $plural] = $chunks; 83 | } 84 | 85 | $translation = $this->createTranslation($context, $original, $plural); 86 | $translations->add($translation); 87 | 88 | if ($translated === '') { 89 | continue; 90 | } 91 | 92 | if ($plural === null) { 93 | $translation->translate($translated); 94 | continue; 95 | } 96 | 97 | $v = explode("\x00", $translated); 98 | $translation->translate(array_shift($v)); 99 | $translation->translatePlural(...array_filter($v)); 100 | } 101 | 102 | return $translations; 103 | } 104 | 105 | private function init(string $string): void 106 | { 107 | $this->string = $string; 108 | $this->position = 0; 109 | $this->length = strlen($string); 110 | } 111 | 112 | private function read(int $bytes): string 113 | { 114 | $data = substr($this->string, $this->position, $bytes); 115 | 116 | $this->seekTo($this->position + $bytes); 117 | 118 | return $data; 119 | } 120 | 121 | private function seekTo(int $position): void 122 | { 123 | $this->position = ($this->length < $position) ? $this->length : $position; 124 | } 125 | 126 | private function readInt(string $byteOrder): int 127 | { 128 | $read = $this->read(4); 129 | 130 | $read = (array) unpack($byteOrder, $read); 131 | 132 | return (int) array_shift($read); 133 | } 134 | 135 | private function readIntArray(string $byteOrder, int $count): array 136 | { 137 | return unpack($byteOrder.$count, $this->read(4 * $count)) ?: []; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Loader/PoLoader.php: -------------------------------------------------------------------------------- 1 | createTranslation(null, ''); 21 | 22 | while ($line !== false) { 23 | $line = trim($line); 24 | $nextLine = next($lines); 25 | 26 | // Treat empty comments as empty lines https://github.com/php-gettext/Gettext/pull/296 27 | if ($line === '#') { 28 | $line = ''; 29 | } 30 | 31 | //Multiline 32 | while (substr($line, -1, 1) === '"' 33 | && $nextLine !== false 34 | && (substr(trim($nextLine), 0, 1) === '"' || substr(trim($nextLine), 0, 4) === '#~ "') 35 | ) { 36 | if (substr(trim($nextLine), 0, 1) === '"') { // Normal multiline 37 | $line = substr($line, 0, -1).substr(trim($nextLine), 1); 38 | } elseif (substr(trim($nextLine), 0, 4) === '#~ "') { // Disabled multiline 39 | $line = substr($line, 0, -1).substr(trim($nextLine), 4); 40 | } 41 | $nextLine = next($lines); 42 | } 43 | 44 | //End of translation 45 | if ($line === '') { 46 | if (!self::isEmpty($translation)) { 47 | $translations->add($translation); 48 | } 49 | 50 | $translation = $this->createTranslation(null, ''); 51 | $line = $nextLine; 52 | continue; 53 | } 54 | 55 | $splitLine = preg_split('/\s+/', $line, 2); 56 | $key = $splitLine[0]; 57 | $data = $splitLine[1] ?? ''; 58 | 59 | if ($key === '#~') { 60 | $translation->disable(); 61 | 62 | $splitLine = preg_split('/\s+/', $data, 2); 63 | $key = $splitLine[0]; 64 | $data = $splitLine[1] ?? ''; 65 | } 66 | 67 | if ($data === '') { 68 | $line = $nextLine; 69 | continue; 70 | } 71 | 72 | switch ($key) { 73 | case '#': 74 | $translation->getComments()->add($data); 75 | break; 76 | case '#.': 77 | $translation->getExtractedComments()->add($data); 78 | break; 79 | case '#,': 80 | foreach (array_map('trim', explode(',', trim($data))) as $value) { 81 | $translation->getFlags()->add($value); 82 | } 83 | break; 84 | case '#:': 85 | foreach (preg_split('/\s+/', trim($data)) as $value) { 86 | if (preg_match('/^(.+)(:(\d*))?$/U', $value, $matches)) { 87 | $line = isset($matches[3]) ? intval($matches[3]) : null; 88 | $translation->getReferences()->add($matches[1], $line); 89 | } 90 | } 91 | break; 92 | case 'msgctxt': 93 | $translation = $translation->withContext(self::decode($data)); 94 | break; 95 | case 'msgid': 96 | $translation = $translation->withOriginal(self::decode($data)); 97 | break; 98 | case 'msgid_plural': 99 | $translation->setPlural(self::decode($data)); 100 | break; 101 | case 'msgstr': 102 | case 'msgstr[0]': 103 | $translation->translate(self::decode($data)); 104 | break; 105 | case 'msgstr[1]': 106 | $translation->translatePlural(self::decode($data)); 107 | break; 108 | default: 109 | if (strpos($key, 'msgstr[') === 0) { 110 | $p = $translation->getPluralTranslations(); 111 | $p[] = self::decode($data); 112 | 113 | $translation->translatePlural(...$p); 114 | break; 115 | } 116 | break; 117 | } 118 | 119 | $line = $nextLine; 120 | } 121 | 122 | if (!self::isEmpty($translation)) { 123 | $translations->add($translation); 124 | } 125 | 126 | //Headers 127 | $translation = $translations->find(null, ''); 128 | 129 | if (!$translation) { 130 | return $translations; 131 | } 132 | 133 | $translations->remove($translation); 134 | 135 | $description = $translation->getComments()->toArray(); 136 | 137 | if (!empty($description)) { 138 | $translations->setDescription(implode("\n", $description)); 139 | } 140 | 141 | $flags = $translation->getFlags()->toArray(); 142 | 143 | if (!empty($flags)) { 144 | $translations->getFlags()->add(...$flags); 145 | } 146 | 147 | $headers = $translations->getHeaders(); 148 | 149 | foreach (self::parseHeaders($translation->getTranslation()) as $name => $value) { 150 | $headers->set($name, $value); 151 | } 152 | 153 | return $translations; 154 | } 155 | 156 | private static function parseHeaders(?string $string): array 157 | { 158 | if (empty($string)) { 159 | return []; 160 | } 161 | 162 | $headers = []; 163 | $lines = explode("\n", $string); 164 | $name = null; 165 | 166 | foreach ($lines as $line) { 167 | $line = self::decode($line); 168 | 169 | if ($line === '') { 170 | continue; 171 | } 172 | 173 | // Checks if it is a header definition line. 174 | // Useful for distinguishing between header definitions and possible continuations of a header entry. 175 | if (preg_match('/^[\w-]+:/', $line)) { 176 | $pieces = array_map('trim', explode(':', $line, 2)); 177 | [$name, $value] = $pieces; 178 | 179 | $headers[$name] = $value; 180 | continue; 181 | } 182 | 183 | $value = $headers[$name] ?? ''; 184 | $headers[$name] = $value.$line; 185 | } 186 | 187 | return $headers; 188 | } 189 | 190 | /** 191 | * Convert a string from its PO representation. 192 | */ 193 | public static function decode(string $value): string 194 | { 195 | if (!$value) { 196 | return ''; 197 | } 198 | 199 | if ($value[0] === '"') { 200 | $value = substr($value, 1, -1); 201 | } 202 | 203 | return strtr( 204 | $value, 205 | [ 206 | '\\\\' => '\\', 207 | '\\a' => "\x07", 208 | '\\b' => "\x08", 209 | '\\t' => "\t", 210 | '\\n' => "\n", 211 | '\\v' => "\x0b", 212 | '\\f' => "\x0c", 213 | '\\r' => "\r", 214 | '\\"' => '"', 215 | ] 216 | ); 217 | } 218 | 219 | private static function isEmpty(Translation $translation): bool 220 | { 221 | if (!empty($translation->getOriginal())) { 222 | return false; 223 | } 224 | 225 | if (!empty($translation->getTranslation())) { 226 | return false; 227 | } 228 | 229 | return true; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Loader/StrictPoLoader.php: -------------------------------------------------------------------------------- 1 | data = $data; 45 | $this->position = 0; 46 | $this->translations = parent::loadString($this->data, $translations); 47 | $this->header = $this->translations->find(null, ''); 48 | $this->pluralCount = $this->translations->getHeaders()->getPluralForm()[0] ?? null; 49 | $this->warnings = []; 50 | for ($length = strlen($this->data); $this->newEntry(); $this->saveEntry()) { 51 | for ($hasComment = false; $this->readComment(); $hasComment = true); 52 | $this->readWhitespace(); 53 | // End of data 54 | if ($this->position >= $length) { 55 | if ($hasComment) { 56 | $this->addWarning("Comment ignored at the end of the string{$this->getErrorPosition()}"); 57 | } 58 | break; 59 | } 60 | $this->readContext(); 61 | $this->readOriginal(); 62 | if ($this->translations->has($this->translation)) { 63 | throw new Exception("Duplicated entry{$this->getErrorPosition()}"); 64 | } 65 | if (!$this->readPlural()) { 66 | $this->readTranslation(); 67 | continue; 68 | } 69 | for ($count = 0; $this->readPluralTranslation(!$count); ++$count); 70 | $count !== ($this->pluralCount ?? $count) && $this->addWarning("The translation has {$count} plural " 71 | . "forms, while the header expects {$this->pluralCount}{$this->getErrorPosition()}"); 72 | } 73 | if (!$this->header) { 74 | $this->addWarning("The loaded string has no header translation{$this->getErrorPosition()}"); 75 | } 76 | 77 | return $this->translations; 78 | } 79 | 80 | /** 81 | * Retrieves the collected warnings 82 | * @return string[] 83 | */ 84 | public function getWarnings(): array 85 | { 86 | return $this->warnings; 87 | } 88 | 89 | /** 90 | * Prepares to parse a new translation 91 | */ 92 | private function newEntry(): Translation 93 | { 94 | $this->isDisabled = false; 95 | 96 | return $this->translation = $this->createTranslation(null, ''); 97 | } 98 | 99 | /** 100 | * Adds the current translation to the output list 101 | */ 102 | private function saveEntry(): void 103 | { 104 | if ($this->isHeader()) { 105 | $this->processHeader(); 106 | 107 | return; 108 | } 109 | $this->translations->add($this->translation); 110 | } 111 | 112 | /** 113 | * Attempts to read whitespace characters, also might skip complex comment prologs when needed 114 | * @return int The position before comments started being consumed 115 | */ 116 | private function readWhitespace(): int 117 | { 118 | do { 119 | $this->position += strspn($this->data, " \n\r\t\v\0", $this->position); 120 | $checkpoint ?? $checkpoint = $this->position; 121 | } while (($this->isDisabled && $this->readString('#~')) || ($this->inPreviousPart && $this->readString('#|'))); 122 | 123 | return $checkpoint; 124 | } 125 | 126 | /** 127 | * Attempts to read the exact informed string 128 | */ 129 | private function readString(string $data): bool 130 | { 131 | return !substr_compare($this->data, $data, $this->position, $l = strlen($data)) && $this->position += $l; 132 | } 133 | 134 | /** 135 | * Attempts to read the exact informed char 136 | */ 137 | private function readChar(string $char): bool 138 | { 139 | return ($this->data[$this->position] ?? null) === $char && ++$this->position; 140 | } 141 | 142 | /** 143 | * Read sequential characters that match the given character set until the length range is satisfied 144 | */ 145 | private function readCharset(string $charset, int $min, int $max, string $name): string 146 | { 147 | if (($length = strspn($this->data, $charset, $this->position, $max)) < $min) { 148 | throw new Exception("Expected at least {$min} occurrence of {$name} characters{$this->getErrorPosition()}"); 149 | } 150 | 151 | return substr($this->data, ($this->position += $length) - $length, $length); 152 | } 153 | 154 | /** 155 | * Attempts to read a standard comment string which ends with a newline 156 | */ 157 | private function readCommentString(): string 158 | { 159 | $length = strcspn($this->data, "\n\r", $this->position); 160 | 161 | return substr($this->data, ($this->position += $length) - $length, $length); 162 | } 163 | 164 | /** 165 | * Attempts to read a quoted string while parsing escape sequences prefixed by \ 166 | */ 167 | private function readQuotedString(?string $context = null): string 168 | { 169 | $this->readWhitespace(); 170 | for ($data = '', $isNewPart = true, $checkpoint = null;;) { 171 | if ($isNewPart && !$this->readChar('"')) { 172 | // The data is over (e.g. beginning of an identifier) or perhaps there's an error 173 | // Restore the checkpoint and let the next parser handle it 174 | if ($checkpoint !== null) { 175 | $this->position = $checkpoint; 176 | break; 177 | } 178 | throw new Exception("Expected an opening quote{$this->getErrorPosition()}"); 179 | } 180 | $isNewPart = false; 181 | // Collects chars until an edge case is found 182 | $length = strcspn($this->data, "\"\r\n\\", $this->position); 183 | $data .= substr($this->data, $this->position, $length); 184 | $this->position += $length; 185 | // Check edge cases 186 | switch ($this->data[$this->position++] ?? null) { 187 | // End of part, saves a checkpoint and attempts to read a new part 188 | case '"': 189 | $checkpoint = $this->readWhitespace(); 190 | $isNewPart = true; 191 | break; 192 | case '\\': 193 | $data .= $this->readEscape(); 194 | break; 195 | // Unexpected newline 196 | case "\r": 197 | case "\n": 198 | throw new Exception("Newline character must be escaped{$this->getErrorPosition()}"); 199 | // Unexpected end of file 200 | case null: 201 | throw new Exception("Expected a closing quote{$this->getErrorPosition()}"); 202 | } 203 | } 204 | if ($context && strlen($data) && strpbrk($data[0] . $data[strlen($data) - 1], "\r\n") && !$this->isHeader()) { 205 | $this->addWarning("$context cannot start nor end with a newline{$this->getErrorPosition()}"); 206 | } 207 | 208 | return $data; 209 | } 210 | 211 | /** 212 | * Reads escaped data 213 | */ 214 | private function readEscape(): string 215 | { 216 | $aliasMap = ['from' => 'efnrtv"ab\\', 'to' => "\e\f\n\r\t\v\"\x07\x08\\"]; 217 | $hexDigits = '0123456789abcdefABCDEF'; 218 | switch ($char = $this->data[$this->position++] ?? "\0") { 219 | case strpbrk($char, $aliasMap['from']) ?: '': 220 | return $aliasMap['to'][strpos($aliasMap['from'], $char)]; 221 | case strpbrk($char, $octalDigits = '01234567'): 222 | // GNU gettext fails with an octal above the signed char range 223 | if (($decimal = octdec($char . $this->readCharset($octalDigits, 0, 2, 'octal'))) > 127) { 224 | throw new Exception("Octal value out of range [0, 0177]{$this->getErrorPosition()}"); 225 | } 226 | 227 | return chr($decimal); 228 | case 'x': 229 | $value = $this->readCharset($hexDigits, 1, PHP_INT_MAX, 'hexadecimal'); 230 | 231 | // GNU reads all valid hexadecimal chars, but only uses the last pair 232 | return hex2bin(str_pad(substr($value, -2), 2, '0', STR_PAD_LEFT)); 233 | case 'U': 234 | case 'u': 235 | // The GNU gettext is supposed to follow the escaping sequences of C 236 | // Curiously it doesn't support the unicode escape 237 | $value = $this->readCharset($hexDigits, 1, $digits = $char === 'u' ? 4 : 8, 'hexadecimal'); 238 | $value = str_pad($value, $digits, '0', STR_PAD_LEFT); 239 | 240 | return mb_convert_encoding(hex2bin($value), 'UTF-8', 'UTF-' . ($digits * 4)); 241 | } 242 | throw new Exception("Invalid escaped character{$this->getErrorPosition()}"); 243 | } 244 | 245 | /** 246 | * Attempts to read and interpret a comment 247 | */ 248 | private function readComment(): bool 249 | { 250 | $this->readWhitespace(); 251 | if (!$this->readChar('#')) { 252 | return false; 253 | } 254 | $type = strpbrk($this->data[$this->position] ?? '', '~|,:.') ?: ''; 255 | $this->position += strlen($type); 256 | // Only a single space might be optionally added 257 | $this->readChar(' '); 258 | switch ($type) { 259 | case '': 260 | $data = $this->readCommentString(); 261 | $this->translation->getComments()->add($data); 262 | break; 263 | case '~': 264 | if ($this->translation->getPreviousOriginal() !== null) { 265 | throw new Exception("Inconsistent use of #~{$this->getErrorPosition()}"); 266 | } 267 | $this->translation->disable(); 268 | $this->isDisabled = true; 269 | break; 270 | case '|': 271 | if ($this->translation->getPreviousOriginal() !== null) { 272 | throw new Exception('Cannot redeclare the previous comment #|, ' 273 | . "ensure the definitions are in the right order{$this->getErrorPosition()}"); 274 | } 275 | $this->inPreviousPart = true; 276 | $this->translation->setPreviousContext($this->readIdentifier('msgctxt')); 277 | $this->translation->setPreviousOriginal($this->readIdentifier('msgid', true)); 278 | $this->translation->setPreviousPlural($this->readIdentifier('msgid_plural')); 279 | $this->inPreviousPart = false; 280 | break; 281 | case ',': 282 | $data = $this->readCommentString(); 283 | foreach (array_map('trim', explode(',', trim($data))) as $value) { 284 | $this->translation->getFlags()->add($value); 285 | } 286 | break; 287 | case ':': 288 | $data = $this->readCommentString(); 289 | foreach (preg_split('/\s+/', trim($data)) as $value) { 290 | if (preg_match('/^(.+)(:(\d*))?$/U', $value, $matches)) { 291 | $line = isset($matches[3]) ? intval($matches[3]) : null; 292 | $this->translation->getReferences()->add($matches[1], $line); 293 | } 294 | } 295 | break; 296 | case '.': 297 | $data = $this->readCommentString(); 298 | $this->translation->getExtractedComments()->add($data); 299 | break; 300 | } 301 | 302 | return true; 303 | } 304 | 305 | /** 306 | * Attempts to read an identifier 307 | */ 308 | private function readIdentifier(string $identifier, bool $throwIfNotFound = false): ?string 309 | { 310 | $checkpoint = $this->readWhitespace(); 311 | if ($this->readString($identifier)) { 312 | return $this->readQuotedString($identifier); 313 | } 314 | if ($throwIfNotFound) { 315 | throw new Exception("Expected $identifier{$this->getErrorPosition()}"); 316 | } 317 | $this->position = $checkpoint; 318 | 319 | return null; 320 | } 321 | 322 | /** 323 | * Attempts to read the context 324 | */ 325 | private function readContext(): bool 326 | { 327 | $data = $this->readIdentifier('msgctxt'); 328 | 329 | if ($data === null) { 330 | return false; 331 | } 332 | 333 | $this->translation = $this->translation->withContext($data); 334 | 335 | return true; 336 | } 337 | 338 | /** 339 | * Reads the original message 340 | */ 341 | private function readOriginal(): void 342 | { 343 | $this->translation = $this->translation->withOriginal($this->readIdentifier('msgid', true)); 344 | } 345 | 346 | /** 347 | * Attempts to read the plural message 348 | */ 349 | private function readPlural(): bool 350 | { 351 | $data = $this->readIdentifier('msgid_plural'); 352 | 353 | if ($data === null) { 354 | return false; 355 | } 356 | 357 | $this->translation->setPlural($data); 358 | 359 | return true; 360 | } 361 | 362 | /** 363 | * Reads the translation 364 | */ 365 | private function readTranslation(): void 366 | { 367 | $this->readWhitespace(); 368 | if (!$this->readString('msgstr')) { 369 | throw new Exception("Expected msgstr{$this->getErrorPosition()}"); 370 | } 371 | $this->translation->translate($this->readQuotedString('msgstr')); 372 | } 373 | 374 | /** 375 | * Attempts to read the pluralized translation 376 | */ 377 | private function readPluralTranslation(bool $throwIfNotFound = false): bool 378 | { 379 | $this->readWhitespace(); 380 | if (!$this->readString('msgstr')) { 381 | if ($throwIfNotFound) { 382 | throw new Exception("Expected indexed msgstr{$this->getErrorPosition()}"); 383 | } 384 | 385 | return false; 386 | } 387 | $this->readWhitespace(); 388 | if (!$this->readChar('[')) { 389 | throw new Exception("Expected character \"[\"{$this->getErrorPosition()}"); 390 | } 391 | $this->readWhitespace(); 392 | $index = (int) $this->readCharset('0123456789', 1, PHP_INT_MAX, 'numeric'); 393 | $this->readWhitespace(); 394 | if (!$this->readChar(']')) { 395 | throw new Exception("Expected character \"]\"{$this->getErrorPosition()}"); 396 | } 397 | $translations = $this->translation->getPluralTranslations(); 398 | if (($translation = $this->translation->getTranslation()) !== null) { 399 | array_unshift($translations, $translation); 400 | } 401 | if (count($translations) !== $index) { 402 | throw new Exception("The msgstr has an invalid index{$this->getErrorPosition()}"); 403 | } 404 | $data = $this->readQuotedString('msgstr'); 405 | $translations[] = $data; 406 | $this->translation->translate(array_shift($translations)); 407 | $this->translation->translatePlural(...$translations); 408 | 409 | return true; 410 | } 411 | 412 | /** 413 | * Setup the current translation as the header translation 414 | */ 415 | private function processHeader(): void 416 | { 417 | $this->header = $header = $this->translation; 418 | if (count($description = $header->getComments()->toArray())) { 419 | $this->translations->setDescription(implode("\n", $description)); 420 | } 421 | if (count($flags = $header->getFlags()->toArray())) { 422 | $this->translations->getFlags()->add(...$flags); 423 | } 424 | $headers = $this->translations->getHeaders(); 425 | if (($header->getTranslation() ?? '') !== '') { 426 | foreach (self::readHeaders($header->getTranslation()) as $name => $value) { 427 | $headers->set($name, $value); 428 | } 429 | } 430 | $this->pluralCount = $headers->getPluralForm()[0] ?? null; 431 | foreach (['Language', 'Plural-Forms', 'Content-Type'] as $header) { 432 | if (($headers->get($header) ?? '') === '') { 433 | $this->addWarning("$header header not declared or empty{$this->getErrorPosition()}"); 434 | } 435 | } 436 | } 437 | 438 | /** 439 | * Parses the translation header data into an array 440 | */ 441 | private function readHeaders(string $data): array 442 | { 443 | $headers = []; 444 | $name = null; 445 | foreach (explode("\n", $data) as $line) { 446 | // Checks if it is a header definition line. 447 | // Useful for distinguishing between header definitions and possible continuations of a header entry. 448 | if (preg_match('/^[\w-]+:/', $line)) { 449 | [$name, $value] = explode(':', $line, 2); 450 | if (isset($headers[$name])) { 451 | $this->addWarning("Header already defined{$this->getErrorPosition()}"); 452 | } 453 | $headers[$name] = trim($value); 454 | continue; 455 | } 456 | // Data without a definition 457 | if ($name === null) { 458 | $this->addWarning("Malformed header name{$this->getErrorPosition()}"); 459 | continue; 460 | } 461 | $headers[$name] .= $line; 462 | } 463 | 464 | return $headers; 465 | } 466 | 467 | /** 468 | * Adds a warning 469 | */ 470 | private function addWarning(string $message): void 471 | { 472 | if ($this->throwOnWarning) { 473 | throw new Exception($message); 474 | } 475 | $this->warnings[] = $message; 476 | } 477 | 478 | /** 479 | * Checks whether the current translation is a header translation 480 | */ 481 | private function isHeader(): bool 482 | { 483 | return $this->translation->getOriginal() === '' && $this->translation->getContext() === null; 484 | } 485 | 486 | /** 487 | * Retrieves the position where an error was detected 488 | */ 489 | private function getErrorPosition(): string 490 | { 491 | if ($this->displayErrorLine) { 492 | $pieces = preg_split('/\\r\\n|\\n\\r|\\n|\\r/', substr($this->data, 0, $this->position)); 493 | $line = count($pieces); 494 | $column = strlen(end($pieces)); 495 | 496 | return " at line {$line} column {$column}"; 497 | } 498 | 499 | return " at byte {$this->position}"; 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /src/Merge.php: -------------------------------------------------------------------------------- 1 | references = $state['references']; 25 | 26 | return $references; 27 | } 28 | 29 | public function __construct() 30 | { 31 | } 32 | 33 | public function __debugInfo() 34 | { 35 | return $this->toArray(); 36 | } 37 | 38 | public function add(string $filename, ?int $line = null): self 39 | { 40 | $fileReferences = $this->references[$filename] ?? []; 41 | 42 | if (isset($line) && !in_array($line, $fileReferences)) { 43 | $fileReferences[] = $line; 44 | } 45 | 46 | $this->references[$filename] = $fileReferences; 47 | 48 | return $this; 49 | } 50 | 51 | #[ReturnTypeWillChange] 52 | public function jsonSerialize() 53 | { 54 | return $this->toArray(); 55 | } 56 | 57 | #[ReturnTypeWillChange] 58 | public function getIterator() 59 | { 60 | return new ArrayIterator($this->references); 61 | } 62 | 63 | public function count(): int 64 | { 65 | return array_reduce($this->references, function ($carry, $item) { 66 | return $carry + (count($item) ?: 1); 67 | }, 0); 68 | } 69 | 70 | public function toArray(): array 71 | { 72 | return $this->references; 73 | } 74 | 75 | public function mergeWith(References $references): References 76 | { 77 | $merged = clone $this; 78 | 79 | foreach ($references as $filename => $lines) { 80 | //Set filename always to string 81 | $filename = (string) $filename; 82 | 83 | if (empty($lines)) { 84 | $merged->add($filename); 85 | continue; 86 | } 87 | 88 | foreach ($lines as $line) { 89 | $merged->add($filename, $line); 90 | } 91 | } 92 | 93 | return $merged; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Scanner/CodeScanner.php: -------------------------------------------------------------------------------- 1 | handler] 24 | */ 25 | public function setFunctions(array $functions): self 26 | { 27 | $this->functions = $functions; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @return array [fnName => handler] 34 | */ 35 | public function getFunctions(): array 36 | { 37 | return $this->functions; 38 | } 39 | 40 | public function ignoreInvalidFunctions($ignore = true): self 41 | { 42 | $this->ignoreInvalidFunctions = $ignore; 43 | 44 | return $this; 45 | } 46 | 47 | public function addReferences($enabled = true): self 48 | { 49 | $this->addReferences = $enabled; 50 | 51 | return $this; 52 | } 53 | 54 | public function extractCommentsStartingWith(string ...$prefixes): self 55 | { 56 | $this->commentsPrefixes = $prefixes; 57 | 58 | return $this; 59 | } 60 | 61 | public function scanString(string $string, string $filename): void 62 | { 63 | $functionsScanner = $this->getFunctionsScanner(); 64 | $functions = $functionsScanner->scan($string, $filename); 65 | 66 | foreach ($functions as $function) { 67 | $this->handleFunction($function); 68 | } 69 | } 70 | 71 | abstract public function getFunctionsScanner(): FunctionsScannerInterface; 72 | 73 | protected function handleFunction(ParsedFunction $function) 74 | { 75 | $handler = $this->getFunctionHandler($function); 76 | 77 | if (is_null($handler)) { 78 | return; 79 | } 80 | 81 | $translation = call_user_func($handler, $function); 82 | 83 | if ($translation && $this->addReferences) { 84 | $translation->getReferences()->add($function->getFilename(), $function->getLine()); 85 | } 86 | } 87 | 88 | protected function getFunctionHandler(ParsedFunction $function): ?callable 89 | { 90 | $name = $function->getName(); 91 | $handler = $this->functions[$name] ?? null; 92 | 93 | return is_null($handler) ? null : [$this, $handler]; 94 | } 95 | 96 | protected function addComments(ParsedFunction $function, ?Translation $translation): ?Translation 97 | { 98 | if (empty($this->commentsPrefixes) || $translation === null) { 99 | return $translation; 100 | } 101 | 102 | foreach ($function->getComments() as $comment) { 103 | if ($this->checkComment($comment)) { 104 | $translation->getExtractedComments()->add($comment); 105 | } 106 | } 107 | 108 | return $translation; 109 | } 110 | 111 | protected function addFlags(ParsedFunction $function, ?Translation $translation): ?Translation 112 | { 113 | if ($translation === null) { 114 | return $translation; 115 | } 116 | 117 | foreach ($function->getFlags() as $flag) { 118 | $translation->getFlags()->add($flag); 119 | } 120 | 121 | return $translation; 122 | } 123 | 124 | protected function checkFunction(ParsedFunction $function, int $minLength): bool 125 | { 126 | if ($function->countArguments() < $minLength) { 127 | if ($this->ignoreInvalidFunctions) { 128 | return false; 129 | } 130 | 131 | throw new Exception( 132 | sprintf( 133 | 'Invalid gettext function in %s:%d. At least %d arguments are required', 134 | $function->getFilename(), 135 | $function->getLine(), 136 | $minLength 137 | ) 138 | ); 139 | } 140 | 141 | $arguments = array_slice($function->getArguments(), 0, $minLength); 142 | 143 | if (in_array(null, $arguments, true)) { 144 | if ($this->ignoreInvalidFunctions) { 145 | return false; 146 | } 147 | 148 | throw new Exception( 149 | sprintf( 150 | 'Invalid gettext function in %s:%d. Some required arguments are not valid', 151 | $function->getFilename(), 152 | $function->getLine() 153 | ) 154 | ); 155 | } 156 | 157 | return true; 158 | } 159 | 160 | protected function checkComment(string $comment): bool 161 | { 162 | foreach ($this->commentsPrefixes as $prefix) { 163 | if ($prefix === '' || strpos($comment, $prefix) === 0) { 164 | return true; 165 | } 166 | } 167 | 168 | return false; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Scanner/FunctionsHandlersTrait.php: -------------------------------------------------------------------------------- 1 | checkFunction($function, 1)) { 18 | return null; 19 | } 20 | [$original] = $function->getArguments(); 21 | 22 | $translation = $this->addComments( 23 | $function, 24 | $this->saveTranslation(null, null, $original) 25 | ); 26 | 27 | return $this->addFlags($function, $translation); 28 | } 29 | 30 | protected function ngettext(ParsedFunction $function): ?Translation 31 | { 32 | if (!$this->checkFunction($function, 2)) { 33 | return null; 34 | } 35 | [$original, $plural] = $function->getArguments(); 36 | 37 | $translation = $this->addComments( 38 | $function, 39 | $this->saveTranslation(null, null, $original, $plural) 40 | ); 41 | 42 | return $this->addFlags($function, $translation); 43 | } 44 | 45 | protected function pgettext(ParsedFunction $function): ?Translation 46 | { 47 | if (!$this->checkFunction($function, 2)) { 48 | return null; 49 | } 50 | [$context, $original] = $function->getArguments(); 51 | 52 | $translation = $this->addComments( 53 | $function, 54 | $this->saveTranslation(null, $context, $original) 55 | ); 56 | 57 | return $this->addFlags($function, $translation); 58 | } 59 | 60 | protected function dgettext(ParsedFunction $function): ?Translation 61 | { 62 | if (!$this->checkFunction($function, 2)) { 63 | return null; 64 | } 65 | [$domain, $original] = $function->getArguments(); 66 | 67 | $translation = $this->addComments( 68 | $function, 69 | $this->saveTranslation($domain, null, $original) 70 | ); 71 | 72 | return $this->addFlags($function, $translation); 73 | } 74 | 75 | protected function dpgettext(ParsedFunction $function): ?Translation 76 | { 77 | if (!$this->checkFunction($function, 3)) { 78 | return null; 79 | } 80 | [$domain, $context, $original] = $function->getArguments(); 81 | 82 | $translation = $this->addComments( 83 | $function, 84 | $this->saveTranslation($domain, $context, $original) 85 | ); 86 | 87 | return $this->addFlags($function, $translation); 88 | } 89 | 90 | protected function npgettext(ParsedFunction $function): ?Translation 91 | { 92 | if (!$this->checkFunction($function, 3)) { 93 | return null; 94 | } 95 | [$context, $original, $plural] = $function->getArguments(); 96 | 97 | $translation = $this->addComments( 98 | $function, 99 | $this->saveTranslation(null, $context, $original, $plural) 100 | ); 101 | 102 | return $this->addFlags($function, $translation); 103 | } 104 | 105 | protected function dngettext(ParsedFunction $function): ?Translation 106 | { 107 | if (!$this->checkFunction($function, 3)) { 108 | return null; 109 | } 110 | [$domain, $original, $plural] = $function->getArguments(); 111 | 112 | $translation = $this->addComments( 113 | $function, 114 | $this->saveTranslation($domain, null, $original, $plural) 115 | ); 116 | 117 | return $this->addFlags($function, $translation); 118 | } 119 | 120 | protected function dnpgettext(ParsedFunction $function): ?Translation 121 | { 122 | if (!$this->checkFunction($function, 4)) { 123 | return null; 124 | } 125 | [$domain, $context, $original, $plural] = $function->getArguments(); 126 | 127 | $translation = $this->addComments( 128 | $function, 129 | $this->saveTranslation($domain, $context, $original, $plural) 130 | ); 131 | 132 | return $this->addFlags($function, $translation); 133 | } 134 | 135 | abstract protected function addComments(ParsedFunction $function, ?Translation $translation): ?Translation; 136 | 137 | abstract protected function addFlags(ParsedFunction $function, ?Translation $translation): ?Translation; 138 | 139 | abstract protected function checkFunction(ParsedFunction $function, int $minLength): bool; 140 | 141 | abstract protected function saveTranslation( 142 | ?string $domain, 143 | ?string $context, 144 | string $original, 145 | ?string $plural = null 146 | ): ?Translation; 147 | } 148 | -------------------------------------------------------------------------------- /src/Scanner/FunctionsScannerInterface.php: -------------------------------------------------------------------------------- 1 | name = $name; 22 | $this->filename = $filename; 23 | $this->line = $line; 24 | $this->lastLine = $lastLine ?? $line; 25 | } 26 | 27 | public function __debugInfo(): array 28 | { 29 | return $this->toArray(); 30 | } 31 | 32 | public function toArray(): array 33 | { 34 | return [ 35 | 'name' => $this->name, 36 | 'filename' => $this->filename, 37 | 'line' => $this->line, 38 | 'lastLine' => $this->lastLine, 39 | 'arguments' => $this->arguments, 40 | 'comments' => $this->comments, 41 | 'flags' => $this->flags, 42 | ]; 43 | } 44 | 45 | public function getName(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | public function getLine(): int 51 | { 52 | return $this->line; 53 | } 54 | 55 | public function getLastLine(): int 56 | { 57 | return $this->lastLine; 58 | } 59 | 60 | public function getFilename(): string 61 | { 62 | return $this->filename; 63 | } 64 | 65 | public function getArguments(): array 66 | { 67 | return $this->arguments; 68 | } 69 | 70 | public function countArguments(): int 71 | { 72 | return count($this->arguments); 73 | } 74 | 75 | public function getComments(): array 76 | { 77 | return $this->comments; 78 | } 79 | 80 | public function getFlags(): array 81 | { 82 | return $this->flags; 83 | } 84 | 85 | public function addArgument($argument = null): self 86 | { 87 | $this->arguments[] = $argument; 88 | 89 | return $this; 90 | } 91 | 92 | public function addComment(string $comment): self 93 | { 94 | $this->comments[] = $comment; 95 | 96 | return $this; 97 | } 98 | 99 | public function addFlag(string $flag): self 100 | { 101 | $this->flags[] = $flag; 102 | 103 | return $this; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Scanner/Scanner.php: -------------------------------------------------------------------------------- 1 | getDomain(); 22 | $this->translations[$domain] = $translations; 23 | } 24 | } 25 | 26 | public function setDefaultDomain(string $defaultDomain): void 27 | { 28 | $this->defaultDomain = $defaultDomain; 29 | } 30 | 31 | public function getDefaultDomain(): string 32 | { 33 | return $this->defaultDomain; 34 | } 35 | 36 | public function getTranslations(): array 37 | { 38 | return $this->translations; 39 | } 40 | 41 | public function scanFile(string $filename): void 42 | { 43 | $string = static::readFile($filename); 44 | 45 | $this->scanString($string, $filename); 46 | } 47 | 48 | abstract public function scanString(string $string, string $filename): void; 49 | 50 | protected function saveTranslation( 51 | ?string $domain, 52 | ?string $context, 53 | string $original, 54 | ?string $plural = null 55 | ): ?Translation { 56 | if (is_null($domain)) { 57 | $domain = $this->defaultDomain; 58 | } 59 | 60 | if (!isset($this->translations[$domain])) { 61 | return null; 62 | } 63 | 64 | $translation = $this->translations[$domain]->addOrMerge( 65 | Translation::create($context, $original) 66 | ); 67 | 68 | if (isset($plural)) { 69 | $translation->setPlural($plural); 70 | } 71 | 72 | return $translation; 73 | } 74 | 75 | /** 76 | * Reads and returns the content of a file. 77 | */ 78 | protected static function readFile(string $file): string 79 | { 80 | $content = @file_get_contents($file); 81 | 82 | if (false === $content) { 83 | throw new Exception("Cannot read the file '$file', probably permissions"); 84 | } 85 | 86 | return $content; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Scanner/ScannerInterface.php: -------------------------------------------------------------------------------- 1 | context = $context; 34 | $translation->original = $original; 35 | 36 | return $translation; 37 | } 38 | 39 | protected static function generateId(?string $context, string $original): string 40 | { 41 | return "{$context}\004{$original}"; 42 | } 43 | 44 | protected function __construct(string $id) 45 | { 46 | $this->id = $id; 47 | 48 | $this->references = new References(); 49 | $this->flags = new Flags(); 50 | $this->comments = new Comments(); 51 | $this->extractedComments = new Comments(); 52 | } 53 | 54 | public function __clone() 55 | { 56 | $this->references = clone $this->references; 57 | $this->flags = clone $this->flags; 58 | $this->comments = clone $this->comments; 59 | $this->extractedComments = clone $this->extractedComments; 60 | } 61 | 62 | public function toArray(): array 63 | { 64 | return [ 65 | 'id' => $this->id, 66 | 'context' => $this->context, 67 | 'original' => $this->original, 68 | 'translation' => $this->translation, 69 | 'plural' => $this->plural, 70 | 'pluralTranslations' => $this->pluralTranslations, 71 | 'disabled' => $this->disabled, 72 | 'previousContext' => $this->previousContext, 73 | 'previousOriginal' => $this->previousOriginal, 74 | 'previousPlural' => $this->previousPlural, 75 | 'references' => $this->getReferences()->toArray(), 76 | 'flags' => $this->getFlags()->toArray(), 77 | 'comments' => $this->getComments()->toArray(), 78 | 'extractedComments' => $this->getExtractedComments()->toArray(), 79 | ]; 80 | } 81 | 82 | public function getId(): string 83 | { 84 | return $this->id; 85 | } 86 | 87 | public function getContext(): ?string 88 | { 89 | return $this->context; 90 | } 91 | 92 | public function withContext(?string $context): Translation 93 | { 94 | $clone = clone $this; 95 | $clone->context = $context; 96 | $clone->id = static::generateId($clone->getContext(), $clone->getOriginal()); 97 | 98 | return $clone; 99 | } 100 | 101 | public function getOriginal(): string 102 | { 103 | return $this->original; 104 | } 105 | 106 | public function withOriginal(string $original): Translation 107 | { 108 | $clone = clone $this; 109 | $clone->original = $original; 110 | $clone->id = static::generateId($clone->getContext(), $clone->getOriginal()); 111 | 112 | return $clone; 113 | } 114 | 115 | public function setPlural(string $plural): self 116 | { 117 | $this->plural = $plural; 118 | 119 | return $this; 120 | } 121 | 122 | public function getPlural(): ?string 123 | { 124 | return $this->plural; 125 | } 126 | 127 | public function setPreviousOriginal(?string $previousOriginal): self 128 | { 129 | $this->previousOriginal = $previousOriginal; 130 | 131 | return $this; 132 | } 133 | 134 | public function getPreviousOriginal(): ?string 135 | { 136 | return $this->previousOriginal; 137 | } 138 | 139 | public function setPreviousContext(?string $previousContext): self 140 | { 141 | $this->previousContext = $previousContext; 142 | 143 | return $this; 144 | } 145 | 146 | public function getPreviousContext(): ?string 147 | { 148 | return $this->previousContext; 149 | } 150 | 151 | public function setPreviousPlural(?string $previousPlural): self 152 | { 153 | $this->previousPlural = $previousPlural; 154 | 155 | return $this; 156 | } 157 | 158 | public function getPreviousPlural(): ?string 159 | { 160 | return $this->previousPlural; 161 | } 162 | 163 | public function disable(bool $disabled = true): self 164 | { 165 | $this->disabled = $disabled; 166 | 167 | return $this; 168 | } 169 | 170 | public function isDisabled(): bool 171 | { 172 | return $this->disabled; 173 | } 174 | 175 | public function translate(string $translation): self 176 | { 177 | $this->translation = $translation; 178 | 179 | return $this; 180 | } 181 | 182 | public function getTranslation(): ?string 183 | { 184 | return $this->translation; 185 | } 186 | 187 | public function isTranslated(): bool 188 | { 189 | return isset($this->translation) && $this->translation !== ''; 190 | } 191 | 192 | public function translatePlural(string ...$translations): self 193 | { 194 | $this->pluralTranslations = $translations; 195 | 196 | return $this; 197 | } 198 | 199 | public function getPluralTranslations(?int $size = null): array 200 | { 201 | if ($size === null) { 202 | return $this->pluralTranslations; 203 | } 204 | 205 | $length = count($this->pluralTranslations); 206 | 207 | if ($size > $length) { 208 | return $this->pluralTranslations + array_fill(0, $size, ''); 209 | } 210 | 211 | return array_slice($this->pluralTranslations, 0, $size); 212 | } 213 | 214 | public function getReferences(): References 215 | { 216 | return $this->references; 217 | } 218 | 219 | public function getFlags(): Flags 220 | { 221 | return $this->flags; 222 | } 223 | 224 | public function getComments(): Comments 225 | { 226 | return $this->comments; 227 | } 228 | 229 | public function getExtractedComments(): Comments 230 | { 231 | return $this->extractedComments; 232 | } 233 | 234 | public function mergeWith(Translation $translation, int $strategy = 0): Translation 235 | { 236 | $merged = clone $this; 237 | 238 | if ($strategy & Merge::COMMENTS_THEIRS) { 239 | $merged->comments = clone $translation->comments; 240 | } elseif (!($strategy & Merge::COMMENTS_OURS)) { 241 | $merged->comments = $merged->comments->mergeWith($translation->comments); 242 | } 243 | 244 | if ($strategy & Merge::EXTRACTED_COMMENTS_THEIRS) { 245 | $merged->extractedComments = clone $translation->extractedComments; 246 | } elseif (!($strategy & Merge::EXTRACTED_COMMENTS_OURS)) { 247 | $merged->extractedComments = $merged->extractedComments->mergeWith($translation->extractedComments); 248 | } 249 | 250 | if ($strategy & Merge::REFERENCES_THEIRS) { 251 | $merged->references = clone $translation->references; 252 | } elseif (!($strategy & Merge::REFERENCES_OURS)) { 253 | $merged->references = $merged->references->mergeWith($translation->references); 254 | } 255 | 256 | if ($strategy & Merge::FLAGS_THEIRS) { 257 | $merged->flags = clone $translation->flags; 258 | } elseif (!($strategy & Merge::FLAGS_OURS)) { 259 | $merged->flags = $merged->flags->mergeWith($translation->flags); 260 | } 261 | 262 | $override = (bool) ($strategy & Merge::TRANSLATIONS_OVERRIDE); 263 | 264 | if (!$merged->translation || ($translation->translation && $override)) { 265 | $merged->translation = $translation->translation; 266 | } 267 | 268 | if (!$merged->plural || ($translation->plural && $override)) { 269 | $merged->plural = $translation->plural; 270 | } 271 | 272 | if (!$merged->previousContext || ($translation->previousContext && $override)) { 273 | $merged->previousContext = $translation->previousContext; 274 | } 275 | 276 | if (!$merged->previousOriginal || ($translation->previousOriginal && $override)) { 277 | $merged->previousOriginal = $translation->previousOriginal; 278 | } 279 | 280 | if (!$merged->previousPlural || ($translation->previousPlural && $override)) { 281 | $merged->previousPlural = $translation->previousPlural; 282 | } 283 | 284 | if (empty($merged->pluralTranslations) || (!empty($translation->pluralTranslations) && $override)) { 285 | $merged->pluralTranslations = $translation->pluralTranslations; 286 | } 287 | 288 | $merged->disable($translation->isDisabled()); 289 | 290 | return $merged; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/Translations.php: -------------------------------------------------------------------------------- 1 | setDomain($domain); 31 | } 32 | 33 | if (isset($language)) { 34 | $translations->setLanguage($language); 35 | } 36 | 37 | return $translations; 38 | } 39 | 40 | protected function __construct() 41 | { 42 | $this->headers = new Headers(); 43 | $this->flags = new Flags(); 44 | } 45 | 46 | public function __clone() 47 | { 48 | foreach ($this->translations as $id => $translation) { 49 | $this->translations[$id] = clone $translation; 50 | } 51 | 52 | $this->headers = clone $this->headers; 53 | } 54 | 55 | public function setDescription(?string $description): self 56 | { 57 | $this->description = $description; 58 | 59 | return $this; 60 | } 61 | 62 | public function getDescription(): ?string 63 | { 64 | return $this->description; 65 | } 66 | 67 | public function getFlags(): Flags 68 | { 69 | return $this->flags; 70 | } 71 | 72 | public function toArray(): array 73 | { 74 | return [ 75 | 'description' => $this->description, 76 | 'headers' => $this->headers->toArray(), 77 | 'flags' => $this->flags->toArray(), 78 | 'translations' => array_map( 79 | function ($translation) { 80 | return $translation->toArray(); 81 | }, 82 | array_values($this->translations) 83 | ), 84 | ]; 85 | } 86 | 87 | #[ReturnTypeWillChange] 88 | public function getIterator() 89 | { 90 | return new ArrayIterator($this->translations); 91 | } 92 | 93 | public function getTranslations(): array 94 | { 95 | return $this->translations; 96 | } 97 | 98 | public function count(): int 99 | { 100 | return count($this->translations); 101 | } 102 | 103 | public function getHeaders(): Headers 104 | { 105 | return $this->headers; 106 | } 107 | 108 | public function add(Translation $translation): self 109 | { 110 | $id = $translation->getId(); 111 | 112 | $this->translations[$id] = $translation; 113 | 114 | return $this; 115 | } 116 | 117 | public function addOrMerge(Translation $translation, int $mergeStrategy = 0): Translation 118 | { 119 | $id = $translation->getId(); 120 | 121 | if (isset($this->translations[$id])) { 122 | return $this->translations[$id] = $this->translations[$id]->mergeWith($translation, $mergeStrategy); 123 | } 124 | 125 | return $this->translations[$id] = $translation; 126 | } 127 | 128 | public function remove(Translation $translation): self 129 | { 130 | unset($this->translations[$translation->getId()]); 131 | 132 | return $this; 133 | } 134 | 135 | public function setDomain(string $domain): self 136 | { 137 | $this->getHeaders()->setDomain($domain); 138 | 139 | return $this; 140 | } 141 | 142 | public function getDomain(): ?string 143 | { 144 | return $this->getHeaders()->getDomain(); 145 | } 146 | 147 | public function setLanguage(string $language): self 148 | { 149 | $info = Language::getById($language); 150 | 151 | if (empty($info)) { 152 | throw new InvalidArgumentException(sprintf('The language "%s" is not valid', $language)); 153 | } 154 | 155 | $this->getHeaders() 156 | ->setLanguage($language) 157 | ->setPluralForm(count($info->categories), $info->formula); 158 | 159 | return $this; 160 | } 161 | 162 | public function getLanguage(): ?string 163 | { 164 | return $this->getHeaders()->getLanguage(); 165 | } 166 | 167 | public function find(?string $context, string $original): ?Translation 168 | { 169 | return $this->translations[(Translation::create($context, $original))->getId()] ?? null; 170 | } 171 | 172 | public function has(Translation $translation): bool 173 | { 174 | return (bool) ($this->translations[$translation->getId()] ?? false); 175 | } 176 | 177 | public function mergeWith(Translations $translations, int $strategy = 0): Translations 178 | { 179 | $merged = clone $this; 180 | 181 | if ($strategy & Merge::HEADERS_THEIRS) { 182 | $merged->headers = clone $translations->headers; 183 | } elseif (!($strategy & Merge::HEADERS_OURS)) { 184 | $merged->headers = $merged->headers->mergeWith($translations->headers); 185 | } 186 | 187 | if ($strategy & Merge::FLAGS_THEIRS) { 188 | $merged->flags = clone $translations->flags; 189 | } elseif (!($strategy & Merge::FLAGS_OURS)) { 190 | $merged->flags = $merged->flags->mergeWith($translations->flags); 191 | } 192 | 193 | if (!$merged->description) { 194 | $merged->description = $translations->description; 195 | } 196 | 197 | foreach ($translations as $id => $translation) { 198 | if (isset($merged->translations[$id])) { 199 | $translation = $merged->translations[$id]->mergeWith($translation, $strategy); 200 | } 201 | 202 | $merged->add($translation); 203 | } 204 | 205 | if ($strategy & Merge::TRANSLATIONS_THEIRS) { 206 | $merged->translations = array_intersect_key($merged->translations, $translations->translations); 207 | } elseif ($strategy & Merge::TRANSLATIONS_OURS) { 208 | $merged->translations = array_intersect_key($merged->translations, $this->translations); 209 | } 210 | 211 | return $merged; 212 | } 213 | } 214 | --------------------------------------------------------------------------------