| 1 | 1 | <?php |
| 2 | 2 | class Config { |
| 3 | const SITE_TITLE = "Dave Jarvis' Repositories"; | |
| 4 | ||
| 5 | private static function getHomeDirectory() { | |
| 6 | if( !empty( $_SERVER['HOME'] ) ) { | |
| 7 | return $_SERVER['HOME']; | |
| 8 | } | |
| 9 | ||
| 10 | if( !empty( getenv( 'HOME' ) ) ) { | |
| 11 | return getenv( 'HOME' ); | |
| 12 | } | |
| 3 | public const SITE_TITLE = "Dave Jarvis' Repositories"; | |
| 4 | private const REPOS_SUBDIR = '/repos'; | |
| 5 | private const LOG_FILE = '/error.log'; | |
| 13 | 6 | |
| 14 | if( function_exists( 'posix_getpwuid' ) && | |
| 15 | function_exists( 'posix_getuid' ) ) { | |
| 16 | $userInfo = posix_getpwuid( posix_getuid() ); | |
| 7 | public function init() { | |
| 8 | ini_set( 'display_errors', 0 ); | |
| 9 | ini_set( 'log_errors', 1 ); | |
| 10 | ini_set( 'error_log', __DIR__ . self::LOG_FILE ); | |
| 11 | } | |
| 17 | 12 | |
| 18 | if( !empty( $userInfo['dir'] ) ) { | |
| 19 | return $userInfo['dir']; | |
| 20 | } | |
| 21 | } | |
| 13 | public function createRouter() { | |
| 14 | $path = $this->getHomeDirectory() . self::REPOS_SUBDIR; | |
| 22 | 15 | |
| 23 | return ''; | |
| 16 | return new Router( $path ); | |
| 24 | 17 | } |
| 25 | 18 | |
| 26 | public static function getReposPath() { | |
| 27 | return self::getHomeDirectory() . '/repos'; | |
| 28 | } | |
| 19 | private function getHomeDirectory() { | |
| 20 | $home = !empty( $_SERVER['HOME'] ) ? $_SERVER['HOME'] : ''; | |
| 21 | $home = $home === '' && !empty( getenv( 'HOME' ) ) | |
| 22 | ? getenv( 'HOME' ) | |
| 23 | : $home; | |
| 29 | 24 | |
| 30 | public static function init() { | |
| 31 | ini_set( 'display_errors', 0 ); | |
| 32 | ini_set( 'log_errors', 1 ); | |
| 33 | ini_set( 'error_log', __DIR__ . '/error.log' ); | |
| 25 | return | |
| 26 | $home === '' && | |
| 27 | function_exists( 'posix_getpwuid' ) && | |
| 28 | function_exists( 'posix_getuid' ) | |
| 29 | ? (posix_getpwuid( posix_getuid() )['dir'] ?? '') | |
| 30 | : $home; | |
| 34 | 31 | } |
| 35 | 32 | } |
| 33 | ||
| 36 | 34 |
| 3 | 3 | |
| 4 | 4 | class File { |
| 5 | private const CAT_IMAGE = 'image'; | |
| 6 | private const CAT_VIDEO = 'video'; | |
| 7 | private const CAT_AUDIO = 'audio'; | |
| 8 | private const CAT_TEXT = 'text'; | |
| 5 | private const CAT_IMAGE = 'image'; | |
| 6 | private const CAT_VIDEO = 'video'; | |
| 7 | private const CAT_AUDIO = 'audio'; | |
| 8 | private const CAT_TEXT = 'text'; | |
| 9 | 9 | private const CAT_ARCHIVE = 'archive'; |
| 10 | private const CAT_BINARY = 'binary'; | |
| 10 | private const CAT_BINARY = 'binary'; | |
| 11 | 11 | |
| 12 | private const ARCHIVE_EXTENSIONS = [ | |
| 12 | private const ICON_FOLDER = 'fa-folder'; | |
| 13 | private const ICON_PDF = 'fa-file-pdf'; | |
| 14 | private const ICON_ARCHIVE = 'fa-file-archive'; | |
| 15 | private const ICON_IMAGE = 'fa-file-image'; | |
| 16 | private const ICON_AUDIO = 'fa-file-audio'; | |
| 17 | private const ICON_VIDEO = 'fa-file-video'; | |
| 18 | private const ICON_CODE = 'fa-file-code'; | |
| 19 | private const ICON_FILE = 'fa-file'; | |
| 20 | ||
| 21 | private const MODE_DIR = '40000'; | |
| 22 | private const MODE_DIR_LONG = '040000'; | |
| 23 | ||
| 24 | private const MEDIA_EMPTY = 'application/x-empty'; | |
| 25 | private const MEDIA_OCTET = 'application/octet-stream'; | |
| 26 | private const MEDIA_PDF = 'application/pdf'; | |
| 27 | private const MEDIA_TEXT = 'text/'; | |
| 28 | private const MEDIA_SVG = 'image/svg'; | |
| 29 | private const MEDIA_APP_TEXT = [ | |
| 30 | 'application/javascript', | |
| 31 | 'application/json', | |
| 32 | 'application/xml', | |
| 33 | 'application/x-httpd-php', | |
| 34 | 'application/x-sh' | |
| 35 | ]; | |
| 36 | ||
| 37 | private const ARCHIVE_EXT = [ | |
| 13 | 38 | 'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab', |
| 14 | 39 | 'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj', |
| ... | ||
| 22 | 47 | private int $size; |
| 23 | 48 | private bool $isDir; |
| 24 | ||
| 25 | 49 | private string $mediaType; |
| 26 | 50 | private string $category; |
| ... | ||
| 35 | 59 | string $contents = '' |
| 36 | 60 | ) { |
| 37 | $this->name = $name; | |
| 38 | $this->sha = $sha; | |
| 39 | $this->mode = $mode; | |
| 61 | $this->name = $name; | |
| 62 | $this->sha = $sha; | |
| 63 | $this->mode = $mode; | |
| 40 | 64 | $this->timestamp = $timestamp; |
| 41 | $this->size = $size; | |
| 42 | $this->isDir = $mode === '40000' || $mode === '040000'; | |
| 43 | ||
| 44 | $buffer = $this->isDir ? '' : $contents; | |
| 65 | $this->size = $size; | |
| 66 | $this->isDir = $mode === self::MODE_DIR || | |
| 67 | $mode === self::MODE_DIR_LONG; | |
| 45 | 68 | |
| 69 | $buffer = $this->isDir ? '' : $contents; | |
| 46 | 70 | $this->mediaType = $this->detectMediaType( $buffer ); |
| 47 | $this->category = $this->detectCategory( $name ); | |
| 48 | $this->binary = $this->detectBinary(); | |
| 71 | $this->category = $this->detectCategory( $name ); | |
| 72 | $this->binary = $this->detectBinary(); | |
| 49 | 73 | } |
| 50 | 74 | |
| ... | ||
| 64 | 88 | $this->size |
| 65 | 89 | ); |
| 90 | } | |
| 91 | ||
| 92 | public function emitRawHeaders(): void { | |
| 93 | header( "Content-Type: " . $this->mediaType ); | |
| 94 | header( "Content-Length: " . $this->size ); | |
| 95 | header( "Content-Disposition: attachment; filename=\"" . | |
| 96 | addslashes( basename( $this->name ) ) . "\"" ); | |
| 66 | 97 | } |
| 67 | 98 | |
| ... | ||
| 74 | 105 | } |
| 75 | 106 | |
| 76 | public function highlight( FileRenderer $renderer, string $content ): string { | |
| 107 | public function highlight( | |
| 108 | FileRenderer $renderer, | |
| 109 | string $content | |
| 110 | ): string { | |
| 77 | 111 | return $renderer->highlight( $this->name, $content, $this->mediaType ); |
| 112 | } | |
| 113 | ||
| 114 | public function isDir(): bool { | |
| 115 | return $this->isDir; | |
| 78 | 116 | } |
| 79 | 117 | |
| ... | ||
| 100 | 138 | public function isName( string $name ): bool { |
| 101 | 139 | return $this->name === $name; |
| 102 | } | |
| 103 | ||
| 104 | public function emitRawHeaders(): void { | |
| 105 | header( "Content-Type: " . $this->mediaType ); | |
| 106 | header( "Content-Length: " . $this->size ); | |
| 107 | header( "Content-Disposition: attachment; filename=\"" . | |
| 108 | addslashes( basename( $this->name ) ) . "\"" ); | |
| 109 | 140 | } |
| 110 | 141 | |
| 111 | 142 | private function resolveIcon(): string { |
| 112 | 143 | return $this->isDir |
| 113 | ? 'fa-folder' | |
| 114 | : (str_contains( $this->mediaType, 'application/pdf' ) | |
| 115 | ? 'fa-file-pdf' | |
| 144 | ? self::ICON_FOLDER | |
| 145 | : (str_contains( $this->mediaType, self::MEDIA_PDF ) | |
| 146 | ? self::ICON_PDF | |
| 116 | 147 | : match( $this->category ) { |
| 117 | self::CAT_ARCHIVE => 'fa-file-archive', | |
| 118 | self::CAT_IMAGE => 'fa-file-image', | |
| 119 | self::CAT_AUDIO => 'fa-file-audio', | |
| 120 | self::CAT_VIDEO => 'fa-file-video', | |
| 121 | self::CAT_TEXT => 'fa-file-code', | |
| 122 | default => 'fa-file', | |
| 148 | self::CAT_ARCHIVE => self::ICON_ARCHIVE, | |
| 149 | self::CAT_IMAGE => self::ICON_IMAGE, | |
| 150 | self::CAT_AUDIO => self::ICON_AUDIO, | |
| 151 | self::CAT_VIDEO => self::ICON_VIDEO, | |
| 152 | self::CAT_TEXT => self::ICON_CODE, | |
| 153 | default => self::ICON_FILE, | |
| 123 | 154 | }); |
| 124 | 155 | } |
| 125 | 156 | |
| 126 | 157 | private function detectMediaType( string $buffer ): string { |
| 127 | if( $buffer === '' ) return 'application/x-empty'; | |
| 128 | ||
| 129 | $finfo = new finfo( FILEINFO_MIME_TYPE ); | |
| 130 | $mediaType = $finfo->buffer( $buffer ); | |
| 131 | ||
| 132 | return $mediaType ?: 'application/octet-stream'; | |
| 158 | return $buffer === '' | |
| 159 | ? self::MEDIA_EMPTY | |
| 160 | : ((new finfo( FILEINFO_MIME_TYPE )) | |
| 161 | ->buffer( substr( $buffer, 0, 256 ) ) | |
| 162 | ?: self::MEDIA_OCTET); | |
| 133 | 163 | } |
| 134 | 164 | |
| 135 | private function detectCategory( string $filename = '' ): string { | |
| 136 | $parts = explode( '/', $this->mediaType ); | |
| 165 | private function detectCategory( string $filename ): string { | |
| 166 | $main = explode( '/', $this->mediaType )[0]; | |
| 167 | $main = $this->isArchive( $filename ) || | |
| 168 | str_contains( $this->mediaType, 'compressed' ) | |
| 169 | ? self::CAT_ARCHIVE | |
| 170 | : $main; | |
| 137 | 171 | |
| 138 | return match( true ) { | |
| 139 | $parts[0] === 'image' => self::CAT_IMAGE, | |
| 140 | $parts[0] === 'video' => self::CAT_VIDEO, | |
| 141 | $parts[0] === 'audio' => self::CAT_AUDIO, | |
| 142 | $parts[0] === 'text' => self::CAT_TEXT, | |
| 143 | $this->isArchiveFile( $filename ) => self::CAT_ARCHIVE, | |
| 144 | str_contains( $this->mediaType, 'compressed' ) => self::CAT_ARCHIVE, | |
| 145 | default => self::CAT_BINARY, | |
| 172 | $main = $main !== self::CAT_ARCHIVE && | |
| 173 | $this->isMediaTypeText() | |
| 174 | ? 'text' | |
| 175 | : $main; | |
| 176 | ||
| 177 | return match( $main ) { | |
| 178 | 'image' => self::CAT_IMAGE, | |
| 179 | 'video' => self::CAT_VIDEO, | |
| 180 | 'audio' => self::CAT_AUDIO, | |
| 181 | 'text' => self::CAT_TEXT, | |
| 182 | self::CAT_ARCHIVE => self::CAT_ARCHIVE, | |
| 183 | default => self::CAT_BINARY, | |
| 146 | 184 | }; |
| 147 | 185 | } |
| 148 | 186 | |
| 149 | 187 | private function detectBinary(): bool { |
| 150 | return $this->mediaType !== 'application/x-empty' | |
| 151 | && !str_starts_with( $this->mediaType, 'text/' ); | |
| 188 | return $this->mediaType !== self::MEDIA_EMPTY && | |
| 189 | !$this->isMediaTypeText() && | |
| 190 | !str_contains( $this->mediaType, self::MEDIA_SVG ); | |
| 152 | 191 | } |
| 153 | 192 | |
| 154 | private function isArchiveFile( string $filename ): bool { | |
| 193 | private function isMediaTypeText(): bool { | |
| 194 | return str_starts_with( $this->mediaType, self::MEDIA_TEXT ) || | |
| 195 | in_array( $this->mediaType, self::MEDIA_APP_TEXT, true ); | |
| 196 | } | |
| 197 | ||
| 198 | private function isArchive( string $filename ): bool { | |
| 155 | 199 | return in_array( |
| 156 | 200 | strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ), |
| 157 | self::ARCHIVE_EXTENSIONS, | |
| 201 | self::ARCHIVE_EXT, | |
| 158 | 202 | true |
| 159 | 203 | ); |
| 1 | 1 | <?php |
| 2 | 2 | class RepositoryList { |
| 3 | private $reposPath; | |
| 3 | private const GIT_EXT = '.git'; | |
| 4 | private const ORDER_FILE = '/order.txt'; | |
| 5 | private const GLOB_PATTERN = '/*'; | |
| 6 | private const HIDDEN_PREFIX = '.'; | |
| 7 | private const EXCLUDE_CHAR = '-'; | |
| 8 | private const SORT_MAX = PHP_INT_MAX; | |
| 4 | 9 | |
| 5 | public function __construct( $path ) { | |
| 10 | private const KEY_SAFE_NAME = 'safe_name'; | |
| 11 | private const KEY_EXCLUDE = 'exclude'; | |
| 12 | private const KEY_ORDER = 'order'; | |
| 13 | private const KEY_PATH = 'path'; | |
| 14 | private const KEY_NAME = 'name'; | |
| 15 | ||
| 16 | private string $reposPath; | |
| 17 | ||
| 18 | public function __construct( string $path ) { | |
| 6 | 19 | $this->reposPath = $path; |
| 7 | 20 | } |
| 8 | 21 | |
| 9 | public function eachRepository( callable $callback ) { | |
| 22 | public function eachRepository( callable $callback ): void { | |
| 23 | $repos = $this->sortRepositories( $this->loadRepositories() ); | |
| 24 | ||
| 25 | foreach( $repos as $repo ) { | |
| 26 | $callback( $repo ); | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | private function loadRepositories(): array { | |
| 10 | 31 | $repos = []; |
| 11 | $dirs = glob( $this->reposPath . '/*', GLOB_ONLYDIR ); | |
| 32 | $path = $this->reposPath . self::GLOB_PATTERN; | |
| 33 | $dirs = glob( $path, GLOB_ONLYDIR ); | |
| 12 | 34 | |
| 13 | if( $dirs === false ) { | |
| 14 | return; | |
| 35 | if( $dirs !== false ) { | |
| 36 | $repos = $this->processDirectories( $dirs ); | |
| 15 | 37 | } |
| 38 | ||
| 39 | return $repos; | |
| 40 | } | |
| 41 | ||
| 42 | private function processDirectories( array $dirs ): array { | |
| 43 | $repos = []; | |
| 16 | 44 | |
| 17 | 45 | foreach( $dirs as $dir ) { |
| 18 | $basename = basename( $dir ); | |
| 46 | $data = $this->createRepositoryData( $dir ); | |
| 19 | 47 | |
| 20 | if( $basename[0] === '.' ) { | |
| 21 | continue; | |
| 48 | if( $data !== [] ) { | |
| 49 | $repos[$data[self::KEY_NAME]] = $data; | |
| 22 | 50 | } |
| 51 | } | |
| 23 | 52 | |
| 24 | $name = $basename; | |
| 53 | return $repos; | |
| 54 | } | |
| 25 | 55 | |
| 26 | if( str_ends_with( $name, '.git' ) ) { | |
| 27 | $name = substr( $name, 0, -4 ); | |
| 28 | } | |
| 56 | private function createRepositoryData( string $dir ): array { | |
| 57 | $data = []; | |
| 58 | $base = basename( $dir ); | |
| 29 | 59 | |
| 30 | $repos[$name] = [ | |
| 31 | 'name' => $name, | |
| 32 | 'safe_name' => $name, | |
| 33 | 'path' => $dir | |
| 60 | if( $base[0] !== self::HIDDEN_PREFIX ) { | |
| 61 | $name = $this->extractName( $base ); | |
| 62 | $data = [ | |
| 63 | self::KEY_NAME => $name, | |
| 64 | self::KEY_SAFE_NAME => $name, | |
| 65 | self::KEY_PATH => $dir, | |
| 34 | 66 | ]; |
| 35 | 67 | } |
| 36 | 68 | |
| 37 | $this->sortRepositories( $repos ); | |
| 69 | return $data; | |
| 70 | } | |
| 38 | 71 | |
| 39 | foreach( $repos as $repo ) { | |
| 40 | $callback( $repo ); | |
| 72 | private function extractName( string $base ): string { | |
| 73 | $name = $base; | |
| 74 | ||
| 75 | if( str_ends_with( $base, self::GIT_EXT ) ) { | |
| 76 | $len = strlen( self::GIT_EXT ); | |
| 77 | $name = substr( $base, 0, -$len ); | |
| 41 | 78 | } |
| 79 | ||
| 80 | return $name; | |
| 42 | 81 | } |
| 43 | 82 | |
| 44 | private function sortRepositories( array &$repos ) { | |
| 45 | $orderFile = __DIR__ . '/order.txt'; | |
| 83 | private function sortRepositories( array $repos ): array { | |
| 84 | $file = __DIR__ . self::ORDER_FILE; | |
| 46 | 85 | |
| 47 | if( !file_exists( $orderFile ) ) { | |
| 86 | if( file_exists( $file ) ) { | |
| 87 | $repos = $this->applyCustomOrder( $repos, $file ); | |
| 88 | } else { | |
| 48 | 89 | ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE ); |
| 49 | return; | |
| 50 | 90 | } |
| 51 | 91 | |
| 52 | $lines = file( $orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); | |
| 53 | $order = []; | |
| 92 | return $repos; | |
| 93 | } | |
| 94 | ||
| 95 | private function applyCustomOrder( array $repos, string $file ): array { | |
| 96 | $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); | |
| 97 | ||
| 98 | if( $lines !== false ) { | |
| 99 | $config = $this->parseOrderFile( $lines ); | |
| 100 | $repos = $this->filterExcluded( $repos, $config[self::KEY_EXCLUDE] ); | |
| 101 | $repos = $this->sortWithConfig( $repos, $config[self::KEY_ORDER] ); | |
| 102 | } | |
| 103 | ||
| 104 | return $repos; | |
| 105 | } | |
| 106 | ||
| 107 | private function parseOrderFile( array $lines ): array { | |
| 108 | $order = []; | |
| 54 | 109 | $exclude = []; |
| 55 | 110 | |
| 56 | 111 | foreach( $lines as $line ) { |
| 57 | $line = trim( $line ); | |
| 58 | ||
| 59 | if( $line === '' ) { | |
| 60 | continue; | |
| 61 | } | |
| 112 | $trim = trim( $line ); | |
| 62 | 113 | |
| 63 | if( $line[0] === '-' ) { | |
| 64 | $exclude[substr( $line, 1 )] = true; | |
| 65 | } else { | |
| 66 | $order[$line] = count( $order ); | |
| 114 | if( $trim !== '' ) { | |
| 115 | if( str_starts_with( $trim, self::EXCLUDE_CHAR ) ) { | |
| 116 | $exclude = $this->addExclusion( $exclude, $trim ); | |
| 117 | } else { | |
| 118 | $order[$trim] = count( $order ); | |
| 119 | } | |
| 67 | 120 | } |
| 68 | 121 | } |
| 122 | ||
| 123 | return [ self::KEY_ORDER => $order, self::KEY_EXCLUDE => $exclude ]; | |
| 124 | } | |
| 125 | ||
| 126 | private function addExclusion( array $exclude, string $line ): array { | |
| 127 | $name = substr( $line, 1 ); | |
| 128 | $exclude[$name] = true; | |
| 129 | ||
| 130 | return $exclude; | |
| 131 | } | |
| 69 | 132 | |
| 133 | private function filterExcluded( array $repos, array $exclude ): array { | |
| 70 | 134 | foreach( $repos as $key => $repo ) { |
| 71 | if( isset( $exclude[$repo['safe_name']] ) ) { | |
| 135 | if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) { | |
| 72 | 136 | unset( $repos[$key] ); |
| 73 | 137 | } |
| 74 | 138 | } |
| 75 | ||
| 76 | uasort( $repos, function( $a, $b ) use ( $order ) { | |
| 77 | $nameA = $a['safe_name']; | |
| 78 | $nameB = $b['safe_name']; | |
| 79 | $posA = $order[$nameA] ?? PHP_INT_MAX; | |
| 80 | $posB = $order[$nameB] ?? PHP_INT_MAX; | |
| 81 | 139 | |
| 82 | if( $posA === $posB ) { | |
| 83 | return strcasecmp( $nameA, $nameB ); | |
| 84 | } | |
| 140 | return $repos; | |
| 141 | } | |
| 85 | 142 | |
| 86 | return $posA <=> $posB; | |
| 143 | private function sortWithConfig( array $repos, array $order ): array { | |
| 144 | uasort( $repos, function( array $repoA, array $repoB ) use( $order ): int { | |
| 145 | return $this->compareRepositories( $repoA, $repoB, $order ); | |
| 87 | 146 | } ); |
| 147 | ||
| 148 | return $repos; | |
| 149 | } | |
| 150 | ||
| 151 | private function compareRepositories( | |
| 152 | array $repoA, | |
| 153 | array $repoB, | |
| 154 | array $order | |
| 155 | ): int { | |
| 156 | $safeA = $repoA[self::KEY_SAFE_NAME]; | |
| 157 | $safeB = $repoB[self::KEY_SAFE_NAME]; | |
| 158 | $posA = $order[$safeA] ?? self::SORT_MAX; | |
| 159 | $posB = $order[$safeB] ?? self::SORT_MAX; | |
| 160 | ||
| 161 | $result = $posA === $posB | |
| 162 | ? strcasecmp( $safeA, $safeB ) | |
| 163 | : $posA <=> $posB; | |
| 164 | ||
| 165 | return $result; | |
| 88 | 166 | } |
| 89 | 167 | } |
| 12 | 12 | |
| 13 | 13 | class Router { |
| 14 | private $repos = []; | |
| 15 | private $git; | |
| 14 | private const ACTION_TREE = 'tree'; | |
| 15 | private const ACTION_BLOB = 'blob'; | |
| 16 | private const ACTION_RAW = 'raw'; | |
| 17 | private const ACTION_COMMITS = 'commits'; | |
| 18 | private const ACTION_COMMIT = 'commit'; | |
| 19 | private const ACTION_COMPARE = 'compare'; | |
| 20 | private const ACTION_TAGS = 'tags'; | |
| 21 | ||
| 22 | private const GET_REPOSITORY = 'repo'; | |
| 23 | private const GET_ACTION = 'action'; | |
| 24 | private const GET_HASH = 'hash'; | |
| 25 | private const GET_NAME = 'name'; | |
| 26 | ||
| 27 | private const REFERENCE_HEAD = 'HEAD'; | |
| 28 | private const ROUTE_REPO = 'repo'; | |
| 29 | private const EXTENSION_GIT = '.git'; | |
| 30 | ||
| 31 | private array $repos = []; | |
| 32 | private Git $git; | |
| 33 | ||
| 34 | private string $repoName = ''; | |
| 35 | private array $repoData = []; | |
| 36 | private string $action = ''; | |
| 37 | private string $commitHash = ''; | |
| 38 | private string $filePath = ''; | |
| 39 | private string $baseHash = ''; | |
| 16 | 40 | |
| 17 | 41 | public function __construct( string $reposPath ) { |
| 18 | 42 | $this->git = new Git( $reposPath ); |
| 19 | $list = new RepositoryList( $reposPath ); | |
| 43 | $list = new RepositoryList( $reposPath ); | |
| 44 | ||
| 20 | 45 | $list->eachRepository( function( $repo ) { |
| 21 | 46 | $this->repos[$repo['safe_name']] = $repo; |
| 22 | } ); | |
| 47 | }); | |
| 23 | 48 | } |
| 24 | 49 | |
| 25 | 50 | public function route(): Page { |
| 26 | if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) { | |
| 27 | parse_str( $_SERVER['QUERY_STRING'], $_GET ); | |
| 28 | } | |
| 29 | ||
| 30 | $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); | |
| 31 | $scriptName = dirname( $_SERVER['SCRIPT_NAME'] ); | |
| 51 | $this->normalizeQueryString(); | |
| 52 | $uriParts = $this->parseUriParts(); | |
| 53 | $repoName = !empty( $uriParts ) ? array_shift( $uriParts ) : ''; | |
| 54 | $page = new HomePage( $this->repos, $this->git ); | |
| 32 | 55 | |
| 33 | if( $scriptName !== '/' && strpos( $uri, $scriptName ) === 0 ) { | |
| 34 | $uri = substr( $uri, strlen( $scriptName ) ); | |
| 56 | if( $repoName !== '' ) { | |
| 57 | if( str_ends_with( $repoName, self::EXTENSION_GIT ) ) { | |
| 58 | $page = $this->handleCloneRoute( $repoName, $uriParts ); | |
| 59 | } elseif( isset( $this->repos[$repoName] ) ) { | |
| 60 | $page = $this->resolveActionRoute( $repoName, $uriParts ); | |
| 61 | } | |
| 35 | 62 | } |
| 36 | 63 | |
| 37 | $uri = trim( $uri, '/' ); | |
| 38 | $parts = explode( '/', $uri ); | |
| 64 | return $page; | |
| 65 | } | |
| 39 | 66 | |
| 40 | if( !empty( $parts ) && $parts[0] === 'repo' ) { | |
| 41 | array_shift( $parts ); | |
| 67 | private function handleCloneRoute( | |
| 68 | string $repoName, | |
| 69 | array $uriParts | |
| 70 | ): Page { | |
| 71 | $realName = substr( $repoName, 0, -4 ); | |
| 72 | $path = ''; | |
| 73 | ||
| 74 | if( isset( $this->repos[$realName]['path'] ) ) { | |
| 75 | $path = $this->repos[$realName]['path']; | |
| 76 | } elseif( isset( $this->repos[$repoName]['path'] ) ) { | |
| 77 | $path = $this->repos[$repoName]['path']; | |
| 42 | 78 | } |
| 43 | 79 | |
| 44 | if( empty( $parts ) || empty( $parts[0] ) ) { | |
| 45 | return new HomePage( $this->repos, $this->git ); | |
| 80 | if( $path === '' ) { | |
| 81 | http_response_code( 404 ); | |
| 82 | exit( "Repository not found" ); | |
| 46 | 83 | } |
| 47 | 84 | |
| 48 | $repoName = array_shift( $parts ); | |
| 85 | $this->git->setRepository( $path ); | |
| 49 | 86 | |
| 50 | if( str_ends_with( $repoName, '.git' ) ) { | |
| 51 | $realName = substr( $repoName, 0, -4 ); | |
| 52 | $repoPath = $this->repos[$realName]['path'] ?? | |
| 53 | $this->repos[$repoName]['path'] ?? null; | |
| 87 | return new ClonePage( $this->git, implode( '/', $uriParts ) ); | |
| 88 | } | |
| 54 | 89 | |
| 55 | if( !$repoPath ) { | |
| 56 | http_response_code( 404 ); | |
| 57 | echo "Repository not found"; | |
| 58 | exit; | |
| 59 | } | |
| 90 | private function resolveActionRoute( | |
| 91 | string $repoName, | |
| 92 | array $uriParts | |
| 93 | ): Page { | |
| 94 | $this->repoData = $this->repos[$repoName]; | |
| 95 | $this->repoName = $repoName; | |
| 60 | 96 | |
| 61 | $this->git->setRepository( $repoPath ); | |
| 97 | $this->git->setRepository( $this->repoData['path'] ); | |
| 62 | 98 | |
| 63 | return new ClonePage( $this->git, implode( '/', $parts ) ); | |
| 64 | } | |
| 99 | $act = array_shift( $uriParts ); | |
| 100 | $this->action = $act ?: self::ACTION_TREE; | |
| 65 | 101 | |
| 66 | if( !isset( $this->repos[$repoName] ) ) { | |
| 67 | return new HomePage( $this->repos, $this->git ); | |
| 68 | } | |
| 102 | $this->commitHash = self::REFERENCE_HEAD; | |
| 103 | $this->filePath = ''; | |
| 104 | $this->baseHash = ''; | |
| 69 | 105 | |
| 70 | $currRepo = $this->repos[$repoName]; | |
| 71 | $this->git->setRepository( $currRepo['path'] ); | |
| 72 | $action = array_shift( $parts ) ?: 'tree'; | |
| 73 | $hash = ''; | |
| 74 | $path = ''; | |
| 75 | $baseHash = ''; | |
| 106 | $hasHash = [ | |
| 107 | self::ACTION_TREE, self::ACTION_BLOB, self::ACTION_RAW, | |
| 108 | self::ACTION_COMMITS | |
| 109 | ]; | |
| 76 | 110 | |
| 77 | if( in_array( $action, ['tree', 'blob', 'raw', 'commits'] ) ) { | |
| 78 | $hash = array_shift( $parts ) ?: 'HEAD'; | |
| 79 | $path = implode( '/', $parts ); | |
| 80 | } elseif( $action === 'commit' ) { | |
| 81 | $hash = array_shift( $parts ); | |
| 82 | } elseif( $action === 'compare' ) { | |
| 83 | $hash = array_shift( $parts ); | |
| 84 | $baseHash = array_shift( $parts ); | |
| 111 | if( in_array( $this->action, $hasHash ) ) { | |
| 112 | $hash = array_shift( $uriParts ); | |
| 113 | $this->commitHash = $hash ?: self::REFERENCE_HEAD; | |
| 114 | $this->filePath = implode( '/', $uriParts ); | |
| 115 | } elseif( $this->action === self::ACTION_COMMIT ) { | |
| 116 | $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | |
| 117 | } elseif( $this->action === self::ACTION_COMPARE ) { | |
| 118 | $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | |
| 119 | $this->baseHash = array_shift( $uriParts ) ?? ''; | |
| 85 | 120 | } |
| 86 | 121 | |
| 87 | $_GET['repo'] = $repoName; | |
| 88 | $_GET['action'] = $action; | |
| 89 | $_GET['hash'] = $hash; | |
| 90 | $_GET['name'] = $path; | |
| 122 | $this->populateGet(); | |
| 91 | 123 | |
| 92 | return match( $action ) { | |
| 93 | 'tree', 'blob' => new FilePage( | |
| 94 | $this->repos, $currRepo, $this->git, $hash, $path | |
| 124 | return $this->createPage(); | |
| 125 | } | |
| 126 | ||
| 127 | private function createPage(): Page { | |
| 128 | return match( $this->action ) { | |
| 129 | self::ACTION_TREE, | |
| 130 | self::ACTION_BLOB => new FilePage( | |
| 131 | $this->repos, $this->repoData, $this->git, $this->commitHash, | |
| 132 | $this->filePath | |
| 95 | 133 | ), |
| 96 | 'raw' => new RawPage( $this->git, $hash ), | |
| 97 | 'commits' => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ), | |
| 98 | 'commit' => new DiffPage( $this->repos, $currRepo, $this->git, $hash ), | |
| 99 | 'tags' => new TagsPage( $this->repos, $currRepo, $this->git ), | |
| 100 | 'compare' => new ComparePage( | |
| 101 | $this->repos, $currRepo, $this->git, $hash, $baseHash | |
| 134 | self::ACTION_RAW => new RawPage( | |
| 135 | $this->git, $this->commitHash | |
| 102 | 136 | ), |
| 103 | default => new FilePage( $this->repos, $currRepo, $this->git, 'HEAD', '' ) | |
| 137 | self::ACTION_COMMITS => new CommitsPage( | |
| 138 | $this->repos, $this->repoData, $this->git, $this->commitHash | |
| 139 | ), | |
| 140 | self::ACTION_COMMIT => new DiffPage( | |
| 141 | $this->repos, $this->repoData, $this->git, $this->commitHash | |
| 142 | ), | |
| 143 | self::ACTION_TAGS => new TagsPage( | |
| 144 | $this->repos, $this->repoData, $this->git | |
| 145 | ), | |
| 146 | self::ACTION_COMPARE => new ComparePage( | |
| 147 | $this->repos, $this->repoData, $this->git, $this->commitHash, | |
| 148 | $this->baseHash | |
| 149 | ), | |
| 150 | default => new FilePage( | |
| 151 | $this->repos, $this->repoData, $this->git, self::REFERENCE_HEAD, '' | |
| 152 | ) | |
| 104 | 153 | }; |
| 154 | } | |
| 155 | ||
| 156 | private function normalizeQueryString(): void { | |
| 157 | if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) { | |
| 158 | parse_str( $_SERVER['QUERY_STRING'], $_GET ); | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | private function parseUriParts(): array { | |
| 163 | $requestUri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); | |
| 164 | $scriptName = dirname( $_SERVER['SCRIPT_NAME'] ); | |
| 165 | ||
| 166 | if( $scriptName !== '/' && strpos( $requestUri, $scriptName ) === 0 ) { | |
| 167 | $requestUri = substr( $requestUri, strlen( $scriptName ) ); | |
| 168 | } | |
| 169 | ||
| 170 | $requestUri = trim( $requestUri, '/' ); | |
| 171 | $uriParts = explode( '/', $requestUri ); | |
| 172 | ||
| 173 | if( !empty( $uriParts ) && $uriParts[0] === self::ROUTE_REPO ) { | |
| 174 | array_shift( $uriParts ); | |
| 175 | } | |
| 176 | ||
| 177 | return $uriParts; | |
| 178 | } | |
| 179 | ||
| 180 | private function populateGet(): void { | |
| 181 | $_GET[self::GET_REPOSITORY] = $this->repoName; | |
| 182 | $_GET[self::GET_ACTION] = $this->action; | |
| 183 | $_GET[self::GET_HASH] = $this->commitHash; | |
| 184 | $_GET[self::GET_NAME] = $this->filePath; | |
| 105 | 185 | } |
| 106 | 186 | } |
| 18 | 18 | string $author |
| 19 | 19 | ) { |
| 20 | $this->name = $name; | |
| 21 | $this->sha = $sha; | |
| 20 | $this->name = $name; | |
| 21 | $this->sha = $sha; | |
| 22 | 22 | $this->targetSha = $targetSha; |
| 23 | 23 | $this->timestamp = $timestamp; |
| 24 | $this->message = $message; | |
| 25 | $this->author = $author; | |
| 24 | $this->message = $message; | |
| 25 | $this->author = $author; | |
| 26 | 26 | } |
| 27 | 27 | |
| ... | ||
| 42 | 42 | } |
| 43 | 43 | } |
| 44 | ||
| 45 | 44 | |
| 1 | 1 | <?php |
| 2 | 2 | class UrlBuilder { |
| 3 | private const REPO_PREFIX = '/repo/'; | |
| 4 | private const HEAD_REF = '/HEAD'; | |
| 5 | private const ACT_TREE = 'tree'; | |
| 6 | ||
| 3 | 7 | private $repo; |
| 4 | 8 | private $action; |
| 5 | 9 | private $hash; |
| 6 | 10 | private $name; |
| 7 | 11 | private $switcher; |
| 8 | 12 | |
| 9 | 13 | public function withRepo( $repo ) { |
| 10 | 14 | $this->repo = $repo; |
| 15 | ||
| 11 | 16 | return $this; |
| 12 | 17 | } |
| 13 | 18 | |
| 14 | 19 | public function withAction( $action ) { |
| 15 | 20 | $this->action = $action; |
| 21 | ||
| 16 | 22 | return $this; |
| 17 | 23 | } |
| 18 | 24 | |
| 19 | 25 | public function withHash( $hash ) { |
| 20 | 26 | $this->hash = $hash; |
| 27 | ||
| 21 | 28 | return $this; |
| 22 | 29 | } |
| 23 | 30 | |
| 24 | 31 | public function withName( $name ) { |
| 25 | 32 | $this->name = $name; |
| 33 | ||
| 26 | 34 | return $this; |
| 27 | 35 | } |
| 28 | 36 | |
| 29 | 37 | public function withSwitcher( $jsValue ) { |
| 30 | 38 | $this->switcher = $jsValue; |
| 39 | ||
| 31 | 40 | return $this; |
| 32 | 41 | } |
| 33 | 42 | |
| 34 | 43 | public function build() { |
| 35 | if( $this->switcher ) { | |
| 36 | return "window.location.href='/repo/' + " . $this->switcher; | |
| 37 | } | |
| 38 | ||
| 39 | if( !$this->repo ) { | |
| 40 | return '/'; | |
| 41 | } | |
| 42 | ||
| 43 | $url = '/repo/' . $this->repo; | |
| 44 | ||
| 45 | if( !$this->action && $this->name ) { | |
| 46 | $this->action = 'tree'; | |
| 47 | } | |
| 44 | return $this->switcher | |
| 45 | ? "window.location.href='" . self::REPO_PREFIX . "' + " . $this->switcher | |
| 46 | : ($this->repo ? $this->assembleUrl() : '/'); | |
| 47 | } | |
| 48 | 48 | |
| 49 | if( $this->action ) { | |
| 50 | $url .= '/' . $this->action; | |
| 49 | private function assembleUrl() { | |
| 50 | $url = self::REPO_PREFIX . $this->repo; | |
| 51 | $act = !$this->action && $this->name ? self::ACT_TREE : $this->action; | |
| 51 | 52 | |
| 52 | if( $this->hash ) { | |
| 53 | $url .= '/' . $this->hash; | |
| 54 | } elseif( in_array( $this->action, ['tree', 'blob', 'raw', 'commits'] ) ) { | |
| 55 | $url .= '/HEAD'; | |
| 56 | } | |
| 53 | if( $act ) { | |
| 54 | $url .= '/' . $act . $this->resolveHashSegment( $act ); | |
| 57 | 55 | } |
| 58 | 56 | |
| 59 | 57 | if( $this->name ) { |
| 60 | 58 | $url .= '/' . ltrim( $this->name, '/' ); |
| 61 | 59 | } |
| 62 | 60 | |
| 63 | 61 | return $url; |
| 62 | } | |
| 63 | ||
| 64 | private function resolveHashSegment( $act ) { | |
| 65 | return $this->hash | |
| 66 | ? '/' . $this->hash | |
| 67 | : (in_array( $act, ['tree', 'blob', 'raw', 'commits'] ) | |
| 68 | ? self::HEAD_REF | |
| 69 | : ''); | |
| 64 | 70 | } |
| 65 | 71 | } |
| 72 | ||
| 66 | 73 |
| 6 | 6 | |
| 7 | 7 | class Git { |
| 8 | private const MAX_READ_SIZE = 1048576; | |
| 9 | ||
| 10 | private string $repoPath; | |
| 11 | private string $objectsPath; | |
| 12 | ||
| 13 | private GitRefs $refs; | |
| 14 | private GitPacks $packs; | |
| 15 | ||
| 16 | public function __construct( string $repoPath ) { | |
| 17 | $this->setRepository( $repoPath ); | |
| 18 | } | |
| 19 | ||
| 20 | public function setRepository( string $repoPath ): void { | |
| 21 | $this->repoPath = rtrim( $repoPath, '/' ); | |
| 22 | $this->objectsPath = $this->repoPath . '/objects'; | |
| 23 | ||
| 24 | $this->refs = new GitRefs( $this->repoPath ); | |
| 25 | $this->packs = new GitPacks( $this->objectsPath ); | |
| 26 | } | |
| 27 | ||
| 28 | public function resolve( string $reference ): string { | |
| 29 | return $this->refs->resolve( $reference ); | |
| 30 | } | |
| 31 | ||
| 32 | public function getMainBranch(): array { | |
| 33 | return $this->refs->getMainBranch(); | |
| 34 | } | |
| 35 | ||
| 36 | public function eachBranch( callable $callback ): void { | |
| 37 | $this->refs->scanRefs( 'refs/heads', $callback ); | |
| 38 | } | |
| 39 | ||
| 40 | public function eachTag( callable $callback ): void { | |
| 41 | $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( | |
| 42 | $callback | |
| 43 | ) { | |
| 44 | $data = $this->read( $sha ); | |
| 45 | $tag = $this->parseTagData( $name, $sha, $data ); | |
| 46 | ||
| 47 | $callback( $tag ); | |
| 48 | } ); | |
| 49 | } | |
| 50 | ||
| 51 | public function walk( | |
| 52 | string $refOrSha, | |
| 53 | callable $callback, | |
| 54 | string $path = '' | |
| 55 | ): void { | |
| 56 | $sha = $this->resolve( $refOrSha ); | |
| 57 | $treeSha = ''; | |
| 58 | ||
| 59 | if( $sha !== '' ) { | |
| 60 | $treeSha = $this->getTreeSha( $sha ); | |
| 61 | } | |
| 62 | ||
| 63 | if( $path !== '' && $treeSha !== '' ) { | |
| 64 | $info = $this->resolvePath( $treeSha, $path ); | |
| 65 | $treeSha = $info['isDir'] ? $info['sha'] : ''; | |
| 66 | } | |
| 67 | ||
| 68 | if( $treeSha !== '' ) { | |
| 69 | $this->walkTree( $treeSha, $callback ); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | public function readFile( string $ref, string $path ): File { | |
| 74 | $sha = $this->resolve( $ref ); | |
| 75 | $tree = $sha !== '' ? $this->getTreeSha( $sha ) : ''; | |
| 76 | $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : []; | |
| 77 | $file = new MissingFile(); | |
| 78 | ||
| 79 | if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) { | |
| 80 | $file = new File( | |
| 81 | basename( $path ), | |
| 82 | $info['sha'], | |
| 83 | $info['mode'], | |
| 84 | 0, | |
| 85 | $this->getObjectSize( $info['sha'] ), | |
| 86 | $this->peek( $info['sha'] ) | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | return $file; | |
| 91 | } | |
| 92 | ||
| 93 | public function getObjectSize( string $sha, string $path = '' ): int { | |
| 94 | $target = $sha; | |
| 95 | ||
| 96 | if( $path !== '' ) { | |
| 97 | $info = $this->resolvePath( | |
| 98 | $this->getTreeSha( $this->resolve( $sha ) ), | |
| 99 | $path | |
| 100 | ); | |
| 101 | $target = $info['sha'] ?? ''; | |
| 102 | } | |
| 103 | ||
| 104 | return $target !== '' | |
| 105 | ? $this->packs->getSize( $target ) ?? $this->getLooseObjectSize( $target ) | |
| 106 | : 0; | |
| 107 | } | |
| 108 | ||
| 109 | public function stream( | |
| 110 | string $sha, | |
| 111 | callable $callback, | |
| 112 | string $path = '' | |
| 113 | ): void { | |
| 114 | $target = $sha; | |
| 115 | ||
| 116 | if( $path !== '' ) { | |
| 117 | $info = $this->resolvePath( | |
| 118 | $this->getTreeSha( $this->resolve( $sha ) ), | |
| 119 | $path | |
| 120 | ); | |
| 121 | $target = isset( $info['isDir'] ) && !$info['isDir'] | |
| 122 | ? $info['sha'] | |
| 123 | : ''; | |
| 124 | } | |
| 125 | ||
| 126 | if( $target !== '' ) { | |
| 127 | $this->slurp( $target, $callback ); | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | private function getTreeSha( string $commitOrTreeSha ): string { | |
| 132 | $data = $this->read( $commitOrTreeSha ); | |
| 133 | $sha = $commitOrTreeSha; | |
| 134 | ||
| 135 | if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) { | |
| 136 | $sha = $this->getTreeSha( $matches[1] ); | |
| 137 | } | |
| 138 | ||
| 139 | if( $sha === $commitOrTreeSha && | |
| 140 | preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { | |
| 141 | $sha = $matches[1]; | |
| 142 | } | |
| 143 | ||
| 144 | return $sha; | |
| 145 | } | |
| 146 | ||
| 147 | private function resolvePath( string $treeSha, string $path ): array { | |
| 148 | $parts = explode( '/', trim( $path, '/' ) ); | |
| 149 | $sha = $treeSha; | |
| 150 | $mode = '40000'; | |
| 151 | ||
| 152 | foreach( $parts as $part ) { | |
| 153 | $entry = $part !== '' && $sha !== '' | |
| 154 | ? $this->findTreeEntry( $sha, $part ) | |
| 155 | : [ 'sha' => '', 'mode' => '' ]; | |
| 156 | ||
| 157 | $sha = $entry['sha']; | |
| 158 | $mode = $entry['mode']; | |
| 159 | } | |
| 160 | ||
| 161 | return [ | |
| 162 | 'sha' => $sha, | |
| 163 | 'mode' => $mode, | |
| 164 | 'isDir' => $mode === '40000' || $mode === '040000' | |
| 165 | ]; | |
| 166 | } | |
| 167 | ||
| 168 | private function findTreeEntry( string $treeSha, string $name ): array { | |
| 169 | $data = $this->read( $treeSha ); | |
| 170 | $pos = 0; | |
| 171 | $len = strlen( $data ); | |
| 172 | $entry = [ 'sha' => '', 'mode' => '' ]; | |
| 173 | ||
| 174 | while( $pos < $len ) { | |
| 175 | $space = strpos( $data, ' ', $pos ); | |
| 176 | $eos = strpos( $data, "\0", $space ); | |
| 177 | ||
| 178 | if( $space === false || $eos === false ) { | |
| 179 | break; | |
| 180 | } | |
| 181 | ||
| 182 | if( substr( $data, $space + 1, $eos - $space - 1 ) === $name ) { | |
| 183 | $entry = [ | |
| 184 | 'sha' => bin2hex( substr( $data, $eos + 1, 20 ) ), | |
| 185 | 'mode' => substr( $data, $pos, $space - $pos ) | |
| 186 | ]; | |
| 187 | break; | |
| 188 | } | |
| 189 | ||
| 190 | $pos = $eos + 21; | |
| 191 | } | |
| 192 | ||
| 193 | return $entry; | |
| 194 | } | |
| 195 | ||
| 196 | private function parseTagData( | |
| 197 | string $name, | |
| 198 | string $sha, | |
| 199 | string $data | |
| 200 | ): Tag { | |
| 201 | $isAnn = strncmp( $data, 'object ', 7 ) === 0; | |
| 202 | $pattern = $isAnn | |
| 203 | ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m' | |
| 204 | : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'; | |
| 205 | $id = $this->parseIdentity( $data, $pattern ); | |
| 206 | $target = $isAnn | |
| 207 | ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha ) | |
| 208 | : $sha; | |
| 209 | ||
| 210 | return new Tag( | |
| 211 | $name, | |
| 212 | $sha, | |
| 213 | $target, | |
| 214 | $id['timestamp'], | |
| 215 | $this->extractMessage( $data ), | |
| 216 | $id['name'] | |
| 217 | ); | |
| 218 | } | |
| 219 | ||
| 220 | private function extractPattern( | |
| 221 | string $data, | |
| 222 | string $pattern, | |
| 223 | int $group, | |
| 224 | string $default = '' | |
| 225 | ): string { | |
| 226 | return preg_match( $pattern, $data, $matches ) | |
| 227 | ? $matches[$group] | |
| 228 | : $default; | |
| 229 | } | |
| 230 | ||
| 231 | private function parseIdentity( string $data, string $pattern ): array { | |
| 232 | $found = preg_match( $pattern, $data, $matches ); | |
| 233 | ||
| 234 | return [ | |
| 235 | 'name' => $found ? trim($matches[1]) : 'Unknown', | |
| 236 | 'email' => $found ? $matches[2] : '', | |
| 237 | 'timestamp' => $found ? (int)$matches[3] : 0 | |
| 238 | ]; | |
| 239 | } | |
| 240 | ||
| 241 | private function extractMessage( string $data ): string { | |
| 242 | $pos = strpos( $data, "\n\n" ); | |
| 243 | ||
| 244 | return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : ''; | |
| 245 | } | |
| 246 | ||
| 247 | public function peek( string $sha, int $length = 255 ): string { | |
| 248 | $size = $this->packs->getSize( $sha ); | |
| 249 | ||
| 250 | return $size === null | |
| 251 | ? $this->peekLooseObject( $sha, $length ) | |
| 252 | : $this->packs->peek( $sha, $length ) ?? ''; | |
| 253 | } | |
| 254 | ||
| 255 | public function read( string $sha ): string { | |
| 256 | $size = $this->getObjectSize( $sha ); | |
| 257 | $content = ''; | |
| 258 | ||
| 259 | if( $size > 0 && $size <= self::MAX_READ_SIZE ) { | |
| 260 | $this->slurp( $sha, function( $chunk ) use ( &$content ) { | |
| 261 | $content .= $chunk; | |
| 262 | } ); | |
| 263 | } | |
| 264 | ||
| 265 | return $content; | |
| 266 | } | |
| 267 | ||
| 268 | private function slurp( string $sha, callable $callback ): void { | |
| 269 | $path = $this->getLoosePath( $sha ); | |
| 270 | ||
| 271 | if( is_file($path) ) { | |
| 272 | $this->slurpLooseObject( $path, $callback ); | |
| 273 | } | |
| 274 | ||
| 275 | if( !is_file($path) ) { | |
| 276 | $this->slurpPackedObject( $sha, $callback ); | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | private function iterateInflated( string $path, callable $processor ): void { | |
| 281 | $this->withInflatedFile( | |
| 282 | $path, | |
| 283 | function( $handle, $inflator ) use ( $processor ) { | |
| 284 | $found = false; | |
| 285 | $buffer = ''; | |
| 286 | ||
| 287 | while( !feof($handle) ) { | |
| 288 | $inflated = inflate_add( $inflator, fread( $handle, 16384 ) ); | |
| 289 | ||
| 290 | if( $inflated === false ) { | |
| 291 | break; | |
| 292 | } | |
| 293 | ||
| 294 | if( !$found ) { | |
| 295 | $buffer .= $inflated; | |
| 296 | $eos = strpos( $buffer, "\0" ); | |
| 297 | ||
| 298 | if( $eos !== false ) { | |
| 299 | $found = true; | |
| 300 | $body = substr( $buffer, $eos + 1 ); | |
| 301 | $head = substr( $buffer, 0, $eos ); | |
| 302 | ||
| 303 | if( $processor( $body, $head ) === false ) { | |
| 304 | break; | |
| 305 | } | |
| 306 | } | |
| 307 | } elseif( $found ) { | |
| 308 | if( $processor( $inflated, null ) === false ) { | |
| 309 | break; | |
| 310 | } | |
| 311 | } | |
| 312 | } | |
| 313 | } | |
| 314 | ); | |
| 315 | } | |
| 316 | ||
| 317 | private function slurpLooseObject( | |
| 318 | string $path, | |
| 319 | callable $callback | |
| 320 | ): void { | |
| 321 | $this->iterateInflated( | |
| 322 | $path, | |
| 323 | function( $chunk ) use ( $callback ) { | |
| 324 | if( $chunk !== '' ) { | |
| 325 | $callback( $chunk ); | |
| 326 | } | |
| 327 | ||
| 328 | return true; | |
| 329 | } | |
| 330 | ); | |
| 331 | } | |
| 332 | ||
| 333 | private function withInflatedFile( string $path, callable $callback ): void { | |
| 334 | $handle = fopen( $path, 'rb' ); | |
| 335 | $infl = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; | |
| 336 | ||
| 337 | if( $handle && $infl ) { | |
| 338 | $callback( $handle, $infl ); | |
| 339 | fclose( $handle ); | |
| 340 | } | |
| 341 | } | |
| 342 | ||
| 343 | private function slurpPackedObject( string $sha, callable $callback ): void { | |
| 344 | $streamed = $this->packs->stream( $sha, $callback ); | |
| 345 | ||
| 346 | if( !$streamed ) { | |
| 347 | $data = $this->packs->read( $sha ); | |
| 348 | ||
| 349 | if( $data !== null && $data !== '' ) { | |
| 350 | $callback( $data ); | |
| 351 | } | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | private function peekLooseObject( string $sha, int $length ): string { | |
| 356 | $path = $this->getLoosePath( $sha ); | |
| 357 | ||
| 358 | return is_file($path) | |
| 359 | ? $this->inflateLooseObjectPrefix( $path, $length ) | |
| 360 | : ''; | |
| 361 | } | |
| 362 | ||
| 363 | private function inflateLooseObjectPrefix( | |
| 364 | string $path, | |
| 365 | int $length | |
| 366 | ): string { | |
| 367 | $buf = ''; | |
| 368 | ||
| 369 | $this->iterateInflated( | |
| 370 | $path, | |
| 371 | function( $chunk ) use ( $length, &$buf ) { | |
| 372 | $buf .= $chunk; | |
| 373 | ||
| 374 | return strlen($buf) < $length; | |
| 375 | } | |
| 376 | ); | |
| 377 | ||
| 378 | return substr( $buf, 0, $length ); | |
| 379 | } | |
| 380 | ||
| 381 | public function history( string $ref, int $limit, callable $callback ): void { | |
| 382 | $sha = $this->resolve( $ref ); | |
| 383 | $count = 0; | |
| 384 | ||
| 385 | while( $sha !== '' && $count < $limit ) { | |
| 386 | $commit = $this->parseCommit( $sha ); | |
| 387 | ||
| 388 | if( $commit->sha === '' ) { | |
| 389 | $sha = ''; | |
| 390 | } | |
| 391 | ||
| 392 | if( $sha !== '' ) { | |
| 393 | $callback( $commit ); | |
| 394 | $sha = $commit->parentSha; | |
| 395 | $count++; | |
| 396 | } | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | private function parseCommit( string $sha ): object { | |
| 401 | $data = $this->read( $sha ); | |
| 402 | ||
| 403 | return $data !== '' | |
| 404 | ? $this->buildCommitObject( $sha, $data ) | |
| 405 | : (object)[ 'sha' => '' ]; | |
| 406 | } | |
| 407 | ||
| 408 | private function buildCommitObject( string $sha, string $data ): object { | |
| 409 | $id = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' ); | |
| 410 | ||
| 411 | return (object)[ | |
| 412 | 'sha' => $sha, | |
| 413 | 'message' => $this->extractMessage( $data ), | |
| 414 | 'author' => $id['name'], | |
| 415 | 'email' => $id['email'], | |
| 416 | 'date' => $id['timestamp'], | |
| 417 | 'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 ) | |
| 418 | ]; | |
| 419 | } | |
| 420 | ||
| 421 | private function walkTree( string $sha, callable $callback ): void { | |
| 422 | $data = $this->read( $sha ); | |
| 423 | $tree = $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) | |
| 424 | ? $this->read($m[1]) | |
| 425 | : $data; | |
| 426 | ||
| 427 | if( $tree !== '' && $this->isTreeData($tree) ) { | |
| 428 | $this->processTree( $tree, $callback ); | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 432 | private function processTree( string $data, callable $callback ): void { | |
| 433 | $pos = 0; | |
| 434 | $len = strlen( $data ); | |
| 435 | ||
| 436 | while( $pos < $len ) { | |
| 437 | $entry = $this->parseTreeEntry( $data, $pos, $len ); | |
| 438 | ||
| 439 | if( $entry === null ) { | |
| 440 | break; | |
| 441 | } | |
| 442 | ||
| 443 | $callback( $entry['file'] ); | |
| 444 | $pos = $entry['nextPosition']; | |
| 445 | } | |
| 446 | } | |
| 447 | ||
| 448 | private function parseTreeEntry( | |
| 449 | string $data, | |
| 450 | int $pos, | |
| 451 | int $len | |
| 452 | ): ?array { | |
| 453 | $space = strpos( $data, ' ', $pos ); | |
| 454 | $eos = strpos( $data, "\0", $space ); | |
| 455 | ||
| 456 | return $space !== false && $eos !== false && $eos + 21 <= $len | |
| 457 | ? $this->buildTreeEntryResult( $data, $pos, $space, $eos ) | |
| 458 | : null; | |
| 459 | } | |
| 460 | ||
| 461 | private function buildTreeEntryResult( | |
| 462 | string $data, | |
| 463 | int $pos, | |
| 464 | int $space, | |
| 465 | int $eos | |
| 466 | ): array { | |
| 467 | $mode = substr( $data, $pos, $space - $pos ); | |
| 468 | $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); | |
| 469 | $isD = $mode === '40000' || $mode === '040000'; | |
| 470 | ||
| 471 | return [ | |
| 472 | 'file' => new File( | |
| 473 | substr( $data, $space + 1, $eos - $space - 1 ), | |
| 474 | $sha, | |
| 475 | $mode, | |
| 476 | 0, | |
| 477 | $isD ? 0 : $this->getObjectSize( $sha ), | |
| 478 | $isD ? '' : $this->peek( $sha ) | |
| 479 | ), | |
| 480 | 'nextPosition' => $eos + 21 | |
| 481 | ]; | |
| 482 | } | |
| 483 | ||
| 484 | private function isTreeData( string $data ): bool { | |
| 485 | $len = strlen( $data ); | |
| 486 | $patt = '/^(40000|100644|100755|120000|160000) /'; | |
| 487 | $match = $len >= 25 && preg_match( $patt, $data ); | |
| 488 | $eos = $match ? strpos( $data, "\0" ) : false; | |
| 489 | ||
| 490 | return $match && $eos !== false && $eos + 21 <= $len; | |
| 491 | } | |
| 492 | ||
| 493 | private function getLoosePath( string $sha ): string { | |
| 494 | return "{$this->objectsPath}/" . | |
| 495 | substr( $sha, 0, 2 ) . "/" . | |
| 496 | substr( $sha, 2 ); | |
| 497 | } | |
| 498 | ||
| 499 | private function getLooseObjectSize( string $sha ): int { | |
| 500 | $path = $this->getLoosePath( $sha ); | |
| 501 | ||
| 502 | return is_file($path) ? $this->readLooseObjectHeader($path) : 0; | |
| 503 | } | |
| 504 | ||
| 505 | private function readLooseObjectHeader( string $path ): int { | |
| 506 | $size = 0; | |
| 507 | ||
| 508 | $this->iterateInflated( $path, function( $chunk, $header ) use ( &$size ) { | |
| 509 | if( $header !== null ) { | |
| 510 | $parts = explode( ' ', $header ); | |
| 511 | $size = isset( $parts[1] ) ? (int)$parts[1] : 0; | |
| 512 | } | |
| 513 | ||
| 514 | return false; | |
| 515 | } ); | |
| 516 | ||
| 517 | return $size; | |
| 518 | } | |
| 519 | ||
| 520 | public function streamRaw( string $subPath ): bool { | |
| 521 | return strpos($subPath, '..') === false | |
| 522 | ? $this->streamRawFile( $subPath ) | |
| 523 | : false; | |
| 524 | } | |
| 525 | ||
| 526 | private function streamRawFile( string $subPath ): bool { | |
| 527 | $path = "{$this->repoPath}/$subPath"; | |
| 528 | ||
| 529 | return is_file($path) ? $this->streamIfPathValid($path) : false; | |
| 530 | } | |
| 531 | ||
| 532 | private function streamIfPathValid( string $fullPath ): bool { | |
| 533 | $real = realpath( $fullPath ); | |
| 534 | $repo = realpath( $this->repoPath ); | |
| 535 | $isValid = $real && strpos($real, $repo) === 0; | |
| 536 | ||
| 537 | return $isValid ? readfile($fullPath) !== false : false; | |
| 538 | } | |
| 539 | ||
| 540 | public function eachRef( callable $callback ): void { | |
| 541 | $head = $this->resolve( 'HEAD' ); | |
| 542 | ||
| 543 | if( $head !== '' ) { | |
| 544 | $callback( 'HEAD', $head ); | |
| 545 | } | |
| 546 | ||
| 547 | $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) { | |
| 548 | $callback( "refs/heads/$n", $s ); | |
| 549 | } ); | |
| 550 | ||
| 551 | $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) { | |
| 552 | $callback( "refs/tags/$n", $s ); | |
| 553 | } ); | |
| 554 | } | |
| 555 | ||
| 556 | public function collectObjects( array $wants, array $haves = [] ): array { | |
| 557 | $objs = []; | |
| 558 | $seen = []; | |
| 559 | ||
| 560 | foreach( $wants as $sha ) { | |
| 561 | $this->collectObjectsRecursive( $sha, $objs, $seen ); | |
| 562 | } | |
| 563 | ||
| 564 | foreach( $haves as $sha ) { | |
| 565 | unset($objs[$sha]); | |
| 566 | } | |
| 567 | ||
| 568 | return $objs; | |
| 569 | } | |
| 570 | ||
| 571 | private function collectObjectsRecursive( | |
| 572 | string $sha, | |
| 573 | array &$objs, | |
| 574 | array &$seen | |
| 575 | ): void { | |
| 576 | if( !isset( $seen[$sha] ) ) { | |
| 577 | $seen[$sha] = true; | |
| 578 | $data = $this->read( $sha ); | |
| 579 | $type = $this->getObjectType( $data ); | |
| 580 | $objs[$sha] = [ 'type' => $type, 'size' => strlen($data) ]; | |
| 581 | ||
| 582 | if( $type === 1 ) { | |
| 583 | $this->collectCommitLinks( $data, $objs, $seen ); | |
| 584 | } | |
| 585 | ||
| 586 | if( $type === 2 ) { | |
| 587 | $this->collectTreeLinks( $data, $objs, $seen ); | |
| 588 | } | |
| 589 | ||
| 590 | if( $type === 4 && preg_match( '/^object (.*)$/m', $data, $m ) ) { | |
| 591 | $this->collectObjectsRecursive( $m[1], $objs, $seen ); | |
| 592 | } | |
| 593 | } | |
| 594 | } | |
| 595 | ||
| 596 | private function collectCommitLinks( $data, &$objs, &$seen ): void { | |
| 597 | if( preg_match( '/^tree (.*)$/m', $data, $m ) ) { | |
| 598 | $this->collectObjectsRecursive( $m[1], $objs, $seen ); | |
| 599 | } | |
| 600 | ||
| 601 | if( preg_match( '/^parent (.*)$/m', $data, $m ) ) { | |
| 602 | $this->collectObjectsRecursive( $m[1], $objs, $seen ); | |
| 603 | } | |
| 604 | } | |
| 605 | ||
| 606 | private function collectTreeLinks( $data, &$objs, &$seen ): void { | |
| 607 | $pos = 0; | |
| 608 | $len = strlen( $data ); | |
| 609 | ||
| 610 | while( $pos < $len ) { | |
| 611 | $space = strpos( $data, ' ', $pos ); | |
| 612 | $eos = strpos( $data, "\0", $space ); | |
| 613 | ||
| 614 | if( $space === false || $eos === false ) { | |
| 615 | break; | |
| 616 | } | |
| 617 | ||
| 618 | $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); | |
| 619 | $this->collectObjectsRecursive( $sha, $objs, $seen ); | |
| 620 | $pos = $eos + 21; | |
| 621 | } | |
| 622 | } | |
| 623 | ||
| 624 | private function getObjectType( string $data ): int { | |
| 625 | $isTree = strpos($data, "tree ") === 0; | |
| 626 | $isObj = strpos($data, "object ") === 0; | |
| 627 | ||
| 628 | return $isTree | |
| 629 | ? 1 | |
| 630 | : ( $this->isTreeData($data) ? 2 : ( $isObj ? 4 : 3 ) ); | |
| 631 | } | |
| 632 | ||
| 633 | public function generatePackfile( array $objs ): string { | |
| 634 | $pData = ''; | |
| 635 | ||
| 636 | if( empty($objs) ) { | |
| 637 | $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', 0 ); | |
| 638 | } | |
| 639 | ||
| 640 | if( !empty($objs) ) { | |
| 641 | $data = ''; | |
| 642 | ||
| 643 | foreach( $objs as $sha => $info ) { | |
| 644 | $cont = $this->read( $sha ); | |
| 645 | $size = strlen($cont); | |
| 646 | $byte = $info['type'] << 4 | $size & 0x0f; | |
| 647 | $size >>= 4; | |
| 648 | ||
| 649 | while( $size > 0 ) { | |
| 650 | $data .= chr( $byte | 0x80 ); | |
| 651 | $byte = $size & 0x7f; | |
| 652 | $size >>= 7; | |
| 653 | } | |
| 654 | ||
| 655 | $data .= chr( $byte ) . gzcompress( $cont ); | |
| 656 | } | |
| 657 | ||
| 658 | $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', count($objs) ) . $data; | |
| 659 | } | |
| 660 | ||
| 661 | return $pData . hash( 'sha1', $pData, true ); | |
| 8 | private const MAX_READ = 1048576; | |
| 9 | ||
| 10 | private string $repoPath; | |
| 11 | private string $objPath; | |
| 12 | private GitRefs $refs; | |
| 13 | private GitPacks $packs; | |
| 14 | ||
| 15 | public function __construct( string $repoPath ) { | |
| 16 | $this->setRepository( $repoPath ); | |
| 17 | } | |
| 18 | ||
| 19 | public function setRepository( string $repoPath ): void { | |
| 20 | $this->repoPath = rtrim( $repoPath, '/' ); | |
| 21 | $this->objPath = $this->repoPath . '/objects'; | |
| 22 | $this->refs = new GitRefs( $this->repoPath ); | |
| 23 | $this->packs = new GitPacks( $this->objPath ); | |
| 24 | } | |
| 25 | ||
| 26 | public function resolve( string $reference ): string { | |
| 27 | return $this->refs->resolve( $reference ); | |
| 28 | } | |
| 29 | ||
| 30 | public function getMainBranch(): array { | |
| 31 | return $this->refs->getMainBranch(); | |
| 32 | } | |
| 33 | ||
| 34 | public function eachBranch( callable $callback ): void { | |
| 35 | $this->refs->scanRefs( 'refs/heads', $callback ); | |
| 36 | } | |
| 37 | ||
| 38 | public function eachTag( callable $callback ): void { | |
| 39 | $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( | |
| 40 | $callback | |
| 41 | ) { | |
| 42 | $data = $this->read( $sha ); | |
| 43 | $tag = $this->parseTagData( $name, $sha, $data ); | |
| 44 | ||
| 45 | $callback( $tag ); | |
| 46 | } ); | |
| 47 | } | |
| 48 | ||
| 49 | public function walk( | |
| 50 | string $refOrSha, | |
| 51 | callable $callback, | |
| 52 | string $path = '' | |
| 53 | ): void { | |
| 54 | $sha = $this->resolve( $refOrSha ); | |
| 55 | $treeSha = ''; | |
| 56 | ||
| 57 | if( $sha !== '' ) { | |
| 58 | $treeSha = $this->getTreeSha( $sha ); | |
| 59 | } | |
| 60 | ||
| 61 | if( $path !== '' && $treeSha !== '' ) { | |
| 62 | $info = $this->resolvePath( $treeSha, $path ); | |
| 63 | $treeSha = $info['isDir'] ? $info['sha'] : ''; | |
| 64 | } | |
| 65 | ||
| 66 | if( $treeSha !== '' ) { | |
| 67 | $this->walkTree( $treeSha, $callback ); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | public function readFile( string $ref, string $path ): File { | |
| 72 | $sha = $this->resolve( $ref ); | |
| 73 | $tree = $sha !== '' ? $this->getTreeSha( $sha ) : ''; | |
| 74 | $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : []; | |
| 75 | $file = new MissingFile(); | |
| 76 | ||
| 77 | if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) { | |
| 78 | $file = new File( | |
| 79 | basename( $path ), | |
| 80 | $info['sha'], | |
| 81 | $info['mode'], | |
| 82 | 0, | |
| 83 | $this->getObjectSize( $info['sha'] ), | |
| 84 | $this->peek( $info['sha'] ) | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | return $file; | |
| 89 | } | |
| 90 | ||
| 91 | public function getObjectSize( string $sha, string $path = '' ): int { | |
| 92 | $target = $sha; | |
| 93 | $result = 0; | |
| 94 | ||
| 95 | if( $path !== '' ) { | |
| 96 | $info = $this->resolvePath( | |
| 97 | $this->getTreeSha( $this->resolve( $sha ) ), | |
| 98 | $path | |
| 99 | ); | |
| 100 | $target = $info['sha'] ?? ''; | |
| 101 | } | |
| 102 | ||
| 103 | if( $target !== '' ) { | |
| 104 | $result = $this->packs->getSize( $target ); | |
| 105 | ||
| 106 | if( $result === 0 ) { | |
| 107 | $result = $this->getLooseObjectSize( $target ); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | return $result; | |
| 112 | } | |
| 113 | ||
| 114 | public function stream( | |
| 115 | string $sha, | |
| 116 | callable $callback, | |
| 117 | string $path = '' | |
| 118 | ): void { | |
| 119 | $target = $sha; | |
| 120 | ||
| 121 | if( $path !== '' ) { | |
| 122 | $info = $this->resolvePath( | |
| 123 | $this->getTreeSha( $this->resolve( $sha ) ), | |
| 124 | $path | |
| 125 | ); | |
| 126 | $target = isset( $info['isDir'] ) && !$info['isDir'] | |
| 127 | ? $info['sha'] | |
| 128 | : ''; | |
| 129 | } | |
| 130 | ||
| 131 | if( $target !== '' ) { | |
| 132 | $this->slurp( $target, $callback ); | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | public function peek( string $sha, int $length = 255 ): string { | |
| 137 | $size = $this->packs->getSize( $sha ); | |
| 138 | ||
| 139 | return $size === 0 | |
| 140 | ? $this->peekLooseObject( $sha, $length ) | |
| 141 | : $this->packs->peek( $sha, $length ); | |
| 142 | } | |
| 143 | ||
| 144 | public function read( string $sha ): string { | |
| 145 | $size = $this->getObjectSize( $sha ); | |
| 146 | $content = ''; | |
| 147 | ||
| 148 | if( $size > 0 && $size <= self::MAX_READ ) { | |
| 149 | $this->slurp( $sha, function( $chunk ) use ( &$content ) { | |
| 150 | $content .= $chunk; | |
| 151 | } ); | |
| 152 | } | |
| 153 | ||
| 154 | return $content; | |
| 155 | } | |
| 156 | ||
| 157 | public function history( | |
| 158 | string $ref, | |
| 159 | int $limit, | |
| 160 | callable $callback | |
| 161 | ): void { | |
| 162 | $sha = $this->resolve( $ref ); | |
| 163 | $count = 0; | |
| 164 | ||
| 165 | while( $sha !== '' && $count < $limit ) { | |
| 166 | $commit = $this->parseCommit( $sha ); | |
| 167 | ||
| 168 | if( $commit->sha === '' ) { | |
| 169 | $sha = ''; | |
| 170 | } | |
| 171 | ||
| 172 | if( $sha !== '' ) { | |
| 173 | $callback( $commit ); | |
| 174 | $sha = $commit->parentSha; | |
| 175 | $count++; | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | public function streamRaw( string $subPath ): bool { | |
| 181 | $result = false; | |
| 182 | ||
| 183 | if( strpos( $subPath, '..' ) === false ) { | |
| 184 | $path = "{$this->repoPath}/$subPath"; | |
| 185 | ||
| 186 | if( is_file( $path ) ) { | |
| 187 | $real = realpath( $path ); | |
| 188 | $repo = realpath( $this->repoPath ); | |
| 189 | ||
| 190 | if( $real && strpos( $real, $repo ) === 0 ) { | |
| 191 | $result = readfile( $path ) !== false; | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | return $result; | |
| 197 | } | |
| 198 | ||
| 199 | public function eachRef( callable $callback ): void { | |
| 200 | $head = $this->resolve( 'HEAD' ); | |
| 201 | ||
| 202 | if( $head !== '' ) { | |
| 203 | $callback( 'HEAD', $head ); | |
| 204 | } | |
| 205 | ||
| 206 | $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) { | |
| 207 | $callback( "refs/heads/$n", $s ); | |
| 208 | } ); | |
| 209 | ||
| 210 | $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) { | |
| 211 | $callback( "refs/tags/$n", $s ); | |
| 212 | } ); | |
| 213 | } | |
| 214 | ||
| 215 | public function generatePackfile( array $objs ): string { | |
| 216 | $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', 0 ); | |
| 217 | ||
| 218 | if( !empty( $objs ) ) { | |
| 219 | $data = ''; | |
| 220 | ||
| 221 | foreach( $objs as $sha => $info ) { | |
| 222 | $cont = $this->read( $sha ); | |
| 223 | $size = strlen( $cont ); | |
| 224 | $byte = $info['type'] << 4 | $size & 0x0f; | |
| 225 | $size >>= 4; | |
| 226 | ||
| 227 | while( $size > 0 ) { | |
| 228 | $data .= chr( $byte | 0x80 ); | |
| 229 | $byte = $size & 0x7f; | |
| 230 | $size >>= 7; | |
| 231 | } | |
| 232 | ||
| 233 | $data .= chr( $byte ) . gzcompress( $cont ); | |
| 234 | } | |
| 235 | ||
| 236 | $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', count( $objs ) ) . $data; | |
| 237 | } | |
| 238 | ||
| 239 | return $pData . hash( 'sha1', $pData, true ); | |
| 240 | } | |
| 241 | ||
| 242 | private function getTreeSha( string $commitOrTreeSha ): string { | |
| 243 | $data = $this->read( $commitOrTreeSha ); | |
| 244 | $sha = $commitOrTreeSha; | |
| 245 | ||
| 246 | if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) { | |
| 247 | $sha = $this->getTreeSha( $matches[1] ); | |
| 248 | } | |
| 249 | ||
| 250 | if( $sha === $commitOrTreeSha && | |
| 251 | preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { | |
| 252 | $sha = $matches[1]; | |
| 253 | } | |
| 254 | ||
| 255 | return $sha; | |
| 256 | } | |
| 257 | ||
| 258 | private function resolvePath( string $treeSha, string $path ): array { | |
| 259 | $parts = explode( '/', trim( $path, '/' ) ); | |
| 260 | $sha = $treeSha; | |
| 261 | $mode = '40000'; | |
| 262 | ||
| 263 | foreach( $parts as $part ) { | |
| 264 | $entry = [ 'sha' => '', 'mode' => '' ]; | |
| 265 | ||
| 266 | if( $part !== '' && $sha !== '' ) { | |
| 267 | $entry = $this->findTreeEntry( $sha, $part ); | |
| 268 | } | |
| 269 | ||
| 270 | $sha = $entry['sha']; | |
| 271 | $mode = $entry['mode']; | |
| 272 | } | |
| 273 | ||
| 274 | return [ | |
| 275 | 'sha' => $sha, | |
| 276 | 'mode' => $mode, | |
| 277 | 'isDir' => $mode === '40000' || $mode === '040000' | |
| 278 | ]; | |
| 279 | } | |
| 280 | ||
| 281 | private function findTreeEntry( string $treeSha, string $name ): array { | |
| 282 | $data = $this->read( $treeSha ); | |
| 283 | $pos = 0; | |
| 284 | $len = strlen( $data ); | |
| 285 | $entry = [ 'sha' => '', 'mode' => '' ]; | |
| 286 | ||
| 287 | while( $pos < $len ) { | |
| 288 | $space = strpos( $data, ' ', $pos ); | |
| 289 | $eos = strpos( $data, "\0", $space ); | |
| 290 | ||
| 291 | if( $space === false || $eos === false ) { | |
| 292 | break; | |
| 293 | } | |
| 294 | ||
| 295 | if( substr( $data, $space + 1, $eos - $space - 1 ) === $name ) { | |
| 296 | $entry = [ | |
| 297 | 'sha' => bin2hex( substr( $data, $eos + 1, 20 ) ), | |
| 298 | 'mode' => substr( $data, $pos, $space - $pos ) | |
| 299 | ]; | |
| 300 | break; | |
| 301 | } | |
| 302 | ||
| 303 | $pos = $eos + 21; | |
| 304 | } | |
| 305 | ||
| 306 | return $entry; | |
| 307 | } | |
| 308 | ||
| 309 | private function parseTagData( | |
| 310 | string $name, | |
| 311 | string $sha, | |
| 312 | string $data | |
| 313 | ): Tag { | |
| 314 | $isAnn = strncmp( $data, 'object ', 7 ) === 0; | |
| 315 | $pattern = $isAnn | |
| 316 | ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m' | |
| 317 | : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'; | |
| 318 | $id = $this->parseIdentity( $data, $pattern ); | |
| 319 | $target = $isAnn | |
| 320 | ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha ) | |
| 321 | : $sha; | |
| 322 | ||
| 323 | return new Tag( | |
| 324 | $name, | |
| 325 | $sha, | |
| 326 | $target, | |
| 327 | $id['timestamp'], | |
| 328 | $this->extractMessage( $data ), | |
| 329 | $id['name'] | |
| 330 | ); | |
| 331 | } | |
| 332 | ||
| 333 | private function extractPattern( | |
| 334 | string $data, | |
| 335 | string $pattern, | |
| 336 | int $group, | |
| 337 | string $default = '' | |
| 338 | ): string { | |
| 339 | return preg_match( $pattern, $data, $matches ) | |
| 340 | ? $matches[$group] | |
| 341 | : $default; | |
| 342 | } | |
| 343 | ||
| 344 | private function parseIdentity( string $data, string $pattern ): array { | |
| 345 | $found = preg_match( $pattern, $data, $matches ); | |
| 346 | ||
| 347 | return [ | |
| 348 | 'name' => $found ? trim( $matches[1] ) : 'Unknown', | |
| 349 | 'email' => $found ? $matches[2] : '', | |
| 350 | 'timestamp' => $found ? (int)$matches[3] : 0 | |
| 351 | ]; | |
| 352 | } | |
| 353 | ||
| 354 | private function extractMessage( string $data ): string { | |
| 355 | $pos = strpos( $data, "\n\n" ); | |
| 356 | ||
| 357 | return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : ''; | |
| 358 | } | |
| 359 | ||
| 360 | private function slurp( string $sha, callable $callback ): void { | |
| 361 | $path = $this->getLoosePath( $sha ); | |
| 362 | ||
| 363 | if( is_file( $path ) ) { | |
| 364 | $this->slurpLooseObject( $path, $callback ); | |
| 365 | } else { | |
| 366 | $this->slurpPackedObject( $sha, $callback ); | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | private function slurpLooseObject( string $path, callable $callback ): void { | |
| 371 | $this->iterateInflated( | |
| 372 | $path, | |
| 373 | function( $chunk ) use ( $callback ) { | |
| 374 | if( $chunk !== '' ) { | |
| 375 | $callback( $chunk ); | |
| 376 | } | |
| 377 | return true; | |
| 378 | } | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | private function slurpPackedObject( string $sha, callable $callback ): void { | |
| 383 | $streamed = $this->packs->stream( $sha, $callback ); | |
| 384 | ||
| 385 | if( !$streamed ) { | |
| 386 | $data = $this->packs->read( $sha ); | |
| 387 | ||
| 388 | if( $data !== '' ) { | |
| 389 | $callback( $data ); | |
| 390 | } | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | private function iterateInflated( | |
| 395 | string $path, | |
| 396 | callable $processor | |
| 397 | ): void { | |
| 398 | $handle = fopen( $path, 'rb' ); | |
| 399 | $infl = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; | |
| 400 | $found = false; | |
| 401 | $buffer = ''; | |
| 402 | ||
| 403 | if( $handle && $infl ) { | |
| 404 | while( !feof( $handle ) ) { | |
| 405 | $chunk = fread( $handle, 16384 ); | |
| 406 | $inflated = inflate_add( $infl, $chunk ); | |
| 407 | ||
| 408 | if( $inflated === false ) { | |
| 409 | break; | |
| 410 | } | |
| 411 | ||
| 412 | if( !$found ) { | |
| 413 | $buffer .= $inflated; | |
| 414 | $eos = strpos( $buffer, "\0" ); | |
| 415 | ||
| 416 | if( $eos !== false ) { | |
| 417 | $found = true; | |
| 418 | $body = substr( $buffer, $eos + 1 ); | |
| 419 | $head = substr( $buffer, 0, $eos ); | |
| 420 | ||
| 421 | if( $processor( $body, $head ) === false ) { | |
| 422 | break; | |
| 423 | } | |
| 424 | } | |
| 425 | } elseif( $processor( $inflated, null ) === false ) { | |
| 426 | break; | |
| 427 | } | |
| 428 | } | |
| 429 | ||
| 430 | fclose( $handle ); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | private function peekLooseObject( string $sha, int $length ): string { | |
| 435 | $path = $this->getLoosePath( $sha ); | |
| 436 | $buf = ''; | |
| 437 | ||
| 438 | if( is_file( $path ) ) { | |
| 439 | $this->iterateInflated( | |
| 440 | $path, | |
| 441 | function( $chunk ) use ( $length, &$buf ) { | |
| 442 | $buf .= $chunk; | |
| 443 | return strlen( $buf ) < $length; | |
| 444 | } | |
| 445 | ); | |
| 446 | } | |
| 447 | ||
| 448 | return substr( $buf, 0, $length ); | |
| 449 | } | |
| 450 | ||
| 451 | private function parseCommit( string $sha ): object { | |
| 452 | $data = $this->read( $sha ); | |
| 453 | $result = (object)[ 'sha' => '' ]; | |
| 454 | ||
| 455 | if( $data !== '' ) { | |
| 456 | $id = $this->parseIdentity( | |
| 457 | $data, | |
| 458 | '/^author (.*) <(.*)> (\d+)/m' | |
| 459 | ); | |
| 460 | ||
| 461 | $result = (object)[ | |
| 462 | 'sha' => $sha, | |
| 463 | 'message' => $this->extractMessage( $data ), | |
| 464 | 'author' => $id['name'], | |
| 465 | 'email' => $id['email'], | |
| 466 | 'date' => $id['timestamp'], | |
| 467 | 'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 ) | |
| 468 | ]; | |
| 469 | } | |
| 470 | ||
| 471 | return $result; | |
| 472 | } | |
| 473 | ||
| 474 | private function walkTree( string $sha, callable $callback ): void { | |
| 475 | $data = $this->read( $sha ); | |
| 476 | $tree = $data; | |
| 477 | ||
| 478 | if( $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) ) { | |
| 479 | $tree = $this->read( $m[1] ); | |
| 480 | } | |
| 481 | ||
| 482 | if( $tree !== '' && $this->isTreeData( $tree ) ) { | |
| 483 | $this->processTree( $tree, $callback ); | |
| 484 | } | |
| 485 | } | |
| 486 | ||
| 487 | private function processTree( string $data, callable $callback ): void { | |
| 488 | $pos = 0; | |
| 489 | $len = strlen( $data ); | |
| 490 | ||
| 491 | while( $pos < $len ) { | |
| 492 | $space = strpos( $data, ' ', $pos ); | |
| 493 | $eos = strpos( $data, "\0", $space ); | |
| 494 | $entry = null; | |
| 495 | ||
| 496 | if( $space !== false && $eos !== false && $eos + 21 <= $len ) { | |
| 497 | $mode = substr( $data, $pos, $space - $pos ); | |
| 498 | $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); | |
| 499 | $isD = $mode === '40000' || $mode === '040000'; | |
| 500 | ||
| 501 | $entry = [ | |
| 502 | 'file' => new File( | |
| 503 | substr( $data, $space + 1, $eos - $space - 1 ), | |
| 504 | $sha, | |
| 505 | $mode, | |
| 506 | 0, | |
| 507 | $isD ? 0 : $this->getObjectSize( $sha ), | |
| 508 | $isD ? '' : $this->peek( $sha ) | |
| 509 | ), | |
| 510 | 'nextPosition' => $eos + 21 | |
| 511 | ]; | |
| 512 | } | |
| 513 | ||
| 514 | if( $entry === null ) { | |
| 515 | break; | |
| 516 | } | |
| 517 | ||
| 518 | $callback( $entry['file'] ); | |
| 519 | $pos = $entry['nextPosition']; | |
| 520 | } | |
| 521 | } | |
| 522 | ||
| 523 | private function isTreeData( string $data ): bool { | |
| 524 | $len = strlen( $data ); | |
| 525 | $patt = '/^(40000|100644|100755|120000|160000) /'; | |
| 526 | $match = $len >= 25 && preg_match( $patt, $data ); | |
| 527 | $eos = $match ? strpos( $data, "\0" ) : false; | |
| 528 | ||
| 529 | return $match && $eos !== false && $eos + 21 <= $len; | |
| 530 | } | |
| 531 | ||
| 532 | private function getLoosePath( string $sha ): string { | |
| 533 | return "{$this->objPath}/" . substr( $sha, 0, 2 ) . "/" . | |
| 534 | substr( $sha, 2 ); | |
| 535 | } | |
| 536 | ||
| 537 | private function getLooseObjectSize( string $sha ): int { | |
| 538 | $path = $this->getLoosePath( $sha ); | |
| 539 | $size = 0; | |
| 540 | ||
| 541 | if( is_file( $path ) ) { | |
| 542 | $this->iterateInflated( | |
| 543 | $path, | |
| 544 | function( $c, $head ) use ( &$size ) { | |
| 545 | if( $head !== null ) { | |
| 546 | $parts = explode( ' ', $head ); | |
| 547 | $size = isset( $parts[1] ) ? (int)$parts[1] : 0; | |
| 548 | } | |
| 549 | return false; | |
| 550 | } | |
| 551 | ); | |
| 552 | } | |
| 553 | ||
| 554 | return $size; | |
| 555 | } | |
| 556 | ||
| 557 | public function collectObjects( array $wants, array $haves = [] ): array { | |
| 558 | $objs = []; | |
| 559 | $result = []; | |
| 560 | ||
| 561 | foreach( $wants as $sha ) { | |
| 562 | $objs = $this->collectObjectsRecursive( $sha, $objs, 0 ); | |
| 563 | } | |
| 564 | ||
| 565 | foreach( $haves as $sha ) { | |
| 566 | if( isset( $objs[$sha] ) ) { | |
| 567 | unset( $objs[$sha] ); | |
| 568 | } | |
| 569 | } | |
| 570 | ||
| 571 | $result = $objs; | |
| 572 | ||
| 573 | return $result; | |
| 574 | } | |
| 575 | ||
| 576 | private function collectObjectsRecursive( | |
| 577 | string $sha, | |
| 578 | array $objs, | |
| 579 | int $expectedType = 0 | |
| 580 | ): array { | |
| 581 | $result = $objs; | |
| 582 | ||
| 583 | if( !isset( $result[$sha] ) ) { | |
| 584 | $data = $this->read( $sha ); | |
| 585 | $type = $expectedType === 0 | |
| 586 | ? $this->getObjectType( $data ) | |
| 587 | : $expectedType; | |
| 588 | ||
| 589 | $result[$sha] = [ | |
| 590 | 'type' => $type, | |
| 591 | 'size' => strlen( $data ) | |
| 592 | ]; | |
| 593 | ||
| 594 | if( $type === 1 ) { | |
| 595 | $hasTree = preg_match( '/^tree ([0-9a-f]{40})/m', $data, $m ); | |
| 596 | ||
| 597 | if( $hasTree ) { | |
| 598 | $result = $this->collectObjectsRecursive( $m[1], $result, 2 ); | |
| 599 | } | |
| 600 | ||
| 601 | $hasParents = preg_match_all( | |
| 602 | '/^parent ([0-9a-f]{40})/m', | |
| 603 | $data, | |
| 604 | $m | |
| 605 | ); | |
| 606 | ||
| 607 | if( $hasParents ) { | |
| 608 | foreach( $m[1] as $parentSha ) { | |
| 609 | $result = $this->collectObjectsRecursive( | |
| 610 | $parentSha, | |
| 611 | $result, | |
| 612 | 1 | |
| 613 | ); | |
| 614 | } | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | if( $type === 2 ) { | |
| 619 | $pos = 0; | |
| 620 | $len = strlen( $data ); | |
| 621 | ||
| 622 | while( $pos < $len ) { | |
| 623 | $sp = strpos( $data, ' ', $pos ); | |
| 624 | $eos = strpos( $data, "\0", $sp ); | |
| 625 | ||
| 626 | if( $sp === false || $eos === false ) { | |
| 627 | break; | |
| 628 | } | |
| 629 | ||
| 630 | $mode = substr( $data, $pos, $sp - $pos ); | |
| 631 | $s = bin2hex( substr( $data, $eos + 1, 20 ) ); | |
| 632 | $isDir = $mode === '40000' || $mode === '040000'; | |
| 633 | $nextType = $isDir ? 2 : 3; | |
| 634 | $pos = $eos + 21; | |
| 635 | ||
| 636 | $result = $this->collectObjectsRecursive( | |
| 637 | $s, | |
| 638 | $result, | |
| 639 | $nextType | |
| 640 | ); | |
| 641 | } | |
| 642 | } | |
| 643 | ||
| 644 | $isTagTarget = $type === 4 && | |
| 645 | preg_match( '/^object ([0-9a-f]{40})/m', $data, $m ); | |
| 646 | ||
| 647 | if( $isTagTarget ) { | |
| 648 | $nextType = 1; | |
| 649 | ||
| 650 | if( preg_match( '/^type (commit|tree|blob|tag)/m', $data, $t ) ) { | |
| 651 | $map = [ | |
| 652 | 'commit' => 1, | |
| 653 | 'tree' => 2, | |
| 654 | 'blob' => 3, | |
| 655 | 'tag' => 4 | |
| 656 | ]; | |
| 657 | ||
| 658 | $nextType = $map[$t[1]] ?? 1; | |
| 659 | } | |
| 660 | ||
| 661 | $result = $this->collectObjectsRecursive( | |
| 662 | $m[1], | |
| 663 | $result, | |
| 664 | $nextType | |
| 665 | ); | |
| 666 | } | |
| 667 | } | |
| 668 | ||
| 669 | return $result; | |
| 670 | } | |
| 671 | ||
| 672 | private function getObjectType( string $data ): int { | |
| 673 | $isTree = strpos( $data, "tree " ) === 0; | |
| 674 | $isObj = strpos( $data, "object " ) === 0; | |
| 675 | $result = 3; | |
| 676 | ||
| 677 | if( $isTree ) { | |
| 678 | $result = 1; | |
| 679 | } elseif( $isObj ) { | |
| 680 | $result = 4; | |
| 681 | } elseif( $this->isTreeData( $data ) ) { | |
| 682 | $result = 2; | |
| 683 | } | |
| 684 | ||
| 685 | return $result; | |
| 662 | 686 | } |
| 663 | 687 | } |
| 4 | 4 | class GitDiff { |
| 5 | 5 | private Git $git; |
| 6 | private const MAX_DIFF_SIZE = 1048576; | |
| 7 | ||
| 8 | public function __construct( Git $git ) { | |
| 9 | $this->git = $git; | |
| 10 | } | |
| 11 | ||
| 12 | public function diff( string $oldSha, string $newSha ) { | |
| 13 | $oldTree = $oldSha ? $this->getTreeHash( $oldSha ) : null; | |
| 14 | $newTree = $newSha ? $this->getTreeHash( $newSha ) : null; | |
| 15 | ||
| 16 | return $this->diffTrees( $oldTree, $newTree ); | |
| 17 | } | |
| 18 | ||
| 19 | public function compare( string $commitHash ) { | |
| 20 | $commitData = $this->git->read( $commitHash ); | |
| 21 | ||
| 22 | $parentHash = preg_match( | |
| 23 | '/^parent ([0-9a-f]{40})/m', | |
| 24 | $commitData, | |
| 25 | $matches | |
| 26 | ) ? $matches[1] : ''; | |
| 27 | ||
| 28 | $newTree = $this->getTreeHash( $commitHash ); | |
| 29 | $oldTree = $parentHash ? $this->getTreeHash( $parentHash ) : null; | |
| 30 | ||
| 31 | return $this->diffTrees( $oldTree, $newTree ); | |
| 32 | } | |
| 33 | ||
| 34 | private function getTreeHash( $commitSha ) { | |
| 35 | $data = $this->git->read( $commitSha ); | |
| 36 | ||
| 37 | return preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) | |
| 38 | ? $matches[1] | |
| 39 | : null; | |
| 40 | } | |
| 41 | ||
| 42 | private function diffTrees( $oldTreeSha, $newTreeSha, $path = '' ) { | |
| 43 | $changes = []; | |
| 44 | ||
| 45 | if( $oldTreeSha !== $newTreeSha ) { | |
| 46 | $oldEntries = $oldTreeSha ? $this->parseTree( $oldTreeSha ) : []; | |
| 47 | $newEntries = $newTreeSha ? $this->parseTree( $newTreeSha ) : []; | |
| 48 | ||
| 49 | $allNames = array_unique( | |
| 50 | array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) ) | |
| 51 | ); | |
| 52 | ||
| 53 | sort( $allNames ); | |
| 54 | ||
| 55 | foreach( $allNames as $name ) { | |
| 56 | $old = $oldEntries[$name] ?? null; | |
| 57 | $new = $newEntries[$name] ?? null; | |
| 58 | $currentPath = $path ? "$path/$name" : $name; | |
| 59 | ||
| 60 | if( !$old ) { | |
| 61 | $changes = $new['is_dir'] | |
| 62 | ? array_merge( | |
| 63 | $changes, | |
| 64 | $this->diffTrees( null, $new['sha'], $currentPath ) | |
| 65 | ) | |
| 66 | : array_merge( | |
| 67 | $changes, | |
| 68 | [$this->createChange( 'A', $currentPath, null, $new['sha'] )] | |
| 69 | ); | |
| 70 | } elseif( !$new ) { | |
| 71 | $changes = $old['is_dir'] | |
| 72 | ? array_merge( | |
| 73 | $changes, | |
| 74 | $this->diffTrees( $old['sha'], null, $currentPath ) | |
| 75 | ) | |
| 76 | : array_merge( | |
| 77 | $changes, | |
| 78 | [$this->createChange( 'D', $currentPath, $old['sha'], null )] | |
| 79 | ); | |
| 80 | } elseif( $old['sha'] !== $new['sha'] ) { | |
| 81 | $changes = ($old['is_dir'] && $new['is_dir']) | |
| 82 | ? array_merge( | |
| 83 | $changes, | |
| 84 | $this->diffTrees( $old['sha'], $new['sha'], $currentPath ) | |
| 85 | ) | |
| 86 | : (($old['is_dir'] || $new['is_dir']) | |
| 87 | ? $changes | |
| 88 | : array_merge( | |
| 89 | $changes, | |
| 90 | [$this->createChange( | |
| 91 | 'M', | |
| 92 | $currentPath, | |
| 93 | $old['sha'], | |
| 94 | $new['sha'] | |
| 95 | )] | |
| 96 | )); | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | return $changes; | |
| 102 | } | |
| 103 | ||
| 104 | private function parseTree( $sha ) { | |
| 105 | $data = $this->git->read( $sha ); | |
| 106 | $entries = []; | |
| 107 | $len = strlen( $data ); | |
| 108 | $pos = 0; | |
| 109 | ||
| 110 | while( $pos < $len ) { | |
| 111 | $space = strpos( $data, ' ', $pos ); | |
| 112 | $null = strpos( $data, "\0", $space ); | |
| 113 | ||
| 114 | if( $space === false || $null === false ) { | |
| 115 | break; | |
| 116 | } | |
| 117 | ||
| 118 | $mode = substr( $data, $pos, $space - $pos ); | |
| 119 | $name = substr( $data, $space + 1, $null - $space - 1 ); | |
| 120 | $hash = bin2hex( substr( $data, $null + 1, 20 ) ); | |
| 121 | ||
| 122 | $entries[$name] = [ | |
| 123 | 'mode' => $mode, | |
| 124 | 'sha' => $hash, | |
| 125 | 'is_dir' => $mode === '40000' || $mode === '040000' | |
| 126 | ]; | |
| 127 | ||
| 128 | $pos = $null + 21; | |
| 129 | } | |
| 130 | ||
| 131 | return $entries; | |
| 132 | } | |
| 133 | ||
| 134 | private function createChange( $type, $path, $oldSha, $newSha ) { | |
| 135 | $oldSize = $oldSha ? $this->git->getObjectSize( $oldSha ) : 0; | |
| 136 | $newSize = $newSha ? $this->git->getObjectSize( $newSha ) : 0; | |
| 137 | $result = []; | |
| 138 | ||
| 139 | if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) { | |
| 140 | $result = [ | |
| 141 | 'type' => $type, | |
| 142 | 'path' => $path, | |
| 143 | 'is_binary' => true, | |
| 144 | 'hunks' => [] | |
| 145 | ]; | |
| 146 | } else { | |
| 147 | $oldContent = $oldSha ? $this->git->read( $oldSha ) : ''; | |
| 148 | $newContent = $newSha ? $this->git->read( $newSha ) : ''; | |
| 149 | ||
| 150 | $isBinary = | |
| 151 | ($newSha && (new VirtualDiffFile( $path, $newContent ))->isBinary()) || | |
| 152 | (!$newSha && $oldSha && | |
| 153 | (new VirtualDiffFile( $path, $oldContent ))->isBinary()); | |
| 154 | ||
| 155 | $result = [ | |
| 156 | 'type' => $type, | |
| 157 | 'path' => $path, | |
| 158 | 'is_binary' => $isBinary, | |
| 159 | 'hunks' => $isBinary | |
| 160 | ? null | |
| 161 | : $this->calculateDiff( $oldContent, $newContent ) | |
| 162 | ]; | |
| 163 | } | |
| 164 | ||
| 165 | return $result; | |
| 166 | } | |
| 167 | ||
| 168 | private function calculateDiff( $old, $new ) { | |
| 169 | $old = str_replace( "\r\n", "\n", $old ); | |
| 170 | $new = str_replace( "\r\n", "\n", $new ); | |
| 171 | ||
| 172 | $oldLines = explode( "\n", $old ); | |
| 173 | $newLines = explode( "\n", $new ); | |
| 174 | ||
| 175 | $m = count( $oldLines ); | |
| 176 | $n = count( $newLines ); | |
| 177 | ||
| 178 | $start = 0; | |
| 179 | ||
| 180 | while( | |
| 181 | $start < $m && | |
| 182 | $start < $n && | |
| 183 | $oldLines[$start] === $newLines[$start] | |
| 184 | ) { | |
| 185 | $start++; | |
| 186 | } | |
| 187 | ||
| 188 | $end = 0; | |
| 189 | ||
| 190 | while( | |
| 191 | $m - $end > $start && | |
| 192 | $n - $end > $start && | |
| 193 | $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end] | |
| 194 | ) { | |
| 195 | $end++; | |
| 196 | } | |
| 197 | ||
| 198 | $oldSlice = array_slice( $oldLines, $start, $m - $start - $end ); | |
| 199 | $newSlice = array_slice( $newLines, $start, $n - $start - $end ); | |
| 200 | ||
| 201 | $result = null; | |
| 202 | ||
| 203 | if( (count( $oldSlice ) * count( $newSlice )) > 500000 ) { | |
| 204 | $result = [['t' => 'gap']]; | |
| 205 | } else { | |
| 206 | $ops = $this->computeLCS( $oldSlice, $newSlice ); | |
| 207 | ||
| 208 | $groupedOps = []; | |
| 209 | $bufferDel = []; | |
| 210 | $bufferAdd = []; | |
| 211 | ||
| 212 | foreach( $ops as $op ) { | |
| 213 | if( $op['t'] === ' ' ) { | |
| 214 | foreach( $bufferDel as $o ) { $groupedOps[] = $o; } | |
| 215 | foreach( $bufferAdd as $o ) { $groupedOps[] = $o; } | |
| 216 | ||
| 217 | $bufferDel = []; | |
| 218 | $bufferAdd = []; | |
| 219 | $groupedOps[] = $op; | |
| 220 | } elseif( $op['t'] === '-' ) { | |
| 221 | $bufferDel[] = $op; | |
| 222 | } elseif( $op['t'] === '+' ) { | |
| 223 | $bufferAdd[] = $op; | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | foreach( $bufferDel as $o ) { $groupedOps[] = $o; } | |
| 228 | foreach( $bufferAdd as $o ) { $groupedOps[] = $o; } | |
| 229 | ||
| 230 | $ops = $groupedOps; | |
| 231 | $stream = []; | |
| 232 | ||
| 233 | for( $i = 0; $i < $start; $i++ ) { | |
| 234 | $stream[] = [ | |
| 235 | 't' => ' ', | |
| 236 | 'l' => $oldLines[$i], | |
| 237 | 'no' => $i + 1, | |
| 238 | 'nn' => $i + 1 | |
| 239 | ]; | |
| 240 | } | |
| 241 | ||
| 242 | $currO = $start + 1; | |
| 243 | $currN = $start + 1; | |
| 244 | ||
| 245 | foreach( $ops as $op ) { | |
| 246 | if( $op['t'] === ' ' ) { | |
| 247 | $stream[] = [ | |
| 248 | 't' => ' ', | |
| 249 | 'l' => $op['l'], | |
| 250 | 'no' => $currO++, | |
| 251 | 'nn' => $currN++ | |
| 252 | ]; | |
| 253 | } elseif( $op['t'] === '-' ) { | |
| 254 | $stream[] = [ | |
| 255 | 't' => '-', | |
| 256 | 'l' => $op['l'], | |
| 257 | 'no' => $currO++, | |
| 258 | 'nn' => null | |
| 259 | ]; | |
| 260 | } elseif( $op['t'] === '+' ) { | |
| 261 | $stream[] = [ | |
| 262 | 't' => '+', | |
| 263 | 'l' => $op['l'], | |
| 264 | 'no' => null, | |
| 265 | 'nn' => $currN++ | |
| 266 | ]; | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | for( $i = $m - $end; $i < $m; $i++ ) { | |
| 271 | $stream[] = [ | |
| 272 | 't' => ' ', | |
| 273 | 'l' => $oldLines[$i], | |
| 274 | 'no' => $currO++, | |
| 275 | 'nn' => $currN++ | |
| 276 | ]; | |
| 277 | } | |
| 278 | ||
| 279 | $finalLines = []; | |
| 280 | $lastVisibleIndex = -1; | |
| 281 | $streamLen = count( $stream ); | |
| 282 | $contextLines = 3; | |
| 283 | ||
| 284 | for( $i = 0; $i < $streamLen; $i++ ) { | |
| 285 | $show = false; | |
| 286 | ||
| 287 | if( $stream[$i]['t'] !== ' ' ) { | |
| 288 | $show = true; | |
| 289 | } else { | |
| 290 | for( $j = 1; $j <= $contextLines; $j++ ) { | |
| 291 | if( ($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ' ) { | |
| 292 | $show = true; | |
| 293 | break; | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | if( !$show ) { | |
| 298 | for( $j = 1; $j <= $contextLines; $j++ ) { | |
| 299 | if( ($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ' ) { | |
| 300 | $show = true; | |
| 301 | break; | |
| 302 | } | |
| 303 | } | |
| 304 | } | |
| 305 | } | |
| 306 | ||
| 307 | if( $show ) { | |
| 308 | if( $lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1 ) { | |
| 309 | $finalLines[] = ['t' => 'gap']; | |
| 310 | } | |
| 311 | ||
| 312 | $finalLines[] = $stream[$i]; | |
| 313 | $lastVisibleIndex = $i; | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | $result = $finalLines; | |
| 318 | } | |
| 319 | ||
| 320 | return $result; | |
| 321 | } | |
| 322 | ||
| 323 | private function computeLCS( $old, $new ) { | |
| 324 | $m = count( $old ); | |
| 325 | $n = count( $new ); | |
| 326 | $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) ); | |
| 327 | ||
| 328 | for( $i = 1; $i <= $m; $i++ ) { | |
| 329 | for( $j = 1; $j <= $n; $j++ ) { | |
| 330 | $c[$i][$j] = ($old[$i - 1] === $new[$j - 1]) | |
| 331 | ? $c[$i - 1][$j - 1] + 1 | |
| 332 | : max( $c[$i][$j - 1], $c[$i - 1][$j] ); | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | $diff = []; | |
| 337 | $i = $m; | |
| 338 | $j = $n; | |
| 339 | ||
| 340 | while( $i > 0 || $j > 0 ) { | |
| 341 | if( $i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1] ) { | |
| 342 | array_unshift( $diff, ['t' => ' ', 'l' => $old[$i - 1]] ); | |
| 343 | $i--; | |
| 344 | $j--; | |
| 345 | } elseif( $j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j]) ) { | |
| 346 | array_unshift( $diff, ['t' => '+', 'l' => $new[$j - 1]] ); | |
| 347 | $j--; | |
| 348 | } elseif( $i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j]) ) { | |
| 349 | array_unshift( $diff, ['t' => '-', 'l' => $old[$i - 1]] ); | |
| 350 | $i--; | |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 | return $diff; | |
| 6 | private const MAX_DIFF_SIZE = 262144; | |
| 7 | ||
| 8 | public function __construct( Git $git ) { | |
| 9 | $this->git = $git; | |
| 10 | } | |
| 11 | ||
| 12 | public function diff( string $oldSha, string $newSha ): Generator { | |
| 13 | $oldTree = $oldSha !== '' ? $this->getTreeHash( $oldSha ) : ''; | |
| 14 | $newTree = $newSha !== '' ? $this->getTreeHash( $newSha ) : ''; | |
| 15 | ||
| 16 | yield from $this->diffTrees( $oldTree, $newTree ); | |
| 17 | } | |
| 18 | ||
| 19 | public function compare( string $commitHash ): Generator { | |
| 20 | $commitData = $this->git->read( $commitHash ); | |
| 21 | $parentHash = ''; | |
| 22 | ||
| 23 | if( preg_match( '/^parent ([0-9a-f]{40})/m', $commitData, $m ) ) { | |
| 24 | $parentHash = $m[1]; | |
| 25 | } | |
| 26 | ||
| 27 | $newTree = $this->getTreeHash( $commitHash ); | |
| 28 | $oldTree = $parentHash !== '' ? $this->getTreeHash( $parentHash ) : ''; | |
| 29 | ||
| 30 | yield from $this->diffTrees( $oldTree, $newTree ); | |
| 31 | } | |
| 32 | ||
| 33 | private function getTreeHash( string $commitSha ): string { | |
| 34 | $data = $this->git->read( $commitSha ); | |
| 35 | $result = ''; | |
| 36 | ||
| 37 | if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { | |
| 38 | $result = $matches[1]; | |
| 39 | } | |
| 40 | ||
| 41 | return $result; | |
| 42 | } | |
| 43 | ||
| 44 | private function diffTrees( | |
| 45 | string $oldTreeSha, | |
| 46 | string $newTreeSha, | |
| 47 | string $path = '' | |
| 48 | ): Generator { | |
| 49 | if( $oldTreeSha !== $newTreeSha ) { | |
| 50 | $oldEntries = $oldTreeSha !== '' | |
| 51 | ? $this->parseTree( $oldTreeSha ) | |
| 52 | : []; | |
| 53 | $newEntries = $newTreeSha !== '' | |
| 54 | ? $this->parseTree( $newTreeSha ) | |
| 55 | : []; | |
| 56 | $allNames = array_unique( | |
| 57 | array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) ) | |
| 58 | ); | |
| 59 | ||
| 60 | sort( $allNames ); | |
| 61 | ||
| 62 | foreach( $allNames as $name ) { | |
| 63 | $old = $oldEntries[$name] ?? null; | |
| 64 | $new = $newEntries[$name] ?? null; | |
| 65 | $currentPath = $path !== '' ? "$path/$name" : $name; | |
| 66 | ||
| 67 | if( !$old && $new ) { | |
| 68 | if( $new['is_dir'] ) { | |
| 69 | yield from $this->diffTrees( '', $new['sha'], $currentPath ); | |
| 70 | } else { | |
| 71 | yield $this->createChange( 'A', $currentPath, '', $new['sha'] ); | |
| 72 | } | |
| 73 | } elseif( !$new && $old ) { | |
| 74 | if( $old['is_dir'] ) { | |
| 75 | yield from $this->diffTrees( $old['sha'], '', $currentPath ); | |
| 76 | } else { | |
| 77 | yield $this->createChange( 'D', $currentPath, $old['sha'], '' ); | |
| 78 | } | |
| 79 | } elseif( $old && $new && $old['sha'] !== $new['sha'] ) { | |
| 80 | if( $old['is_dir'] && $new['is_dir'] ) { | |
| 81 | yield from $this->diffTrees( | |
| 82 | $old['sha'], | |
| 83 | $new['sha'], | |
| 84 | $currentPath | |
| 85 | ); | |
| 86 | } elseif( !$old['is_dir'] && !$new['is_dir'] ) { | |
| 87 | yield $this->createChange( | |
| 88 | 'M', | |
| 89 | $currentPath, | |
| 90 | $old['sha'], | |
| 91 | $new['sha'] | |
| 92 | ); | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | private function parseTree( string $sha ): array { | |
| 100 | $data = $this->git->read( $sha ); | |
| 101 | $entries = []; | |
| 102 | $len = strlen( $data ); | |
| 103 | $pos = 0; | |
| 104 | ||
| 105 | while( $pos < $len ) { | |
| 106 | $space = strpos( $data, ' ', $pos ); | |
| 107 | $null = strpos( $data, "\0", $space ); | |
| 108 | ||
| 109 | if( $space === false || $null === false ) { | |
| 110 | break; | |
| 111 | } | |
| 112 | ||
| 113 | $mode = substr( $data, $pos, $space - $pos ); | |
| 114 | $name = substr( $data, $space + 1, $null - $space - 1 ); | |
| 115 | $hash = bin2hex( substr( $data, $null + 1, 20 ) ); | |
| 116 | ||
| 117 | $entries[$name] = [ | |
| 118 | 'mode' => $mode, | |
| 119 | 'sha' => $hash, | |
| 120 | 'is_dir' => $mode === '40000' || $mode === '040000' | |
| 121 | ]; | |
| 122 | ||
| 123 | $pos = $null + 21; | |
| 124 | } | |
| 125 | ||
| 126 | return $entries; | |
| 127 | } | |
| 128 | ||
| 129 | private function createChange( | |
| 130 | string $type, | |
| 131 | string $path, | |
| 132 | string $oldSha, | |
| 133 | string $newSha | |
| 134 | ): array { | |
| 135 | $oldSize = $oldSha !== '' ? $this->git->getObjectSize( $oldSha ) : 0; | |
| 136 | $newSize = $newSha !== '' ? $this->git->getObjectSize( $newSha ) : 0; | |
| 137 | $result = []; | |
| 138 | ||
| 139 | if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) { | |
| 140 | $result = [ | |
| 141 | 'type' => $type, | |
| 142 | 'path' => $path, | |
| 143 | 'is_binary' => true, | |
| 144 | 'hunks' => [] | |
| 145 | ]; | |
| 146 | } else { | |
| 147 | $oldContent = $oldSha !== '' ? $this->git->read( $oldSha ) : ''; | |
| 148 | $newContent = $newSha !== '' ? $this->git->read( $newSha ) : ''; | |
| 149 | $vDiffOld = new VirtualDiffFile( $path, $oldContent ); | |
| 150 | $vDiffNew = new VirtualDiffFile( $path, $newContent ); | |
| 151 | ||
| 152 | $isBinary = ($newSha !== '' && $vDiffNew->isBinary()) || | |
| 153 | ($newSha === '' && $oldSha !== '' && $vDiffOld->isBinary()); | |
| 154 | ||
| 155 | $result = [ | |
| 156 | 'type' => $type, | |
| 157 | 'path' => $path, | |
| 158 | 'is_binary' => $isBinary, | |
| 159 | 'hunks' => $isBinary | |
| 160 | ? null | |
| 161 | : $this->calculateDiff( $oldContent, $newContent ) | |
| 162 | ]; | |
| 163 | } | |
| 164 | ||
| 165 | return $result; | |
| 166 | } | |
| 167 | ||
| 168 | private function calculateDiff( string $old, string $new ): array { | |
| 169 | $oldLines = explode( "\n", str_replace( "\r\n", "\n", $old ) ); | |
| 170 | $newLines = explode( "\n", str_replace( "\r\n", "\n", $new ) ); | |
| 171 | $m = count( $oldLines ); | |
| 172 | $n = count( $newLines ); | |
| 173 | $start = 0; | |
| 174 | $end = 0; | |
| 175 | ||
| 176 | while( $start < $m && $start < $n && | |
| 177 | $oldLines[$start] === $newLines[$start] ) { | |
| 178 | $start++; | |
| 179 | } | |
| 180 | ||
| 181 | while( $m - $end > $start && $n - $end > $start && | |
| 182 | $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end] ) { | |
| 183 | $end++; | |
| 184 | } | |
| 185 | ||
| 186 | $context = 2; | |
| 187 | $limit = 100000; | |
| 188 | $stream = []; | |
| 189 | ||
| 190 | $pStart = max( 0, $start - $context ); | |
| 191 | ||
| 192 | for( $i = $pStart; $i < $start; $i++ ) { | |
| 193 | $stream[] = [ | |
| 194 | 't' => ' ', | |
| 195 | 'l' => $oldLines[$i], | |
| 196 | 'no' => $i + 1, | |
| 197 | 'nn' => $i + 1 | |
| 198 | ]; | |
| 199 | } | |
| 200 | ||
| 201 | $oldSlice = array_slice( $oldLines, $start, $m - $start - $end ); | |
| 202 | $newSlice = array_slice( $newLines, $start, $n - $start - $end ); | |
| 203 | $mid = []; | |
| 204 | ||
| 205 | if( (count( $oldSlice ) * count( $newSlice )) > $limit ) { | |
| 206 | $mid = $this->buildFallbackDiff( $oldSlice, $newSlice, $start ); | |
| 207 | } else { | |
| 208 | $ops = $this->computeLCS( $oldSlice, $newSlice ); | |
| 209 | $mid = $this->buildDiffStream( $ops, $start ); | |
| 210 | } | |
| 211 | ||
| 212 | foreach( $mid as $line ) { | |
| 213 | $stream[] = $line; | |
| 214 | } | |
| 215 | ||
| 216 | $sLimit = min( $end, $context ); | |
| 217 | ||
| 218 | for( $i = 0; $i < $sLimit; $i++ ) { | |
| 219 | $idxO = $m - $end + $i; | |
| 220 | $idxN = $n - $end + $i; | |
| 221 | $stream[] = [ | |
| 222 | 't' => ' ', | |
| 223 | 'l' => $oldLines[$idxO], | |
| 224 | 'no' => $idxO + 1, | |
| 225 | 'nn' => $idxN + 1 | |
| 226 | ]; | |
| 227 | } | |
| 228 | ||
| 229 | return $this->formatDiffOutput( $stream ); | |
| 230 | } | |
| 231 | ||
| 232 | private function formatDiffOutput( array $stream ): array { | |
| 233 | $n = count( $stream ); | |
| 234 | $keep = array_fill( 0, $n, false ); | |
| 235 | $context = 2; | |
| 236 | ||
| 237 | for( $i = 0; $i < $n; $i++ ) { | |
| 238 | if( $stream[$i]['t'] !== ' ' ) { | |
| 239 | $low = max( 0, $i - $context ); | |
| 240 | $high = min( $n - 1, $i + $context ); | |
| 241 | ||
| 242 | for( $j = $low; $j <= $high; $j++ ) { | |
| 243 | $keep[$j] = true; | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | $result = []; | |
| 249 | $buffer = []; | |
| 250 | ||
| 251 | for( $i = 0; $i < $n; $i++ ) { | |
| 252 | if( $keep[$i] ) { | |
| 253 | $cnt = count( $buffer ); | |
| 254 | ||
| 255 | if( $cnt > 0 ) { | |
| 256 | if( $cnt > 5 ) { | |
| 257 | $result[] = [ 't' => 'gap' ]; | |
| 258 | } else { | |
| 259 | foreach( $buffer as $bufLine ) { | |
| 260 | $result[] = $bufLine; | |
| 261 | } | |
| 262 | } | |
| 263 | $buffer = []; | |
| 264 | } | |
| 265 | ||
| 266 | $result[] = $stream[$i]; | |
| 267 | } else { | |
| 268 | $buffer[] = $stream[$i]; | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | $cnt = count( $buffer ); | |
| 273 | ||
| 274 | if( $cnt > 0 ) { | |
| 275 | if( $cnt > 5 ) { | |
| 276 | $result[] = [ 't' => 'gap' ]; | |
| 277 | } else { | |
| 278 | foreach( $buffer as $bufLine ) { | |
| 279 | $result[] = $bufLine; | |
| 280 | } | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | return $result; | |
| 285 | } | |
| 286 | ||
| 287 | private function buildFallbackDiff( | |
| 288 | array $old, | |
| 289 | array $new, | |
| 290 | int $offset | |
| 291 | ): array { | |
| 292 | $stream = []; | |
| 293 | $currO = $offset + 1; | |
| 294 | $currN = $offset + 1; | |
| 295 | ||
| 296 | foreach( $old as $line ) { | |
| 297 | $stream[] = [ | |
| 298 | 't' => '-', | |
| 299 | 'l' => $line, | |
| 300 | 'no' => $currO++, | |
| 301 | 'nn' => null | |
| 302 | ]; | |
| 303 | } | |
| 304 | ||
| 305 | foreach( $new as $line ) { | |
| 306 | $stream[] = [ | |
| 307 | 't' => '+', | |
| 308 | 'l' => $line, | |
| 309 | 'no' => null, | |
| 310 | 'nn' => $currN++ | |
| 311 | ]; | |
| 312 | } | |
| 313 | ||
| 314 | return $stream; | |
| 315 | } | |
| 316 | ||
| 317 | private function buildDiffStream( array $ops, int $start ): array { | |
| 318 | $stream = []; | |
| 319 | $currO = $start + 1; | |
| 320 | $currN = $start + 1; | |
| 321 | ||
| 322 | foreach( $ops as $op ) { | |
| 323 | $stream[] = [ | |
| 324 | 't' => $op['t'], | |
| 325 | 'l' => $op['l'], | |
| 326 | 'no' => $op['t'] === '+' ? null : $currO++, | |
| 327 | 'nn' => $op['t'] === '-' ? null : $currN++ | |
| 328 | ]; | |
| 329 | } | |
| 330 | ||
| 331 | return $stream; | |
| 332 | } | |
| 333 | ||
| 334 | private function computeLCS( array $old, array $new ): array { | |
| 335 | $m = count( $old ); | |
| 336 | $n = count( $new ); | |
| 337 | $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) ); | |
| 338 | ||
| 339 | for( $i = 1; $i <= $m; $i++ ) { | |
| 340 | for( $j = 1; $j <= $n; $j++ ) { | |
| 341 | $c[$i][$j] = ($old[$i - 1] === $new[$j - 1]) | |
| 342 | ? $c[$i - 1][$j - 1] + 1 | |
| 343 | : max( $c[$i][$j - 1], $c[$i - 1][$j] ); | |
| 344 | } | |
| 345 | } | |
| 346 | ||
| 347 | $diff = []; | |
| 348 | $i = $m; | |
| 349 | $j = $n; | |
| 350 | ||
| 351 | while( $i > 0 || $j > 0 ) { | |
| 352 | if( $i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1] ) { | |
| 353 | $diff[] = [ 't' => ' ', 'l' => $old[$i - 1] ]; | |
| 354 | $i--; | |
| 355 | $j--; | |
| 356 | } elseif( $j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j]) ) { | |
| 357 | $diff[] = [ 't' => '+', 'l' => $new[$j - 1] ]; | |
| 358 | $j--; | |
| 359 | } elseif( $i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j]) ) { | |
| 360 | $diff[] = [ 't' => '-', 'l' => $old[$i - 1] ]; | |
| 361 | $i--; | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | return array_reverse( $diff ); | |
| 355 | 366 | } |
| 356 | 367 | } |
| 1 | 1 | <?php |
| 2 | 2 | class GitPacks { |
| 3 | private const MAX_READ = 1040576; | |
| 4 | private const MAX_RAM = 1048576; | |
| 5 | ||
| 6 | private string $objectsPath; | |
| 7 | private array $packFiles; | |
| 8 | private ?string $lastPack = null; | |
| 9 | ||
| 10 | private array $fileHandles = []; | |
| 11 | private array $fanoutCache = []; | |
| 12 | private array $shaBucketCache = []; | |
| 13 | private array $offsetBucketCache = []; | |
| 14 | ||
| 15 | public function __construct( string $objectsPath ) { | |
| 16 | $this->objectsPath = $objectsPath; | |
| 17 | $this->packFiles = glob( "{$this->objectsPath}/pack/*.idx" ) ?: []; | |
| 18 | } | |
| 19 | ||
| 20 | public function __destruct() { | |
| 21 | foreach( $this->fileHandles as $handle ) { | |
| 22 | if( is_resource( $handle ) ) { | |
| 23 | fclose( $handle ); | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | public function peek( string $sha, int $len = 12 ): ?string { | |
| 29 | $info = $this->findPackInfo( $sha ); | |
| 30 | ||
| 31 | if( $info['offset'] === -1 ) { | |
| 32 | return null; | |
| 33 | } | |
| 34 | ||
| 35 | $handle = $this->getHandle( $info['file'] ); | |
| 36 | ||
| 37 | if( !$handle ) { | |
| 38 | return null; | |
| 39 | } | |
| 40 | ||
| 41 | return $this->readPackEntry( $handle, $info['offset'], $len, $len ); | |
| 42 | } | |
| 43 | ||
| 44 | public function read( string $sha ): ?string { | |
| 45 | $info = $this->findPackInfo( $sha ); | |
| 46 | ||
| 47 | if( $info['offset'] === -1 ) { | |
| 48 | return null; | |
| 49 | } | |
| 50 | ||
| 51 | $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 52 | ||
| 53 | if( $size > self::MAX_RAM ) { | |
| 54 | return null; | |
| 55 | } | |
| 56 | ||
| 57 | $handle = $this->getHandle( $info['file'] ); | |
| 58 | ||
| 59 | return $handle | |
| 60 | ? $this->readPackEntry( $handle, $info['offset'], $size ) | |
| 61 | : null; | |
| 62 | } | |
| 63 | ||
| 64 | public function stream( string $sha, callable $callback ): bool { | |
| 65 | $info = $this->findPackInfo( $sha ); | |
| 66 | ||
| 67 | if( $info['offset'] === -1 ) { | |
| 68 | return false; | |
| 69 | } | |
| 70 | ||
| 71 | $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 72 | $handle = $this->getHandle( $info['file'] ); | |
| 73 | ||
| 74 | if( !$handle ) { | |
| 75 | return false; | |
| 76 | } | |
| 77 | ||
| 78 | return $this->streamPackEntry( | |
| 79 | $handle, | |
| 80 | $info['offset'], | |
| 81 | $size, | |
| 82 | $callback | |
| 83 | ); | |
| 84 | } | |
| 85 | ||
| 86 | public function getSize( string $sha ): ?int { | |
| 87 | $info = $this->findPackInfo( $sha ); | |
| 88 | ||
| 89 | if( $info['offset'] === -1 ) { | |
| 90 | return null; | |
| 91 | } | |
| 92 | ||
| 93 | return $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 94 | } | |
| 95 | ||
| 96 | private function findPackInfo( string $sha ): array { | |
| 97 | if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) { | |
| 98 | return ['offset' => -1]; | |
| 99 | } | |
| 100 | ||
| 101 | $binarySha = hex2bin( $sha ); | |
| 102 | ||
| 103 | if( $this->lastPack ) { | |
| 104 | $offset = $this->findInIdx( $this->lastPack, $binarySha ); | |
| 105 | ||
| 106 | if( $offset !== -1 ) { | |
| 107 | return $this->makeResult( $this->lastPack, $offset ); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | foreach( $this->packFiles as $indexFile ) { | |
| 112 | if( $indexFile === $this->lastPack ) { | |
| 113 | continue; | |
| 114 | } | |
| 115 | ||
| 116 | $offset = $this->findInIdx( $indexFile, $binarySha ); | |
| 117 | ||
| 118 | if( $offset !== -1 ) { | |
| 119 | $this->lastPack = $indexFile; | |
| 120 | ||
| 121 | return $this->makeResult( $indexFile, $offset ); | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | return ['offset' => -1]; | |
| 126 | } | |
| 127 | ||
| 128 | private function makeResult( string $indexPath, int $offset ): array { | |
| 129 | return [ | |
| 130 | 'file' => str_replace( '.idx', '.pack', $indexPath ), | |
| 131 | 'offset' => $offset | |
| 132 | ]; | |
| 133 | } | |
| 134 | ||
| 135 | private function findInIdx( string $indexFile, string $binarySha ): int { | |
| 136 | $fileHandle = $this->getHandle( $indexFile ); | |
| 137 | ||
| 138 | if( !$fileHandle ) { | |
| 139 | return -1; | |
| 140 | } | |
| 141 | ||
| 142 | if( !isset( $this->fanoutCache[$indexFile] ) ) { | |
| 143 | fseek( $fileHandle, 0 ); | |
| 144 | ||
| 145 | if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { | |
| 146 | $this->fanoutCache[$indexFile] = array_values( | |
| 147 | unpack( 'N*', fread( $fileHandle, 1024 ) ) | |
| 148 | ); | |
| 149 | } else { | |
| 150 | return -1; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | $fanout = $this->fanoutCache[$indexFile]; | |
| 155 | ||
| 156 | $firstByte = ord( $binarySha[0] ); | |
| 157 | $start = $firstByte === 0 ? 0 : $fanout[$firstByte - 1]; | |
| 158 | $end = $fanout[$firstByte]; | |
| 159 | ||
| 160 | if( $end <= $start ) { | |
| 161 | return -1; | |
| 162 | } | |
| 163 | ||
| 164 | $cacheKey = "$indexFile:$firstByte"; | |
| 165 | ||
| 166 | if( !isset( $this->shaBucketCache[$cacheKey] ) ) { | |
| 167 | $count = $end - $start; | |
| 168 | ||
| 169 | fseek( $fileHandle, 1032 + ($start * 20) ); | |
| 170 | ||
| 171 | $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 ); | |
| 172 | ||
| 173 | fseek( | |
| 174 | $fileHandle, | |
| 175 | 1032 + ($fanout[255] * 24) + ($start * 4) | |
| 176 | ); | |
| 177 | ||
| 178 | $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 ); | |
| 179 | } | |
| 180 | ||
| 181 | $shaBlock = $this->shaBucketCache[$cacheKey]; | |
| 182 | $count = strlen( $shaBlock ) / 20; | |
| 183 | $low = 0; | |
| 184 | $high = $count - 1; | |
| 185 | $foundIdx = -1; | |
| 186 | ||
| 187 | while( $low <= $high ) { | |
| 188 | $mid = ($low + $high) >> 1; | |
| 189 | $compare = substr( $shaBlock, $mid * 20, 20 ); | |
| 190 | ||
| 191 | if( $compare < $binarySha ) { | |
| 192 | $low = $mid + 1; | |
| 193 | } elseif( $compare > $binarySha ) { | |
| 194 | $high = $mid - 1; | |
| 195 | } else { | |
| 196 | $foundIdx = $mid; | |
| 197 | break; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | if( $foundIdx === -1 ) { | |
| 202 | return -1; | |
| 203 | } | |
| 204 | ||
| 205 | $offsetData = substr( | |
| 206 | $this->offsetBucketCache[$cacheKey], | |
| 207 | $foundIdx * 4, | |
| 208 | 4 | |
| 209 | ); | |
| 210 | ||
| 211 | $offset = unpack( 'N', $offsetData )[1]; | |
| 212 | ||
| 213 | if( $offset & 0x80000000 ) { | |
| 214 | $packTotal = $fanout[255]; | |
| 215 | $pos64 = 1032 + ($packTotal * 28) + | |
| 216 | (($offset & 0x7FFFFFFF) * 8); | |
| 217 | ||
| 218 | fseek( $fileHandle, $pos64 ); | |
| 219 | ||
| 220 | $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; | |
| 221 | } | |
| 222 | ||
| 223 | return (int)$offset; | |
| 224 | } | |
| 225 | ||
| 226 | private function readPackEntry( | |
| 227 | $fileHandle, | |
| 228 | int $offset, | |
| 229 | int $expectedSize, | |
| 230 | int $cap = 0 | |
| 231 | ): string { | |
| 232 | fseek( $fileHandle, $offset ); | |
| 233 | ||
| 234 | $header = $this->readVarInt( $fileHandle ); | |
| 235 | $type = ($header['byte'] >> 4) & 7; | |
| 236 | ||
| 237 | if( $type === 6 ) { | |
| 238 | return $this->handleOfsDelta( | |
| 239 | $fileHandle, | |
| 240 | $offset, | |
| 241 | $expectedSize, | |
| 242 | $cap | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | if( $type === 7 ) { | |
| 247 | return $this->handleRefDelta( $fileHandle, $expectedSize, $cap ); | |
| 248 | } | |
| 249 | ||
| 250 | return $this->decompressToString( $fileHandle, $expectedSize, $cap ); | |
| 251 | } | |
| 252 | ||
| 253 | private function streamPackEntry( | |
| 254 | $fileHandle, | |
| 255 | int $offset, | |
| 256 | int $expectedSize, | |
| 257 | callable $callback | |
| 258 | ): bool { | |
| 259 | fseek( $fileHandle, $offset ); | |
| 260 | ||
| 261 | $header = $this->readVarInt( $fileHandle ); | |
| 262 | $type = ($header['byte'] >> 4) & 7; | |
| 263 | ||
| 264 | if( $type === 6 || $type === 7 ) { | |
| 265 | return $this->streamDeltaObject( | |
| 266 | $fileHandle, | |
| 267 | $offset, | |
| 268 | $type, | |
| 269 | $expectedSize, | |
| 270 | $callback | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | return $this->streamDecompression( $fileHandle, $callback ); | |
| 275 | } | |
| 276 | ||
| 277 | private function streamDeltaObject( | |
| 278 | $fileHandle, | |
| 279 | int $offset, | |
| 280 | int $type, | |
| 281 | int $expectedSize, | |
| 282 | callable $callback | |
| 283 | ): bool { | |
| 284 | fseek( $fileHandle, $offset ); | |
| 285 | $this->readVarInt( $fileHandle ); | |
| 286 | ||
| 287 | if( $type === 6 ) { | |
| 288 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 289 | $negative = $byte & 127; | |
| 290 | ||
| 291 | while( $byte & 128 ) { | |
| 292 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 293 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 294 | } | |
| 295 | ||
| 296 | $deltaPos = ftell( $fileHandle ); | |
| 297 | $baseOffset = $offset - $negative; | |
| 298 | ||
| 299 | $base = ''; | |
| 300 | ||
| 301 | $this->streamPackEntry( | |
| 302 | $fileHandle, | |
| 303 | $baseOffset, | |
| 304 | 0, | |
| 305 | function( $chunk ) use ( &$base ) { $base .= $chunk; } | |
| 306 | ); | |
| 307 | ||
| 308 | fseek( $fileHandle, $deltaPos ); | |
| 309 | } else { | |
| 310 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 311 | ||
| 312 | $base = ''; | |
| 313 | $streamed = $this->stream( | |
| 314 | $baseSha, | |
| 315 | function( $chunk ) use ( &$base ) { $base .= $chunk; } | |
| 316 | ); | |
| 317 | ||
| 318 | if( !$streamed ) { | |
| 319 | return false; | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 324 | ||
| 325 | if( $inflator === false ) { | |
| 326 | return false; | |
| 327 | } | |
| 328 | ||
| 329 | // 0: source size, 1: target size, 2: opcodes | |
| 330 | $headerState = 0; | |
| 331 | $buffer = ''; | |
| 332 | ||
| 333 | while( !feof( $fileHandle ) ) { | |
| 334 | // Read small chunks to prevent memory spikes | |
| 335 | $chunk = fread( $fileHandle, 8192 ); | |
| 336 | ||
| 337 | if( $chunk === false || $chunk === '' ) { | |
| 338 | break; | |
| 339 | } | |
| 340 | ||
| 341 | $data = @inflate_add( $inflator, $chunk ); | |
| 342 | ||
| 343 | if( $data === false ) { | |
| 344 | break; | |
| 345 | } | |
| 346 | ||
| 347 | $buffer .= $data; | |
| 348 | ||
| 349 | // Process the buffer | |
| 350 | while( true ) { | |
| 351 | $bufLen = strlen( $buffer ); | |
| 352 | ||
| 353 | if( $bufLen === 0 ) { | |
| 354 | break; | |
| 355 | } | |
| 356 | ||
| 357 | if( $headerState < 2 ) { | |
| 358 | $pos = 0; | |
| 359 | ||
| 360 | while( $pos < $bufLen && (ord( $buffer[$pos] ) & 128) ) { | |
| 361 | $pos++; | |
| 362 | } | |
| 363 | ||
| 364 | if( $pos === $bufLen && (ord( $buffer[$pos - 1] ) & 128) ) { | |
| 365 | break; | |
| 366 | } | |
| 367 | ||
| 368 | $pos++; | |
| 369 | $buffer = substr( $buffer, $pos ); | |
| 370 | $headerState++; | |
| 371 | continue; | |
| 372 | } | |
| 373 | ||
| 374 | $opcode = ord( $buffer[0] ); | |
| 375 | ||
| 376 | if( $opcode & 128 ) { | |
| 377 | $needed = 1; | |
| 378 | if( $opcode & 0x01 ) { $needed++; } | |
| 379 | if( $opcode & 0x02 ) { $needed++; } | |
| 380 | if( $opcode & 0x04 ) { $needed++; } | |
| 381 | if( $opcode & 0x08 ) { $needed++; } | |
| 382 | if( $opcode & 0x10 ) { $needed++; } | |
| 383 | if( $opcode & 0x20 ) { $needed++; } | |
| 384 | if( $opcode & 0x40 ) { $needed++; } | |
| 385 | ||
| 386 | if( $bufLen < $needed ) { | |
| 387 | break; | |
| 388 | } | |
| 389 | ||
| 390 | $off = 0; | |
| 391 | $len = 0; | |
| 392 | $p = 1; | |
| 393 | ||
| 394 | if( $opcode & 0x01 ) { $off |= ord( $buffer[$p++] ); } | |
| 395 | if( $opcode & 0x02 ) { $off |= ord( $buffer[$p++] ) << 8; } | |
| 396 | if( $opcode & 0x04 ) { $off |= ord( $buffer[$p++] ) << 16; } | |
| 397 | if( $opcode & 0x08 ) { $off |= ord( $buffer[$p++] ) << 24; } | |
| 398 | ||
| 399 | if( $opcode & 0x10 ) { $len |= ord( $buffer[$p++] ); } | |
| 400 | if( $opcode & 0x20 ) { $len |= ord( $buffer[$p++] ) << 8; } | |
| 401 | if( $opcode & 0x40 ) { $len |= ord( $buffer[$p++] ) << 16; } | |
| 402 | ||
| 403 | if( $len === 0 ) { $len = 0x10000; } | |
| 404 | ||
| 405 | $callback( substr( $base, $off, $len ) ); | |
| 406 | $buffer = substr( $buffer, $needed ); | |
| 407 | ||
| 408 | } else { | |
| 409 | $len = $opcode & 127; | |
| 410 | ||
| 411 | if( $bufLen < 1 + $len ) { | |
| 412 | break; | |
| 413 | } | |
| 414 | ||
| 415 | $callback( substr( $buffer, 1, $len ) ); | |
| 416 | $buffer = substr( $buffer, 1 + $len ); | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | |
| 421 | break; | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | return true; | |
| 426 | } | |
| 427 | ||
| 428 | private function streamDecompression( | |
| 429 | $fileHandle, | |
| 430 | callable $callback | |
| 431 | ): bool { | |
| 432 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 433 | ||
| 434 | if( $inflator === false ) { | |
| 435 | return false; | |
| 436 | } | |
| 437 | ||
| 438 | while( !feof( $fileHandle ) ) { | |
| 439 | $chunk = fread( $fileHandle, 8192 ); | |
| 440 | ||
| 441 | if( $chunk === false || $chunk === '' ) { | |
| 442 | break; | |
| 443 | } | |
| 444 | ||
| 445 | $data = @inflate_add( $inflator, $chunk ); | |
| 446 | ||
| 447 | if( $data !== false && $data !== '' ) { | |
| 448 | $callback( $data ); | |
| 449 | } | |
| 450 | ||
| 451 | if( | |
| 452 | $data === false || | |
| 453 | inflate_get_status( $inflator ) === ZLIB_STREAM_END | |
| 454 | ) { | |
| 455 | break; | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 459 | return true; | |
| 460 | } | |
| 461 | ||
| 462 | private function decompressToString( | |
| 463 | $fileHandle, | |
| 464 | int $maxSize, | |
| 465 | int $cap = 0 | |
| 466 | ): string { | |
| 467 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 468 | ||
| 469 | if( $inflator === false ) { | |
| 470 | return ''; | |
| 471 | } | |
| 472 | ||
| 473 | $result = ''; | |
| 474 | ||
| 475 | while( !feof( $fileHandle ) ) { | |
| 476 | $chunk = fread( $fileHandle, 8192 ); | |
| 477 | ||
| 478 | if( $chunk === false || $chunk === '' ) { | |
| 479 | break; | |
| 480 | } | |
| 481 | ||
| 482 | $data = @inflate_add( $inflator, $chunk ); | |
| 483 | ||
| 484 | if( $data !== false ) { | |
| 485 | $result .= $data; | |
| 486 | } | |
| 487 | ||
| 488 | if( $cap > 0 && strlen( $result ) >= $cap ) { | |
| 489 | return substr( $result, 0, $cap ); | |
| 490 | } | |
| 491 | ||
| 492 | if( | |
| 493 | $data === false || | |
| 494 | inflate_get_status( $inflator ) === ZLIB_STREAM_END | |
| 495 | ) { | |
| 496 | break; | |
| 497 | } | |
| 498 | } | |
| 499 | ||
| 500 | return $result; | |
| 501 | } | |
| 502 | ||
| 503 | private function extractPackedSize( string $packPath, int $offset ): int { | |
| 504 | $fileHandle = $this->getHandle( $packPath ); | |
| 505 | ||
| 506 | if( !$fileHandle ) { | |
| 507 | return 0; | |
| 508 | } | |
| 509 | ||
| 510 | fseek( $fileHandle, $offset ); | |
| 511 | ||
| 512 | $header = $this->readVarInt( $fileHandle ); | |
| 513 | $size = $header['value']; | |
| 514 | $type = ($header['byte'] >> 4) & 7; | |
| 515 | ||
| 516 | if( $type === 6 || $type === 7 ) { | |
| 517 | return $this->readDeltaTargetSize( $fileHandle, $type ); | |
| 518 | } | |
| 519 | ||
| 520 | return $size; | |
| 521 | } | |
| 522 | ||
| 523 | private function handleOfsDelta( | |
| 524 | $fileHandle, | |
| 525 | int $offset, | |
| 526 | int $expectedSize, | |
| 527 | int $cap = 0 | |
| 528 | ): string { | |
| 529 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 530 | $negative = $byte & 127; | |
| 531 | ||
| 532 | while( $byte & 128 ) { | |
| 533 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 534 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 535 | } | |
| 536 | ||
| 537 | $currentPos = ftell( $fileHandle ); | |
| 538 | $baseOffset = $offset - $negative; | |
| 539 | ||
| 540 | fseek( $fileHandle, $baseOffset ); | |
| 541 | ||
| 542 | $baseHeader = $this->readVarInt( $fileHandle ); | |
| 543 | $baseSize = $baseHeader['value']; | |
| 544 | ||
| 545 | fseek( $fileHandle, $baseOffset ); | |
| 546 | ||
| 547 | $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap ); | |
| 548 | ||
| 549 | fseek( $fileHandle, $currentPos ); | |
| 550 | ||
| 551 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 552 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 553 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 554 | ||
| 555 | return $this->applyDelta( $base, $delta, $cap ); | |
| 556 | } | |
| 557 | ||
| 558 | private function handleRefDelta( | |
| 559 | $fileHandle, | |
| 560 | int $expectedSize, | |
| 561 | int $cap = 0 | |
| 562 | ): string { | |
| 563 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 564 | ||
| 565 | if( $cap > 0 ) { | |
| 566 | $base = $this->peek( $baseSha, $cap ) ?? ''; | |
| 567 | } else { | |
| 568 | $base = $this->read( $baseSha ) ?? ''; | |
| 569 | } | |
| 570 | ||
| 571 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 572 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 573 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 574 | ||
| 575 | return $this->applyDelta( $base, $delta, $cap ); | |
| 576 | } | |
| 577 | ||
| 578 | private function applyDelta( string $base, string $delta, int $cap = 0 ): string { | |
| 579 | $position = 0; | |
| 580 | ||
| 581 | $this->skipSize( $delta, $position ); | |
| 582 | $this->skipSize( $delta, $position ); | |
| 583 | ||
| 584 | $output = ''; | |
| 585 | $deltaLength = strlen( $delta ); | |
| 586 | ||
| 587 | while( $position < $deltaLength ) { | |
| 588 | if( $cap > 0 && strlen( $output ) >= $cap ) { | |
| 589 | break; | |
| 590 | } | |
| 591 | ||
| 592 | $opcode = ord( $delta[$position++] ); | |
| 593 | ||
| 594 | if( $opcode & 128 ) { | |
| 595 | $offset = 0; | |
| 596 | $length = 0; | |
| 597 | ||
| 598 | if( $opcode & 0x01 ) { $offset |= ord( $delta[$position++] ); } | |
| 599 | if( $opcode & 0x02 ) { $offset |= ord( $delta[$position++] ) << 8; } | |
| 600 | if( $opcode & 0x04 ) { $offset |= ord( $delta[$position++] ) << 16; } | |
| 601 | if( $opcode & 0x08 ) { $offset |= ord( $delta[$position++] ) << 24; } | |
| 602 | ||
| 603 | if( $opcode & 0x10 ) { $length |= ord( $delta[$position++] ); } | |
| 604 | if( $opcode & 0x20 ) { $length |= ord( $delta[$position++] ) << 8; } | |
| 605 | if( $opcode & 0x40 ) { $length |= ord( $delta[$position++] ) << 16; } | |
| 606 | ||
| 607 | if( $length === 0 ) { $length = 0x10000; } | |
| 608 | ||
| 609 | $output .= substr( $base, $offset, $length ); | |
| 610 | } else { | |
| 611 | $length = $opcode & 127; | |
| 612 | $output .= substr( $delta, $position, $length ); | |
| 613 | $position += $length; | |
| 614 | } | |
| 615 | } | |
| 616 | ||
| 617 | return $output; | |
| 618 | } | |
| 619 | ||
| 620 | private function readVarInt( $fileHandle ): array { | |
| 621 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 622 | $value = $byte & 15; | |
| 623 | $shift = 4; | |
| 624 | $first = $byte; | |
| 625 | ||
| 626 | while( $byte & 128 ) { | |
| 627 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 628 | $value |= (($byte & 127) << $shift); | |
| 629 | $shift += 7; | |
| 630 | } | |
| 631 | ||
| 632 | return ['value' => $value, 'byte' => $first]; | |
| 633 | } | |
| 634 | ||
| 635 | private function readDeltaTargetSize( $fileHandle, int $type ): int { | |
| 636 | if( $type === 6 ) { | |
| 637 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 638 | ||
| 639 | while( $byte & 128 ) { | |
| 640 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 641 | } | |
| 642 | } else { | |
| 643 | fseek( $fileHandle, 20, SEEK_CUR ); | |
| 644 | } | |
| 645 | ||
| 646 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 647 | ||
| 648 | if( $inflator === false ) { | |
| 649 | return 0; | |
| 650 | } | |
| 651 | ||
| 652 | $header = ''; | |
| 653 | $attempts = 0; | |
| 654 | $maxAttempts = 64; | |
| 655 | ||
| 656 | while( | |
| 657 | !feof( $fileHandle ) && | |
| 658 | strlen( $header ) < 32 && | |
| 659 | $attempts < $maxAttempts | |
| 660 | ) { | |
| 661 | $chunk = fread( $fileHandle, 512 ); | |
| 662 | ||
| 663 | if( $chunk === false || $chunk === '' ) { | |
| 664 | break; | |
| 665 | } | |
| 666 | ||
| 667 | $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH ); | |
| 668 | ||
| 669 | if( $output !== false ) { | |
| 670 | $header .= $output; | |
| 671 | } | |
| 672 | ||
| 673 | if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | |
| 674 | break; | |
| 675 | } | |
| 676 | ||
| 677 | $attempts++; | |
| 678 | } | |
| 679 | ||
| 680 | $position = 0; | |
| 681 | ||
| 682 | if( strlen( $header ) > 0 ) { | |
| 683 | $this->skipSize( $header, $position ); | |
| 684 | ||
| 685 | return $this->readSize( $header, $position ); | |
| 686 | } | |
| 687 | ||
| 688 | return 0; | |
| 689 | } | |
| 690 | ||
| 691 | private function skipSize( string $data, int &$position ): void { | |
| 692 | $length = strlen( $data ); | |
| 693 | ||
| 694 | while( $position < $length && (ord( $data[$position++] ) & 128) ) { | |
| 695 | } | |
| 696 | } | |
| 697 | ||
| 698 | private function readSize( string $data, int &$position ): int { | |
| 699 | $byte = ord( $data[$position++] ); | |
| 700 | $value = $byte & 127; | |
| 701 | $shift = 7; | |
| 702 | ||
| 703 | while( $byte & 128 ) { | |
| 704 | $byte = ord( $data[$position++] ); | |
| 705 | $value |= (($byte & 127) << $shift); | |
| 706 | $shift += 7; | |
| 707 | } | |
| 708 | ||
| 709 | return $value; | |
| 3 | private const MAX_READ = 1040576; | |
| 4 | private const MAX_RAM = 1048576; | |
| 5 | private const MAX_BASE_RAM = 524288; | |
| 6 | private const MAX_DEPTH = 50; | |
| 7 | ||
| 8 | private string $objectsPath; | |
| 9 | private array $packFiles; | |
| 10 | private string $lastPack = ''; | |
| 11 | private array $fileHandles; | |
| 12 | private array $fanoutCache; | |
| 13 | private array $shaBucketCache; | |
| 14 | private array $offsetBucketCache; | |
| 15 | ||
| 16 | public function __construct( string $objectsPath ) { | |
| 17 | $this->objectsPath = $objectsPath; | |
| 18 | $this->packFiles = glob( "{$this->objectsPath}/pack/*.idx" ) ?: []; | |
| 19 | $this->fileHandles = []; | |
| 20 | $this->fanoutCache = []; | |
| 21 | $this->shaBucketCache = []; | |
| 22 | $this->offsetBucketCache = []; | |
| 23 | } | |
| 24 | ||
| 25 | public function __destruct() { | |
| 26 | foreach( $this->fileHandles as $handle ) { | |
| 27 | if( is_resource( $handle ) ) { | |
| 28 | fclose( $handle ); | |
| 29 | } | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | public function peek( string $sha, int $len = 12 ): string { | |
| 34 | $info = $this->findPackInfo( $sha ); | |
| 35 | $result = ''; | |
| 36 | ||
| 37 | if( $info['offset'] !== 0 ) { | |
| 38 | $handle = $this->getHandle( $info['file'] ); | |
| 39 | ||
| 40 | if( $handle ) { | |
| 41 | $result = $this->readPackEntry( | |
| 42 | $handle, | |
| 43 | $info['offset'], | |
| 44 | $len, | |
| 45 | $len | |
| 46 | ); | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | return $result; | |
| 51 | } | |
| 52 | ||
| 53 | public function read( string $sha ): string { | |
| 54 | $info = $this->findPackInfo( $sha ); | |
| 55 | $result = ''; | |
| 56 | ||
| 57 | if( $info['offset'] !== 0 ) { | |
| 58 | $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 59 | ||
| 60 | if( $size <= self::MAX_RAM ) { | |
| 61 | $handle = $this->getHandle( $info['file'] ); | |
| 62 | ||
| 63 | if( $handle ) { | |
| 64 | $result = $this->readPackEntry( | |
| 65 | $handle, | |
| 66 | $info['offset'], | |
| 67 | $size | |
| 68 | ); | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | return $result; | |
| 74 | } | |
| 75 | ||
| 76 | public function stream( string $sha, callable $callback ): bool { | |
| 77 | return $this->streamInternal( $sha, $callback, 0 ); | |
| 78 | } | |
| 79 | ||
| 80 | private function streamInternal( | |
| 81 | string $sha, | |
| 82 | callable $callback, | |
| 83 | int $depth | |
| 84 | ): bool { | |
| 85 | $info = $this->findPackInfo( $sha ); | |
| 86 | $result = false; | |
| 87 | ||
| 88 | if( $info['offset'] !== 0 ) { | |
| 89 | $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 90 | $handle = $this->getHandle( $info['file'] ); | |
| 91 | ||
| 92 | if( $handle ) { | |
| 93 | $result = $this->streamPackEntry( | |
| 94 | $handle, | |
| 95 | $info['offset'], | |
| 96 | $size, | |
| 97 | $callback, | |
| 98 | $depth | |
| 99 | ); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | return $result; | |
| 104 | } | |
| 105 | ||
| 106 | public function getSize( string $sha ): int { | |
| 107 | $info = $this->findPackInfo( $sha ); | |
| 108 | $result = 0; | |
| 109 | ||
| 110 | if( $info['offset'] !== 0 ) { | |
| 111 | $result = $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 112 | } | |
| 113 | ||
| 114 | return $result; | |
| 115 | } | |
| 116 | ||
| 117 | private function findPackInfo( string $sha ): array { | |
| 118 | $result = [ 'offset' => 0, 'file' => '' ]; | |
| 119 | ||
| 120 | if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) { | |
| 121 | $binarySha = hex2bin( $sha ); | |
| 122 | if( $this->lastPack !== '' ) { | |
| 123 | $offset = $this->findInIdx( $this->lastPack, $binarySha ); | |
| 124 | ||
| 125 | if( $offset !== 0 ) { | |
| 126 | $result = [ | |
| 127 | 'file' => str_replace( '.idx', '.pack', $this->lastPack ), | |
| 128 | 'offset' => $offset | |
| 129 | ]; | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | if( $result['offset'] === 0 ) { | |
| 134 | foreach( $this->packFiles as $indexFile ) { | |
| 135 | if( $indexFile !== $this->lastPack ) { | |
| 136 | $offset = $this->findInIdx( $indexFile, $binarySha ); | |
| 137 | ||
| 138 | if( $offset !== 0 ) { | |
| 139 | $this->lastPack = $indexFile; | |
| 140 | $result = [ | |
| 141 | 'file' => str_replace( '.idx', '.pack', $indexFile ), | |
| 142 | 'offset' => $offset | |
| 143 | ]; | |
| 144 | break; | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | return $result; | |
| 152 | } | |
| 153 | ||
| 154 | private function findInIdx( string $indexFile, string $binarySha ): int { | |
| 155 | $handle = $this->getHandle( $indexFile ); | |
| 156 | $result = 0; | |
| 157 | ||
| 158 | if( $handle ) { | |
| 159 | if( !isset( $this->fanoutCache[$indexFile] ) ) { | |
| 160 | fseek( $handle, 0 ); | |
| 161 | $head = fread( $handle, 8 ); | |
| 162 | ||
| 163 | if( $head === "\377tOc\0\0\0\2" ) { | |
| 164 | $this->fanoutCache[$indexFile] = array_values( | |
| 165 | unpack( 'N*', fread( $handle, 1024 ) ) | |
| 166 | ); | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | if( isset( $this->fanoutCache[$indexFile] ) ) { | |
| 171 | $fanout = $this->fanoutCache[$indexFile]; | |
| 172 | $byte = ord( $binarySha[0] ); | |
| 173 | $start = $byte === 0 ? 0 : $fanout[$byte - 1]; | |
| 174 | $end = $fanout[$byte]; | |
| 175 | ||
| 176 | if( $end > $start ) { | |
| 177 | $result = $this->binarySearchIdx( | |
| 178 | $indexFile, | |
| 179 | $handle, | |
| 180 | $start, | |
| 181 | $end, | |
| 182 | $binarySha, | |
| 183 | $fanout[255] | |
| 184 | ); | |
| 185 | } | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | return $result; | |
| 190 | } | |
| 191 | ||
| 192 | private function binarySearchIdx( | |
| 193 | string $indexFile, | |
| 194 | $handle, | |
| 195 | int $start, | |
| 196 | int $end, | |
| 197 | string $binarySha, | |
| 198 | int $total | |
| 199 | ): int { | |
| 200 | $key = "$indexFile:$start"; | |
| 201 | $count = $end - $start; | |
| 202 | $result = 0; | |
| 203 | ||
| 204 | if( !isset( $this->shaBucketCache[$key] ) ) { | |
| 205 | fseek( $handle, 1032 + ($start * 20) ); | |
| 206 | $this->shaBucketCache[$key] = fread( $handle, $count * 20 ); | |
| 207 | ||
| 208 | fseek( $handle, 1032 + ($total * 24) + ($start * 4) ); | |
| 209 | $this->offsetBucketCache[$key] = fread( $handle, $count * 4 ); | |
| 210 | } | |
| 211 | ||
| 212 | $shaBlock = $this->shaBucketCache[$key]; | |
| 213 | $low = 0; | |
| 214 | $high = $count - 1; | |
| 215 | $found = -1; | |
| 216 | ||
| 217 | while( $low <= $high ) { | |
| 218 | $mid = ($low + $high) >> 1; | |
| 219 | $cmp = substr( $shaBlock, $mid * 20, 20 ); | |
| 220 | ||
| 221 | if( $cmp < $binarySha ) { | |
| 222 | $low = $mid + 1; | |
| 223 | } elseif( $cmp > $binarySha ) { | |
| 224 | $high = $mid - 1; | |
| 225 | } else { | |
| 226 | $found = $mid; | |
| 227 | break; | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | if( $found !== -1 ) { | |
| 232 | $packed = substr( $this->offsetBucketCache[$key], $found * 4, 4 ); | |
| 233 | $offset = unpack( 'N', $packed )[1]; | |
| 234 | ||
| 235 | if( $offset & 0x80000000 ) { | |
| 236 | $pos64 = 1032 + ($total * 28) + (($offset & 0x7FFFFFFF) * 8); | |
| 237 | fseek( $handle, $pos64 ); | |
| 238 | $offset = unpack( 'J', fread( $handle, 8 ) )[1]; | |
| 239 | } | |
| 240 | $result = (int)$offset; | |
| 241 | } | |
| 242 | ||
| 243 | return $result; | |
| 244 | } | |
| 245 | ||
| 246 | private function readPackEntry( | |
| 247 | $handle, | |
| 248 | int $offset, | |
| 249 | int $size, | |
| 250 | int $cap = 0 | |
| 251 | ): string { | |
| 252 | fseek( $handle, $offset ); | |
| 253 | $header = $this->readVarInt( $handle ); | |
| 254 | $type = ($header['byte'] >> 4) & 7; | |
| 255 | ||
| 256 | return ($type === 6) | |
| 257 | ? $this->handleOfsDelta( $handle, $offset, $size, $cap ) | |
| 258 | : (($type === 7) | |
| 259 | ? $this->handleRefDelta( $handle, $size, $cap ) | |
| 260 | : $this->decompressToString( $handle, $cap )); | |
| 261 | } | |
| 262 | ||
| 263 | private function streamPackEntry( | |
| 264 | $handle, | |
| 265 | int $offset, | |
| 266 | int $size, | |
| 267 | callable $callback, | |
| 268 | int $depth = 0 | |
| 269 | ): bool { | |
| 270 | fseek( $handle, $offset ); | |
| 271 | $header = $this->readVarInt( $handle ); | |
| 272 | $type = ($header['byte'] >> 4) & 7; | |
| 273 | ||
| 274 | return ($type === 6 || $type === 7) | |
| 275 | ? $this->streamDeltaObject( $handle, $offset, $type, $callback, $depth ) | |
| 276 | : $this->streamDecompression( $handle, $callback ); | |
| 277 | } | |
| 278 | ||
| 279 | private function streamDeltaObject( | |
| 280 | $handle, | |
| 281 | int $offset, | |
| 282 | int $type, | |
| 283 | callable $callback, | |
| 284 | int $depth = 0 | |
| 285 | ): bool { | |
| 286 | if( $depth >= self::MAX_DEPTH ) { | |
| 287 | return false; | |
| 288 | } | |
| 289 | ||
| 290 | fseek( $handle, $offset ); | |
| 291 | $this->readVarInt( $handle ); | |
| 292 | $result = false; | |
| 293 | ||
| 294 | if( $type === 6 ) { | |
| 295 | $neg = $this->readOffsetDelta( $handle ); | |
| 296 | $deltaPos = ftell( $handle ); | |
| 297 | $base = ''; | |
| 298 | ||
| 299 | $baseSize = $this->extractPackedSize( $handle, $offset - $neg ); | |
| 300 | ||
| 301 | if( $baseSize > self::MAX_BASE_RAM ) { | |
| 302 | return false; | |
| 303 | } | |
| 304 | ||
| 305 | $this->streamPackEntry( | |
| 306 | $handle, | |
| 307 | $offset - $neg, | |
| 308 | 0, | |
| 309 | function( $c ) use ( &$base ) { $base .= $c; }, | |
| 310 | $depth + 1 | |
| 311 | ); | |
| 312 | ||
| 313 | fseek( $handle, $deltaPos ); | |
| 314 | $result = $this->applyDeltaStream( $handle, $base, $callback ); | |
| 315 | } else { | |
| 316 | $baseSha = bin2hex( fread( $handle, 20 ) ); | |
| 317 | $baseSize = $this->getSize( $baseSha ); | |
| 318 | ||
| 319 | if( $baseSize > self::MAX_BASE_RAM ) { | |
| 320 | return false; | |
| 321 | } | |
| 322 | ||
| 323 | $base = ''; | |
| 324 | ||
| 325 | if( $this->streamInternal( $baseSha, function( $c ) use ( &$base ) { | |
| 326 | $base .= $c; | |
| 327 | }, $depth + 1 ) ) { | |
| 328 | $result = $this->applyDeltaStream( $handle, $base, $callback ); | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | return $result; | |
| 333 | } | |
| 334 | ||
| 335 | private function applyDeltaStream( | |
| 336 | $handle, | |
| 337 | string $base, | |
| 338 | callable $callback | |
| 339 | ): bool { | |
| 340 | $infl = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 341 | $ok = false; | |
| 342 | ||
| 343 | if( $infl ) { | |
| 344 | $state = 0; | |
| 345 | $buffer = ''; | |
| 346 | $ok = true; | |
| 347 | ||
| 348 | while( !feof( $handle ) ) { | |
| 349 | $chunk = fread( $handle, 8192 ); | |
| 350 | ||
| 351 | if( $chunk === '' ) { | |
| 352 | break; | |
| 353 | } | |
| 354 | ||
| 355 | $data = @inflate_add( $infl, $chunk ); | |
| 356 | ||
| 357 | if( $data === false ) { | |
| 358 | $ok = false; | |
| 359 | break; | |
| 360 | } | |
| 361 | ||
| 362 | $buffer .= $data; | |
| 363 | ||
| 364 | while( true ) { | |
| 365 | $len = strlen( $buffer ); | |
| 366 | ||
| 367 | if( $len === 0 ) { | |
| 368 | break; | |
| 369 | } | |
| 370 | ||
| 371 | if( $state < 2 ) { | |
| 372 | $pos = 0; | |
| 373 | while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) { $pos++; } | |
| 374 | ||
| 375 | if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) { | |
| 376 | break; | |
| 377 | } | |
| 378 | ||
| 379 | $buffer = substr( $buffer, $pos + 1 ); | |
| 380 | $state++; | |
| 381 | continue; | |
| 382 | } | |
| 383 | ||
| 384 | $op = ord( $buffer[0] ); | |
| 385 | ||
| 386 | if( $op & 128 ) { | |
| 387 | $need = $this->getCopyInstructionSize( $op ); | |
| 388 | ||
| 389 | if( $len < 1 + $need ) { | |
| 390 | break; | |
| 391 | } | |
| 392 | ||
| 393 | $info = $this->parseCopyInstruction( $op, $buffer, 1 ); | |
| 394 | ||
| 395 | $callback( substr( $base, $info['off'], $info['len'] ) ); | |
| 396 | $buffer = substr( $buffer, 1 + $need ); | |
| 397 | } else { | |
| 398 | $ln = $op & 127; | |
| 399 | ||
| 400 | if( $len < 1 + $ln ) { | |
| 401 | break; | |
| 402 | } | |
| 403 | ||
| 404 | $callback( substr( $buffer, 1, $ln ) ); | |
| 405 | $buffer = substr( $buffer, 1 + $ln ); | |
| 406 | } | |
| 407 | } | |
| 408 | ||
| 409 | if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) { | |
| 410 | break; | |
| 411 | } | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | return $ok; | |
| 416 | } | |
| 417 | ||
| 418 | private function streamDecompression( $handle, callable $callback ): bool { | |
| 419 | $infl = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 420 | ||
| 421 | if( !$infl ) { | |
| 422 | return false; | |
| 423 | } | |
| 424 | ||
| 425 | while( !feof( $handle ) ) { | |
| 426 | $chunk = fread( $handle, 8192 ); | |
| 427 | ||
| 428 | if( $chunk === '' ) { | |
| 429 | break; | |
| 430 | } | |
| 431 | ||
| 432 | $data = @inflate_add( $infl, $chunk ); | |
| 433 | ||
| 434 | if( $data !== false && $data !== '' ) { | |
| 435 | $callback( $data ); | |
| 436 | } | |
| 437 | ||
| 438 | if( $data === false || | |
| 439 | inflate_get_status( $infl ) === ZLIB_STREAM_END ) { | |
| 440 | break; | |
| 441 | } | |
| 442 | } | |
| 443 | ||
| 444 | return true; | |
| 445 | } | |
| 446 | ||
| 447 | private function decompressToString( | |
| 448 | $handle, | |
| 449 | int $cap = 0 | |
| 450 | ): string { | |
| 451 | $infl = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 452 | $res = ''; | |
| 453 | ||
| 454 | if( $infl ) { | |
| 455 | while( !feof( $handle ) ) { | |
| 456 | $chunk = fread( $handle, 8192 ); | |
| 457 | ||
| 458 | if( $chunk === '' ) { | |
| 459 | break; | |
| 460 | } | |
| 461 | ||
| 462 | $data = @inflate_add( $infl, $chunk ); | |
| 463 | ||
| 464 | if( $data !== false ) { | |
| 465 | $res .= $data; | |
| 466 | } | |
| 467 | ||
| 468 | if( $cap > 0 && strlen( $res ) >= $cap ) { | |
| 469 | $res = substr( $res, 0, $cap ); | |
| 470 | break; | |
| 471 | } | |
| 472 | ||
| 473 | if( $data === false || | |
| 474 | inflate_get_status( $infl ) === ZLIB_STREAM_END ) { | |
| 475 | break; | |
| 476 | } | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | return $res; | |
| 481 | } | |
| 482 | ||
| 483 | private function extractPackedSize( $packPathOrHandle, int $offset ): int { | |
| 484 | $handle = is_resource( $packPathOrHandle ) | |
| 485 | ? $packPathOrHandle | |
| 486 | : $this->getHandle( $packPathOrHandle ); | |
| 487 | $size = 0; | |
| 488 | ||
| 489 | if( $handle ) { | |
| 490 | fseek( $handle, $offset ); | |
| 491 | $header = $this->readVarInt( $handle ); | |
| 492 | $size = $header['value']; | |
| 493 | $type = ($header['byte'] >> 4) & 7; | |
| 494 | ||
| 495 | if( $type === 6 || $type === 7 ) { | |
| 496 | $size = $this->readDeltaTargetSize( $handle, $type ); | |
| 497 | } | |
| 498 | } | |
| 499 | ||
| 500 | return $size; | |
| 501 | } | |
| 502 | ||
| 503 | private function handleOfsDelta( | |
| 504 | $handle, | |
| 505 | int $offset, | |
| 506 | int $size, | |
| 507 | int $cap | |
| 508 | ): string { | |
| 509 | $neg = $this->readOffsetDelta( $handle ); | |
| 510 | $cur = ftell( $handle ); | |
| 511 | $base = $offset - $neg; | |
| 512 | ||
| 513 | fseek( $handle, $base ); | |
| 514 | $bHead = $this->readVarInt( $handle ); | |
| 515 | ||
| 516 | fseek( $handle, $base ); | |
| 517 | $bData = $this->readPackEntry( $handle, $base, $bHead['value'], $cap ); | |
| 518 | ||
| 519 | fseek( $handle, $cur ); | |
| 520 | $rem = min( self::MAX_READ, max( $size * 2, 1048576 ) ); | |
| 521 | $comp = fread( $handle, $rem ); | |
| 522 | $delta = @gzuncompress( $comp ) ?: ''; | |
| 523 | ||
| 524 | return $this->applyDelta( $bData, $delta, $cap ); | |
| 525 | } | |
| 526 | ||
| 527 | private function handleRefDelta( $handle, int $size, int $cap ): string { | |
| 528 | $sha = bin2hex( fread( $handle, 20 ) ); | |
| 529 | $bas = $cap > 0 ? $this->peek( $sha, $cap ) : $this->read( $sha ); | |
| 530 | $rem = min( self::MAX_READ, max( $size * 2, 1048576 ) ); | |
| 531 | $cmp = fread( $handle, $rem ); | |
| 532 | $del = @gzuncompress( $cmp ) ?: ''; | |
| 533 | ||
| 534 | return $this->applyDelta( $bas, $del, $cap ); | |
| 535 | } | |
| 536 | ||
| 537 | private function applyDelta( string $base, string $delta, int $cap ): string { | |
| 538 | $pos = 0; | |
| 539 | $res = $this->readDeltaSize( $delta, $pos ); | |
| 540 | $pos += $res['used']; | |
| 541 | $res = $this->readDeltaSize( $delta, $pos ); | |
| 542 | $pos += $res['used']; | |
| 543 | ||
| 544 | $out = ''; | |
| 545 | $len = strlen( $delta ); | |
| 546 | ||
| 547 | while( $pos < $len ) { | |
| 548 | if( $cap > 0 && strlen( $out ) >= $cap ) { | |
| 549 | break; | |
| 550 | } | |
| 551 | ||
| 552 | $op = ord( $delta[$pos++] ); | |
| 553 | ||
| 554 | if( $op & 128 ) { | |
| 555 | $info = $this->parseCopyInstruction( $op, $delta, $pos ); | |
| 556 | $out .= substr( $base, $info['off'], $info['len'] ); | |
| 557 | $pos += $info['used']; | |
| 558 | } else { | |
| 559 | $ln = $op & 127; | |
| 560 | $out .= substr( $delta, $pos, $ln ); | |
| 561 | $pos += $ln; | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | return $out; | |
| 566 | } | |
| 567 | ||
| 568 | private function parseCopyInstruction( | |
| 569 | int $op, | |
| 570 | string $data, | |
| 571 | int $pos | |
| 572 | ): array { | |
| 573 | $off = 0; | |
| 574 | $len = 0; | |
| 575 | $ptr = $pos; | |
| 576 | ||
| 577 | if( $op & 0x01 ) { $off |= ord( $data[$ptr++] ); } | |
| 578 | if( $op & 0x02 ) { $off |= ord( $data[$ptr++] ) << 8; } | |
| 579 | if( $op & 0x04 ) { $off |= ord( $data[$ptr++] ) << 16; } | |
| 580 | if( $op & 0x08 ) { $off |= ord( $data[$ptr++] ) << 24; } | |
| 581 | ||
| 582 | if( $op & 0x10 ) { $len |= ord( $data[$ptr++] ); } | |
| 583 | if( $op & 0x20 ) { $len |= ord( $data[$ptr++] ) << 8; } | |
| 584 | if( $op & 0x40 ) { $len |= ord( $data[$ptr++] ) << 16; } | |
| 585 | ||
| 586 | return [ | |
| 587 | 'off' => $off, | |
| 588 | 'len' => $len ?: 0x10000, | |
| 589 | 'used' => $ptr - $pos | |
| 590 | ]; | |
| 591 | } | |
| 592 | ||
| 593 | private function getCopyInstructionSize( int $op ): int { | |
| 594 | $c = $op & 0x7F; | |
| 595 | $c = $c - (( $c >> 1 ) & 0x55); | |
| 596 | $c = (( $c >> 2 ) & 0x33) + ( $c & 0x33 ); | |
| 597 | $c = (( $c >> 4 ) + $c) & 0x0F; | |
| 598 | ||
| 599 | return $c; | |
| 600 | } | |
| 601 | ||
| 602 | private function readVarInt( $handle ): array { | |
| 603 | $byte = ord( fread( $handle, 1 ) ); | |
| 604 | $val = $byte & 15; | |
| 605 | $shft = 4; | |
| 606 | $fst = $byte; | |
| 607 | ||
| 608 | while( $byte & 128 ) { | |
| 609 | $byte = ord( fread( $handle, 1 ) ); | |
| 610 | $val |= (($byte & 127) << $shft); | |
| 611 | $shft += 7; | |
| 612 | } | |
| 613 | ||
| 614 | return [ 'value' => $val, 'byte' => $fst ]; | |
| 615 | } | |
| 616 | ||
| 617 | private function readOffsetDelta( $handle ): int { | |
| 618 | $byte = ord( fread( $handle, 1 ) ); | |
| 619 | $neg = $byte & 127; | |
| 620 | ||
| 621 | while( $byte & 128 ) { | |
| 622 | $byte = ord( fread( $handle, 1 ) ); | |
| 623 | $neg = (($neg + 1) << 7) | ($byte & 127); | |
| 624 | } | |
| 625 | ||
| 626 | return $neg; | |
| 627 | } | |
| 628 | ||
| 629 | private function readDeltaTargetSize( $handle, int $type ): int { | |
| 630 | if( $type === 6 ) { | |
| 631 | $b = ord( fread( $handle, 1 ) ); | |
| 632 | while( $b & 128 ) { $b = ord( fread( $handle, 1 ) ); } | |
| 633 | } else { | |
| 634 | fseek( $handle, 20, SEEK_CUR ); | |
| 635 | } | |
| 636 | ||
| 637 | $infl = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 638 | $head = ''; | |
| 639 | $try = 0; | |
| 640 | ||
| 641 | if( $infl ) { | |
| 642 | while( !feof( $handle ) && strlen( $head ) < 32 && $try < 64 ) { | |
| 643 | $chunk = fread( $handle, 512 ); | |
| 644 | ||
| 645 | if( $chunk === '' ) { | |
| 646 | break; | |
| 647 | } | |
| 648 | ||
| 649 | $out = @inflate_add( $infl, $chunk, ZLIB_NO_FLUSH ); | |
| 650 | ||
| 651 | if( $out !== false ) { | |
| 652 | $head .= $out; | |
| 653 | } | |
| 654 | ||
| 655 | if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) { | |
| 656 | break; | |
| 657 | } | |
| 658 | ||
| 659 | $try++; | |
| 660 | } | |
| 661 | } | |
| 662 | ||
| 663 | $pos = 0; | |
| 664 | ||
| 665 | if( strlen( $head ) > 0 ) { | |
| 666 | $res = $this->readDeltaSize( $head, $pos ); | |
| 667 | $pos += $res['used']; | |
| 668 | $res = $this->readDeltaSize( $head, $pos ); | |
| 669 | ||
| 670 | return $res['val']; | |
| 671 | } | |
| 672 | ||
| 673 | return 0; | |
| 674 | } | |
| 675 | ||
| 676 | private function readDeltaSize( string $data, int $pos ): array { | |
| 677 | $len = strlen( $data ); | |
| 678 | $val = 0; | |
| 679 | $shift = 0; | |
| 680 | $start = $pos; | |
| 681 | ||
| 682 | while( $pos < $len ) { | |
| 683 | $byte = ord( $data[$pos++] ); | |
| 684 | $val |= ($byte & 0x7F) << $shift; | |
| 685 | ||
| 686 | if( !($byte & 0x80) ) { | |
| 687 | break; | |
| 688 | } | |
| 689 | ||
| 690 | $shift += 7; | |
| 691 | } | |
| 692 | ||
| 693 | return [ 'val' => $val, 'used' => $pos - $start ]; | |
| 710 | 694 | } |
| 711 | 695 |
| 8 | 8 | |
| 9 | 9 | public function resolve( string $input ): string { |
| 10 | if( preg_match( '/^[0-9a-f]{40}$/', $input ) ) { | |
| 11 | return $input; | |
| 12 | } | |
| 13 | ||
| 14 | $headFile = "{$this->repoPath}/HEAD"; | |
| 10 | $result = ''; | |
| 15 | 11 | |
| 16 | if( $input === 'HEAD' && file_exists( $headFile ) ) { | |
| 17 | $head = trim( file_get_contents( $headFile ) ); | |
| 12 | if( preg_match( '/^[0-9a-f]{40}$/', $input ) ) { | |
| 13 | $result = $input; | |
| 14 | } else { | |
| 15 | $headFile = "{$this->repoPath}/HEAD"; | |
| 18 | 16 | |
| 19 | return strpos( $head, 'ref: ' ) === 0 | |
| 20 | ? $this->resolve( substr( $head, 5 ) ) | |
| 21 | : $head; | |
| 17 | if( $input === 'HEAD' && file_exists( $headFile ) ) { | |
| 18 | $head = trim( file_get_contents( $headFile ) ); | |
| 19 | $result = strpos( $head, 'ref: ' ) === 0 | |
| 20 | ? $this->resolve( substr( $head, 5 ) ) | |
| 21 | : $head; | |
| 22 | } else { | |
| 23 | $result = $this->resolveRef( $input ); | |
| 24 | } | |
| 22 | 25 | } |
| 23 | 26 | |
| 24 | return $this->resolveRef( $input ); | |
| 27 | return $result; | |
| 25 | 28 | } |
| 26 | 29 | |
| ... | ||
| 34 | 37 | } |
| 35 | 38 | ); |
| 39 | ||
| 40 | $found = []; | |
| 36 | 41 | |
| 37 | 42 | foreach( ['main', 'master', 'trunk', 'develop'] as $try ) { |
| 38 | 43 | if( isset( $branches[$try] ) ) { |
| 39 | return ['name' => $try, 'hash' => $branches[$try]]; | |
| 44 | $found = [ 'name' => $try, 'hash' => $branches[$try] ]; | |
| 45 | break; | |
| 40 | 46 | } |
| 41 | 47 | } |
| 42 | 48 | |
| 43 | $firstKey = array_key_first( $branches ); | |
| 49 | if( empty( $found ) ) { | |
| 50 | $key = array_key_first( $branches ); | |
| 51 | $found = $key | |
| 52 | ? [ 'name' => $key, 'hash' => $branches[$key] ] | |
| 53 | : [ 'name' => '', 'hash' => '' ]; | |
| 54 | } | |
| 44 | 55 | |
| 45 | return $firstKey | |
| 46 | ? ['name' => $firstKey, 'hash' => $branches[$firstKey]] | |
| 47 | : ['name' => '', 'hash' => '']; | |
| 56 | return $found; | |
| 48 | 57 | } |
| 49 | 58 | |
| 50 | 59 | public function scanRefs( string $prefix, callable $callback ): void { |
| 51 | 60 | $dir = "{$this->repoPath}/$prefix"; |
| 52 | 61 | |
| 53 | 62 | if( is_dir( $dir ) ) { |
| 54 | 63 | $files = array_diff( scandir( $dir ), ['.', '..'] ); |
| 55 | ||
| 56 | 64 | foreach( $files as $file ) { |
| 57 | 65 | $callback( $file, trim( file_get_contents( "$dir/$file" ) ) ); |
| 58 | 66 | } |
| 59 | 67 | } |
| 60 | 68 | } |
| 61 | 69 | |
| 62 | 70 | private function resolveRef( string $input ): string { |
| 63 | $paths = [$input, "refs/heads/$input", "refs/tags/$input"]; | |
| 71 | $paths = [$input, "refs/heads/$input", "refs/tags/$input"]; | |
| 72 | $result = ''; | |
| 64 | 73 | |
| 65 | 74 | foreach( $paths as $ref ) { |
| 66 | 75 | $path = "{$this->repoPath}/$ref"; |
| 67 | ||
| 68 | 76 | if( file_exists( $path ) ) { |
| 69 | return trim( file_get_contents( $path ) ); | |
| 77 | $result = trim( file_get_contents( $path ) ); | |
| 78 | break; | |
| 70 | 79 | } |
| 71 | 80 | } |
| 72 | 81 | |
| 73 | $packedPath = "{$this->repoPath}/packed-refs"; | |
| 82 | if( $result === '' ) { | |
| 83 | $packedPath = "{$this->repoPath}/packed-refs"; | |
| 84 | if( file_exists( $packedPath ) ) { | |
| 85 | $result = $this->findInPackedRefs( $packedPath, $input ); | |
| 86 | } | |
| 87 | } | |
| 74 | 88 | |
| 75 | return file_exists( $packedPath ) | |
| 76 | ? $this->findInPackedRefs( $packedPath, $input ) | |
| 77 | : ''; | |
| 89 | return $result; | |
| 78 | 90 | } |
| 79 | 91 | |
| 80 | 92 | private function findInPackedRefs( string $path, string $input ): string { |
| 81 | 93 | $targets = [$input, "refs/heads/$input", "refs/tags/$input"]; |
| 82 | ||
| 83 | foreach( file( $path ) as $line ) { | |
| 84 | if( $line[0] === '#' || $line[0] === '^' ) { | |
| 85 | continue; | |
| 86 | } | |
| 87 | ||
| 88 | $parts = explode( ' ', trim( $line ) ); | |
| 94 | $lines = file( $path ); | |
| 95 | $result = ''; | |
| 89 | 96 | |
| 90 | if( count( $parts ) >= 2 && in_array( $parts[1], $targets ) ) { | |
| 91 | return $parts[0]; | |
| 97 | foreach( $lines as $line ) { | |
| 98 | if( $line[0] !== '#' && $line[0] !== '^' ) { | |
| 99 | $parts = explode( ' ', trim( $line ) ); | |
| 100 | if( count( $parts ) >= 2 && in_array( $parts[1], $targets ) ) { | |
| 101 | $result = $parts[0]; | |
| 102 | break; | |
| 103 | } | |
| 92 | 104 | } |
| 93 | 105 | } |
| 94 | 106 | |
| 95 | return ''; | |
| 107 | return $result; | |
| 96 | 108 | } |
| 97 | 109 | } |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#8b949e"> | |
| 2 | <path d="M12 2 L7 7 H10 V9 H14 V7 H17 Z" /> | |
| 3 | <circle cx="8" cy="12" r="1.5" /> | |
| 4 | <circle cx="12" cy="12" r="1.5" /> | |
| 5 | <circle cx="16" cy="12" r="1.5" /> | |
| 6 | <path d="M12 22 L7 17 H10 V15 H14 V17 H17 Z" /> | |
| 7 | </svg> | |
| 1 | 8 |
| 3 | 3 | require_once __DIR__ . '/Router.php'; |
| 4 | 4 | |
| 5 | Config::init(); | |
| 6 | ||
| 7 | $router = new Router( Config::getReposPath() ); | |
| 8 | $page = $router->route(); | |
| 5 | $config = new Config(); | |
| 6 | $router = $config->createRouter(); | |
| 7 | $page = $router->route(); | |
| 9 | 8 | |
| 10 | 9 | $page->render(); |
| 5 | 5 | |
| 6 | 6 | abstract class BasePage implements Page { |
| 7 | protected $repositories; | |
| 8 | protected $title; | |
| 7 | private $repositories; | |
| 8 | private $title; | |
| 9 | 9 | |
| 10 | public function __construct( array $repositories ) { | |
| 10 | public function __construct( array $repositories, string $title = '' ) { | |
| 11 | 11 | $this->repositories = $repositories; |
| 12 | $this->title = $title; | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | protected function renderLayout( $contentCallback, $currentRepo = null ) { | |
| 15 | protected function renderLayout( | |
| 16 | $contentCallback, | |
| 17 | array $currentRepo = [] | |
| 18 | ) { | |
| 19 | $siteTitle = Config::SITE_TITLE; | |
| 20 | $pageTitle = $this->title | |
| 21 | ? ' - ' . htmlspecialchars( $this->title ) | |
| 22 | : ''; | |
| 23 | ||
| 15 | 24 | ?> |
| 16 | <!DOCTYPE html> | |
| 17 | <html lang="en"> | |
| 18 | <head> | |
| 19 | <meta charset="UTF-8"> | |
| 20 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 21 | <title><?php | |
| 22 | echo Config::SITE_TITLE . | |
| 23 | ( $this->title ? ' - ' . htmlspecialchars( $this->title ) : '' ); | |
| 24 | ?></title> | |
| 25 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| 26 | <link rel="stylesheet" href="/styles/repo.css"> | |
| 27 | </head> | |
| 28 | <body> | |
| 29 | <div class="container"> | |
| 30 | <header> | |
| 31 | <h1><?php echo Config::SITE_TITLE; ?></h1> | |
| 32 | <nav class="nav"> | |
| 33 | <a href="<?php echo (new UrlBuilder())->build(); ?>">Home</a> | |
| 34 | <?php if( $currentRepo ): | |
| 35 | $safeName = $currentRepo['safe_name']; | |
| 36 | ?> | |
| 37 | <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tree' )->build(); ?>">Files</a> | |
| 38 | <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'commits' )->build(); ?>">Commits</a> | |
| 39 | <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tags' )->build(); ?>">Tags</a> | |
| 40 | <?php endif; ?> | |
| 25 | <!DOCTYPE html> | |
| 26 | <html lang="en"> | |
| 27 | <head> | |
| 28 | <meta charset="UTF-8"> | |
| 29 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 30 | <title><?php echo $siteTitle . $pageTitle; ?></title> | |
| 31 | <link rel="stylesheet" | |
| 32 | href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> | |
| 33 | <link rel="stylesheet" href="/styles/repo.css"> | |
| 34 | </head> | |
| 35 | <body> | |
| 36 | <div class="container"> | |
| 37 | <header> | |
| 38 | <h1><?php echo Config::SITE_TITLE; ?></h1> | |
| 41 | 39 | |
| 42 | <?php if( $currentRepo ): ?> | |
| 43 | <div class="repo-selector"> | |
| 44 | <label>Repository:</label> | |
| 45 | <select onchange="<?php echo (new UrlBuilder())->withSwitcher( 'this.value' )->build(); ?>"> | |
| 46 | <?php foreach( $this->repositories as $r ): ?> | |
| 47 | <option value="<?php echo htmlspecialchars( $r['safe_name'] ); ?>" | |
| 48 | <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | |
| 49 | <?php echo htmlspecialchars( $r['name'] ); ?> | |
| 50 | </option> | |
| 51 | <?php endforeach; ?> | |
| 52 | </select> | |
| 53 | </div> | |
| 54 | <?php endif; ?> | |
| 55 | </nav> | |
| 56 | </header> | |
| 40 | <?php if( $currentRepo ) { ?> | |
| 41 | <input type="checkbox" id="clone-toggle" class="clone-checkbox"> | |
| 42 | <?php } ?> | |
| 57 | 43 | |
| 58 | <?php call_user_func( $contentCallback ); ?> | |
| 44 | <nav class="nav"> | |
| 45 | <a href="<?php echo (new UrlBuilder())->build(); ?>">Home</a> | |
| 46 | <?php if( $currentRepo ) { ?> | |
| 47 | <?php $safeName = $currentRepo['safe_name']; ?> | |
| 48 | <a href="<?php echo (new UrlBuilder()) | |
| 49 | ->withRepo( $safeName ) | |
| 50 | ->withAction( 'tree' ) | |
| 51 | ->build(); ?>">Files</a> | |
| 52 | <a href="<?php echo (new UrlBuilder()) | |
| 53 | ->withRepo( $safeName ) | |
| 54 | ->withAction( 'commits' ) | |
| 55 | ->build(); ?>">Commits</a> | |
| 56 | <a href="<?php echo (new UrlBuilder()) | |
| 57 | ->withRepo( $safeName ) | |
| 58 | ->withAction( 'tags' ) | |
| 59 | ->build(); ?>">Tags</a> | |
| 59 | 60 | |
| 60 | </div> | |
| 61 | </body> | |
| 62 | </html> | |
| 61 | <label for="clone-toggle" class="clone-link">Clone</label> | |
| 62 | <?php } ?> | |
| 63 | ||
| 64 | <?php if( $currentRepo ) { ?> | |
| 65 | <div class="repo-selector"> | |
| 66 | <label>Repository:</label> | |
| 67 | <select onchange="<?php echo (new UrlBuilder()) | |
| 68 | ->withSwitcher( 'this.value' ) | |
| 69 | ->build(); ?>"> | |
| 70 | <?php foreach( $this->repositories as $r ) { ?> | |
| 71 | <option | |
| 72 | value="<?php echo htmlspecialchars( $r['safe_name'] ); ?>" | |
| 73 | <?php | |
| 74 | echo $r['safe_name'] === $currentRepo['safe_name'] | |
| 75 | ? 'selected' | |
| 76 | : ''; | |
| 77 | ?>> | |
| 78 | <?php echo htmlspecialchars( $r['name'] ); ?> | |
| 79 | </option> | |
| 80 | <?php } ?> | |
| 81 | </select> | |
| 82 | </div> | |
| 83 | <?php } ?> | |
| 84 | </nav> | |
| 85 | ||
| 86 | <?php if( $currentRepo ) { ?> | |
| 87 | <div class="clone-region"> | |
| 88 | <div class="clone-wrapper"> | |
| 89 | <?php | |
| 90 | $cloneCmd = 'git clone https://repo.autonoma.ca/repo/' . | |
| 91 | $currentRepo['safe_name'] . '.git'; | |
| 92 | ?> | |
| 93 | <span class="clone-sizer"><?php echo htmlspecialchars( $cloneCmd ); ?></span> | |
| 94 | <input type="text" class="clone-input" readonly | |
| 95 | value="<?php echo htmlspecialchars( $cloneCmd ); ?>"> | |
| 96 | </div> | |
| 97 | </div> | |
| 98 | <?php } ?> | |
| 99 | </header> | |
| 100 | ||
| 101 | <?php call_user_func( $contentCallback ); ?> | |
| 102 | ||
| 103 | </div> | |
| 104 | </body> | |
| 105 | </html> | |
| 63 | 106 | <?php |
| 64 | 107 | } |
| 65 | 108 | |
| 66 | protected function renderBreadcrumbs( $repo, $trail = [] ) { | |
| 109 | protected function renderBreadcrumbs( array $repo, array $trail = [] ) { | |
| 67 | 110 | $repoUrl = (new UrlBuilder())->withRepo( $repo['safe_name'] )->build(); |
| 68 | 111 |
| 7 | 7 | |
| 8 | 8 | public function __construct( Git $git, string $subPath ) { |
| 9 | $this->git = $git; | |
| 9 | $this->git = $git; | |
| 10 | 10 | $this->subPath = $subPath; |
| 11 | 11 | } |
| ... | ||
| 90 | 90 | header( 'Cache-Control: no-cache' ); |
| 91 | 91 | |
| 92 | $input = file_get_contents( 'php://input' ); | |
| 93 | $wants = []; | |
| 94 | $haves = []; | |
| 95 | $offset = 0; | |
| 92 | $input = file_get_contents( 'php://input' ); | |
| 93 | $wants = []; | |
| 94 | $haves = []; | |
| 95 | $offset = 0; | |
| 96 | $isGzip = isset( $_SERVER['HTTP_CONTENT_ENCODING'] ) && | |
| 97 | $_SERVER['HTTP_CONTENT_ENCODING'] === 'gzip'; | |
| 98 | ||
| 99 | if( $isGzip ) { | |
| 100 | $decoded = gzdecode( $input ); | |
| 101 | ||
| 102 | if( is_string( $decoded ) ) { | |
| 103 | $input = $decoded; | |
| 104 | } | |
| 105 | } | |
| 96 | 106 | |
| 97 | 107 | while( $offset < strlen( $input ) ) { |
| 98 | $line = $this->readPacketLine( $input, $offset ); | |
| 108 | $result = $this->readPacketLine( $input, $offset ); | |
| 109 | $line = $result[0]; | |
| 110 | $next = $result[1]; | |
| 99 | 111 | |
| 100 | if( $line === null || $line === 'done' ) { | |
| 112 | if( $next === $offset || $line === 'done' ) { | |
| 101 | 113 | break; |
| 102 | 114 | } |
| 115 | ||
| 116 | $offset = $next; | |
| 103 | 117 | |
| 104 | 118 | if( $line === '' ) { |
| ... | ||
| 129 | 143 | private function sendSidebandData( int $band, string $data ): void { |
| 130 | 144 | $chunkSize = 65000; |
| 131 | $len = strlen( $data ); | |
| 145 | $len = strlen( $data ); | |
| 132 | 146 | |
| 133 | 147 | for( $offset = 0; $offset < $len; $offset += $chunkSize ) { |
| 134 | 148 | $chunk = substr( $data, $offset, $chunkSize ); |
| 135 | 149 | |
| 136 | 150 | $this->packetWrite( chr( $band ) . $chunk ); |
| 137 | 151 | } |
| 138 | 152 | } |
| 139 | 153 | |
| 140 | private function readPacketLine( string $input, int &$offset ): ?string { | |
| 141 | $line = null; | |
| 154 | private function readPacketLine( string $input, int $offset ): array { | |
| 155 | $line = ''; | |
| 156 | $next = $offset; | |
| 142 | 157 | |
| 143 | 158 | if( $offset + 4 <= strlen( $input ) ) { |
| 144 | 159 | $lenHex = substr( $input, $offset, 4 ); |
| 145 | 160 | |
| 146 | 161 | if( ctype_xdigit( $lenHex ) ) { |
| 147 | 162 | $len = hexdec( $lenHex ); |
| 148 | $offset += 4; | |
| 149 | $valid = $len >= 4 && $offset + ( $len - 4 ) <= strlen( $input ); | |
| 150 | ||
| 151 | $line = ($len === 0) | |
| 152 | ? '' | |
| 153 | : ($valid ? substr( $input, $offset, $len - 4 ) : null); | |
| 154 | 163 | |
| 155 | $offset += ($len >= 4) ? ($len - 4) : 0; | |
| 164 | if( $len === 0 ) { | |
| 165 | $next = $offset + 4; | |
| 166 | } elseif( $len >= 4 ) { | |
| 167 | if( $offset + $len <= strlen( $input ) ) { | |
| 168 | $line = substr( $input, $offset + 4, $len - 4 ); | |
| 169 | $next = $offset + $len; | |
| 170 | } | |
| 171 | } | |
| 156 | 172 | } |
| 157 | 173 | } |
| 158 | 174 | |
| 159 | return $line; | |
| 175 | return [$line, $next]; | |
| 160 | 176 | } |
| 161 | 177 | |
| 14 | 14 | string $hash |
| 15 | 15 | ) { |
| 16 | parent::__construct( $repositories ); | |
| 16 | parent::__construct( $repositories, $currentRepo['name'] ); | |
| 17 | ||
| 17 | 18 | $this->currentRepo = $currentRepo; |
| 18 | $this->git = $git; | |
| 19 | $this->hash = $hash; | |
| 20 | $this->title = $currentRepo['name']; | |
| 19 | $this->git = $git; | |
| 20 | $this->hash = $hash; | |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | 23 | public function render() { |
| 24 | 24 | $this->renderLayout( function() { |
| 25 | 25 | $main = $this->git->getMainBranch(); |
| 26 | 26 | |
| 27 | 27 | if( !$main ) { |
| 28 | 28 | echo '<div class="empty-state"><h3>No branches</h3>' . |
| 29 | 29 | '<p>Empty repository.</p></div>'; |
| 30 | return; | |
| 31 | } | |
| 30 | } else { | |
| 31 | $this->renderBreadcrumbs( $this->currentRepo, ['Commits'] ); | |
| 32 | 32 | |
| 33 | $this->renderBreadcrumbs( $this->currentRepo, ['Commits'] ); | |
| 33 | echo '<h2>Commit History <span class="branch-badge">' . | |
| 34 | htmlspecialchars( $main['name'] ) . '</span></h2>'; | |
| 35 | echo '<div class="commit-list">'; | |
| 34 | 36 | |
| 35 | echo '<h2>Commit History <span class="branch-badge">' . | |
| 36 | htmlspecialchars( $main['name'] ) . '</span></h2>'; | |
| 37 | echo '<div class="commit-list">'; | |
| 37 | $start = $this->hash ?: $main['hash']; | |
| 38 | 38 | |
| 39 | $start = $this->hash ?: $main['hash']; | |
| 39 | $this->git->history( $start, 100, function( $commit ) { | |
| 40 | $msg = htmlspecialchars( explode( "\n", $commit->message )[0] ); | |
| 40 | 41 | |
| 41 | $this->git->history( $start, 100, function( $commit ) { | |
| 42 | $msg = htmlspecialchars( explode( "\n", $commit->message )[0] ); | |
| 42 | $url = (new UrlBuilder()) | |
| 43 | ->withRepo( $this->currentRepo['safe_name'] ) | |
| 44 | ->withAction( 'commit' ) | |
| 45 | ->withHash( $commit->sha ) | |
| 46 | ->build(); | |
| 43 | 47 | |
| 44 | $url = (new UrlBuilder()) | |
| 45 | ->withRepo( $this->currentRepo['safe_name'] ) | |
| 46 | ->withAction( 'commit' ) | |
| 47 | ->withHash( $commit->sha ) | |
| 48 | ->build(); | |
| 48 | echo '<div class="commit-row">'; | |
| 49 | echo '<a href="' . $url . '" class="sha">' . | |
| 50 | substr( $commit->sha, 0, 7 ) . '</a>'; | |
| 51 | echo '<span class="message">' . $msg . '</span>'; | |
| 52 | echo '<span class="meta">' . | |
| 53 | htmlspecialchars( $commit->author ) . | |
| 54 | ' • ' . date( 'Y-m-d', $commit->date ) . '</span>'; | |
| 55 | echo '</div>'; | |
| 56 | } ); | |
| 49 | 57 | |
| 50 | echo '<div class="commit-row">'; | |
| 51 | echo '<a href="' . $url . '" class="sha">' . substr( $commit->sha, 0, 7 ) . '</a>'; | |
| 52 | echo '<span class="message">' . $msg . '</span>'; | |
| 53 | echo '<span class="meta">' . htmlspecialchars( $commit->author ) . | |
| 54 | ' • ' . date( 'Y-m-d', $commit->date ) . '</span>'; | |
| 55 | 58 | echo '</div>'; |
| 56 | } ); | |
| 57 | ||
| 58 | echo '</div>'; | |
| 59 | } | |
| 59 | 60 | }, $this->currentRepo ); |
| 60 | 61 | } |
| 16 | 16 | string $oldSha |
| 17 | 17 | ) { |
| 18 | parent::__construct( $repositories ); | |
| 18 | $title = $currentRepo['name'] . ' - Compare'; | |
| 19 | parent::__construct( $repositories, $title ); | |
| 20 | ||
| 19 | 21 | $this->currentRepo = $currentRepo; |
| 20 | $this->git = $git; | |
| 21 | $this->newSha = $newSha; | |
| 22 | $this->oldSha = $oldSha; | |
| 23 | $this->title = $currentRepo['name'] . ' - Compare'; | |
| 22 | $this->git = $git; | |
| 23 | $this->newSha = $newSha; | |
| 24 | $this->oldSha = $oldSha; | |
| 24 | 25 | } |
| 25 | 26 | |
| ... | ||
| 34 | 35 | ); |
| 35 | 36 | |
| 36 | $differ = new GitDiff( $this->git ); | |
| 37 | $differ = new GitDiff( $this->git ); | |
| 37 | 38 | $changes = $differ->diff( $this->oldSha, $this->newSha ); |
| 38 | 39 | |
| 39 | 40 | if( empty( $changes ) ) { |
| 40 | 41 | echo '<div class="empty-state"><h3>No changes</h3>' . |
| 41 | 42 | '<p>No differences.</p></div>'; |
| 42 | return; | |
| 43 | } | |
| 44 | ||
| 45 | foreach( $changes as $change ) { | |
| 46 | $this->renderDiffFile( $change ); | |
| 43 | } else { | |
| 44 | foreach( $changes as $change ) { | |
| 45 | $this->renderDiffFile( $change ); | |
| 46 | } | |
| 47 | 47 | } |
| 48 | 48 | }, $this->currentRepo ); |
| ... | ||
| 57 | 57 | |
| 58 | 58 | $statusClass = $typeMap[$change['type']] ?? 'modified'; |
| 59 | $path = htmlspecialchars( $change['path'] ); | |
| 59 | $path = htmlspecialchars( $change['path'] ); | |
| 60 | 60 | |
| 61 | 61 | echo '<div class="diff-file">'; |
| ... | ||
| 85 | 85 | if( isset( $line['t'] ) && $line['t'] === 'gap' ) { |
| 86 | 86 | echo '<tr class="diff-gap"><td colspan="3">...</td></tr>'; |
| 87 | return; | |
| 88 | } | |
| 89 | ||
| 90 | $class = match( $line['t'] ) { | |
| 91 | '+' => 'diff-add', | |
| 92 | '-' => 'diff-del', | |
| 93 | default => '' | |
| 94 | }; | |
| 87 | } else { | |
| 88 | $class = match( $line['t'] ) { | |
| 89 | '+' => 'diff-add', | |
| 90 | '-' => 'diff-del', | |
| 91 | default => '' | |
| 92 | }; | |
| 95 | 93 | |
| 96 | echo '<tr class="' . $class . '">'; | |
| 97 | echo '<td class="diff-line-num">' . ($line['no'] ?? '') . '</td>'; | |
| 98 | echo '<td class="diff-line-num">' . ($line['nn'] ?? '') . '</td>'; | |
| 99 | echo '<td class="diff-code"><pre>' . | |
| 100 | htmlspecialchars( $line['l'] ?? '' ) . '</pre></td>'; | |
| 101 | echo '</tr>'; | |
| 94 | echo '<tr class="' . $class . '">'; | |
| 95 | echo '<td class="diff-line-num">' . ($line['no'] ?? '') . '</td>'; | |
| 96 | echo '<td class="diff-line-num">' . ($line['nn'] ?? '') . '</td>'; | |
| 97 | echo '<td class="diff-code"><pre>' . | |
| 98 | htmlspecialchars( $line['l'] ?? '' ) . '</pre></td>'; | |
| 99 | echo '</tr>'; | |
| 100 | } | |
| 102 | 101 | } |
| 103 | 102 | } |
| 15 | 15 | string $hash |
| 16 | 16 | ) { |
| 17 | parent::__construct( $repositories ); | |
| 17 | parent::__construct( $repositories, substr( $hash, 0, 7 ) ); | |
| 18 | ||
| 18 | 19 | $this->currentRepo = $currentRepo; |
| 19 | $this->git = $git; | |
| 20 | $this->hash = $hash; | |
| 21 | $this->title = substr( $hash, 0, 7 ); | |
| 20 | $this->git = $git; | |
| 21 | $this->hash = $hash; | |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | public function render() { |
| 25 | 25 | $this->renderLayout( function() { |
| 26 | 26 | $commitData = $this->git->read( $this->hash ); |
| 27 | 27 | $diffEngine = new GitDiff( $this->git ); |
| 28 | $lines = explode( "\n", $commitData ); | |
| 29 | $msg = ''; | |
| 30 | $isMsg = false; | |
| 31 | $headers = []; | |
| 28 | $lines = explode( "\n", $commitData ); | |
| 29 | $msg = ''; | |
| 30 | $isMsg = false; | |
| 31 | $headers = []; | |
| 32 | 32 | |
| 33 | 33 | foreach( $lines as $line ) { |
| ... | ||
| 56 | 56 | '<a href="' . $commitsUrl . '">Commits</a>', |
| 57 | 57 | substr( $this->hash, 0, 7 ) |
| 58 | ]); | |
| 58 | ] ); | |
| 59 | 59 | |
| 60 | 60 | $author = $headers['author'] ?? 'Unknown'; |
| 61 | 61 | $author = preg_replace( '/<[^>]+>/', '<email>', $author ); |
| 62 | 62 | |
| 63 | 63 | echo '<div class="commit-details">'; |
| 64 | 64 | echo '<div class="commit-header">'; |
| 65 | echo '<h1 class="commit-title">' . htmlspecialchars( trim( $msg ) ) . '</h1>'; | |
| 65 | echo '<h1 class="commit-title">' . | |
| 66 | htmlspecialchars( trim( $msg ) ) . '</h1>'; | |
| 66 | 67 | echo '<div class="commit-info">'; |
| 67 | echo '<div class="commit-info-row"><span class="commit-info-label">Author</span>' . | |
| 68 | '<span class="commit-author">' . htmlspecialchars( $author ) . '</span></div>'; | |
| 69 | echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span>' . | |
| 70 | '<span class="commit-info-value">' . $this->hash . '</span></div>'; | |
| 68 | echo '<div class="commit-info-row">' . | |
| 69 | '<span class="commit-info-label">Author</span>' . | |
| 70 | '<span class="commit-author">' . | |
| 71 | htmlspecialchars( $author ) . '</span></div>'; | |
| 72 | echo '<div class="commit-info-row">' . | |
| 73 | '<span class="commit-info-label">Commit</span>' . | |
| 74 | '<span class="commit-info-value">' . | |
| 75 | $this->hash . '</span></div>'; | |
| 71 | 76 | |
| 72 | 77 | if( isset( $headers['parent'] ) ) { |
| 73 | 78 | $url = (new UrlBuilder()) |
| 74 | 79 | ->withRepo( $this->currentRepo['safe_name'] ) |
| 75 | 80 | ->withAction( 'commit' ) |
| 76 | 81 | ->withHash( $headers['parent'] ) |
| 77 | 82 | ->build(); |
| 78 | 83 | |
| 79 | echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span>' . | |
| 84 | echo '<div class="commit-info-row">' . | |
| 85 | '<span class="commit-info-label">Parent</span>' . | |
| 80 | 86 | '<span class="commit-info-value">'; |
| 81 | 87 | echo '<a href="' . $url . '" class="parent-link">' . |
| ... | ||
| 101 | 107 | |
| 102 | 108 | private function renderFileDiff( $change ) { |
| 103 | $statusIcon = 'fa-file'; | |
| 109 | $statusIcon = 'fa-file'; | |
| 104 | 110 | $statusClass = ''; |
| 105 | 111 | |
| 106 | 112 | if( $change['type'] === 'A' ) { |
| 107 | $statusIcon = 'fa-plus-circle'; | |
| 113 | $statusIcon = 'fa-plus-circle'; | |
| 108 | 114 | $statusClass = 'status-add'; |
| 109 | 115 | } |
| 110 | 116 | |
| 111 | 117 | if( $change['type'] === 'D' ) { |
| 112 | $statusIcon = 'fa-minus-circle'; | |
| 118 | $statusIcon = 'fa-minus-circle'; | |
| 113 | 119 | $statusClass = 'status-del'; |
| 114 | 120 | } |
| 115 | 121 | |
| 116 | 122 | if( $change['type'] === 'M' ) { |
| 117 | $statusIcon = 'fa-pencil-alt'; | |
| 123 | $statusIcon = 'fa-pencil-alt'; | |
| 118 | 124 | $statusClass = 'status-mod'; |
| 119 | 125 | } |
| 120 | 126 | |
| 121 | 127 | echo '<div class="diff-file">'; |
| 122 | 128 | echo '<div class="diff-header">'; |
| 123 | 129 | echo '<span class="diff-status ' . $statusClass . '">' . |
| 124 | 130 | '<i class="fa ' . $statusIcon . '"></i></span>'; |
| 125 | echo '<span class="diff-path">' . htmlspecialchars( $change['path'] ) . '</span>'; | |
| 131 | echo '<span class="diff-path">' . | |
| 132 | htmlspecialchars( $change['path'] ) . '</span>'; | |
| 126 | 133 | echo '</div>'; |
| 127 | 134 | |
| ... | ||
| 134 | 141 | foreach( $change['hunks'] as $line ) { |
| 135 | 142 | if( isset( $line['t'] ) && $line['t'] === 'gap' ) { |
| 136 | echo '<tr class="diff-gap"><td colspan="3">...</td></tr>'; | |
| 143 | echo '<tr class="diff-gap"><td colspan="3">'; | |
| 144 | echo '<img src="/images/diff-gap.svg" class="diff-gap-icon" />'; | |
| 145 | echo '</td></tr>'; | |
| 137 | 146 | continue; |
| 138 | 147 | } |
| 139 | 148 | |
| 140 | 149 | $class = 'diff-ctx'; |
| 141 | $char = ' '; | |
| 150 | $char = ' '; | |
| 142 | 151 | |
| 143 | 152 | if( $line['t'] === '+' ) { |
| 144 | 153 | $class = 'diff-add'; |
| 145 | $char = '+'; | |
| 154 | $char = '+'; | |
| 146 | 155 | } |
| 147 | 156 | |
| 148 | 157 | if( $line['t'] === '-' ) { |
| 149 | 158 | $class = 'diff-del'; |
| 150 | $char = '-'; | |
| 159 | $char = '-'; | |
| 151 | 160 | } |
| 152 | 161 | |
| 153 | 162 | echo '<tr class="' . $class . '">'; |
| 154 | 163 | echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>'; |
| 155 | 164 | echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>'; |
| 156 | echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . | |
| 157 | htmlspecialchars( $line['l'] ) . '</td>'; | |
| 165 | echo '<td class="diff-code"><span class="diff-marker">' . | |
| 166 | $char . '</span>' . htmlspecialchars( $line['l'] ) . '</td>'; | |
| 158 | 167 | echo '</tr>'; |
| 159 | 168 | } |
| 6 | 6 | class FilePage extends BasePage { |
| 7 | 7 | private const MAX_HIGHLIGHT_SIZE = 65536; |
| 8 | private const MAX_DISPLAY_SIZE = 524288; | |
| 8 | private const MAX_DISPLAY_SIZE = 524288; | |
| 9 | 9 | |
| 10 | 10 | private $currentRepo; |
| ... | ||
| 20 | 20 | string $path = '' |
| 21 | 21 | ) { |
| 22 | parent::__construct( $repositories ); | |
| 22 | parent::__construct( $repositories, $currentRepo['name'] ); | |
| 23 | ||
| 23 | 24 | $this->currentRepo = $currentRepo; |
| 24 | $this->git = $git; | |
| 25 | $this->hash = $hash ?: 'HEAD'; | |
| 26 | $this->path = $path; | |
| 27 | $this->title = $currentRepo['name']; | |
| 25 | $this->git = $git; | |
| 26 | $this->hash = $hash ?: 'HEAD'; | |
| 27 | $this->path = $path; | |
| 28 | 28 | } |
| 29 | 29 | |
| ... | ||
| 51 | 51 | } |
| 52 | 52 | |
| 53 | private function isExactFileMatch($entries) { | |
| 54 | return count( $entries ) === 1 && | |
| 55 | $entries[0]->isName( basename( $this->path ) ) && | |
| 56 | !$entries[0]->isDir; | |
| 53 | private function isExactFileMatch( $entries ) { | |
| 54 | return count( $entries ) === 1 && | |
| 55 | $entries[0]->isName( basename( $this->path ) ) && | |
| 56 | !$entries[0]->isDir; | |
| 57 | 57 | } |
| 58 | 58 | |
| ... | ||
| 69 | 69 | |
| 70 | 70 | echo '<table class="file-list-table">'; |
| 71 | echo '<thead><tr><th></th><th>Name</th><th class="file-mode-cell">Mode</th><th class="file-size-cell">Size</th></tr></thead>'; | |
| 71 | echo '<thead><tr><th></th><th>Name</th>' . | |
| 72 | '<th class="file-mode-cell">Mode</th>' . | |
| 73 | '<th class="file-size-cell">Size</th></tr></thead>'; | |
| 72 | 74 | echo '<tbody>'; |
| 73 | 75 | |
| ... | ||
| 87 | 89 | private function renderBlob( $targetHash ) { |
| 88 | 90 | $filename = $this->path; |
| 89 | $file = $this->git->readFile( $targetHash, $filename ); | |
| 90 | $size = $this->git->getObjectSize( $targetHash, $filename ); | |
| 91 | $file = $this->git->readFile( $targetHash, $filename ); | |
| 92 | $size = $this->git->getObjectSize( $targetHash, $filename ); | |
| 91 | 93 | |
| 92 | $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], dirname($filename), $targetHash ); | |
| 94 | $renderer = new HtmlFileRenderer( | |
| 95 | $this->currentRepo['safe_name'], | |
| 96 | dirname( $filename ), | |
| 97 | $targetHash | |
| 98 | ); | |
| 93 | 99 | |
| 94 | 100 | $this->emitBreadcrumbs( $targetHash, 'File', $filename ); |
| 95 | 101 | |
| 96 | 102 | if( $size === 0 && !$file ) { |
| 97 | echo '<div class="empty-state">File not found.</div>'; | |
| 98 | return; | |
| 99 | } | |
| 103 | echo '<div class="empty-state">File not found.</div>'; | |
| 104 | } else { | |
| 105 | $rawUrl = (new UrlBuilder()) | |
| 106 | ->withRepo( $this->currentRepo['safe_name'] ) | |
| 107 | ->withAction( 'raw' ) | |
| 108 | ->withHash( $targetHash ) | |
| 109 | ->withName( $filename ) | |
| 110 | ->build(); | |
| 100 | 111 | |
| 101 | $rawUrl = (new UrlBuilder()) | |
| 102 | ->withRepo( $this->currentRepo['safe_name'] ) | |
| 103 | ->withAction( 'raw' ) | |
| 104 | ->withHash( $targetHash ) | |
| 105 | ->withName( $filename ) | |
| 106 | ->build(); | |
| 112 | if( !$file->renderMedia( $renderer, $rawUrl ) ) { | |
| 113 | if( $file->isText() ) { | |
| 114 | if( $size > self::MAX_DISPLAY_SIZE ) { | |
| 115 | ob_start(); | |
| 116 | $file->renderSize( $renderer ); | |
| 117 | $sizeStr = ob_get_clean(); | |
| 118 | $this->renderDownloadState( | |
| 119 | $targetHash, | |
| 120 | "File is too large to display ($sizeStr)." | |
| 121 | ); | |
| 122 | } else { | |
| 123 | $content = ''; | |
| 124 | $this->git->stream( | |
| 125 | $targetHash, | |
| 126 | function( $d ) use ( &$content ) { | |
| 127 | $content .= $d; | |
| 128 | }, | |
| 129 | $filename | |
| 130 | ); | |
| 107 | 131 | |
| 108 | if( !$file->renderMedia( $renderer, $rawUrl ) ) { | |
| 109 | if( $file->isText() ) { | |
| 110 | if( $size > self::MAX_DISPLAY_SIZE ) { | |
| 111 | ob_start(); | |
| 112 | $file->renderSize( $renderer ); | |
| 113 | $sizeStr = ob_get_clean(); | |
| 114 | $this->renderDownloadState( $targetHash, "File is too large to display ($sizeStr)." ); | |
| 132 | echo '<div class="blob-content"><pre class="blob-code">' . | |
| 133 | ($size > self::MAX_HIGHLIGHT_SIZE | |
| 134 | ? htmlspecialchars( $content ) | |
| 135 | : $file->highlight( $renderer, $content )) . | |
| 136 | '</pre></div>'; | |
| 137 | } | |
| 115 | 138 | } else { |
| 116 | $content = ''; | |
| 117 | $this->git->stream( $targetHash, function( $d ) use ( &$content ) { | |
| 118 | $content .= $d; | |
| 119 | }, $filename ); | |
| 120 | ||
| 121 | echo '<div class="blob-content"><pre class="blob-code">' . | |
| 122 | ($size > self::MAX_HIGHLIGHT_SIZE | |
| 123 | ? htmlspecialchars( $content ) | |
| 124 | : $file->highlight( $renderer, $content )) . | |
| 125 | '</pre></div>'; | |
| 139 | $this->renderDownloadState( | |
| 140 | $targetHash, | |
| 141 | "This is a binary file." | |
| 142 | ); | |
| 126 | 143 | } |
| 127 | } else { | |
| 128 | $this->renderDownloadState( $targetHash, "This is a binary file." ); | |
| 129 | 144 | } |
| 130 | 145 | } |
| ... | ||
| 150 | 165 | if( $path ) { |
| 151 | 166 | $parts = explode( '/', trim( $path, '/' ) ); |
| 152 | $acc = ''; | |
| 167 | $acc = ''; | |
| 153 | 168 | |
| 154 | 169 | foreach( $parts as $idx => $part ) { |
| ... | ||
| 165 | 180 | ->build(); |
| 166 | 181 | |
| 167 | $trail[] = '<a href="' . $url . '">' . htmlspecialchars( $part ) . '</a>'; | |
| 182 | $trail[] = '<a href="' . $url . '">' . | |
| 183 | htmlspecialchars( $part ) . '</a>'; | |
| 168 | 184 | } |
| 169 | 185 | } |
| 4 | 4 | |
| 5 | 5 | class HomePage extends BasePage { |
| 6 | private $repositories; | |
| 6 | 7 | private $git; |
| 7 | 8 | |
| 8 | 9 | public function __construct( array $repositories, Git $git ) { |
| 9 | 10 | parent::__construct( $repositories ); |
| 10 | $this->git = $git; | |
| 11 | $this->repositories = $repositories; | |
| 12 | $this->git = $git; | |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | 15 | public function render() { |
| 14 | 16 | $this->renderLayout( function() { |
| 15 | 17 | echo '<h2>Repositories</h2>'; |
| 16 | 18 | |
| 17 | 19 | if( empty( $this->repositories ) ) { |
| 18 | 20 | echo '<div class="empty-state">No repositories found.</div>'; |
| 19 | return; | |
| 20 | } | |
| 21 | } else { | |
| 22 | echo '<div class="repo-grid">'; | |
| 21 | 23 | |
| 22 | echo '<div class="repo-grid">'; | |
| 24 | foreach( $this->repositories as $repo ) { | |
| 25 | $this->renderRepoCard( $repo ); | |
| 26 | } | |
| 23 | 27 | |
| 24 | foreach( $this->repositories as $repo ) { | |
| 25 | $this->renderRepoCard( $repo ); | |
| 28 | echo '</div>'; | |
| 26 | 29 | } |
| 27 | ||
| 28 | echo '</div>'; | |
| 29 | 30 | } ); |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | private function renderRepoCard( $repo ) { |
| 33 | 34 | $this->git->setRepository( $repo['path'] ); |
| 34 | 35 | |
| 35 | $main = $this->git->getMainBranch(); | |
| 36 | $main = $this->git->getMainBranch(); | |
| 36 | 37 | $stats = [ |
| 37 | 38 | 'branches' => 0, |
| 38 | 'tags' => 0 | |
| 39 | 'tags' => 0 | |
| 39 | 40 | ]; |
| 40 | 41 | |
| ... | ||
| 53 | 54 | |
| 54 | 55 | $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches'; |
| 55 | $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags'; | |
| 56 | $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags'; | |
| 56 | 57 | |
| 57 | 58 | echo $stats['branches'] . ' ' . $branchLabel . ', ' . |
| 1 | 1 | <?php |
| 2 | 2 | interface Page { |
| 3 | public function render(); | |
| 3 | public function render(); | |
| 4 | 4 | } |
| 5 | 5 |
| 12 | 12 | Git $git |
| 13 | 13 | ) { |
| 14 | parent::__construct( $repositories ); | |
| 14 | parent::__construct( | |
| 15 | $repositories, | |
| 16 | $currentRepo['name'] . ' - Tags' | |
| 17 | ); | |
| 18 | ||
| 15 | 19 | $this->currentRepo = $currentRepo; |
| 16 | $this->git = $git; | |
| 17 | $this->title = $currentRepo['name'] . ' - Tags'; | |
| 20 | $this->git = $git; | |
| 18 | 21 | } |
| 19 | 22 | |
| ... | ||
| 35 | 38 | echo '<p>No tags found.</p>'; |
| 36 | 39 | } else { |
| 37 | $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] ); | |
| 40 | $renderer = new HtmlTagRenderer( | |
| 41 | $this->currentRepo['safe_name'] | |
| 42 | ); | |
| 38 | 43 | |
| 39 | 44 | echo '<table class="tag-table">'; |
| ... | ||
| 49 | 54 | $count = count( $tags ); |
| 50 | 55 | for( $i = 0; $i < $count; $i++ ) { |
| 51 | $tag = $tags[$i]; | |
| 56 | $tag = $tags[$i]; | |
| 52 | 57 | $prevTag = $tags[$i + 1] ?? null; |
| 53 | 58 | $tag->render( $renderer, $prevTag ); |
| 14 | 14 | $this->content = $content; |
| 15 | 15 | $this->language = $this->detectLanguage( $mediaType, $filename ); |
| 16 | $this->rules = LanguageDefinitions::get( $this->language ) ?? []; | |
| 16 | $this->rules = LanguageDefinitions::get( $this->language ); | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | public function render(): string { |
| 20 | 20 | $result = htmlspecialchars( $this->content ); |
| 21 | 21 | |
| 22 | 22 | if( !empty( $this->rules ) ) { |
| 23 | 23 | $patterns = []; |
| 24 | 24 | |
| 25 | 25 | foreach( $this->rules as $name => $pattern ) { |
| 26 | $delimiter = $pattern[0]; | |
| 27 | $inner = substr( | |
| 28 | $pattern, | |
| 29 | 1, | |
| 30 | strrpos( $pattern, $delimiter ) - 1 | |
| 31 | ); | |
| 32 | $inner = str_replace( '~', '\~', $inner ); | |
| 33 | ||
| 26 | $delimiter = $pattern[0]; | |
| 27 | $pos = strrpos( $pattern, $delimiter ) - 1; | |
| 28 | $inner = substr( $pattern, 1, $pos ); | |
| 29 | $inner = str_replace( '~', '\~', $inner ); | |
| 34 | 30 | $patterns[] = "(?P<{$name}>{$inner})"; |
| 35 | 31 | } |
| 36 | 32 | |
| 37 | 33 | if( !in_array( $this->language, ['markdown', 'rmd'] ) ) { |
| 38 | $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,])"; | |
| 34 | $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,\\:])"; | |
| 39 | 35 | } |
| 40 | 36 | |
| 41 | 37 | $patterns[] = "(?P<any>[\s\S])"; |
| 42 | $combined = '~' . implode( '|', $patterns ) . '~msu'; | |
| 38 | $imploded = implode( '|', $patterns ); | |
| 39 | $combined = '~' . $imploded . '~msu'; | |
| 43 | 40 | |
| 44 | 41 | $processed = preg_replace_callback( $combined, function( $matches ) { |
| ... | ||
| 64 | 61 | }, $this->content ); |
| 65 | 62 | |
| 66 | if( $processed !== null ) { | |
| 63 | if( is_string( $processed ) ) { | |
| 67 | 64 | $result = $processed; |
| 68 | 65 | } |
| ... | ||
| 76 | 73 | |
| 77 | 74 | return $this->processSegments( $content, $pattern, function( $part ) { |
| 78 | $out = htmlspecialchars( $part ); | |
| 75 | if( !str_starts_with( $part, '$' ) || strlen( $part ) <= 1 ) { | |
| 76 | $out = $this->wrap( $part, 'hl-string' ); | |
| 77 | } else { | |
| 78 | $isComplex = str_starts_with( $part, '${' ) && | |
| 79 | str_ends_with( $part, '}' ); | |
| 79 | 80 | |
| 80 | if( str_starts_with( $part, '${' ) && str_ends_with( $part, '}' ) ) { | |
| 81 | $inner = substr( $part, 2, -1 ); | |
| 82 | $out = $this->wrap( '${', 'hl-interp-punct', false ) . | |
| 83 | $this->wrap( $inner, 'hl-variable' ) . | |
| 84 | $this->wrap( '}', 'hl-interp-punct', false ); | |
| 85 | } elseif( str_starts_with( $part, '$' ) && strlen( $part ) > 1 ) { | |
| 86 | $inner = substr( $part, 1 ); | |
| 87 | $out = $this->wrap( '$', 'hl-interp-punct', false ) . | |
| 88 | $this->wrap( $inner, 'hl-variable' ); | |
| 89 | } else { | |
| 90 | $out = $this->wrap( $part, 'hl-string' ); | |
| 91 | } | |
| 92 | return $out; | |
| 93 | }); | |
| 81 | $inner = $isComplex ? substr( $part, 2, -1 ) : substr( $part, 1 ); | |
| 82 | $prefix = $isComplex ? '${' : '$'; | |
| 83 | $suffix = $isComplex | |
| 84 | ? $this->wrap( '}', 'hl-interp-punct', false ) | |
| 85 | : ''; | |
| 86 | ||
| 87 | $out = $this->wrap( $prefix, 'hl-interp-punct', false ) . | |
| 88 | $this->wrap( $inner, 'hl-variable' ) . | |
| 89 | $suffix; | |
| 90 | } | |
| 91 | ||
| 92 | return $out; | |
| 93 | } ); | |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | 96 | private function renderMath( string $content ): string { |
| 97 | return $this->processSegments( $content, '/(`[^`]+`)/', function( $part ) { | |
| 98 | if( str_starts_with( $part, '`' ) && str_ends_with( $part, '`' ) ) { | |
| 99 | return $this->wrap( $part, 'hl-function' ); | |
| 100 | } | |
| 101 | return $this->wrap( $part, 'hl-math' ); | |
| 102 | }); | |
| 97 | $pattern = '/(`[^`]+`)/'; | |
| 98 | ||
| 99 | return $this->processSegments( $content, $pattern, function( $part ) { | |
| 100 | $output = $this->wrap( $part, 'hl-math' ); | |
| 101 | ||
| 102 | if( str_starts_with( $part, '`' ) && str_ends_with( $part, '`' ) ) { | |
| 103 | $output = $this->wrap( $part, 'hl-function' ); | |
| 104 | } | |
| 105 | ||
| 106 | return $output; | |
| 107 | } ); | |
| 103 | 108 | } |
| 104 | 109 | |
| ... | ||
| 125 | 130 | bool $escape = true |
| 126 | 131 | ): string { |
| 127 | $safeContent = $escape ? htmlspecialchars( $content ) : $content; | |
| 132 | $safeContent = $content; | |
| 133 | ||
| 134 | if( $escape ) { | |
| 135 | $safeContent = htmlspecialchars( $content ); | |
| 136 | } | |
| 137 | ||
| 128 | 138 | return '<span class="' . $className . '">' . $safeContent . '</span>'; |
| 129 | 139 | } |
| 130 | 140 | |
| 131 | 141 | private function detectLanguage( |
| 132 | 142 | string $mediaType, |
| 133 | 143 | string $filename |
| 134 | 144 | ): string { |
| 135 | 145 | $basename = basename( $filename ); |
| 136 | 146 | $extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ); |
| 137 | $language = null; | |
| 138 | ||
| 139 | $language = match( $basename ) { | |
| 147 | $language = match( $basename ) { | |
| 140 | 148 | 'Containerfile', |
| 141 | 'Dockerfile' => 'containerfile', | |
| 142 | 'Makefile' => 'makefile', | |
| 143 | 'Jenkinsfile' => 'groovy', | |
| 144 | default => null | |
| 149 | 'Dockerfile' => 'containerfile', | |
| 150 | 'Makefile' => 'makefile', | |
| 151 | 'Jenkinsfile' => 'groovy', | |
| 152 | default => '' | |
| 145 | 153 | }; |
| 146 | 154 | |
| 147 | if( $language === null ) { | |
| 155 | if( $language === '' ) { | |
| 148 | 156 | $language = match( $extension ) { |
| 149 | 157 | 'php', 'phtml', 'php8', 'php7' => 'php', |
| ... | ||
| 172 | 180 | 'r' => 'r', |
| 173 | 181 | 'xml', 'svg' => 'xml', |
| 182 | 'xsl', 'xslt' => 'xslt', | |
| 174 | 183 | 'html', 'htm' => 'html', |
| 175 | 184 | 'css' => 'css', |
| ... | ||
| 184 | 193 | 'mk', 'mak' => 'makefile', |
| 185 | 194 | 'diff', 'patch' => 'diff', |
| 186 | default => null | |
| 195 | 'for', 'f', 'f90', 'f95' => 'fortran', | |
| 196 | default => '' | |
| 187 | 197 | }; |
| 188 | 198 | } |
| 189 | 199 | |
| 190 | if( $language === null ) { | |
| 200 | if( $language === '' ) { | |
| 191 | 201 | $language = match( $mediaType ) { |
| 192 | 202 | 'text/x-php', 'application/x-php', |
| ... | ||
| 201 | 211 | 'application/xml', 'text/xml', |
| 202 | 212 | 'image/svg+xml' => 'xml', |
| 213 | 'application/xslt+xml' => 'xslt', | |
| 203 | 214 | 'text/x-shellscript', |
| 204 | 215 | 'application/x-sh' => 'bash', |
| ... | ||
| 237 | 248 | 'application/toml', 'text/toml' => 'toml', |
| 238 | 249 | 'text/x-diff', 'text/x-patch' => 'diff', |
| 250 | 'text/x-fortran' => 'fortran', | |
| 239 | 251 | default => 'text' |
| 240 | 252 | }; |
| 2 | 2 | class LanguageDefinitions { |
| 3 | 3 | public static function get( string $lang ): array { |
| 4 | $int = '(-?\b\d+(\.\d+)?\b)'; | |
| 5 | $str = '("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')'; | |
| 6 | $float = '(-?\d+(\.\d+)?([eE][+-]?\d+)?)'; | |
| 7 | ||
| 8 | $rules = [ | |
| 9 | 'gradle' => [ | |
| 10 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 11 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*"|""".*?""")/', | |
| 12 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\'|\'\'\'.*?\'\'\'|\/.*?\/)/', | |
| 13 | 'keyword' => '/\b(?:def|task|group|version|ext|return|if|else)\b/', | |
| 14 | 'function' => '/\b(apply|plugin|sourceCompatibility|targetCompatibility|repositories|dependencies|test|plugins|buildscript|allprojects|subprojects|project|implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly|mavenCentral|google|jcenter|classpath)\b|\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\(|{)/', | |
| 15 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 16 | 'boolean' => '/\b(?:true|false|null)\b/', | |
| 17 | 'number' => '/' . $int . '/', | |
| 18 | ], | |
| 19 | 'tex' => [ | |
| 20 | 'comment' => '/(%[^\r\n]*)/m', | |
| 21 | 'math' => '/(\$\$?.*?\$\$?)/s', | |
| 22 | 'keyword' => '/(\\\\(?:def|edef|gdef|xdef|let|futurelet|if|else|fi|ifnum|ifdim|ifodd|ifmmode|ifx|ifeof|iftrue|iffalse|ifcase|or|loop|repeat|newif|expandafter|noexpand|csname|endcsname|string|number|the|long|outer|global|par|advance|hsize|vsize|hoffset|voffset|displaywidth|parindent|baselineskip|leftskip|rightskip|hangindent|hangafter|parshape|pageno|nopagenumbers|folio|headline|footline|hbox|vbox|vtop|vcenter|rlap|llap|hskip|vskip|hfil|hfill|hfilneg|vfil|vfill|mskip|quad|qquad|enspace|thinspace|enskip|strut|phantom|vphantom|hphantom|smash|raise|lower|moveleft|moveright|halign|valign|noalign|openup|cr|crcr|omit|span|multispan|tabskip|settabs|matrix|pmatrix|bordermatrix|eqalign|displaylines|eqno|leqno|cases|left|right|over|atop|choose|brace|brack|root|of|buildrel|input|end|bye|item|itemitem|indent|noindent|narrower|rm|bf|tt|sl|it|font|char|magnification|magstep|magstephalf|day|month|year|jobname|romannumeral|uppercase|lowercase|footnote|topinsert|pageinsert|midinsert|endinsert|underbar|hfuzz|vfuzz|overfullrule|raggedright|raggedbottom|everypar|everymath|everydisplay|everycr))\b/', | |
| 23 | 'function' => '/(\\\\[a-zA-Z@]+|\\\\[^a-zA-Z@])/', | |
| 24 | 'variable' => '/(#[0-9])/', | |
| 25 | ], | |
| 26 | 'php' => [ | |
| 27 | 'tag' => '/(<\?php|<\?|=\?>|\?>)/', | |
| 28 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 29 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 30 | 'comment' => '/(\/\/[^\r\n]*|#[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 31 | 'type' => '/\b(?:array|bool|callable|float|int|iterable|mixed|never|object|string|void)\b/', | |
| 32 | 'keyword' => '/\b(?:abstract|and|as|break|case|catch|class|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|new|or|print|private|protected|public|readonly|require|require_once|return|static|switch|throw|trait|try|unset|use|var|while|xor|yield)\b/', | |
| 33 | 'function' => '/\b([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\s*(?=\()/', | |
| 34 | 'variable' => '/(\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/', | |
| 35 | 'number' => '/' . $int . '/', | |
| 36 | 'boolean' => '/\b(true|false|null)\b/i', | |
| 37 | ], | |
| 38 | 'bash' => [ | |
| 39 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 40 | 'string' => '/(\'.*?\')/', | |
| 41 | 'comment' => '/(#[^\n]*)/', | |
| 42 | 'keyword' => '/(?<!-)\b(?:alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|coproc|declare|dirs|disown|do|done|echo|elif|else|enable|esac|eval|exec|exit|export|fc|fg|fi|for|function|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|select|set|shift|shopt|source|suspend|test|then|time|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while)\b/', | |
| 43 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 44 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 45 | 'number' => '/' . $int . '/', | |
| 46 | ], | |
| 47 | 'batch' => [ | |
| 48 | 'comment' => '/((?i:rem)\b[^\n]*|::[^\n]*)/', | |
| 49 | 'string' => '/("[^"]*")/', | |
| 50 | 'keyword' => '/(?i)\b(?:if|else|goto|for|in|do|exit|echo|pause|set|shift|start|cd|dir|copy|del|md|rd|cls|setlocal|endlocal|enabledelayedexpansion|defined|exist|not|errorlevel|setx|findstr|reg|nul|tokens|usebackq|equ|neq|lss|leq|gtr|geq)\b/', | |
| 51 | 'function' => '/(?i)\b(call)\b/', | |
| 52 | 'variable' => '/(![\w-]+!|%[\w\(\)-]+%|%%[~a-zA-Z]+|%[~a-zA-Z0-9]+)/', | |
| 53 | 'label' => '/(^\s*:[a-zA-Z0-9_-]+)/m', | |
| 54 | 'number' => '/' . $int . '/', | |
| 55 | ], | |
| 56 | 'c' => [ | |
| 57 | 'string' => '/' . $str . '/', | |
| 58 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 59 | 'include' => '/(^\s*#include[^\r\n]*)/m', | |
| 60 | 'preprocessor' => '/(^\s*#(?!include\b)[^\r\n]*)/m', | |
| 61 | 'type' => '/\b(?:char|double|float|int|long|short|void|signed|unsigned)\b/', | |
| 62 | 'keyword' => '/\b(?:auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|noreturn|register|return|sizeof|static|struct|switch|typedef|union|volatile|while)\b/', | |
| 63 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 64 | 'number' => '/' . $int . '/', | |
| 65 | ], | |
| 66 | 'cpp' => [ | |
| 67 | 'string' => '/' . $str . '/', | |
| 68 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 69 | 'include' => '/(^\s*#include[^\r\n]*)/m', | |
| 70 | 'preprocessor' => '/(^\s*#(?!include\b)[^\r\n]*)/m', | |
| 71 | 'type' => '/\b(?:bool|char|char8_t|char16_t|char32_t|double|float|int|long|short|signed|unsigned|void|wchar_t)\b/', | |
| 72 | 'keyword' => '/\b(?:alignas|alignof|and|and_eq|asm|auto|bitand|bitor|break|case|catch|class|co_await|co_return|co_yield|compl|concept|const|consteval|constexpr|constinit|const_cast|continue|decltype|default|delete|do|dynamic_cast|else|enum|explicit|export|extern|for|friend|goto|if|inline|mutable|namespace|new|noexcept|noreturn|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|requires|return|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|union|using|virtual|volatile|while|xor|xor_eq)\b/', | |
| 73 | 'boolean' => '/\b(?:true|false)\b/', | |
| 74 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 75 | 'number' => '/' . $int . '/', | |
| 76 | ], | |
| 77 | 'java' => [ | |
| 78 | 'class' => '/(@[a-zA-Z_][a-zA-Z0-9_]*)/', | |
| 79 | 'string' => '/' . $str . '/', | |
| 80 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 81 | 'type' => '/\b(?:boolean|byte|char|double|float|int|long|short|void)\b/', | |
| 82 | 'keyword' => '/\b(?:abstract|assert|break|case|catch|class|const|continue|default|do|else|enum|extends|final|finally|for|goto|if|implements|import|instanceof|interface|native|new|non-sealed|package|permits|private|protected|public|record|return|sealed|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|var|volatile|while|yield)\b/', | |
| 83 | 'boolean' => '/\b(?:true|false|null)\b/', | |
| 84 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 85 | 'number' => '/' . $int . '/', | |
| 86 | ], | |
| 87 | 'go' => [ | |
| 88 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|`.*?`)/s', | |
| 89 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 90 | 'type' => '/\b(?:bool|byte|complex64|complex128|error|float32|float64|int|int8|int16|int32|int64|rune|string|uint|uint8|uint16|uint32|uint64|uintptr)\b/', | |
| 91 | 'keyword' => '/\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/', | |
| 92 | 'boolean' => '/\b(?:true|false|nil|iota)\b/', | |
| 93 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 94 | 'number' => '/' . $int . '/', | |
| 95 | ], | |
| 96 | 'rust' => [ | |
| 97 | 'string' => '/' . $str . '/', | |
| 98 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 99 | 'type' => '/\b(?:bool|char|f32|f64|i8|i16|i32|i64|i128|isize|str|u8|u16|u32|u64|u128|usize)\b/', | |
| 100 | 'keyword' => '/\b(?:as|async|await|break|const|continue|crate|dyn|else|enum|extern|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|type|union|unsafe|use|where|while)\b/', | |
| 101 | 'boolean' => '/\b(?:true|false)\b/', | |
| 102 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 103 | 'number' => '/' . $int . '/', | |
| 104 | ], | |
| 105 | 'python' => [ | |
| 106 | 'string' => '/(\'\'\'.*?\'\'\'|""".*?"""|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')/s', | |
| 107 | 'comment' => '/(#[^\r\n]*)/m', | |
| 108 | 'type' => '/\b(?:bool|bytearray|bytes|complex|dict|float|frozenset|int|list|memoryview|object|range|set|str|tuple)\b/', | |
| 109 | 'keyword' => '/\b(?:and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b/', | |
| 110 | 'boolean' => '/\b(?:False|None|True)\b/', | |
| 111 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 112 | 'number' => '/' . $int . '/', | |
| 113 | ], | |
| 114 | 'ruby' => [ | |
| 115 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 116 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 117 | 'comment' => '/(#[^\r\n]*)/m', | |
| 118 | 'keyword' => '/\b(?:alias|and|begin|break|case|class|def|defined|do|else|elsif|end|ensure|for|if|in|module|next|not|or|redo|rescue|retry|return|self|super|then|undef|unless|until|when|while|yield)\b/', | |
| 119 | 'boolean' => '/\b(?:true|false|nil)\b/', | |
| 120 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*[?!]?)\s*(?=\()/', | |
| 121 | 'variable' => '/(@[a-zA-Z_]\w*|\$[a-zA-Z_]\w*)/', | |
| 122 | 'number' => '/' . $int . '/', | |
| 123 | ], | |
| 124 | 'lua' => [ | |
| 125 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|\[\[.*?\]\])/s', | |
| 126 | 'comment' => '/(--\[\[.*?\]\]|--[^\r\n]*)/ms', | |
| 127 | 'keyword' => '/\b(?:and|break|do|else|elseif|end|for|function|if|in|local|not|or|repeat|return|then|until|while)\b/', | |
| 128 | 'boolean' => '/\b(?:false|nil|true)\b/', | |
| 129 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 130 | 'number' => '/' . $int . '/', | |
| 131 | ], | |
| 132 | 'javascript' => [ | |
| 133 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|`(?:\\\\.|[^`\\\\])*`)/s', | |
| 134 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 135 | 'keyword' => '/\b(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/', | |
| 136 | 'boolean' => '/\b(?:true|false|null|undefined)\b/', | |
| 137 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 138 | 'number' => '/' . $int . '/', | |
| 139 | ], | |
| 140 | 'typescript' => [ | |
| 141 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|`(?:\\\\.|[^`\\\\])*`)/s', | |
| 142 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 143 | 'type' => '/\b(?:boolean|number|string|void|any|never|unknown|object|symbol|bigint)\b/', | |
| 144 | 'keyword' => '/\b(?:abstract|as|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|is|let|module|namespace|new|of|package|private|protected|public|readonly|require|return|static|super|switch|this|throw|try|type|typeof|var|while|with|yield)\b/', | |
| 145 | 'boolean' => '/\b(?:true|false|null|undefined)\b/', | |
| 146 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 147 | 'number' => '/' . $int . '/', | |
| 148 | ], | |
| 149 | 'xml' => [ | |
| 150 | 'comment' => '/()/', | |
| 151 | 'string' => '/' . $str . '/', | |
| 152 | 'tag' => '/(<\/?[!a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>)/', | |
| 153 | 'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/', | |
| 154 | ], | |
| 155 | 'html' => [ | |
| 156 | 'comment' => '/()/', | |
| 157 | 'string' => '/' . $str . '/', | |
| 158 | 'tag' => '/(<\/?[!a-zA-Z0-9:-]+|\s*\/?>)/', | |
| 159 | 'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/', | |
| 160 | ], | |
| 161 | 'css' => [ | |
| 162 | 'comment' => '/(\/\*.*?\*\/)/s', | |
| 163 | 'tag' => '/(?<=^|\}|\{)\s*([a-zA-Z0-9_\-#\.\s,>+~]+)(?=\{)/m', | |
| 164 | 'property' => '/([a-zA-Z-]+)(?=\s*:)/', | |
| 165 | 'string' => '/' . $str . '/', | |
| 166 | 'number' => '/(-?(\d*\.)?\d+(px|em|rem|%|vh|vw|s|ms|deg))/', | |
| 167 | ], | |
| 168 | 'json' => [ | |
| 169 | 'attribute' => '/("(?:\\\\.|[^"\\\\])*")(?=\s*:)/', | |
| 170 | 'string' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 171 | 'boolean' => '/\b(true|false|null)\b/', | |
| 172 | 'number' => '/\b(-?\d+(\.\d+)?([eE][+-]?\d+)?)\b/', | |
| 173 | ], | |
| 174 | 'sql' => [ | |
| 175 | 'string' => '/(\'.*?\')/', | |
| 176 | 'comment' => '/(--[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 177 | 'type' => '/(?i)\b(?:BIGINT|BIT|BOOLEAN|CHAR|DATE|DATETIME|DECIMAL|DOUBLE|FLOAT|INT|INTEGER|MONEY|NUMERIC|REAL|SMALLINT|TEXT|TIME|TIMESTAMP|TINYINT|VARCHAR)\b/', | |
| 178 | 'keyword' => '/(?i)\b(ADD|ALTER|AND|AS|ASC|BEGIN|BETWEEN|BY|CASE|CHECK|COLUMN|COMMIT|CONSTRAINT|CREATE|DATABASE|DEFAULT|DELETE|DESC|DISTINCT|DROP|ELSE|END|EXISTS|FOREIGN|FROM|FULL|FUNCTION|GRANT|GROUP|HAVING|IF|IN|INDEX|INNER|INSERT|INTO|IS|JOIN|KEY|LEFT|LIKE|LIMIT|NOT|NULL|OFFSET|ON|OR|ORDER|OUTER|PRIMARY|PROCEDURE|REFERENCES|REVOKE|RIGHT|ROLLBACK|SCHEMA|SELECT|SET|TABLE|THEN|TRANSACTION|TRIGGER|TRUNCATE|UNION|UNIQUE|UPDATE|VALUES|VIEW|WHEN|WHERE)\b/', | |
| 179 | 'boolean' => '/(?i)\b(NULL|TRUE|FALSE)\b/', | |
| 180 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 181 | 'number' => '/' . $int . '/', | |
| 182 | ], | |
| 183 | 'yaml' => [ | |
| 184 | 'comment' => '/(#[^\r\n]*)/m', | |
| 185 | 'variable' => '/^(\s*[a-zA-Z0-9_-]+:)/m', | |
| 186 | 'string_interp' => '/((?<=:)\s*[^\r\n]*)/', | |
| 187 | 'number' => '/' . $float . '/', | |
| 188 | ], | |
| 189 | 'properties' => [ | |
| 190 | 'comment' => '/(^[ \t]*[#!][^\r\n]*)/m', | |
| 191 | 'variable' => '/(^[ \t]*[^:=\s]+)(?=[ \t]*[:=])/m', | |
| 192 | 'string_interp' => '/((?<=[=:])\s*[^\r\n]*)/', | |
| 193 | ], | |
| 194 | 'ini' => [ | |
| 195 | 'comment' => '/(^[ \t]*[;#][^\r\n]*)/m', | |
| 196 | 'keyword' => '/(^\[[^\]\r\n]+\])/m', | |
| 197 | 'variable' => '/(^[ \t]*[a-zA-Z0-9_\.\-]+)(?=\s*=)/m', | |
| 198 | 'string' => '/((?<==)\s*[^\r\n]*)/', | |
| 199 | ], | |
| 200 | 'toml' => [ | |
| 201 | 'comment' => '/(#[^\r\n]*)/', | |
| 202 | 'keyword' => '/(^\[[^\]\r\n]+\])/m', | |
| 203 | 'variable' => '/(\b[a-zA-Z0-9_-]+\b)(?=\s*=)/', | |
| 204 | 'string' => '/(' . $str . '|"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\')/', | |
| 205 | 'boolean' => '/\b(true|false)\b/', | |
| 206 | 'date' => '/(\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?)/', | |
| 207 | 'number' => '/' . $float . '/', | |
| 208 | ], | |
| 209 | 'markdown' => [ | |
| 210 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 211 | 'comment' => '/(```[\s\S]*?```|~~~[\s\S]*?~~~)/', | |
| 212 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 213 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 214 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 215 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/', | |
| 216 | 'function' => '/(`[^`\n]+`)/', | |
| 217 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 218 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 219 | ], | |
| 220 | 'rmd' => [ | |
| 221 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 222 | 'comment' => '/(```\{r[^\}]*\}[\s\S]*?```)/', | |
| 223 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 224 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 225 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 226 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/', | |
| 227 | 'function' => '/(`[^`\n]+`)/', | |
| 228 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 229 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 230 | ], | |
| 231 | 'r' => [ | |
| 232 | 'string' => '/' . $str . '/', | |
| 233 | 'comment' => '/(#[^\r\n]*)/m', | |
| 234 | 'keyword' => '/\b(?:if|else|repeat|while|function|for|in|next|break)\b/', | |
| 235 | 'boolean' => '/\b(?:TRUE|FALSE|NULL|Inf|NaN|NA)\b/', | |
| 236 | 'function' => '/\b([a-zA-Z_.][a-zA-Z0-9_.]*)\s*(?=\()/', | |
| 237 | 'number' => '/' . $float . '/', | |
| 238 | ], | |
| 239 | 'csharp' => [ | |
| 240 | 'string' => '/(@"(?:""|[^"])*"|' . $str . ')/', | |
| 241 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 242 | 'preprocessor' => '/(^\s*#[^\r\n]*)/m', | |
| 243 | 'type' => '/\b(?:bool|byte|char|decimal|double|float|int|long|object|sbyte|short|string|uint|ulong|ushort|void)\b/', | |
| 244 | 'keyword' => '/\b(?:abstract|as|base|break|case|catch|checked|class|const|continue|default|delegate|do|else|enum|event|explicit|extern|false|finally|fixed|for|foreach|goto|if|implicit|in|interface|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|struct|switch|this|throw|true|try|typeof|unchecked|unsafe|using|virtual|volatile|while)\b/', | |
| 245 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 246 | 'number' => '/' . $int . '/', | |
| 247 | ], | |
| 248 | 'kotlin' => [ | |
| 249 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 250 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 251 | 'type' => '/\b(?:Boolean|Byte|Char|Double|Float|Int|Long|Short|String|Void|Unit|Any|Nothing)\b/', | |
| 252 | 'keyword' => '/\b(?:as|break|class|continue|do|else|false|for|fun|if|in|interface|is|null|object|package|return|super|this|throw|true|try|typealias|typeof|val|var|when|while)\b/', | |
| 253 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 254 | 'number' => '/' . $int . '/', | |
| 255 | ], | |
| 256 | 'scala' => [ | |
| 257 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 258 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 259 | 'type' => '/\b(?:Boolean|Byte|Char|Double|Float|Int|Long|Short|String|Unit|Any|AnyRef|AnyVal|Nothing|Null|void)\b/', | |
| 260 | 'keyword' => '/\b(?:abstract|case|catch|class|def|do|else|extends|false|final|finally|for|forSome|if|implicit|import|lazy|match|new|null|object|override|package|private|protected|return|sealed|super|this|throw|trait|try|true|type|val|var|while|with|yield)\b/', | |
| 261 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 262 | 'number' => '/' . $int . '/', | |
| 263 | ], | |
| 264 | 'groovy' => [ | |
| 265 | 'string' => '/(\'\'\'[\s\S]*?\'\'\'|""".*?"""|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|\/[^\/]+\/)/', | |
| 266 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 267 | 'type' => '/\b(?:boolean|byte|char|double|float|int|long|short|void)\b/', | |
| 268 | 'keyword' => '/\b(?:def|as|assert|break|case|catch|class|const|continue|default|do|else|enum|extends|false|finally|for|goto|if|implements|import|in|instanceof|interface|new|null|package|return|super|switch|this|throw|throws|trait|true|try|var|while)\b/', | |
| 269 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 270 | 'number' => '/' . $int . '/', | |
| 271 | ], | |
| 272 | 'dart' => [ | |
| 273 | 'string' => '/(r?\'\'\'[\s\S]*?\'\'\'|r?"""[\s\S]*?"""|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 274 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 275 | 'type' => '/\b(?:void|bool|int|double|num|dynamic)\b/', | |
| 276 | 'keyword' => '/\b(?:abstract|as|assert|async|await|break|case|catch|class|const|continue|default|do|else|enum|export|extends|extension|external|factory|false|final|finally|for|get|if|implements|import|in|interface|is|library|mixin|new|null|on|operator|part|rethrow|return|set|static|super|switch|sync|this|throw|true|try|typedef|var|while|with|yield)\b/', | |
| 277 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 278 | 'number' => '/' . $int . '/', | |
| 279 | ], | |
| 280 | 'swift' => [ | |
| 281 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 282 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 283 | 'type' => '/\b(?:Int|Double|Float|Bool|String|Void|Character|Any|AnyObject)\b/', | |
| 284 | 'keyword' => '/\b(?:associatedtype|class|deinit|enum|extension|fileprivate|func|import|init|inout|internal|let|open|operator|private|protocol|public|rethrows|static|struct|subscript|typealias|var|break|case|continue|default|defer|do|else|fallthrough|for|guard|if|in|repeat|return|switch|where|while|as|catch|false|is|nil|super|self|Self|throw|throws|true|try)\b/', | |
| 285 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 286 | 'number' => '/' . $int . '/', | |
| 287 | ], | |
| 288 | 'perl' => [ | |
| 289 | 'comment' => '/(#[^\r\n]*)/', | |
| 290 | 'string' => '/(' . $str . '|`[^`]*`)/', | |
| 291 | 'variable' => '/([$@%](?:\{[a-zA-Z_]\w*\}|[a-zA-Z_]\w*))/', | |
| 292 | 'keyword' => '/\b(?:my|local|our|state|use|sub|package|if|else|elsif|unless|while|until|for|foreach|do|last|next|redo|goto|continue|return|print|printf|say|die|warn|eval|try|catch)\b/', | |
| 293 | 'number' => '/' . $float . '/', | |
| 294 | ], | |
| 295 | 'powershell' => [ | |
| 296 | 'comment' => '/(<#[\s\S]*?#>|#[^\r\n]*)/', | |
| 297 | 'string' => '/(@"(?:""|[^"])*"@|@\'(?:[^[\'])*\'@|"(?:`.|[^"`])*"|\'(?:[^[\'])*\')/', | |
| 298 | 'variable' => '/(\$[a-zA-Z0-9_]+)/', | |
| 299 | 'keyword' => '/(?i)\b(?:Begin|Break|Catch|Class|Continue|Data|Define|Do|DynamicParam|Else|ElseIf|End|Exit|Filter|Finally|For|ForEach|From|Function|If|In|InlineScript|Hidden|Parallel|Param|Process|Return|Sequence|Switch|Throw|Trap|Try|Until|Using|Var|While|Workflow)\b/', | |
| 300 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_-]*)\s*(?=\()/', | |
| 301 | 'number' => '/' . $int . '/', | |
| 302 | ], | |
| 303 | 'containerfile' => [ | |
| 304 | 'comment' => '/(#[^\r\n]*)/', | |
| 305 | 'string' => '/' . $str . '/', | |
| 306 | 'keyword' => '/(?i)^\s*(?:FROM|MAINTAINER|RUN|CMD|LABEL|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\b/m', | |
| 307 | ], | |
| 308 | 'makefile' => [ | |
| 309 | 'comment' => '/(#[^\r\n]*)/', | |
| 310 | 'variable' => '/(\$+[{(][^})]+[})])/', | |
| 311 | 'keyword' => '/(?i)\b(?:include|define|endef|export|override|ifdef|ifndef|ifeq|ifneq|else|endif|vpath)\b/', | |
| 312 | 'function' => '/^([a-zA-Z0-9._-]+):/m', | |
| 313 | ], | |
| 314 | 'diff' => [ | |
| 315 | 'comment' => '/^(?:---| \+\+\+|index|diff).*/m', | |
| 316 | 'meta' => '/^(?:@@).*/m', | |
| 317 | 'inserted' => '/(^\+.*)/m', | |
| 318 | 'deleted' => '/(^-.*)/m', | |
| 319 | ] | |
| 320 | ]; | |
| 321 | ||
| 322 | return $rules[strtolower( $lang )] ?? []; | |
| 4 | $int = '(-?\b\d+(\.\d+)?\b)'; | |
| 5 | $str = '("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')'; | |
| 6 | $float = '(-?\d+(\.\d+)?([eE][+-]?\d+)?)'; | |
| 7 | $normalized = strtolower( $lang ); | |
| 8 | $result = []; | |
| 9 | $rules = [ | |
| 10 | 'gradle' => [ | |
| 11 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 12 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*"|""".*?""")/', | |
| 13 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\'|\'\'\'.*?\'\'\'' . | |
| 14 | '|\/.*?\/)/', | |
| 15 | 'keyword' => '/\b(?:def|task|group|version|ext|return|if|' . | |
| 16 | 'else)\b/', | |
| 17 | 'function' => '/\b(apply|plugin|sourceCompatibility|' . | |
| 18 | 'targetCompatibility|repositories|dependencies|' . | |
| 19 | 'test|plugins|buildscript|allprojects|' . | |
| 20 | 'subprojects|project|implementation|api|' . | |
| 21 | 'compileOnly|runtimeOnly|testImplementation|' . | |
| 22 | 'testRuntimeOnly|mavenCentral|google|jcenter|' . | |
| 23 | 'classpath)\b|\b([a-zA-Z_][a-zA-Z0-9_]*)\s*' . | |
| 24 | '(?=\(|{)/', | |
| 25 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 26 | 'boolean' => '/\b(?:true|false|null)\b/', | |
| 27 | 'number' => '/' . $int . '/', | |
| 28 | ], | |
| 29 | 'tex' => [ | |
| 30 | 'comment' => '/(%[^\r\n]*)/m', | |
| 31 | 'math' => '/(\$\$?.*?\$\$?)/s', | |
| 32 | 'keyword' => '/(\\\\(?:def|edef|gdef|xdef|let|futurelet|if|else|' . | |
| 33 | 'fi|ifnum|ifdim|ifodd|ifmmode|ifx|ifeof|iftrue|' . | |
| 34 | 'iffalse|ifcase|or|loop|repeat|newif|expandafter|' . | |
| 35 | 'noexpand|csname|endcsname|string|number|the|long|' . | |
| 36 | 'outer|global|par|advance|hsize|vsize|hoffset|' . | |
| 37 | 'voffset|displaywidth|parindent|baselineskip|' . | |
| 38 | 'leftskip|rightskip|hangindent|hangafter|parshape|' . | |
| 39 | 'pageno|nopagenumbers|folio|headline|footline|hbox|' . | |
| 40 | 'vbox|vtop|vcenter|rlap|llap|hskip|vskip|hfil|hfill|' . | |
| 41 | 'hfilneg|vfil|vfill|mskip|quad|qquad|enspace|' . | |
| 42 | 'thinspace|enskip|strut|phantom|vphantom|hphantom|' . | |
| 43 | 'smash|raise|lower|moveleft|moveright|halign|valign|' . | |
| 44 | 'noalign|openup|cr|crcr|omit|span|multispan|tabskip|' . | |
| 45 | 'settabs|matrix|pmatrix|bordermatrix|eqalign|' . | |
| 46 | 'displaylines|eqno|leqno|cases|left|right|over|atop|' . | |
| 47 | 'choose|brace|brack|root|of|buildrel|input|end|bye|' . | |
| 48 | 'item|itemitem|indent|noindent|narrower|rm|bf|tt|sl|' . | |
| 49 | 'it|font|char|magnification|magstep|magstephalf|day|' . | |
| 50 | 'month|year|jobname|romannumeral|uppercase|lowercase|' . | |
| 51 | 'footnote|topinsert|pageinsert|midinsert|endinsert|' . | |
| 52 | 'underbar|hfuzz|vfuzz|overfullrule|raggedright|' . | |
| 53 | 'raggedbottom|everypar|everymath|everydisplay|' . | |
| 54 | 'everycr))\b/', | |
| 55 | 'function' => '/(\\\\[a-zA-Z@]+|\\\\[^a-zA-Z@])/', | |
| 56 | 'variable' => '/(#[0-9])/', | |
| 57 | ], | |
| 58 | 'php' => [ | |
| 59 | 'tag' => '/(<\?php|<\?|=\?>|\?>)/', | |
| 60 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 61 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 62 | 'comment' => '/(\/\/[^\r\n]*|#[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 63 | 'type' => '/\b(?:array|bool|callable|float|int|iterable|' . | |
| 64 | 'mixed|never|object|string|void)\b/', | |
| 65 | 'keyword' => '/\b(?:abstract|and|as|break|case|catch|class|' . | |
| 66 | 'clone|const|continue|declare|default|die|do|' . | |
| 67 | 'echo|else|elseif|empty|enddeclare|endfor|' . | |
| 68 | 'endforeach|endif|endswitch|endwhile|enum|eval|' . | |
| 69 | 'exit|extends|final|finally|fn|for|foreach|' . | |
| 70 | 'function|global|goto|if|implements|include|' . | |
| 71 | 'include_once|instanceof|insteadof|interface|' . | |
| 72 | 'isset|list|match|namespace|new|or|print|' . | |
| 73 | 'private|protected|public|readonly|require|' . | |
| 74 | 'require_once|return|static|switch|throw|trait|' . | |
| 75 | 'try|unset|use|var|while|xor|yield)\b/', | |
| 76 | 'function' => '/\b([a-zA-Z_\x7f-\xff]' . | |
| 77 | '[a-zA-Z0-9_\x7f-\xff]*)\s*(?=\()/', | |
| 78 | 'variable' => '/(\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/', | |
| 79 | 'number' => '/' . $int . '/', | |
| 80 | 'boolean' => '/\b(true|false|null)\b/i', | |
| 81 | ], | |
| 82 | 'bash' => [ | |
| 83 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 84 | 'string' => '/(\'.*?\')/', | |
| 85 | 'comment' => '/(#[^\n]*)/', | |
| 86 | 'keyword' => '/(?<!-)\b(?:alias|bg|bind|break|builtin|case|' . | |
| 87 | 'cd|command|compgen|complete|continue|coproc|' . | |
| 88 | 'declare|dirs|disown|do|done|echo|elif|else|' . | |
| 89 | 'enable|esac|eval|exec|exit|export|fc|fg|fi|' . | |
| 90 | 'for|function|getopts|hash|help|history|if|' . | |
| 91 | 'jobs|kill|let|local|logout|popd|printf|pushd|' . | |
| 92 | 'pwd|read|readonly|return|select|set|shift|' . | |
| 93 | 'shopt|source|suspend|test|then|time|times|' . | |
| 94 | 'trap|type|typeset|ulimit|umask|unalias|unset|' . | |
| 95 | 'until|wait|while)\b/', | |
| 96 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 97 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 98 | 'number' => '/' . $int . '/', | |
| 99 | ], | |
| 100 | 'batch' => [ | |
| 101 | 'comment' => '/((?i:rem)\b[^\n]*|::[^\n]*)/', | |
| 102 | 'string' => '/("[^"]*")/', | |
| 103 | 'keyword' => '/(?i)\b(?:if|else|goto|for|in|do|exit|echo|pause|' . | |
| 104 | 'set|shift|start|cd|dir|copy|del|md|rd|cls|setlocal|' . | |
| 105 | 'endlocal|enabledelayedexpansion|defined|exist|not|' . | |
| 106 | 'errorlevel|setx|findstr|reg|nul|tokens|usebackq|' . | |
| 107 | 'equ|neq|lss|leq|gtr|geq)\b/', | |
| 108 | 'function' => '/(?i)\b(call)\b/', | |
| 109 | 'variable' => '/(![\w-]+!|%[\w\(\)-]+%|%%[~a-zA-Z]+|' . | |
| 110 | '%[~a-zA-Z0-9]+)/', | |
| 111 | 'label' => '/(^\s*:[a-zA-Z0-9_-]+)/m', | |
| 112 | 'number' => '/' . $int . '/', | |
| 113 | ], | |
| 114 | 'c' => [ | |
| 115 | 'string' => '/' . $str . '/', | |
| 116 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 117 | 'include' => '/(^\s*#include[^\r\n]*)/m', | |
| 118 | 'preprocessor' => '/(^\s*#(?!include\b)[^\r\n]*)/m', | |
| 119 | 'type' => '/\b(?:char|double|float|int|long|short|void|' . | |
| 120 | 'signed|unsigned)\b/', | |
| 121 | 'keyword' => '/\b(?:auto|break|case|const|continue|default|' . | |
| 122 | 'do|else|enum|extern|for|goto|if|noreturn|' . | |
| 123 | 'register|return|sizeof|static|struct|switch|' . | |
| 124 | 'typedef|union|volatile|while)\b/', | |
| 125 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 126 | 'number' => '/' . $int . '/', | |
| 127 | ], | |
| 128 | 'cpp' => [ | |
| 129 | 'string' => '/' . $str . '/', | |
| 130 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 131 | 'include' => '/(^\s*#include[^\r\n]*)/m', | |
| 132 | 'preprocessor' => '/(^\s*#(?!include\b)[^\r\n]*)/m', | |
| 133 | 'type' => '/\b(?:bool|char|char8_t|char16_t|char32_t|' . | |
| 134 | 'double|float|int|long|short|signed|unsigned|' . | |
| 135 | 'void|wchar_t)\b/', | |
| 136 | 'keyword' => '/\b(?:alignas|alignof|and|and_eq|asm|auto|' . | |
| 137 | 'bitand|bitor|break|case|catch|class|co_await|' . | |
| 138 | 'co_return|co_yield|compl|concept|const|' . | |
| 139 | 'consteval|constexpr|constinit|const_cast|' . | |
| 140 | 'continue|decltype|default|delete|do|' . | |
| 141 | 'dynamic_cast|else|enum|explicit|export|extern|' . | |
| 142 | 'for|friend|goto|if|inline|mutable|namespace|' . | |
| 143 | 'new|noexcept|noreturn|not|not_eq|nullptr|' . | |
| 144 | 'operator|or|or_eq|private|protected|public|' . | |
| 145 | 'register|reinterpret_cast|requires|return|' . | |
| 146 | 'sizeof|static|static_assert|static_cast|' . | |
| 147 | 'struct|switch|template|this|thread_local|' . | |
| 148 | 'throw|try|typedef|typeid|typename|union|using|' . | |
| 149 | 'virtual|volatile|while|xor|xor_eq)\b/', | |
| 150 | 'boolean' => '/\b(?:true|false)\b/', | |
| 151 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 152 | 'number' => '/' . $int . '/', | |
| 153 | ], | |
| 154 | 'java' => [ | |
| 155 | 'class' => '/(@[a-zA-Z_][a-zA-Z0-9_]*)/', | |
| 156 | 'string' => '/' . $str . '/', | |
| 157 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 158 | 'type' => '/\b(?:boolean|byte|char|double|float|int|long|' . | |
| 159 | 'short|void)\b/', | |
| 160 | 'keyword' => '/\b(?:abstract|assert|break|case|catch|class|' . | |
| 161 | 'const|continue|default|do|else|enum|extends|final|' . | |
| 162 | 'finally|for|goto|if|implements|import|instanceof|' . | |
| 163 | 'interface|native|new|non-sealed|package|permits|' . | |
| 164 | 'private|protected|public|record|return|sealed|' . | |
| 165 | 'static|strictfp|super|switch|synchronized|this|' . | |
| 166 | 'throw|throws|transient|try|var|volatile|while|' . | |
| 167 | 'yield)\b/', | |
| 168 | 'boolean' => '/\b(?:true|false|null)\b/', | |
| 169 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 170 | 'number' => '/' . $int . '/', | |
| 171 | ], | |
| 172 | 'go' => [ | |
| 173 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|`.*?`)/s', | |
| 174 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 175 | 'type' => '/\b(?:bool|byte|complex64|complex128|error|' . | |
| 176 | 'float32|float64|int|int8|int16|int32|int64|rune|' . | |
| 177 | 'string|uint|uint8|uint16|uint32|uint64|uintptr)\b/', | |
| 178 | 'keyword' => '/\b(?:break|case|chan|const|continue|default|' . | |
| 179 | 'defer|else|fallthrough|for|func|go|goto|if|import|' . | |
| 180 | 'interface|map|package|range|return|select|struct|' . | |
| 181 | 'switch|type|var)\b/', | |
| 182 | 'boolean' => '/\b(?:true|false|nil|iota)\b/', | |
| 183 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 184 | 'number' => '/' . $int . '/', | |
| 185 | ], | |
| 186 | 'rust' => [ | |
| 187 | 'string' => '/' . $str . '/', | |
| 188 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 189 | 'type' => '/\b(?:bool|char|f32|f64|i8|i16|i32|i64|i128|isize|' . | |
| 190 | 'str|u8|u16|u32|u64|u128|usize)\b/', | |
| 191 | 'keyword' => '/\b(?:as|async|await|break|const|continue|crate|' . | |
| 192 | 'dyn|else|enum|extern|fn|for|if|impl|in|let|loop|' . | |
| 193 | 'match|mod|move|mut|pub|ref|return|self|Self|' . | |
| 194 | 'static|struct|super|trait|type|union|unsafe|use|' . | |
| 195 | 'where|while)\b/', | |
| 196 | 'boolean' => '/\b(?:true|false)\b/', | |
| 197 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 198 | 'number' => '/' . $int . '/', | |
| 199 | ], | |
| 200 | 'python' => [ | |
| 201 | 'string' => '/(\'\'\'.*?\'\'\'|""".*?"""|"(?:\\\\.|[^"\\\\])*"|' . | |
| 202 | '\'(?:\\\\.|[^\'\\\\])*\')/s', | |
| 203 | 'comment' => '/(#[^\r\n]*)/m', | |
| 204 | 'type' => '/\b(?:bool|bytearray|bytes|complex|dict|float|' . | |
| 205 | 'frozenset|int|list|memoryview|object|range|set|' . | |
| 206 | 'str|tuple)\b/', | |
| 207 | 'keyword' => '/\b(?:and|as|assert|async|await|break|class|' . | |
| 208 | 'continue|def|del|elif|else|except|finally|for|' . | |
| 209 | 'from|global|if|import|in|is|lambda|nonlocal|not|' . | |
| 210 | 'or|pass|raise|return|try|while|with|yield)\b/', | |
| 211 | 'boolean' => '/\b(?:False|None|True)\b/', | |
| 212 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 213 | 'number' => '/' . $int . '/', | |
| 214 | ], | |
| 215 | 'ruby' => [ | |
| 216 | 'string_interp' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 217 | 'string' => '/(\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 218 | 'comment' => '/(#[^\r\n]*)/m', | |
| 219 | 'keyword' => '/\b(?:alias|and|begin|break|case|class|def|' . | |
| 220 | 'defined|do|else|elsif|end|ensure|for|if|in|' . | |
| 221 | 'module|next|not|or|redo|rescue|retry|return|' . | |
| 222 | 'self|super|then|undef|unless|until|when|' . | |
| 223 | 'while|yield)\b/', | |
| 224 | 'boolean' => '/\b(?:true|false|nil)\b/', | |
| 225 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*[?!]?)\s*(?=\()/', | |
| 226 | 'variable' => '/(@[a-zA-Z_]\w*|\$[a-zA-Z_]\w*)/', | |
| 227 | 'number' => '/' . $int . '/', | |
| 228 | ], | |
| 229 | 'lua' => [ | |
| 230 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|' . | |
| 231 | '\[\[.*?\]\])/s', | |
| 232 | 'comment' => '/(--\[\[.*?\]\]|--[^\r\n]*)/ms', | |
| 233 | 'keyword' => '/\b(?:and|break|do|else|elseif|end|for|function|' . | |
| 234 | 'if|in|local|not|or|repeat|return|then|until|' . | |
| 235 | 'while)\b/', | |
| 236 | 'boolean' => '/\b(?:false|nil|true)\b/', | |
| 237 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 238 | 'number' => '/' . $int . '/', | |
| 239 | ], | |
| 240 | 'javascript' => [ | |
| 241 | 'regex' => '/(\/(?![/*])(?:\\\\.|[^\\/\r\n])+\/[a-zA-Z]*)/', | |
| 242 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|' . | |
| 243 | '`(?:\\\\.|[^`\\\\])*`)/s', | |
| 244 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 245 | 'keyword' => '/\b(?:as|async|await|break|case|catch|class|' . | |
| 246 | 'const|continue|debugger|default|delete|do|else|' . | |
| 247 | 'enum|export|extends|finally|for|from|function|if|' . | |
| 248 | 'import|in|instanceof|let|new|of|return|static|' . | |
| 249 | 'super|switch|this|throw|try|typeof|var|void|' . | |
| 250 | 'while|with|yield)\b/', | |
| 251 | 'boolean' => '/\b(?:true|false|null|undefined)\b/', | |
| 252 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 253 | 'number' => '/' . $int . '/', | |
| 254 | ], | |
| 255 | 'typescript' => [ | |
| 256 | 'regex' => '/(\/(?![/*])(?:\\\\.|[^\\/\r\n])+\/[a-zA-Z]*)/', | |
| 257 | 'string' => '/("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|' . | |
| 258 | '`(?:\\\\.|[^`\\\\])*`)/s', | |
| 259 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 260 | 'type' => '/\b(?:boolean|number|string|void|any|never|' . | |
| 261 | 'unknown|object|symbol|bigint)\b/', | |
| 262 | 'keyword' => '/\b(?:abstract|as|break|case|catch|class|const|' . | |
| 263 | 'continue|debugger|declare|default|delete|do|else|' . | |
| 264 | 'enum|export|extends|finally|for|from|function|if|' . | |
| 265 | 'implements|import|in|instanceof|interface|is|let|' . | |
| 266 | 'module|namespace|new|of|package|private|' . | |
| 267 | 'protected|public|readonly|require|return|static|' . | |
| 268 | 'super|switch|this|throw|try|type|typeof|var|' . | |
| 269 | 'while|with|yield)\b/', | |
| 270 | 'boolean' => '/\b(?:true|false|null|undefined)\b/', | |
| 271 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 272 | 'number' => '/' . $int . '/', | |
| 273 | ], | |
| 274 | 'html' => [ | |
| 275 | 'comment' => '/(<!--[\s\S]*?-->)/', | |
| 276 | 'string' => '/' . $str . '/', | |
| 277 | 'tag' => '/<\/?[!a-zA-Z0-9:-]+|\s*\/?>/', | |
| 278 | 'attribute' => '/[a-zA-Z0-9:-]+(?=\=)/', | |
| 279 | ], | |
| 280 | 'xml' => [ | |
| 281 | 'comment' => '/(<!--[\s\S]*?-->)/', | |
| 282 | 'string' => '/' . $str . '/', | |
| 283 | 'tag' => '/<\/?[!a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>/', | |
| 284 | 'attribute' => '/[a-zA-Z0-9:-]+(?=\=)/', | |
| 285 | ], | |
| 286 | 'xslt' => [ | |
| 287 | 'comment' => '/(<!--[\s\S]*?-->)/', | |
| 288 | 'string' => '/' . $str . '/', | |
| 289 | 'keyword' => '/<\/?[a-zA-Z0-9-]*:?(?:accept|accumulator|' . | |
| 290 | 'accumulator-rule|analyze-string|apply-imports|' . | |
| 291 | 'apply-templates|assert|attribute|attribute-set|' . | |
| 292 | 'break|call-template|catch|character-map|choose|' . | |
| 293 | 'comment|context-item|copy|copy-of|' . | |
| 294 | 'decimal-format|document|element|evaluate|' . | |
| 295 | 'expose|fallback|for-each|for-each-group|fork|' . | |
| 296 | 'function|global-context-item|if|import|include|' . | |
| 297 | 'iterate|key|map|map-entry|matching-substring|' . | |
| 298 | 'merge|merge-action|merge-key|merge-source|' . | |
| 299 | 'message|mode|namespace|namespace-alias|' . | |
| 300 | 'next-iteration|next-match|' . | |
| 301 | 'non-matching-substring|on-empty|on-non-empty|' . | |
| 302 | 'otherwise|output|output-character|override|' . | |
| 303 | 'package|param|perform-sort|preserve-space|' . | |
| 304 | 'processing-instruction|result-document|sequence|' . | |
| 305 | 'sort|source-document|strip-space|stylesheet|' . | |
| 306 | 'template|text|transform|try|use-package|' . | |
| 307 | 'value-of|variable|when|where-populated|' . | |
| 308 | 'with-param)\b/', | |
| 309 | 'function' => '/\b[a-zA-Z_][a-zA-Z0-9_-]*\s*(?=\()/', | |
| 310 | 'variable' => '/\$[a-zA-Z0-9_-]+/', | |
| 311 | 'tag' => '/<\/?[!a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>/', | |
| 312 | 'attribute' => '/[a-zA-Z0-9:-]+(?=\=)/', | |
| 313 | ], | |
| 314 | 'css' => [ | |
| 315 | 'comment' => '/(\/\*.*?\*\/)/s', | |
| 316 | 'tag' => '/(?<=^|\}|\{)\s*([a-zA-Z0-9_\-#\.\s,>+~]+)(?=\{)/m', | |
| 317 | 'property' => '/([a-zA-Z-]+)(?=\s*:)/', | |
| 318 | 'string' => '/' . $str . '/', | |
| 319 | 'number' => '/(-?(\d*\.)?\d+(px|em|rem|%|vh|vw|s|ms|deg))/', | |
| 320 | ], | |
| 321 | 'json' => [ | |
| 322 | 'attribute' => '/("(?:\\\\.|[^"\\\\])*")(?=\s*:)/', | |
| 323 | 'string' => '/("(?:\\\\.|[^"\\\\])*")/', | |
| 324 | 'boolean' => '/\b(true|false|null)\b/', | |
| 325 | 'number' => '/\b(-?\d+(\.\d+)?([eE][+-]?\d+)?)\b/', | |
| 326 | ], | |
| 327 | 'sql' => [ | |
| 328 | 'string' => '/(\'.*?\')/', | |
| 329 | 'comment' => '/(--[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 330 | 'type' => '/(?i)\b(?:BIGINT|BIT|BOOLEAN|CHAR|DATE|DATETIME|' . | |
| 331 | 'DECIMAL|DOUBLE|FLOAT|INT|INTEGER|MONEY|NUMERIC|' . | |
| 332 | 'REAL|SMALLINT|TEXT|TIME|TIMESTAMP|TINYINT|' . | |
| 333 | 'VARCHAR)\b/', | |
| 334 | 'keyword' => '/(?i)\b(ADD|ALTER|AND|AS|ASC|BEGIN|BETWEEN|BY|' . | |
| 335 | 'CASE|CHECK|COLUMN|COMMIT|CONSTRAINT|CREATE|' . | |
| 336 | 'DATABASE|DEFAULT|DELETE|DESC|DISTINCT|DROP|ELSE|' . | |
| 337 | 'END|EXISTS|FOREIGN|FROM|FULL|FUNCTION|GRANT|' . | |
| 338 | 'GROUP|HAVING|IF|IN|INDEX|INNER|INSERT|INTO|IS|' . | |
| 339 | 'JOIN|KEY|LEFT|LIKE|LIMIT|NOT|NULL|OFFSET|ON|OR|' . | |
| 340 | 'ORDER|OUTER|PRIMARY|PROCEDURE|REFERENCES|REVOKE|' . | |
| 341 | 'RIGHT|ROLLBACK|SCHEMA|SELECT|SET|TABLE|THEN|' . | |
| 342 | 'TRANSACTION|TRIGGER|TRUNCATE|UNION|UNIQUE|' . | |
| 343 | 'UPDATE|VALUES|VIEW|WHEN|WHERE)\b/', | |
| 344 | 'boolean' => '/(?i)\b(NULL|TRUE|FALSE)\b/', | |
| 345 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 346 | 'number' => '/' . $int . '/', | |
| 347 | ], | |
| 348 | 'yaml' => [ | |
| 349 | 'comment' => '/(#[^\r\n]*)/m', | |
| 350 | 'variable' => '/^(\s*[a-zA-Z0-9_-]+:)/m', | |
| 351 | 'string_interp' => '/((?<=:)\s*[^\r\n]*)/', | |
| 352 | 'number' => '/' . $float . '/', | |
| 353 | ], | |
| 354 | 'properties' => [ | |
| 355 | 'comment' => '/(^[ \t]*[#!][^\r\n]*)/m', | |
| 356 | 'variable' => '/(^[ \t]*[^:=\s]+)(?=[ \t]*[:=])/m', | |
| 357 | 'string_interp' => '/((?<=[=:])\s*[^\r\n]*)/', | |
| 358 | ], | |
| 359 | 'ini' => [ | |
| 360 | 'comment' => '/(^[ \t]*[;#][^\r\n]*)/m', | |
| 361 | 'keyword' => '/(^\[[^\]\r\n]+\])/m', | |
| 362 | 'variable' => '/(^[ \t]*[a-zA-Z0-9_\.\-]+)(?=\s*=)/m', | |
| 363 | 'string' => '/((?<==)\s*[^\r\n]*)/', | |
| 364 | ], | |
| 365 | 'toml' => [ | |
| 366 | 'comment' => '/(#[^\r\n]*)/', | |
| 367 | 'keyword' => '/(^\[[^\]\r\n]+\])/m', | |
| 368 | 'variable' => '/(\b[a-zA-Z0-9_-]+\b)(?=\s*=)/', | |
| 369 | 'string' => '/(' . $str . '|"""[\s\S]*?"""|' . | |
| 370 | '\'\'\'[\s\S]*?\'\'\')/', | |
| 371 | 'boolean' => '/\b(true|false)\b/', | |
| 372 | 'date' => '/(\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}' . | |
| 373 | '(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?)/', | |
| 374 | 'number' => '/' . $float . '/', | |
| 375 | ], | |
| 376 | 'markdown' => [ | |
| 377 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 378 | 'comment' => '/(```[\s\S]*?```|~~~[\s\S]*?~~~)/', | |
| 379 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 380 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 381 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 382 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|' . | |
| 383 | '(?<!_)(_[^\n_]+_)(?!_)/', | |
| 384 | 'function' => '/(`[^`\n]+`)/', | |
| 385 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 386 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 387 | ], | |
| 388 | 'rmd' => [ | |
| 389 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 390 | 'comment' => '/(```\{r[^\}]*\}[\s\S]*?```)/', | |
| 391 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 392 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 393 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 394 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|' . | |
| 395 | '(?<!_)(_[^\n_]+_)(?!_)/', | |
| 396 | 'function' => '/(`[^`\n]+`)/', | |
| 397 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 398 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 399 | ], | |
| 400 | 'r' => [ | |
| 401 | 'string' => '/' . $str . '/', | |
| 402 | 'comment' => '/(#[^\r\n]*)/m', | |
| 403 | 'keyword' => '/\b(?:if|else|repeat|while|function|for|in|next|' . | |
| 404 | 'break)\b/', | |
| 405 | 'boolean' => '/\b(?:TRUE|FALSE|NULL|Inf|NaN|NA)\b/', | |
| 406 | 'function' => '/\b([a-zA-Z_.][a-zA-Z0-9_.]*)\s*(?=\()/', | |
| 407 | 'number' => '/' . $float . '/', | |
| 408 | ], | |
| 409 | 'csharp' => [ | |
| 410 | 'string' => '/(@"(?:""|[^"])*"|' . $str . ')/', | |
| 411 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 412 | 'preprocessor' => '/(^\s*#[^\r\n]*)/m', | |
| 413 | 'type' => '/\b(?:bool|byte|char|decimal|double|float|' . | |
| 414 | 'int|long|object|sbyte|short|string|uint|' . | |
| 415 | 'ulong|ushort|void)\b/', | |
| 416 | 'keyword' => '/\b(?:abstract|as|base|break|case|catch|' . | |
| 417 | 'checked|class|const|continue|default|' . | |
| 418 | 'delegate|do|else|enum|event|explicit|extern|' . | |
| 419 | 'false|finally|fixed|for|foreach|goto|if|' . | |
| 420 | 'implicit|in|interface|internal|is|lock|' . | |
| 421 | 'namespace|new|null|operator|out|override|' . | |
| 422 | 'params|private|protected|public|readonly|ref|' . | |
| 423 | 'return|sealed|sizeof|stackalloc|static|' . | |
| 424 | 'struct|switch|this|throw|true|try|typeof|' . | |
| 425 | 'unchecked|unsafe|using|virtual|volatile|' . | |
| 426 | 'while)\b/', | |
| 427 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 428 | 'number' => '/' . $int . '/', | |
| 429 | ], | |
| 430 | 'kotlin' => [ | |
| 431 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 432 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 433 | 'type' => '/\b(?:Boolean|Byte|Char|Double|Float|Int|Long|' . | |
| 434 | 'Short|String|Void|Unit|Any|Nothing)\b/', | |
| 435 | 'keyword' => '/\b(?:as|break|class|continue|do|else|false|for|' . | |
| 436 | 'fun|if|in|interface|is|null|object|package|' . | |
| 437 | 'return|super|this|throw|true|try|typealias|' . | |
| 438 | 'typeof|val|var|when|while)\b/', | |
| 439 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 440 | 'number' => '/' . $int . '/', | |
| 441 | ], | |
| 442 | 'scala' => [ | |
| 443 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 444 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 445 | 'type' => '/\b(?:Boolean|Byte|Char|Double|Float|Int|Long|' . | |
| 446 | 'Short|String|Unit|Any|AnyRef|AnyVal|Nothing|' . | |
| 447 | 'Null|void)\b/', | |
| 448 | 'keyword' => '/\b(?:abstract|case|catch|class|def|do|else|' . | |
| 449 | 'extends|false|final|finally|for|forSome|if|' . | |
| 450 | 'implicit|import|lazy|match|new|null|object|' . | |
| 451 | 'override|package|private|protected|return|' . | |
| 452 | 'sealed|super|this|throw|trait|try|true|type|val|' . | |
| 453 | 'var|while|with|yield)\b/', | |
| 454 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 455 | 'number' => '/' . $int . '/', | |
| 456 | ], | |
| 457 | 'groovy' => [ | |
| 458 | 'string' => '/(\'\'\'[\s\S]*?\'\'\'|""".*?"""|' . | |
| 459 | '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'' . | |
| 460 | '|\/[^\/]+\/)/', | |
| 461 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 462 | 'type' => '/\b(?:boolean|byte|char|double|float|int|' . | |
| 463 | 'long|short|void)\b/', | |
| 464 | 'keyword' => '/\b(?:def|as|assert|break|case|catch|class|' . | |
| 465 | 'const|continue|default|do|else|enum|extends|' . | |
| 466 | 'false|finally|for|goto|if|implements|import|' . | |
| 467 | 'in|instanceof|interface|new|null|package|' . | |
| 468 | 'return|super|switch|this|throw|throws|trait|' . | |
| 469 | 'true|try|var|while)\b/', | |
| 470 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 471 | 'number' => '/' . $int . '/', | |
| 472 | ], | |
| 473 | 'dart' => [ | |
| 474 | 'string' => '/(r?\'\'\'[\s\S]*?\'\'\'|r?"""[\s\S]*?"""|' . | |
| 475 | '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')/', | |
| 476 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 477 | 'type' => '/\b(?:void|bool|int|double|num|dynamic)\b/', | |
| 478 | 'keyword' => '/\b(?:abstract|as|assert|async|await|break|case|' . | |
| 479 | 'catch|class|const|continue|default|do|else|enum|' . | |
| 480 | 'export|extends|extension|external|factory|false|' . | |
| 481 | 'final|finally|for|get|if|implements|import|in|' . | |
| 482 | 'interface|is|library|mixin|new|null|on|operator|' . | |
| 483 | 'part|rethrow|return|set|static|super|switch|' . | |
| 484 | 'sync|this|throw|true|try|typedef|var|while|with|' . | |
| 485 | 'yield)\b/', | |
| 486 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 487 | 'number' => '/' . $int . '/', | |
| 488 | ], | |
| 489 | 'swift' => [ | |
| 490 | 'string' => '/("""[\s\S]*?"""|' . $str . ')/', | |
| 491 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 492 | 'type' => '/\b(?:Int|Double|Float|Bool|String|Void|' . | |
| 493 | 'Character|Any|AnyObject)\b/', | |
| 494 | 'keyword' => '/\b(?:associatedtype|class|deinit|enum|' . | |
| 495 | 'extension|fileprivate|func|import|init|inout|' . | |
| 496 | 'internal|let|open|operator|private|protocol|' . | |
| 497 | 'public|rethrows|static|struct|subscript|' . | |
| 498 | 'typealias|var|break|case|continue|default|defer|' . | |
| 499 | 'do|else|fallthrough|for|guard|if|in|repeat|' . | |
| 500 | 'return|switch|where|while|as|catch|false|is|nil|' . | |
| 501 | 'super|self|Self|throw|throws|true|try)\b/', | |
| 502 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 503 | 'number' => '/' . $int . '/', | |
| 504 | ], | |
| 505 | 'perl' => [ | |
| 506 | 'comment' => '/(#[^\r\n]*)/', | |
| 507 | 'string' => '/(' . $str . '|`[^`]*`)/', | |
| 508 | 'variable' => '/([$@%](?:\{[a-zA-Z_]\w*\}|[a-zA-Z_]\w*))/', | |
| 509 | 'keyword' => '/\b(?:my|local|our|state|use|sub|package|if|' . | |
| 510 | 'else|elsif|unless|while|until|for|foreach|do|' . | |
| 511 | 'last|next|redo|goto|continue|return|print|' . | |
| 512 | 'printf|say|die|warn|eval|try|catch)\b/', | |
| 513 | 'number' => '/' . $float . '/', | |
| 514 | ], | |
| 515 | 'powershell' => [ | |
| 516 | 'comment' => '/(<#[\s\S]*?#>|#[^\r\n]*)/', | |
| 517 | 'string' => '/(@"(?:""|[^"])*"@|@\'(?:[^[\'])*\'@|' . | |
| 518 | '"(?:`.|[^"`])*"|\'(?:[^[\'])*\')/', | |
| 519 | 'variable' => '/(\$[a-zA-Z0-9_]+)/', | |
| 520 | 'keyword' => '/(?i)\b(?:Begin|Break|Catch|Class|Continue|Data|' . | |
| 521 | 'Define|Do|DynamicParam|Else|ElseIf|End|Exit|' . | |
| 522 | 'Filter|Finally|For|ForEach|From|Function|If|In|' . | |
| 523 | 'InlineScript|Hidden|Parallel|Param|Process|' . | |
| 524 | 'Return|Sequence|Switch|Throw|Trap|Try|Until|' . | |
| 525 | 'Using|Var|While|Workflow)\b/', | |
| 526 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_-]*)\s*(?=\()/', | |
| 527 | 'number' => '/' . $int . '/', | |
| 528 | ], | |
| 529 | 'containerfile' => [ | |
| 530 | 'comment' => '/(#[^\r\n]*)/', | |
| 531 | 'string' => '/' . $str . '/', | |
| 532 | 'keyword' => '/(?i)^\s*(?:FROM|MAINTAINER|RUN|CMD|LABEL|EXPOSE|' . | |
| 533 | 'ENV|ADD|COPY|ENTRYPOINT|VOLUME|USER|WORKDIR|ARG|' . | |
| 534 | 'ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\b/m', | |
| 535 | ], | |
| 536 | 'makefile' => [ | |
| 537 | 'comment' => '/(#[^\r\n]*)/', | |
| 538 | 'variable' => '/(\$+[{(][^})]+[})])/', | |
| 539 | 'keyword' => '/(?i)\b(?:include|define|endef|export|override|' . | |
| 540 | 'ifdef|ifndef|ifeq|ifneq|else|endif|vpath)\b/', | |
| 541 | 'function' => '/^([a-zA-Z0-9._-]+):/m', | |
| 542 | ], | |
| 543 | 'diff' => [ | |
| 544 | 'comment' => '/^(?:---| \+\+\+|index|diff).*/m', | |
| 545 | 'meta' => '/^(?:@@).*/m', | |
| 546 | 'inserted' => '/(^\+.*)/m', | |
| 547 | 'deleted' => '/(^-.*)/m', | |
| 548 | ], | |
| 549 | 'fortran' => [ | |
| 550 | 'comment' => '/(^[Cc*][^\r\n]*|![^\r\n]*)/m', | |
| 551 | 'string' => '/(\'.*?\')/', | |
| 552 | 'boolean' => '/\B(\.(?:TRUE|FALSE)\.)\B/i', | |
| 553 | 'operator' => '/\B(\.(?:EQ|NE|LT|LE|GT|GE|NOT|' . | |
| 554 | 'AND|OR|EQV|NEQV)\.)\B/i', | |
| 555 | 'type' => '/(?i)\b(?:INTEGER(?:\*[0-9]+)?|' . | |
| 556 | 'REAL(?:\*[0-9]+)?|DOUBLE PRECISION|' . | |
| 557 | 'COMPLEX|LOGICAL(?:\*[0-9]+)?|CHARACTER)\b/', | |
| 558 | 'keyword' => '/(?i)\b(?:IMPLICIT|COMMON|CALL|GO\s*TO|IF|' . | |
| 559 | 'READ|PRINT|WRITE|STOP|REWIND|FORMAT|END|' . | |
| 560 | 'SUBROUTINE|RETURN|EQUIVALENCE|DATA|' . | |
| 561 | 'BLOCK\s*DATA|FUNCTION|DO|CONTINUE|THEN|ELSE|' . | |
| 562 | 'ELSEIF|ENDIF|SAVE|DIMENSION|PAUSE|ASSIGN|TO)\b/', | |
| 563 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 564 | 'number' => '/\b(\d+(\.\d+)?([eEdD][+-]?\d+)?)\b/', | |
| 565 | 'label' => '/^[ \t]*([0-9]+)/m' | |
| 566 | ] | |
| 567 | ]; | |
| 568 | ||
| 569 | if( array_key_exists( $normalized, $rules ) ) { | |
| 570 | $result = $rules[$normalized]; | |
| 571 | } | |
| 572 | ||
| 573 | return $result; | |
| 323 | 574 | } |
| 324 | 575 | } |
| 231 | 231 | |
| 232 | 232 | .blob-code pre { |
| 233 | overflow-x: auto; | |
| 234 | } | |
| 235 | ||
| 236 | .refs-list { | |
| 237 | display: grid; | |
| 238 | gap: 10px; | |
| 239 | } | |
| 240 | ||
| 241 | .ref-item { | |
| 242 | background: #161b22; | |
| 243 | border: 1px solid #30363d; | |
| 244 | border-radius: 6px; | |
| 245 | padding: 12px 16px; | |
| 246 | display: flex; | |
| 247 | align-items: center; | |
| 248 | gap: 12px; | |
| 249 | } | |
| 250 | ||
| 251 | .ref-type { | |
| 252 | background: #238636; | |
| 253 | color: white; | |
| 254 | padding: 2px 8px; | |
| 255 | border-radius: 12px; | |
| 256 | font-size: 0.75rem; | |
| 257 | font-weight: 600; | |
| 258 | text-transform: uppercase; | |
| 259 | } | |
| 260 | ||
| 261 | .ref-type.tag { | |
| 262 | background: #8957e5; | |
| 263 | } | |
| 264 | ||
| 265 | .ref-name { | |
| 266 | font-weight: 600; | |
| 267 | color: #f0f6fc; | |
| 268 | } | |
| 269 | ||
| 270 | .empty-state { | |
| 271 | text-align: center; | |
| 272 | padding: 60px 20px; | |
| 273 | color: #8b949e; | |
| 274 | } | |
| 275 | ||
| 276 | .commit-details { | |
| 277 | background: #161b22; | |
| 278 | border: 1px solid #30363d; | |
| 279 | border-radius: 6px; | |
| 280 | padding: 20px; | |
| 281 | margin-bottom: 20px; | |
| 282 | } | |
| 283 | ||
| 284 | .commit-header { | |
| 285 | margin-bottom: 20px; | |
| 286 | } | |
| 287 | ||
| 288 | .commit-title { | |
| 289 | font-size: 1.25rem; | |
| 290 | color: #f0f6fc; | |
| 291 | margin-bottom: 10px; | |
| 292 | } | |
| 293 | ||
| 294 | .commit-info { | |
| 295 | display: grid; | |
| 296 | gap: 8px; | |
| 297 | font-size: 0.875rem; | |
| 298 | } | |
| 299 | ||
| 300 | .commit-info-row { | |
| 301 | display: flex; | |
| 302 | gap: 10px; | |
| 303 | } | |
| 304 | ||
| 305 | .commit-info-label { | |
| 306 | color: #8b949e; | |
| 307 | width: 80px; | |
| 308 | flex-shrink: 0; | |
| 309 | } | |
| 310 | ||
| 311 | .commit-info-value { | |
| 312 | color: #c9d1d9; | |
| 313 | font-family: monospace; | |
| 314 | } | |
| 315 | ||
| 316 | .parent-link { | |
| 317 | color: #58a6ff; | |
| 318 | text-decoration: none; | |
| 319 | } | |
| 320 | ||
| 321 | .parent-link:hover { | |
| 322 | text-decoration: underline; | |
| 323 | } | |
| 324 | ||
| 325 | .repo-grid { | |
| 326 | display: grid; | |
| 327 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
| 328 | gap: 16px; | |
| 329 | margin-top: 20px; | |
| 330 | } | |
| 331 | ||
| 332 | .repo-card { | |
| 333 | background: #161b22; | |
| 334 | border: 1px solid #30363d; | |
| 335 | border-radius: 8px; | |
| 336 | padding: 20px; | |
| 337 | text-decoration: none; | |
| 338 | color: inherit; | |
| 339 | transition: border-color 0.2s, transform 0.1s; | |
| 340 | } | |
| 341 | ||
| 342 | .repo-card:hover { | |
| 343 | border-color: #58a6ff; | |
| 344 | transform: translateY(-2px); | |
| 345 | } | |
| 346 | ||
| 347 | .repo-card h3 { | |
| 348 | color: #58a6ff; | |
| 349 | margin-bottom: 8px; | |
| 350 | font-size: 1.1rem; | |
| 351 | } | |
| 352 | ||
| 353 | .repo-card p { | |
| 354 | color: #8b949e; | |
| 355 | font-size: 0.875rem; | |
| 356 | margin: 0; | |
| 357 | } | |
| 358 | ||
| 359 | .current-repo { | |
| 360 | background: #21262d; | |
| 361 | border: 1px solid #58a6ff; | |
| 362 | padding: 8px 16px; | |
| 363 | border-radius: 6px; | |
| 364 | font-size: 0.875rem; | |
| 365 | color: #f0f6fc; | |
| 366 | } | |
| 367 | ||
| 368 | .current-repo strong { | |
| 369 | color: #58a6ff; | |
| 370 | } | |
| 371 | ||
| 372 | .branch-badge { | |
| 373 | background: #238636; | |
| 374 | color: white; | |
| 375 | padding: 2px 8px; | |
| 376 | border-radius: 12px; | |
| 377 | font-size: 0.75rem; | |
| 378 | font-weight: 600; | |
| 379 | margin-left: 10px; | |
| 380 | } | |
| 381 | ||
| 382 | .commit-row { | |
| 383 | display: flex; | |
| 384 | padding: 10px 0; | |
| 385 | border-bottom: 1px solid #30363d; | |
| 386 | gap: 15px; | |
| 387 | align-items: baseline; | |
| 388 | } | |
| 389 | ||
| 390 | .commit-row:last-child { | |
| 391 | border-bottom: none; | |
| 392 | } | |
| 393 | ||
| 394 | .commit-row .sha { | |
| 395 | font-family: monospace; | |
| 396 | color: #58a6ff; | |
| 397 | text-decoration: none; | |
| 398 | } | |
| 399 | ||
| 400 | .commit-row .message { | |
| 401 | flex: 1; | |
| 402 | font-weight: 500; | |
| 403 | } | |
| 404 | ||
| 405 | .commit-row .meta { | |
| 406 | font-size: 0.85em; | |
| 407 | color: #8b949e; | |
| 408 | white-space: nowrap; | |
| 409 | } | |
| 410 | ||
| 411 | .blob-content-image { | |
| 412 | text-align: center; | |
| 413 | padding: 20px; | |
| 414 | background: #0d1117; | |
| 415 | } | |
| 416 | ||
| 417 | .blob-content-image img { | |
| 418 | max-width: 100%; | |
| 419 | border: 1px solid #30363d; | |
| 420 | } | |
| 421 | ||
| 422 | .blob-content-video { | |
| 423 | text-align: center; | |
| 424 | padding: 20px; | |
| 425 | background: #000; | |
| 426 | } | |
| 427 | ||
| 428 | .blob-content-video video { | |
| 429 | max-width: 100%; | |
| 430 | max-height: 80vh; | |
| 431 | } | |
| 432 | ||
| 433 | .blob-content-audio { | |
| 434 | text-align: center; | |
| 435 | padding: 40px; | |
| 436 | background: #161b22; | |
| 437 | } | |
| 438 | ||
| 439 | .blob-content-audio audio { | |
| 440 | width: 100%; | |
| 441 | max-width: 600px; | |
| 442 | } | |
| 443 | ||
| 444 | .download-state { | |
| 445 | text-align: center; | |
| 446 | padding: 40px; | |
| 447 | border: 1px solid #30363d; | |
| 448 | border-radius: 6px; | |
| 449 | margin-top: 10px; | |
| 450 | } | |
| 451 | ||
| 452 | .download-state p { | |
| 453 | margin-bottom: 20px; | |
| 454 | color: #8b949e; | |
| 455 | } | |
| 456 | ||
| 457 | .btn-download { | |
| 458 | display: inline-block; | |
| 459 | padding: 6px 16px; | |
| 460 | background: #238636; | |
| 461 | color: white; | |
| 462 | text-decoration: none; | |
| 463 | border-radius: 6px; | |
| 464 | font-weight: 600; | |
| 465 | } | |
| 466 | ||
| 467 | .repo-info-banner { | |
| 468 | margin-top: 15px; | |
| 469 | } | |
| 470 | ||
| 471 | .file-icon-container { | |
| 472 | width: 20px; | |
| 473 | text-align: center; | |
| 474 | margin-right: 5px; | |
| 475 | color: #8b949e; | |
| 476 | } | |
| 477 | ||
| 478 | .file-size { | |
| 479 | color: #8b949e; | |
| 480 | font-size: 0.8em; | |
| 481 | margin-left: 10px; | |
| 482 | } | |
| 483 | ||
| 484 | .file-date { | |
| 485 | color: #8b949e; | |
| 486 | font-size: 0.8em; | |
| 487 | margin-left: auto; | |
| 488 | } | |
| 489 | ||
| 490 | .repo-card-time { | |
| 491 | margin-top: 8px; | |
| 492 | color: #58a6ff; | |
| 493 | } | |
| 494 | ||
| 495 | ||
| 496 | .diff-container { | |
| 497 | display: flex; | |
| 498 | flex-direction: column; | |
| 499 | gap: 20px; | |
| 500 | } | |
| 501 | ||
| 502 | .diff-file { | |
| 503 | background: #161b22; | |
| 504 | border: 1px solid #30363d; | |
| 505 | border-radius: 6px; | |
| 506 | overflow: hidden; | |
| 507 | } | |
| 508 | ||
| 509 | .diff-header { | |
| 510 | background: #21262d; | |
| 511 | padding: 10px 16px; | |
| 512 | border-bottom: 1px solid #30363d; | |
| 513 | display: flex; | |
| 514 | align-items: center; | |
| 515 | gap: 10px; | |
| 516 | } | |
| 517 | ||
| 518 | .diff-path { | |
| 519 | font-family: monospace; | |
| 520 | font-size: 0.9rem; | |
| 521 | color: #f0f6fc; | |
| 522 | } | |
| 523 | ||
| 524 | .diff-binary { | |
| 525 | padding: 20px; | |
| 526 | text-align: center; | |
| 527 | color: #8b949e; | |
| 528 | font-style: italic; | |
| 529 | } | |
| 530 | ||
| 531 | .diff-content { | |
| 532 | overflow-x: auto; | |
| 533 | } | |
| 534 | ||
| 535 | .diff-content table { | |
| 536 | width: 100%; | |
| 537 | border-collapse: collapse; | |
| 538 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 539 | font-size: 12px; | |
| 540 | } | |
| 541 | ||
| 542 | .diff-content td { | |
| 543 | padding: 2px 0; | |
| 544 | line-height: 20px; | |
| 545 | } | |
| 546 | ||
| 547 | .diff-num { | |
| 548 | width: 1%; | |
| 549 | min-width: 40px; | |
| 550 | text-align: right; | |
| 551 | padding-right: 10px; | |
| 552 | color: #6e7681; | |
| 553 | user-select: none; | |
| 554 | background: #0d1117; | |
| 555 | border-right: 1px solid #30363d; | |
| 556 | } | |
| 557 | ||
| 558 | .diff-num::before { | |
| 559 | content: attr(data-num); | |
| 560 | } | |
| 561 | ||
| 562 | .diff-code { | |
| 563 | padding-left: 10px; | |
| 564 | white-space: pre-wrap; | |
| 565 | word-break: break-all; | |
| 566 | color: #c9d1d9; | |
| 567 | } | |
| 568 | ||
| 569 | .diff-marker { | |
| 570 | display: inline-block; | |
| 571 | width: 15px; | |
| 572 | user-select: none; | |
| 573 | color: #8b949e; | |
| 574 | } | |
| 575 | ||
| 576 | /* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */ | |
| 577 | .diff-add { | |
| 578 | background-color: rgba(2, 59, 149, 0.25); | |
| 579 | } | |
| 580 | .diff-add .diff-code { | |
| 581 | color: #79c0ff; | |
| 582 | } | |
| 583 | .diff-add .diff-marker { | |
| 584 | color: #79c0ff; | |
| 585 | } | |
| 586 | ||
| 587 | .diff-del { | |
| 588 | background-color: rgba(148, 99, 0, 0.25); | |
| 589 | } | |
| 590 | .diff-del .diff-code { | |
| 591 | color: #d29922; | |
| 592 | } | |
| 593 | .diff-del .diff-marker { | |
| 594 | color: #d29922; | |
| 595 | } | |
| 596 | ||
| 597 | .diff-gap { | |
| 598 | background: #0d1117; | |
| 599 | color: #484f58; | |
| 600 | text-align: center; | |
| 601 | font-size: 0.8em; | |
| 602 | height: 20px; | |
| 603 | } | |
| 604 | .diff-gap td { | |
| 605 | padding: 0; | |
| 606 | line-height: 20px; | |
| 607 | background: rgba(110, 118, 129, 0.1); | |
| 608 | } | |
| 609 | ||
| 610 | .status-add { color: #58a6ff; } | |
| 611 | .status-del { color: #d29922; } | |
| 612 | .status-mod { color: #a371f7; } | |
| 613 | ||
| 614 | .tag-table, .file-list-table { | |
| 615 | width: 100%; | |
| 616 | border-collapse: collapse; | |
| 617 | margin-top: 10px; | |
| 618 | background: #161b22; | |
| 619 | border: 1px solid #30363d; | |
| 620 | border-radius: 6px; | |
| 621 | overflow: hidden; | |
| 622 | } | |
| 623 | ||
| 624 | .tag-table th, .file-list-table th { | |
| 625 | text-align: left; | |
| 626 | padding: 10px 16px; | |
| 627 | border-bottom: 2px solid #30363d; | |
| 628 | color: #8b949e; | |
| 629 | font-size: 0.875rem; | |
| 630 | font-weight: 600; | |
| 631 | white-space: nowrap; | |
| 632 | } | |
| 633 | ||
| 634 | .tag-table td, .file-list-table td { | |
| 635 | padding: 12px 16px; | |
| 636 | border-bottom: 1px solid #21262d; | |
| 637 | vertical-align: middle; | |
| 638 | color: #c9d1d9; | |
| 639 | font-size: 0.9rem; | |
| 640 | } | |
| 641 | ||
| 642 | .tag-table tr:hover td, .file-list-table tr:hover td { | |
| 643 | background: #161b22; | |
| 644 | } | |
| 645 | ||
| 646 | .tag-table .tag-name { | |
| 647 | min-width: 140px; | |
| 648 | width: 20%; | |
| 649 | } | |
| 650 | ||
| 651 | .tag-table .tag-message { | |
| 652 | width: auto; | |
| 653 | white-space: normal; | |
| 654 | word-break: break-word; | |
| 655 | color: #c9d1d9; | |
| 656 | font-weight: 500; | |
| 657 | } | |
| 658 | ||
| 659 | .tag-table .tag-author, | |
| 660 | .tag-table .tag-time, | |
| 661 | .tag-table .tag-hash { | |
| 662 | width: 1%; | |
| 663 | white-space: nowrap; | |
| 664 | } | |
| 665 | ||
| 666 | .tag-table .tag-time { | |
| 667 | text-align: right; | |
| 668 | color: #8b949e; | |
| 669 | } | |
| 670 | ||
| 671 | .tag-table .tag-hash { | |
| 672 | text-align: right; | |
| 673 | } | |
| 674 | ||
| 675 | .tag-table .tag-name a { | |
| 676 | color: #58a6ff; | |
| 677 | text-decoration: none; | |
| 678 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 679 | } | |
| 680 | ||
| 681 | .tag-table .tag-author { | |
| 682 | color: #c9d1d9; | |
| 683 | } | |
| 684 | ||
| 685 | .tag-table .tag-age-header { | |
| 686 | text-align: right; | |
| 687 | } | |
| 688 | ||
| 689 | .tag-table .tag-commit-header { | |
| 690 | text-align: right; | |
| 691 | } | |
| 692 | ||
| 693 | .tag-table .commit-hash { | |
| 694 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 695 | color: #58a6ff; | |
| 696 | text-decoration: none; | |
| 697 | } | |
| 698 | ||
| 699 | .tag-table .commit-hash:hover { | |
| 700 | text-decoration: underline; | |
| 701 | } | |
| 702 | ||
| 703 | .file-list-table .file-icon-cell { | |
| 704 | width: 20px; | |
| 705 | text-align: center; | |
| 706 | color: #8b949e; | |
| 707 | padding-right: 0; | |
| 708 | } | |
| 709 | ||
| 710 | .file-list-table .file-name-cell a { | |
| 711 | color: #58a6ff; | |
| 712 | text-decoration: none; | |
| 713 | font-weight: 500; | |
| 714 | } | |
| 715 | ||
| 716 | .file-list-table .file-name-cell a:hover { | |
| 717 | text-decoration: underline; | |
| 718 | } | |
| 719 | ||
| 720 | .file-list-table .file-mode-cell { | |
| 721 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 722 | color: #8b949e; | |
| 723 | font-size: 0.8rem; | |
| 724 | width: 1%; | |
| 725 | white-space: nowrap; | |
| 726 | text-align: center; | |
| 727 | } | |
| 728 | ||
| 729 | .file-list-table .file-size-cell { | |
| 730 | color: #8b949e; | |
| 731 | text-align: right; | |
| 732 | width: 1%; | |
| 733 | white-space: nowrap; | |
| 734 | font-size: 0.85rem; | |
| 735 | } | |
| 736 | ||
| 737 | .file-list-table .file-date-cell { | |
| 738 | color: #8b949e; | |
| 739 | text-align: right; | |
| 740 | width: 150px; | |
| 741 | font-size: 0.85rem; | |
| 742 | white-space: nowrap; | |
| 743 | } | |
| 744 | ||
| 745 | ||
| 746 | .blob-code { | |
| 747 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 748 | background-color: #161b22; | |
| 749 | color: #fcfcfa; | |
| 750 | font-size: 0.875rem; | |
| 751 | line-height: 1.6; | |
| 752 | tab-size: 2; | |
| 753 | } | |
| 754 | ||
| 755 | .hl-comment, | |
| 756 | .hl-doc-comment { | |
| 757 | color: #727072; | |
| 758 | font-style: italic; | |
| 759 | } | |
| 760 | ||
| 761 | .hl-function, | |
| 762 | .hl-method { | |
| 763 | color: #78dce8; | |
| 764 | } | |
| 765 | ||
| 766 | .hl-tag { | |
| 767 | color: #3e8bff; | |
| 768 | } | |
| 769 | ||
| 770 | .hl-class, | |
| 771 | .hl-interface, | |
| 772 | .hl-struct { | |
| 773 | color: #a9dc76; | |
| 774 | } | |
| 775 | ||
| 776 | .hl-type { | |
| 777 | color: #a9dc76; | |
| 778 | } | |
| 779 | ||
| 780 | .hl-keyword, | |
| 781 | .hl-storage, | |
| 782 | .hl-modifier, | |
| 783 | .hl-statement { | |
| 784 | color: #ff6188; | |
| 785 | font-weight: 600; | |
| 786 | } | |
| 787 | ||
| 788 | .hl-string, | |
| 789 | .hl-string_interp { | |
| 790 | color: #ffd866; | |
| 791 | } | |
| 792 | ||
| 793 | .hl-number, | |
| 794 | .hl-boolean, | |
| 795 | .hl-constant, | |
| 796 | .hl-preprocessor { | |
| 797 | color: #ab9df2; | |
| 798 | } | |
| 799 | ||
| 800 | .hl-variable { | |
| 801 | color: #fcfcfa; | |
| 802 | } | |
| 803 | ||
| 804 | .hl-attribute, | |
| 805 | .hl-property { | |
| 806 | color: #fc9867; | |
| 807 | } | |
| 808 | ||
| 809 | .hl-operator, | |
| 810 | .hl-punctuation, | |
| 811 | .hl-escape { | |
| 812 | color: #939293; | |
| 813 | } | |
| 814 | ||
| 815 | .hl-interp-punct { | |
| 816 | color: #ff6188; | |
| 817 | } | |
| 818 | ||
| 819 | .hl-math { | |
| 820 | color: #ab9df2; | |
| 821 | font-style: italic; | |
| 822 | } | |
| 823 | ||
| 824 | .hl-code { | |
| 825 | display: inline-block; | |
| 826 | width: 100%; | |
| 827 | background-color: #0d1117; | |
| 828 | color: #c9d1d9; | |
| 829 | padding: 2px 4px; | |
| 830 | border-radius: 3px; | |
| 831 | } | |
| 832 | ||
| 833 | @media (max-width: 768px) { | |
| 834 | .container { | |
| 835 | padding: 10px; | |
| 836 | } | |
| 837 | ||
| 838 | h1 { font-size: 1.5rem; } | |
| 839 | h2 { font-size: 1.2rem; } | |
| 840 | ||
| 841 | .nav { | |
| 842 | flex-direction: column; | |
| 843 | align-items: flex-start; | |
| 844 | gap: 10px; | |
| 845 | } | |
| 846 | ||
| 847 | .repo-selector { | |
| 848 | margin-left: 0; | |
| 849 | width: 100%; | |
| 850 | } | |
| 851 | ||
| 852 | .repo-selector select { | |
| 853 | flex: 1; | |
| 854 | } | |
| 855 | ||
| 856 | .file-list-table th, | |
| 857 | .file-list-table td { | |
| 858 | padding: 8px 10px; | |
| 859 | } | |
| 860 | ||
| 861 | .file-list-table .file-mode-cell, | |
| 862 | .file-list-table .file-date-cell { | |
| 863 | display: none; | |
| 864 | } | |
| 865 | ||
| 866 | .commit-details { | |
| 867 | padding: 15px; | |
| 868 | } | |
| 869 | ||
| 870 | .commit-title { | |
| 871 | font-size: 1.1rem; | |
| 872 | word-break: break-word; | |
| 873 | } | |
| 874 | ||
| 875 | .commit-info-row { | |
| 876 | flex-direction: column; | |
| 877 | gap: 2px; | |
| 878 | margin-bottom: 10px; | |
| 879 | } | |
| 880 | ||
| 881 | .commit-info-label { | |
| 882 | width: 100%; | |
| 883 | font-size: 0.8rem; | |
| 884 | color: #8b949e; | |
| 885 | } | |
| 886 | ||
| 887 | .commit-info-value { | |
| 888 | word-break: break-all; | |
| 889 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 890 | font-size: 0.9rem; | |
| 891 | padding-left: 0; | |
| 892 | } | |
| 893 | ||
| 894 | .commit-row { | |
| 895 | flex-direction: column; | |
| 896 | gap: 5px; | |
| 897 | } | |
| 898 | ||
| 899 | .commit-row .message { | |
| 900 | width: 100%; | |
| 901 | white-space: normal; | |
| 902 | } | |
| 903 | ||
| 904 | .commit-row .meta { | |
| 905 | font-size: 0.8rem; | |
| 906 | } | |
| 907 | ||
| 908 | .tag-table .tag-author, | |
| 909 | .tag-table .tag-time, | |
| 910 | .tag-table .tag-hash { | |
| 911 | font-size: 0.8rem; | |
| 912 | } | |
| 913 | ||
| 914 | .blob-code, .diff-content { | |
| 915 | overflow-x: scroll; | |
| 916 | -webkit-overflow-scrolling: touch; | |
| 917 | } | |
| 918 | } | |
| 919 | ||
| 920 | @media screen and (orientation: landscape) and (max-height: 600px) { | |
| 921 | .container { | |
| 922 | max-width: 100%; | |
| 923 | } | |
| 924 | ||
| 925 | header { | |
| 926 | margin-bottom: 15px; | |
| 927 | padding-bottom: 10px; | |
| 928 | } | |
| 929 | ||
| 930 | .file-list-table .file-date-cell { | |
| 931 | display: table-cell; | |
| 932 | } | |
| 233 | overflow-x: auto; | |
| 234 | } | |
| 235 | ||
| 236 | .refs-list { | |
| 237 | display: grid; | |
| 238 | gap: 10px; | |
| 239 | } | |
| 240 | ||
| 241 | .ref-item { | |
| 242 | background: #161b22; | |
| 243 | border: 1px solid #30363d; | |
| 244 | border-radius: 6px; | |
| 245 | padding: 12px 16px; | |
| 246 | display: flex; | |
| 247 | align-items: center; | |
| 248 | gap: 12px; | |
| 249 | } | |
| 250 | ||
| 251 | .ref-type { | |
| 252 | background: #238636; | |
| 253 | color: white; | |
| 254 | padding: 2px 8px; | |
| 255 | border-radius: 12px; | |
| 256 | font-size: 0.75rem; | |
| 257 | font-weight: 600; | |
| 258 | text-transform: uppercase; | |
| 259 | } | |
| 260 | ||
| 261 | .ref-type.tag { | |
| 262 | background: #8957e5; | |
| 263 | } | |
| 264 | ||
| 265 | .ref-name { | |
| 266 | font-weight: 600; | |
| 267 | color: #f0f6fc; | |
| 268 | } | |
| 269 | ||
| 270 | .empty-state { | |
| 271 | text-align: center; | |
| 272 | padding: 60px 20px; | |
| 273 | color: #8b949e; | |
| 274 | } | |
| 275 | ||
| 276 | .commit-details { | |
| 277 | background: #161b22; | |
| 278 | border: 1px solid #30363d; | |
| 279 | border-radius: 6px; | |
| 280 | padding: 20px; | |
| 281 | margin-bottom: 20px; | |
| 282 | } | |
| 283 | ||
| 284 | .commit-header { | |
| 285 | margin-bottom: 20px; | |
| 286 | } | |
| 287 | ||
| 288 | .commit-title { | |
| 289 | font-size: 1.25rem; | |
| 290 | color: #f0f6fc; | |
| 291 | margin-bottom: 10px; | |
| 292 | } | |
| 293 | ||
| 294 | .commit-info { | |
| 295 | display: grid; | |
| 296 | gap: 8px; | |
| 297 | font-size: 0.875rem; | |
| 298 | } | |
| 299 | ||
| 300 | .commit-info-row { | |
| 301 | display: flex; | |
| 302 | gap: 10px; | |
| 303 | } | |
| 304 | ||
| 305 | .commit-info-label { | |
| 306 | color: #8b949e; | |
| 307 | width: 80px; | |
| 308 | flex-shrink: 0; | |
| 309 | } | |
| 310 | ||
| 311 | .commit-info-value { | |
| 312 | color: #c9d1d9; | |
| 313 | font-family: monospace; | |
| 314 | } | |
| 315 | ||
| 316 | .parent-link { | |
| 317 | color: #58a6ff; | |
| 318 | text-decoration: none; | |
| 319 | } | |
| 320 | ||
| 321 | .parent-link:hover { | |
| 322 | text-decoration: underline; | |
| 323 | } | |
| 324 | ||
| 325 | .repo-grid { | |
| 326 | display: grid; | |
| 327 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
| 328 | gap: 16px; | |
| 329 | margin-top: 20px; | |
| 330 | } | |
| 331 | ||
| 332 | .repo-card { | |
| 333 | background: #161b22; | |
| 334 | border: 1px solid #30363d; | |
| 335 | border-radius: 8px; | |
| 336 | padding: 20px; | |
| 337 | text-decoration: none; | |
| 338 | color: inherit; | |
| 339 | transition: border-color 0.2s, transform 0.1s; | |
| 340 | } | |
| 341 | ||
| 342 | .repo-card:hover { | |
| 343 | border-color: #58a6ff; | |
| 344 | transform: translateY(-2px); | |
| 345 | } | |
| 346 | ||
| 347 | .repo-card h3 { | |
| 348 | color: #58a6ff; | |
| 349 | margin-bottom: 8px; | |
| 350 | font-size: 1.1rem; | |
| 351 | } | |
| 352 | ||
| 353 | .repo-card p { | |
| 354 | color: #8b949e; | |
| 355 | font-size: 0.875rem; | |
| 356 | margin: 0; | |
| 357 | } | |
| 358 | ||
| 359 | .current-repo { | |
| 360 | background: #21262d; | |
| 361 | border: 1px solid #58a6ff; | |
| 362 | padding: 8px 16px; | |
| 363 | border-radius: 6px; | |
| 364 | font-size: 0.875rem; | |
| 365 | color: #f0f6fc; | |
| 366 | } | |
| 367 | ||
| 368 | .current-repo strong { | |
| 369 | color: #58a6ff; | |
| 370 | } | |
| 371 | ||
| 372 | .branch-badge { | |
| 373 | background: #238636; | |
| 374 | color: white; | |
| 375 | padding: 2px 8px; | |
| 376 | border-radius: 12px; | |
| 377 | font-size: 0.75rem; | |
| 378 | font-weight: 600; | |
| 379 | margin-left: 10px; | |
| 380 | } | |
| 381 | ||
| 382 | .commit-row { | |
| 383 | display: flex; | |
| 384 | padding: 10px 0; | |
| 385 | border-bottom: 1px solid #30363d; | |
| 386 | gap: 15px; | |
| 387 | align-items: baseline; | |
| 388 | } | |
| 389 | ||
| 390 | .commit-row:last-child { | |
| 391 | border-bottom: none; | |
| 392 | } | |
| 393 | ||
| 394 | .commit-row .sha { | |
| 395 | font-family: monospace; | |
| 396 | color: #58a6ff; | |
| 397 | text-decoration: none; | |
| 398 | } | |
| 399 | ||
| 400 | .commit-row .message { | |
| 401 | flex: 1; | |
| 402 | font-weight: 500; | |
| 403 | } | |
| 404 | ||
| 405 | .commit-row .meta { | |
| 406 | font-size: 0.85em; | |
| 407 | color: #8b949e; | |
| 408 | white-space: nowrap; | |
| 409 | } | |
| 410 | ||
| 411 | .blob-content-image { | |
| 412 | text-align: center; | |
| 413 | padding: 20px; | |
| 414 | background: #0d1117; | |
| 415 | } | |
| 416 | ||
| 417 | .blob-content-image img { | |
| 418 | max-width: 100%; | |
| 419 | border: 1px solid #30363d; | |
| 420 | } | |
| 421 | ||
| 422 | .blob-content-video { | |
| 423 | text-align: center; | |
| 424 | padding: 20px; | |
| 425 | background: #000; | |
| 426 | } | |
| 427 | ||
| 428 | .blob-content-video video { | |
| 429 | max-width: 100%; | |
| 430 | max-height: 80vh; | |
| 431 | } | |
| 432 | ||
| 433 | .blob-content-audio { | |
| 434 | text-align: center; | |
| 435 | padding: 40px; | |
| 436 | background: #161b22; | |
| 437 | } | |
| 438 | ||
| 439 | .blob-content-audio audio { | |
| 440 | width: 100%; | |
| 441 | max-width: 600px; | |
| 442 | } | |
| 443 | ||
| 444 | .download-state { | |
| 445 | text-align: center; | |
| 446 | padding: 40px; | |
| 447 | border: 1px solid #30363d; | |
| 448 | border-radius: 6px; | |
| 449 | margin-top: 10px; | |
| 450 | } | |
| 451 | ||
| 452 | .download-state p { | |
| 453 | margin-bottom: 20px; | |
| 454 | color: #8b949e; | |
| 455 | } | |
| 456 | ||
| 457 | .btn-download { | |
| 458 | display: inline-block; | |
| 459 | padding: 6px 16px; | |
| 460 | background: #238636; | |
| 461 | color: white; | |
| 462 | text-decoration: none; | |
| 463 | border-radius: 6px; | |
| 464 | font-weight: 600; | |
| 465 | } | |
| 466 | ||
| 467 | .repo-info-banner { | |
| 468 | margin-top: 15px; | |
| 469 | } | |
| 470 | ||
| 471 | .file-icon-container { | |
| 472 | width: 20px; | |
| 473 | text-align: center; | |
| 474 | margin-right: 5px; | |
| 475 | color: #8b949e; | |
| 476 | } | |
| 477 | ||
| 478 | .file-size { | |
| 479 | color: #8b949e; | |
| 480 | font-size: 0.8em; | |
| 481 | margin-left: 10px; | |
| 482 | } | |
| 483 | ||
| 484 | .file-date { | |
| 485 | color: #8b949e; | |
| 486 | font-size: 0.8em; | |
| 487 | margin-left: auto; | |
| 488 | } | |
| 489 | ||
| 490 | .repo-card-time { | |
| 491 | margin-top: 8px; | |
| 492 | color: #58a6ff; | |
| 493 | } | |
| 494 | ||
| 495 | .diff-container { | |
| 496 | display: flex; | |
| 497 | flex-direction: column; | |
| 498 | gap: 20px; | |
| 499 | } | |
| 500 | ||
| 501 | .diff-file { | |
| 502 | background: #161b22; | |
| 503 | border: 1px solid #30363d; | |
| 504 | border-radius: 6px; | |
| 505 | overflow: hidden; | |
| 506 | } | |
| 507 | ||
| 508 | .diff-header { | |
| 509 | background: #21262d; | |
| 510 | padding: 10px 16px; | |
| 511 | border-bottom: 1px solid #30363d; | |
| 512 | display: flex; | |
| 513 | align-items: center; | |
| 514 | gap: 10px; | |
| 515 | } | |
| 516 | ||
| 517 | .diff-path { | |
| 518 | font-family: monospace; | |
| 519 | font-size: 0.9rem; | |
| 520 | color: #f0f6fc; | |
| 521 | } | |
| 522 | ||
| 523 | .diff-binary { | |
| 524 | padding: 20px; | |
| 525 | text-align: center; | |
| 526 | color: #8b949e; | |
| 527 | font-style: italic; | |
| 528 | } | |
| 529 | ||
| 530 | .diff-content { | |
| 531 | overflow-x: auto; | |
| 532 | } | |
| 533 | ||
| 534 | .diff-content table { | |
| 535 | width: 100%; | |
| 536 | border-collapse: collapse; | |
| 537 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 538 | font-size: 12px; | |
| 539 | } | |
| 540 | ||
| 541 | .diff-content td { | |
| 542 | padding: 2px 0; | |
| 543 | line-height: 20px; | |
| 544 | } | |
| 545 | ||
| 546 | .diff-num { | |
| 547 | width: 1%; | |
| 548 | min-width: 40px; | |
| 549 | text-align: right; | |
| 550 | padding-right: 10px; | |
| 551 | color: #6e7681; | |
| 552 | user-select: none; | |
| 553 | background: #0d1117; | |
| 554 | border-right: 1px solid #30363d; | |
| 555 | } | |
| 556 | ||
| 557 | .diff-num::before { | |
| 558 | content: attr(data-num); | |
| 559 | } | |
| 560 | ||
| 561 | .diff-code { | |
| 562 | padding-left: 10px; | |
| 563 | white-space: pre-wrap; | |
| 564 | word-break: break-all; | |
| 565 | color: #c9d1d9; | |
| 566 | } | |
| 567 | ||
| 568 | .diff-marker { | |
| 569 | display: inline-block; | |
| 570 | width: 15px; | |
| 571 | user-select: none; | |
| 572 | color: #8b949e; | |
| 573 | } | |
| 574 | ||
| 575 | .diff-add { | |
| 576 | background-color: rgba(2, 59, 149, 0.25); | |
| 577 | } | |
| 578 | .diff-add .diff-code { | |
| 579 | color: #79c0ff; | |
| 580 | } | |
| 581 | .diff-add .diff-marker { | |
| 582 | color: #79c0ff; | |
| 583 | } | |
| 584 | ||
| 585 | .diff-del { | |
| 586 | background-color: rgba(148, 99, 0, 0.25); | |
| 587 | } | |
| 588 | .diff-del .diff-code { | |
| 589 | color: #d29922; | |
| 590 | } | |
| 591 | .diff-del .diff-marker { | |
| 592 | color: #d29922; | |
| 593 | } | |
| 594 | ||
| 595 | .diff-gap { | |
| 596 | background: #0d1117; | |
| 597 | color: #484f58; | |
| 598 | text-align: center; | |
| 599 | font-size: 0.8em; | |
| 600 | height: 20px; | |
| 601 | } | |
| 602 | .diff-gap td { | |
| 603 | padding: 8px 0; | |
| 604 | background: #161b22; | |
| 605 | border-top: 1px solid #30363d; | |
| 606 | border-bottom: 1px solid #30363d; | |
| 607 | text-align: center; | |
| 608 | } | |
| 609 | ||
| 610 | .diff-gap-icon { | |
| 611 | vertical-align: middle; | |
| 612 | } | |
| 613 | ||
| 614 | .status-add { color: #58a6ff; } | |
| 615 | .status-del { color: #d29922; } | |
| 616 | .status-mod { color: #a371f7; } | |
| 617 | ||
| 618 | .tag-table, .file-list-table { | |
| 619 | width: 100%; | |
| 620 | border-collapse: collapse; | |
| 621 | margin-top: 10px; | |
| 622 | background: #161b22; | |
| 623 | border: 1px solid #30363d; | |
| 624 | border-radius: 6px; | |
| 625 | overflow: hidden; | |
| 626 | } | |
| 627 | ||
| 628 | .tag-table th, .file-list-table th { | |
| 629 | text-align: left; | |
| 630 | padding: 10px 16px; | |
| 631 | border-bottom: 2px solid #30363d; | |
| 632 | color: #8b949e; | |
| 633 | font-size: 0.875rem; | |
| 634 | font-weight: 600; | |
| 635 | white-space: nowrap; | |
| 636 | } | |
| 637 | ||
| 638 | .tag-table td, .file-list-table td { | |
| 639 | padding: 12px 16px; | |
| 640 | border-bottom: 1px solid #21262d; | |
| 641 | vertical-align: middle; | |
| 642 | color: #c9d1d9; | |
| 643 | font-size: 0.9rem; | |
| 644 | } | |
| 645 | ||
| 646 | .tag-table tr:hover td, .file-list-table tr:hover td { | |
| 647 | background: #161b22; | |
| 648 | } | |
| 649 | ||
| 650 | .tag-table .tag-name { | |
| 651 | min-width: 140px; | |
| 652 | width: 20%; | |
| 653 | } | |
| 654 | ||
| 655 | .tag-table .tag-message { | |
| 656 | width: auto; | |
| 657 | white-space: normal; | |
| 658 | word-break: break-word; | |
| 659 | color: #c9d1d9; | |
| 660 | font-weight: 500; | |
| 661 | } | |
| 662 | ||
| 663 | .tag-table .tag-author, | |
| 664 | .tag-table .tag-time, | |
| 665 | .tag-table .tag-hash { | |
| 666 | width: 1%; | |
| 667 | white-space: nowrap; | |
| 668 | } | |
| 669 | ||
| 670 | .tag-table .tag-time { | |
| 671 | text-align: right; | |
| 672 | color: #8b949e; | |
| 673 | } | |
| 674 | ||
| 675 | .tag-table .tag-hash { | |
| 676 | text-align: right; | |
| 677 | } | |
| 678 | ||
| 679 | .tag-table .tag-name a { | |
| 680 | color: #58a6ff; | |
| 681 | text-decoration: none; | |
| 682 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 683 | } | |
| 684 | ||
| 685 | .tag-table .tag-author { | |
| 686 | color: #c9d1d9; | |
| 687 | } | |
| 688 | ||
| 689 | .tag-table .tag-age-header { | |
| 690 | text-align: right; | |
| 691 | } | |
| 692 | ||
| 693 | .tag-table .tag-commit-header { | |
| 694 | text-align: right; | |
| 695 | } | |
| 696 | ||
| 697 | .tag-table .commit-hash { | |
| 698 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 699 | color: #58a6ff; | |
| 700 | text-decoration: none; | |
| 701 | } | |
| 702 | ||
| 703 | .tag-table .commit-hash:hover { | |
| 704 | text-decoration: underline; | |
| 705 | } | |
| 706 | ||
| 707 | .file-list-table .file-icon-cell { | |
| 708 | width: 20px; | |
| 709 | text-align: center; | |
| 710 | color: #8b949e; | |
| 711 | padding-right: 0; | |
| 712 | } | |
| 713 | ||
| 714 | .file-list-table .file-name-cell a { | |
| 715 | color: #58a6ff; | |
| 716 | text-decoration: none; | |
| 717 | font-weight: 500; | |
| 718 | } | |
| 719 | ||
| 720 | .file-list-table .file-name-cell a:hover { | |
| 721 | text-decoration: underline; | |
| 722 | } | |
| 723 | ||
| 724 | .file-list-table .file-mode-cell { | |
| 725 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 726 | color: #8b949e; | |
| 727 | font-size: 0.8rem; | |
| 728 | width: 1%; | |
| 729 | white-space: nowrap; | |
| 730 | text-align: center; | |
| 731 | } | |
| 732 | ||
| 733 | .file-list-table .file-size-cell { | |
| 734 | color: #8b949e; | |
| 735 | text-align: right; | |
| 736 | width: 1%; | |
| 737 | white-space: nowrap; | |
| 738 | font-size: 0.85rem; | |
| 739 | } | |
| 740 | ||
| 741 | .file-list-table .file-date-cell { | |
| 742 | color: #8b949e; | |
| 743 | text-align: right; | |
| 744 | width: 150px; | |
| 745 | font-size: 0.85rem; | |
| 746 | white-space: nowrap; | |
| 747 | } | |
| 748 | ||
| 749 | .blob-code { | |
| 750 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 751 | background-color: #161b22; | |
| 752 | color: #fcfcfa; | |
| 753 | font-size: 0.875rem; | |
| 754 | line-height: 1.6; | |
| 755 | tab-size: 2; | |
| 756 | } | |
| 757 | ||
| 758 | .hl-comment, | |
| 759 | .hl-doc-comment { | |
| 760 | color: #727072; | |
| 761 | font-style: italic; | |
| 762 | } | |
| 763 | ||
| 764 | .hl-function, | |
| 765 | .hl-method { | |
| 766 | color: #78dce8; | |
| 767 | } | |
| 768 | ||
| 769 | .hl-tag { | |
| 770 | color: #3e8bff; | |
| 771 | } | |
| 772 | ||
| 773 | .hl-class, | |
| 774 | .hl-interface, | |
| 775 | .hl-struct { | |
| 776 | color: #a9dc76; | |
| 777 | } | |
| 778 | ||
| 779 | .hl-type { | |
| 780 | color: #a9dc76; | |
| 781 | } | |
| 782 | ||
| 783 | .hl-keyword, | |
| 784 | .hl-storage, | |
| 785 | .hl-modifier, | |
| 786 | .hl-statement { | |
| 787 | color: #ff6188; | |
| 788 | font-weight: 600; | |
| 789 | } | |
| 790 | ||
| 791 | .hl-string, | |
| 792 | .hl-string_interp { | |
| 793 | color: #ffd866; | |
| 794 | } | |
| 795 | ||
| 796 | .hl-number, | |
| 797 | .hl-boolean, | |
| 798 | .hl-constant, | |
| 799 | .hl-preprocessor { | |
| 800 | color: #ab9df2; | |
| 801 | } | |
| 802 | ||
| 803 | .hl-variable { | |
| 804 | color: #fcfcfa; | |
| 805 | } | |
| 806 | ||
| 807 | .hl-attribute, | |
| 808 | .hl-property { | |
| 809 | color: #fc9867; | |
| 810 | } | |
| 811 | ||
| 812 | .hl-operator, | |
| 813 | .hl-punctuation, | |
| 814 | .hl-escape { | |
| 815 | color: #939293; | |
| 816 | } | |
| 817 | ||
| 818 | .hl-interp-punct { | |
| 819 | color: #ff6188; | |
| 820 | } | |
| 821 | ||
| 822 | .hl-math { | |
| 823 | color: #ab9df2; | |
| 824 | font-style: italic; | |
| 825 | } | |
| 826 | ||
| 827 | .hl-code { | |
| 828 | display: inline-block; | |
| 829 | width: 100%; | |
| 830 | background-color: #0d1117; | |
| 831 | color: #c9d1d9; | |
| 832 | padding: 2px 4px; | |
| 833 | border-radius: 3px; | |
| 834 | } | |
| 835 | ||
| 836 | @media (max-width: 768px) { | |
| 837 | .container { | |
| 838 | padding: 10px; | |
| 839 | } | |
| 840 | ||
| 841 | h1 { font-size: 1.5rem; } | |
| 842 | h2 { font-size: 1.2rem; } | |
| 843 | ||
| 844 | .nav { | |
| 845 | flex-direction: column; | |
| 846 | align-items: flex-start; | |
| 847 | gap: 10px; | |
| 848 | } | |
| 849 | ||
| 850 | .repo-selector { | |
| 851 | margin-left: 0; | |
| 852 | width: 100%; | |
| 853 | } | |
| 854 | ||
| 855 | .repo-selector select { | |
| 856 | flex: 1; | |
| 857 | } | |
| 858 | ||
| 859 | .file-list-table th, | |
| 860 | .file-list-table td { | |
| 861 | padding: 8px 10px; | |
| 862 | } | |
| 863 | ||
| 864 | .file-list-table .file-mode-cell, | |
| 865 | .file-list-table .file-date-cell { | |
| 866 | display: none; | |
| 867 | } | |
| 868 | ||
| 869 | .commit-details { | |
| 870 | padding: 15px; | |
| 871 | } | |
| 872 | ||
| 873 | .commit-title { | |
| 874 | font-size: 1.1rem; | |
| 875 | word-break: break-word; | |
| 876 | } | |
| 877 | ||
| 878 | .commit-info-row { | |
| 879 | flex-direction: column; | |
| 880 | gap: 2px; | |
| 881 | margin-bottom: 10px; | |
| 882 | } | |
| 883 | ||
| 884 | .commit-info-label { | |
| 885 | width: 100%; | |
| 886 | font-size: 0.8rem; | |
| 887 | color: #8b949e; | |
| 888 | } | |
| 889 | ||
| 890 | .commit-info-value { | |
| 891 | word-break: break-all; | |
| 892 | font-family: 'SFMono-Regular', Consolas, monospace; | |
| 893 | font-size: 0.9rem; | |
| 894 | padding-left: 0; | |
| 895 | } | |
| 896 | ||
| 897 | .commit-row { | |
| 898 | flex-direction: column; | |
| 899 | gap: 5px; | |
| 900 | } | |
| 901 | ||
| 902 | .commit-row .message { | |
| 903 | width: 100%; | |
| 904 | white-space: normal; | |
| 905 | } | |
| 906 | ||
| 907 | .commit-row .meta { | |
| 908 | font-size: 0.8rem; | |
| 909 | } | |
| 910 | ||
| 911 | .tag-table .tag-author, | |
| 912 | .tag-table .tag-time, | |
| 913 | .tag-table .tag-hash { | |
| 914 | font-size: 0.8rem; | |
| 915 | } | |
| 916 | ||
| 917 | .blob-code, .diff-content { | |
| 918 | overflow-x: scroll; | |
| 919 | -webkit-overflow-scrolling: touch; | |
| 920 | } | |
| 921 | } | |
| 922 | ||
| 923 | @media screen and (orientation: landscape) and (max-height: 600px) { | |
| 924 | .container { | |
| 925 | max-width: 100%; | |
| 926 | } | |
| 927 | ||
| 928 | header { | |
| 929 | margin-bottom: 15px; | |
| 930 | padding-bottom: 10px; | |
| 931 | } | |
| 932 | ||
| 933 | .file-list-table .file-date-cell { | |
| 934 | display: table-cell; | |
| 935 | } | |
| 936 | } | |
| 937 | ||
| 938 | .clone-checkbox { | |
| 939 | display: none; | |
| 940 | } | |
| 941 | ||
| 942 | .clone-link { | |
| 943 | cursor: pointer; | |
| 944 | color: #58a6ff; | |
| 945 | text-decoration: none; | |
| 946 | } | |
| 947 | ||
| 948 | .clone-link:hover { | |
| 949 | text-decoration: underline; | |
| 950 | } | |
| 951 | ||
| 952 | .clone-region { | |
| 953 | display: none; | |
| 954 | margin-top: 10px; | |
| 955 | padding: 10px; | |
| 956 | background-color: #161b22; | |
| 957 | border: 1px solid #30363d; | |
| 958 | border-radius: 6px; | |
| 959 | } | |
| 960 | ||
| 961 | .clone-checkbox:checked ~ .clone-region { | |
| 962 | display: block; | |
| 963 | } | |
| 964 | ||
| 965 | .clone-wrapper { | |
| 966 | display: inline-grid; | |
| 967 | vertical-align: top; | |
| 968 | } | |
| 969 | ||
| 970 | .clone-sizer { | |
| 971 | grid-area: 1 / 1; | |
| 972 | visibility: hidden; | |
| 973 | white-space: pre; | |
| 974 | font-family: monospace; | |
| 975 | font-size: 13px; | |
| 976 | padding: 8px; | |
| 977 | border: 1px solid transparent; | |
| 978 | } | |
| 979 | ||
| 980 | .clone-input { | |
| 981 | grid-area: 1 / 1; | |
| 982 | width: 100%; | |
| 983 | padding: 8px; | |
| 984 | background: #0d1117; | |
| 985 | color: #c9d1d9; | |
| 986 | border: 1px solid #30363d; | |
| 987 | border-radius: 4px; | |
| 988 | font-family: monospace; | |
| 989 | font-size: 13px; | |
| 990 | box-sizing: border-box; | |
| 933 | 991 | } |
| 934 | 992 |