ごめん

守矢諏訪子 <suwako@076.moe>、2026-11-10 19:09
コミット b3d0fe015023ef4ac2a13e851c2c3336a8693c1e ・親 de8b809 ・ファイルを見る

1個のファイル、+153 -87

@@ -48,119 +48,185 @@
4848 }
4949
5050 public function displaySideBySide(): string {
51- $lines = explode("\n", $this->diffContent);
52- $fileDiffs = [];
53- $currentFile = null;
54- $hunk = [];
55- $lineNumbers = ['left' => 0, 'right' => 0];
56- $currentLeftLines = [];
57- $currentRightLines = [];
51+ $files = $this->parse();
5852
59- foreach ($lines as $line) {
60- // ファイルヘッダーの処理
61- if (preg_match('/^---\s+(.+)/', $line, $matches)) {
62- // ファイルを処理する場合、データを保存する
63- if ($currentFile !== null) {
64- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
65- $fileDiffs[$currentFile] = [
66- 'leftLines' => $currentLeftLines,
67- 'rightLines' => $currentRightLines
53+ // 各ファイルにHTMLの出力の作成
54+ $html = '';
55+ foreach ($files as $f) {
56+ $old = $f['old'] ?? $f['gitOld'];
57+ $new = $f['new'] ?? $f['gitNew'];
58+ $name = $old === $new ? $old : $old.' | '.$new;
59+ $html .= "\n <h2>ファイル: ".htmlspecialchars($name)."</h2>\n";
60+ if (empty($f['rows'])) {
61+ // 内容の差分が無い(バイナリ、名前の変更、モードの変更だけ等)
62+ $notes = empty($f['notes']) ? ['変更なし'] : $f['notes'];
63+ $html .= " <p>".htmlspecialchars(implode(' / ', $notes))."</p>\n";
64+ continue;
65+ }
66+ $html .= $this->generateHtml($f['rows']);
67+ }
68+
69+ return $html;
70+ }
71+
72+ /**
73+ * 統一形式(unified diff)を解析し、ファイル毎の行のリストを返す
74+ *
75+ * @return array ['old', 'new', 'gitOld', 'gitNew', 'notes', 'rows'] の配列
76+ */
77+ private function parse(): array {
78+ $files = [];
79+ $cur = null;
80+ $leftLeft = 0; // 今のハンクで残っている変更前の行数
81+ $rightLeft = 0; // 今のハンクで残っている変更後の行数
82+ $left = 0; // 次の変更前の行番号
83+ $right = 0; // 次の変更後の行番号
84+ $removed = [];
85+ $added = [];
86+
87+ $flush = function() use (&$cur, &$removed, &$added): void {
88+ // 削除と追加の塊を左右に並べる
89+ $n = max(count($removed), count($added));
90+ for ($i = 0; $i < $n; $i++) {
91+ $cur['rows'][] = [
92+ 'left' => $removed[$i] ?? null,
93+ 'right' => $added[$i] ?? null,
94+ ];
95+ }
96+ $removed = [];
97+ $added = [];
98+ };
99+
100+ $newFile = function(?string $gitOld, ?string $gitNew) use (&$files, &$cur, $flush): void {
101+ if ($cur !== null) {
102+ $flush();
103+ $files[] = $cur;
104+ }
105+ $cur = ['old' => null, 'new' => null, 'gitOld' => $gitOld ?? '?', 'gitNew' => $gitNew ?? '?',
106+ 'notes' => [], 'rows' => []];
107+ };
108+
109+ foreach (explode("\n", $this->diffContent) as $raw) {
110+ $line = rtrim($raw, "\r");
111+
112+ // ハンクの中:行数で判断する("---" で始まる削除行をファイルヘッダーと間違えない様に)
113+ if ($cur !== null && ($leftLeft > 0 || $rightLeft > 0)) {
114+ $prefix = substr($line, 0, 1);
115+ $content = substr($raw, 1);
116+ if ($prefix === ' ' || $line === '') {
117+ $flush();
118+ $cur['rows'][] = [
119+ 'left' => ['line' => $left++, 'type' => 'context', 'content' => $content],
120+ 'right' => ['line' => $right++, 'type' => 'context', 'content' => $content],
68121 ];
69- $hunk = [];
70- $currentLeftLines = [];
71- $currentRightLines = [];
72- $lineNumbers = ['left' => 0, 'right' => 0];
122+ $leftLeft--;
123+ $rightLeft--;
124+ continue;
73125 }
74- $currentFile = $matches[1];
126+ if ($prefix === '-') {
127+ $removed[] = ['line' => $left++, 'type' => 'removed', 'content' => $content];
128+ $leftLeft--;
129+ continue;
130+ }
131+ if ($prefix === '+') {
132+ $added[] = ['line' => $right++, 'type' => 'added', 'content' => $content];
133+ $rightLeft--;
134+ continue;
135+ }
136+ if ($prefix === '\\') {
137+ continue; // "\ No newline at end of file"
138+ }
139+ // 想定外の行:ハンクを終わらせて、下で普通に処理する
140+ $leftLeft = 0;
141+ $rightLeft = 0;
142+ }
143+ // ハンクの最後の行の直後の "\ No newline at end of file"
144+ if (substr($line, 0, 1) === '\\') continue;
145+
146+ // ファイルの始まり(git形式)
147+ if (preg_match('#^diff --git a/(.+) b/(.+)$#', $line, $m)) {
148+ $newFile('a/'.$m[1], 'b/'.$m[2]);
75149 continue;
76150 }
77- if (preg_match('/^\+\+\+\s+(.+)/', $line)) {
151+ // ファイルヘッダー(git以外のdiffでは、此処がファイルの始まり)
152+ if (preg_match('/^--- (.+?)(\t.*)?$/', $line, $m)) {
153+ if ($cur === null || $cur['old'] !== null || !empty($cur['rows'])) $newFile(null, null);
154+ $cur['old'] = $m[1];
78155 continue;
79156 }
157+ if (preg_match('/^\+\+\+ (.+?)(\t.*)?$/', $line, $m)) {
158+ if ($cur === null) $newFile(null, null);
159+ $cur['new'] = $m[1];
160+ continue;
161+ }
162+ if ($cur === null) continue;
80163
81- // ハンクヘッダーの処理 (例:@@ -10,6 +10,7 @@)
82- if (preg_match('/^@@\s+-(\d+),\d+\s+\+(\d+),\d+\s+@@/', $line, $matches)) {
83- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
84- $hunk = [];
85- $lineNumbers['left'] = (int)$matches[1];
86- $lineNumbers['right'] = (int)$matches[2];
164+ // ハンクヘッダー(例:@@ -10,6 +10,7 @@、行数が1の時は ",1" が省略される)
165+ if (preg_match('/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/', $line, $m)) {
166+ $flush();
167+ $left = (int)$m[1];
168+ $leftLeft = ($m[2] ?? '') === '' ? 1 : (int)$m[2];
169+ $right = (int)$m[3];
170+ $rightLeft = ($m[4] ?? '') === '' ? 1 : (int)$m[4];
171+ // 行数0のハンク(新しい・削除されたファイル)では、番号は次の行から
172+ if ($leftLeft === 0) $left++;
173+ if ($rightLeft === 0) $right++;
174+ $cur['rows'][] = ['hunk' => trim($line)];
87175 continue;
88176 }
89177
90- // ハンクでの行列の集まり
91- if (substr($line, 0, 1) === '-' || substr($line, 0, 1) === '+' || substr($line, 0, 1) === ' ') {
92- $hunk[] = $line;
178+ // 内容の無い変更の説明
179+ if (preg_match('/^Binary files (.+) and (.+) differ$/', $line, $m)) {
180+ $cur['notes'][] = 'バイナリファイル';
181+ $cur['old'] = $m[1];
182+ $cur['new'] = $m[2];
183+ } elseif (preg_match('/^(new file mode|deleted file mode|old mode|new mode|similarity index|rename from|rename to|copy from|copy to) (.+)$/', $line, $m)) {
184+ $labels = [
185+ 'new file mode' => '新しいファイル', 'deleted file mode' => '削除されたファイル',
186+ 'old mode' => '前のモード', 'new mode' => '新しいモード', 'similarity index' => '類似度',
187+ 'rename from' => '名前の変更元', 'rename to' => '名前の変更先', 'copy from' => 'コピー元',
188+ 'copy to' => 'コピー先',
189+ ];
190+ $cur['notes'][] = $labels[$m[1]].':'.$m[2];
93191 }
94192 }
95193
96- // 最後のハンク・ファイルの処理
97- if ($currentFile !== null) {
98- $this->processHunk($hunk, $currentLeftLines, $currentRightLines, $lineNumbers);
99- $fileDiffs[$currentFile] = [
100- 'leftLines' => $currentLeftLines,
101- 'rightLines' => $currentRightLines
102- ];
194+ if ($cur !== null) {
195+ $flush();
196+ $files[] = $cur;
103197 }
104198
105- // 各ファイルにHTMLの出力の作成
106- $html = '';
107- foreach ($fileDiffs as $fileName => $diff) {
108- $html .= "\n <h2>ファイル: ".htmlspecialchars($fileName)."</h2>\n";
109- $html .= $this->generateHtml($diff['leftLines'], $diff['rightLines']);
110- }
111-
112- return $html;
199+ return $files;
113200 }
114201
115- private function processHunk(array $hunk, array &$leftLines, array &$rightLines, array &$lineNumbers): void {
116- foreach ($hunk as $line) {
117- $prefix = substr($line, 0, 1);
118- $content = substr($line, 1);
119-
120- if ($prefix === '-') {
121- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'removed', 'line' => $lineNumbers['left']];
122- $lineNumbers['left']++;
123- } elseif ($prefix === '+') {
124- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'added', 'line' => $lineNumbers['right']];
125- $lineNumbers['right']++;
126- } elseif ($prefix === ' ') {
127- // 両側のコンテキストは同じ行列があるかの確認
128- while ($lineNumbers['left'] < $lineNumbers['right']) {
129- $leftLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['left']];
130- $lineNumbers['left']++;
131- }
132- while ($lineNumbers['right'] < $lineNumbers['left']) {
133- $rightLines[] = ['content' => '', 'type' => 'empty', 'line' => $lineNumbers['right']];
134- $lineNumbers['right']++;
135- }
136- $leftLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['left']];
137- $rightLines[] = ['content' => htmlspecialchars($content), 'type' => 'context', 'line' => $lineNumbers['right']];
138- $lineNumbers['left']++;
139- $lineNumbers['right']++;
140- }
141- }
142- }
143-
144- private function generateHtml(array $leftLines, array $rightLines): string {
202+ private function generateHtml(array $rows): string {
145203 $html = " <p>\n <table class=\"diff-table\">\n";
146204 $html .= " <tr class=\"diff-header\"><th colspan=\"2\">前</th><th colspan=\"2\">新</th></tr>\n";
147205
148- $maxLines = max(count($leftLines), count($rightLines));
149- for ($i = 0; $i < $maxLines; $i++) {
150- $left = isset($leftLines[$i]) ? $leftLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
151- $right = isset($rightLines[$i]) ? $rightLines[$i] : ['content' => '', 'type' => 'empty', 'line' => ''];
152-
206+ foreach ($rows as $row) {
207+ if (isset($row['hunk'])) {
208+ $html .= " <tr class=\"hunk\">\n";
209+ $html .= " <td colspan=\"4\">".htmlspecialchars($row['hunk'])."</td>\n";
210+ $html .= " </tr>\n";
211+ continue;
212+ }
153213 $html .= " <tr>\n";
154- // 左(変更前)
155- $html .= " <td class=\"line-number\">".($left['line'] ?: '&nbsp;')."</td>\n";
156- $html .= " <td class=\"{$left['type']}\">".($left['content'] ?: '&nbsp;')."</td>\n";
157- // 右(変更後)
158- $html .= " <td class=\"line-number\">".($right['line'] ?: '&nbsp;')."</td>\n";
159- $html .= " <td class=\"{$right['type']}\">".($right['content'] ?: '&nbsp;')."</td>\n";
214+ $html .= $this->cells($row['left']); // 左(変更前)
215+ $html .= $this->cells($row['right']); // 右(変更後)
160216 $html .= " </tr>\n";
161217 }
162218
163219 $html .= " </table>\n </p>\n";
164220 return $html;
221+ }
222+
223+ private function cells(?array $c): string {
224+ if ($c === null) {
225+ return " <td class=\"line-number\">&nbsp;</td>\n <td class=\"empty\">&nbsp;</td>\n";
226+ }
227+ $content = rtrim($c['content'], "\r");
228+ $content = $content === '' ? '&nbsp;' : htmlspecialchars($content);
229+ return " <td class=\"line-number\">{$c['line']}</td>\n"
230+ . " <td class=\"{$c['type']}\">{$content}</td>\n";
165231 }
166232 }