├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
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 |
--------------------------------------------------------------------------------