| Current Path : /home/users/unlimited/www/whatsjet-saas/Source/vendor/gettext/gettext/src/Loader/ |
| Current File : /home/users/unlimited/www/whatsjet-saas/Source/vendor/gettext/gettext/src/Loader/StrictPoLoader.php |
<?php
declare(strict_types = 1);
namespace Gettext\Loader;
use Exception;
use Gettext\Translation;
use Gettext\Translations;
/**
* Class to load a PO file following the same rules of the GNU gettext tools.
*/
final class StrictPoLoader extends Loader
{
/** @var bool */
public $throwOnWarning = false;
/** @var bool */
public $displayErrorLine = false;
/** @var Translations */
private $translations;
/** @var Translation */
private $translation;
/** @var Translation|null */
private $header;
/** @var string */
private $data;
/** @var int */
private $position;
/** @var int|null */
private $pluralCount;
/** @var bool */
private $inPreviousPart;
/** @var string[] */
private $warnings = [];
/** @var bool */
private $isDisabled;
/** @var bool */
private $displayLineColumn;
/**
* Generates a Translations object from a .po based string
*/
public function loadString(string $data, ?Translations $translations = null): Translations
{
$this->data = $data;
$this->position = 0;
$this->translations = parent::loadString($this->data, $translations);
$this->header = $this->translations->find(null, '');
$this->pluralCount = $this->translations->getHeaders()->getPluralForm()[0] ?? null;
$this->warnings = [];
for ($length = strlen($this->data); $this->newEntry(); $this->saveEntry()) {
for ($hasComment = false; $this->readComment(); $hasComment = true);
$this->readWhitespace();
// End of data
if ($this->position >= $length) {
if ($hasComment) {
$this->addWarning("Comment ignored at the end of the string{$this->getErrorPosition()}");
}
break;
}
$this->readContext();
$this->readOriginal();
if ($this->translations->has($this->translation)) {
throw new Exception("Duplicated entry{$this->getErrorPosition()}");
}
if (!$this->readPlural()) {
$this->readTranslation();
continue;
}
for ($count = 0; $this->readPluralTranslation(!$count); ++$count);
$count !== ($this->pluralCount ?? $count) && $this->addWarning("The translation has {$count} plural "
. "forms, while the header expects {$this->pluralCount}{$this->getErrorPosition()}");
}
if (!$this->header) {
$this->addWarning("The loaded string has no header translation{$this->getErrorPosition()}");
}
return $this->translations;
}
/**
* Retrieves the collected warnings
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* Prepares to parse a new translation
*/
private function newEntry(): Translation
{
$this->isDisabled = false;
return $this->translation = $this->createTranslation(null, '');
}
/**
* Adds the current translation to the output list
*/
private function saveEntry(): void
{
if ($this->isHeader()) {
$this->processHeader();
return;
}
$this->translations->add($this->translation);
}
/**
* Attempts to read whitespace characters, also might skip complex comment prologs when needed
* @return int The position before comments started being consumed
*/
private function readWhitespace(): int
{
do {
$this->position += strspn($this->data, " \n\r\t\v\0", $this->position);
$checkpoint ?? $checkpoint = $this->position;
} while (($this->isDisabled && $this->readString('#~')) || ($this->inPreviousPart && $this->readString('#|')));
return $checkpoint;
}
/**
* Attempts to read the exact informed string
*/
private function readString(string $data): bool
{
return !substr_compare($this->data, $data, $this->position, $l = strlen($data)) && $this->position += $l;
}
/**
* Attempts to read the exact informed char
*/
private function readChar(string $char): bool
{
return ($this->data[$this->position] ?? null) === $char && ++$this->position;
}
/**
* Read sequential characters that match the given character set until the length range is satisfied
*/
private function readCharset(string $charset, int $min, int $max, string $name): string
{
if (($length = strspn($this->data, $charset, $this->position, $max)) < $min) {
throw new Exception("Expected at least {$min} occurrence of {$name} characters{$this->getErrorPosition()}");
}
return substr($this->data, ($this->position += $length) - $length, $length);
}
/**
* Attempts to read a standard comment string which ends with a newline
*/
private function readCommentString(): string
{
$length = strcspn($this->data, "\n\r", $this->position);
return substr($this->data, ($this->position += $length) - $length, $length);
}
/**
* Attempts to read a quoted string while parsing escape sequences prefixed by \
*/
private function readQuotedString(?string $context = null): string
{
$this->readWhitespace();
for ($data = '', $isNewPart = true, $checkpoint = null;;) {
if ($isNewPart && !$this->readChar('"')) {
// The data is over (e.g. beginning of an identifier) or perhaps there's an error
// Restore the checkpoint and let the next parser handle it
if ($checkpoint !== null) {
$this->position = $checkpoint;
break;
}
throw new Exception("Expected an opening quote{$this->getErrorPosition()}");
}
$isNewPart = false;
// Collects chars until an edge case is found
$length = strcspn($this->data, "\"\r\n\\", $this->position);
$data .= substr($this->data, $this->position, $length);
$this->position += $length;
// Check edge cases
switch ($this->data[$this->position++] ?? null) {
// End of part, saves a checkpoint and attempts to read a new part
case '"':
$checkpoint = $this->readWhitespace();
$isNewPart = true;
break;
case '\\':
$data .= $this->readEscape();
break;
// Unexpected newline
case "\r":
case "\n":
throw new Exception("Newline character must be escaped{$this->getErrorPosition()}");
// Unexpected end of file
case null:
throw new Exception("Expected a closing quote{$this->getErrorPosition()}");
}
}
if ($context && strlen($data) && strpbrk($data[0] . $data[strlen($data) - 1], "\r\n") && !$this->isHeader()) {
$this->addWarning("$context cannot start nor end with a newline{$this->getErrorPosition()}");
}
return $data;
}
/**
* Reads escaped data
*/
private function readEscape(): string
{
$aliasMap = ['from' => 'efnrtv"ab\\', 'to' => "\e\f\n\r\t\v\"\x07\x08\\"];
$hexDigits = '0123456789abcdefABCDEF';
switch ($char = $this->data[$this->position++] ?? "\0") {
case strpbrk($char, $aliasMap['from']) ?: '':
return $aliasMap['to'][strpos($aliasMap['from'], $char)];
case strpbrk($char, $octalDigits = '01234567'):
// GNU gettext fails with an octal above the signed char range
if (($decimal = octdec($char . $this->readCharset($octalDigits, 0, 2, 'octal'))) > 127) {
throw new Exception("Octal value out of range [0, 0177]{$this->getErrorPosition()}");
}
return chr($decimal);
case 'x':
$value = $this->readCharset($hexDigits, 1, PHP_INT_MAX, 'hexadecimal');
// GNU reads all valid hexadecimal chars, but only uses the last pair
return hex2bin(str_pad(substr($value, -2), 2, '0', STR_PAD_LEFT));
case 'U':
case 'u':
// The GNU gettext is supposed to follow the escaping sequences of C
// Curiously it doesn't support the unicode escape
$value = $this->readCharset($hexDigits, 1, $digits = $char === 'u' ? 4 : 8, 'hexadecimal');
$value = str_pad($value, $digits, '0', STR_PAD_LEFT);
return mb_convert_encoding(hex2bin($value), 'UTF-8', 'UTF-' . ($digits * 4));
}
throw new Exception("Invalid escaped character{$this->getErrorPosition()}");
}
/**
* Attempts to read and interpret a comment
*/
private function readComment(): bool
{
$this->readWhitespace();
if (!$this->readChar('#')) {
return false;
}
$type = strpbrk($this->data[$this->position] ?? '', '~|,:.') ?: '';
$this->position += strlen($type);
// Only a single space might be optionally added
$this->readChar(' ');
switch ($type) {
case '':
$data = $this->readCommentString();
$this->translation->getComments()->add($data);
break;
case '~':
if ($this->translation->getPreviousOriginal() !== null) {
throw new Exception("Inconsistent use of #~{$this->getErrorPosition()}");
}
$this->translation->disable();
$this->isDisabled = true;
break;
case '|':
if ($this->translation->getPreviousOriginal() !== null) {
throw new Exception('Cannot redeclare the previous comment #|, '
. "ensure the definitions are in the right order{$this->getErrorPosition()}");
}
$this->inPreviousPart = true;
$this->translation->setPreviousContext($this->readIdentifier('msgctxt'));
$this->translation->setPreviousOriginal($this->readIdentifier('msgid', true));
$this->translation->setPreviousPlural($this->readIdentifier('msgid_plural'));
$this->inPreviousPart = false;
break;
case ',':
$data = $this->readCommentString();
foreach (array_map('trim', explode(',', trim($data))) as $value) {
$this->translation->getFlags()->add($value);
}
break;
case ':':
$data = $this->readCommentString();
foreach (preg_split('/\s+/', trim($data)) as $value) {
if (preg_match('/^(.+)(:(\d*))?$/U', $value, $matches)) {
$line = isset($matches[3]) ? intval($matches[3]) : null;
$this->translation->getReferences()->add($matches[1], $line);
}
}
break;
case '.':
$data = $this->readCommentString();
$this->translation->getExtractedComments()->add($data);
break;
}
return true;
}
/**
* Attempts to read an identifier
*/
private function readIdentifier(string $identifier, bool $throwIfNotFound = false): ?string
{
$checkpoint = $this->readWhitespace();
if ($this->readString($identifier)) {
return $this->readQuotedString($identifier);
}
if ($throwIfNotFound) {
throw new Exception("Expected $identifier{$this->getErrorPosition()}");
}
$this->position = $checkpoint;
return null;
}
/**
* Attempts to read the context
*/
private function readContext(): bool
{
return ($data = $this->readIdentifier('msgctxt')) !== null
&& ($this->translation = $this->translation->withContext($data));
}
/**
* Reads the original message
*/
private function readOriginal(): void
{
$this->translation = $this->translation->withOriginal($this->readIdentifier('msgid', true));
}
/**
* Attempts to read the plural message
*/
private function readPlural(): bool
{
return ($data = $this->readIdentifier('msgid_plural')) !== null && $this->translation->setPlural($data);
}
/**
* Reads the translation
*/
private function readTranslation(): void
{
$this->readWhitespace();
if (!$this->readString('msgstr')) {
throw new Exception("Expected msgstr{$this->getErrorPosition()}");
}
$this->translation->translate($this->readQuotedString('msgstr'));
}
/**
* Attempts to read the pluralized translation
*/
private function readPluralTranslation(bool $throwIfNotFound = false): bool
{
$this->readWhitespace();
if (!$this->readString('msgstr')) {
if ($throwIfNotFound) {
throw new Exception("Expected indexed msgstr{$this->getErrorPosition()}");
}
return false;
}
$this->readWhitespace();
if (!$this->readChar('[')) {
throw new Exception("Expected character \"[\"{$this->getErrorPosition()}");
}
$this->readWhitespace();
$index = (int) $this->readCharset('0123456789', 1, PHP_INT_MAX, 'numeric');
$this->readWhitespace();
if (!$this->readChar(']')) {
throw new Exception("Expected character \"]\"{$this->getErrorPosition()}");
}
$translations = $this->translation->getPluralTranslations();
if (($translation = $this->translation->getTranslation()) !== null) {
array_unshift($translations, $translation);
}
if (count($translations) !== (int) $index) {
throw new Exception("The msgstr has an invalid index{$this->getErrorPosition()}");
}
$data = $this->readQuotedString('msgstr');
$translations[] = $data;
$this->translation->translate(array_shift($translations));
$this->translation->translatePlural(...$translations);
return true;
}
/**
* Setup the current translation as the header translation
*/
private function processHeader(): void
{
$this->header = $header = $this->translation;
if (count($description = $header->getComments()->toArray())) {
$this->translations->setDescription(implode("\n", $description));
}
if (count($flags = $header->getFlags()->toArray())) {
$this->translations->getFlags()->add(...$flags);
}
$headers = $this->translations->getHeaders();
if (($header->getTranslation() ?? '') !== '') {
foreach (self::readHeaders($header->getTranslation()) as $name => $value) {
$headers->set($name, $value);
}
}
$this->pluralCount = $headers->getPluralForm()[0] ?? null;
foreach (['Language', 'Plural-Forms', 'Content-Type'] as $header) {
if (($headers->get($header) ?? '') === '') {
$this->addWarning("$header header not declared or empty{$this->getErrorPosition()}");
}
}
}
/**
* Parses the translation header data into an array
*/
private function readHeaders(string $data): array
{
$headers = [];
$name = null;
foreach (explode("\n", $data) as $line) {
// Checks if it is a header definition line.
// Useful for distinguishing between header definitions and possible continuations of a header entry.
if (preg_match('/^[\w-]+:/', $line)) {
[$name, $value] = explode(':', $line, 2);
if (isset($headers[$name])) {
$this->addWarning("Header already defined{$this->getErrorPosition()}");
}
$headers[$name] = trim($value);
continue;
}
// Data without a definition
if ($name === null) {
$this->addWarning("Malformed header name{$this->getErrorPosition()}");
continue;
}
$headers[$name] .= $line;
}
return $headers;
}
/**
* Adds a warning
*/
private function addWarning(string $message): void
{
if ($this->throwOnWarning) {
throw new Exception($message);
}
$this->warnings[] = $message;
}
/**
* Checks whether the current translation is a header translation
*/
private function isHeader(): bool
{
return $this->translation->getOriginal() === '' && $this->translation->getContext() === null;
}
/**
* Retrieves the position where an error was detected
*/
private function getErrorPosition(): string
{
if ($this->displayErrorLine) {
$pieces = preg_split('/\\r\\n|\\n\\r|\\n|\\r/', substr($this->data, 0, $this->position));
$line = count($pieces);
$column = strlen(end($pieces));
return " at line {$line} column {$column}";
}
return " at byte {$this->position}";
}
}