PHP Classes

File: scripts/check_i18n_syntax.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/check_i18n_syntax.php   Download  
File: scripts/check_i18n_syntax.php
Role: Auxiliary script
Content type: text/plain
Description: Configuration script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 6,995 bytes
 

Contents

Class file image Download
#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
 * check_i18n_syntax.php
 *
 * Three-pass check for every catalog file under config/i18n/:
 *
 * Pass 1 ? php -l : catches parse errors (missing semicolons, unmatched brackets?)
 * Pass 2 ? include : executes the file and checks for runtime fatals; catches
 * U+2018/U+2019 curly quotes used as string delimiters, which
 * PHP 8 treats as Unicode identifier starts rather than quote
 * tokens so they survive php -l but blow up at runtime.
 * Pass 3 ? byte scan: flags U+2018/U+2019 in positions that are unambiguously wrong
 * string delimiters (key-opener, key-closer before '=>',
 * value-opener after '=>'). Gives a precise line/column
 * diagnostic before the caller even deploys.
 *
 * Usage:
 * php scripts/check_i18n_syntax.php # check all locales
 * php scripts/check_i18n_syntax.php --locale=it # check a specific locale only
 */

$locale = null;

foreach (
$argv as $arg) {
    if (
str_starts_with($arg, '--locale=')) {
       
$locale = trim(substr($arg, strlen('--locale=')));
    }
}

$i18nDir = __DIR__ . '/../config/i18n';

if (!
is_dir($i18nDir)) {
   
fwrite(STDERR, "i18n directory not found: {$i18nDir}\n");
    exit(
2);
}

$pattern = $locale !== null
   
? $i18nDir . '/' . $locale . '/*.php'
   
: $i18nDir . '/*/*.php';

$files = glob($pattern);

if (
$files === false || count($files) === 0) {
   
$msg = $locale ? "No catalog files found for locale: {$locale}" : "No catalog files found under {$i18nDir}";
   
fwrite(STDERR, $msg . "\n");
    exit(
2);
}

sort($files);

// ?? helpers ??????????????????????????????????????????????????????????????????

/**
 * Pass 2: execute the file in a child PHP process and return any error output.
 * A non-zero exit code means a fatal was thrown (e.g. Undefined constant "'ui").
 *
 * Uses a temp-file bootstrap to avoid shell-quoting problems with Windows paths.
 *
 * @return array{code: int, lines: string[]}
 */
function includeTest(string $file): array
{
   
$bootstrap = '<?php' . "\n"
       
. 'error_reporting(E_ALL);' . "\n"
       
. '$r = include ' . var_export($file, true) . ';' . "\n"
       
. 'if (!is_array($r)) { fwrite(STDERR, "File did not return an array\n"); exit(1); }' . "\n";

   
$tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'i18n_check_' . md5($file) . '.php';
   
file_put_contents($tmp, $bootstrap);

   
$output = [];
   
$code = 0;
   
exec('php ' . escapeshellarg($tmp) . ' 2>&1', $output, $code);
    @
unlink($tmp);

    return [
'code' => $code, 'lines' => $output];
}

/**
 * Pass 3: scan raw bytes for U+2018 / U+2019 in unambiguously-wrong delimiter
 * positions within catalog PHP files.
 *
 * Positions checked:
 * a) Key-opening ? after leading whitespace, where ASCII ' should open a key
 * b) Key-closing ? immediately before ' =>' or '\t=>'
 * c) Value-opening ? immediately after '=> '
 *
 * Value-closing position (before trailing ',') is skipped because U+2019 there
 * is ambiguous with a legitimate apostrophe; the include test (pass 2) catches
 * that case at runtime.
 *
 * @return string[] Human-readable issue descriptions, empty if clean.
 */
function scanCurlyQuoteDelimiters(string $file): array
{
   
$u2018 = "\xe2\x80\x98"; // U+2018 LEFT SINGLE QUOTATION MARK '
   
$u2019 = "\xe2\x80\x99"; // U+2019 RIGHT SINGLE QUOTATION MARK '
   
$both = [$u2018, $u2019];

   
$lines = file($file);
   
$issues = [];

    if (
$lines === false) {
        return [
"could not read file"];
    }

    foreach (
$lines as $i => $rawLine) {
       
$lineNum = $i + 1;
       
$trimmedLine = rtrim($rawLine, "\r\n");
       
$ltrimmed = ltrim($trimmedLine);

       
// (a) Key-opening delimiter
       
foreach ($both as $cq) {
            if (
str_starts_with($ltrimmed, $cq)) {
               
$char = $cq === $u2018 ? 'U+2018 \xe2\x80\x98' : 'U+2019 \xe2\x80\x99';
               
$issues[] = "line {$lineNum}: {$char} (curly quote) in key-opening delimiter position";
            }
        }

       
// (b) Key-closing delimiter ? curly quote immediately before ' =>'
       
foreach ($both as $cq) {
            if (
str_contains($trimmedLine, $cq . ' =>') || str_contains($trimmedLine, $cq . "\t=>")) {
               
$char = $cq === $u2018 ? 'U+2018 \xe2\x80\x98' : 'U+2019 \xe2\x80\x99';
               
$issues[] = "line {$lineNum}: {$char} (curly quote) in key-closing delimiter position (before '=>')";
            }
        }

       
// (c) Value-opening delimiter ? curly quote immediately after '=> '
       
foreach ($both as $cq) {
            if (
str_contains($trimmedLine, '=> ' . $cq) || str_contains($trimmedLine, '=>' . $cq)) {
               
$char = $cq === $u2018 ? 'U+2018 \xe2\x80\x98' : 'U+2019 \xe2\x80\x99';
               
$issues[] = "line {$lineNum}: {$char} (curly quote) in value-opening delimiter position (after '=>')";
            }
        }
    }

    return
$issues;
}

// ?? main loop ?????????????????????????????????????????????????????????????????

$failed = 0;
$checked = 0;

foreach (
$files as $file) {
    if (!
str_ends_with($file, '.php')) {
        continue;
    }

   
$checked++;
   
$rel = str_replace(realpath($i18nDir . '/..') . DIRECTORY_SEPARATOR, '', realpath($file));
   
$rel = str_replace('\\', '/', $rel);

   
$fileErrors = [];

   
// Pass 1: php -l
   
$lintOutput = [];
   
$lintCode = 0;
   
exec('php -l ' . escapeshellarg($file) . ' 2>&1', $lintOutput, $lintCode);
    if (
$lintCode !== 0) {
        foreach (
$lintOutput as $line) {
            if (
trim($line) !== '') {
               
$fileErrors[] = '[syntax] ' . $line;
            }
        }
    }

   
// Pass 2: include/execute test (only if syntax is clean ? avoids double-reporting)
   
if ($lintCode === 0) {
       
$inc = includeTest($file);
        if (
$inc['code'] !== 0) {
            foreach (
$inc['lines'] as $line) {
                if (
trim($line) !== '') {
                   
$fileErrors[] = '[runtime] ' . $line;
                }
            }
        }
    }

   
// Pass 3: byte-level curly-quote delimiter scan
   
foreach (scanCurlyQuoteDelimiters($file) as $issue) {
       
$fileErrors[] = '[curly] ' . $issue;
    }

    if (
$fileErrors !== []) {
        echo
"[FAIL] {$rel}\n";
        foreach (
$fileErrors as $line) {
            echo
" {$line}\n";
        }
       
$failed++;
    } else {
        echo
"[OK] {$rel}\n";
    }
}

echo
"\nChecked {$checked} file(s). Syntax errors: {$failed}\n";

if (
$failed === 0) {
    echo
"i18n syntax check passed.\n";
}

exit(
$failed > 0 ? 1 : 0);