Somebody broke parser tests by adding a required table.
[mediawiki.git] / maintenance / parserTests.inc
blob1fe5984903080a556b623c8b15fc3d37fc82d313
1 <?php
2 # Copyright (C) 2004 Brion Vibber <brion@pobox.com>
3 # http://www.mediawiki.org/
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # http://www.gnu.org/copyleft/gpl.html
20 /**
21  * @todo Make this more independent of the configuration (and if possible the database)
22  * @todo document
23  * @package MediaWiki
24  * @subpackage Maintenance
25  */
27 /** */
28 $options = array( 'quick', 'color', 'quiet', 'help' );
29 $optionsWithArgs = array( 'regex' );
31 require_once( 'commandLine.inc' );
32 require_once( "$IP/includes/ObjectCache.php" );
33 require_once( "$IP/includes/BagOStuff.php" );
34 require_once( "$IP/languages/LanguageUtf8.php" );
35 require_once( "$IP/includes/Hooks.php" );
36 require_once( "$IP/maintenance/parserTestsParserHook.php" );
37 require_once( "$IP/maintenance/parserTestsStaticParserHook.php" );
38 require_once( "$IP/maintenance/parserTestsParserTime.php" );
40 /**
41  * @package MediaWiki
42  * @subpackage Maintenance
43  */
44 class ParserTest {
45         /**
46          * boolean $color whereas output should be colorized
47          * @access private
48          */
49         var $color;
51         /**
52          * boolean $lightcolor whereas output should use light colors
53          * @access private
54          */
55         var $lightcolor;
57         /**
58          * Sets terminal colorization and diff/quick modes depending on OS and
59          * command-line options (--color and --quick).
60          *
61          * @access public
62          */
63         function ParserTest() {
64                 global $options;
66                 # Only colorize output if stdout is a terminal.
67                 $this->lightcolor = false;
68                 $this->color = !wfIsWindows() && posix_isatty(1);
70                 if( isset( $options['color'] ) ) {
71                         switch( $options['color'] ) {
72                         case 'no':
73                                 $this->color = false;
74                                 break;
75                         case 'light':
76                                 $this->lightcolor = true;
77                                 # Fall through
78                         case 'yes':
79                         default:
80                                 $this->color = true;
81                                 break;
82                         }
83                 }
85                 $this->showDiffs = !isset( $options['quick'] );
87                 $this->quiet = isset( $options['quiet'] );
89                 if (isset($options['regex'])) {
90                         $this->regex = $options['regex'];
91                 } else {
92                         # Matches anything
93                         $this->regex = '';
94                 }
95         }
97         /**
98          * Remove last character if it is a newline
99          * @access private
100          */
101         function chomp($s) {
102                 if (substr($s, -1) === "\n") {
103                         return substr($s, 0, -1);
104                 }
105                 else {
106                         return $s;
107                 }
108         }
110         /**
111          * Run a series of tests listed in the given text file.
112          * Each test consists of a brief description, wikitext input,
113          * and the expected HTML output.
114          *
115          * Prints status updates on stdout and counts up the total
116          * number and percentage of passed tests.
117          *
118          * @param string $filename
119          * @return bool True if passed all tests, false if any tests failed.
120          * @access public
121          */
122         function runTestsFromFile( $filename ) {
123                 $infile = fopen( $filename, 'rt' );
124                 if( !$infile ) {
125                         wfDie( "Couldn't open parserTests.txt\n" );
126                 }
128                 $data = array();
129                 $section = null;
130                 $success = 0;
131                 $total = 0;
132                 $n = 0;
133                 while( false !== ($line = fgets( $infile ) ) ) {
134                         $n++;
135                         if( preg_match( '/^!!\s*(\w+)/', $line, $matches ) ) {
136                                 $section = strtolower( $matches[1] );
137                                 if( $section == 'endarticle') {
138                                         if( !isset( $data['text'] ) ) {
139                                                 wfDie( "'endarticle' without 'text' at line $n\n" );
140                                         }
141                                         if( !isset( $data['article'] ) ) {
142                                                 wfDie( "'endarticle' without 'article' at line $n\n" );
143                                         }
144                                         $this->addArticle($this->chomp($data['article']), $this->chomp($data['text']), $n);
145                                         $data = array();
146                                         $section = null;
147                                         continue;
148                                 }
149                                 if( $section == 'end' ) {
150                                         if( !isset( $data['test'] ) ) {
151                                                 wfDie( "'end' without 'test' at line $n\n" );
152                                         }
153                                         if( !isset( $data['input'] ) ) {
154                                                 wfDie( "'end' without 'input' at line $n\n" );
155                                         }
156                                         if( !isset( $data['result'] ) ) {
157                                                 wfDie( "'end' without 'result' at line $n\n" );
158                                         }
159                                         if( !isset( $data['options'] ) ) {
160                                                 $data['options'] = '';
161                                         }
162                                         else {
163                                                 $data['options'] = $this->chomp( $data['options'] );
164                                         }
165                                         if (preg_match('/\\bdisabled\\b/i', $data['options'])
166                                                 || !preg_match("/{$this->regex}/i", $data['test'])) {
167                                                 # disabled test
168                                                 $data = array();
169                                                 $section = null;
170                                                 continue;
171                                         }
172                                         if( $this->runTest(
173                                                 $this->chomp( $data['test'] ),
174                                                 $this->chomp( $data['input'] ),
175                                                 $this->chomp( $data['result'] ),
176                                                 $this->chomp( $data['options'] ) ) ) {
177                                                 $success++;
178                                         }
179                                         $total++;
180                                         $data = array();
181                                         $section = null;
182                                         continue;
183                                 }
184                                 if ( isset ($data[$section] ) ) {
185                                         wfDie( "duplicate section '$section' at line $n\n" );
186                                 }
187                                 $data[$section] = '';
188                                 continue;
189                         }
190                         if( $section ) {
191                                 $data[$section] .= $line;
192                         }
193                 }
194                 if( $total > 0 ) {
195                         $ratio = wfPercent( 100 * $success / $total );
196                         print $this->termColor( 1 ) . "\nPassed $success of $total tests ($ratio) ";
197                         if( $success == $total ) {
198                                 print $this->termColor( 32 ) . "PASSED!";
199                         } else {
200                                 print $this->termColor( 31 ) . "FAILED!";
201                         }
202                         print $this->termReset() . "\n";
203                         return ($success == $total);
204                 } else {
205                         wfDie( "No tests found.\n" );
206                 }
207         }
209         /**
210          * Run a given wikitext input through a freshly-constructed wiki parser,
211          * and compare the output against the expected results.
212          * Prints status and explanatory messages to stdout.
213          *
214          * @param string $input Wikitext to try rendering
215          * @param string $result Result to output
216          * @return bool
217          */
218         function runTest( $desc, $input, $result, $opts ) {
219                 if( !$this->quiet ) {
220                         $this->showTesting( $desc );
221                 }
223                 $this->setupGlobals($opts);
225                 $user =& new User();
226                 $options = ParserOptions::newFromUser( $user );
228                 if (preg_match('/\\bmath\\b/i', $opts)) {
229                         # XXX this should probably be done by the ParserOptions
230                         require_once('Math.php');
232                         $options->setUseTex(true);
233                 }
235                 if (preg_match('/title=\[\[(.*)\]\]/', $opts, $m)) {
236                         $titleText = $m[1];
237                 }
238                 else {
239                         $titleText = 'Parser test';
240                 }
242                 $noxml = (bool)preg_match( '~\\b noxml \\b~x', $opts );
244                 $parser =& new Parser();
245                 wfRunHooks( 'ParserTestParser', array( &$parser ) );
246                 
247                 $title =& Title::makeTitle( NS_MAIN, $titleText );
249                 if (preg_match('/\\bpst\\b/i', $opts)) {
250                         $out = $parser->preSaveTransform( $input, $title, $user, $options );
251                 } elseif (preg_match('/\\bmsg\\b/i', $opts)) {
252                         $out = $parser->transformMsg( $input, $options );
253                 } else {
254                         $output = $parser->parse( $input, $title, $options, true, true, 1337 );
255                         $out = $output->getText();
257                         if (preg_match('/\\bill\\b/i', $opts)) {
258                                 $out = $this->tidy( implode( ' ', $output->getLanguageLinks() ) );
259                         } else if (preg_match('/\\bcat\\b/i', $opts)) {
260                                 global $wgOut;
261                                 $wgOut->addCategoryLinks($output->getCategories());
262                                 $out = $this->tidy ( implode( ' ', $wgOut->getCategoryLinks() ) );
263                         }
265                         $result = $this->tidy($result);
266                 }
268                 $this->teardownGlobals();
270                 if( $result === $out && ( $noxml === true || $this->wellFormed( $out ) ) ) {
271                         return $this->showSuccess( $desc );
272                 } else {
273                         return $this->showFailure( $desc, $result, $out );
274                 }
275         }
277         /**
278          * Set up the global variables for a consistent environment for each test.
279          * Ideally this should replace the global configuration entirely.
280          *
281          * @access private
282          */
283         function setupGlobals($opts = '') {
284                 # Save the prefixed / quoted table names for later use when we make the temporaries.
285                 $db =& wfGetDB( DB_READ );
286                 $this->oldTableNames = array();
287                 foreach( $this->listTables() as $table ) {
288                         $this->oldTableNames[$table] = $db->tableName( $table );
289                 }
290                 if( !isset( $this->uploadDir ) ) {
291                         $this->uploadDir = $this->setupUploadDir();
292                 }
294                 $settings = array(
295                         'wgServer' => 'http://localhost',
296                         'wgScript' => '/index.php',
297                         'wgScriptPath' => '/',
298                         'wgArticlePath' => '/wiki/$1',
299                         'wgActionPaths' => array(),
300                         'wgUploadPath' => 'http://example.com/images',
301                         'wgUploadDirectory' => $this->uploadDir,
302                         'wgStyleSheetPath' => '/skins',
303                         'wgSitename' => 'MediaWiki',
304                         'wgServerName' => 'Britney Spears',
305                         'wgLanguageCode' => 'en',
306                         'wgContLanguageCode' => 'en',
307                         'wgDBprefix' => 'parsertest_',
308                         'wgDefaultUserOptions' => array(),
310                         'wgLang' => new LanguageUtf8(),
311                         'wgContLang' => new LanguageUtf8(),
312                         'wgNamespacesWithSubpages' => array( 0 => preg_match('/\\bsubpage\\b/i', $opts)),
313                         'wgMaxTocLevel' => 999,
314                         'wgCapitalLinks' => true,
315                         'wgDefaultUserOptions' => array(),
316                         'wgNoFollowLinks' => true,
317                         'wgThumbnailScriptPath' => false,
318                         'wgUseTeX' => false,
319                         'wgLocaltimezone' => 'UTC',
320                         );
321                 $this->savedGlobals = array();
322                 foreach( $settings as $var => $val ) {
323                         $this->savedGlobals[$var] = $GLOBALS[$var];
324                         $GLOBALS[$var] = $val;
325                 }
326                 $GLOBALS['wgLoadBalancer']->loadMasterPos();
327                 $GLOBALS['wgMessageCache']->initialise( new BagOStuff(), false, 0, $GLOBALS['wgDBname'] );
328                 $this->setupDatabase();
330                 global $wgUser;
331                 $wgUser = new User();
332         }
334         # List of temporary tables to create, without prefix
335         # Some of these probably aren't necessary
336         function listTables() {
337                 $tables = array('user', 'page', 'revision', 'text',
338                         'pagelinks', 'imagelinks', 'categorylinks',
339                         'templatelinks', 'externallinks', 'langlinks',
340                         'site_stats', 'hitcounter',
341                         'ipblocks', 'image', 'oldimage',
342                         'recentchanges',
343                         'watchlist', 'math', 'searchindex',
344                         'interwiki', 'querycache',
345                         'objectcache', 'job'
346                 );
348                 // FIXME manually adding additional table for the tasks extension
349                 // we probably need a better software wide system to register new
350                 // tables.
351                 global $wgExtensionFunctions;
352                 if( in_array('wfTasksExtension' , $wgExtensionFunctions ) ) {
353                         $tables[] = 'tasks';
354                 }
356                 return $tables;
357         }
359         /**
360          * Set up a temporary set of wiki tables to work with for the tests.
361          * Currently this will only be done once per run, and any changes to
362          * the db will be visible to later tests in the run.
363          *
364          * @access private
365          */
366         function setupDatabase() {
367                 static $setupDB = false;
368                 global $wgDBprefix;
370                 # Make sure we don't mess with the live DB
371                 if (!$setupDB && $wgDBprefix === 'parsertest_') {
372                         # oh teh horror
373                         $GLOBALS['wgLoadBalancer'] = LoadBalancer::newFromParams( $GLOBALS['wgDBservers'] );
374                         $db =& wfGetDB( DB_MASTER );
376                         $tables = $this->listTables();
378                         if (!(strcmp($db->getServerVersion(), '4.1') < 0 and stristr($db->getSoftwareLink(), 'MySQL'))) {
379                                 # Database that supports CREATE TABLE ... LIKE
380                                 global $wgDBtype;
381                                 if( $wgDBtype == 'PostgreSQL' ) {
382                                         $def = 'INCLUDING DEFAULTS';
383                                 } else {
384                                         $def = '';
385                                 }
386                                 foreach ($tables as $tbl) {
387                                         $newTableName = $db->tableName( $tbl );
388                                         $tableName = $this->oldTableNames[$tbl];
389                                         $db->query("CREATE TEMPORARY TABLE $newTableName (LIKE $tableName $def)");
390                                 }
391                         } else {
392                                 # Hack for MySQL versions < 4.1, which don't support
393                                 # "CREATE TABLE ... LIKE". Note that
394                                 # "CREATE TEMPORARY TABLE ... SELECT * FROM ... LIMIT 0"
395                                 # would not create the indexes we need....
396                                 foreach ($tables as $tbl) {
397                                         $res = $db->query("SHOW CREATE TABLE {$this->oldTableNames[$tbl]}");
398                                         $row = $db->fetchRow($res);
399                                         $create = $row[1];
400                                         $create_tmp = preg_replace('/CREATE TABLE `(.*?)`/', 'CREATE TEMPORARY TABLE `'
401                                                 . $wgDBprefix . $tbl .'`', $create);
402                                         if ($create === $create_tmp) {
403                                                 # Couldn't do replacement
404                                                 wfDie("could not create temporary table $tbl");
405                                         }
406                                         $db->query($create_tmp);
407                                 }
409                         }
411                         # Hack: insert a few Wikipedia in-project interwiki prefixes,
412                         # for testing inter-language links
413                         $db->insert( 'interwiki', array(
414                                 array( 'iw_prefix' => 'Wikipedia',
415                                        'iw_url'    => 'http://en.wikipedia.org/wiki/$1',
416                                        'iw_local'  => 0 ),
417                                 array( 'iw_prefix' => 'MeatBall',
418                                        'iw_url'    => 'http://www.usemod.com/cgi-bin/mb.pl?$1',
419                                        'iw_local'  => 0 ),
420                                 array( 'iw_prefix' => 'zh',
421                                        'iw_url'    => 'http://zh.wikipedia.org/wiki/$1',
422                                        'iw_local'  => 1 ),
423                                 array( 'iw_prefix' => 'es',
424                                        'iw_url'    => 'http://es.wikipedia.org/wiki/$1',
425                                        'iw_local'  => 1 ),
426                                 array( 'iw_prefix' => 'fr',
427                                        'iw_url'    => 'http://fr.wikipedia.org/wiki/$1',
428                                        'iw_local'  => 1 ),
429                                 array( 'iw_prefix' => 'ru',
430                                        'iw_url'    => 'http://ru.wikipedia.org/wiki/$1',
431                                        'iw_local'  => 1 ),
432                                 ) );
434                         # Hack: Insert an image to work with
435                         $db->insert( 'image', array(
436                                 'img_name'        => 'Foobar.jpg',
437                                 'img_size'        => 12345,
438                                 'img_description' => 'Some lame file',
439                                 'img_user'        => 1,
440                                 'img_user_text'   => 'WikiSysop',
441                                 'img_timestamp'   => $db->timestamp( '20010115123500' ),
442                                 'img_width'       => 1941,
443                                 'img_height'      => 220,
444                                 'img_bits'        => 24,
445                                 'img_media_type'  => MEDIATYPE_BITMAP,
446                                 'img_major_mime'  => "image",
447                                 'img_minor_mime'  => "jpeg",
448                                 ) );
450                         $setupDB = true;
451                 }
452         }
454         /**
455          * Create a dummy uploads directory which will contain a couple
456          * of files in order to pass existence tests.
457          * @return string The directory
458          * @access private
459          */
460         function setupUploadDir() {
461                 global $IP;
463                 $dir = wfTempDir() . "/mwParser-" . mt_rand() . "-images";
464                 mkdir( $dir );
465                 mkdir( $dir . '/3' );
466                 mkdir( $dir . '/3/3a' );
468                 $img = "$IP/skins/monobook/headbg.jpg";
469                 $h = fopen($img, 'r');
470                 $c = fread($h, filesize($img));
471                 fclose($h);
473                 $f = fopen( $dir . '/3/3a/Foobar.jpg', 'wb' );
474                 fwrite( $f, $c );
475                 fclose( $f );
476                 return $dir;
477         }
479         /**
480          * Restore default values and perform any necessary clean-up
481          * after each test runs.
482          *
483          * @access private
484          */
485         function teardownGlobals() {
486                 foreach( $this->savedGlobals as $var => $val ) {
487                         $GLOBALS[$var] = $val;
488                 }
489                 if( isset( $this->uploadDir ) ) {
490                         $this->teardownUploadDir( $this->uploadDir );
491                         unset( $this->uploadDir );
492                 }
493         }
495         /**
496          * Remove the dummy uploads directory
497          * @access private
498          */
499         function teardownUploadDir( $dir ) {
500                 unlink( "$dir/3/3a/Foobar.jpg" );
501                 rmdir( "$dir/3/3a" );
502                 rmdir( "$dir/3" );
503                 @rmdir( "$dir/thumb/6/65" );
504                 @rmdir( "$dir/thumb/6" );
506                 @unlink( "$dir/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" );
507                 @rmdir( "$dir/thumb/3/3a/Foobar.jpg" );
508                 @rmdir( "$dir/thumb/3/3a" );
509                 @rmdir( "$dir/thumb/3/39" ); # wtf?
510                 @rmdir( "$dir/thumb/3" );
511                 @rmdir( "$dir/thumb" );
512                 rmdir( "$dir" );
513         }
515         /**
516          * "Running test $desc..."
517          * @access private
518          */
519         function showTesting( $desc ) {
520                 print "Running test $desc... ";
521         }
523         /**
524          * Print a happy success message.
525          *
526          * @param string $desc The test name
527          * @return bool
528          * @access private
529          */
530         function showSuccess( $desc ) {
531                 if( !$this->quiet ) {
532                         print $this->termColor( '1;32' ) . 'PASSED' . $this->termReset() . "\n";
533                 }
534                 return true;
535         }
537         /**
538          * Print a failure message and provide some explanatory output
539          * about what went wrong if so configured.
540          *
541          * @param string $desc The test name
542          * @param string $result Expected HTML output
543          * @param string $html Actual HTML output
544          * @return bool
545          * @access private
546          */
547         function showFailure( $desc, $result, $html ) {
548                 if( $this->quiet ) {
549                         # In quiet mode we didn't show the 'Testing' message before the
550                         # test, in case it succeeded. Show it now:
551                         $this->showTesting( $desc );
552                 }
553                 print $this->termColor( '1;31' ) . 'FAILED!' . $this->termReset() . "\n";
554                 if( $this->showDiffs ) {
555                         print $this->quickDiff( $result, $html );
556                         if( !$this->wellFormed( $html ) ) {
557                                 print "XML error: $this->mXmlError\n";
558                         }
559                 }
560                 return false;
561         }
563         /**
564          * Run given strings through a diff and return the (colorized) output.
565          * Requires writable /tmp directory and a 'diff' command in the PATH.
566          *
567          * @param string $input
568          * @param string $output
569          * @param string $inFileTail Tailing for the input file name
570          * @param string $outFileTail Tailing for the output file name
571          * @return string
572          * @access private
573          */
574         function quickDiff( $input, $output, $inFileTail='expected', $outFileTail='actual' ) {
575                 $prefix = wfTempDir() . "/mwParser-" . mt_rand();
577                 $infile = "$prefix-$inFileTail";
578                 $this->dumpToFile( $input, $infile );
580                 $outfile = "$prefix-$outFileTail";
581                 $this->dumpToFile( $output, $outfile );
583                 $diff = `diff -au $infile $outfile`;
584                 unlink( $infile );
585                 unlink( $outfile );
587                 return $this->colorDiff( $diff );
588         }
590         /**
591          * Write the given string to a file, adding a final newline.
592          *
593          * @param string $data
594          * @param string $filename
595          * @access private
596          */
597         function dumpToFile( $data, $filename ) {
598                 $file = fopen( $filename, "wt" );
599                 fwrite( $file, $data . "\n" );
600                 fclose( $file );
601         }
603         /**
604          * Return ANSI terminal escape code for changing text attribs/color,
605          * or empty string if color output is disabled.
606          *
607          * @param string $color Semicolon-separated list of attribute/color codes
608          * @return string
609          * @access private
610          */
611         function termColor( $color ) {
612                 if($this->lightcolor) {
613                         return $this->color ? "\x1b[1;{$color}m" : '';
614                 } else {
615                         return $this->color ? "\x1b[{$color}m" : '';
616                 }
617         }
619         /**
620          * Return ANSI terminal escape code for restoring default text attributes,
621          * or empty string if color output is disabled.
622          *
623          * @return string
624          * @access private
625          */
626         function termReset() {
627                 return $this->color ? "\x1b[0m" : '';
628         }
630         /**
631          * Colorize unified diff output if set for ANSI color output.
632          * Subtractions are colored blue, additions red.
633          *
634          * @param string $text
635          * @return string
636          * @access private
637          */
638         function colorDiff( $text ) {
639                 return preg_replace(
640                         array( '/^(-.*)$/m', '/^(\+.*)$/m' ),
641                         array( $this->termColor( 34 ) . '$1' . $this->termReset(),
642                                $this->termColor( 31 ) . '$1' . $this->termReset() ),
643                         $text );
644         }
646         /**
647          * Insert a temporary test article
648          * @param string $name the title, including any prefix
649          * @param string $text the article text
650          * @param int $line the input line number, for reporting errors
651          * @static
652          * @access private
653          */
654         function addArticle($name, $text, $line) {
655                 $this->setupGlobals();
656                 $title = Title::newFromText( $name );
657                 if ( is_null($title) ) {
658                         wfDie( "invalid title at line $line\n" );
659                 }
661                 $aid = $title->getArticleID( GAID_FOR_UPDATE );
662                 if ($aid != 0) {
663                         wfDie( "duplicate article at line $line\n" );
664                 }
666                 $art = new Article($title);
667                 $art->insertNewArticle($text, '', false, false );
668                 $this->teardownGlobals();
669         }
671         /*
672          * Run the "tidy" command on text if the $wgUseTidy
673          * global is true
674          *
675          * @param string $text the text to tidy
676          * @return string
677          * @static
678          * @access private
679          */
680         function tidy( $text ) {
681                 global $wgUseTidy;
682                 if ($wgUseTidy) {
683                         $text = Parser::tidy($text);
684                 }
685                 return $text;
686         }
688         function wellFormed( $text ) {
689                 $html =
690                         Sanitizer::hackDocType() .
691                         '<html>' .
692                         $text .
693                         '</html>';
695                 $parser = xml_parser_create( "UTF-8" );
697                 # case folding violates XML standard, turn it off
698                 xml_parser_set_option( $parser, XML_OPTION_CASE_FOLDING, false );
700                 if( !xml_parse( $parser, $html, true ) ) {
701                         $err = xml_error_string( xml_get_error_code( $parser ) );
702                         $position = xml_get_current_byte_index( $parser );
703                         $fragment = $this->extractFragment( $html, $position );
704                         $this->mXmlError = "$err at byte $position:\n$fragment";
705                         xml_parser_free( $parser );
706                         return false;
707                 }
708                 xml_parser_free( $parser );
709                 return true;
710         }
712         function extractFragment( $text, $position ) {
713                 $start = max( 0, $position - 10 );
714                 $before = $position - $start;
715                 $fragment = '...' .
716                         $this->termColor( 34 ) .
717                         substr( $text, $start, $before ) .
718                         $this->termColor( 0 ) .
719                         $this->termColor( 31 ) .
720                         $this->termColor( 1 ) .
721                         substr( $text, $position, 1 ) .
722                         $this->termColor( 0 ) .
723                         $this->termColor( 34 ) .
724                         substr( $text, $position + 1, 9 ) .
725                         $this->termColor( 0 ) .
726                         '...';
727                 $display = str_replace( "\n", ' ', $fragment );
728                 $caret = '   ' .
729                         str_repeat( ' ', $before ) .
730                         $this->termColor( 31 ) .
731                         '^' .
732                         $this->termColor( 0 );
733                 return "$display\n$caret";
734         }