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