Skip to content

Commit ef04159

Browse files
authored
feat: provide message validation during extraction (#28)
1 parent 260e8a5 commit ef04159

18 files changed

Lines changed: 615 additions & 23 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1212
- Add [Crowdin](https://crowdin.com) as a format for writing and reading extracted messages
1313
- Add `pseudo-locale` console command to allow conversion of a locale to one of the supported pseudo-locales (`en-XA`, `en-XB`, `xx-AC`, `xx-HA`, and `xx-LS`).
1414
- Provide `--flatten` extraction option to tell the extractor to hoist selectors and flatten sentences as much as possible. For example, `I have {count, plural, one{a dog} other{many dogs}}` becomes `{count, plural, one{I have a dog} other{I have many dogs}}`. The goal is to provide as many full sentences as possible, since fragmented sentences are not translator-friendly.
15+
- Provide `--validate-messages` extraction option to print a list of validation failures and respond with a non-zero exit code on validation failures
1516
- Provide `--add-missing-ids` extraction option to update source code with auto-generated identifiers
1617
- Add `Util\FormatHelper` that provides `getReader()` and `getWriter()` methods
1718
- Introduce `Format\Format` final static class for format constants

phpcs.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<file>./src</file>
99
<file>./tests</file>
1010

11+
<exclude-pattern>*/tests/fixtures/*</exclude-pattern>
1112
<exclude-pattern>*/tests/*/fixtures/*</exclude-pattern>
1213

1314
<rule ref="Ramsey">

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ parameters:
55
- ./src
66
- ./tests
77
excludePaths:
8+
- */tests/fixtures/*
89
- */tests/*/fixtures/*
910
ignoreErrors:
1011
- '#Cannot call method getName\(\) on ReflectionType\|null#'

src/Console/Command/AbstractCommand.php

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,12 @@
3333
*/
3434
abstract class AbstractCommand extends SymfonyConsoleCommand
3535
{
36-
private const LOG_FORMAT_MAPPING = [
37-
LogLevel::WARNING => ConsoleLogger::ERROR,
38-
LogLevel::NOTICE => ConsoleLogger::ERROR,
39-
LogLevel::INFO => ConsoleLogger::ERROR,
40-
LogLevel::DEBUG => ConsoleLogger::ERROR,
41-
];
42-
4336
private const LOG_VERBOSITY_MAPPING = [
4437
LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL,
4538
];
4639

4740
protected function getConsoleLogger(OutputInterface $output): LoggerInterface
4841
{
49-
return new ConsoleLogger($output, self::LOG_VERBOSITY_MAPPING, self::LOG_FORMAT_MAPPING);
42+
return new ConsoleLogger($output, self::LOG_VERBOSITY_MAPPING);
5043
}
5144
}

src/Console/Command/ExtractCommand.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,31 @@
2929
use FormatPHP\Extractor\IdInterpolator;
3030
use FormatPHP\Extractor\MessageExtractor;
3131
use FormatPHP\Extractor\MessageExtractorOptions;
32+
use FormatPHP\Extractor\Parser\ParserErrorCollection;
33+
use FormatPHP\Icu\MessageFormat\Parser\Exception\InvalidMessageException;
3234
use FormatPHP\Util\FileSystemHelper;
3335
use FormatPHP\Util\FormatHelper;
3436
use FormatPHP\Util\Globber;
3537
use LogicException;
3638
use Ramsey\Collection\Exception\CollectionMismatchException;
3739
use Symfony\Component\Console\Exception\InvalidArgumentException as SymfonyInvalidArgumentException;
40+
use Symfony\Component\Console\Helper\Table;
3841
use Symfony\Component\Console\Input\InputArgument;
3942
use Symfony\Component\Console\Input\InputInterface;
4043
use Symfony\Component\Console\Input\InputOption;
44+
use Symfony\Component\Console\Output\ConsoleOutputInterface;
4145
use Symfony\Component\Console\Output\OutputInterface;
46+
use Symfony\Component\Console\Style\SymfonyStyle;
4247

4348
use function array_map;
4449
use function array_merge;
4550
use function array_unique;
51+
use function count;
4652
use function explode;
4753
use function getcwd;
54+
use function ksort;
55+
use function strlen;
56+
use function substr;
4857

4958
use const PHP_EOL;
5059

@@ -118,6 +127,15 @@ protected function configure(): void
118127
. 'full sentences as possible, since fragmented' . PHP_EOL
119128
. 'sentences are not translator-friendly.',
120129
)
130+
->addOption(
131+
'--validate-messages',
132+
null,
133+
InputOption::VALUE_NONE,
134+
'Whether to validate messages as proper ICU' . PHP_EOL
135+
. 'message syntax. If any messages fail, this' . PHP_EOL
136+
. 'will respond with a non-zero exit code and' . PHP_EOL
137+
. 'print the error messages to stderr.',
138+
)
121139
->addOption(
122140
'--extract-source-location',
123141
null,
@@ -210,6 +228,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
210228

211229
$extractor->process($files);
212230

231+
if ($options->validateMessages && $this->printErrors($extractor->getErrors(), $input, $output)) {
232+
return self::FAILURE;
233+
}
234+
213235
return self::SUCCESS;
214236
}
215237

@@ -249,6 +271,7 @@ private function buildOptions(InputInterface $input): MessageExtractorOptions
249271
$options->preserveWhitespace = (bool) $input->getOption('preserve-whitespace');
250272
$options->flatten = (bool) $input->getOption('flatten');
251273
$options->addGeneratedIdsToSourceCode = (bool) $input->getOption('add-missing-ids');
274+
$options->validateMessages = (bool) $input->getOption('validate-messages');
252275

253276
/** @var string $inputFunctionNames */
254277
$inputFunctionNames = $input->getOption('addl-func') ?? '';
@@ -257,4 +280,68 @@ private function buildOptions(InputInterface $input): MessageExtractorOptions
257280

258281
return $options;
259282
}
283+
284+
/**
285+
* @throws LogicException
286+
* @throws SymfonyInvalidArgumentException
287+
*/
288+
private function printErrors(ParserErrorCollection $errors, InputInterface $input, OutputInterface $output): bool
289+
{
290+
$tableErrors = [];
291+
foreach ($errors as $error) {
292+
$message = $error->message;
293+
if ($error->exception instanceof InvalidMessageException) {
294+
$message = 'Syntax Error: '
295+
. $error->exception->getParserError()->getErrorKindName()
296+
. ' in message "' . $error->exception->getParserError()->message . '"';
297+
}
298+
299+
$tableErrors[$error->sourceFile][] = [$error->sourceLine, $message];
300+
}
301+
302+
if (count($tableErrors) === 0) {
303+
return false;
304+
}
305+
306+
if ($output instanceof ConsoleOutputInterface) {
307+
$output = $output->getErrorOutput();
308+
}
309+
310+
$style = new SymfonyStyle($input, $output);
311+
$style->warning('The following errors occurred while extracting ICU formatted messages.');
312+
313+
ksort($tableErrors);
314+
foreach ($tableErrors as $file => $fileErrors) {
315+
$this->renderTable($file, $fileErrors, $output);
316+
}
317+
318+
$style->error('Errors encountered during ICU formatted message extraction.');
319+
320+
return true;
321+
}
322+
323+
/**
324+
* @param non-empty-array<array{int | null, string}> $errors
325+
*
326+
* @throws LogicException
327+
* @throws SymfonyInvalidArgumentException
328+
*/
329+
private function renderTable(string $file, array $errors, OutputInterface $output): void
330+
{
331+
$fileHeader = strlen($file) > 68 ? '...' . substr($file, -65) : $file;
332+
333+
$style = Table::getStyleDefinition('borderless');
334+
$style->setHorizontalBorderChars('-');
335+
336+
$table = new Table($output);
337+
$table->setStyle($style);
338+
$table->setColumnMaxWidth(0, 4);
339+
$table->setColumnMaxWidth(1, 68);
340+
$table->setHeaders(['Line', $fileHeader]);
341+
$table->setRows($errors);
342+
343+
$table->render();
344+
345+
$output->write(PHP_EOL);
346+
}
260347
}

src/Extractor/MessageExtractor.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@
3131
use FormatPHP\Exception\InvalidArgumentException;
3232
use FormatPHP\Exception\UnableToProcessFileException;
3333
use FormatPHP\Exception\UnableToWriteFileException;
34+
use FormatPHP\ExtendedDescriptorInterface;
3435
use FormatPHP\Extractor\Parser\Descriptor\PhpParser;
3536
use FormatPHP\Extractor\Parser\DescriptorParserInterface;
37+
use FormatPHP\Extractor\Parser\ParserError;
3638
use FormatPHP\Extractor\Parser\ParserErrorCollection;
3739
use FormatPHP\Format\WriterInterface;
3840
use FormatPHP\Format\WriterOptions;
3941
use FormatPHP\Icu\MessageFormat\Manipulator;
4042
use FormatPHP\Icu\MessageFormat\Parser as MessageFormatParser;
4143
use FormatPHP\Icu\MessageFormat\Printer;
44+
use FormatPHP\Icu\MessageFormat\Validator;
4245
use FormatPHP\Util\FileSystemHelper;
4346
use FormatPHP\Util\FormatHelper;
4447
use FormatPHP\Util\Globber;
@@ -133,6 +136,10 @@ public function process(array $files): void
133136
return;
134137
}
135138

139+
if ($this->options->validateMessages) {
140+
$this->validateDescriptors($descriptors);
141+
}
142+
136143
$this->write($formatter, $descriptors);
137144
}
138145

@@ -225,6 +232,12 @@ private function write(callable $formatter, DescriptorCollection $descriptors):
225232
$descriptors = new DescriptorCollection($flattened);
226233
}
227234

235+
if ($this->options->validateMessages === true && count($this->errors) > 0) {
236+
$this->logger->error('Validation errors encountered; extraction failed');
237+
238+
return;
239+
}
240+
228241
$file = $this->options->outFile ?? 'php://output';
229242

230243
$writerOptions = new WriterOptions();
@@ -283,4 +296,25 @@ private function flattenMessage(): Closure
283296
return $descriptor;
284297
};
285298
}
299+
300+
private function validateDescriptors(DescriptorCollection $descriptors): void
301+
{
302+
$validator = new Validator();
303+
304+
foreach ($descriptors as $descriptor) {
305+
try {
306+
$validator->validate((string) $descriptor->getDefaultMessage());
307+
} catch (MessageFormatParser\Exception\InvalidMessageException $exception) {
308+
$sourceFile = '';
309+
$sourceLine = -1;
310+
311+
if ($descriptor instanceof ExtendedDescriptorInterface) {
312+
$sourceFile = $descriptor->getSourceFile() ?? '';
313+
$sourceLine = $descriptor->getSourceLine() ?? -1;
314+
}
315+
316+
$this->errors[] = new ParserError($exception->getMessage(), $sourceFile, $sourceLine, $exception);
317+
}
318+
}
319+
}
286320
}

src/Extractor/MessageExtractorOptions.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,9 @@ class MessageExtractorOptions
114114
* Any IDs already present in the source code will remain unchanged.
115115
*/
116116
public bool $addGeneratedIdsToSourceCode = false;
117+
118+
/**
119+
* Whether to validate ICU message syntax during extraction
120+
*/
121+
public bool $validateMessages = false;
117122
}

src/Icu/MessageFormat/Parser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ private function parseArgumentOptions(
656656
Error::INVALID_NUMBER_SKELETON,
657657
$this->message,
658658
$styleAndLocation['styleLocation'],
659+
$exception,
659660
),
660661
);
661662
}

src/Icu/MessageFormat/Parser/Error.php

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,24 @@
2323
namespace FormatPHP\Icu\MessageFormat\Parser;
2424

2525
use FormatPHP\Icu\MessageFormat\Parser\Type\Location;
26+
use ReflectionObject;
27+
use Throwable;
28+
29+
use function array_flip;
2630

2731
/**
2832
* @psalm-type ErrorKind = Error::*
2933
*/
3034
class Error
3135
{
36+
/**
37+
* An error that does not fit with any of the other constants on this class.
38+
*
39+
* If receiving this kind of error, check {@see getThrowable()} to see if
40+
* there is an associated exception.
41+
*/
42+
public const OTHER = 0;
43+
3244
/**
3345
* Argument is unclosed (e.g. `{0`)
3446
*/
@@ -168,6 +180,11 @@ class Error
168180
*/
169181
public const UNCLOSED_TAG = 27;
170182

183+
/**
184+
* @var string[]
185+
*/
186+
private static array $constants = [];
187+
171188
/**
172189
* @var ErrorKind
173190
*/
@@ -176,13 +193,43 @@ class Error
176193
public string $message;
177194
public Location $location;
178195

196+
private ?Throwable $throwable;
197+
179198
/**
180199
* @param ErrorKind $kind
181200
*/
182-
public function __construct(int $kind, string $message, Location $location)
183-
{
201+
public function __construct(
202+
int $kind,
203+
string $message,
204+
Location $location,
205+
?Throwable $throwable = null
206+
) {
184207
$this->kind = $kind;
185208
$this->message = $message;
186209
$this->location = $location;
210+
$this->throwable = $throwable;
211+
}
212+
213+
/**
214+
* May return a Throwable instance if {@see $kind} is {@see OTHER}
215+
*/
216+
public function getThrowable(): ?Throwable
217+
{
218+
return $this->throwable;
219+
}
220+
221+
/**
222+
* Returns the name for the kind of error this represents
223+
*/
224+
public function getErrorKindName(): string
225+
{
226+
if (self::$constants === []) {
227+
$reflection = new ReflectionObject($this);
228+
229+
// @phpstan-ignore-next-line
230+
self::$constants = array_flip($reflection->getConstants());
231+
}
232+
233+
return self::$constants[$this->kind] ?? '';
187234
}
188235
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/**
4+
* This file is part of skillshare/formatphp
5+
*
6+
* skillshare/formatphp is open source software: you can distribute
7+
* it and/or modify it under the terms of the MIT License
8+
* (the "License"). You may not use this file except in
9+
* compliance with the License.
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14+
* implied. See the License for the specific language governing
15+
* permissions and limitations under the License.
16+
*
17+
* @copyright Copyright (c) Skillshare, Inc. <https://www.skillshare.com>
18+
* @license https://opensource.org/licenses/MIT MIT License
19+
*/
20+
21+
declare(strict_types=1);
22+
23+
namespace FormatPHP\Icu\MessageFormat\Parser\Exception;
24+
25+
use FormatPHP\Icu\MessageFormat\Parser\Error;
26+
use RuntimeException as PhpRuntimeException;
27+
use Throwable;
28+
29+
/**
30+
* Thrown when ICU message validation fails
31+
*/
32+
class InvalidMessageException extends PhpRuntimeException implements ParserExceptionInterface
33+
{
34+
private Error $error;
35+
36+
public function __construct(Error $error, ?Throwable $previous = null)
37+
{
38+
parent::__construct('Syntax error', 0, $previous);
39+
$this->error = $error;
40+
}
41+
42+
/**
43+
* Returns the specific syntax error that caused validation to fail
44+
*/
45+
public function getParserError(): Error
46+
{
47+
return $this->error;
48+
}
49+
}

0 commit comments

Comments
 (0)