Split the magical inline tags so we don't parse them except in the real case
[viewsvn.git] / includes / svnlib.php
1 <?php
2 /*=====================================================================*\
3 || ###################################################################
4 || # ViewSVN [#]version[#]
5 || # Copyright ©2002-[#]year[#] Iris Studios, Inc.
6 || #
7 || # This program is free software; you can redistribute it and/or modify
8 || # it under the terms of the GNU General Public License as published by
9 || # the Free Software Foundation; version [#]gpl[#] of the License.
10 || #
11 || # This program is distributed in the hope that it will be useful, but
12 || # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
13 || # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14 || # more details.
15 || #
16 || # You should have received a copy of the GNU General Public License along
17 || # with this program; if not, write to the Free Software Foundation, Inc.,
18 || # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
19 || ###################################################################
20 \*=====================================================================*/
21
22 /**
23 * Command line interface with the SVN commands
24 *
25 * @package ViewSVN
26 */
27
28 /**
29 * Interacts with the command line subsystem to
30 * access SVN information
31 *
32 * @package ViewSVN
33 * @version $Id$
34 */
35 class SVNLib
36 {
37 /**
38 * Path to the SVN binary
39 * @var string
40 */
41 var $svnpath;
42
43 /**
44 * Common command system
45 * @var object
46 */
47 var $common;
48
49 /**
50 * Constructor: validate SVN path
51 *
52 * @param string Path to SVN binary
53 */
54 function SVNLib($svnpath)
55 {
56 global $viewsvn;
57
58 $this->svnpath = $viewsvn->shell->cmd($svnpath);
59
60 $this->common =& new SVNCommon();
61
62 $access = $viewsvn->shell->exec($this->svnpath . ' --version');
63
64 if (!$access)
65 {
66 $viewsvn->trigger->error('svn binary could not be found');
67 }
68
69 if (!preg_match('#^svn, version (.*?)\)$#i', trim($access[0])))
70 {
71 $viewsvn->trigger->error('svn binary does not pass test');
72 }
73 }
74
75 /**
76 * Prepares data for output
77 *
78 * @access public
79 *
80 * @param string Standard data
81 *
82 * @return string Output-ready data
83 */
84 function format($string)
85 {
86 // convert entities
87 $string = htmlspecialchars($string);
88
89 // tabs to 5 spaces
90 $string = str_replace("\t", ' ', $string);
91
92 // spaces to nbsp
93 if (true)
94 {
95 $string = preg_replace('#( +)#e', '$this->format_spaces("\1")', $string);
96 }
97 // no word wrap
98 else
99 {
100 $string = str_replace(' ', '&nbsp;', $string);
101 }
102
103 // convert advanced diff
104 $string = str_replace(array('{@+' . '+}', '{@-' . '-}'), array('<span class="diff_add_bit">', '<span class="diff_del_bit">'), $string);
105 $string = str_replace(array('{/@+' . '+}', '{/@-' . '-}'), '</span>', $string);
106
107 // nl2br
108 $string = nl2br($string);
109
110 return $string;
111 }
112
113 /**
114 * Counts the spaces and replaces two or more ones
115 *
116 * @access private
117 *
118 * @param string Spaced string
119 *
120 * @return string &nbsp;'d string
121 */
122 function format_spaces($thestring)
123 {
124 if (strlen($thestring) >= 2)
125 {
126 $thestring = str_replace(' ', '&nbsp;', $thestring);
127 }
128
129 return $thestring;
130 }
131
132 /**
133 * Executes the SVN binary
134 *
135 * @access private
136 *
137 * @param string Command
138 *
139 * @return array Output
140 */
141 function svn($command)
142 {
143 global $viewsvn;
144
145 $output = $viewsvn->shell->exec($this->svnpath . ' ' . $command . ' 2>&1');
146 $temp = implode("\n", $output);
147 if (strpos($temp, '(apr' . '_err=') !== false)
148 {
149 $viewsvn->trigger->error(nl2br($temp));
150 }
151 return $output;
152 }
153
154 /**
155 * SVN Wrapper: standard command system
156 *
157 * @access private
158 *
159 * @param string SVN command
160 * @param string Repository
161 * @param string Path
162 * @param integer Revision
163 *
164 * @return array Lines of output
165 */
166 function std($command, $repos, $path, $revision)
167 {
168 global $viewsvn;
169
170 $revision = $this->rev($revision);
171 $repospath = $viewsvn->repos->fetch_path($repos, false);
172
173 return $this->svn($command . ' -r' . $revision . ' ' . $repospath . $path);
174 }
175
176 /**
177 * SVN Wrapper: blame
178 *
179 * @access protected
180 *
181 * @param string Repository
182 * @param string Path
183 * @param integer Revision
184 *
185 * @return array Lines of blame output
186 */
187 function blame($repos, $path, $revision)
188 {
189 return $this->std('blame', $repos, $path, $revision);
190 }
191
192 /**
193 * SVN Wrapper: cat
194 *
195 * @access protected
196 *
197 * @param string Repository
198 * @param string Path
199 * @param integer Revision
200 *
201 * @return array Lines of cat output
202 */
203 function cat($repos, $path, $revision)
204 {
205 return $this->std('cat', $repos, $path, $revision);
206 }
207
208 /**
209 * SVN Wrapper: diff
210 *
211 * @access protected
212 *
213 * @param string Repository
214 * @param string Path
215 * @param integer Lower revision
216 * @param integer Higher revision
217 *
218 * @return array Lines of diff output
219 */
220 function diff($repos, $path, $lorev, $hirev)
221 {
222 global $viewsvn;
223
224 $hirev = $this->rev($hirev);
225 $lorev = $this->rev($lorev);
226 if ($lorev == 'HEAD')
227 {
228 $lorev = 1;
229 }
230
231 if (is_integer($hirev) AND is_integer($lorev))
232 {
233 if ($lorev > $hirev)
234 {
235 $lorev = $hirev - 1;
236 }
237 if ($lorev == $hirev)
238 {
239 $lorev = 0;
240 }
241 }
242
243 $repospath = $viewsvn->repos->fetch_path($repos, false);
244
245 return $this->svn('diff -r' . $lorev . ':' . $hirev . ' ' . $repospath . $path);
246 }
247
248 /**
249 * SVN Wrapper: log
250 *
251 * @access protected
252 *
253 * @param string Repository
254 * @param string Path
255 * @param integer Lower revision
256 * @param integer Higher revision
257 *
258 * @return array Lines of log output
259 */
260 function log($repos, $path, $lorev, $hirev)
261 {
262 global $viewsvn;
263
264 $hirev = $this->rev($hirev);
265 $lorev = $this->rev($hirev);
266 if ($lorev == 'HEAD')
267 {
268 $lorev = 0;
269 }
270
271 if (is_integer($hirev) AND is_integer($lorev))
272 {
273 if ($lorev > $hirev)
274 {
275 $lorev = $hirev - 1;
276 }
277 if ($lorev == $hirev)
278 {
279 $lorev = 0;
280 }
281 }
282
283 $repospath = $viewsvn->repos->fetch_path($repos, false);
284
285 return $this->svn('log -v -r' . $hirev . ':' . $lorev . ' ' . $repospath . $path);
286 }
287
288 /**
289 * SVN Wrapper: ls (list)
290 *
291 * @access protected
292 *
293 * @param string Repository
294 * @param string Path
295 * @param integer Revision
296 *
297 * @return array Lines of list output
298 */
299 function ls($repos, $path, $revision)
300 {
301 return $this->std('list', $repos, $path, $revision);
302 }
303
304 /**
305 * Generates a clean revision number
306 *
307 * @access public
308 *
309 * @param integer Revision number
310 *
311 * @return mixed Cleaned revision or HEAD
312 */
313 function rev($revision)
314 {
315 if (($revision = intval($revision)) < 1)
316 {
317 $revision = 'HEAD';
318 }
319 return $revision;
320 }
321 }
322
323 /**
324 * Commonly executed SVN commands that return data
325 * used in many parts of the system
326 *
327 * @package ViewSVN
328 * @version $Id$
329 */
330 class SVNCommon
331 {
332 /**
333 * Registry object
334 * @var object
335 */
336 var $registry;
337
338 /**
339 * List of revisions
340 * @var array
341 */
342 var $revisions;
343
344 /**
345 * List of logs
346 * @var array
347 */
348 var $logs;
349
350 /**
351 * Constructor: bind with registry
352 */
353 function SVNCommon()
354 {
355 global $viewsvn;
356
357 $this->registry =& $viewsvn;
358 }
359
360 /**
361 * Checks to see if the given universal path is
362 * a directory
363 *
364 * @access public
365 *
366 * @param string Universal path
367 *
368 * @return bool Directory or not
369 */
370 function isdir($path)
371 {
372 $output = $this->registry->svn->std('info', $this->registry->paths->fetch_repos($path), $this->registry->paths->fetch_path($path), 'HEAD');
373
374 foreach ($output AS $line)
375 {
376 if (preg_match('#^Node Kind: (.*)#', $line, $matches))
377 {
378 if (trim(strtolower($matches[1])) == 'directory')
379 {
380 return true;
381 }
382 }
383 }
384
385 return false;
386 }
387
388 /**
389 * Get a list of revisions for a path
390 *
391 * @access public
392 *
393 * @param string Universal path
394 *
395 * @return array Key revisions
396 */
397 function fetch_revs($path)
398 {
399 if (!isset($this->revisions["$path"]))
400 {
401 $log = $this->fetch_logs($path);
402
403 $revs = array_keys($log);
404
405 $this->revisions["$path"] = array(
406 'HEAD' => $revs[0],
407 'START' => $revs[ count($revs) - 1 ],
408 'revs' => $revs
409 );
410 }
411
412 return $this->revisions["$path"];
413 }
414
415 /**
416 * Gets the revision that is marked as HEAD
417 *
418 * @access public
419 *
420 * @param string Universal path
421 *
422 * @return integer Revision
423 */
424 function fetch_head_rev($path)
425 {
426 $revs = $this->fetch_revs($path);
427 return $revs['HEAD'];
428 }
429
430 /**
431 * Returns the previous revision to the one given
432 *
433 * @access public
434 *
435 * @param string Universal path
436 * @param integer Arbitrary revision
437 *
438 * @return integer Previous revision (-1 if none)
439 */
440 function fetch_prev_rev($path, $current)
441 {
442 $revs = $this->fetch_revs($path);
443
444 if ($current == 'HEAD')
445 {
446 $current = $this->fetch_head_rev($path);
447 }
448
449 $index = array_search($current, $revs['revs']);
450 if ($current === false)
451 {
452 $message->trigger->error('revision ' . $current . ' is not in ' . $path);
453 }
454
455 if (isset($revs['revs'][ $index + 1 ]))
456 {
457 return $revs['revs'][ $index + 1 ];
458 }
459 else
460 {
461 return -1;
462 }
463 }
464
465 /**
466 * Get a list of logs
467 *
468 * @access public
469 *
470 * @param string Universal path
471 *
472 * @return array Log data
473 */
474 function fetch_logs($path)
475 {
476 if (!isset($this->logs["$path"]))
477 {
478 $log = new SVNLog($this->registry->paths->fetch_repos($path), $this->registry->paths->fetch_path($path), 0, 0);
479
480 $this->logs["$path"] = $log->fetch();
481 }
482
483 return $this->logs["$path"];
484 }
485
486 /**
487 * Returns a given log entry for a path
488 * and revision
489 *
490 * @access public
491 *
492 * @param string Universal path
493 * @param integer Arbitrary revision
494 *
495 * @return array Log entry
496 */
497 function fetch_log($path, $rev)
498 {
499 $logs = $this->fetch_logs($path);
500
501 $rev = $this->registry->svn->rev($rev);
502 if ($rev == 'HEAD')
503 {
504 $rev = $this->fetch_head_rev($path);
505 }
506
507 if (isset($logs["$rev"]))
508 {
509 return $logs["$rev"];
510 }
511 else
512 {
513 return null;
514 }
515 }
516 }
517
518 /**
519 * Annotation/blame system; constructs an array
520 * that is ready for output
521 *
522 * @package ViewSVN
523 * @version $Id$
524 */
525 class SVNBlame
526 {
527 /**
528 * Array of blame information
529 * @var array
530 */
531 var $blame = array();
532
533 /**
534 * Raw "svn blame" output
535 * @var array
536 */
537 var $rawoutput;
538
539 /**
540 * Constructor: create blame and store data
541 *
542 * @param string Repository
543 * @param string Path
544 * @param integer Revision
545 */
546 function SVNBlame($repos, $path, $revision)
547 {
548 global $viewsvn;
549
550 $this->rawoutput = $viewsvn->svn->blame($repos, $path, $revision);
551 $this->process();
552 }
553
554 /**
555 * Returns blame for display
556 *
557 * @access public
558 *
559 * @return array Blame data
560 */
561 function fetch()
562 {
563 return $this->blame;
564 }
565
566 /**
567 * Parses the blame data
568 *
569 * @access private
570 */
571 function process()
572 {
573 $lineno = 1;
574
575 foreach ($this->rawoutput AS $line)
576 {
577 if (preg_match('#^\s+([0-9]+)\s+([\w\.\-_]+)\s(.*)$#', $line, $matches))
578 {
579 $this->blame[] = array(
580 'rev' => $matches[1],
581 'author' => $matches[2],
582 'line' => $matches[3],
583 'lineno' => $lineno++
584 );
585 }
586 // a blank line
587 else if (preg_match('#^\s+([0-9]+)\s+([\w\.\-_]+)$#', $line, $matches))
588 {
589 $this->blame[] = array(
590 'rev' => $matches[1],
591 'author' => $matches[2],
592 'line' => '',
593 'lineno' => $lineno++
594 );
595 }
596 }
597 }
598 }
599
600 /**
601 * Log management system; creates a complex list
602 * of SVN log information
603 *
604 * @package ViewSVN
605 * @version $Id$
606 */
607 class SVNLog
608 {
609 /**
610 * Array of logs
611 * @var array
612 */
613 var $logs = array();
614
615 /**
616 * Raw "svn log" output
617 * @var array
618 */
619 var $rawoutput;
620
621 /**
622 * Constructor: create log store for the given file
623 *
624 * @param string Repository
625 * @param string Path
626 * @param integer Lower revision
627 * @param integer Higher revision
628 */
629 function SVNLog($repos, $path, $lorev, $hirev)
630 {
631 global $viewsvn;
632
633 $this->rawoutput = $viewsvn->svn->log($repos, $path, $lorev, $hirev);
634 $this->process();
635 }
636
637 /**
638 * Returns logs for display
639 *
640 * @access public
641 *
642 * @return array Log data
643 */
644 function fetch()
645 {
646 return $this->logs;
647 }
648
649 /**
650 * Splits up the raw output into a usable log
651 *
652 * @access private
653 */
654 function process()
655 {
656 $lastrev = 0;
657
658 for ($i = 1; $i <= count($this->rawoutput) - 1; $i++)
659 {
660 $line = $this->rawoutput["$i"];
661
662 if (preg_match('#^r([0-9]*) \| (.*?) \| (....-..-.. ..:..:..) ([0-9\-]*) \((.*?)\) \| ([0-9]*) lines?$#', $line, $matches))
663 {
664 if (isset($this->logs["$lastrev"]))
665 {
666 $this->logs["$lastrev"]['message'] = $this->strip_last_line($this->logs["$lastrev"]['message']);
667 }
668
669 $this->logs["$matches[1]"] = array(
670 'rev' => $matches[1],
671 'author' => $matches[2],
672 'date' => $matches[3],
673 'timezone' => $matches[4],
674 'lines' => $matches[6],
675 'message' => ''
676 );
677
678 $lastrev = $matches[1];
679 }
680 else if (preg_match('#^\s+([ADMR])\s(.*)#', $line, $matches))
681 {
682 $this->logs["$lastrev"]['files'][] = array(
683 'action' => $matches[1],
684 'file' => $matches[2]
685 );
686 }
687 else
688 {
689 if (trim($line) != 'Changed paths:')
690 {
691 $this->logs["$lastrev"]['message'] .= $line . "\n";
692 }
693 }
694 }
695
696 if (isset($this->logs["$lastrev"]))
697 {
698 $this->logs["$lastrev"]['message'] = $this->strip_last_line($this->logs["$lastrev"]['message']);
699 }
700 }
701
702 /**
703 * Trims the last dash line off a message
704 *
705 * @access private
706 *
707 * @param string Message with annoying-ass line
708 *
709 * @return string Clean string
710 */
711 function strip_last_line($string)
712 {
713 return trim(preg_replace("#\n(.*?)\n$#", '', $string));
714 }
715 }
716
717 /**
718 * Diff system; constructs a diff array that
719 * is ready for output
720 *
721 * @package ViewSVN
722 */
723 class SVNDiff
724 {
725 /**
726 * Array of diff information
727 * @var array
728 */
729 var $diff = array();
730
731 /**
732 * Raw "svn diff" output
733 * @var array
734 */
735 var $rawoutput;
736
737 /**
738 * Constructor: create and store diff data
739 *
740 * @param string Repository
741 * @param string Path
742 * @param integer Lower revision
743 * @param integer Higher revision
744 */
745 function SVNDiff($repos, $path, $lorev, $hirev)
746 {
747 global $viewsvn;
748
749 $this->rawoutput = $viewsvn->svn->diff($repos, $path, $lorev, $hirev);
750 $this->process();
751 }
752
753 /**
754 * Returns diffs for display
755 *
756 * @access public
757 *
758 * @return array Diff data
759 */
760 function fetch()
761 {
762 return $this->diff;
763 }
764
765 /**
766 * Processes and prepares diff data
767 *
768 * @access private
769 */
770 function process()
771 {
772 $chunk = 0;
773 $indexcounter = null;
774
775 $lastact = '';
776 $lastcontent = '';
777
778 foreach ($this->rawoutput AS $line)
779 {
780 if (preg_match('#^@@ \-([0-9]*),([0-9]*) \+([0-9]*),([0-9]*) @@$#', $line, $bits))
781 {
782 $lastact = '';
783 $lastcontent = '';
784
785 $this->diff["$index"][ ++$chunk ]['hunk'] = array('old' => array('line' => $bits[1], 'count' => $bits[2]), 'new' => array('line' => $bits[3], 'count' => $bits[4]));
786 $lines['old'] = $this->diff["$index"]["$chunk"]['hunk']['old']['line'] - 1;
787 $lines['new'] = $this->diff["$index"]["$chunk"]['hunk']['new']['line'] - 1;
788 continue;
789 }
790
791 if ($indexcounter <= 5 AND $indexcounter !== null)
792 {
793 $indexcounter++;
794 continue;
795 }
796 else if ($indexcounter == 5)
797 {
798 $indexcounter = null;
799 continue;
800 }
801
802 if (preg_match('#^([\+\- ])(.*)#', $line, $matches))
803 {
804 $act = $matches[1];
805 $content = $matches[2];
806
807 if ($act == ' ')
808 {
809 $this->diff["$index"]["$chunk"][] = array(
810 'line' => $content,
811 'act' => '',
812 'oldlineno' => ++$lines['old'],
813 'newlineno' => ++$lines['new']
814 );
815 }
816 else if ($act == '+')
817 {
818 // potential line delta
819 if ($lastact == '-')
820 {
821 if ($delta = @$this->fetch_diff_extent($lastcontent, $content))
822 {
823 // create two sets of ends for the two contents
824 $delta['endo'] = strlen($lastcontent) - $delta['end'];
825 $delta['endn'] = strlen($content) - $delta['end'];
826
827 $diffo = $delta['endo'] - $delta['start'];
828 $diffn = $delta['endn'] - $delta['start'];
829
830 if (strlen($lastcontent) > $delta['endo'] - $diffo)
831 {
832 $removed = substr($lastcontent, $delta['start'], $diffo);
833 $this->diff["$index"]["$chunk"][ count($this->diff["$index"]["$chunk"]) - 2 ]['line'] = substr_replace($lastcontent, '{@-' . '-}' . $removed . '{/@-' . '-}', $delta['start'], $diffo);
834 }
835
836 if (strlen($content) > $delta['endn'] - $diffn)
837 {
838 $added = substr($content, $delta['start'], $diffn);
839 $content = substr_replace($content, '{@+' . '+}' . $added . '{/@+' . '+}', $delta['start'], $diffn);
840 }
841 }
842 }
843
844 $this->diff["$index"]["$chunk"][] = array(
845 'line' => $content,
846 'act' => '+',
847 'oldlineno' => '',
848 'newlineno' => ++$lines['new']
849 );
850 }
851 else if ($act == '-')
852 {
853 $lastcontent = $content;
854
855 $this->diff["$index"]["$chunk"][] = array(
856 'line' => $content,
857 'act' => '-',
858 'oldlineno' => ++$lines['old'],
859 'newlineno' => ''
860 );
861 }
862
863 $lastact = $act;
864 }
865 // whitespace lines
866 else
867 {
868 if (preg_match('#^Index: (.*?)$#', $line, $matches))
869 {
870 $index = $matches[1];
871 $indexcounter = 1;
872 $chunk = 0;
873 continue;
874 }
875
876 $lastact = '';
877
878 $this->diff["$index"]["$chunk"][] = array(
879 'line' => '',
880 'act' => '',
881 'oldlineno' => ++$lines['old'],
882 'newlineno' => ++$lines['new']
883 );
884 }
885 }
886 }
887
888 /**
889 * Returns the amount of change that occured
890 * between two lines
891 *
892 * @access private
893 *
894 * @param string Old line
895 * @param string New line
896 *
897 * @return array Difference of positions
898 */
899 function fetch_diff_extent($old, $new)
900 {
901 $start = 0;
902 $min = min(strlen($old), strlen($new));
903
904 for ($start = 0; $start < $min; $start++)
905 {
906 if ($old{"$start"} != $new{"$start"})
907 {
908 break;
909 }
910 }
911
912 $max = max(strlen($old), strlen($new));
913
914 for ($end = 0; $end < $min; $end++)
915 {
916 $oldpos = strlen($old) - $end;
917 $newpos = strlen($new) - $end;
918
919 if ($old{"$oldpos"} != $new{"$newpos"})
920 {
921 break;
922 }
923 }
924
925 $end--;
926
927 if ($start == 0 AND $end == $max)
928 {
929 return false;
930 }
931
932 return array('start' => $start, 'end' => $end);
933 }
934 }
935
936 /*=====================================================================*\
937 || ###################################################################
938 || # $HeadURL$
939 || # $Id$
940 || ###################################################################
941 \*=====================================================================*/
942 ?>