Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env php
- <?php
- declare(strict_types=1);
- /**
- * Совместимо с PHP 7.4.
- *
- * Конкатенирует содержимое файлов, найденных рекурсивно по маскам,
- * печатая перед каждым файлом комментарий с его относительным путём.
- * При флаге --minify минимизирует код (комментарии/лишние пробелы в PHP-коде),
- * сохраняя работоспособность. HTML не сжимается.
- *
- * Примеры:
- * php concat.php -m "*.php,*.md" -d ./src
- * php concat.php --masks="*.php,*.phtml" --dir=. --minify --prefix="// "
- */
- const EXIT_OK = 0;
- const EXIT_INVALID_ARGS = 1;
- main($argv);
- /**
- * Точка входа.
- *
- * @param array<int,string> $argv
- */
- function main(array $argv): void
- {
- [$masksInput, $baseDir, $prefix, $minify] = parseArgs($argv);
- $baseDirReal = realpath($baseDir);
- if ($baseDirReal === false || !is_dir($baseDirReal)) {
- fwrite(STDERR, "Error: directory not found: {$baseDir}\n");
- exit(EXIT_INVALID_ARGS);
- }
- $masks = normalizeMasks($masksInput);
- if ($masks === []) {
- fwrite(STDERR, "Error: provide at least one file mask (e.g. \"*.php,*.md\")\n");
- exit(EXIT_INVALID_ARGS);
- }
- $files = collectFiles($baseDirReal, $masks);
- sort($files, SORT_STRING);
- foreach ($files as $path) {
- $relative = relativePath($baseDirReal, $path);
- fwrite(STDOUT, $prefix . $relative . PHP_EOL);
- if ($minify) {
- // Читаем в память, чтобы при необходимости минимизировать.
- $contents = @file_get_contents($path);
- if ($contents === false) {
- fwrite(STDERR, "# [warn] cannot read: {$relative}" . PHP_EOL);
- continue;
- }
- if (isPhpFileOrContainsPhp($path, $contents)) {
- $contents = minifyPhpFilePreservingHtml($contents);
- }
- fwrite(STDOUT, $contents);
- fwrite(STDOUT, PHP_EOL);
- continue;
- }
- // Без минификации — потоковая передача.
- $fp = @fopen($path, 'rb');
- if ($fp === false) {
- fwrite(STDERR, "# [warn] cannot read: {$relative}" . PHP_EOL);
- continue;
- }
- stream_copy_to_stream($fp, STDOUT);
- fclose($fp);
- fwrite(STDOUT, PHP_EOL);
- }
- exit(EXIT_OK);
- }
- /**
- * Разбор аргументов командной строки через getopt.
- *
- * @param array<int,string> $argv
- * @return array{0:string,1:string,2:string,3:bool} [$masksInput, $baseDir, $prefix, $minify]
- */
- function parseArgs(array $argv): array
- {
- $opts = getopt(
- 'm:d:p:ch',
- ['masks:', 'dir:', 'prefix:', 'minify', 'help'],
- $optind
- );
- if (isset($opts['h']) || isset($opts['help'])) {
- printUsage($argv[0] ?? 'concat.php');
- exit(EXIT_OK);
- }
- $masks = (string)($opts['m'] ?? $opts['masks'] ?? '');
- $dir = (string)($opts['d'] ?? $opts['dir'] ?? '');
- $prefix = (string)($opts['p'] ?? $opts['prefix'] ?? '# ');
- $minify = isset($opts['c']) || isset($opts['minify']);
- if ($masks === '' || $dir === '') {
- printUsage($argv[0] ?? 'concat.php');
- exit(EXIT_INVALID_ARGS);
- }
- return [$masks, $dir, $prefix, $minify];
- }
- /**
- * Печатает справку по использованию.
- */
- function printUsage(string $script): void
- {
- $usage = <<<TXT
- Usage:
- php {$script} -m "<m1,m2,...>" -d <directory> [-p "<prefix>"] [--minify]
- Options:
- -m, --masks Comma-separated glob masks, e.g. "*.php,*.md" (required)
- -d, --dir Base directory to search (required)
- -p, --prefix Comment prefix before each file (default: "# ")
- -c, --minify Minify code (working with PHP, remove comments and excess whitespace)
- -h, --help Show this help
- TXT;
- fwrite(STDOUT, $usage);
- }
- /**
- * Нормализует строку масок в массив.
- *
- * @return list<string>
- */
- function normalizeMasks(string $masksInput): array
- {
- $parts = array_map(
- static fn(string $m): string => trim($m),
- explode(',', $masksInput)
- );
- return array_values(array_filter($parts, static fn(string $m): bool => $m !== ''));
- }
- /**
- * Рекурсивно собирает файлы, соответствующие хотя бы одной маске.
- *
- * @param list<string> $masks
- * @return list<string> Абсолютные пути
- */
- function collectFiles(string $baseDir, array $masks): array
- {
- $files = [];
- $iterator = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator(
- $baseDir,
- FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
- )
- );
- /** @var SplFileInfo $info */
- foreach ($iterator as $info) {
- if (!$info->isFile()) {
- continue;
- }
- $path = $info->getPathname();
- if (matchesAnyMask($path, $masks)) {
- $files[] = $path;
- }
- }
- return $files;
- }
- /**
- * Проверяет соответствие basename файла любой из масок.
- *
- * @param list<string> $masks
- */
- function matchesAnyMask(string $filePath, array $masks): bool
- {
- $name = basename($filePath);
- foreach ($masks as $mask) {
- if (function_exists('fnmatch')) {
- if (@fnmatch($mask, $name)) {
- return true;
- }
- } else {
- $regex = '/^' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote($mask, '/')) . '$/i';
- if (preg_match($regex, $name) === 1) {
- return true;
- }
- }
- }
- return false;
- }
- /**
- * Возвращает относительный путь от базового каталога.
- */
- function relativePath(string $baseDir, string $path): string
- {
- $base = rtrim(str_replace('\\', '/', (string)realpath($baseDir)), '/');
- $full = str_replace('\\', '/', (string)realpath($path));
- if ($full !== '' && str_starts_with($full, $base . '/')) {
- return ltrim(substr($full, strlen($base)), '/');
- }
- return $path; // fallback
- }
- /**
- * Определяет, является ли файл PHP или содержит PHP-блоки.
- */
- function isPhpFileOrContainsPhp(string $path, string $contents): bool
- {
- $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
- if (in_array($ext, ['php', 'phtml', 'php5', 'php7', 'inc'], true)) {
- return true;
- }
- return (strpos($contents, '<?php') !== false) || (strpos($contents, '<?=') !== false);
- }
- /**
- * Минимизирует только PHP-код, не затрагивая HTML (T_INLINE_HTML).
- * Комментарии (T_COMMENT, T_DOC_COMMENT) удаляются.
- * Пробелы схлопываются до минимума, необходимого для корректного парсинга.
- */
- function minifyPhpFilePreservingHtml(string $code): string
- {
- $tokens = token_get_all($code);
- $out = '';
- $prevNonSpaceChar = ''; // последний выведенный не-пробельный символ
- $count = count($tokens);
- for ($i = 0; $i < $count; $i++) {
- $t = $tokens[$i];
- if (is_string($t)) {
- // Операторы/скобки/символы — выводим как есть (без дополнительных пробелов)
- $out .= $t;
- if (!ctype_space($t)) {
- $prevNonSpaceChar = substr($t, -1);
- }
- continue;
- }
- [$id, $text] = $t;
- switch ($id) {
- case T_INLINE_HTML:
- // HTML оставляем как есть
- $out .= $text;
- $prevNonSpaceChar = '';
- break;
- case T_COMMENT:
- case T_DOC_COMMENT:
- // удаляем
- break;
- case T_WHITESPACE:
- // Подглядываем следующий значимый токен, чтобы понять, нужен ли пробел
- $next = nextSignificantToken($tokens, $i + 1);
- $needSpace = needSpaceBetween($prevNonSpaceChar, $next);
- if ($needSpace) {
- $out .= ' ';
- $prevNonSpaceChar = ' ';
- }
- break;
- case T_START_HEREDOC:
- // До конца HEREDOC/DOC получаем неизменно
- $out .= $text;
- $i++;
- while ($i < $count) {
- $t2 = $tokens[$i];
- if (is_array($t2) && $t2[0] === T_END_HEREDOC) {
- $out .= $t2[1];
- break;
- }
- $out .= is_array($t2) ? $t2[1] : $t2;
- $i++;
- }
- $prevNonSpaceChar = '';
- break;
- default:
- // Обычный код/идентификаторы/литералы — печатаем
- $out .= $text;
- $last = $text === '' ? '' : substr($text, -1);
- if ($last !== '' && !ctype_space($last)) {
- $prevNonSpaceChar = $last;
- }
- break;
- }
- }
- // Убираем начальные/конечные пробелы
- return trim($out);
- }
- /**
- * Возвращает следующий "значимый" токен (без пробелов/комментариев).
- *
- * @param array<int, mixed> $tokens
- * @return array{0:int,1:string}|string|null
- */
- function nextSignificantToken(array $tokens, int $start)
- {
- $count = count($tokens);
- for ($i = $start; $i < $count; $i++) {
- $t = $tokens[$i];
- if (is_string($t)) {
- if (trim($t) === '') {
- continue;
- }
- return $t;
- }
- $id = $t[0];
- if ($id === T_WHITESPACE || $id === T_COMMENT || $id === T_DOC_COMMENT) {
- continue;
- }
- return $t;
- }
- return null;
- }
- /**
- * Нужен ли пробел между ранее выведенным символом и следующим токеном.
- *
- * Логика: ставим пробел, если слепание может изменить токенизацию.
- * Например, "$a" + "b" => нужно разделить, "}" и "else" => нужен пробел, и т.п.
- *
- * @param string $prevChar Последний выведенный НЕ пробельный символ
- * @param array{0:int,1:string}|string|null $nextToken
- */
- function needSpaceBetween(string $prevChar, $nextToken): bool
- {
- if ($nextToken === null) {
- return false;
- }
- // Если ещё ничего значимого не выводилось — пробел не нужен
- if ($prevChar === '') {
- return false;
- }
- // Следующий как строка (операторы/символы)
- if (is_string($nextToken)) {
- // Перед оператором/скобкой пробел не требуется
- return false;
- }
- // Следующий — токен с текстом
- [$id, $text] = $nextToken;
- $first = $text === '' ? '' : $text[0];
- // Если предыдущий символ "словной" категории и следующий начинается на "словный" — нужен пробел
- $isPrevWordy = ctype_alnum($prevChar) || $prevChar === '_' || $prevChar === '$';
- $isNextWordy = ($first !== '') && (ctype_alnum($first) || $first === '_' || $first === '$');
- // Особые случаи: '}' перед 'else', 'return' перед переменной и т.п. покрываются правилом "wordy".
- return $isPrevWordy && $isNextWordy;
- }
- /**
- * Полифил "starts with" для PHP 7.4.
- */
- if (!function_exists('str_starts_with')) {
- function str_starts_with(string $haystack, string $needle): bool
- {
- if ($needle === '') {
- return true;
- }
- return substr($haystack, 0, strlen($needle)) === $needle;
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment