@@ -30,6 +30,8 @@ final class Differ
3030 * show-modified?: bool,
3131 * comment-marker?: string,
3232 * heading?: string,
33+ * repo?: string,
34+ * pr-number?: int|string,
3335 * } $options
3436 */
3537 public function __construct (private array $ options = [])
@@ -70,13 +72,13 @@ public function diff(array $headRecords, array $baseRecords): string
7072
7173 $ body = '' ;
7274 if (!empty ($ added )) {
73- $ body .= self :: renderSection ('New API Surface ' , $ added );
75+ $ body .= $ this -> renderSection ('New API Surface ' , $ added, side: ' R ' );
7476 }
7577 if ($ showRemoved && !empty ($ removed )) {
76- $ body .= self :: renderSection ('Removed API Surface ' , $ removed );
78+ $ body .= $ this -> renderSection ('Removed API Surface ' , $ removed, side: ' L ' );
7779 }
7880 if ($ showModified && !empty ($ modified )) {
79- $ body .= self :: renderModifiedSection ($ modified );
81+ $ body .= $ this -> renderModifiedSection ($ modified );
8082 }
8183
8284 if ($ body === '' ) {
@@ -136,7 +138,7 @@ public static function recordKey(array $record): string
136138 /**
137139 * @param list<array<string,mixed>> $records
138140 */
139- private static function renderSection (string $ title , array $ records ): string
141+ private function renderSection (string $ title , array $ records, string $ side ): string
140142 {
141143 $ byKind = [];
142144 foreach ($ records as $ r ) {
@@ -149,26 +151,31 @@ private static function renderSection(string $title, array $records): string
149151 }
150152 $ out .= "\n#### " . self ::KIND_LABELS [$ kind ] . "\n" ;
151153 foreach ($ byKind [$ kind ] as $ r ) {
152- $ out .= self :: renderRecord ($ r ) . "\n" ;
154+ $ out .= $ this -> renderRecord ($ r, $ side ) . "\n" ;
153155 }
154156 }
155157 return $ out . "\n" ;
156158 }
157159
158- private static function renderRecord (array $ record ): string
160+ private function renderRecord (array $ record, string $ side ): string
159161 {
160- $ loc = '` ' . $ record ['file ' ] . ': ' . $ record ['line ' ] . '` ' ;
161- if ($ record ['member ' ] !== null ) {
162- $ label = $ record ['fqcn ' ] . ':: ' . $ record ['member ' ];
163- return "- ` {$ label }` — ` " . trim ($ record ['signature ' ]) . '` in ' . $ loc ;
162+ $ label = $ record ['member ' ] !== null
163+ ? $ record ['fqcn ' ] . ':: ' . $ record ['member ' ]
164+ : $ record ['fqcn ' ];
165+
166+ $ head = $ this ->formatLabel ($ label , $ record ['file ' ], (int ) $ record ['line ' ], $ side );
167+ $ sig = trim ($ record ['signature ' ]);
168+ $ line = '- ' . $ head . ' — ` ' . $ sig . '` ' ;
169+ if (!$ this ->hasRepoLink ()) {
170+ $ line .= ' in ` ' . $ record ['file ' ] . ': ' . $ record ['line ' ] . '` ' ;
164171 }
165- return " - ` " . trim ( $ record [ ' signature ' ]) . ' ` in ' . $ loc ;
172+ return $ line ;
166173 }
167174
168175 /**
169176 * @param list<array{head: array<string,mixed>, base: array<string,mixed>}> $changes
170177 */
171- private static function renderModifiedSection (array $ changes ): string
178+ private function renderModifiedSection (array $ changes ): string
172179 {
173180 $ byKind = [];
174181 foreach ($ changes as $ c ) {
@@ -184,11 +191,109 @@ private static function renderModifiedSection(array $changes): string
184191 $ h = $ c ['head ' ];
185192 $ b = $ c ['base ' ];
186193 $ label = $ h ['member ' ] !== null ? ($ h ['fqcn ' ] . ':: ' . $ h ['member ' ]) : $ h ['fqcn ' ];
187- $ out .= "- ` {$ label }` in ` " . $ h ['file ' ] . ': ' . $ h ['line ' ] . "` \n" ;
188- $ out .= " - was: ` " . trim ($ b ['signature ' ]) . "` \n" ;
189- $ out .= " - now: ` " . trim ($ h ['signature ' ]) . "` \n" ;
194+
195+ $ head = $ this ->formatLabel ($ label , $ h ['file ' ], (int ) $ h ['line ' ], 'R ' );
196+ $ line = '- ' . $ head ;
197+ if (!$ this ->hasRepoLink ()) {
198+ $ line .= ' in ` ' . $ h ['file ' ] . ': ' . $ h ['line ' ] . '` ' ;
199+ }
200+ $ out .= $ line . "\n" ;
201+ $ out .= self ::renderSignatureDiff (
202+ self ::sourceFor ($ b ),
203+ self ::sourceFor ($ h ),
204+ ) . "\n" ;
190205 }
191206 }
192207 return $ out . "\n" ;
193208 }
209+
210+ private function hasRepoLink (): bool
211+ {
212+ return !empty ($ this ->options ['repo ' ]) && !empty ($ this ->options ['pr-number ' ]);
213+ }
214+
215+ private function formatLabel (string $ label , string $ file , int $ line , string $ side ): string
216+ {
217+ if (!$ this ->hasRepoLink ()) {
218+ return '` ' . $ label . '` ' ;
219+ }
220+ $ url = sprintf (
221+ 'https://github.com/%s/pull/%s/files#diff-%s%s%d ' ,
222+ $ this ->options ['repo ' ],
223+ $ this ->options ['pr-number ' ],
224+ hash ('sha256 ' , $ file ),
225+ $ side ,
226+ $ line ,
227+ );
228+ return '[` ' . $ label . '`]( ' . $ url . ') ' ;
229+ }
230+
231+ private static function sourceFor (array $ record ): string
232+ {
233+ if (!empty ($ record ['signature_source ' ])) {
234+ return rtrim ($ record ['signature_source ' ]);
235+ }
236+ return rtrim ($ record ['signature ' ]);
237+ }
238+
239+ /**
240+ * Render the was → now diff. For short snippets (≤ 5 lines on both sides),
241+ * emit the full was prefixed by `-` and now by `+` inside a ```diff fence.
242+ * For longer snippets, emit a unified diff (only changed hunks) via `diff -u`.
243+ */
244+ private static function renderSignatureDiff (string $ was , string $ now ): string
245+ {
246+ $ wasLines = explode ("\n" , $ was );
247+ $ nowLines = explode ("\n" , $ now );
248+
249+ if (count ($ wasLines ) <= 5 && count ($ nowLines ) <= 5 ) {
250+ $ body = '' ;
251+ foreach ($ wasLines as $ l ) {
252+ $ body .= '- ' . $ l . "\n" ;
253+ }
254+ foreach ($ nowLines as $ l ) {
255+ $ body .= '+ ' . $ l . "\n" ;
256+ }
257+ return " ```diff \n" . self ::indentBlock ($ body ) . " ``` " ;
258+ }
259+
260+ $ diff = self ::computeUnifiedDiff ($ was , $ now );
261+ return " ```diff \n" . self ::indentBlock ($ diff ) . " ``` " ;
262+ }
263+
264+ private static function computeUnifiedDiff (string $ was , string $ now ): string
265+ {
266+ $ wasFile = tempnam (sys_get_temp_dir (), 'asc_was_ ' );
267+ $ nowFile = tempnam (sys_get_temp_dir (), 'asc_now_ ' );
268+ try {
269+ file_put_contents ($ wasFile , $ was . "\n" );
270+ file_put_contents ($ nowFile , $ now . "\n" );
271+ $ cmd = sprintf (
272+ 'diff -U1 --label was --label now %s %s ' ,
273+ escapeshellarg ($ wasFile ),
274+ escapeshellarg ($ nowFile ),
275+ );
276+ $ out = shell_exec ($ cmd ) ?? '' ;
277+ } finally {
278+ @unlink ($ wasFile );
279+ @unlink ($ nowFile );
280+ }
281+
282+ // Strip the file headers (--- was / +++ now) — the @@ hunks are what's useful.
283+ $ lines = explode ("\n" , rtrim ($ out , "\n" ));
284+ $ kept = [];
285+ foreach ($ lines as $ line ) {
286+ if (str_starts_with ($ line , '--- ' ) || str_starts_with ($ line , '+++ ' )) {
287+ continue ;
288+ }
289+ $ kept [] = $ line ;
290+ }
291+ return implode ("\n" , $ kept ) . "\n" ;
292+ }
293+
294+ private static function indentBlock (string $ block ): string
295+ {
296+ $ lines = explode ("\n" , rtrim ($ block , "\n" ));
297+ return implode ("\n" , array_map (static fn (string $ l ) => ' ' . $ l , $ lines )) . "\n" ;
298+ }
194299}
0 commit comments