├── .php-cs-fixer.php ├── LICENSE.md ├── README.md ├── codecov.yml ├── composer.json ├── examples ├── aspell_print_mispellings.php ├── custom_spellchecker.php ├── file_source_mispelling_finder.php ├── markdown_remover_text_processor.php ├── mispelling_finder_aspell_source_echo.php ├── mispelling_finder_aspell_string_echo.php └── multisource_mispelling_finder.php ├── renovate.json └── src ├── Exception ├── ExceptionInterface.php ├── FilesystemException.php ├── InvalidArgumentException.php ├── JsonException.php ├── LogicException.php ├── PcreException.php ├── ProcessFailedException.php ├── ProcessHasErrorOutputException.php ├── PspellException.php └── RuntimeException.php ├── Misspelling.php ├── MisspellingFinder.php ├── MisspellingHandler ├── EchoHandler.php └── MisspellingHandlerInterface.php ├── MisspellingInterface.php ├── Source ├── Directory.php ├── File.php ├── MultiSource.php └── SourceInterface.php ├── Spellchecker ├── Aspell.php ├── Hunspell.php ├── Ispell.php ├── JamSpell.php ├── LanguageTool.php ├── LanguageTool │ └── LanguageToolApiClient.php ├── MultiSpellchecker.php ├── PHPPspell.php └── SpellcheckerInterface.php ├── Text.php ├── Text └── functions.php ├── TextInterface.php ├── TextProcessor ├── MarkdownRemover.php └── TextProcessorInterface.php └── Utils ├── CommandLine.php ├── IspellParser.php ├── LineAndOffset.php ├── ProcessRunner.php └── php-functions.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__.'/src', 6 | __DIR__.'/tests', 7 | __DIR__.'/examples' 8 | ]) 9 | ; 10 | 11 | return (new PhpCsFixer\Config()) 12 | ->setRules([ 13 | '@PSR12' => true, 14 | '@PHP81Migration' => true, 15 | 'no_unused_imports' => true, 16 | 'blank_line_before_statement' => true, 17 | 'cast_spaces' => true, 18 | 'comment_to_phpdoc' => true, 19 | 'declare_strict_types' => true, 20 | 'type_declaration_spaces' => true, 21 | 'linebreak_after_opening_tag' => true, 22 | 'list_syntax' => ['syntax' => 'short'], 23 | 'lowercase_static_reference' => true, 24 | 'lowercase_cast' => true, 25 | 'method_chaining_indentation' => true, 26 | 'native_function_casing' => true, 27 | 'native_function_invocation' => [ 28 | 'include' => ['@compiler_optimized'], 29 | 'strict' => false 30 | ], 31 | 'new_with_parentheses' => true, 32 | 'modernize_types_casting' => true, 33 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 34 | 'no_empty_statement' => true, 35 | 'no_extra_blank_lines' => true, 36 | 'no_multiline_whitespace_around_double_arrow' => true, 37 | 'no_singleline_whitespace_before_semicolons' => true, 38 | 'object_operator_without_whitespace' => true, 39 | 'ordered_class_elements' => true, 40 | 'array_syntax' => ['syntax' => 'short'], 41 | 'php_unit_dedicate_assert' => true, 42 | 'php_unit_dedicate_assert_internal_type' => true, 43 | 'php_unit_expectation' => true, 44 | 'phpdoc_add_missing_param_annotation' => true, 45 | 'phpdoc_annotation_without_dot' => true, 46 | 'phpdoc_to_return_type' => true, 47 | 'phpdoc_align' => ['align' => 'left'], 48 | 'no_empty_phpdoc' => true, 49 | 'phpdoc_indent' => true, 50 | 'trim_array_spaces' => true, 51 | 'phpdoc_no_empty_return' => true, 52 | 'include' => true, 53 | 'phpdoc_no_useless_inheritdoc' => true, 54 | 'no_unneeded_control_parentheses' => true, 55 | 'no_leading_import_slash' => true, 56 | 'phpdoc_order' => true, 57 | 'phpdoc_return_self_reference' => true, 58 | 'phpdoc_scalar' => true, 59 | 'phpdoc_separation' => true, 60 | 'phpdoc_single_line_var_spacing' => true, 61 | 'phpdoc_summary' => true, 62 | 'phpdoc_trim' => true, 63 | 'phpdoc_types' => true, 64 | 'phpdoc_types_order' => ['null_adjustment' => 'always_last'], 65 | 'phpdoc_var_annotation_correct_order' => true, 66 | // 'phpdoc_to_param_type' => false, 67 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], 68 | 'binary_operator_spaces' => true, 69 | 'single_quote' => true, 70 | 'semicolon_after_instruction' => true, 71 | 'return_type_declaration' => true, 72 | 'short_scalar_cast' => true, 73 | 'single_line_comment_style' => true, 74 | 'psr_autoloading' => true, 75 | 'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']], 76 | 'space_after_semicolon' => true, 77 | 'no_whitespace_in_blank_line' => true, 78 | 'strict_comparison' => true, 79 | 'ternary_operator_spaces' => true, 80 | 'ternary_to_null_coalescing' => true, 81 | 'unary_operator_spaces' => true, 82 | 'whitespace_after_comma_in_array' => true, 83 | ]) 84 | ->setFinder($finder) 85 | ; 86 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2018 PhpSpellCheck Project 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

PHP-Spellchecker

2 | 3 |

4 | PHP-Spellchecker 5 |

6 |

7 | Build Status 8 | Code coverage 9 | Code coverage 10 | PHP-Spellchecker chat room 11 | License 12 |

13 | 14 |

Check misspellings from any text source with the most popular PHP spellchecker.

15 | 16 | 17 | ------ 18 | # About 19 | 20 | PHP-Spellchecker is a spellchecker abstraction library for PHP. By providing a unified interface for many different spellcheckers, you’re able to swap out spellcheckers without extensive rewrites. 21 | 22 | Using PHP-Spellchecker can eliminate vendor lock-in, reduce technical debt, and improve the testability of your code. 23 | 24 | # Features 25 | 26 | - 🧐 Supports many popular spellcheckers out of the box: [Aspell][aspell], [Hunspell][hunspell], [Ispell][ispell], [PHP Pspell][pspell], [LanguageTools][languagetools], [JamSpell][jamspell] and [MultiSpellchecker][multispellchecker] [(add yours!)][spellchecker_custom] 27 | - 📄 Supports different text sources: file system [file][filesource]/[directory][directory], [string][php-string], and [multi-source][multisource] [(add yours!)][source_custom] 28 | - 🛠 Supports text processors: [MarkdownRemover][markdownremover] [(add yours!)][textprocessor_custom] 29 | - 🔁 Supports misspelling handlers: [EchoHandler][echohandler] [(add yours!)][custom_handler] 30 | - ➰ Makes use of generators to reduce memory footprint 31 | - ⚖ Flexible and straightforward design 32 | - 💡 Makes it a breeze to implement your own spellcheckers, text processors and misspellings handlers 33 | - 💪 Runs tests against real spellcheckers to ensure full compatibility 34 | 35 | **PHP-Spellchecker** is a welcoming project for new contributors. 36 | 37 | Want to make **your first open source contribution**? Check the [roadmap](#roadmap), pick one task, [open an issue](https://github.com/tigitz/php-spellchecker/issues/new) and we'll help you go through it 🤓🚀 38 | 39 | # Install 40 | 41 | Via Composer 42 | 43 | ```sh 44 | composer require tigitz/php-spellchecker 45 | ``` 46 | 47 | # Usage 48 | 49 | [Check out the documentation](https://tigitz.github.io/php-spellchecker) and [examples](https://github.com/tigitz/php-spellchecker/tree/master/examples) 50 | 51 | ## Using the spellchecker directly 52 | 53 | You can check misspellings directly from a `PhpSpellcheck\Spellchecker` class and process them on your own. 54 | 55 | ```php 56 | check('mispell', ['en_US'], ['from_example']); 64 | foreach ($misspellings as $misspelling) { 65 | $misspelling->getWord(); // 'mispell' 66 | $misspelling->getLineNumber(); // '1' 67 | $misspelling->getOffset(); // '0' 68 | $misspelling->getSuggestions(); // ['misspell', ...] 69 | $misspelling->getContext(); // ['from_example'] 70 | } 71 | ``` 72 | 73 | ## Using the `MisspellingFinder` orchestrator 74 | 75 | You can also use an opinionated `MisspellingFinder` class to orchestrate your spellchecking flow: 76 | 77 |

78 | PHP-Spellchecker-misspellingfinder-flow 79 |

80 | 81 | Following the well-known [Unix philosophy](http://en.wikipedia.org/wiki/Unix_philosophy): 82 | > Write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface. 83 | 84 | ```php 85 | getContent()); 92 | 93 | return $text->replaceContent($contentProcessed); 94 | } 95 | }; 96 | 97 | $misspellingFinder = new MisspellingFinder( 98 | Aspell::create(), // Creates aspell spellchecker pointing to "aspell" as it's binary path 99 | new EchoHandler(), // Handles all the misspellings found by echoing their information 100 | $customTextProcessor 101 | ); 102 | 103 | // using a string 104 | $misspellingFinder->find('It\'s_a_mispelling', ['en_US']); 105 | // word: mispelling | line: 1 | offset: 7 | suggestions: mi spelling,mi-spelling,misspelling | context: [] 106 | 107 | // using a TextSource 108 | $inMemoryTextProvider = new class implements SourceInterface 109 | { 110 | public function toTexts(array $context): iterable 111 | { 112 | yield new Text('my_mispell', ['from_source_interface']); 113 | // t() is a shortcut for new Text() 114 | yield t('my_other_mispell', ['from_named_constructor']); 115 | } 116 | }; 117 | 118 | $misspellingFinder->find($inMemoryTextProvider, ['en_US']); 119 | //word: mispell | line: 1 | offset: 3 | suggestions: mi spell,mi-spell,misspell,... | context: ["from_source_interface"] 120 | //word: mispell | line: 1 | offset: 9 | suggestions: mi spell,mi-spell,misspell,... | context: ["from_named_constructor"] 121 | ``` 122 | 123 | # Roadmap 124 | 125 | The project is still in its initial phase, requiring more real-life usage to stabilize its final 1.0.0 API. 126 | 127 | ## Global 128 | 129 | - [ ] Add a CLI that could do something like `vendor/bin/php-spellchecker "misspell" Languagetools EchoHandler --lang=en_US` 130 | - [ ] Add asynchronous mechanism to spellcheckers. 131 | - [ ] Make some computed misspelling properties optional to improve performance for certain use cases (e.g., lines and offset in `LanguageTools`). 132 | - [ ] Add a language mapper to manage different representations across spellcheckers. 133 | - [ ] Evaluate `strtok` instead of `explode` to parse lines of text, for performance. 134 | - [ ] Evaluate `MutableMisspelling` for performance comparison. 135 | - [ ] Wrap `Webmozart/Assert` library exceptions to throw PHP-Spellchecker custom exceptions instead. 136 | - [ ] Improve the `Makefile`. 137 | 138 | ## Sources 139 | 140 | - [ ] Make a `SourceInterface` class that's able to have an effect on the used spellchecker configuration. 141 | - [ ] `League/Flysystem` source. 142 | - [ ] `Symfony/Finder` source. 143 | 144 | ## Text processors 145 | 146 | - [ ] Markdown - Find a way to keep the original offset and line of words after stripping. 147 | - [ ] Add PHPDoc processor. 148 | - [ ] Add HTML Processor ([inspiration](https://github.com/mekras/php-speller/blob/master/src/Source/Filter/HtmlFilter.php)). 149 | - [ ] Add XLIFF Processor ([inspiration](https://github.com/mekras/php-speller/blob/master/src/Source/XliffSource.php)). 150 | 151 | ## Spell checkers 152 | 153 | - [ ] Cache suggestions of already spellchecked words (PSR-6/PSR-16?). 154 | - [ ] Pspell - Find way to compute word offset. 155 | - [ ] LanguageTools - Evaluate [HTTPlug library][httplug] to make API requests. 156 | - [x] Pspell - find way to list available dictionaries. 157 | - [x] Add [JamSpell](https://github.com/bakwc/JamSpell#http-api) spellchecker. 158 | - [ ] Add [NuSpell](https://github.com/nuspell/nuspell) spellchecker. 159 | - [ ] Add [SymSpell](https://github.com/LeonErath/SymSpellAPI) spellchecker. 160 | - [ ] Add [Yandex.Speller API](https://yandex.ru/dev/speller/doc/dg/concepts/api-overview-docpage/) spellchecker. 161 | - [ ] Add [Bing Spell Check API](https://docs.microsoft.com/en-us/azure/cognitive-services/bing-spell-check/overview) spellchecker. 162 | 163 | ## Handlers 164 | 165 | - [ ] MonologHandler 166 | - [ ] ChainedHandler 167 | - [ ] HTMLReportHandler 168 | - [ ] XmlReportHandler 169 | - [ ] JSONReportHandler 170 | - [ ] ConsoleTableHandler 171 | 172 | ## Tests 173 | 174 | - [ ] Add or improve tests with different text encoding. 175 | - [ ] Refactor duplicate Dockerfile content between PHP images. 176 | 177 | 178 | # Versioning 179 | 180 | We follow [SemVer v2.0.0](http://semver.org/). 181 | 182 | There still are many design decisions that should be confronted with real-world usage before thinking about a v1.0.0 stable release: 183 | 184 | - Are `TextInterface` and `MisspellingInterface` really useful? 185 | - Is using generators the right way to go? 186 | - Should all the contributed spellcheckers be maintained by the package itself? 187 | - How to design an intuitive CLI given the needed flexibility of usage? 188 | - Is the "context" array passed through all the layers the right design to handle data sharing? 189 | 190 | # Testing 191 | 192 | Spell checkers come in many different forms, from HTTP API to command line tools. **PHP-Spellchecker** wants to ensure real-world usage is OK, so it contains integration tests. To run these, spellcheckers need to all be available during tests execution. 193 | 194 | The most convenient way to do it is by using Docker and avoid polluting your local machine. 195 | 196 | ## Docker 197 | 198 | Requires `docker` and `docker-compose` to be installed (tested on Linux). 199 | 200 | ```sh 201 | $ make build # build container images 202 | $ make setup # start spellcheckers container 203 | $ make tests-dox 204 | ``` 205 | 206 | You can also specify PHP version, dependency version target and if you want coverage. 207 | 208 | ```sh 209 | $ PHP_VERSION=8.2 DEPS=LOWEST WITH_COVERAGE="true" make tests-dox 210 | ``` 211 | 212 | Run `make help` to list all available tasks. 213 | 214 | ## Environment variables 215 | 216 | If spellcheckers execution paths are different than their default values (e.g., `docker exec -ti myispell` instead of `ispell`) you can override the path used in tests by redefining environment variables in the [PHPUnit config file](https://github.com/tigitz/php-spellchecker/blob/master/phpunit.xml.dist). 217 | 218 | # Contributing 219 | 220 | Please see [CONTRIBUTING](https://github.com/tigitz/php-spellchecker/tree/master/examples). 221 | 222 | # Credits 223 | 224 | - Inspired by [php-speller](https://github.com/mekras/php-speller), [monolog](https://github.com/Seldaek/monolog) and [flysystem](https://github.com/thephpleague/flysystem) 225 | - [Philippe Segatori][link-author] 226 | - [All Contributors][link-contributors] 227 | 228 | # License 229 | 230 | The MIT License (MIT). Please see [license file](https://github.com/tigitz/php-spellchecker/blob/master/LICENSE.md) for more information. 231 | 232 | **Logo**: 233 | Elements taken for the final rendering are [Designed by rawpixel.com / Freepik](http://www.freepik.com). 234 | 235 | [link-author]: https://github.com/tigitz 236 | [link-contributors]: ../../contributors 237 | 238 | [aspell]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/aspell.html 239 | [hunspell]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/hunspell.html 240 | [ispell]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/ispell.html 241 | [languagetools]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/languagetools.html 242 | [jamspell]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/jamspell.html 243 | [pspell]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/php-pspell.html 244 | [multispellchecker]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/multispellchecker.html 245 | [spellchecker_custom]: https://tigitz.github.io/php-spellchecker/docs/spellcheckers/create-custom.html 246 | 247 | [echohandler]: https://tigitz.github.io/php-spellchecker/docs/misspellings-handlers/echohandler.html 248 | [custom_handler]: https://tigitz.github.io/php-spellchecker/docs/misspellings-handlers/create-custom.html 249 | 250 | [filesource]: https://tigitz.github.io/php-spellchecker/docs/text-sources/file.html 251 | [directory]: https://tigitz.github.io/php-spellchecker/docs/text-sources/directory.html 252 | [php-string]: https://tigitz.github.io/php-spellchecker/docs/text-sources/php-string.html 253 | [multisource]: https://tigitz.github.io/php-spellchecker/docs/text-sources/multisource.html 254 | [source_custom]: https://tigitz.github.io/php-spellchecker/docs/text-sources/create-custom.html 255 | 256 | [markdownremover]: https://tigitz.github.io/php-spellchecker/docs/text-processors/markdown-remover.html 257 | [textprocessor_custom]: https://tigitz.github.io/php-spellchecker/docs/text-processors/create-custom.html 258 | 259 | [httplug]: https://github.com/php-http/httplug 260 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tigitz/php-spellchecker", 3 | "type": "library", 4 | "version": "0.8.0", 5 | "description": "Provides an easy way to spellcheck multiple text source by many spellcheckers, directly from PHP", 6 | "keywords": [ 7 | "spelling", 8 | "spellcheck", 9 | "spellchecker", 10 | "spell-checker", 11 | "spell-check", 12 | "php-spellchecker" 13 | ], 14 | "homepage": "https://github.com/tigitz/php-spellchecker", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Philippe Segatori", 19 | "email": "contact@philippe-segatori.fr", 20 | "homepage": "https://github.com/tigitz", 21 | "role": "Maintainer" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2", 26 | "nyholm/psr7": "^1.3", 27 | "psr/http-client": "^1.0", 28 | "symfony/process": "^6.4 | ^7", 29 | "webmozart/assert": "^1.11" 30 | }, 31 | "require-dev": { 32 | "aptoma/twig-markdown": "^3.0", 33 | "cocur/slugify": "^3.2 || ^4.0", 34 | "erusev/parsedown": "^1.7", 35 | "erusev/parsedown-extra": "^0.8", 36 | "phpstan/phpstan": "^2", 37 | "phpstan/phpstan-strict-rules": "^2", 38 | "phpstan/phpstan-webmozart-assert": "^2", 39 | "phpstan/phpstan-phpunit": "^2", 40 | "phpunit/phpunit": "^11.0", 41 | "pixelrobin/php-feather": "^2", 42 | "symfony/filesystem": "^5 |^6 | ^7", 43 | "symfony/finder": "^5 |^6 | ^7", 44 | "symfony/http-client": "^5 |^6 | ^7" 45 | }, 46 | "suggest": { 47 | "symfony/http-client": "A PSR-18 Client implementation to use spellcheckers that relies on HTTP APIs" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "PhpSpellcheck\\": "src" 52 | }, 53 | "files": [ "src/Text/functions.php" , "src/Utils/php-functions.php" ] 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "PhpSpellcheck\\Tests\\": "tests" 58 | } 59 | }, 60 | "extra": { 61 | "branch-alias": { 62 | "dev-master": "1.0-dev" 63 | } 64 | }, 65 | "config": { 66 | "sort-packages": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/aspell_print_mispellings.php: -------------------------------------------------------------------------------- 1 | check('mispell', ['en_US'], ['from' => 'aspell spellchecker']); 14 | foreach ($misspellings as $misspelling) { 15 | print_r([ 16 | $misspelling->getWord(), // 'mispell' 17 | $misspelling->getLineNumber(), // '1' 18 | $misspelling->getOffset(), // '0' 19 | $misspelling->getSuggestions(), // ['misspell', ...] 20 | $misspelling->getContext(), // ['from' => 'aspell spellchecker'] 21 | ]); 22 | } 23 | -------------------------------------------------------------------------------- /examples/custom_spellchecker.php: -------------------------------------------------------------------------------- 1 | check('The PHP-Spellcheckerer library', ['en_US'], ['context']); 44 | foreach ($misspellings as $misspelling) { 45 | print_r([ 46 | $misspelling->getWord(), // 'PHP-Spellcheckerer' 47 | $misspelling->getLineNumber(), // '...' 48 | $misspelling->getOffset(), // '...' 49 | $misspelling->getSuggestions(), // ['PHP Spellcheck'] 50 | $misspelling->getContext(), // [] 51 | ]); 52 | } 53 | -------------------------------------------------------------------------------- /examples/file_source_mispelling_finder.php: -------------------------------------------------------------------------------- 1 | find( 17 | new \PhpSpellcheck\Source\File(__DIR__ . '/../tests/Fixtures/Text/mispelling1.txt'), 18 | ['en_US'], 19 | ['from' => 'aspell spellchecker'] 20 | ); 21 | -------------------------------------------------------------------------------- /examples/markdown_remover_text_processor.php: -------------------------------------------------------------------------------- 1 | find($mdFormattedString, ['en_US']); 28 | // word: mispelling | line: 1 | offset: 7 | suggestions: mi spelling,mi-spelling,misspelling | context: [] 29 | -------------------------------------------------------------------------------- /examples/mispelling_finder_aspell_source_echo.php: -------------------------------------------------------------------------------- 1 | getContent()); 21 | 22 | return $text->replaceContent($contentProcessed); 23 | } 24 | }; 25 | 26 | $misspellingFinder = new MisspellingFinder( 27 | Aspell::create(), // Creates aspell spellchecker pointing to "aspell" as it's binary path 28 | new EchoHandler(), // Handles all the misspellings found by echoing their information 29 | $customTextProcessor 30 | ); 31 | 32 | // Using a custom SourceInterface that generates Text 33 | $inMemoryTextProvider = new class () implements SourceInterface { 34 | public function toTexts(array $context): iterable 35 | { 36 | yield t('my_mispell', ['from_source_interface']); 37 | yield t('my_other_mispell', ['from_named_constructor']); 38 | } 39 | }; 40 | 41 | $misspellingFinder->find($inMemoryTextProvider, ['en_US']); 42 | //word: mispell | line: 1 | offset: 3 | suggestions: mi spell,mi-spell,misspell,... | context: ["from_source_interface"] 43 | //word: mispell | line: 1 | offset: 9 | suggestions: mi spell,mi-spell,misspell,... | context: ["from_named_constructor"] 44 | -------------------------------------------------------------------------------- /examples/mispelling_finder_aspell_string_echo.php: -------------------------------------------------------------------------------- 1 | getContent()); 18 | 19 | return $text->replaceContent($contentProcessed); 20 | } 21 | }; 22 | 23 | $misspellingFinder = new MisspellingFinder( 24 | Aspell::create(), // Creates aspell spellchecker pointing to "aspell" as it's binary path 25 | new EchoHandler(), // Handles all the misspellings found by echoing their information 26 | $customTextProcessor 27 | ); 28 | 29 | // using a string 30 | $misspellingFinder->find('It\'s_a_mispelling', ['en_US']); 31 | // word: mispelling | line: 1 | offset: 7 | suggestions: mi spelling,mi-spelling,misspelling | context: [] 32 | -------------------------------------------------------------------------------- /examples/multisource_mispelling_finder.php: -------------------------------------------------------------------------------- 1 | find( 17 | new \PhpSpellcheck\Source\MultiSource( 18 | [ 19 | new \PhpSpellcheck\Source\File(__DIR__ . '/../tests/Fixtures/Text/mispelling1.txt'), 20 | new \PhpSpellcheck\Source\Directory(__DIR__ . '/../tests/Fixtures/Text/Directory'), 21 | ] 22 | ), 23 | ['en_US'], 24 | ['from' => 'aspell spellchecker'] 25 | ); 26 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 'PREG_INTERNAL_ERROR: Internal error', 13 | PREG_BACKTRACK_LIMIT_ERROR => 'PREG_BACKTRACK_LIMIT_ERROR: Backtrack limit reached', 14 | PREG_RECURSION_LIMIT_ERROR => 'PREG_RECURSION_LIMIT_ERROR: Recursion limit reached', 15 | PREG_BAD_UTF8_ERROR => 'PREG_BAD_UTF8_ERROR: Invalid UTF8 character', 16 | PREG_BAD_UTF8_OFFSET_ERROR => 'PREG_BAD_UTF8_OFFSET_ERROR', 17 | PREG_JIT_STACKLIMIT_ERROR => 'PREG_JIT_STACKLIMIT_ERROR', 18 | ]; 19 | $errMsg = $errorMap[preg_last_error()] ?? 'Unknown PCRE error: '.preg_last_error(); 20 | 21 | return new self($errMsg, preg_last_error()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Exception/ProcessFailedException.php: -------------------------------------------------------------------------------- 1 | process = $process; 23 | 24 | $message = \sprintf( 25 | 'Process with command "%s" has failed%s with exit code %d(%s)%s', 26 | $process->getCommandLine(), 27 | $process->isStarted() ? ' running' : '', 28 | $process->getExitCode(), 29 | $process->getExitCodeText(), 30 | $failureReason !== '' ? ' because "' . $failureReason . '"' : '' 31 | ); 32 | 33 | parent::__construct( 34 | $message, 35 | $code, 36 | $previous 37 | ); 38 | } 39 | 40 | public function getProcess(): Process 41 | { 42 | return $this->process; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Exception/ProcessHasErrorOutputException.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private $suggestions; 31 | 32 | /** 33 | * @var array 34 | */ 35 | private $context; 36 | 37 | /** 38 | * @param array $context 39 | * @param array $suggestions 40 | */ 41 | public function __construct( 42 | string $word, 43 | ?int $offset = null, 44 | ?int $lineNumber = null, 45 | array $suggestions = [], 46 | array $context = [] 47 | ) { 48 | Assert::stringNotEmpty($word); 49 | 50 | $this->word = $word; 51 | $this->offset = $offset; 52 | $this->lineNumber = $lineNumber; 53 | $this->suggestions = $suggestions; 54 | $this->context = $context; 55 | } 56 | 57 | public function mergeSuggestions(array $suggestionsToAdd): MisspellingInterface 58 | { 59 | $mergedSuggestions = []; 60 | $existingSuggestionsAsKeys = array_flip($this->suggestions); 61 | foreach ($suggestionsToAdd as $suggestionToAdd) { 62 | if (!isset($existingSuggestionsAsKeys[$suggestionToAdd])) { 63 | $this->suggestions[] = $suggestionToAdd; 64 | } 65 | } 66 | 67 | return new self( 68 | $this->word, 69 | $this->offset, 70 | $this->lineNumber, 71 | $mergedSuggestions, 72 | $this->context 73 | ); 74 | } 75 | 76 | public function getUniqueIdentity(): string 77 | { 78 | return $this->getWord() . $this->getLineNumber() . $this->getOffset(); 79 | } 80 | 81 | public function canDeterminateUniqueIdentity(): bool 82 | { 83 | return $this->getLineNumber() !== null 84 | && $this->getOffset() !== null; 85 | } 86 | 87 | public function getWord(): string 88 | { 89 | return $this->word; 90 | } 91 | 92 | public function getOffset(): ?int 93 | { 94 | return $this->offset; 95 | } 96 | 97 | public function getLineNumber(): ?int 98 | { 99 | return $this->lineNumber; 100 | } 101 | 102 | public function hasSuggestions(): bool 103 | { 104 | return !empty($this->suggestions); 105 | } 106 | 107 | public function hasContext(): bool 108 | { 109 | return !empty($this->context); 110 | } 111 | 112 | public function getSuggestions(): array 113 | { 114 | return $this->suggestions; 115 | } 116 | 117 | public function getContext(): array 118 | { 119 | return $this->context; 120 | } 121 | 122 | public function setContext(array $context): MisspellingInterface 123 | { 124 | return new self( 125 | $this->word, 126 | $this->offset, 127 | $this->lineNumber, 128 | $this->suggestions, 129 | $context 130 | ); 131 | } 132 | 133 | public function mergeContext(array $context, bool $override = true): MisspellingInterface 134 | { 135 | if (empty($context)) { 136 | throw new InvalidArgumentException('Context trying to be merged is empty'); 137 | } 138 | 139 | return new self( 140 | $this->word, 141 | $this->offset, 142 | $this->lineNumber, 143 | $this->suggestions, 144 | $override ? array_merge($this->context, $context) : array_merge($context, $this->context) 145 | ); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/MisspellingFinder.php: -------------------------------------------------------------------------------- 1 | spellChecker = $spellChecker; 36 | $this->misspellingHandler = $misspellingHandler; 37 | $this->textProcessor = $textProcessor; 38 | } 39 | 40 | /** 41 | * @param iterable|SourceInterface|string|TextInterface $source 42 | * @param array $context 43 | * @param array $languages 44 | * 45 | * @return MisspellingInterface[] 46 | */ 47 | public function find( 48 | $source, 49 | array $languages = [], 50 | array $context = [] 51 | ): iterable { 52 | if (\is_string($source)) { 53 | $texts = [new Text($source, $context)]; 54 | } elseif ($source instanceof TextInterface) { 55 | $texts = [$source]; 56 | } elseif (\is_array($source)) { 57 | $texts = $source; 58 | } elseif ($source instanceof SourceInterface) { 59 | $texts = $source->toTexts($context); 60 | } else { 61 | $sourceVarType = \is_object($source) ? \get_class($source) : \gettype($source); 62 | $allowedTypes = implode(' or ', ['"string"', '"' . SourceInterface::class . '"', '"iterable<' . TextInterface::class . '>"', '"' . TextInterface::class . '"']); 63 | 64 | throw new InvalidArgumentException('Source should be of type ' . $allowedTypes . ', "' . $sourceVarType . '" given'); 65 | } 66 | 67 | $misspellings = $this->doSpellCheckTexts($texts, $languages); 68 | 69 | if ($this->misspellingHandler !== null) { 70 | $this->misspellingHandler->handle($misspellings); 71 | } 72 | 73 | return $misspellings; 74 | } 75 | 76 | public function setSpellchecker(SpellcheckerInterface $spellChecker): void 77 | { 78 | $this->spellChecker = $spellChecker; 79 | } 80 | 81 | public function setMisspellingHandler(MisspellingHandlerInterface $misspellingHandler): void 82 | { 83 | $this->misspellingHandler = $misspellingHandler; 84 | } 85 | 86 | /** 87 | * @param TextInterface[] $texts 88 | * @param string[] $languages 89 | * 90 | * @return iterable 91 | */ 92 | private function doSpellCheckTexts( 93 | iterable $texts, 94 | array $languages 95 | ): iterable { 96 | foreach ($texts as $text) { 97 | if ($this->textProcessor !== null) { 98 | $text = $this->textProcessor->process($text); 99 | } 100 | 101 | yield from $this->spellChecker->check( 102 | $text->getContent(), 103 | $languages, 104 | $text->getContext() 105 | ); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/MisspellingHandler/EchoHandler.php: -------------------------------------------------------------------------------- 1 | getWord(), 20 | $misspelling->getLineNumber(), 21 | $misspelling->getOffset(), 22 | $misspelling->hasSuggestions() ? implode(',', $misspelling->getSuggestions()) : '', 23 | \PhpSpellcheck\json_encode($misspelling->getContext()) 24 | ); 25 | 26 | echo $output; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/MisspellingHandler/MisspellingHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getSuggestions(): array; 21 | 22 | /** 23 | * @param array $suggestions 24 | */ 25 | public function mergeSuggestions(array $suggestions): MisspellingInterface; 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function getContext(): array; 31 | 32 | /** 33 | * @param array $context 34 | */ 35 | public function setContext(array $context): MisspellingInterface; 36 | 37 | public function hasContext(): bool; 38 | 39 | /** 40 | * @param array $context 41 | */ 42 | public function mergeContext(array $context, bool $override = true): MisspellingInterface; 43 | 44 | public function getUniqueIdentity(): string; 45 | 46 | public function canDeterminateUniqueIdentity(): bool; 47 | } 48 | -------------------------------------------------------------------------------- /src/Source/Directory.php: -------------------------------------------------------------------------------- 1 | dirPath = $dirPath; 25 | $this->pattern = $pattern; 26 | } 27 | 28 | /** 29 | * @param array $context 30 | * 31 | * @return iterable 32 | */ 33 | public function toTexts(array $context): iterable 34 | { 35 | foreach ($this->getContents() as $text) { 36 | yield new Text($text->getContent(), array_merge($text->getContext(), $context)); 37 | } 38 | } 39 | 40 | /** 41 | * @return iterable 42 | */ 43 | private function getContents(): iterable 44 | { 45 | $filesInDir = new \RecursiveIteratorIterator( 46 | new \RecursiveDirectoryIterator( 47 | $this->dirPath, 48 | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME 49 | ), 50 | \RecursiveIteratorIterator::SELF_FIRST 51 | ); 52 | 53 | if ($this->pattern !== null) { 54 | $filesInDir = new \RegexIterator($filesInDir, $this->pattern, \RegexIterator::GET_MATCH); 55 | } 56 | 57 | /** @var array|\SplFileInfo|string $file */ 58 | foreach ($filesInDir as $file) { 59 | if (\is_string($file)) { 60 | $file = new \SplFileInfo($file); 61 | } elseif (\is_array($file) && !empty($file)) { 62 | // When regex pattern is used, an array containing the file path in its first element is returned 63 | $file = new \SplFileInfo(current($file)); 64 | } else { 65 | throw new RuntimeException(\sprintf('Couldn\'t create "%s" object from the given file', \SplFileInfo::class)); 66 | } 67 | 68 | if (!$file->isDir() && $file->getRealPath() !== false) { 69 | yield from (new File($file->getRealPath()))->toTexts(); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Source/File.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 19 | } 20 | 21 | /** 22 | * @param array $context 23 | * 24 | * @return iterable 25 | */ 26 | public function toTexts(array $context = []): iterable 27 | { 28 | $context['filePath'] = \PhpSpellcheck\realpath($this->filePath); 29 | 30 | yield new Text($this->getFileContent(), $context); 31 | } 32 | 33 | private function getFileContent(): string 34 | { 35 | return \PhpSpellcheck\file_get_contents($this->filePath); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Source/MultiSource.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private $sources; 15 | 16 | /** 17 | * @param iterable $sources 18 | */ 19 | public function __construct(iterable $sources) 20 | { 21 | Assert::allIsInstanceOf($sources, SourceInterface::class); 22 | $this->sources = $sources; 23 | } 24 | 25 | public function toTexts(array $context = []): iterable 26 | { 27 | foreach ($this->sources as $source) { 28 | foreach ($source->toTexts($context) as $text) { 29 | yield $text->mergeContext($context, true); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Source/SourceInterface.php: -------------------------------------------------------------------------------- 1 | $context 13 | * 14 | * @return iterable 15 | */ 16 | public function toTexts(array $context): iterable; 17 | } 18 | -------------------------------------------------------------------------------- /src/Spellchecker/Aspell.php: -------------------------------------------------------------------------------- 1 | binaryPath = $binaryPath; 24 | } 25 | 26 | public function check(string $text, array $languages = [], array $context = []): iterable 27 | { 28 | Assert::maxCount($languages, 1, 'Aspell spellchecker doesn\'t support multiple languages check'); 29 | 30 | $cmd = $this->binaryPath->addArgs(['--encoding', 'utf-8']); 31 | $cmd = $cmd->addArg('-a'); 32 | 33 | if (!empty($languages)) { 34 | $cmd = $cmd->addArg('--lang=' . implode(',', $languages)); 35 | } 36 | 37 | $process = new Process($cmd->getArgs()); 38 | // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, 39 | // ignoring correct words and thus speeding up the execution 40 | $process->setInput('!' . PHP_EOL . IspellParser::adaptInputForTerseModeProcessing($text) . PHP_EOL . '%'); 41 | 42 | $output = ProcessRunner::run($process)->getOutput(); 43 | 44 | if ($process->getErrorOutput() !== '') { 45 | throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); 46 | } 47 | 48 | return IspellParser::parseMisspellingsFromOutput($output, $context); 49 | } 50 | 51 | public function getBinaryPath(): CommandLine 52 | { 53 | return $this->binaryPath; 54 | } 55 | 56 | public function getSupportedLanguages(): iterable 57 | { 58 | $languages = []; 59 | $cmd = $this->binaryPath->addArgs(['dump', 'dicts']); 60 | $process = new Process($cmd->getArgs()); 61 | $output = explode(PHP_EOL, ProcessRunner::run($process)->getOutput()); 62 | 63 | foreach ($output as $line) { 64 | $name = trim($line); 65 | if (strpos($name, '-variant') !== false || $name === '') { 66 | // Skip variants 67 | continue; 68 | } 69 | $languages[$name] = true; 70 | } 71 | $languages = array_keys($languages); 72 | sort($languages); 73 | 74 | return $languages; 75 | } 76 | 77 | public static function create(?string $binaryPathAsString = null): self 78 | { 79 | return new self(new CommandLine($binaryPathAsString ?? 'aspell')); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Spellchecker/Hunspell.php: -------------------------------------------------------------------------------- 1 | binaryPath = $binaryPath; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function check( 29 | string $text, 30 | array $languages = [], 31 | array $context = [] 32 | ): iterable { 33 | $cmd = $this->binaryPath->addArgs(['-i', 'UTF-8']); 34 | $cmd = $cmd->addArg('-a'); 35 | 36 | if (!empty($languages)) { 37 | $cmd = $cmd->addArgs(['-d', implode(',', $languages)]); 38 | } 39 | 40 | $process = new Process($cmd->getArgs()); 41 | // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, 42 | // ignoring correct words and thus speeding execution 43 | $process->setInput('!' . PHP_EOL . IspellParser::adaptInputForTerseModeProcessing($text) . PHP_EOL . '%'); 44 | 45 | $output = ProcessRunner::run($process)->getOutput(); 46 | if ($process->getErrorOutput() !== '') { 47 | throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); 48 | } 49 | 50 | return IspellParser::parseMisspellingsFromOutput($output, $context); 51 | } 52 | 53 | public function getBinaryPath(): CommandLine 54 | { 55 | return $this->binaryPath; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function getSupportedLanguages(): iterable 62 | { 63 | $languages = []; 64 | $cmd = $this->binaryPath->addArg('-D'); 65 | $process = new Process($cmd->getArgs()); 66 | $output = explode(PHP_EOL, ProcessRunner::run($process)->getErrorOutput()); 67 | 68 | foreach ($output as $line) { 69 | $line = trim($line); 70 | if ('' === $line // Skip empty lines 71 | || substr($line, -1) === ':' // Skip headers 72 | || strpos($line, ':') !== false // Skip search path 73 | ) { 74 | continue; 75 | } 76 | $name = basename($line); 77 | if (strpos($name, 'hyph_') === 0) { 78 | // Skip MySpell hyphen files 79 | continue; 80 | } 81 | $name = \PhpSpellcheck\preg_replace('/\.(aff|dic)$/', '', $name); 82 | if (\is_string($name)) { 83 | $languages[$name] = true; 84 | } 85 | } 86 | $languages = array_keys($languages); 87 | sort($languages); 88 | 89 | return $languages; 90 | } 91 | 92 | public static function create(?string $binaryPathAsString = null): self 93 | { 94 | return new self(new CommandLine($binaryPathAsString ?? 'hunspell')); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Spellchecker/Ispell.php: -------------------------------------------------------------------------------- 1 | ispellCommandLine = $ispellCommandLine; 34 | $this->shellEntryPoint = $shellEntryPoint; 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function check(string $text, array $languages = [], array $context = []): iterable 41 | { 42 | Assert::greaterThan($languages, 1, 'Ispell spellchecker doesn\'t support multiple languages check'); 43 | 44 | $cmd = $this->ispellCommandLine->addArg('-a'); 45 | 46 | if (!empty($languages)) { 47 | $cmd = $cmd->addArgs(['-d', implode(',', $languages)]); 48 | } 49 | 50 | $process = new Process($cmd->getArgs()); 51 | 52 | // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, 53 | // ignoring correct words and thus speeding execution 54 | $process->setInput('!' . PHP_EOL . IspellParser::adaptInputForTerseModeProcessing($text) . PHP_EOL . '%'); 55 | 56 | $output = ProcessRunner::run($process)->getOutput(); 57 | 58 | if ($process->getErrorOutput() !== '') { 59 | throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); 60 | } 61 | 62 | return IspellParser::parseMisspellingsFromOutput($output, $context); 63 | } 64 | 65 | public function getCommandLine(): CommandLine 66 | { 67 | return $this->ispellCommandLine; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function getSupportedLanguages(): iterable 74 | { 75 | if ($this->supportedLanguages === null) { 76 | $shellEntryPoint = $this->shellEntryPoint ?? new CommandLine([]); 77 | $whichCommand = clone $shellEntryPoint; 78 | $process = new Process( 79 | $whichCommand 80 | ->addArg('which') 81 | ->addArg('ispell') 82 | ->getArgs() 83 | ); 84 | $process->mustRun(); 85 | $binaryPath = trim($process->getOutput()); 86 | 87 | $lsCommand = clone $shellEntryPoint; 88 | $process = new Process( 89 | $lsCommand 90 | ->addArg('ls') 91 | ->addArg(\dirname($binaryPath, 2) . '/lib/ispell') 92 | ->getArgs() 93 | ); 94 | $process->mustRun(); 95 | 96 | $listOfFiles = trim($process->getOutput()); 97 | 98 | $this->supportedLanguages = []; 99 | foreach (explode(PHP_EOL, $listOfFiles) as $file) { 100 | if (strpos($file, '.aff', -4) === false) { 101 | continue; 102 | } 103 | 104 | yield substr($file, 0, -4); 105 | } 106 | } 107 | 108 | return $this->supportedLanguages; 109 | } 110 | 111 | public static function create(?string $ispellCommandLineAsString): self 112 | { 113 | return new self(new CommandLine($ispellCommandLineAsString ?? 'ispell')); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Spellchecker/JamSpell.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 29 | $this->endpoint = $endpoint; 30 | } 31 | 32 | public function check(string $text, array $languages, array $context): iterable 33 | { 34 | $request = (new Psr17Factory()) 35 | ->createRequest('POST', $this->endpoint) 36 | ->withBody(Stream::create($text)); 37 | 38 | /** @var array{results: array} $spellcheckResponseAsArray */ 39 | $spellcheckResponseAsArray = json_decode($spellcheckResponse = $this->httpClient->sendRequest($request)->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); 40 | 41 | if (!isset($spellcheckResponseAsArray['results'])) { 42 | throw new RuntimeException('Jamspell spellcheck HTTP response must include a "results" key. Response given: "'.$spellcheckResponse.'"'); 43 | } 44 | 45 | foreach ($spellcheckResponseAsArray['results'] as $result) { 46 | 47 | [$line, $offset] = LineAndOffset::findFromFirstCharacterOffset($text, $result['pos_from']); 48 | 49 | yield new Misspelling( 50 | mb_substr($text, $result['pos_from'], $result['len']), 51 | $offset, 52 | $line, 53 | $result['candidates'], 54 | $context 55 | ); 56 | } 57 | } 58 | 59 | public function getSupportedLanguages(): iterable 60 | { 61 | throw new RuntimeException('Jamspell doesn\'t provide a way to retrieve the language its actually supporting through its HTTP API. Rely on the language models it has been setup with.'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Spellchecker/LanguageTool.php: -------------------------------------------------------------------------------- 1 | apiClient = $apiClient; 23 | } 24 | 25 | /** 26 | * @param array $context 27 | * @param array $languages 28 | * 29 | * @return \Generator 30 | */ 31 | public function check( 32 | string $text, 33 | array $languages, 34 | array $context 35 | ): iterable { 36 | Assert::notEmpty($languages, 'LanguageTool requires at least one language to be set to run it\'s spellchecking process'); 37 | 38 | if (isset($context[self::class])) { 39 | Assert::isArray($context[self::class]); 40 | /** @var array $options */ 41 | $options = $context[self::class]; 42 | } 43 | $check = $this->apiClient->spellCheck($text, $languages, $options ?? []); 44 | 45 | /** @var array{matches: list, 49 | * sentence: string, 50 | * message: string, 51 | * rule: string 52 | * }>} $check */ 53 | 54 | if (!\is_array($check['matches'])) { 55 | throw new RuntimeException('LanguageTool spellcheck response must contain a "matches" array'); 56 | } 57 | 58 | foreach ($check['matches'] as $match) { 59 | [$line, $offsetFromLine] = LineAndOffset::findFromFirstCharacterOffset( 60 | $text, 61 | $match['offset'] 62 | ); 63 | 64 | yield new Misspelling( 65 | mb_substr($match['context']['text'], $match['context']['offset'], $match['context']['length']), 66 | $offsetFromLine, 67 | $line, // line break index transformed in line number 68 | array_column($match['replacements'], 'value'), 69 | array_merge( 70 | [ 71 | 'sentence' => $match['sentence'], 72 | 'spellingErrorMessage' => $match['message'], 73 | 'ruleUsed' => $match['rule'], 74 | ], 75 | $context 76 | ) 77 | ); 78 | } 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function getSupportedLanguages(): array 85 | { 86 | return $this->apiClient->getSupportedLanguages(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Spellchecker/LanguageTool/LanguageToolApiClient.php: -------------------------------------------------------------------------------- 1 | baseUrl = $baseUrl; 20 | } 21 | 22 | /** 23 | * @param array $languages 24 | * @param array $options 25 | * 26 | * @return array{matches: array, 30 | * sentence: string, 31 | * message: string, 32 | * rule: string 33 | * }>} 34 | */ 35 | public function spellCheck(string $text, array $languages, array $options): array 36 | { 37 | $options['text'] = $text; 38 | $options['language'] = array_shift($languages); 39 | 40 | if (!empty($languages)) { 41 | $options['altLanguages'] = implode(',', $languages); 42 | } 43 | 44 | /** @var array{matches: array, sentence: string, message: string, rule: string}>} */ 45 | return $this->requestAPI( 46 | '/v2/check', 47 | 'POST', 48 | 'Content-type: application/x-www-form-urlencoded; Accept: application/json', 49 | $options 50 | ); 51 | } 52 | 53 | /** 54 | * @return array 55 | */ 56 | public function getSupportedLanguages(): array 57 | { 58 | /** @var array */ 59 | return array_values(array_unique(array_column( 60 | $this->requestAPI( 61 | '/v2/languages', 62 | 'GET', 63 | 'Accept: application/json' 64 | ), 65 | 'longCode' 66 | ))); 67 | } 68 | 69 | /** 70 | * @param array $queryParams 71 | * 72 | * @throws \RuntimeException 73 | * 74 | * @return array 75 | */ 76 | public function requestAPI(string $endpoint, string $method, string $header, array $queryParams = []): array 77 | { 78 | $httpData = [ 79 | 'method' => $method, 80 | 'header' => $header, 81 | ]; 82 | 83 | if (!empty($queryParams)) { 84 | $httpData['content'] = http_build_query($queryParams); 85 | } 86 | 87 | $content = \PhpSpellcheck\file_get_contents($this->baseUrl . $endpoint, false, stream_context_create(['http' => $httpData])); 88 | /** @var array $contentAsArray */ 89 | $contentAsArray = \PhpSpellcheck\json_decode($content, true); 90 | 91 | return $contentAsArray; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Spellchecker/MultiSpellchecker.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private $spellCheckers; 16 | 17 | /** 18 | * @var bool 19 | */ 20 | private $mergeMisspellingsSuggestions; 21 | 22 | /** 23 | * @param SpellcheckerInterface[] $spellCheckers 24 | */ 25 | public function __construct(iterable $spellCheckers, bool $mergeMisspellingsSuggestions = true) 26 | { 27 | Assert::allIsInstanceOf($spellCheckers, SpellcheckerInterface::class); 28 | $this->spellCheckers = $spellCheckers; 29 | $this->mergeMisspellingsSuggestions = $mergeMisspellingsSuggestions; 30 | } 31 | 32 | public function check(string $text, array $languages, array $context): iterable 33 | { 34 | if (!$this->mergeMisspellingsSuggestions) { 35 | yield from $this->checkForAllSpellcheckers($text, $languages, $context); 36 | 37 | return; 38 | } 39 | 40 | /** @var MisspellingInterface[] $misspellings */ 41 | $misspellings = []; 42 | /** @var SpellcheckerInterface $spellChecker */ 43 | foreach ($this->spellCheckers as $spellChecker) { 44 | $supportedLanguages = \is_array($spellChecker->getSupportedLanguages()) ? 45 | $spellChecker->getSupportedLanguages() : 46 | iterator_to_array($spellChecker->getSupportedLanguages()); 47 | 48 | $spellcheckerSupportedLanguages = array_intersect($supportedLanguages, $languages); 49 | 50 | if ($spellcheckerSupportedLanguages === []) { 51 | continue; 52 | } 53 | 54 | foreach ($spellChecker->check($text, $spellcheckerSupportedLanguages, $context) as $misspelling) { 55 | if (!empty($context)) { 56 | $misspelling = $misspelling->mergeContext($context); 57 | } 58 | 59 | if (!$misspelling->canDeterminateUniqueIdentity()) { 60 | $misspellings[] = $misspelling; 61 | 62 | continue; 63 | } 64 | 65 | if (isset($misspellings[$misspelling->getUniqueIdentity()])) { 66 | $misspellings[$misspelling->getUniqueIdentity()]->mergeSuggestions($misspelling->getSuggestions()); 67 | 68 | continue; 69 | } 70 | 71 | $misspellings[$misspelling->getUniqueIdentity()] = $misspelling; 72 | } 73 | } 74 | 75 | yield from array_values($misspellings); 76 | } 77 | 78 | public function getSupportedLanguages(): iterable 79 | { 80 | $supportedLanguages = []; 81 | foreach ($this->spellCheckers as $spellChecker) { 82 | foreach ($spellChecker->getSupportedLanguages() as $language) { 83 | $supportedLanguages[] = $language; 84 | } 85 | } 86 | 87 | return array_values(array_unique($supportedLanguages)); 88 | } 89 | 90 | /** 91 | * @param array $context 92 | * @param array $languages 93 | * 94 | * @return iterable 95 | */ 96 | private function checkForAllSpellcheckers( 97 | string $text, 98 | array $languages, 99 | array $context 100 | ): iterable { 101 | foreach ($this->spellCheckers as $spellChecker) { 102 | $supportedLanguages = \is_array($spellChecker->getSupportedLanguages()) ? 103 | $spellChecker->getSupportedLanguages() : 104 | iterator_to_array($spellChecker->getSupportedLanguages()); 105 | 106 | $spellcheckerSupportedLanguages = array_intersect($supportedLanguages, $languages); 107 | 108 | if ($spellcheckerSupportedLanguages === []) { 109 | continue; 110 | } 111 | 112 | foreach ($spellChecker->check($text, $spellcheckerSupportedLanguages, $context) as $misspelling) { 113 | if (!empty($context)) { 114 | $misspelling = $misspelling->mergeContext($context); 115 | } 116 | 117 | yield $misspelling; 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Spellchecker/PHPPspell.php: -------------------------------------------------------------------------------- 1 | mode = $mode; 52 | $this->numberOfCharactersLowerLimit = $numberOfCharactersLowerLimit; 53 | $this->aspell = $aspell ?? Aspell::create(); 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function check( 60 | string $text, 61 | array $languages, 62 | array $context 63 | ): iterable { 64 | Assert::count($languages, 1, 'PHPPspell spellchecker doesn\'t support multi-language check'); 65 | 66 | $chosenLanguage = current($languages); 67 | $pspellConfig = pspell_config_create($chosenLanguage); 68 | pspell_config_mode($pspellConfig, $this->mode); 69 | pspell_config_ignore($pspellConfig, $this->numberOfCharactersLowerLimit); 70 | $dictionary = \PhpSpellcheck\pspell_new_config($pspellConfig); 71 | 72 | $lines = explode(PHP_EOL, $text); 73 | 74 | /** @var string $line */ 75 | foreach ($lines as $lineNumber => $line) { 76 | $words = explode(' ', \PhpSpellcheck\preg_replace("/(?!['’-])(\p{P}|\+|--)/u", '', $line)); 77 | foreach ($words as $word) { 78 | if (!pspell_check($dictionary, $word)) { 79 | $suggestions = pspell_suggest($dictionary, $word); 80 | Assert::isArray( 81 | $suggestions, 82 | \sprintf('pspell_suggest method failed with language "%s" and word "%s"', $chosenLanguage, $word) 83 | ); 84 | 85 | yield new Misspelling($word, 0, $lineNumber + 1, $suggestions, $context); 86 | } 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function getSupportedLanguages(): iterable 95 | { 96 | return $this->aspell->getSupportedLanguages(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Spellchecker/SpellcheckerInterface.php: -------------------------------------------------------------------------------- 1 | $context 13 | * @param array $languages 14 | * 15 | * @return iterable 16 | */ 17 | public function check( 18 | string $text, 19 | array $languages, 20 | array $context 21 | ): iterable; 22 | 23 | /** 24 | * @return iterable 25 | */ 26 | public function getSupportedLanguages(): iterable; 27 | } 28 | -------------------------------------------------------------------------------- /src/Text.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private $context; 13 | 14 | /** 15 | * @var string 16 | */ 17 | private $string; 18 | 19 | /** 20 | * @param array $context 21 | */ 22 | public function __construct(string $string, array $context) 23 | { 24 | $this->string = $string; 25 | $this->context = $context; 26 | } 27 | 28 | /** 29 | * @param array $context 30 | */ 31 | public function setContext(array $context): self 32 | { 33 | $this->context = $context; 34 | 35 | return $this; 36 | } 37 | 38 | public function getContent(): string 39 | { 40 | return $this->string; 41 | } 42 | 43 | public function getContext(): array 44 | { 45 | return $this->context; 46 | } 47 | 48 | public function replaceContent(string $newContent): TextInterface 49 | { 50 | return new self($newContent, $this->context); 51 | } 52 | 53 | public function mergeContext(array $context, bool $override = true): TextInterface 54 | { 55 | return new self( 56 | $this->getContent(), 57 | $override ? array_merge($this->getContext(), $context) : array_merge($context, $this->getContext()) 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Text/functions.php: -------------------------------------------------------------------------------- 1 | $context 10 | */ 11 | function t(string $string = '', array $context = []): Text 12 | { 13 | return new Text($string, $context); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/TextInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function getContext(): array; 15 | 16 | public function replaceContent(string $newContent): TextInterface; 17 | 18 | /** 19 | * @param array $context 20 | */ 21 | public function mergeContext(array $context, bool $override): TextInterface; 22 | } 23 | -------------------------------------------------------------------------------- /src/TextProcessor/MarkdownRemover.php: -------------------------------------------------------------------------------- 1 | getContent()); 21 | 22 | // Github Flavored Markdown 23 | // Header 24 | $output = \PhpSpellcheck\preg_replace('/\n={2,}/', '\n', $output); 25 | /** 26 | * Fenced codeblocks. 27 | * 28 | *@TODO parse programming language comments from codeblock instead of removing whole block 29 | */ 30 | $output = \PhpSpellcheck\preg_replace('/~{3}.*\n/', '', $output); 31 | // Strikethrough 32 | $output = \PhpSpellcheck\preg_replace('/~~/', '', $output); 33 | // Common Markdown 34 | // Remove HTML tags 35 | $output = \PhpSpellcheck\preg_replace('/<[^>]*>/', '', $output); 36 | // Remove setext-style headers 37 | $output = \PhpSpellcheck\preg_replace('/^[=\-]{2,}\s*$/', '', $output); 38 | // Remove footnotes? 39 | $output = \PhpSpellcheck\preg_replace('/\[\^.+?\](\: .*?$)?/', '', $output); 40 | $output = \PhpSpellcheck\preg_replace('/\s{0,2}\[.*?\]: .*?$/', '', $output); 41 | // Remove images 42 | $output = \PhpSpellcheck\preg_replace('/\!\[(.*?)\][\[\(].*?[\]\)]/', '$1', $output); 43 | // Remove inline links 44 | $output = \PhpSpellcheck\preg_replace('/\[(.*?)\][\[\(].*?[\]\)]/', '$1', $output); 45 | // Remove blockquotes 46 | $output = \PhpSpellcheck\preg_replace('/^\s{0,3}>\s?/', '', $output); 47 | // Remove reference-style links? 48 | 49 | $output = \PhpSpellcheck\preg_replace('/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/', '', $output); 50 | // Remove ## Heading 51 | $output = \PhpSpellcheck\preg_replace('/^#{1,6}\s+(.*?)(?:\s+#{1,6})?$/m', '$1', $output); 52 | // Remove all layers of emphasis 53 | while (\PhpSpellcheck\preg_match('/([\*_]{1,3})(\S.*?\S{0,1})\1/', $output)) { 54 | $output = \PhpSpellcheck\preg_replace('/([\*_]{1,3})(\S.*?\S{0,1})\1/', '$2', $output); 55 | } 56 | 57 | // Remove list items 58 | $output = \PhpSpellcheck\preg_replace('/^([^\S\r\n]*)\*\s/m', '$1', $output); 59 | // Remove code blocks 60 | $output = \PhpSpellcheck\preg_replace('/^`{3,}(.*)*$/m', '', $output); 61 | // Remove inline code 62 | $output = \PhpSpellcheck\preg_replace('/`(.+?)`/', '$1', $output); 63 | 64 | return $text->replaceContent($output); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/TextProcessor/TextProcessorInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private $commandArgs; 15 | 16 | /** 17 | * @param array|string $command 18 | */ 19 | public function __construct($command) 20 | { 21 | if (\is_array($command)) { 22 | $this->commandArgs = $command; 23 | } elseif (\is_string($command)) { 24 | $this->commandArgs = [$command]; 25 | } else { 26 | throw new InvalidArgumentException( 27 | \sprintf( 28 | 'Command should be an "array" or a "string", "%s" given', 29 | \is_object($command) ? \get_class($command) : \gettype($command) 30 | ) 31 | ); 32 | } 33 | } 34 | 35 | public function addArg(string $arg): self 36 | { 37 | $args = $this->commandArgs; 38 | $args[] = $arg; 39 | 40 | return new self($args); 41 | } 42 | 43 | /** 44 | * @param iterable $argsToAdd 45 | */ 46 | public function addArgs(iterable $argsToAdd): self 47 | { 48 | $args = $this->commandArgs; 49 | 50 | foreach ($argsToAdd as $arg) { 51 | $args[] = $arg; 52 | } 53 | 54 | return new self($args); 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getArgs(): array 61 | { 62 | return $this->commandArgs; 63 | } 64 | 65 | public function asString(): string 66 | { 67 | return implode(' ', array_map([$this, 'escapeArgument'], $this->commandArgs)); 68 | } 69 | 70 | /** 71 | * Escapes a string to be used as a shell argument. 72 | */ 73 | private function escapeArgument(string $argument): string 74 | { 75 | if ('\\' !== \DIRECTORY_SEPARATOR) { 76 | return "'" . str_replace("'", "'\\''", $argument) . "'"; 77 | } 78 | if ('' === $argument) { 79 | return '""'; 80 | } 81 | if (false !== strpos($argument, "\0")) { 82 | $argument = str_replace("\0", '?', $argument); 83 | } 84 | if (\PhpSpellcheck\preg_match('/[\/()%!^"<>&|\s]/', $argument) !== 0) { 85 | return $argument; 86 | } 87 | $argument = \PhpSpellcheck\preg_replace('/(\\\\+)$/', '$1$1', $argument); 88 | 89 | return '"' . str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument) . '"'; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Utils/IspellParser.php: -------------------------------------------------------------------------------- 1 | $context 14 | * 15 | * @return iterable 16 | */ 17 | public static function parseMisspellingsFromOutput(string $output, array $context = []): iterable 18 | { 19 | $lines = explode(PHP_EOL, $output); 20 | $lineNumber = 1; 21 | foreach ($lines as $line) { 22 | $line = trim($line); 23 | if ('' === $line) { 24 | ++$lineNumber; 25 | 26 | // Go to the next line 27 | continue; 28 | } 29 | 30 | switch ($line[0]) { 31 | case '#': 32 | [, $word, $offset] = explode(' ', $line); 33 | yield new Misspelling( 34 | $word, 35 | /** 36 | * a `^` character is added to each line while sending text as input so it needs to 37 | * account for that. {@see IspellParser::adaptInputForTerseModeProcessing}. 38 | */ 39 | (int) trim($offset) - 1, 40 | $lineNumber, 41 | [], 42 | $context 43 | ); 44 | 45 | break; 46 | case '&': 47 | $parts = explode(':', $line); 48 | [, $word, , $offset] = explode(' ', $parts[0]); 49 | yield new Misspelling( 50 | $word, 51 | /** 52 | * a `^` character is added to each line while sending text as input so it needs to 53 | * account for that. {@see IspellParser::adaptInputForTerseModeProcessing}. 54 | */ 55 | (int) trim($offset) - 1, 56 | $lineNumber, 57 | explode(', ', trim($parts[1])), 58 | $context 59 | ); 60 | 61 | break; 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * Preprocess the source text so that aspell/ispell pipe mode instruction is ignored. 68 | * 69 | * In pipe mode some special characters at the beginning of the line are instructions for aspell/ispell 70 | * {@link http://aspell.net/man-html/Through-A-Pipe.html#Through-A-Pipe}. 71 | * 72 | * Spellchecker must not interpret them, so it escapes them using the ^ symbol. 73 | */ 74 | public static function adaptInputForTerseModeProcessing(string $input): string 75 | { 76 | return \PhpSpellcheck\preg_replace('/^/m', '^', $input); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Utils/LineAndOffset.php: -------------------------------------------------------------------------------- 1 | Line number as the first element and offset from beginning of line as second element 20 | */ 21 | public static function findFromFirstCharacterOffset(string $text, int $offsetFromFirstCharacter): array 22 | { 23 | // positive offset 24 | Assert::greaterThanEq($offsetFromFirstCharacter, 0, \sprintf('Offset must be a positive integer, "%s" given', $offsetFromFirstCharacter)); 25 | 26 | $textLength = mb_strlen($text); 27 | if ($textLength < $offsetFromFirstCharacter) { 28 | throw new InvalidArgumentException( 29 | \sprintf('Offset given "%d" is higher than the string length "%d"', $offsetFromFirstCharacter, $textLength) 30 | ); 31 | } 32 | 33 | $textBeforeOffset = mb_substr($text, 0, $offsetFromFirstCharacter); 34 | $line = (\PhpSpellcheck\preg_match_all('/\R/u', $textBeforeOffset, $matches)) + 1; 35 | $offsetOfPreviousLinebreak = mb_strrpos($textBeforeOffset, PHP_EOL, 0); 36 | 37 | $offset = $offsetFromFirstCharacter - ($offsetOfPreviousLinebreak !== false ? $offsetOfPreviousLinebreak + 1 : 0); 38 | 39 | return [$line, $offset]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Utils/ProcessRunner.php: -------------------------------------------------------------------------------- 1 | $env 16 | */ 17 | public static function run(Process $process, float|int|null $timeout = null, ?callable $callback = null, array $env = []): Process 18 | { 19 | $process->setTimeout($timeout); 20 | 21 | try { 22 | $process->mustRun($callback, $env); 23 | } catch (ExceptionInterface $e) { 24 | throw new ProcessFailedException($process, $e); 25 | } 26 | 27 | return $process; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Utils/php-functions.php: -------------------------------------------------------------------------------- 1 | > 23 | * : (TFlags is 2 24 | * ? list> 25 | * : (TFlags is 256|257 26 | * ? array> 27 | * : (TFlags is 258 28 | * ? list> 29 | * : (TFlags is 512|513 30 | * ? array> 31 | * : (TFlags is 514 32 | * ? list> 33 | * : (TFlags is 770 34 | * ? list> 35 | * : (TFlags is 0 ? array> : array) 36 | * ) 37 | * ) 38 | * ) 39 | * ) 40 | * ) 41 | * ) 42 | * ) $matches 43 | */ 44 | function preg_match_all(string $pattern, string $subject, &$matches = [], int $flags = 1, int $offset = 0): int 45 | { 46 | error_clear_last(); 47 | $safeResult = \preg_match_all($pattern, $subject, $matches, $flags, $offset); 48 | if ($safeResult === false) { 49 | throw PcreException::createFromPhpError(); 50 | } 51 | 52 | return $safeResult; 53 | } 54 | 55 | /** 56 | * @param string|string[] $pattern 57 | * @param array|string $replacement 58 | * @param array|string $subject 59 | * 60 | * @param-out 0|positive-int $count 61 | * 62 | * @return ($subject is array ? list : string) 63 | */ 64 | function preg_replace(array|string $pattern, array|string $replacement, array|string $subject, int $limit = -1, ?int &$count = null): array|string 65 | { 66 | error_clear_last(); 67 | $result = \preg_replace($pattern, $replacement, $subject, $limit, $count); 68 | if (preg_last_error() !== PREG_NO_ERROR || $result === null) { 69 | throw PcreException::createFromPhpError(); 70 | } 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * @template TFlags as int-mask<0, 256, 512> 77 | * 78 | * @param mixed $matches 79 | * @param TFlags $flags 80 | * 81 | * @param-out ( 82 | * TFlags is 256 83 | * ? array 84 | * : (TFlags is 512 85 | * ? array 86 | * : (TFlags is 768 87 | * ? array 88 | * : array 89 | * ) 90 | * ) 91 | * ) $matches 92 | * 93 | * @return 0|1 94 | */ 95 | function preg_match(string $pattern, string $subject, &$matches = [], int $flags = 0, int $offset = 0) 96 | { 97 | error_clear_last(); 98 | $safeResult = \preg_match($pattern, $subject, $matches, $flags, $offset); 99 | if ($safeResult === false) { 100 | throw PcreException::createFromPhpError(); 101 | } 102 | 103 | return $safeResult; 104 | } 105 | 106 | /** 107 | * @param ?resource $context 108 | * @param int<0, max>|null $length 109 | */ 110 | function file_get_contents(string $filename, bool $use_include_path = false, $context = null, int $offset = 0, ?int $length = null): string 111 | { 112 | error_clear_last(); 113 | if ($length !== null) { 114 | $safeResult = \file_get_contents($filename, $use_include_path, $context, $offset, $length); 115 | } elseif ($offset !== 0) { 116 | $safeResult = \file_get_contents($filename, $use_include_path, $context, $offset); 117 | } elseif ($context !== null) { 118 | $safeResult = \file_get_contents($filename, $use_include_path, $context); 119 | } else { 120 | $safeResult = \file_get_contents($filename, $use_include_path); 121 | } 122 | if ($safeResult === false) { 123 | throw FilesystemException::createFromPhpError(); 124 | } 125 | 126 | return $safeResult; 127 | } 128 | 129 | /** 130 | * @param ?resource $context 131 | */ 132 | function file_put_contents(string $filename, mixed $data, int $flags = 0, $context = null): int 133 | { 134 | error_clear_last(); 135 | if ($context !== null) { 136 | $safeResult = \file_put_contents($filename, $data, $flags, $context); 137 | } else { 138 | $safeResult = \file_put_contents($filename, $data, $flags); 139 | } 140 | if ($safeResult === false) { 141 | throw FilesystemException::createFromPhpError(); 142 | } 143 | 144 | return $safeResult; 145 | } 146 | 147 | function realpath(string $path): string 148 | { 149 | error_clear_last(); 150 | $safeResult = \realpath($path); 151 | if ($safeResult === false) { 152 | throw FilesystemException::createFromPhpError(); 153 | } 154 | 155 | return $safeResult; 156 | } 157 | 158 | /** 159 | * @param int<1, max> $flags 160 | */ 161 | function json_encode(mixed $value, int $flags = 0, int $depth = 512): string 162 | { 163 | error_clear_last(); 164 | $safeResult = \json_encode($value, $flags, $depth); 165 | if ($safeResult === false) { 166 | throw JsonException::createFromPhpError(); 167 | } 168 | 169 | return $safeResult; 170 | } 171 | 172 | function json_decode(string $json, bool $assoc = false, int $depth = 512, int $flags = 0): mixed 173 | { 174 | $data = \json_decode($json, $assoc, $depth, $flags); 175 | if (JSON_ERROR_NONE !== json_last_error()) { 176 | throw JsonException::createFromPhpError(); 177 | } 178 | 179 | return $data; 180 | } 181 | 182 | function pspell_new_config(Config $config): Dictionary 183 | { 184 | error_clear_last(); 185 | $result = \pspell_new_config($config); 186 | if ($result === false) { 187 | throw PspellException::createFromPhpError(); 188 | } 189 | 190 | return $result; 191 | } 192 | --------------------------------------------------------------------------------