Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Add [Crowdin](https://crowdin.com) as a format for writing and reading extracted messages
- 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`).
- 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.
- Provide `--validate-messages` extraction option to print a list of validation failures and respond with a non-zero exit code on validation failures
- Provide `--add-missing-ids` extraction option to update source code with auto-generated identifiers
- Add `Util\FormatHelper` that provides `getReader()` and `getWriter()` methods
- Introduce `Format\Format` final static class for format constants
Expand Down
1 change: 1 addition & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<file>./src</file>
<file>./tests</file>

<exclude-pattern>*/tests/fixtures/*</exclude-pattern>
<exclude-pattern>*/tests/*/fixtures/*</exclude-pattern>

<rule ref="Ramsey">
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ parameters:
- ./src
- ./tests
excludePaths:
- */tests/fixtures/*
- */tests/*/fixtures/*
ignoreErrors:
- '#Cannot call method getName\(\) on ReflectionType\|null#'
9 changes: 1 addition & 8 deletions src/Console/Command/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,12 @@
*/
abstract class AbstractCommand extends SymfonyConsoleCommand
{
private const LOG_FORMAT_MAPPING = [
LogLevel::WARNING => ConsoleLogger::ERROR,
LogLevel::NOTICE => ConsoleLogger::ERROR,
LogLevel::INFO => ConsoleLogger::ERROR,
LogLevel::DEBUG => ConsoleLogger::ERROR,
];

private const LOG_VERBOSITY_MAPPING = [
LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL,
];

protected function getConsoleLogger(OutputInterface $output): LoggerInterface
{
return new ConsoleLogger($output, self::LOG_VERBOSITY_MAPPING, self::LOG_FORMAT_MAPPING);
return new ConsoleLogger($output, self::LOG_VERBOSITY_MAPPING);
}
}
87 changes: 87 additions & 0 deletions src/Console/Command/ExtractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,31 @@
use FormatPHP\Extractor\IdInterpolator;
use FormatPHP\Extractor\MessageExtractor;
use FormatPHP\Extractor\MessageExtractorOptions;
use FormatPHP\Extractor\Parser\ParserErrorCollection;
use FormatPHP\Icu\MessageFormat\Parser\Exception\InvalidMessageException;
use FormatPHP\Util\FileSystemHelper;
use FormatPHP\Util\FormatHelper;
use FormatPHP\Util\Globber;
use LogicException;
use Ramsey\Collection\Exception\CollectionMismatchException;
use Symfony\Component\Console\Exception\InvalidArgumentException as SymfonyInvalidArgumentException;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

use function array_map;
use function array_merge;
use function array_unique;
use function count;
use function explode;
use function getcwd;
use function ksort;
use function strlen;
use function substr;

use const PHP_EOL;

Expand Down Expand Up @@ -118,6 +127,15 @@ protected function configure(): void
. 'full sentences as possible, since fragmented' . PHP_EOL
. 'sentences are not translator-friendly.',
)
->addOption(
'--validate-messages',
null,
InputOption::VALUE_NONE,
'Whether to validate messages as proper ICU' . PHP_EOL
. 'message syntax. If any messages fail, this' . PHP_EOL
. 'will respond with a non-zero exit code and' . PHP_EOL
. 'print the error messages to stderr.',
)
->addOption(
'--extract-source-location',
null,
Expand Down Expand Up @@ -210,6 +228,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$extractor->process($files);

if ($options->validateMessages && $this->printErrors($extractor->getErrors(), $input, $output)) {
return self::FAILURE;
}

return self::SUCCESS;
}

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

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

return $options;
}

/**
* @throws LogicException
* @throws SymfonyInvalidArgumentException
*/
private function printErrors(ParserErrorCollection $errors, InputInterface $input, OutputInterface $output): bool
Comment thread
ramsey marked this conversation as resolved.
{
$tableErrors = [];
foreach ($errors as $error) {
$message = $error->message;
if ($error->exception instanceof InvalidMessageException) {
$message = 'Syntax Error: '
. $error->exception->getParserError()->getErrorKindName()
. ' in message "' . $error->exception->getParserError()->message . '"';
}

$tableErrors[$error->sourceFile][] = [$error->sourceLine, $message];
}

if (count($tableErrors) === 0) {
return false;
}

if ($output instanceof ConsoleOutputInterface) {
$output = $output->getErrorOutput();
}

$style = new SymfonyStyle($input, $output);
$style->warning('The following errors occurred while extracting ICU formatted messages.');

ksort($tableErrors);
foreach ($tableErrors as $file => $fileErrors) {
$this->renderTable($file, $fileErrors, $output);
}

$style->error('Errors encountered during ICU formatted message extraction.');

return true;
}

/**
* @param non-empty-array<array{int | null, string}> $errors
*
* @throws LogicException
* @throws SymfonyInvalidArgumentException
*/
private function renderTable(string $file, array $errors, OutputInterface $output): void
{
$fileHeader = strlen($file) > 68 ? '...' . substr($file, -65) : $file;

$style = Table::getStyleDefinition('borderless');
$style->setHorizontalBorderChars('-');

$table = new Table($output);
$table->setStyle($style);
$table->setColumnMaxWidth(0, 4);
$table->setColumnMaxWidth(1, 68);
$table->setHeaders(['Line', $fileHeader]);
$table->setRows($errors);

$table->render();

$output->write(PHP_EOL);
}
}
34 changes: 34 additions & 0 deletions src/Extractor/MessageExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@
use FormatPHP\Exception\InvalidArgumentException;
use FormatPHP\Exception\UnableToProcessFileException;
use FormatPHP\Exception\UnableToWriteFileException;
use FormatPHP\ExtendedDescriptorInterface;
use FormatPHP\Extractor\Parser\Descriptor\PhpParser;
use FormatPHP\Extractor\Parser\DescriptorParserInterface;
use FormatPHP\Extractor\Parser\ParserError;
use FormatPHP\Extractor\Parser\ParserErrorCollection;
use FormatPHP\Format\WriterInterface;
use FormatPHP\Format\WriterOptions;
use FormatPHP\Icu\MessageFormat\Manipulator;
use FormatPHP\Icu\MessageFormat\Parser as MessageFormatParser;
use FormatPHP\Icu\MessageFormat\Printer;
use FormatPHP\Icu\MessageFormat\Validator;
use FormatPHP\Util\FileSystemHelper;
use FormatPHP\Util\FormatHelper;
use FormatPHP\Util\Globber;
Expand Down Expand Up @@ -133,6 +136,10 @@ public function process(array $files): void
return;
}

if ($this->options->validateMessages) {
$this->validateDescriptors($descriptors);
}

$this->write($formatter, $descriptors);
}

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

if ($this->options->validateMessages === true && count($this->errors) > 0) {
$this->logger->error('Validation errors encountered; extraction failed');

return;
}

$file = $this->options->outFile ?? 'php://output';

$writerOptions = new WriterOptions();
Expand Down Expand Up @@ -283,4 +296,25 @@ private function flattenMessage(): Closure
return $descriptor;
};
}

private function validateDescriptors(DescriptorCollection $descriptors): void
Comment thread
ramsey marked this conversation as resolved.
{
$validator = new Validator();

foreach ($descriptors as $descriptor) {
try {
$validator->validate((string) $descriptor->getDefaultMessage());
} catch (MessageFormatParser\Exception\InvalidMessageException $exception) {
$sourceFile = '';
$sourceLine = -1;

if ($descriptor instanceof ExtendedDescriptorInterface) {
$sourceFile = $descriptor->getSourceFile() ?? '';
$sourceLine = $descriptor->getSourceLine() ?? -1;
}

$this->errors[] = new ParserError($exception->getMessage(), $sourceFile, $sourceLine, $exception);
}
}
}
}
5 changes: 5 additions & 0 deletions src/Extractor/MessageExtractorOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,9 @@ class MessageExtractorOptions
* Any IDs already present in the source code will remain unchanged.
*/
public bool $addGeneratedIdsToSourceCode = false;

/**
* Whether to validate ICU message syntax during extraction
*/
public bool $validateMessages = false;
}
1 change: 1 addition & 0 deletions src/Icu/MessageFormat/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ private function parseArgumentOptions(
Error::INVALID_NUMBER_SKELETON,
$this->message,
$styleAndLocation['styleLocation'],
$exception,
),
);
}
Expand Down
51 changes: 49 additions & 2 deletions src/Icu/MessageFormat/Parser/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,24 @@
namespace FormatPHP\Icu\MessageFormat\Parser;

use FormatPHP\Icu\MessageFormat\Parser\Type\Location;
use ReflectionObject;
use Throwable;

use function array_flip;

/**
* @psalm-type ErrorKind = Error::*
*/
class Error
{
/**
* An error that does not fit with any of the other constants on this class.
*
* If receiving this kind of error, check {@see getThrowable()} to see if
* there is an associated exception.
*/
public const OTHER = 0;

/**
* Argument is unclosed (e.g. `{0`)
*/
Expand Down Expand Up @@ -168,6 +180,11 @@ class Error
*/
public const UNCLOSED_TAG = 27;

/**
* @var string[]
*/
private static array $constants = [];

/**
* @var ErrorKind
*/
Expand All @@ -176,13 +193,43 @@ class Error
public string $message;
public Location $location;

private ?Throwable $throwable;

/**
* @param ErrorKind $kind
*/
public function __construct(int $kind, string $message, Location $location)
{
public function __construct(
int $kind,
string $message,
Location $location,
?Throwable $throwable = null
) {
$this->kind = $kind;
$this->message = $message;
$this->location = $location;
$this->throwable = $throwable;
}

/**
* May return a Throwable instance if {@see $kind} is {@see OTHER}
*/
public function getThrowable(): ?Throwable
{
return $this->throwable;
}

/**
* Returns the name for the kind of error this represents
*/
public function getErrorKindName(): string
{
if (self::$constants === []) {
$reflection = new ReflectionObject($this);

// @phpstan-ignore-next-line
self::$constants = array_flip($reflection->getConstants());
}

return self::$constants[$this->kind] ?? '';
}
}
49 changes: 49 additions & 0 deletions src/Icu/MessageFormat/Parser/Exception/InvalidMessageException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* This file is part of skillshare/formatphp
*
* skillshare/formatphp is open source software: you can distribute
* it and/or modify it under the terms of the MIT License
* (the "License"). You may not use this file except in
* compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* @copyright Copyright (c) Skillshare, Inc. <https://www.skillshare.com>
* @license https://opensource.org/licenses/MIT MIT License
*/

declare(strict_types=1);

namespace FormatPHP\Icu\MessageFormat\Parser\Exception;

use FormatPHP\Icu\MessageFormat\Parser\Error;
use RuntimeException as PhpRuntimeException;
use Throwable;

/**
* Thrown when ICU message validation fails
*/
class InvalidMessageException extends PhpRuntimeException implements ParserExceptionInterface
{
private Error $error;

public function __construct(Error $error, ?Throwable $previous = null)
{
parent::__construct('Syntax error', 0, $previous);
$this->error = $error;
}

/**
* Returns the specific syntax error that caused validation to fail
*/
public function getParserError(): Error
{
return $this->error;
}
}
Loading