sigalx

Untitled

Sep 3rd, 2025
382
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 12.51 KB | None | 0 0
  1. #!/usr/bin/env php
  2. <?php
  3.  
  4. declare(strict_types=1);
  5.  
  6. /**
  7.  * Совместимо с PHP 7.4.
  8.  *
  9.  * Конкатенирует содержимое файлов, найденных рекурсивно по маскам,
  10.  * печатая перед каждым файлом комментарий с его относительным путём.
  11.  * При флаге --minify минимизирует код (комментарии/лишние пробелы в PHP-коде),
  12.  * сохраняя работоспособность. HTML не сжимается.
  13.  *
  14.  * Примеры:
  15.  *   php concat.php -m "*.php,*.md" -d ./src
  16.  *   php concat.php --masks="*.php,*.phtml" --dir=. --minify --prefix="// "
  17.  */
  18.  
  19. const EXIT_OK = 0;
  20. const EXIT_INVALID_ARGS = 1;
  21.  
  22. main($argv);
  23.  
  24. /**
  25.  * Точка входа.
  26.  *
  27.  * @param array<int,string> $argv
  28.  */
  29. function main(array $argv): void
  30. {
  31.     [$masksInput, $baseDir, $prefix, $minify] = parseArgs($argv);
  32.  
  33.     $baseDirReal = realpath($baseDir);
  34.     if ($baseDirReal === false || !is_dir($baseDirReal)) {
  35.         fwrite(STDERR, "Error: directory not found: {$baseDir}\n");
  36.         exit(EXIT_INVALID_ARGS);
  37.     }
  38.  
  39.     $masks = normalizeMasks($masksInput);
  40.     if ($masks === []) {
  41.         fwrite(STDERR, "Error: provide at least one file mask (e.g. \"*.php,*.md\")\n");
  42.         exit(EXIT_INVALID_ARGS);
  43.     }
  44.  
  45.     $files = collectFiles($baseDirReal, $masks);
  46.     sort($files, SORT_STRING);
  47.  
  48.     foreach ($files as $path) {
  49.         $relative = relativePath($baseDirReal, $path);
  50.         fwrite(STDOUT, $prefix . $relative . PHP_EOL);
  51.  
  52.         if ($minify) {
  53.             // Читаем в память, чтобы при необходимости минимизировать.
  54.             $contents = @file_get_contents($path);
  55.             if ($contents === false) {
  56.                 fwrite(STDERR, "# [warn] cannot read: {$relative}" . PHP_EOL);
  57.                 continue;
  58.             }
  59.  
  60.             if (isPhpFileOrContainsPhp($path, $contents)) {
  61.                 $contents = minifyPhpFilePreservingHtml($contents);
  62.             }
  63.  
  64.             fwrite(STDOUT, $contents);
  65.             fwrite(STDOUT, PHP_EOL);
  66.             continue;
  67.         }
  68.  
  69.         // Без минификации — потоковая передача.
  70.         $fp = @fopen($path, 'rb');
  71.         if ($fp === false) {
  72.             fwrite(STDERR, "# [warn] cannot read: {$relative}" . PHP_EOL);
  73.             continue;
  74.         }
  75.         stream_copy_to_stream($fp, STDOUT);
  76.         fclose($fp);
  77.         fwrite(STDOUT, PHP_EOL);
  78.     }
  79.  
  80.     exit(EXIT_OK);
  81. }
  82.  
  83. /**
  84.  * Разбор аргументов командной строки через getopt.
  85.  *
  86.  * @param array<int,string> $argv
  87.  * @return array{0:string,1:string,2:string,3:bool} [$masksInput, $baseDir, $prefix, $minify]
  88.  */
  89. function parseArgs(array $argv): array
  90. {
  91.     $opts = getopt(
  92.         'm:d:p:ch',
  93.         ['masks:', 'dir:', 'prefix:', 'minify', 'help'],
  94.         $optind
  95.     );
  96.  
  97.     if (isset($opts['h']) || isset($opts['help'])) {
  98.         printUsage($argv[0] ?? 'concat.php');
  99.         exit(EXIT_OK);
  100.     }
  101.  
  102.     $masks = (string)($opts['m'] ?? $opts['masks'] ?? '');
  103.     $dir = (string)($opts['d'] ?? $opts['dir'] ?? '');
  104.     $prefix = (string)($opts['p'] ?? $opts['prefix'] ?? '# ');
  105.     $minify = isset($opts['c']) || isset($opts['minify']);
  106.  
  107.     if ($masks === '' || $dir === '') {
  108.         printUsage($argv[0] ?? 'concat.php');
  109.         exit(EXIT_INVALID_ARGS);
  110.     }
  111.  
  112.     return [$masks, $dir, $prefix, $minify];
  113. }
  114.  
  115. /**
  116.  * Печатает справку по использованию.
  117.  */
  118. function printUsage(string $script): void
  119. {
  120.     $usage = <<<TXT
  121. Usage:
  122.   php {$script} -m "<m1,m2,...>" -d <directory> [-p "<prefix>"] [--minify]
  123.  
  124. Options:
  125.   -m, --masks       Comma-separated glob masks, e.g. "*.php,*.md" (required)
  126.   -d, --dir         Base directory to search (required)
  127.   -p, --prefix      Comment prefix before each file (default: "# ")
  128.   -c, --minify      Minify code (working with PHP, remove comments and excess whitespace)
  129.   -h, --help        Show this help
  130.  
  131. TXT;
  132.  
  133.     fwrite(STDOUT, $usage);
  134. }
  135.  
  136. /**
  137.  * Нормализует строку масок в массив.
  138.  *
  139.  * @return list<string>
  140.  */
  141. function normalizeMasks(string $masksInput): array
  142. {
  143.     $parts = array_map(
  144.         static fn(string $m): string => trim($m),
  145.         explode(',', $masksInput)
  146.     );
  147.  
  148.     return array_values(array_filter($parts, static fn(string $m): bool => $m !== ''));
  149. }
  150.  
  151. /**
  152.  * Рекурсивно собирает файлы, соответствующие хотя бы одной маске.
  153.  *
  154.  * @param list<string> $masks
  155.  * @return list<string> Абсолютные пути
  156.  */
  157. function collectFiles(string $baseDir, array $masks): array
  158. {
  159.     $files = [];
  160.  
  161.     $iterator = new RecursiveIteratorIterator(
  162.         new RecursiveDirectoryIterator(
  163.             $baseDir,
  164.             FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
  165.         )
  166.     );
  167.  
  168.     /** @var SplFileInfo $info */
  169.     foreach ($iterator as $info) {
  170.         if (!$info->isFile()) {
  171.             continue;
  172.         }
  173.  
  174.         $path = $info->getPathname();
  175.         if (matchesAnyMask($path, $masks)) {
  176.             $files[] = $path;
  177.         }
  178.     }
  179.  
  180.     return $files;
  181. }
  182.  
  183. /**
  184.  * Проверяет соответствие basename файла любой из масок.
  185.  *
  186.  * @param list<string> $masks
  187.  */
  188. function matchesAnyMask(string $filePath, array $masks): bool
  189. {
  190.     $name = basename($filePath);
  191.  
  192.     foreach ($masks as $mask) {
  193.         if (function_exists('fnmatch')) {
  194.             if (@fnmatch($mask, $name)) {
  195.                 return true;
  196.             }
  197.         } else {
  198.             $regex = '/^' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote($mask, '/')) . '$/i';
  199.             if (preg_match($regex, $name) === 1) {
  200.                 return true;
  201.             }
  202.         }
  203.     }
  204.  
  205.     return false;
  206. }
  207.  
  208. /**
  209.  * Возвращает относительный путь от базового каталога.
  210.  */
  211. function relativePath(string $baseDir, string $path): string
  212. {
  213.     $base = rtrim(str_replace('\\', '/', (string)realpath($baseDir)), '/');
  214.     $full = str_replace('\\', '/', (string)realpath($path));
  215.  
  216.     if ($full !== '' && str_starts_with($full, $base . '/')) {
  217.         return ltrim(substr($full, strlen($base)), '/');
  218.     }
  219.  
  220.     return $path; // fallback
  221. }
  222.  
  223. /**
  224.  * Определяет, является ли файл PHP или содержит PHP-блоки.
  225.  */
  226. function isPhpFileOrContainsPhp(string $path, string $contents): bool
  227. {
  228.     $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  229.     if (in_array($ext, ['php', 'phtml', 'php5', 'php7', 'inc'], true)) {
  230.         return true;
  231.     }
  232.     return (strpos($contents, '<?php') !== false) || (strpos($contents, '<?=') !== false);
  233. }
  234.  
  235. /**
  236.  * Минимизирует только PHP-код, не затрагивая HTML (T_INLINE_HTML).
  237.  * Комментарии (T_COMMENT, T_DOC_COMMENT) удаляются.
  238.  * Пробелы схлопываются до минимума, необходимого для корректного парсинга.
  239.  */
  240. function minifyPhpFilePreservingHtml(string $code): string
  241. {
  242.     $tokens = token_get_all($code);
  243.     $out = '';
  244.     $prevNonSpaceChar = ''; // последний выведенный не-пробельный символ
  245.  
  246.     $count = count($tokens);
  247.     for ($i = 0; $i < $count; $i++) {
  248.         $t = $tokens[$i];
  249.  
  250.         if (is_string($t)) {
  251.             // Операторы/скобки/символы — выводим как есть (без дополнительных пробелов)
  252.             $out .= $t;
  253.             if (!ctype_space($t)) {
  254.                 $prevNonSpaceChar = substr($t, -1);
  255.             }
  256.             continue;
  257.         }
  258.  
  259.         [$id, $text] = $t;
  260.  
  261.         switch ($id) {
  262.             case T_INLINE_HTML:
  263.                 // HTML оставляем как есть
  264.                 $out .= $text;
  265.                 $prevNonSpaceChar = '';
  266.                 break;
  267.  
  268.             case T_COMMENT:
  269.             case T_DOC_COMMENT:
  270.                 // удаляем
  271.                 break;
  272.  
  273.             case T_WHITESPACE:
  274.                 // Подглядываем следующий значимый токен, чтобы понять, нужен ли пробел
  275.                 $next = nextSignificantToken($tokens, $i + 1);
  276.                 $needSpace = needSpaceBetween($prevNonSpaceChar, $next);
  277.                 if ($needSpace) {
  278.                     $out .= ' ';
  279.                     $prevNonSpaceChar = ' ';
  280.                 }
  281.                 break;
  282.  
  283.             case T_START_HEREDOC:
  284.                 // До конца HEREDOC/DOC получаем неизменно
  285.                 $out .= $text;
  286.                 $i++;
  287.                 while ($i < $count) {
  288.                     $t2 = $tokens[$i];
  289.                     if (is_array($t2) && $t2[0] === T_END_HEREDOC) {
  290.                         $out .= $t2[1];
  291.                         break;
  292.                     }
  293.                     $out .= is_array($t2) ? $t2[1] : $t2;
  294.                     $i++;
  295.                 }
  296.                 $prevNonSpaceChar = '';
  297.                 break;
  298.  
  299.             default:
  300.                 // Обычный код/идентификаторы/литералы — печатаем
  301.                 $out .= $text;
  302.                 $last = $text === '' ? '' : substr($text, -1);
  303.                 if ($last !== '' && !ctype_space($last)) {
  304.                     $prevNonSpaceChar = $last;
  305.                 }
  306.                 break;
  307.         }
  308.     }
  309.  
  310.     // Убираем начальные/конечные пробелы
  311.     return trim($out);
  312. }
  313.  
  314. /**
  315.  * Возвращает следующий "значимый" токен (без пробелов/комментариев).
  316.  *
  317.  * @param array<int, mixed> $tokens
  318.  * @return array{0:int,1:string}|string|null
  319.  */
  320. function nextSignificantToken(array $tokens, int $start)
  321. {
  322.     $count = count($tokens);
  323.     for ($i = $start; $i < $count; $i++) {
  324.         $t = $tokens[$i];
  325.         if (is_string($t)) {
  326.             if (trim($t) === '') {
  327.                 continue;
  328.             }
  329.             return $t;
  330.         }
  331.         $id = $t[0];
  332.         if ($id === T_WHITESPACE || $id === T_COMMENT || $id === T_DOC_COMMENT) {
  333.             continue;
  334.         }
  335.         return $t;
  336.     }
  337.     return null;
  338. }
  339.  
  340. /**
  341.  * Нужен ли пробел между ранее выведенным символом и следующим токеном.
  342.  *
  343.  * Логика: ставим пробел, если слепание может изменить токенизацию.
  344.  * Например, "$a" + "b" => нужно разделить, "}" и "else" => нужен пробел, и т.п.
  345.  *
  346.  * @param string $prevChar Последний выведенный НЕ пробельный символ
  347.  * @param array{0:int,1:string}|string|null $nextToken
  348.  */
  349. function needSpaceBetween(string $prevChar, $nextToken): bool
  350. {
  351.     if ($nextToken === null) {
  352.         return false;
  353.     }
  354.  
  355.     // Если ещё ничего значимого не выводилось — пробел не нужен
  356.     if ($prevChar === '') {
  357.         return false;
  358.     }
  359.  
  360.     // Следующий как строка (операторы/символы)
  361.     if (is_string($nextToken)) {
  362.         // Перед оператором/скобкой пробел не требуется
  363.         return false;
  364.     }
  365.  
  366.     // Следующий — токен с текстом
  367.     [$id, $text] = $nextToken;
  368.     $first = $text === '' ? '' : $text[0];
  369.  
  370.     // Если предыдущий символ "словной" категории и следующий начинается на "словный" — нужен пробел
  371.     $isPrevWordy = ctype_alnum($prevChar) || $prevChar === '_' || $prevChar === '$';
  372.     $isNextWordy = ($first !== '') && (ctype_alnum($first) || $first === '_' || $first === '$');
  373.  
  374.     // Особые случаи: '}' перед 'else', 'return' перед переменной и т.п. покрываются правилом "wordy".
  375.     return $isPrevWordy && $isNextWordy;
  376. }
  377.  
  378. /**
  379.  * Полифил "starts with" для PHP 7.4.
  380.  */
  381. if (!function_exists('str_starts_with')) {
  382.     function str_starts_with(string $haystack, string $needle): bool
  383.     {
  384.         if ($needle === '') {
  385.             return true;
  386.         }
  387.         return substr($haystack, 0, strlen($needle)) === $needle;
  388.     }
  389. }
  390.  
Advertisement
Add Comment
Please, Sign In to add comment