Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M Config.php
11
<?php
22
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';
136
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
  }
1712
18
      if( !empty( $userInfo['dir'] ) ) {
19
        return $userInfo['dir'];
20
      }
21
    }
13
  public function createRouter() {
14
    $path = $this->getHomeDirectory() . self::REPOS_SUBDIR;
2215
23
    return '';
16
    return new Router( $path );
2417
  }
2518
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;
2924
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;
3431
  }
3532
}
33
3634
M File.php
33
44
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';
99
  private const CAT_ARCHIVE = 'archive';
10
  private const CAT_BINARY = 'binary';
10
  private const CAT_BINARY  = 'binary';
1111
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 = [
1338
    'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab',
1439
    'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj',
...
2247
  private int $size;
2348
  private bool $isDir;
24
2549
  private string $mediaType;
2650
  private string $category;
...
3559
    string $contents = ''
3660
  ) {
37
    $this->name = $name;
38
    $this->sha = $sha;
39
    $this->mode = $mode;
61
    $this->name      = $name;
62
    $this->sha       = $sha;
63
    $this->mode      = $mode;
4064
    $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;
4568
69
    $buffer          = $this->isDir ? '' : $contents;
4670
    $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();
4973
  }
5074
...
6488
      $this->size
6589
    );
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 ) ) . "\"" );
6697
  }
6798
...
74105
  }
75106
76
  public function highlight( FileRenderer $renderer, string $content ): string {
107
  public function highlight(
108
    FileRenderer $renderer,
109
    string $content
110
  ): string {
77111
    return $renderer->highlight( $this->name, $content, $this->mediaType );
112
  }
113
114
  public function isDir(): bool {
115
    return $this->isDir;
78116
  }
79117
...
100138
  public function isName( string $name ): bool {
101139
    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 ) ) . "\"" );
109140
  }
110141
111142
  private function resolveIcon(): string {
112143
    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
116147
        : 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,
123154
        });
124155
  }
125156
126157
  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);
133163
  }
134164
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;
137171
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,
146184
    };
147185
  }
148186
149187
  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 );
152191
  }
153192
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 {
155199
    return in_array(
156200
      strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ),
157
      self::ARCHIVE_EXTENSIONS,
201
      self::ARCHIVE_EXT,
158202
      true
159203
    );
M RepositoryList.php
11
<?php
22
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;
49
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 ) {
619
    $this->reposPath = $path;
720
  }
821
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 {
1031
    $repos = [];
11
    $dirs = glob( $this->reposPath . '/*', GLOB_ONLYDIR );
32
    $path  = $this->reposPath . self::GLOB_PATTERN;
33
    $dirs  = glob( $path, GLOB_ONLYDIR );
1234
13
    if( $dirs === false ) {
14
      return;
35
    if( $dirs !== false ) {
36
      $repos = $this->processDirectories( $dirs );
1537
    }
38
39
    return $repos;
40
  }
41
42
  private function processDirectories( array $dirs ): array {
43
    $repos = [];
1644
1745
    foreach( $dirs as $dir ) {
18
      $basename = basename( $dir );
46
      $data = $this->createRepositoryData( $dir );
1947
20
      if( $basename[0] === '.' ) {
21
        continue;
48
      if( $data !== [] ) {
49
        $repos[$data[self::KEY_NAME]] = $data;
2250
      }
51
    }
2352
24
      $name = $basename;
53
    return $repos;
54
  }
2555
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 );
2959
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,
3466
      ];
3567
    }
3668
37
    $this->sortRepositories( $repos );
69
    return $data;
70
  }
3871
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 );
4178
    }
79
80
    return $name;
4281
  }
4382
44
  private function sortRepositories( array &$repos ) {
45
    $orderFile = __DIR__ . '/order.txt';
83
  private function sortRepositories( array $repos ): array {
84
    $file = __DIR__ . self::ORDER_FILE;
4685
47
    if( !file_exists( $orderFile ) ) {
86
    if( file_exists( $file ) ) {
87
      $repos = $this->applyCustomOrder( $repos, $file );
88
    } else {
4889
      ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE );
49
      return;
5090
    }
5191
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   = [];
54109
    $exclude = [];
55110
56111
    foreach( $lines as $line ) {
57
      $line = trim( $line );
58
59
      if( $line === '' ) {
60
        continue;
61
      }
112
      $trim = trim( $line );
62113
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
        }
67120
      }
68121
    }
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
  }
69132
133
  private function filterExcluded( array $repos, array $exclude ): array {
70134
    foreach( $repos as $key => $repo ) {
71
      if( isset( $exclude[$repo['safe_name']] ) ) {
135
      if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) {
72136
        unset( $repos[$key] );
73137
      }
74138
    }
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;
81139
82
      if( $posA === $posB ) {
83
        return strcasecmp( $nameA, $nameB );
84
      }
140
    return $repos;
141
  }
85142
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 );
87146
    } );
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;
88166
  }
89167
}
M Router.php
1212
1313
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   = '';
1640
1741
  public function __construct( string $reposPath ) {
1842
    $this->git = new Git( $reposPath );
19
    $list = new RepositoryList( $reposPath );
43
    $list      = new RepositoryList( $reposPath );
44
2045
    $list->eachRepository( function( $repo ) {
2146
      $this->repos[$repo['safe_name']] = $repo;
22
    } );
47
    });
2348
  }
2449
2550
  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 );
3255
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
      }
3562
    }
3663
37
    $uri = trim( $uri, '/' );
38
    $parts = explode( '/', $uri );
64
    return $page;
65
  }
3966
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'];
4278
    }
4379
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" );
4683
    }
4784
48
    $repoName = array_shift( $parts );
85
    $this->git->setRepository( $path );
4986
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
  }
5489
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;
6096
61
      $this->git->setRepository( $repoPath );
97
    $this->git->setRepository( $this->repoData['path'] );
6298
63
      return new ClonePage( $this->git, implode( '/', $parts ) );
64
    }
99
    $act = array_shift( $uriParts );
100
    $this->action = $act ?: self::ACTION_TREE;
65101
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   = '';
69105
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
    ];
76110
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 ) ?? '';
85120
    }
86121
87
    $_GET['repo'] = $repoName;
88
    $_GET['action'] = $action;
89
    $_GET['hash'] = $hash;
90
    $_GET['name'] = $path;
122
    $this->populateGet();
91123
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
95133
      ),
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
102136
      ),
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
      )
104153
    };
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;
105185
  }
106186
}
M Tag.php
1818
    string $author
1919
  ) {
20
    $this->name = $name;
21
    $this->sha = $sha;
20
    $this->name      = $name;
21
    $this->sha       = $sha;
2222
    $this->targetSha = $targetSha;
2323
    $this->timestamp = $timestamp;
24
    $this->message = $message;
25
    $this->author = $author;
24
    $this->message   = $message;
25
    $this->author    = $author;
2626
  }
2727
...
4242
  }
4343
}
44
4544
M UrlBuilder.php
11
<?php
22
class UrlBuilder {
3
  private const REPO_PREFIX = '/repo/';
4
  private const HEAD_REF    = '/HEAD';
5
  private const ACT_TREE    = 'tree';
6
37
  private $repo;
48
  private $action;
59
  private $hash;
610
  private $name;
711
  private $switcher;
812
913
  public function withRepo( $repo ) {
1014
    $this->repo = $repo;
15
1116
    return $this;
1217
  }
1318
1419
  public function withAction( $action ) {
1520
    $this->action = $action;
21
1622
    return $this;
1723
  }
1824
1925
  public function withHash( $hash ) {
2026
    $this->hash = $hash;
27
2128
    return $this;
2229
  }
2330
2431
  public function withName( $name ) {
2532
    $this->name = $name;
33
2634
    return $this;
2735
  }
2836
2937
  public function withSwitcher( $jsValue ) {
3038
    $this->switcher = $jsValue;
39
3140
    return $this;
3241
  }
3342
3443
  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
  }
4848
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;
5152
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 );
5755
    }
5856
5957
    if( $this->name ) {
6058
      $url .= '/' . ltrim( $this->name, '/' );
6159
    }
6260
6361
    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
        : '');
6470
  }
6571
}
72
6673
M git/Git.php
66
77
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;
662686
  }
663687
}
M git/GitDiff.php
44
class GitDiff {
55
  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 );
355366
  }
356367
}
M git/GitPacks.php
11
<?php
22
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 ];
710694
  }
711695
M git/GitRefs.php
88
99
  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 = '';
1511
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";
1816
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
      }
2225
    }
2326
24
    return $this->resolveRef( $input );
27
    return $result;
2528
  }
2629
...
3437
      }
3538
    );
39
40
    $found = [];
3641
3742
    foreach( ['main', 'master', 'trunk', 'develop'] as $try ) {
3843
      if( isset( $branches[$try] ) ) {
39
        return ['name' => $try, 'hash' => $branches[$try]];
44
        $found = [ 'name' => $try, 'hash' => $branches[$try] ];
45
        break;
4046
      }
4147
    }
4248
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
    }
4455
45
    return $firstKey
46
      ? ['name' => $firstKey, 'hash' => $branches[$firstKey]]
47
      : ['name' => '', 'hash' => ''];
56
    return $found;
4857
  }
4958
5059
  public function scanRefs( string $prefix, callable $callback ): void {
5160
    $dir = "{$this->repoPath}/$prefix";
5261
5362
    if( is_dir( $dir ) ) {
5463
      $files = array_diff( scandir( $dir ), ['.', '..'] );
55
5664
      foreach( $files as $file ) {
5765
        $callback( $file, trim( file_get_contents( "$dir/$file" ) ) );
5866
      }
5967
    }
6068
  }
6169
6270
  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 = '';
6473
6574
    foreach( $paths as $ref ) {
6675
      $path = "{$this->repoPath}/$ref";
67
6876
      if( file_exists( $path ) ) {
69
        return trim( file_get_contents( $path ) );
77
        $result = trim( file_get_contents( $path ) );
78
        break;
7079
      }
7180
    }
7281
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
    }
7488
75
    return file_exists( $packedPath )
76
      ? $this->findInPackedRefs( $packedPath, $input )
77
      : '';
89
    return $result;
7890
  }
7991
8092
  private function findInPackedRefs( string $path, string $input ): string {
8193
    $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  = '';
8996
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
        }
92104
      }
93105
    }
94106
95
    return '';
107
    return $result;
96108
  }
97109
}
A images/diff-gap.svg
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>
18
M index.php
33
require_once __DIR__ . '/Router.php';
44
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();
98
109
$page->render();
M pages/BasePage.php
55
66
abstract class BasePage implements Page {
7
  protected $repositories;
8
  protected $title;
7
  private $repositories;
8
  private $title;
99
10
  public function __construct( array $repositories ) {
10
  public function __construct( array $repositories, string $title = '' ) {
1111
    $this->repositories = $repositories;
12
    $this->title        = $title;
1213
  }
1314
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
1524
    ?>
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>
4139
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 } ?>
5743
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>
5960
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>
63106
    <?php
64107
  }
65108
66
  protected function renderBreadcrumbs( $repo, $trail = [] ) {
109
  protected function renderBreadcrumbs( array $repo, array $trail = [] ) {
67110
    $repoUrl = (new UrlBuilder())->withRepo( $repo['safe_name'] )->build();
68111
M pages/ClonePage.php
77
88
  public function __construct( Git $git, string $subPath ) {
9
    $this->git = $git;
9
    $this->git     = $git;
1010
    $this->subPath = $subPath;
1111
  }
...
9090
    header( 'Cache-Control: no-cache' );
9191
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
    }
96106
97107
    while( $offset < strlen( $input ) ) {
98
      $line = $this->readPacketLine( $input, $offset );
108
      $result = $this->readPacketLine( $input, $offset );
109
      $line   = $result[0];
110
      $next   = $result[1];
99111
100
      if( $line === null || $line === 'done' ) {
112
      if( $next === $offset || $line === 'done' ) {
101113
        break;
102114
      }
115
116
      $offset = $next;
103117
104118
      if( $line === '' ) {
...
129143
  private function sendSidebandData( int $band, string $data ): void {
130144
    $chunkSize = 65000;
131
    $len = strlen( $data );
145
    $len       = strlen( $data );
132146
133147
    for( $offset = 0; $offset < $len; $offset += $chunkSize ) {
134148
      $chunk = substr( $data, $offset, $chunkSize );
135149
136150
      $this->packetWrite( chr( $band ) . $chunk );
137151
    }
138152
  }
139153
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;
142157
143158
    if( $offset + 4 <= strlen( $input ) ) {
144159
      $lenHex = substr( $input, $offset, 4 );
145160
146161
      if( ctype_xdigit( $lenHex ) ) {
147162
        $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);
154163
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
        }
156172
      }
157173
    }
158174
159
    return $line;
175
    return [$line, $next];
160176
  }
161177
M pages/CommitsPage.php
1414
    string $hash
1515
  ) {
16
    parent::__construct( $repositories );
16
    parent::__construct( $repositories, $currentRepo['name'] );
17
1718
    $this->currentRepo = $currentRepo;
18
    $this->git = $git;
19
    $this->hash = $hash;
20
    $this->title = $currentRepo['name'];
19
    $this->git         = $git;
20
    $this->hash        = $hash;
2121
  }
2222
2323
  public function render() {
2424
    $this->renderLayout( function() {
2525
      $main = $this->git->getMainBranch();
2626
2727
      if( !$main ) {
2828
        echo '<div class="empty-state"><h3>No branches</h3>' .
2929
             '<p>Empty repository.</p></div>';
30
        return;
31
      }
30
      } else {
31
        $this->renderBreadcrumbs( $this->currentRepo, ['Commits'] );
3232
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">';
3436
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'];
3838
39
      $start = $this->hash ?: $main['hash'];
39
        $this->git->history( $start, 100, function( $commit ) {
40
          $msg = htmlspecialchars( explode( "\n", $commit->message )[0] );
4041
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();
4347
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
               ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
55
          echo '</div>';
56
        } );
4957
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
             ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
5558
        echo '</div>';
56
      } );
57
58
      echo '</div>';
59
      }
5960
    }, $this->currentRepo );
6061
  }
M pages/ComparePage.php
1616
    string $oldSha
1717
  ) {
18
    parent::__construct( $repositories );
18
    $title = $currentRepo['name'] . ' - Compare';
19
    parent::__construct( $repositories, $title );
20
1921
    $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;
2425
  }
2526
...
3435
      );
3536
36
      $differ = new GitDiff( $this->git );
37
      $differ  = new GitDiff( $this->git );
3738
      $changes = $differ->diff( $this->oldSha, $this->newSha );
3839
3940
      if( empty( $changes ) ) {
4041
        echo '<div class="empty-state"><h3>No changes</h3>' .
4142
             '<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
        }
4747
      }
4848
    }, $this->currentRepo );
...
5757
5858
    $statusClass = $typeMap[$change['type']] ?? 'modified';
59
    $path = htmlspecialchars( $change['path'] );
59
    $path        = htmlspecialchars( $change['path'] );
6060
6161
    echo '<div class="diff-file">';
...
8585
    if( isset( $line['t'] ) && $line['t'] === 'gap' ) {
8686
      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
      };
9593
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
    }
102101
  }
103102
}
M pages/DiffPage.php
1515
    string $hash
1616
  ) {
17
    parent::__construct( $repositories );
17
    parent::__construct( $repositories, substr( $hash, 0, 7 ) );
18
1819
    $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;
2222
  }
2323
2424
  public function render() {
2525
    $this->renderLayout( function() {
2626
      $commitData = $this->git->read( $this->hash );
2727
      $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    = [];
3232
3333
      foreach( $lines as $line ) {
...
5656
        '<a href="' . $commitsUrl . '">Commits</a>',
5757
        substr( $this->hash, 0, 7 )
58
      ]);
58
      ] );
5959
6060
      $author = $headers['author'] ?? 'Unknown';
6161
      $author = preg_replace( '/<[^>]+>/', '<email>', $author );
6262
6363
      echo '<div class="commit-details">';
6464
      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>';
6667
      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>';
7176
7277
      if( isset( $headers['parent'] ) ) {
7378
        $url = (new UrlBuilder())
7479
          ->withRepo( $this->currentRepo['safe_name'] )
7580
          ->withAction( 'commit' )
7681
          ->withHash( $headers['parent'] )
7782
          ->build();
7883
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>' .
8086
             '<span class="commit-info-value">';
8187
        echo '<a href="' . $url . '" class="parent-link">' .
...
101107
102108
  private function renderFileDiff( $change ) {
103
    $statusIcon = 'fa-file';
109
    $statusIcon  = 'fa-file';
104110
    $statusClass = '';
105111
106112
    if( $change['type'] === 'A' ) {
107
      $statusIcon = 'fa-plus-circle';
113
      $statusIcon  = 'fa-plus-circle';
108114
      $statusClass = 'status-add';
109115
    }
110116
111117
    if( $change['type'] === 'D' ) {
112
      $statusIcon = 'fa-minus-circle';
118
      $statusIcon  = 'fa-minus-circle';
113119
      $statusClass = 'status-del';
114120
    }
115121
116122
    if( $change['type'] === 'M' ) {
117
      $statusIcon = 'fa-pencil-alt';
123
      $statusIcon  = 'fa-pencil-alt';
118124
      $statusClass = 'status-mod';
119125
    }
120126
121127
    echo '<div class="diff-file">';
122128
    echo '<div class="diff-header">';
123129
    echo '<span class="diff-status ' . $statusClass . '">' .
124130
         '<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>';
126133
    echo '</div>';
127134
...
134141
      foreach( $change['hunks'] as $line ) {
135142
        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>';
137146
          continue;
138147
        }
139148
140149
        $class = 'diff-ctx';
141
        $char = ' ';
150
        $char  = ' ';
142151
143152
        if( $line['t'] === '+' ) {
144153
          $class = 'diff-add';
145
          $char = '+';
154
          $char  = '+';
146155
        }
147156
148157
        if( $line['t'] === '-' ) {
149158
          $class = 'diff-del';
150
          $char = '-';
159
          $char  = '-';
151160
        }
152161
153162
        echo '<tr class="' . $class . '">';
154163
        echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
155164
        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>';
158167
        echo '</tr>';
159168
      }
M pages/FilePage.php
66
class FilePage extends BasePage {
77
  private const MAX_HIGHLIGHT_SIZE = 65536;
8
  private const MAX_DISPLAY_SIZE = 524288;
8
  private const MAX_DISPLAY_SIZE   = 524288;
99
1010
  private $currentRepo;
...
2020
    string $path = ''
2121
  ) {
22
    parent::__construct( $repositories );
22
    parent::__construct( $repositories, $currentRepo['name'] );
23
2324
    $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;
2828
  }
2929
...
5151
  }
5252
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;
5757
  }
5858
...
6969
7070
    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>';
7274
    echo '<tbody>';
7375
...
8789
  private function renderBlob( $targetHash ) {
8890
    $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 );
9193
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
    );
9399
94100
    $this->emitBreadcrumbs( $targetHash, 'File', $filename );
95101
96102
    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();
100111
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
            );
107131
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
          }
115138
        } 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
          );
126143
        }
127
      } else {
128
        $this->renderDownloadState( $targetHash, "This is a binary file." );
129144
      }
130145
    }
...
150165
    if( $path ) {
151166
      $parts = explode( '/', trim( $path, '/' ) );
152
      $acc = '';
167
      $acc   = '';
153168
154169
      foreach( $parts as $idx => $part ) {
...
165180
            ->build();
166181
167
          $trail[] = '<a href="' . $url . '">' . htmlspecialchars( $part ) . '</a>';
182
          $trail[] = '<a href="' . $url . '">' .
183
                     htmlspecialchars( $part ) . '</a>';
168184
        }
169185
      }
M pages/HomePage.php
44
55
class HomePage extends BasePage {
6
  private $repositories;
67
  private $git;
78
89
  public function __construct( array $repositories, Git $git ) {
910
    parent::__construct( $repositories );
10
    $this->git = $git;
11
    $this->repositories = $repositories;
12
    $this->git          = $git;
1113
  }
1214
1315
  public function render() {
1416
    $this->renderLayout( function() {
1517
      echo '<h2>Repositories</h2>';
1618
1719
      if( empty( $this->repositories ) ) {
1820
        echo '<div class="empty-state">No repositories found.</div>';
19
        return;
20
      }
21
      } else {
22
        echo '<div class="repo-grid">';
2123
22
      echo '<div class="repo-grid">';
24
        foreach( $this->repositories as $repo ) {
25
          $this->renderRepoCard( $repo );
26
        }
2327
24
      foreach( $this->repositories as $repo ) {
25
        $this->renderRepoCard( $repo );
28
        echo '</div>';
2629
      }
27
28
      echo '</div>';
2930
    } );
3031
  }
3132
3233
  private function renderRepoCard( $repo ) {
3334
    $this->git->setRepository( $repo['path'] );
3435
35
    $main = $this->git->getMainBranch();
36
    $main  = $this->git->getMainBranch();
3637
    $stats = [
3738
      'branches' => 0,
38
      'tags' => 0
39
      'tags'     => 0
3940
    ];
4041
...
5354
5455
    $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches';
55
    $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags';
56
    $tagLabel    = $stats['tags'] === 1 ? 'tag' : 'tags';
5657
5758
    echo $stats['branches'] . ' ' . $branchLabel . ', ' .
M pages/Page.php
11
<?php
22
interface Page {
3
    public function render();
3
  public function render();
44
}
55
M pages/TagsPage.php
1212
    Git $git
1313
  ) {
14
    parent::__construct( $repositories );
14
    parent::__construct(
15
      $repositories,
16
      $currentRepo['name'] . ' - Tags'
17
    );
18
1519
    $this->currentRepo = $currentRepo;
16
    $this->git = $git;
17
    $this->title = $currentRepo['name'] . ' - Tags';
20
    $this->git         = $git;
1821
  }
1922
...
3538
        echo '<p>No tags found.</p>';
3639
      } else {
37
        $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] );
40
        $renderer = new HtmlTagRenderer(
41
          $this->currentRepo['safe_name']
42
        );
3843
3944
        echo '<table class="tag-table">';
...
4954
        $count = count( $tags );
5055
        for( $i = 0; $i < $count; $i++ ) {
51
          $tag = $tags[$i];
56
          $tag     = $tags[$i];
5257
          $prevTag = $tags[$i + 1] ?? null;
5358
          $tag->render( $renderer, $prevTag );
M render/Highlighter.php
1414
    $this->content  = $content;
1515
    $this->language = $this->detectLanguage( $mediaType, $filename );
16
    $this->rules    = LanguageDefinitions::get( $this->language ) ?? [];
16
    $this->rules    = LanguageDefinitions::get( $this->language );
1717
  }
1818
1919
  public function render(): string {
2020
    $result = htmlspecialchars( $this->content );
2121
2222
    if( !empty( $this->rules ) ) {
2323
      $patterns = [];
2424
2525
      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 );
3430
        $patterns[] = "(?P<{$name}>{$inner})";
3531
      }
3632
3733
      if( !in_array( $this->language, ['markdown', 'rmd'] ) ) {
38
        $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,])";
34
        $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,\\:])";
3935
      }
4036
4137
      $patterns[] = "(?P<any>[\s\S])";
42
      $combined   = '~' . implode( '|', $patterns ) . '~msu';
38
      $imploded   = implode( '|', $patterns );
39
      $combined   = '~' . $imploded . '~msu';
4340
4441
      $processed = preg_replace_callback( $combined, function( $matches ) {
...
6461
      }, $this->content );
6562
66
      if( $processed !== null ) {
63
      if( is_string( $processed ) ) {
6764
        $result = $processed;
6865
      }
...
7673
7774
    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, '}' );
7980
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
    } );
9494
  }
9595
9696
  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
    } );
103108
  }
104109
...
125130
    bool $escape = true
126131
  ): string {
127
    $safeContent = $escape ? htmlspecialchars( $content ) : $content;
132
    $safeContent = $content;
133
134
    if( $escape ) {
135
      $safeContent = htmlspecialchars( $content );
136
    }
137
128138
    return '<span class="' . $className . '">' . $safeContent . '</span>';
129139
  }
130140
131141
  private function detectLanguage(
132142
    string $mediaType,
133143
    string $filename
134144
  ): string {
135145
    $basename  = basename( $filename );
136146
    $extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
137
    $language  = null;
138
139
    $language = match( $basename ) {
147
    $language  = match( $basename ) {
140148
      'Containerfile',
141
      'Dockerfile'     => 'containerfile',
142
      'Makefile'       => 'makefile',
143
      'Jenkinsfile'    => 'groovy',
144
      default          => null
149
      'Dockerfile'  => 'containerfile',
150
      'Makefile'    => 'makefile',
151
      'Jenkinsfile' => 'groovy',
152
      default       => ''
145153
    };
146154
147
    if( $language === null ) {
155
    if( $language === '' ) {
148156
      $language = match( $extension ) {
149157
        'php', 'phtml', 'php8', 'php7' => 'php',
...
172180
        'r'                            => 'r',
173181
        'xml', 'svg'                   => 'xml',
182
        'xsl', 'xslt'                  => 'xslt',
174183
        'html', 'htm'                  => 'html',
175184
        'css'                          => 'css',
...
184193
        'mk', 'mak'                    => 'makefile',
185194
        'diff', 'patch'                => 'diff',
186
        default                        => null
195
        'for', 'f', 'f90', 'f95'       => 'fortran',
196
        default                        => ''
187197
      };
188198
    }
189199
190
    if( $language === null ) {
200
    if( $language === '' ) {
191201
       $language = match( $mediaType ) {
192202
        'text/x-php', 'application/x-php',
...
201211
        'application/xml', 'text/xml',
202212
        'image/svg+xml'                     => 'xml',
213
        'application/xslt+xml'              => 'xslt',
203214
        'text/x-shellscript',
204215
        'application/x-sh'                  => 'bash',
...
237248
        'application/toml', 'text/toml'     => 'toml',
238249
        'text/x-diff', 'text/x-patch'       => 'diff',
250
        'text/x-fortran'                    => 'fortran',
239251
        default                             => 'text'
240252
      };
M render/LanguageDefinitions.php
22
class LanguageDefinitions {
33
  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;
323574
  }
324575
}
M styles/repo.css
231231
232232
.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;
933991
}
934992