Fix r53270: drop &returntoquery parameter if empty, and prevent Special:Userlogin...
[lhc/web/wiklou.git] / includes / ConfEditor.php
1 <?php
2
3 /**
4 * This is a state machine style parser with two internal stacks:
5 * * A next state stack, which determines the state the machine will progress to next
6 * * A path stack, which keeps track of the logical location in the file.
7 *
8 * Reference grammar:
9 *
10 * file = T_OPEN_TAG *statement
11 * statement = T_VARIABLE "=" expression ";"
12 * expression = array / scalar / T_VARIABLE
13 * array = T_ARRAY "(" [ element *( "," element ) [ "," ] ] ")"
14 * element = assoc-element / expression
15 * assoc-element = scalar T_DOUBLE_ARROW expression
16 * scalar = T_LNUMBER / T_DNUMBER / T_STRING / T_CONSTANT_ENCAPSED_STRING
17 */
18 class ConfEditor {
19 /** The text to parse */
20 var $text;
21
22 /** The token array from token_get_all() */
23 var $tokens;
24
25 /** The current position in the token array */
26 var $pos;
27
28 /** The current 1-based line number */
29 var $lineNum;
30
31 /** The current 1-based column number */
32 var $colNum;
33
34 /** The current 0-based byte number */
35 var $byteNum;
36
37 /** The current ConfEditorToken object */
38 var $currentToken;
39
40 /** The previous ConfEditorToken object */
41 var $prevToken;
42
43 /**
44 * The state machine stack. This is an array of strings where the topmost
45 * element will be popped off and become the next parser state.
46 */
47 var $stateStack;
48
49
50 /**
51 * The path stack is a stack of associative arrays with the following elements:
52 * name The name of top level of the path
53 * level The level (number of elements) of the path
54 * startByte The byte offset of the start of the path
55 * startToken The token offset of the start
56 * endByte The byte offset of thee
57 * endToken The token offset of the end, plus one
58 * valueStartToken The start token offset of the value part
59 * valueStartByte The start byte offset of the value part
60 * valueEndToken The end token offset of the value part, plus one
61 * valueEndByte The end byte offset of the value part, plus one
62 * nextArrayIndex The next numeric array index at this level
63 * hasComma True if the array element ends with a comma
64 * arrowByte The byte offset of the "=>", or false if there isn't one
65 */
66 var $pathStack;
67
68 /**
69 * The elements of the top of the pathStack for every path encountered, indexed
70 * by slash-separated path.
71 */
72 var $pathInfo;
73
74 /**
75 * Next serial number for whitespace placeholder paths (@extra-N)
76 */
77 var $serial;
78
79 /**
80 * Editor state. This consists of the internal copy/insert operations which
81 * are applied to the source string to obtain the destination string.
82 */
83 var $edits;
84
85 /**
86 * Simple entry point for command-line testing
87 */
88 static function test( $text ) {
89 try {
90 $ce = new self( $text );
91 $ce->parse();
92 } catch ( ConfEditorParseError $e ) {
93 return $e->getMessage() . "\n" . $e->highlight( $text );
94 }
95 return "OK";
96 }
97
98 /**
99 * Construct a new parser
100 */
101 public function __construct( $text ) {
102 $this->text = $text;
103 }
104
105 /**
106 * Edit the text. Returns the edited text.
107 * @param array $ops Array of operations.
108 *
109 * Operations are given as an associative array, with members:
110 * type: One of delete, set, append or insert (required)
111 * path: The path to operate on (required)
112 * key: The array key to insert/append, with PHP quotes
113 * value: The value, with PHP quotes
114 *
115 * delete
116 * Deletes an array element or statement with the specified path.
117 * e.g.
118 * array('type' => 'delete', 'path' => '$foo/bar/baz' )
119 * is equivalent to the runtime PHP code:
120 * unset( $foo['bar']['baz'] );
121 *
122 * set
123 * Sets the value of an array element. If the element doesn't exist, it
124 * is appended to the array. If it does exist, the value is set, with
125 * comments and indenting preserved.
126 *
127 * append
128 * Appends a new element to the end of the array. Adds a trailing comma.
129 * e.g.
130 * array( 'type' => 'append', 'path', '$foo/bar',
131 * 'key' => 'baz', 'value' => "'x'" )
132 * is like the PHP code:
133 * $foo['bar']['baz'] = 'x';
134 *
135 * insert
136 * Insert a new element at the start of the array.
137 *
138 */
139 public function edit( $ops ) {
140 $this->parse();
141
142 $this->edits = array(
143 array( 'copy', 0, strlen( $this->text ) )
144 );
145 foreach ( $ops as $op ) {
146 $type = $op['type'];
147 $path = $op['path'];
148 $value = isset( $op['value'] ) ? $op['value'] : null;
149 $key = isset( $op['key'] ) ? $op['key'] : null;
150
151 switch ( $type ) {
152 case 'delete':
153 list( $start, $end ) = $this->findDeletionRegion( $path );
154 $this->replaceSourceRegion( $start, $end, false );
155 break;
156 case 'set':
157 if ( isset( $this->pathInfo[$path] ) ) {
158 list( $start, $end ) = $this->findValueRegion( $path );
159 $encValue = $value; // var_export( $value, true );
160 $this->replaceSourceRegion( $start, $end, $encValue );
161 break;
162 }
163 // No existing path, fall through to append
164 $slashPos = strrpos( $path, '/' );
165 $key = var_export( substr( $path, $slashPos + 1 ), true );
166 $path = substr( $path, 0, $slashPos );
167 // Fall through
168 case 'append':
169 // Find the last array element
170 $lastEltPath = $this->findLastArrayElement( $path );
171 if ( $lastEltPath === false ) {
172 throw new MWException( "Can't find any element of array \"$path\"" );
173 }
174 $lastEltInfo = $this->pathInfo[$lastEltPath];
175
176 // Has it got a comma already?
177 if ( strpos( $lastEltPath, '@extra' ) === false && !$lastEltInfo['hasComma'] ) {
178 // No comma, insert one after the value region
179 list( $start, $end ) = $this->findValueRegion( $lastEltPath );
180 $this->replaceSourceRegion( $end - 1, $end - 1, ',' );
181 }
182
183 // Make the text to insert
184 list( $start, $end ) = $this->findDeletionRegion( $lastEltPath );
185
186 if ( $key === null ) {
187 list( $indent, $arrowIndent ) = $this->getIndent( $start );
188 $textToInsert = "$indent$value,";
189 } else {
190 list( $indent, $arrowIndent ) =
191 $this->getIndent( $start, $key, $lastEltInfo['arrowByte'] );
192 $textToInsert = "$indent$key$arrowIndent=> $value,";
193 }
194 $textToInsert .= ( $indent === false ? ' ' : "\n" );
195
196 // Insert the item
197 $this->replaceSourceRegion( $end, $end, $textToInsert );
198 break;
199 case 'insert':
200 // Find first array element
201 $firstEltPath = $this->findFirstArrayElement( $path );
202 if ( $firstEltPath === false ) {
203 throw new MWException( "Can't find array element of \"$path\"" );
204 }
205 list( $start, $end ) = $this->findDeletionRegion( $firstEltPath );
206 $info = $this->pathInfo[$firstEltPath];
207
208 // Make the text to insert
209 if ( $key === null ) {
210 list( $indent, $arrowIndent ) = $this->getIndent( $start );
211 $textToInsert = "$indent$value,";
212 } else {
213 list( $indent, $arrowIndent ) =
214 $this->getIndent( $start, $key, $info['arrowByte'] );
215 $textToInsert = "$indent$key$arrowIndent=> $value,";
216 }
217 $textToInsert .= ( $indent === false ? ' ' : "\n" );
218
219 // Insert the item
220 $this->replaceSourceRegion( $start, $start, $textToInsert );
221 break;
222 default:
223 throw new MWException( "Unrecognised operation: \"$type\"" );
224 }
225 }
226
227 // Do the edits
228 $out = '';
229 foreach ( $this->edits as $edit ) {
230 if ( $edit[0] == 'copy' ) {
231 $out .= substr( $this->text, $edit[1], $edit[2] - $edit[1] );
232 } else { // if ( $edit[0] == 'insert' )
233 $out .= $edit[1];
234 }
235 }
236
237 // Do a second parse as a sanity check
238 $this->text = $out;
239 try {
240 $this->parse();
241 } catch ( ConfEditorParseError $e ) {
242 throw new MWException(
243 "Sorry, ConfEditor broke the file during editing and it won't parse anymore: " .
244 $e->getMessage() );
245 }
246 return $out;
247 }
248
249 /**
250 * Get the variables defined in the text
251 * @return array( varname => value )
252 */
253 function getVars() {
254 $vars = array();
255 $this->parse();
256 foreach( $this->pathInfo as $path => $data ) {
257 if ( $path[0] != '$' )
258 continue;
259 $trimmedPath = substr( $path, 1 );
260 $name = $data['name'];
261 if ( $name[0] == '@' )
262 continue;
263 if ( $name[0] == '$' )
264 $name = substr( $name, 1 );
265 $parentPath = substr( $trimmedPath, 0,
266 strlen( $trimmedPath ) - strlen( $name ) );
267 if( substr( $parentPath, -1 ) == '/' )
268 $parentPath = substr( $parentPath, 0, -1 );
269
270 $value = substr( $this->text, $data['valueStartByte'],
271 $data['valueEndByte'] - $data['valueStartByte']
272 );
273 $this->setVar( $vars, $parentPath, $name,
274 $this->parseScalar( $value ) );
275 }
276 return $vars;
277 }
278
279 /**
280 * Set a value in an array, unless it's set already. For instance,
281 * setVar( $arr, 'foo/bar', 'baz', 3 ); will set
282 * $arr['foo']['bar']['baz'] = 3;
283 * @param $array array
284 * @param $path string slash-delimited path
285 * @param $key mixed Key
286 * @param $value mixed Value
287 */
288 function setVar( &$array, $path, $key, $value ) {
289 $pathArr = explode( '/', $path );
290 $target =& $array;
291 if ( $path !== '' ) {
292 foreach ( $pathArr as $p ) {
293 if( !isset( $target[$p] ) )
294 $target[$p] = array();
295 $target =& $target[$p];
296 }
297 }
298 if ( !isset( $target[$key] ) )
299 $target[$key] = $value;
300 }
301
302 /**
303 * Parse a scalar value in PHP
304 * @return mixed Parsed value
305 */
306 function parseScalar( $str ) {
307 if ( $str !== '' && $str[0] == '\'' )
308 // Single-quoted string
309 return strtr( substr( $str, 1, -1 ),
310 array( '\\\'' => '\'', '\\\\' => '\\' ) );
311 if ( $str !== '' && @$str[0] == '"' )
312 // Double-quoted string
313 return strtr( stripcslashes( substr( $str, 1, -1 ) ),
314 array( '\'' => '\\\'' ) );
315 if ( substr( $str, 0, 4 ) == 'true' )
316 return true;
317 if ( substr( $str, 0, 5 ) == 'false' )
318 return false;
319 if ( substr( $str, 0, 4 ) == 'null' )
320 return null;
321 // Must be some kind of numeric value, so let PHP's weak typing
322 // be useful for a change
323 return $str;
324 }
325
326 /**
327 * Replace the byte offset region of the source with $newText.
328 * Works by adding elements to the $this->edits array.
329 */
330 function replaceSourceRegion( $start, $end, $newText = false ) {
331 // Split all copy operations with a source corresponding to the region
332 // in question.
333 $newEdits = array();
334 foreach ( $this->edits as $i => $edit ) {
335 if ( $edit[0] !== 'copy' ) {
336 $newEdits[] = $edit;
337 continue;
338 }
339 $copyStart = $edit[1];
340 $copyEnd = $edit[2];
341 if ( $start >= $copyEnd || $end <= $copyStart ) {
342 // Outside this region
343 $newEdits[] = $edit;
344 continue;
345 }
346 if ( ( $start < $copyStart && $end > $copyStart )
347 || ( $start < $copyEnd && $end > $copyEnd )
348 ) {
349 throw new MWException( "Overlapping regions found, can't do the edit" );
350 }
351 // Split the copy
352 $newEdits[] = array( 'copy', $copyStart, $start );
353 if ( $newText !== false ) {
354 $newEdits[] = array( 'insert', $newText );
355 }
356 $newEdits[] = array( 'copy', $end, $copyEnd );
357 }
358 $this->edits = $newEdits;
359 }
360
361 /**
362 * Finds the source byte region which you would want to delete, if $pathName
363 * was to be deleted. Includes the leading spaces and tabs, the trailing line
364 * break, and any comments in between.
365 */
366 function findDeletionRegion( $pathName ) {
367 if ( !isset( $this->pathInfo[$pathName] ) ) {
368 throw new MWException( "Can't find path \"$pathName\"" );
369 }
370 $path = $this->pathInfo[$pathName];
371 // Find the start
372 $this->firstToken();
373 while ( $this->pos != $path['startToken'] ) {
374 $this->nextToken();
375 }
376 $regionStart = $path['startByte'];
377 for ( $offset = -1; $offset >= -$this->pos; $offset-- ) {
378 $token = $this->getTokenAhead( $offset );
379 if ( !$token->isSkip() ) {
380 // If there is other content on the same line, don't move the start point
381 // back, because that will cause the regions to overlap.
382 $regionStart = $path['startByte'];
383 break;
384 }
385 $lfPos = strrpos( $token->text, "\n" );
386 if ( $lfPos === false ) {
387 $regionStart -= strlen( $token->text );
388 } else {
389 // The line start does not include the LF
390 $regionStart -= strlen( $token->text ) - $lfPos - 1;
391 break;
392 }
393 }
394 // Find the end
395 while ( $this->pos != $path['endToken'] ) {
396 $this->nextToken();
397 }
398 $regionEnd = $path['endByte']; // past the end
399 for ( $offset = 0; $offset < count( $this->tokens ) - $this->pos; $offset++ ) {
400 $token = $this->getTokenAhead( $offset );
401 if ( !$token->isSkip() ) {
402 break;
403 }
404 $lfPos = strpos( $token->text, "\n" );
405 if ( $lfPos === false ) {
406 $regionEnd += strlen( $token->text );
407 } else {
408 // This should point past the LF
409 $regionEnd += $lfPos + 1;
410 break;
411 }
412 }
413 return array( $regionStart, $regionEnd );
414 }
415
416 /**
417 * Find the byte region in the source corresponding to the value part.
418 * This includes the quotes, but does not include the trailing comma
419 * or semicolon.
420 *
421 * The end position is the past-the-end (end + 1) value as per convention.
422 */
423 function findValueRegion( $pathName ) {
424 if ( !isset( $this->pathInfo[$pathName] ) ) {
425 throw new MWEXception( "Can't find path \"$pathName\"" );
426 }
427 $path = $this->pathInfo[$pathName];
428 if ( $path['valueStartByte'] === false || $path['valueEndByte'] === false ) {
429 throw new MWException( "Can't find value region for path \"$pathName\"" );
430 }
431 return array( $path['valueStartByte'], $path['valueEndByte'] );
432 }
433
434 /**
435 * Find the path name of the last element in the array.
436 * If the array is empty, this will return the @extra interstitial element.
437 * If the specified path is not found or is not an array, it will return false.
438 */
439 function findLastArrayElement( $path ) {
440 // Try for a real element
441 $lastEltPath = false;
442 foreach ( $this->pathInfo as $candidatePath => $info ) {
443 $part1 = substr( $candidatePath, 0, strlen( $path ) + 1 );
444 $part2 = substr( $candidatePath, strlen( $path ) + 1, 1 );
445 if ( $part2 == '@' ) {
446 // Do nothing
447 } elseif ( $part1 == "$path/" ) {
448 $lastEltPath = $candidatePath;
449 } elseif ( $lastEltPath !== false ) {
450 break;
451 }
452 }
453 if ( $lastEltPath !== false ) {
454 return $lastEltPath;
455 }
456
457 // Try for an interstitial element
458 $extraPath = false;
459 foreach ( $this->pathInfo as $candidatePath => $info ) {
460 $part1 = substr( $candidatePath, 0, strlen( $path ) + 1 );
461 if ( $part1 == "$path/" ) {
462 $extraPath = $candidatePath;
463 } elseif ( $extraPath !== false ) {
464 break;
465 }
466 }
467 return $extraPath;
468 }
469
470 /*
471 * Find the path name of first element in the array.
472 * If the array is empty, this will return the @extra interstitial element.
473 * If the specified path is not found or is not an array, it will return false.
474 */
475 function findFirstArrayElement( $path ) {
476 // Try for an ordinary element
477 foreach ( $this->pathInfo as $candidatePath => $info ) {
478 $part1 = substr( $candidatePath, 0, strlen( $path ) + 1 );
479 $part2 = substr( $candidatePath, strlen( $path ) + 1, 1 );
480 if ( $part1 == "$path/" && $part2 != '@' ) {
481 return $candidatePath;
482 }
483 }
484
485 // Try for an interstitial element
486 foreach ( $this->pathInfo as $candidatePath => $info ) {
487 $part1 = substr( $candidatePath, 0, strlen( $path ) + 1 );
488 if ( $part1 == "$path/" ) {
489 return $candidatePath;
490 }
491 }
492 return false;
493 }
494
495 /**
496 * Get the indent string which sits after a given start position.
497 * Returns false if the position is not at the start of the line.
498 */
499 function getIndent( $pos, $key = false, $arrowPos = false ) {
500 $arrowIndent = ' ';
501 if ( $pos == 0 || $this->text[$pos-1] == "\n" ) {
502 $indentLength = strspn( $this->text, " \t", $pos );
503 $indent = substr( $this->text, $pos, $indentLength );
504 } else {
505 $indent = false;
506 }
507 if ( $indent !== false && $arrowPos !== false ) {
508 $textToInsert = "$indent$key ";
509 $arrowIndentLength = $arrowPos - $pos - $indentLength - strlen( $key );
510 if ( $arrowIndentLength > 0 ) {
511 $arrowIndent = str_repeat( ' ', $arrowIndentLength );
512 }
513 }
514 return array( $indent, $arrowIndent );
515 }
516
517 /**
518 * Run the parser on the text. Throws an exception if the string does not
519 * match our defined subset of PHP syntax.
520 */
521 public function parse() {
522 $this->initParse();
523 $this->pushState( 'file' );
524 $this->pushPath( '@extra-' . ($this->serial++) );
525 $token = $this->firstToken();
526
527 while ( !$token->isEnd() ) {
528 $state = $this->popState();
529 if ( !$state ) {
530 $this->error( 'internal error: empty state stack' );
531 }
532
533 switch ( $state ) {
534 case 'file':
535 $token = $this->expect( T_OPEN_TAG );
536 $token = $this->skipSpace();
537 if ( $token->isEnd() ) {
538 break 2;
539 }
540 $this->pushState( 'statement', 'file 2' );
541 break;
542 case 'file 2':
543 $token = $this->skipSpace();
544 if ( $token->isEnd() ) {
545 break 2;
546 }
547 $this->pushState( 'statement', 'file 2' );
548 break;
549 case 'statement':
550 $token = $this->skipSpace();
551 if ( !$this->validatePath( $token->text ) ) {
552 $this->error( "Invalid variable name \"{$token->text}\"" );
553 }
554 $this->nextPath( $token->text );
555 $this->expect( T_VARIABLE );
556 $this->skipSpace();
557 $arrayAssign = false;
558 if ( $this->currentToken()->type == '[' ) {
559 $this->nextToken();
560 $token = $this->skipSpace();
561 if ( !$token->isScalar() ) {
562 $this->error( "expected a string or number for the array key" );
563 }
564 if ( $token->type == T_CONSTANT_ENCAPSED_STRING ) {
565 $text = $this->parseScalar( $token->text );
566 } else {
567 $text = $token->text;
568 }
569 if ( !$this->validatePath( $text ) ) {
570 $this->error( "Invalid associative array name \"$text\"" );
571 }
572 $this->pushPath( $text );
573 $this->nextToken();
574 $this->skipSpace();
575 $this->expect( ']' );
576 $this->skipSpace();
577 $arrayAssign = true;
578 }
579 $this->expect( '=' );
580 $this->skipSpace();
581 $this->startPathValue();
582 if ( $arrayAssign )
583 $this->pushState( 'expression', 'array assign end' );
584 else
585 $this->pushState( 'expression', 'statement end' );
586 break;
587 case 'array assign end':
588 case 'statement end':
589 $this->endPathValue();
590 if ( $state == 'array assign end' )
591 $this->popPath();
592 $this->skipSpace();
593 $this->expect( ';' );
594 $this->nextPath( '@extra-' . ($this->serial++) );
595 break;
596 case 'expression':
597 $token = $this->skipSpace();
598 if ( $token->type == T_ARRAY ) {
599 $this->pushState( 'array' );
600 } elseif ( $token->isScalar() ) {
601 $this->nextToken();
602 } elseif ( $token->type == T_VARIABLE ) {
603 $this->nextToken();
604 } else {
605 $this->error( "expected simple expression" );
606 }
607 break;
608 case 'array':
609 $this->skipSpace();
610 $this->expect( T_ARRAY );
611 $this->skipSpace();
612 $this->expect( '(' );
613 $this->skipSpace();
614 $this->pushPath( '@extra-' . ($this->serial++) );
615 if ( $this->isAhead( ')' ) ) {
616 // Empty array
617 $this->pushState( 'array end' );
618 } else {
619 $this->pushState( 'element', 'array end' );
620 }
621 break;
622 case 'array end':
623 $this->skipSpace();
624 $this->popPath();
625 $this->expect( ')' );
626 break;
627 case 'element':
628 $token = $this->skipSpace();
629 // Look ahead to find the double arrow
630 if ( $token->isScalar() && $this->isAhead( T_DOUBLE_ARROW, 1 ) ) {
631 // Found associative element
632 $this->pushState( 'assoc-element', 'element end' );
633 } else {
634 // Not associative
635 $this->nextPath( '@next' );
636 $this->startPathValue();
637 $this->pushState( 'expression', 'element end' );
638 }
639 break;
640 case 'element end':
641 $token = $this->skipSpace();
642 if ( $token->type == ',' ) {
643 $this->endPathValue();
644 $this->markComma();
645 $this->nextToken();
646 $this->nextPath( '@extra-' . ($this->serial++) );
647 // Look ahead to find ending bracket
648 if ( $this->isAhead( ")" ) ) {
649 // Found ending bracket, no continuation
650 $this->skipSpace();
651 } else {
652 // No ending bracket, continue to next element
653 $this->pushState( 'element' );
654 }
655 } elseif ( $token->type == ')' ) {
656 // End array
657 $this->endPathValue();
658 } else {
659 $this->error( "expected the next array element or the end of the array" );
660 }
661 break;
662 case 'assoc-element':
663 $token = $this->skipSpace();
664 if ( !$token->isScalar() ) {
665 $this->error( "expected a string or number for the array key" );
666 }
667 if ( $token->type == T_CONSTANT_ENCAPSED_STRING ) {
668 $text = $this->parseScalar( $token->text );
669 } else {
670 $text = $token->text;
671 }
672 if ( !$this->validatePath( $text ) ) {
673 $this->error( "Invalid associative array name \"$text\"" );
674 }
675 $this->nextPath( $text );
676 $this->nextToken();
677 $this->skipSpace();
678 $this->markArrow();
679 $this->expect( T_DOUBLE_ARROW );
680 $this->skipSpace();
681 $this->startPathValue();
682 $this->pushState( 'expression' );
683 break;
684 }
685 }
686 if ( count( $this->stateStack ) ) {
687 $this->error( 'unexpected end of file' );
688 }
689 $this->popPath();
690 }
691
692 /**
693 * Initialise a parse.
694 */
695 protected function initParse() {
696 $this->tokens = token_get_all( $this->text );
697 $this->stateStack = array();
698 $this->pathStack = array();
699 $this->firstToken();
700 $this->pathInfo = array();
701 $this->serial = 1;
702 }
703
704 /**
705 * Set the parse position. Do not call this except from firstToken() and
706 * nextToken(), there is more to update than just the position.
707 */
708 protected function setPos( $pos ) {
709 $this->pos = $pos;
710 if ( $this->pos >= count( $this->tokens ) ) {
711 $this->currentToken = ConfEditorToken::newEnd();
712 } else {
713 $this->currentToken = $this->newTokenObj( $this->tokens[$this->pos] );
714 }
715 return $this->currentToken;
716 }
717
718 /**
719 * Create a ConfEditorToken from an element of token_get_all()
720 */
721 function newTokenObj( $internalToken ) {
722 if ( is_array( $internalToken ) ) {
723 return new ConfEditorToken( $internalToken[0], $internalToken[1] );
724 } else {
725 return new ConfEditorToken( $internalToken, $internalToken );
726 }
727 }
728
729 /**
730 * Reset the parse position
731 */
732 function firstToken() {
733 $this->setPos( 0 );
734 $this->prevToken = ConfEditorToken::newEnd();
735 $this->lineNum = 1;
736 $this->colNum = 1;
737 $this->byteNum = 0;
738 return $this->currentToken;
739 }
740
741 /**
742 * Get the current token
743 */
744 function currentToken() {
745 return $this->currentToken;
746 }
747
748 /**
749 * Advance the current position and return the resulting next token
750 */
751 function nextToken() {
752 if ( $this->currentToken ) {
753 $text = $this->currentToken->text;
754 $lfCount = substr_count( $text, "\n" );
755 if ( $lfCount ) {
756 $this->lineNum += $lfCount;
757 $this->colNum = strlen( $text ) - strrpos( $text, "\n" );
758 } else {
759 $this->colNum += strlen( $text );
760 }
761 $this->byteNum += strlen( $text );
762 }
763 $this->prevToken = $this->currentToken;
764 $this->setPos( $this->pos + 1 );
765 return $this->currentToken;
766 }
767
768 /**
769 * Get the token $offset steps ahead of the current position.
770 * $offset may be negative, to get tokens behind the current position.
771 */
772 function getTokenAhead( $offset ) {
773 $pos = $this->pos + $offset;
774 if ( $pos >= count( $this->tokens ) || $pos < 0 ) {
775 return ConfEditorToken::newEnd();
776 } else {
777 return $this->newTokenObj( $this->tokens[$pos] );
778 }
779 }
780
781 /**
782 * Advances the current position past any whitespace or comments
783 */
784 function skipSpace() {
785 while ( $this->currentToken && $this->currentToken->isSkip() ) {
786 $this->nextToken();
787 }
788 return $this->currentToken;
789 }
790
791 /**
792 * Throws an error if the current token is not of the given type, and
793 * then advances to the next position.
794 */
795 function expect( $type ) {
796 if ( $this->currentToken && $this->currentToken->type == $type ) {
797 return $this->nextToken();
798 } else {
799 $this->error( "expected " . $this->getTypeName( $type ) .
800 ", got " . $this->getTypeName( $this->currentToken->type ) );
801 }
802 }
803
804 /**
805 * Push a state or two on to the state stack.
806 */
807 function pushState( $nextState, $stateAfterThat = null ) {
808 if ( $stateAfterThat !== null ) {
809 $this->stateStack[] = $stateAfterThat;
810 }
811 $this->stateStack[] = $nextState;
812 }
813
814 /**
815 * Pop a state from the state stack.
816 */
817 function popState() {
818 return array_pop( $this->stateStack );
819 }
820
821 /**
822 * Returns true if the user input path is valid.
823 * This exists to allow "/" and "@" to be reserved for string path keys
824 */
825 function validatePath( $path ) {
826 return strpos( $path, '/' ) === false && substr( $path, 0, 1 ) != '@';
827 }
828
829 /**
830 * Internal function to update some things at the end of a path region. Do
831 * not call except from popPath() or nextPath().
832 */
833 function endPath() {
834 $i = count( $this->pathStack ) - 1;
835 $key = '';
836 foreach ( $this->pathStack as $pathInfo ) {
837 if ( $key !== '' ) {
838 $key .= '/';
839 }
840 $key .= $pathInfo['name'];
841 }
842 $pathInfo['endByte'] = $this->byteNum;
843 $pathInfo['endToken'] = $this->pos;
844 $this->pathInfo[$key] = $pathInfo;
845 }
846
847 /**
848 * Go up to a new path level, for example at the start of an array.
849 */
850 function pushPath( $path ) {
851 $this->pathStack[] = array(
852 'name' => $path,
853 'level' => count( $this->pathStack ) + 1,
854 'startByte' => $this->byteNum,
855 'startToken' => $this->pos,
856 'valueStartToken' => false,
857 'valueStartByte' => false,
858 'valueEndToken' => false,
859 'valueEndByte' => false,
860 'nextArrayIndex' => 0,
861 'hasComma' => false,
862 'arrowByte' => false
863 );
864 }
865
866 /**
867 * Go down a path level, for example at the end of an array.
868 */
869 function popPath() {
870 $this->endPath();
871 array_pop( $this->pathStack );
872 }
873
874 /**
875 * Go to the next path on the same level. This ends the current path and
876 * starts a new one. If $path is @next, the new path is set to the next
877 * numeric array element.
878 */
879 function nextPath( $path ) {
880 $this->endPath();
881 $i = count( $this->pathStack ) - 1;
882 if ( $path == '@next' ) {
883 $nextArrayIndex =& $this->pathStack[$i]['nextArrayIndex'];
884 $this->pathStack[$i]['name'] = $nextArrayIndex;
885 $nextArrayIndex++;
886 } else {
887 $this->pathStack[$i]['name'] = $path;
888 }
889 $this->pathStack[$i] =
890 array(
891 'startByte' => $this->byteNum,
892 'startToken' => $this->pos,
893 'valueStartToken' => false,
894 'valueStartByte' => false,
895 'valueEndToken' => false,
896 'valueEndByte' => false,
897 'hasComma' => false,
898 'arrowByte' => false,
899 ) + $this->pathStack[$i];
900 }
901
902 /**
903 * Mark the start of the value part of a path.
904 */
905 function startPathValue() {
906 $path =& $this->pathStack[count( $this->pathStack ) - 1];
907 $path['valueStartToken'] = $this->pos;
908 $path['valueStartByte'] = $this->byteNum;
909 }
910
911 /**
912 * Mark the end of the value part of a path.
913 */
914 function endPathValue() {
915 $path =& $this->pathStack[count( $this->pathStack ) - 1];
916 $path['valueEndToken'] = $this->pos;
917 $path['valueEndByte'] = $this->byteNum;
918 }
919
920 /**
921 * Mark the comma separator in an array element
922 */
923 function markComma() {
924 $path =& $this->pathStack[count( $this->pathStack ) - 1];
925 $path['hasComma'] = true;
926 }
927
928 /**
929 * Mark the arrow separator in an associative array element
930 */
931 function markArrow() {
932 $path =& $this->pathStack[count( $this->pathStack ) - 1];
933 $path['arrowByte'] = $this->byteNum;
934 }
935
936 /**
937 * Generate a parse error
938 */
939 function error( $msg ) {
940 throw new ConfEditorParseError( $this, $msg );
941 }
942
943 /**
944 * Get a readable name for the given token type.
945 */
946 function getTypeName( $type ) {
947 if ( is_int( $type ) ) {
948 return token_name( $type );
949 } else {
950 return "\"$type\"";
951 }
952 }
953
954 /**
955 * Looks ahead to see if the given type is the next token type, starting
956 * from the current position plus the given offset. Skips any intervening
957 * whitespace.
958 */
959 function isAhead( $type, $offset = 0 ) {
960 $ahead = $offset;
961 $token = $this->getTokenAhead( $offset );
962 while ( !$token->isEnd() ) {
963 if ( $token->isSkip() ) {
964 $ahead++;
965 $token = $this->getTokenAhead( $ahead );
966 continue;
967 } elseif ( $token->type == $type ) {
968 // Found the type
969 return true;
970 } else {
971 // Not found
972 return false;
973 }
974 }
975 return false;
976 }
977
978 /**
979 * Get the previous token object
980 */
981 function prevToken() {
982 return $this->prevToken;
983 }
984
985 /**
986 * Echo a reasonably readable representation of the tokenizer array.
987 */
988 function dumpTokens() {
989 $out = '';
990 foreach ( $this->tokens as $token ) {
991 $obj = $this->newTokenObj( $token );
992 $out .= sprintf( "%-28s %s\n",
993 $this->getTypeName( $obj->type ),
994 addcslashes( $obj->text, "\0..\37" ) );
995 }
996 echo "<pre>" . htmlspecialchars( $out ) . "</pre>";
997 }
998 }
999
1000 /**
1001 * Exception class for parse errors
1002 */
1003 class ConfEditorParseError extends MWException {
1004 var $lineNum, $colNum;
1005 function __construct( $editor, $msg ) {
1006 $this->lineNum = $editor->lineNum;
1007 $this->colNum = $editor->colNum;
1008 parent::__construct( "Parse error on line {$editor->lineNum} " .
1009 "col {$editor->colNum}: $msg" );
1010 }
1011
1012 function highlight( $text ) {
1013 $lines = StringUtils::explode( "\n", $text );
1014 foreach ( $lines as $lineNum => $line ) {
1015 if ( $lineNum == $this->lineNum - 1 ) {
1016 return "$line\n" .str_repeat( ' ', $this->colNum - 1 ) . "^\n";
1017 }
1018 }
1019 }
1020
1021 }
1022
1023 /**
1024 * Class to wrap a token from the tokenizer.
1025 */
1026 class ConfEditorToken {
1027 var $type, $text;
1028
1029 static $scalarTypes = array( T_LNUMBER, T_DNUMBER, T_STRING, T_CONSTANT_ENCAPSED_STRING );
1030 static $skipTypes = array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT );
1031
1032 static function newEnd() {
1033 return new self( 'END', '' );
1034 }
1035
1036 function __construct( $type, $text ) {
1037 $this->type = $type;
1038 $this->text = $text;
1039 }
1040
1041 function isSkip() {
1042 return in_array( $this->type, self::$skipTypes );
1043 }
1044
1045 function isScalar() {
1046 return in_array( $this->type, self::$scalarTypes );
1047 }
1048
1049 function isEnd() {
1050 return $this->type == 'END';
1051 }
1052 }
1053