.+?)\s*$/mi'; private const COMMENT_PREFIX_PATTERN = '/^\s*\/\/\s*/'; private const ALLOWED_EXPRESSION_PATTERN = '/^[0-9n\s!<>=&|?:()%+*\/-]+$/'; /** * @return array{formula:string,nplurals:int,lambda:string} */ public function compileFormula(string $pluralForms): array { ['formula' => $formula, 'nplurals' => $pluralCount, 'expression' => $expression] = $this->parsePluralHeader($pluralForms); $this->validatePluralExpression($expression, $formula); $lambdaExpression = $pluralCount === 2 && !str_contains($expression, '?') ? '((' . $this->transpileLeafExpression($expression) . ') ? 1 : 0)' : $this->transpileExpression($expression); return [ 'formula' => $formula, 'nplurals' => $pluralCount, 'lambda' => 'static fn (int $n): int => ' . $lambdaExpression, ]; } public function compileFormulaToLambda(string $pluralForms): string { return $this->compileFormula($pluralForms)['lambda']; } public function compileFile(string $filePath, bool $throwOnError = true): bool { try { if (!is_file($filePath)) { throw new InvalidArgumentException('Plural file not found: ' . $filePath); } $compiled = $this->compileFormula($this->extractPluralFormsFromFile($filePath)); $newContent = $this->renderCompiledFile($compiled); $currentContent = file_get_contents($filePath); if (!is_string($currentContent)) { throw new RuntimeException('Unable to read plural file: ' . $filePath); } if ($currentContent === $newContent) { return false; } if (file_put_contents($filePath, $newContent) === false) { throw new RuntimeException('Unable to write plural file: ' . $filePath); } } catch (Throwable $e) { if ($throwOnError) { throw $e; } $message = 'Error compiling plural file `' . $filePath . '`: ' . $e->getMessage() . "\n"; if (defined('STDERR')) { fwrite(STDERR, $message); } else { echo $message; } return false; } return true; } public function compileAll(string $globPattern = I18N_PATH . '/*/plurals.php'): int { $files = glob($globPattern) ?: []; sort($files, SORT_NATURAL); $changed = 0; foreach ($files as $filePath) { if ($this->compileFile($filePath, throwOnError: false)) { $changed++; } } return $changed; } private function extractPluralFormsFromFile(string $filePath): string { $fileContent = file_get_contents($filePath); if (!is_string($fileContent)) { throw new RuntimeException('Unable to read plural file: ' . $filePath); } if (preg_match(self::COMMENT_PATTERN, $fileContent, $matches) === 1) { return $this->normalisePluralForms($matches['formula']); } return $this->extractGetTextPluralFormsFromFile($filePath); } /** * @param array{formula:string,nplurals:int,lambda:string} $compiled */ private function renderCompiledFile(array $compiled): string { return << {$compiled['nplurals']}, 'plural' => {$compiled['lambda']}, ); PHP; } /** * @return array{formula:string,nplurals:int,expression:string} */ private function parsePluralHeader(string $pluralForms): array { $formula = $this->normalisePluralForms($pluralForms); if (!preg_match(self::FORMULA_PATTERN, $formula, $matches)) { throw new InvalidArgumentException('Invalid plural formula: ' . $formula); } return [ 'formula' => $formula, 'nplurals' => max(1, (int)$matches[1]), 'expression' => $matches[2], ]; } private function normalisePluralForms(string $pluralForms): string { $pluralForms = trim($pluralForms); $pluralForms = preg_replace(self::COMMENT_PREFIX_PATTERN, '', $pluralForms) ?? $pluralForms; if (preg_match('/^\s*Plural-Forms:\s*(?P.+?)\s*$/i', $pluralForms, $matches) === 1) { $pluralForms = $matches['formula']; } return trim($pluralForms); } private function extractGetTextPluralFormsFromFile(string $filePath): string { $pluralData = include $filePath; $pluralForms = is_array($pluralData) ? ($pluralData['plural-forms'] ?? null) : null; if (!is_string($pluralForms) || $pluralForms === '') { throw new RuntimeException('No plural formula found in `' . $filePath . '`'); } return $this->normalisePluralForms($pluralForms); } /** * Lightweight validation only. The compiler transpiles real shipped formulas heuristically. */ private function validatePluralExpression(string $expression, string $pluralForms): void { if (!preg_match(self::ALLOWED_EXPRESSION_PATTERN, $expression)) { throw new RuntimeException('Unsupported token in plural expression `' . $pluralForms . '`'); } $depth = 0; $length = strlen($expression); for ($index = 0; $index < $length; $index++) { $character = $expression[$index]; if ($character === '(') { $depth++; } elseif ($character === ')') { $depth--; if ($depth < 0) { throw new RuntimeException('Unbalanced parentheses in plural expression `' . $pluralForms . '`'); } } } if ($depth !== 0) { throw new RuntimeException('Unbalanced parentheses in plural expression `' . $pluralForms . '`'); } if (substr_count($expression, '?') !== substr_count($expression, ':')) { throw new RuntimeException('Unbalanced ternary operators in plural expression `' . $pluralForms . '`'); } if (str_contains($expression, '/')) { throw new RuntimeException('Operator `/` is not supported in plural expression `' . $pluralForms . '`'); } } private function transpileExpression(string $expression): string { $expression = $this->stripOuterParentheses(trim($expression)); [$condition, $ifTrue, $ifFalse] = $this->splitTopLevelTernary($expression); if ($condition === null) { return $this->transpileLeafExpression($expression); } return '(' . $this->transpileLeafExpression($condition) . ' ? ' . $this->transpileExpression($ifTrue) . ' : ' . $this->transpileExpression($ifFalse) . ')'; } private function transpileLeafExpression(string $expression): string { $expression = $this->stripOuterParentheses(trim($expression)); // Convert gettext variable name to PHP variable syntax $expression = preg_replace('/\bn\b/', '$n', $expression) ?? $expression; // Enforce strict equality $expression = preg_replace('/(?])==(?!=)/', '===', $expression) ?? $expression; // Enforce strict inequality $expression = preg_replace('/!=(?!=)/', '!==', $expression) ?? $expression; // Normalise operator spacing $expression = preg_replace('/\s*(===|!==|==|!=|<=|>=|\|\||&&|[%*+\-<>])\s*/', ' $1 ', $expression) ?? $expression; // Collapse repeated whitespace $expression = preg_replace('/\s+/', ' ', trim($expression)) ?? trim($expression); return $expression; } /** * @return array{0:?string,1:string,2:string} */ private function splitTopLevelTernary(string $expression): array { $questionPosition = null; $depth = 0; $ternaryDepth = 0; $length = strlen($expression); for ($index = 0; $index < $length; $index++) { $character = $expression[$index]; if ($character === '(') { $depth++; continue; } if ($character === ')') { $depth--; continue; } if ($depth !== 0) { continue; } if ($character === '?') { $questionPosition ??= $index; $ternaryDepth++; continue; } if ($character === ':' && $questionPosition !== null) { $ternaryDepth--; if ($ternaryDepth === 0) { return [ trim(substr($expression, 0, $questionPosition)), trim(substr($expression, $questionPosition + 1, $index - $questionPosition - 1)), trim(substr($expression, $index + 1)), ]; } } } return [null, '', '']; } private function stripOuterParentheses(string $expression): string { $expression = trim($expression); while (str_starts_with($expression, '(') && str_ends_with($expression, ')')) { $depth = 0; $isWrapped = true; $length = strlen($expression); for ($index = 0; $index < $length; $index++) { $character = $expression[$index]; if ($character === '(') { $depth++; } elseif ($character === ')') { $depth--; } if ($depth === 0 && $index < $length - 1) { $isWrapped = false; break; } } if (!$isWrapped) { break; } $expression = trim(substr($expression, 1, -1)); } return $expression; } }