initialize variable, right, thanks Brion! :)
[mediawiki.git] / maintenance / parserTests.inc
blobb6c083e3ab11bc85ea3cd2a89a4e57c30424a142
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', 'templatelinks', 'externallinks',
339                         'site_stats', 'hitcounter',
340                         'ipblocks', 'image', 'oldimage',
341                         'recentchanges',
342                         'watchlist', 'math', 'searchindex',
343                         'interwiki', 'querycache',
344                         'objectcache', 'job'
345                 );
347                 // FIXME manually adding additional table for the tasks extension
348                 // we probably need a better software wide system to register new
349                 // tables.
350                 global $wgExtensionFunctions;
351                 if( in_array('wfTasksExtension' , $wgExtensionFunctions ) ) {
352                         $tables[] = 'tasks';
353                 }
355                 return $tables;
356         }
358         /**
359          * Set up a temporary set of wiki tables to work with for the tests.
360          * Currently this will only be done once per run, and any changes to
361          * the db will be visible to later tests in the run.
362          *
363          * @access private
364          */
365         function setupDatabase() {
366                 static $setupDB = false;
367                 global $wgDBprefix;
369                 # Make sure we don't mess with the live DB
370                 if (!$setupDB && $wgDBprefix === 'parsertest_') {
371                         # oh teh horror
372                         $GLOBALS['wgLoadBalancer'] = LoadBalancer::newFromParams( $GLOBALS['wgDBservers'] );
373                         $db =& wfGetDB( DB_MASTER );
375                         $tables = $this->listTables();
377                         if (!(strcmp($db->getServerVersion(), '4.1') < 0 and stristr($db->getSoftwareLink(), 'MySQL'))) {
378                                 # Database that supports CREATE TABLE ... LIKE
379                                 global $wgDBtype;
380                                 if( $wgDBtype == 'PostgreSQL' ) {
381                                         $def = 'INCLUDING DEFAULTS';
382                                 } else {
383                                         $def = '';
384                                 }
385                                 foreach ($tables as $tbl) {
386                                         $newTableName = $db->tableName( $tbl );
387                                         $tableName = $this->oldTableNames[$tbl];
388                                         $db->query("CREATE TEMPORARY TABLE $newTableName (LIKE $tableName $def)");
389                                 }
390                         } else {
391                                 # Hack for MySQL versions < 4.1, which don't support
392                                 # "CREATE TABLE ... LIKE". Note that
393                                 # "CREATE TEMPORARY TABLE ... SELECT * FROM ... LIMIT 0"
394                                 # would not create the indexes we need....
395                                 foreach ($tables as $tbl) {
396                                         $res = $db->query("SHOW CREATE TABLE {$this->oldTableNames[$tbl]}");
397                                         $row = $db->fetchRow($res);
398                                         $create = $row[1];
399                                         $create_tmp = preg_replace('/CREATE TABLE `(.*?)`/', 'CREATE TEMPORARY TABLE `'
400                                                 . $wgDBprefix . $tbl .'`', $create);
401                                         if ($create === $create_tmp) {
402                                                 # Couldn't do replacement
403                                                 wfDie("could not create temporary table $tbl");
404                                         }
405                                         $db->query($create_tmp);
406                                 }
408                         }
410                         # Hack: insert a few Wikipedia in-project interwiki prefixes,
411                         # for testing inter-language links
412                         $db->insert( 'interwiki', array(
413                                 array( 'iw_prefix' => 'Wikipedia',
414                                        'iw_url'    => 'http://en.wikipedia.org/wiki/$1',
415                                        'iw_local'  => 0 ),
416                                 array( 'iw_prefix' => 'MeatBall',
417                                        'iw_url'    => 'http://www.usemod.com/cgi-bin/mb.pl?$1',
418                                        'iw_local'  => 0 ),
419                                 array( 'iw_prefix' => 'zh',
420                                        'iw_url'    => 'http://zh.wikipedia.org/wiki/$1',
421                                        'iw_local'  => 1 ),
422                                 array( 'iw_prefix' => 'es',
423                                        'iw_url'    => 'http://es.wikipedia.org/wiki/$1',
424                                        'iw_local'  => 1 ),
425                                 array( 'iw_prefix' => 'fr',
426                                        'iw_url'    => 'http://fr.wikipedia.org/wiki/$1',
427                                        'iw_local'  => 1 ),
428                                 array( 'iw_prefix' => 'ru',
429                                        'iw_url'    => 'http://ru.wikipedia.org/wiki/$1',
430                                        'iw_local'  => 1 ),
431                                 ) );
433                         # Hack: Insert an image to work with
434                         $db->insert( 'image', array(
435                                 'img_name'        => 'Foobar.jpg',
436                                 'img_size'        => 12345,
437                                 'img_description' => 'Some lame file',
438                                 'img_user'        => 1,
439                                 'img_user_text'   => 'WikiSysop',
440                                 'img_timestamp'   => $db->timestamp( '20010115123500' ),
441                                 'img_width'       => 1941,
442                                 'img_height'      => 220,
443                                 'img_bits'        => 24,
444                                 'img_media_type'  => MEDIATYPE_BITMAP,
445                                 'img_major_mime'  => "image",
446                                 'img_minor_mime'  => "jpeg",
447                                 ) );
449                         $setupDB = true;
450                 }
451         }
453         /**
454          * Create a dummy uploads directory which will contain a couple
455          * of files in order to pass existence tests.
456          * @return string The directory
457          * @access private
458          */
459         function setupUploadDir() {
460                 global $IP;
462                 $dir = wfTempDir() . "/mwParser-" . mt_rand() . "-images";
463                 mkdir( $dir );
464                 mkdir( $dir . '/3' );
465                 mkdir( $dir . '/3/3a' );
467                 $img = "$IP/skins/monobook/headbg.jpg";
468                 $h = fopen($img, 'r');
469                 $c = fread($h, filesize($img));
470                 fclose($h);
472                 $f = fopen( $dir . '/3/3a/Foobar.jpg', 'wb' );
473                 fwrite( $f, $c );
474                 fclose( $f );
475                 return $dir;
476         }
478         /**
479          * Restore default values and perform any necessary clean-up
480          * after each test runs.
481          *
482          * @access private
483          */
484         function teardownGlobals() {
485                 foreach( $this->savedGlobals as $var => $val ) {
486                         $GLOBALS[$var] = $val;
487                 }
488                 if( isset( $this->uploadDir ) ) {
489                         $this->teardownUploadDir( $this->uploadDir );
490                         unset( $this->uploadDir );
491                 }
492         }
494         /**
495          * Remove the dummy uploads directory
496          * @access private
497          */
498         function teardownUploadDir( $dir ) {
499                 unlink( "$dir/3/3a/Foobar.jpg" );
500                 rmdir( "$dir/3/3a" );
501                 rmdir( "$dir/3" );
502                 @rmdir( "$dir/thumb/6/65" );
503                 @rmdir( "$dir/thumb/6" );
505                 @unlink( "$dir/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" );
506                 @rmdir( "$dir/thumb/3/3a/Foobar.jpg" );
507                 @rmdir( "$dir/thumb/3/3a" );
508                 @rmdir( "$dir/thumb/3/39" ); # wtf?
509                 @rmdir( "$dir/thumb/3" );
510                 @rmdir( "$dir/thumb" );
511                 rmdir( "$dir" );
512         }
514         /**
515          * "Running test $desc..."
516          * @access private
517          */
518         function showTesting( $desc ) {
519                 print "Running test $desc... ";
520         }
522         /**
523          * Print a happy success message.
524          *
525          * @param string $desc The test name
526          * @return bool
527          * @access private
528          */
529         function showSuccess( $desc ) {
530                 if( !$this->quiet ) {
531                         print $this->termColor( '1;32' ) . 'PASSED' . $this->termReset() . "\n";
532                 }
533                 return true;
534         }
536         /**
537          * Print a failure message and provide some explanatory output
538          * about what went wrong if so configured.
539          *
540          * @param string $desc The test name
541          * @param string $result Expected HTML output
542          * @param string $html Actual HTML output
543          * @return bool
544          * @access private
545          */
546         function showFailure( $desc, $result, $html ) {
547                 if( $this->quiet ) {
548                         # In quiet mode we didn't show the 'Testing' message before the
549                         # test, in case it succeeded. Show it now:
550                         $this->showTesting( $desc );
551                 }
552                 print $this->termColor( '1;31' ) . 'FAILED!' . $this->termReset() . "\n";
553                 if( $this->showDiffs ) {
554                         print $this->quickDiff( $result, $html );
555                         if( !$this->wellFormed( $html ) ) {
556                                 print "XML error: $this->mXmlError\n";
557                         }
558                 }
559                 return false;
560         }
562         /**
563          * Run given strings through a diff and return the (colorized) output.
564          * Requires writable /tmp directory and a 'diff' command in the PATH.
565          *
566          * @param string $input
567          * @param string $output
568          * @param string $inFileTail Tailing for the input file name
569          * @param string $outFileTail Tailing for the output file name
570          * @return string
571          * @access private
572          */
573         function quickDiff( $input, $output, $inFileTail='expected', $outFileTail='actual' ) {
574                 $prefix = wfTempDir() . "/mwParser-" . mt_rand();
576                 $infile = "$prefix-$inFileTail";
577                 $this->dumpToFile( $input, $infile );
579                 $outfile = "$prefix-$outFileTail";
580                 $this->dumpToFile( $output, $outfile );
582                 $diff = `diff -au $infile $outfile`;
583                 unlink( $infile );
584                 unlink( $outfile );
586                 return $this->colorDiff( $diff );
587         }
589         /**
590          * Write the given string to a file, adding a final newline.
591          *
592          * @param string $data
593          * @param string $filename
594          * @access private
595          */
596         function dumpToFile( $data, $filename ) {
597                 $file = fopen( $filename, "wt" );
598                 fwrite( $file, $data . "\n" );
599                 fclose( $file );
600         }
602         /**
603          * Return ANSI terminal escape code for changing text attribs/color,
604          * or empty string if color output is disabled.
605          *
606          * @param string $color Semicolon-separated list of attribute/color codes
607          * @return string
608          * @access private
609          */
610         function termColor( $color ) {
611                 if($this->lightcolor) {
612                         return $this->color ? "\x1b[1;{$color}m" : '';
613                 } else {
614                         return $this->color ? "\x1b[{$color}m" : '';
615                 }
616         }
618         /**
619          * Return ANSI terminal escape code for restoring default text attributes,
620          * or empty string if color output is disabled.
621          *
622          * @return string
623          * @access private
624          */
625         function termReset() {
626                 return $this->color ? "\x1b[0m" : '';
627         }
629         /**
630          * Colorize unified diff output if set for ANSI color output.
631          * Subtractions are colored blue, additions red.
632          *
633          * @param string $text
634          * @return string
635          * @access private
636          */
637         function colorDiff( $text ) {
638                 return preg_replace(
639                         array( '/^(-.*)$/m', '/^(\+.*)$/m' ),
640                         array( $this->termColor( 34 ) . '$1' . $this->termReset(),
641                                $this->termColor( 31 ) . '$1' . $this->termReset() ),
642                         $text );
643         }
645         /**
646          * Insert a temporary test article
647          * @param string $name the title, including any prefix
648          * @param string $text the article text
649          * @param int $line the input line number, for reporting errors
650          * @static
651          * @access private
652          */
653         function addArticle($name, $text, $line) {
654                 $this->setupGlobals();
655                 $title = Title::newFromText( $name );
656                 if ( is_null($title) ) {
657                         wfDie( "invalid title at line $line\n" );
658                 }
660                 $aid = $title->getArticleID( GAID_FOR_UPDATE );
661                 if ($aid != 0) {
662                         wfDie( "duplicate article at line $line\n" );
663                 }
665                 $art = new Article($title);
666                 $art->insertNewArticle($text, '', false, false );
667                 $this->teardownGlobals();
668         }
670         /*
671          * Run the "tidy" command on text if the $wgUseTidy
672          * global is true
673          *
674          * @param string $text the text to tidy
675          * @return string
676          * @static
677          * @access private
678          */
679         function tidy( $text ) {
680                 global $wgUseTidy;
681                 if ($wgUseTidy) {
682                         $text = Parser::tidy($text);
683                 }
684                 return $text;
685         }
687         function wellFormed( $text ) {
688                 $html =
689                         Sanitizer::hackDocType() .
690                         '<html>' .
691                         $text .
692                         '</html>';
694                 $parser = xml_parser_create( "UTF-8" );
696                 # case folding violates XML standard, turn it off
697                 xml_parser_set_option( $parser, XML_OPTION_CASE_FOLDING, false );
699                 if( !xml_parse( $parser, $html, true ) ) {
700                         $err = xml_error_string( xml_get_error_code( $parser ) );
701                         $position = xml_get_current_byte_index( $parser );
702                         $fragment = $this->extractFragment( $html, $position );
703                         $this->mXmlError = "$err at byte $position:\n$fragment";
704                         xml_parser_free( $parser );
705                         return false;
706                 }
707                 xml_parser_free( $parser );
708                 return true;
709         }
711         function extractFragment( $text, $position ) {
712                 $start = max( 0, $position - 10 );
713                 $before = $position - $start;
714                 $fragment = '...' .
715                         $this->termColor( 34 ) .
716                         substr( $text, $start, $before ) .
717                         $this->termColor( 0 ) .
718                         $this->termColor( 31 ) .
719                         $this->termColor( 1 ) .
720                         substr( $text, $position, 1 ) .
721                         $this->termColor( 0 ) .
722                         $this->termColor( 34 ) .
723                         substr( $text, $position + 1, 9 ) .
724                         $this->termColor( 0 ) .
725                         '...';
726                 $display = str_replace( "\n", ' ', $fragment );
727                 $caret = '   ' .
728                         str_repeat( ' ', $before ) .
729                         $this->termColor( 31 ) .
730                         '^' .
731                         $this->termColor( 0 );
732                 return "$display\n$caret";
733         }