master
ブランチ master
LittleBeast / src / Std / Lib / Gitler / Languages.php
26.2 KB、452行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Gitler;
39
40use Std\Lib\Git\Diff;
41use Std\Lib\Git\GitException;
42use Std\Lib\Git\Repository;
43use Std\Lib\Git\Tree;
44
45/**
46 * リポジトリの言語の割合(GitHubのLinguistの様に)
47 *
48 * ファイルの名前・拡張子・#!から言語を決め、言語毎のバイト数を数える。数えるのはプログラミングとマークアップの言語だけで、
49 * データ(JSON等)と文章(Markdown等)、外部のコード(vendor/等)、ドキュメント(docs/等)、生成されたファイル、リンクとサブモジュールは数えない。
50 * ルートの.gitattributesのlinguist-language、linguist-vendored、linguist-generated、linguist-documentation、linguist-detectableで変えられる。
51 * 結果はリポジトリの中のgitler-languages.jsonにツリー毎に保存する。
52 */
53final class Languages {
54 /** 保存する形式(言語の表を変えたら上げる) */
55 private const VERSION = 1;
56 /** 保存するツリーの数 */
57 private const KEEP = 20;
58 /** これよりファイルが多いリポジトリは数えない */
59 private const MAX_FILES = 100000;
60 /** 中身で言語を決めるファイルの最大の大きさ */
61 private const MAX_PEEK = 524288;
62 /** 表示する言語の数(それより多ければ残りは「その他」) */
63 private const SHOW = 8;
64
65 private const PROGRAMMING = 'p';
66 private const MARKUP = 'm';
67 private const DATA = 'd';
68 private const PROSE = 't';
69
70 /** 名前 => [種類, 色](色はLinguistと同じ、MaronとSlangは独自) */
71 private const LANGS = [
72 'Ada' => ['p', '#02f88c'], 'Assembly' => ['p', '#6E4C13'], 'AWK' => ['p', '#c30e9b'], 'Batchfile' => ['p', '#C1F12E'],
73 'C' => ['p', '#555555'], 'C#' => ['p', '#178600'], 'C++' => ['p', '#f34b7d'], 'Clojure' => ['p', '#db5855'],
74 'CMake' => ['p', '#DA3434'], 'Common Lisp' => ['p', '#3fb68b'], 'Coq' => ['p', '#d0b68c'], 'Crystal' => ['p', '#000100'],
75 'CSS' => ['m', '#663399'], 'CUDA' => ['p', '#3A4E3A'], 'Cython' => ['p', '#fedf5b'], 'D' => ['p', '#ba595e'],
76 'Dart' => ['p', '#00B4AB'], 'Dockerfile' => ['p', '#384d54'], 'Elixir' => ['p', '#6e4a7e'], 'Elm' => ['p', '#60B5CC'],
77 'Emacs Lisp' => ['p', '#c065db'], 'Erlang' => ['p', '#B83998'], 'F#' => ['p', '#b845fc'], 'Forth' => ['p', '#341708'],
78 'Fortran' => ['p', '#4d41b1'], 'GDScript' => ['p', '#355570'], 'Gleam' => ['p', '#ffaff3'], 'GLSL' => ['p', '#5686a5'],
79 'Go' => ['p', '#00ADD8'], 'Groovy' => ['p', '#4298b8'], 'Handlebars' => ['m', '#f7931e'], 'Hare' => ['p', '#9d7424'],
80 'Haskell' => ['p', '#5e5086'], 'Haxe' => ['p', '#df7900'], 'HCL' => ['p', '#844FBA'], 'HLSL' => ['p', '#aace60'],
81 'HTML' => ['m', '#e34c26'], 'Java' => ['p', '#b07219'], 'JavaScript' => ['p', '#f1e05a'], 'Julia' => ['p', '#a270ba'],
82 'Jupyter Notebook' => ['m', '#DA5B0B'], 'Just' => ['p', '#384d54'], 'Kotlin' => ['p', '#A97BFF'], 'Less' => ['m', '#1d365d'],
83 'Lex' => ['p', '#DBCA00'], 'Lua' => ['p', '#000080'], 'Makefile' => ['p', '#427819'], 'Maron' => ['m', '#ea79d8'],
84 'MATLAB' => ['p', '#e16737'], 'Meson' => ['p', '#007800'], 'Metal' => ['p', '#8f14e9'], 'Nim' => ['p', '#ffc200'],
85 'Nix' => ['p', '#7e7eff'], 'Objective-C' => ['p', '#438eff'], 'Objective-C++' => ['p', '#6866fb'], 'OCaml' => ['p', '#ef7a08'],
86 'Odin' => ['p', '#60AFFE'], 'Pascal' => ['p', '#E3F171'], 'Perl' => ['p', '#0298c3'], 'PHP' => ['p', '#4F5D95'],
87 'PowerShell' => ['p', '#012456'], 'Python' => ['p', '#3572A5'], 'QML' => ['p', '#44a51c'], 'R' => ['p', '#198CE7'],
88 'Racket' => ['p', '#3c5caa'], 'Raku' => ['p', '#0000fb'], 'Roff' => ['m', '#ecdebe'], 'Ruby' => ['p', '#701516'],
89 'Rust' => ['p', '#dea584'], 'Sass' => ['m', '#a53b70'], 'Scala' => ['p', '#c22d40'], 'Scheme' => ['p', '#1e4aec'],
90 'SCSS' => ['m', '#c6538c'], 'Shell' => ['p', '#89e051'], 'Slang' => ['p', '#1fbec9'], 'Solidity' => ['p', '#AA6746'],
91 'Starlark' => ['p', '#76d275'], 'Svelte' => ['m', '#ff3e00'], 'Swift' => ['p', '#F05138'], 'SystemVerilog' => ['p', '#DAE1C2'],
92 'Tcl' => ['p', '#e4cc98'], 'TeX' => ['m', '#3D6117'], 'TSX' => ['p', '#3178c6'], 'Twig' => ['m', '#c1d026'],
93 'TypeScript' => ['p', '#3178c6'], 'V' => ['p', '#4f87c4'], 'Verilog' => ['p', '#b2b7f8'], 'VHDL' => ['p', '#adb2cb'],
94 'Vim Script' => ['p', '#199f4b'], 'Visual Basic .NET' => ['p', '#945db7'], 'Vue' => ['m', '#41b883'], 'WGSL' => ['p', '#1a5e9a'],
95 'Yacc' => ['p', '#4B6C4B'], 'Zig' => ['p', '#ec915c'],
96 // 数えない(linguist-detectableで数えられる)
97 'JSON' => ['d', '#292929'], 'Markdown' => ['t', '#083fa1'], 'SQL' => ['d', '#e38c00'], 'TOML' => ['d', '#9c4221'],
98 'XML' => ['d', '#0060ac'], 'YAML' => ['d', '#cb171e'],
99 ];
100
101 /** 拡張子(小文字) => 言語 */
102 private const EXTENSIONS = [
103 'adb' => 'Ada', 'ads' => 'Ada', 'asm' => 'Assembly', 'nasm' => 'Assembly', 's' => 'Assembly', 'awk' => 'AWK',
104 'bat' => 'Batchfile', 'cmd' => 'Batchfile', 'c' => 'C', 'cs' => 'C#', 'csx' => 'C#',
105 'cpp' => 'C++', 'cc' => 'C++', 'cxx' => 'C++', 'c++' => 'C++', 'hpp' => 'C++', 'hh' => 'C++', 'hxx' => 'C++', 'h++' => 'C++',
106 'ipp' => 'C++', 'tpp' => 'C++', 'inl' => 'C++', 'ixx' => 'C++', 'cppm' => 'C++',
107 'clj' => 'Clojure', 'cljs' => 'Clojure', 'cljc' => 'Clojure', 'cmake' => 'CMake', 'lisp' => 'Common Lisp', 'lsp' => 'Common Lisp',
108 'cr' => 'Crystal', 'css' => 'CSS', 'cu' => 'CUDA', 'cuh' => 'CUDA', 'pyx' => 'Cython', 'pxd' => 'Cython', 'dart' => 'Dart',
109 'dockerfile' => 'Dockerfile', 'ex' => 'Elixir', 'exs' => 'Elixir', 'elm' => 'Elm', 'el' => 'Emacs Lisp', 'erl' => 'Erlang', 'hrl' => 'Erlang',
110 'fsi' => 'F#', 'fsx' => 'F#', 'fth' => 'Forth', '4th' => 'Forth', 'forth' => 'Forth',
111 'f' => 'Fortran', 'for' => 'Fortran', 'f77' => 'Fortran', 'f90' => 'Fortran', 'f95' => 'Fortran', 'f03' => 'Fortran', 'f08' => 'Fortran',
112 'gd' => 'GDScript', 'gleam' => 'Gleam', 'glsl' => 'GLSL', 'vert' => 'GLSL', 'frag' => 'GLSL', 'geom' => 'GLSL', 'comp' => 'GLSL',
113 'tesc' => 'GLSL', 'tese' => 'GLSL', 'rgen' => 'GLSL', 'rchit' => 'GLSL', 'rmiss' => 'GLSL', 'rahit' => 'GLSL', 'mesh' => 'GLSL', 'task' => 'GLSL',
114 'go' => 'Go', 'groovy' => 'Groovy', 'gradle' => 'Groovy', 'hbs' => 'Handlebars', 'handlebars' => 'Handlebars', 'ha' => 'Hare',
115 'hs' => 'Haskell', 'lhs' => 'Haskell', 'hx' => 'Haxe', 'tf' => 'HCL', 'hcl' => 'HCL', 'hlsl' => 'HLSL', 'hlsli' => 'HLSL', 'fx' => 'HLSL', 'fxh' => 'HLSL',
116 'html' => 'HTML', 'htm' => 'HTML', 'xhtml' => 'HTML', 'java' => 'Java', 'js' => 'JavaScript', 'mjs' => 'JavaScript', 'cjs' => 'JavaScript',
117 'jsx' => 'JavaScript', 'jl' => 'Julia', 'ipynb' => 'Jupyter Notebook', 'just' => 'Just', 'kt' => 'Kotlin', 'kts' => 'Kotlin', 'less' => 'Less',
118 'l' => 'Lex', 'lex' => 'Lex', 'lua' => 'Lua', 'mk' => 'Makefile', 'mak' => 'Makefile', 'maron' => 'Maron', 'metal' => 'Metal',
119 'nim' => 'Nim', 'nims' => 'Nim', 'nimble' => 'Nim', 'nix' => 'Nix', 'mm' => 'Objective-C++', 'ml' => 'OCaml', 'mli' => 'OCaml',
120 'odin' => 'Odin', 'pas' => 'Pascal', 'pp' => 'Pascal', 'dpr' => 'Pascal', 'lpr' => 'Pascal', 'pl' => 'Perl', 'pm' => 'Perl',
121 'php' => 'PHP', 'phtml' => 'PHP', 'php3' => 'PHP', 'php4' => 'PHP', 'php5' => 'PHP', 'phps' => 'PHP',
122 'ps1' => 'PowerShell', 'psm1' => 'PowerShell', 'psd1' => 'PowerShell', 'py' => 'Python', 'pyw' => 'Python', 'pyi' => 'Python',
123 'qml' => 'QML', 'r' => 'R', 'rkt' => 'Racket', 'raku' => 'Raku', 'rakumod' => 'Raku', 'p6' => 'Raku',
124 'roff' => 'Roff', 'man' => 'Roff', 'mdoc' => 'Roff', '1' => 'Roff', '2' => 'Roff', '3' => 'Roff', '4' => 'Roff', '5' => 'Roff',
125 '6' => 'Roff', '7' => 'Roff', '8' => 'Roff', '9' => 'Roff',
126 'rb' => 'Ruby', 'rake' => 'Ruby', 'gemspec' => 'Ruby', 'rs' => 'Rust', 'sass' => 'Sass', 'scala' => 'Scala', 'scm' => 'Scheme', 'ss' => 'Scheme',
127 'scss' => 'SCSS', 'sh' => 'Shell', 'bash' => 'Shell', 'zsh' => 'Shell', 'ksh' => 'Shell', 'slang' => 'Slang', 'sol' => 'Solidity',
128 'bzl' => 'Starlark', 'star' => 'Starlark', 'svelte' => 'Svelte', 'swift' => 'Swift', 'sv' => 'SystemVerilog', 'svh' => 'SystemVerilog',
129 'tcl' => 'Tcl', 'tex' => 'TeX', 'sty' => 'TeX', 'ltx' => 'TeX', 'tsx' => 'TSX', 'twig' => 'Twig', 'ts' => 'TypeScript', 'mts' => 'TypeScript',
130 'cts' => 'TypeScript', 'vhd' => 'VHDL', 'vhdl' => 'VHDL', 'vim' => 'Vim Script', 'vb' => 'Visual Basic .NET', 'vue' => 'Vue',
131 'wgsl' => 'WGSL', 'y' => 'Yacc', 'yy' => 'Yacc', 'zig' => 'Zig', 'zon' => 'Zig',
132 'json' => 'JSON', 'md' => 'Markdown', 'markdown' => 'Markdown', 'sql' => 'SQL', 'toml' => 'TOML', 'xml' => 'XML', 'yml' => 'YAML', 'yaml' => 'YAML',
133 ];
134
135 /** 拡張子で決まらない物(中身で決める、決まらなければこの言語) */
136 private const AMBIGUOUS = ['h' => 'C', 'm' => 'Objective-C', 'v' => 'V', 'fs' => 'F#', 'd' => 'D'];
137
138 /** ファイル名 => 言語 */
139 private const FILENAMES = [
140 'Makefile' => 'Makefile', 'makefile' => 'Makefile', 'GNUmakefile' => 'Makefile', 'BSDmakefile' => 'Makefile', 'Kbuild' => 'Makefile',
141 'Makefile.am' => 'Makefile', 'CMakeLists.txt' => 'CMake', 'meson.build' => 'Meson', 'meson_options.txt' => 'Meson', 'meson.options' => 'Meson',
142 'Dockerfile' => 'Dockerfile', 'Containerfile' => 'Dockerfile', 'Rakefile' => 'Ruby', 'Gemfile' => 'Ruby', 'Vagrantfile' => 'Ruby',
143 'Podfile' => 'Ruby', 'Justfile' => 'Just', 'justfile' => 'Just', '.justfile' => 'Just', 'BUILD' => 'Starlark', 'BUILD.bazel' => 'Starlark',
144 'WORKSPACE' => 'Starlark', 'WORKSPACE.bazel' => 'Starlark', 'MODULE.bazel' => 'Starlark', '.bashrc' => 'Shell', '.bash_profile' => 'Shell',
145 '.bash_logout' => 'Shell', '.profile' => 'Shell', '.zshrc' => 'Shell', '.zprofile' => 'Shell', '.kshrc' => 'Shell', '.xinitrc' => 'Shell',
146 '.xsession' => 'Shell', 'PKGBUILD' => 'Shell', 'APKBUILD' => 'Shell', '.vimrc' => 'Vim Script', '_vimrc' => 'Vim Script',
147 '.gvimrc' => 'Vim Script', '.emacs' => 'Emacs Lisp', 'SConstruct' => 'Python', 'SConscript' => 'Python', 'Jenkinsfile' => 'Groovy',
148 ];
149
150 /** #!のコマンド(後ろの数字を除く) => 言語 */
151 private const INTERPRETERS = [
152 'sh' => 'Shell', 'bash' => 'Shell', 'zsh' => 'Shell', 'ksh' => 'Shell', 'mksh' => 'Shell', 'oksh' => 'Shell', 'pdksh' => 'Shell',
153 'dash' => 'Shell', 'ash' => 'Shell', 'rc' => 'Shell', 'fish' => 'Shell', 'python' => 'Python', 'pypy' => 'Python', 'ruby' => 'Ruby',
154 'jruby' => 'Ruby', 'perl' => 'Perl', 'raku' => 'Raku', 'php' => 'PHP', 'node' => 'JavaScript', 'nodejs' => 'JavaScript',
155 'bun' => 'JavaScript', 'deno' => 'TypeScript', 'lua' => 'Lua', 'luajit' => 'Lua', 'tclsh' => 'Tcl', 'wish' => 'Tcl', 'awk' => 'AWK',
156 'gawk' => 'AWK', 'nawk' => 'AWK', 'mawk' => 'AWK', 'rscript' => 'R', 'make' => 'Makefile', 'gmake' => 'Makefile', 'bmake' => 'Makefile',
157 'julia' => 'Julia', 'elixir' => 'Elixir', 'escript' => 'Erlang', 'guile' => 'Scheme', 'csi' => 'Scheme', 'racket' => 'Racket',
158 'sbcl' => 'Common Lisp', 'clisp' => 'Common Lisp', 'ecl' => 'Common Lisp', 'ocaml' => 'OCaml', 'runghc' => 'Haskell',
159 'runhaskell' => 'Haskell', 'crystal' => 'Crystal', 'pwsh' => 'PowerShell', 'dart' => 'Dart', 'swift' => 'Swift', 'groovy' => 'Groovy',
160 'scala' => 'Scala', 'emacs' => 'Emacs Lisp',
161 ];
162
163 /** linguist-languageの別名(小文字) */
164 private const ALIASES = [
165 'cpp' => 'C++', 'csharp' => 'C#', 'js' => 'JavaScript', 'ts' => 'TypeScript', 'sh' => 'Shell', 'bash' => 'Shell', 'golang' => 'Go',
166 'py' => 'Python', 'rb' => 'Ruby', 'objc' => 'Objective-C', 'objcpp' => 'Objective-C++', 'asm' => 'Assembly', 'make' => 'Makefile',
167 'vim' => 'Vim Script', 'viml' => 'Vim Script', 'elisp' => 'Emacs Lisp', 'md' => 'Markdown', 'yml' => 'YAML', 'vbnet' => 'Visual Basic .NET',
168 'terraform' => 'HCL', 'latex' => 'TeX', 'groff' => 'Roff', 'nroff' => 'Roff', 'manpage' => 'Roff',
169 ];
170
171 /** 外部のコード(Linguistのvendor.ymlの主な物) */
172 private const VENDORED = '~(^|/)(vendor|vendors|node_modules|bower_components|jspm_packages|third[_-]?party|3rd[_-]?party|externals?|extern|deps|'
173 .'Pods|Carthage|Godeps|\.git|\.svn|\.hg|\.vscode|\.idea|\.yarn|\.bundle)/'
174 .'|(^|/)(gradlew(\.bat)?|mvnw(\.cmd)?|config\.(guess|sub|status)|ltmain\.sh|install-sh|missing|depcomp|aclocal\.m4|Makefile\.in)$'
175 .'|\.min\.(js|css)$|-min\.js$|(^|/)(jquery|bootstrap)[^/]*\.(js|css)$~i';
176 /** ドキュメント */
177 private const DOCUMENTATION = '~^(docs?|documentation|man|examples?|samples?|demos?)/|(^|/)(javadoc|groovydoc)/'
178 .'|(^|/)(README|CHANGELOG|CHANGES|LICEN[CS]E|COPYING|CONTRIBUTING|INSTALL|NEWS|AUTHORS|THANKS|HISTORY)(\.[^/]*)?$~i';
179 /** 生成されたファイル */
180 private const GENERATED = '~(\.pb\.(go|cc|h)|_pb2\.py|\.designer\.cs|\.g\.dart|\.freezed\.dart)$'
181 .'|(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|go\.sum)$~';
182
183 /** 言語の色(知らない言語は灰色) */
184 public static function color(string $name): string {
185 return self::LANGS[$name][1] ?? '#cccccc';
186 }
187
188 /**
189 * 言語毎のバイト数(保存した物が有ればそれ)
190 *
191 * @param bool $head 既定のブランチ(リポジトリの一覧に出す言語)
192 * @return array|null 言語 => バイト数(多い順)。ファイルが多過ぎればnull
193 */
194 public static function get(Repository $repo, string $treeish, bool $head = false): ?array {
195 $tree = $repo->treeOf($treeish);
196 if ($tree === null) return null;
197 $file = $repo->path.'/gitler-languages.json';
198 $c = self::load($file);
199 if (isset($c['trees'][$tree])) {
200 if ($head && $c['head'] !== $tree) {
201 $c['head'] = $tree;
202 self::save($repo, $file, $c);
203 }
204 return $c['trees'][$tree];
205 }
206 $bytes = self::stats($repo, $tree);
207 if ($bytes === null) return null;
208 $c['trees'][$tree] = $bytes;
209 if ($head) $c['head'] = $tree;
210 // 古い物から消す(既定のブランチの物は残す)
211 foreach (array_keys($c['trees']) as $k) {
212 if (count($c['trees']) <= self::KEEP) break;
213 if ($k !== $c['head'] && $k !== $tree) unset($c['trees'][$k]);
214 }
215 self::save($repo, $file, $c);
216 return $bytes;
217 }
218
219 /**
220 * 既定のブランチの一番多い言語(保存した物だけ見る)
221 *
222 * @param string $dir リポジトリのディレクトリ
223 * @return array|null name、color
224 */
225 public static function primary(string $dir): ?array {
226 $c = self::load($dir.'/gitler-languages.json');
227 $name = array_key_first($c['trees'][$c['head']] ?? []);
228 return $name === null ? null : ['name' => (string)$name, 'color' => self::color((string)$name)];
229 }
230
231 /**
232 * 表示用(多い順、多過ぎれば残りは「その他」)
233 *
234 * @return array name、color、percent(「12.3%」)、width(CSSの幅)
235 */
236 public static function present(array $bytes): array {
237 $total = array_sum($bytes);
238 if ($total <= 0) return [];
239 arsort($bytes);
240 if (count($bytes) > self::SHOW) {
241 $rest = array_slice($bytes, self::SHOW - 1, null, true);
242 $bytes = array_slice($bytes, 0, self::SHOW - 1, true) + ['その他' => array_sum($rest)];
243 }
244 $out = [];
245 foreach ($bytes as $name => $n) {
246 $p = $n * 100 / $total;
247 $out[] = ['name' => (string)$name, 'color' => $name === 'その他' ? '#746c75' : self::color((string)$name),
248 'percent' => $p < 0.1 ? '<0.1%' : number_format($p, 1).'%', 'width' => sprintf('%.3F', $p)];
249 }
250 return $out;
251 }
252
253 /**
254 * ツリーの言語毎のバイト数を数える
255 *
256 * @return array|null 言語 => バイト数(多い順)。ファイルが多過ぎればnull
257 */
258 public static function stats(Repository $repo, string $treeish): ?array {
259 $root = $repo->treeOf($treeish);
260 if ($root === null) return null;
261 $rules = self::attributes($repo, $root);
262 // 除外を取り消す.gitattributesが無ければ、除外するディレクトリの中は見ない
263 $undo = false;
264 foreach ($rules as [, $set]) {
265 foreach (['linguist-vendored', 'linguist-generated', 'linguist-documentation'] as $k) if (array_key_exists($k, $set) && $set[$k] !== true) $undo = true;
266 }
267 $bytes = [];
268 $files = 0;
269 $stack = [['', $root]];
270 while (!empty($stack)) {
271 [$dir, $id] = array_pop($stack);
272 foreach ($repo->tree($id)->entries as [$mode, $name, $eid]) {
273 $path = $dir.$name;
274 if ($mode === Tree::DIR) {
275 if ($undo || !preg_match(self::VENDORED, $path.'/') && !preg_match(self::DOCUMENTATION, $path.'/')) $stack[] = [$path.'/', $eid];
276 continue;
277 }
278 // リンクとサブモジュールは数えない
279 if ($mode !== Tree::FILE && $mode !== Tree::EXEC) continue;
280 if (++$files > self::MAX_FILES) return null;
281 $size = 0;
282 $lang = self::file($repo, $path, $mode, $eid, self::match($rules, $path), $size);
283 if ($lang !== null) $bytes[$lang] = ($bytes[$lang] ?? 0) + $size;
284 }
285 }
286 arsort($bytes);
287 return $bytes;
288 }
289
290 /**
291 * 1つのファイルの言語(数えなければnull)
292 *
293 * @param array $a .gitattributesの属性
294 */
295 private static function file(Repository $repo, string $path, string $mode, string $id, array $a, int &$size): ?string {
296 if ($a['linguist-vendored'] ?? (bool)preg_match(self::VENDORED, $path)) return null;
297 if ($a['linguist-generated'] ?? (bool)preg_match(self::GENERATED, $path)) return null;
298 if ($a['linguist-documentation'] ?? (bool)preg_match(self::DOCUMENTATION, $path)) return null;
299 $size = (int)($repo->objects->info($id)[1] ?? 0);
300 $base = basename($path);
301 $dot = strrpos($base, '.');
302 $ext = $dot === false || $dot === 0 ? '' : strtolower(substr($base, $dot + 1));
303 $lang = isset($a['linguist-language']) ? self::named((string)$a['linguist-language']) : null;
304 $lang ??= self::FILENAMES[$base] ?? null;
305 if ($lang === null && preg_match('/^((GNU|BSD)?[Mm]akefile|Dockerfile|Containerfile)\./', $base, $m)) $lang = str_ends_with($m[1], 'akefile') ? 'Makefile' : 'Dockerfile';
306 $lang ??= self::EXTENSIONS[$ext] ?? null;
307 // 中身を見る:拡張子で決まらない物と、拡張子の無いスクリプト
308 if ($lang === null && (isset(self::AMBIGUOUS[$ext]) || $ext === '' || $ext === 'cgi' || $mode === Tree::EXEC)) {
309 if ($size > self::MAX_PEEK) {
310 $lang = self::AMBIGUOUS[$ext] ?? null;
311 } else {
312 $data = $repo->blob($id);
313 if (Diff::isBinary($data)) return null;
314 // Autoconfのconfigure等は生成された物
315 if (str_contains(substr($data, 0, 4096), 'Generated by GNU Autoconf')) return null;
316 $lang = isset(self::AMBIGUOUS[$ext]) ? self::guess($ext, $data) : self::shebang($data);
317 }
318 }
319 if ($lang === null) return null;
320 $type = self::LANGS[$lang][0] ?? self::PROGRAMMING;
321 return ($a['linguist-detectable'] ?? ($type === self::PROGRAMMING || $type === self::MARKUP)) ? $lang : null;
322 }
323
324 /** linguist-languageの名前(大文字・小文字、空白・「-」・「_」は区別しない) */
325 private static function named(string $value): string {
326 $key = strtolower($value);
327 if (isset(self::ALIASES[$key])) return self::ALIASES[$key];
328 $norm = fn(string $s): string => str_replace([' ', '-', '_'], '', strtolower($s));
329 foreach (array_keys(self::LANGS) as $name) if ($norm($name) === $norm($value)) return $name;
330 return $value;
331 }
332
333 /** 拡張子で決まらない物を中身で決める */
334 private static function guess(string $ext, string $data): ?string {
335 return match ($ext) {
336 'h' => preg_match('/^\s*(@interface|@implementation|@protocol|@end|#import)\b/m', $data) ? 'Objective-C'
337 : (preg_match('/^\s*(class|namespace|template\s*<|using\s+namespace)\b|\bstd::|^\s*(public|private|protected)\s*:|#include\s*<(iostream|string|vector|map|memory|algorithm|cstd\w+)>/m', $data) ? 'C++' : 'C'),
338 'm' => preg_match('/^\s*(@interface|@implementation|@protocol|@end|#import|#include)\b/m', $data) ? 'Objective-C'
339 : (preg_match('/^\s*(function\b|%)/m', $data) ? 'MATLAB' : 'Objective-C'),
340 'v' => preg_match('/^\s*module\s+\w+[\s\S]*^\s*endmodule\b/m', $data) ? 'Verilog'
341 : (preg_match('/^\s*(Theorem|Lemma|Proof|Qed|Require\s+Import|Inductive)\b/m', $data) ? 'Coq' : 'V'),
342 'fs' => preg_match('/^\s*#version\s+\d+|\bgl_Frag\w+|^\s*(uniform|in|out)\s+(vec|float|int|mat|sampler)\w*\s+\w+\s*;/m', $data) ? 'GLSL' : 'F#',
343 // makeの依存関係のファイル(生成された物)
344 'd' => preg_match('/^[^\s:#]+\.o\s*:/m', $data) ? null : 'D',
345 default => self::AMBIGUOUS[$ext] ?? null,
346 };
347 }
348
349 /** #!から言語を決める(「#!/usr/bin/env -S php」等) */
350 private static function shebang(string $data): ?string {
351 if (!str_starts_with($data, '#!')) return null;
352 $words = preg_split('/\s+/', trim(strtok(substr($data, 2), "\n")));
353 $prog = basename((string)array_shift($words));
354 if ($prog === 'env') {
355 $prog = '';
356 foreach ($words as $w) {
357 if ($w === '' || $w[0] === '-' || str_contains($w, '=')) continue;
358 $prog = basename($w);
359 break;
360 }
361 }
362 $prog = (string)preg_replace('/[\d.\-]+$/', '', strtolower($prog));
363 return self::INTERPRETERS[$prog] ?? null;
364 }
365
366 //////// .gitattributes
367
368 /**
369 * ルートの.gitattributesのlinguist-*の属性
370 *
371 * @return array [正規表現, 属性 => true、false、null(未指定)又は言語]の配列
372 */
373 private static function attributes(Repository $repo, string $root): array {
374 $e = $repo->tree($root)->find('.gitattributes');
375 if ($e === null || ($e[0] !== Tree::FILE && $e[0] !== Tree::EXEC)) return [];
376 $rules = [];
377 foreach (preg_split('/\r?\n/', $repo->blob($e[2])) as $line) {
378 $line = trim($line);
379 if ($line === '' || $line[0] === '#') continue;
380 $words = preg_split('/\s+/', $line);
381 $pattern = (string)array_shift($words);
382 $set = [];
383 foreach ($words as $w) {
384 if (!preg_match('/^([-!]?)(linguist-[a-z]+)(?:=(.*))?$/', $w, $m)) continue;
385 if ($m[1] === '!') $set[$m[2]] = null;
386 else if ($m[2] === 'linguist-language') $set[$m[2]] = $m[1] === '' && ($m[3] ?? '') !== '' ? $m[3] : null;
387 else $set[$m[2]] = $m[1] === '-' ? false : !in_array(strtolower($m[3] ?? 'true'), ['false', '0', 'no'], true);
388 }
389 if (!empty($set)) $rules[] = [self::glob($pattern), $set];
390 }
391 return $rules;
392 }
393
394 /** 後に書いた物が優先 */
395 private static function match(array $rules, string $path): array {
396 $a = [];
397 foreach ($rules as [$re, $set]) if (preg_match($re, $path)) $a = array_merge($a, $set);
398 return $a;
399 }
400
401 /** .gitattributesのパターンを正規表現にする(「/」が無ければどの深さの名前にも一致する) */
402 public static function glob(string $pattern): string {
403 // ディレクトリだけのパターンはファイルに一致しない
404 if (str_ends_with($pattern, '/')) return '~(?!)~';
405 $anchored = str_contains($pattern, '/');
406 if (str_starts_with($pattern, '/')) $pattern = substr($pattern, 1);
407 $re = '';
408 $len = strlen($pattern);
409 for ($i = 0; $i < $len; $i++) {
410 $ch = $pattern[$i];
411 $start = $i === 0 || $pattern[$i - 1] === '/';
412 if ($ch === '*' && $start && substr($pattern, $i, 3) === '**/') {
413 $re .= '(?:.*/)?';
414 $i += 2;
415 } else if ($ch === '*' && $start && $i + 2 === $len && substr($pattern, $i, 2) === '**') {
416 $re .= '.*';
417 $i++;
418 } else if ($ch === '*') {
419 $re .= '[^/]*';
420 } else if ($ch === '?') {
421 $re .= '[^/]';
422 } else if ($ch === '\\' && $i + 1 < $len) {
423 $re .= preg_quote($pattern[++$i], '~');
424 } else if ($ch === '[' && ($end = strpos($pattern, ']', $i + 2)) !== false) {
425 $class = substr($pattern, $i + 1, $end - $i - 1);
426 if ($class[0] === '!') $class = '^'.substr($class, 1);
427 $re .= '['.str_replace(['\\', '~'], ['\\\\', '\~'], $class).']';
428 $i = $end;
429 } else {
430 $re .= preg_quote($ch, '~');
431 }
432 }
433 return '~^'.($anchored ? '' : '(?:.*/)?').$re.'$~';
434 }
435
436 //////// 保存
437
438 private static function load(string $file): array {
439 $c = json_decode((string)@file_get_contents($file), true);
440 if (!is_array($c) || ($c['version'] ?? 0) !== self::VERSION || !is_array($c['trees'] ?? null)) return ['version' => self::VERSION, 'head' => '', 'trees' => []];
441 $c['head'] = (string)($c['head'] ?? '');
442 return $c;
443 }
444
445 private static function save(Repository $repo, string $file, array $c): void {
446 try {
447 $repo->writeFile($file, (string)json_encode($c, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
448 } catch (GitException) {
449 // 保存出来なくても表示は出来る
450 }
451 }
452}