| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Gitler; |
| 39 | |
| 40 | use Std\Lib\Git\Diff; |
| 41 | use Std\Lib\Git\GitException; |
| 42 | use Std\Lib\Git\Repository; |
| 43 | use 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 | */ |
| 53 | final 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 | } |