]> git.donarmstrong.com Git - roundcube.git/blob - plugins/managesieve/lib/rcube_sieve_script.php
Imported Upstream version 0.7
[roundcube.git] / plugins / managesieve / lib / rcube_sieve_script.php
1 <?php
2
3 /**
4  *  Class for operations on Sieve scripts
5  *
6  * Copyright (C) 2008-2011, The Roundcube Dev Team
7  * Copyright (C) 2011, Kolab Systems AG
8  *
9  * This program is free software; you can redistribute it and/or modify
10  * it under the terms of the GNU General Public License version 2
11  * as published by the Free Software Foundation.
12  *
13  * This program is distributed in the hope that it will be useful,
14  * but WITHOUT ANY WARRANTY; without even the implied warranty of
15  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16  * GNU General Public License for more details.
17  *
18  * You should have received a copy of the GNU General Public License along
19  * with this program; if not, write to the Free Software Foundation, Inc.,
20  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21  *
22  * $Id: rcube_sieve_script.php 5452 2011-11-18 14:44:48Z alec $
23  *
24  */
25
26 class rcube_sieve_script
27 {
28     public $content = array();      // script rules array
29
30     private $vars = array();        // "global" variables
31     private $prefix = '';           // script header (comments)
32     private $supported = array(     // Sieve extensions supported by class
33         'fileinto',                 // RFC5228
34         'envelope',                 // RFC5228
35         'reject',                   // RFC5429
36         'ereject',                  // RFC5429
37         'copy',                     // RFC3894
38         'vacation',                 // RFC5230
39         'relational',               // RFC3431
40         'regex',                    // draft-ietf-sieve-regex-01
41         'imapflags',                // draft-melnikov-sieve-imapflags-06
42         'imap4flags',               // RFC5232
43         'include',                  // draft-ietf-sieve-include-12
44         'variables',                // RFC5229
45         'body',                     // RFC5173
46         'subaddress',               // RFC5233
47         // @TODO: enotify/notify, spamtest+virustest, mailbox, date
48     );
49
50     /**
51      * Object constructor
52      *
53      * @param  string  Script's text content
54      * @param  array   List of capabilities supported by server
55      */
56     public function __construct($script, $capabilities=array())
57     {
58         $capabilities = array_map('strtolower', (array) $capabilities);
59
60         // disable features by server capabilities
61         if (!empty($capabilities)) {
62             foreach ($this->supported as $idx => $ext) {
63                 if (!in_array($ext, $capabilities)) {
64                     unset($this->supported[$idx]);
65                 }
66             }
67         }
68
69         // Parse text content of the script
70         $this->_parse_text($script);
71     }
72
73     /**
74      * Adds rule to the script (at the end)
75      *
76      * @param string Rule name
77      * @param array  Rule content (as array)
78      *
79      * @return int The index of the new rule
80      */
81     public function add_rule($content)
82     {
83         // TODO: check this->supported
84         array_push($this->content, $content);
85         return sizeof($this->content)-1;
86     }
87
88     public function delete_rule($index)
89     {
90         if(isset($this->content[$index])) {
91             unset($this->content[$index]);
92             return true;
93         }
94         return false;
95     }
96
97     public function size()
98     {
99         return sizeof($this->content);
100     }
101
102     public function update_rule($index, $content)
103     {
104         // TODO: check this->supported
105         if ($this->content[$index]) {
106             $this->content[$index] = $content;
107             return $index;
108         }
109         return false;
110     }
111
112     /**
113      * Sets "global" variable
114      *
115      * @param string $name  Variable name
116      * @param string $value Variable value
117      * @param array  $mods  Variable modifiers
118      */
119     public function set_var($name, $value, $mods = array())
120     {
121         // Check if variable exists
122         for ($i=0, $len=count($this->vars); $i<$len; $i++) {
123             if ($this->vars[$i]['name'] == $name) {
124                 break;
125             }
126         }
127
128         $var = array_merge($mods, array('name' => $name, 'value' => $value));
129         $this->vars[$i] = $var;
130     }
131
132     /**
133      * Unsets "global" variable
134      *
135      * @param string $name  Variable name
136      */
137     public function unset_var($name)
138     {
139         // Check if variable exists
140         foreach ($this->vars as $idx => $var) {
141             if ($var['name'] == $name) {
142                 unset($this->vars[$idx]);
143                 break;
144             }
145         }
146     }
147
148     /**
149      * Gets the value of  "global" variable
150      *
151      * @param string $name  Variable name
152      *
153      * @return string Variable value
154      */
155     public function get_var($name)
156     {
157         // Check if variable exists
158         for ($i=0, $len=count($this->vars); $i<$len; $i++) {
159             if ($this->vars[$i]['name'] == $name) {
160                 return $this->vars[$i]['name'];
161             }
162         }
163     }
164
165     /**
166      * Sets script header content
167      *
168      * @param string $text  Header content
169      */
170     public function set_prefix($text)
171     {
172         $this->prefix = $text;
173     }
174
175     /**
176      * Returns script as text
177      */
178     public function as_text()
179     {
180         $output = '';
181         $exts   = array();
182         $idx    = 0;
183
184         if (!empty($this->vars)) {
185             if (in_array('variables', (array)$this->supported)) {
186                 $has_vars = true;
187                 array_push($exts, 'variables');
188             }
189             foreach ($this->vars as $var) {
190                 if (empty($has_vars)) {
191                     // 'variables' extension not supported, put vars in comments
192                     $output .= sprintf("# %s %s\n", $var['name'], $var['value']);
193                 }
194                 else {
195                     $output .= 'set ';
196                     foreach (array_diff(array_keys($var), array('name', 'value')) as $opt) {
197                         $output .= ":$opt ";
198                     }
199                     $output .= self::escape_string($var['name']) . ' ' . self::escape_string($var['value']) . ";\n";
200                 }
201             }
202         }
203
204         // rules
205         foreach ($this->content as $rule) {
206             $extension = '';
207             $script    = '';
208             $tests     = array();
209             $i         = 0;
210
211             // header
212             if (!empty($rule['name']) && strlen($rule['name'])) {
213                 $script .= '# rule:[' . $rule['name'] . "]\n";
214             }
215
216             // constraints expressions
217             if (!empty($rule['tests'])) {
218                 foreach ($rule['tests'] as $test) {
219                     $tests[$i] = '';
220                     switch ($test['test']) {
221                     case 'size':
222                         $tests[$i] .= ($test['not'] ? 'not ' : '');
223                         $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
224                         break;
225
226                     case 'true':
227                         $tests[$i] .= ($test['not'] ? 'false' : 'true');
228                         break;
229
230                     case 'exists':
231                         $tests[$i] .= ($test['not'] ? 'not ' : '');
232                         $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
233                         break;
234
235                     case 'header':
236                         $tests[$i] .= ($test['not'] ? 'not ' : '');
237                         $tests[$i] .= 'header';
238
239                         if (!empty($test['type'])) {
240                             // relational operator + comparator
241                             if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
242                                 array_push($exts, 'relational');
243                                 array_push($exts, 'comparator-i;ascii-numeric');
244
245                                 $tests[$i] .= ' :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
246                             }
247                             else {
248                                 $this->add_comparator($test, $tests[$i], $exts);
249
250                                 if ($test['type'] == 'regex') {
251                                     array_push($exts, 'regex');
252                                 }
253
254                                 $tests[$i] .= ' :' . $test['type'];
255                             }
256                         }
257
258                         $tests[$i] .= ' ' . self::escape_string($test['arg1']);
259                         $tests[$i] .= ' ' . self::escape_string($test['arg2']);
260                         break;
261
262                     case 'address':
263                     case 'envelope':
264                         if ($test['test'] == 'envelope') {
265                             array_push($exts, 'envelope');
266                         }
267
268                         $tests[$i] .= ($test['not'] ? 'not ' : '');
269                         $tests[$i] .= $test['test'];
270
271                         if (!empty($test['part'])) {
272                             $tests[$i] .= ' :' . $test['part'];
273                             if ($test['part'] == 'user' || $test['part'] == 'detail') {
274                                 array_push($exts, 'subaddress');
275                             }
276                         }
277
278                         $this->add_comparator($test, $tests[$i], $exts);
279
280                         if (!empty($test['type'])) {
281                             if ($test['type'] == 'regex') {
282                                 array_push($exts, 'regex');
283                             }
284                             $tests[$i] .= ' :' . $test['type'];
285                         }
286
287                         $tests[$i] .= ' ' . self::escape_string($test['arg1']);
288                         $tests[$i] .= ' ' . self::escape_string($test['arg2']);
289                         break;
290
291                     case 'body':
292                         array_push($exts, 'body');
293
294                         $tests[$i] .= ($test['not'] ? 'not ' : '') . 'body';
295
296                         $this->add_comparator($test, $tests[$i], $exts);
297
298                         if (!empty($test['part'])) {
299                             $tests[$i] .= ' :' . $test['part'];
300
301                             if (!empty($test['content']) && $test['part'] == 'content') {
302                                 $tests[$i] .= ' ' . self::escape_string($test['content']);
303                             }
304                         }
305
306                         if (!empty($test['type'])) {
307                             if ($test['type'] == 'regex') {
308                                 array_push($exts, 'regex');
309                             }
310                             $tests[$i] .= ' :' . $test['type'];
311                         }
312
313                         $tests[$i] .= ' ' . self::escape_string($test['arg']);
314                         break;
315                     }
316                     $i++;
317                 }
318             }
319
320             // disabled rule: if false #....
321             if (!empty($tests)) {
322                 $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
323
324                 if (count($tests) > 1) {
325                     $tests_str = implode(', ', $tests);
326                 }
327                 else {
328                     $tests_str = $tests[0];
329                 }
330
331                 if ($rule['join'] || count($tests) > 1) {
332                     $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
333                 }
334                 else {
335                     $script .= $tests_str;
336                 }
337                 $script .= "\n{\n";
338             }
339
340             // action(s)
341             if (!empty($rule['actions'])) {
342                 foreach ($rule['actions'] as $action) {
343                     $action_script = '';
344
345                     switch ($action['type']) {
346
347                     case 'fileinto':
348                         array_push($exts, 'fileinto');
349                         $action_script .= 'fileinto ';
350                         if ($action['copy']) {
351                             $action_script .= ':copy ';
352                             array_push($exts, 'copy');
353                         }
354                         $action_script .= self::escape_string($action['target']);
355                         break;
356
357                     case 'redirect':
358                         $action_script .= 'redirect ';
359                         if ($action['copy']) {
360                             $action_script .= ':copy ';
361                             array_push($exts, 'copy');
362                         }
363                         $action_script .= self::escape_string($action['target']);
364                         break;
365
366                     case 'reject':
367                     case 'ereject':
368                         array_push($exts, $action['type']);
369                         $action_script .= $action['type'].' '
370                             . self::escape_string($action['target']);
371                         break;
372
373                     case 'addflag':
374                     case 'setflag':
375                     case 'removeflag':
376                         if (in_array('imap4flags', $this->supported))
377                             array_push($exts, 'imap4flags');
378                         else
379                             array_push($exts, 'imapflags');
380
381                         $action_script .= $action['type'].' '
382                             . self::escape_string($action['target']);
383                         break;
384
385                     case 'keep':
386                     case 'discard':
387                     case 'stop':
388                         $action_script .= $action['type'];
389                         break;
390
391                     case 'include':
392                         array_push($exts, 'include');
393                         $action_script .= 'include ';
394                         foreach (array_diff(array_keys($action), array('target', 'type')) as $opt) {
395                             $action_script .= ":$opt ";
396                         }
397                         $action_script .= self::escape_string($action['target']);
398                         break;
399
400                     case 'set':
401                         array_push($exts, 'variables');
402                         $action_script .= 'set ';
403                         foreach (array_diff(array_keys($action), array('name', 'value', 'type')) as $opt) {
404                             $action_script .= ":$opt ";
405                         }
406                         $action_script .= self::escape_string($action['name']) . ' ' . self::escape_string($action['value']);
407                         break;
408
409                     case 'vacation':
410                         array_push($exts, 'vacation');
411                         $action_script .= 'vacation';
412                         if (!empty($action['days']))
413                             $action_script .= " :days " . $action['days'];
414                         if (!empty($action['addresses']))
415                             $action_script .= " :addresses " . self::escape_string($action['addresses']);
416                         if (!empty($action['subject']))
417                             $action_script .= " :subject " . self::escape_string($action['subject']);
418                         if (!empty($action['handle']))
419                             $action_script .= " :handle " . self::escape_string($action['handle']);
420                         if (!empty($action['from']))
421                             $action_script .= " :from " . self::escape_string($action['from']);
422                         if (!empty($action['mime']))
423                             $action_script .= " :mime";
424                         $action_script .= " " . self::escape_string($action['reason']);
425                         break;
426                     }
427
428                     if ($action_script) {
429                         $script .= !empty($tests) ? "\t" : '';
430                         $script .= $action_script . ";\n";
431                     }
432                 }
433             }
434
435             if ($script) {
436                 $output .= $script . (!empty($tests) ? "}\n" : '');
437                 $idx++;
438             }
439         }
440
441         // requires
442         if (!empty($exts))
443             $output = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $output;
444
445         if (!empty($this->prefix)) {
446             $output = $this->prefix . "\n\n" . $output;
447         }
448
449         return $output;
450     }
451
452     /**
453      * Returns script object
454      *
455      */
456     public function as_array()
457     {
458         return $this->content;
459     }
460
461     /**
462      * Returns array of supported extensions
463      *
464      */
465     public function get_extensions()
466     {
467         return array_values($this->supported);
468     }
469
470     /**
471      * Converts text script to rules array
472      *
473      * @param string Text script
474      */
475     private function _parse_text($script)
476     {
477         $prefix     = '';
478         $options = array();
479
480         while ($script) {
481             $script = trim($script);
482             $rule   = array();
483
484             // Comments
485             while (!empty($script) && $script[0] == '#') {
486                 $endl = strpos($script, "\n");
487                 $line = $endl ? substr($script, 0, $endl) : $script;
488
489                 // Roundcube format
490                 if (preg_match('/^# rule:\[(.*)\]/', $line, $matches)) {
491                     $rulename = $matches[1];
492                 }
493                 // KEP:14 variables
494                 else if (preg_match('/^# (EDITOR|EDITOR_VERSION) (.+)$/', $line, $matches)) {
495                     $this->set_var($matches[1], $matches[2]);
496                 }
497                 // Horde-Ingo format
498                 else if (!empty($options['format']) && $options['format'] == 'INGO'
499                     && preg_match('/^# (.*)/', $line, $matches)
500                 ) {
501                     $rulename = $matches[1];
502                 }
503                 else if (empty($options['prefix'])) {
504                     $prefix .= $line . "\n";
505                 }
506
507                 $script = ltrim(substr($script, strlen($line) + 1));
508             }
509
510             // handle script header
511             if (empty($options['prefix'])) {
512                 $options['prefix'] = true;
513                 if ($prefix && strpos($prefix, 'horde.org/ingo')) {
514                     $options['format'] = 'INGO';
515                 }
516             }
517
518             // Control structures/blocks
519             if (preg_match('/^(if|else|elsif)/i', $script)) {
520                 $rule = $this->_tokenize_rule($script);
521                 if (strlen($rulename) && !empty($rule)) {
522                     $rule['name'] = $rulename;
523                 }
524             }
525             // Simple commands
526             else {
527                 $rule = $this->_parse_actions($script, ';');
528                 if (!empty($rule[0]) && is_array($rule)) {
529                     // set "global" variables
530                     if ($rule[0]['type'] == 'set') {
531                         unset($rule[0]['type']);
532                         $this->vars[] = $rule[0];
533                     }
534                     else {
535                         $rule = array('actions' => $rule);
536                     }
537                 }
538             }
539
540             $rulename = '';
541
542             if (!empty($rule)) {
543                 $this->content[] = $rule;
544             }
545         }
546
547         if (!empty($prefix)) {
548             $this->prefix = trim($prefix);
549         }
550     }
551
552     /**
553      * Convert text script fragment to rule object
554      *
555      * @param string Text rule
556      *
557      * @return array Rule data
558      */
559     private function _tokenize_rule(&$content)
560     {
561         $cond = strtolower(self::tokenize($content, 1));
562
563         if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
564             return null;
565         }
566
567         $disabled = false;
568         $join     = false;
569
570         // disabled rule (false + comment): if false # .....
571         if (preg_match('/^\s*false\s+#/i', $content)) {
572             $content = preg_replace('/^\s*false\s+#\s*/i', '', $content);
573             $disabled = true;
574         }
575
576         while (strlen($content)) {
577             $tokens = self::tokenize($content, true);
578             $separator = array_pop($tokens);
579
580             if (!empty($tokens)) {
581                 $token = array_shift($tokens);
582             }
583             else {
584                 $token = $separator;
585             }
586
587             $token = strtolower($token);
588
589             if ($token == 'not') {
590                 $not = true;
591                 $token = strtolower(array_shift($tokens));
592             }
593             else {
594                 $not = false;
595             }
596
597             switch ($token) {
598             case 'allof':
599                 $join = true;
600                 break;
601             case 'anyof':
602                 break;
603
604             case 'size':
605                 $size = array('test' => 'size', 'not'  => $not);
606                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
607                     if (!is_array($tokens[$i])
608                         && preg_match('/^:(under|over)$/i', $tokens[$i])
609                     ) {
610                         $size['type'] = strtolower(substr($tokens[$i], 1));
611                     }
612                     else {
613                         $size['arg'] = $tokens[$i];
614                     }
615                 }
616
617                 $tests[] = $size;
618                 break;
619
620             case 'header':
621                 $header = array('test' => 'header', 'not' => $not, 'arg1' => '', 'arg2' => '');
622                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
623                     if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
624                         $header['comparator'] = $tokens[++$i];
625                     }
626                     else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
627                         $header['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
628                     }
629                     else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
630                         $header['type'] = strtolower(substr($tokens[$i], 1));
631                     }
632                     else {
633                         $header['arg1'] = $header['arg2'];
634                         $header['arg2'] = $tokens[$i];
635                     }
636                 }
637
638                 $tests[] = $header;
639                 break;
640
641             case 'address':
642             case 'envelope':
643                 $header = array('test' => $token, 'not' => $not, 'arg1' => '', 'arg2' => '');
644                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
645                     if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
646                         $header['comparator'] = $tokens[++$i];
647                     }
648                     else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
649                         $header['type'] = strtolower(substr($tokens[$i], 1));
650                     }
651                     else if (!is_array($tokens[$i]) && preg_match('/^:(localpart|domain|all|user|detail)$/i', $tokens[$i])) {
652                         $header['part'] = strtolower(substr($tokens[$i], 1));
653                     }
654                     else {
655                         $header['arg1'] = $header['arg2'];
656                         $header['arg2'] = $tokens[$i];
657                     }
658                 }
659
660                 $tests[] = $header;
661                 break;
662
663             case 'body':
664                 $header = array('test' => 'body', 'not' => $not, 'arg' => '');
665                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
666                     if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
667                         $header['comparator'] = $tokens[++$i];
668                     }
669                     else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
670                         $header['type'] = strtolower(substr($tokens[$i], 1));
671                     }
672                     else if (!is_array($tokens[$i]) && preg_match('/^:(raw|content|text)$/i', $tokens[$i])) {
673                         $header['part'] = strtolower(substr($tokens[$i], 1));
674
675                         if ($header['part'] == 'content') {
676                             $header['content'] = $tokens[++$i];
677                         }
678                     }
679                     else {
680                         $header['arg'] = $tokens[$i];
681                     }
682                 }
683
684                 $tests[] = $header;
685                 break;
686
687             case 'exists':
688                 $tests[] = array('test' => 'exists', 'not'  => $not,
689                     'arg'  => array_pop($tokens));
690                 break;
691
692             case 'true':
693                 $tests[] = array('test' => 'true', 'not'  => $not);
694                 break;
695
696             case 'false':
697                 $tests[] = array('test' => 'true', 'not'  => !$not);
698                 break;
699             }
700
701             // goto actions...
702             if ($separator == '{') {
703                 break;
704             }
705         }
706
707         // ...and actions block
708         $actions = $this->_parse_actions($content);
709
710         if ($tests && $actions) {
711             $result = array(
712                 'type'     => $cond,
713                 'tests'    => $tests,
714                 'actions'  => $actions,
715                 'join'     => $join,
716                 'disabled' => $disabled,
717             );
718         }
719
720         return $result;
721     }
722
723     /**
724      * Parse body of actions section
725      *
726      * @param string $content  Text body
727      * @param string $end      End of text separator
728      *
729      * @return array Array of parsed action type/target pairs
730      */
731     private function _parse_actions(&$content, $end = '}')
732     {
733         $result = null;
734
735         while (strlen($content)) {
736             $tokens = self::tokenize($content, true);
737             $separator = array_pop($tokens);
738
739             if (!empty($tokens)) {
740                 $token = array_shift($tokens);
741             }
742             else {
743                 $token = $separator;
744             }
745
746             switch ($token) {
747             case 'discard':
748             case 'keep':
749             case 'stop':
750                 $result[] = array('type' => $token);
751                 break;
752
753             case 'fileinto':
754             case 'redirect':
755                 $copy   = false;
756                 $target = '';
757
758                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
759                     if (strtolower($tokens[$i]) == ':copy') {
760                         $copy = true;
761                     }
762                     else {
763                         $target = $tokens[$i];
764                     }
765                 }
766
767                 $result[] = array('type' => $token, 'copy' => $copy,
768                     'target' => $target);
769                 break;
770
771             case 'reject':
772             case 'ereject':
773                 $result[] = array('type' => $token, 'target' => array_pop($tokens));
774                 break;
775
776             case 'vacation':
777                 $vacation = array('type' => 'vacation', 'reason' => array_pop($tokens));
778
779                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
780                     $tok = strtolower($tokens[$i]);
781                     if ($tok == ':days') {
782                         $vacation['days'] = $tokens[++$i];
783                     }
784                     else if ($tok == ':subject') {
785                         $vacation['subject'] = $tokens[++$i];
786                     }
787                     else if ($tok == ':addresses') {
788                         $vacation['addresses'] = $tokens[++$i];
789                     }
790                     else if ($tok == ':handle') {
791                         $vacation['handle'] = $tokens[++$i];
792                     }
793                     else if ($tok == ':from') {
794                         $vacation['from'] = $tokens[++$i];
795                     }
796                     else if ($tok == ':mime') {
797                         $vacation['mime'] = true;
798                     }
799                 }
800
801                 $result[] = $vacation;
802                 break;
803
804             case 'setflag':
805             case 'addflag':
806             case 'removeflag':
807                 $result[] = array('type' => $token,
808                     // Flags list: last token (skip optional variable)
809                     'target' => $tokens[count($tokens)-1]
810                 );
811                 break;
812
813             case 'include':
814                 $include = array('type' => 'include', 'target' => array_pop($tokens));
815
816                 // Parameters: :once, :optional, :global, :personal
817                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
818                     $tok = strtolower($tokens[$i]);
819                     if ($tok[0] == ':') {
820                         $include[substr($tok, 1)] = true;
821                     }
822                 }
823
824                 $result[] = $include;
825                 break;
826
827             case 'set':
828                 $set = array('type' => 'set', 'value' => array_pop($tokens), 'name' => array_pop($tokens));
829
830                 // Parameters: :lower :upper :lowerfirst :upperfirst :quotewildcard :length
831                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
832                     $tok = strtolower($tokens[$i]);
833                     if ($tok[0] == ':') {
834                         $set[substr($tok, 1)] = true;
835                     }
836                 }
837
838                 $result[] = $set;
839                 break;
840
841             case 'require':
842                 // skip, will be build according to used commands
843                 // $result[] = array('type' => 'require', 'target' => $tokens);
844                 break;
845
846             }
847
848             if ($separator == $end)
849                 break;
850         }
851
852         return $result;
853     }
854
855     /**
856      *
857      */
858     private function add_comparator($test, &$out, &$exts)
859     {
860         if (empty($test['comparator'])) {
861             return;
862         }
863
864         if ($test['comparator'] == 'i;ascii-numeric') {
865             array_push($exts, 'relational');
866             array_push($exts, 'comparator-i;ascii-numeric');
867         }
868         else if (!in_array($test['comparator'], array('i;octet', 'i;ascii-casemap'))) {
869             array_push($exts, 'comparator-' . $test['comparator']);
870         }
871
872         // skip default comparator
873         if ($test['comparator'] != 'i;ascii-casemap') {
874             $out .= ' :comparator ' . self::escape_string($test['comparator']);
875         }
876     }
877
878     /**
879      * Escape special chars into quoted string value or multi-line string
880      * or list of strings
881      *
882      * @param string $str Text or array (list) of strings
883      *
884      * @return string Result text
885      */
886     static function escape_string($str)
887     {
888         if (is_array($str) && count($str) > 1) {
889             foreach($str as $idx => $val)
890                 $str[$idx] = self::escape_string($val);
891
892             return '[' . implode(',', $str) . ']';
893         }
894         else if (is_array($str)) {
895             $str = array_pop($str);
896         }
897
898         // multi-line string
899         if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
900             return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
901         }
902         // quoted-string
903         else {
904             return '"' . addcslashes($str, '\\"') . '"';
905         }
906     }
907
908     /**
909      * Escape special chars in multi-line string value
910      *
911      * @param string $str Text
912      *
913      * @return string Text
914      */
915     static function escape_multiline_string($str)
916     {
917         $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
918
919         foreach ($str as $idx => $line) {
920             // dot-stuffing
921             if (isset($line[0]) && $line[0] == '.') {
922                 $str[$idx] = '.' . $line;
923             }
924         }
925
926         return implode($str);
927     }
928
929     /**
930      * Splits script into string tokens
931      *
932      * @param string &$str    The script
933      * @param mixed  $num     Number of tokens to return, 0 for all
934      *                        or True for all tokens until separator is found.
935      *                        Separator will be returned as last token.
936      * @param int    $in_list Enable to call recursively inside a list
937      *
938      * @return mixed Tokens array or string if $num=1
939      */
940     static function tokenize(&$str, $num=0, $in_list=false)
941     {
942         $result = array();
943
944         // remove spaces from the beginning of the string
945         while (($str = ltrim($str)) !== ''
946             && (!$num || $num === true || count($result) < $num)
947         ) {
948             switch ($str[0]) {
949
950             // Quoted string
951             case '"':
952                 $len = strlen($str);
953
954                 for ($pos=1; $pos<$len; $pos++) {
955                     if ($str[$pos] == '"') {
956                         break;
957                     }
958                     if ($str[$pos] == "\\") {
959                         if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
960                             $pos++;
961                         }
962                     }
963                 }
964                 if ($str[$pos] != '"') {
965                     // error
966                 }
967                 // we need to strip slashes for a quoted string
968                 $result[] = stripslashes(substr($str, 1, $pos - 1));
969                 $str      = substr($str, $pos + 1);
970                 break;
971
972             // Parenthesized list
973             case '[':
974                 $str = substr($str, 1);
975                 $result[] = self::tokenize($str, 0, true);
976                 break;
977             case ']':
978                 $str = substr($str, 1);
979                 return $result;
980                 break;
981
982             // list/test separator
983             case ',':
984             // command separator
985             case ';':
986             // block/tests-list
987             case '(':
988             case ')':
989             case '{':
990             case '}':
991                 $sep = $str[0];
992                 $str = substr($str, 1);
993                 if ($num === true) {
994                     $result[] = $sep;
995                     break 2;
996                 }
997                 break;
998
999             // bracket-comment
1000             case '/':
1001                 if ($str[1] == '*') {
1002                     if ($end_pos = strpos($str, '*/')) {
1003                         $str = substr($str, $end_pos + 2);
1004                     }
1005                     else {
1006                         // error
1007                         $str = '';
1008                     }
1009                 }
1010                 break;
1011
1012             // hash-comment
1013             case '#':
1014                 if ($lf_pos = strpos($str, "\n")) {
1015                     $str = substr($str, $lf_pos);
1016                     break;
1017                 }
1018                 else {
1019                     $str = '';
1020                 }
1021
1022             // String atom
1023             default:
1024                 // empty or one character
1025                 if ($str === '' || $str === null) {
1026                     break 2;
1027                 }
1028                 if (strlen($str) < 2) {
1029                     $result[] = $str;
1030                     $str = '';
1031                     break;
1032                 }
1033
1034                 // tag/identifier/number
1035                 if (preg_match('/^([a-z0-9:_]+)/i', $str, $m)) {
1036                     $str = substr($str, strlen($m[1]));
1037
1038                     if ($m[1] != 'text:') {
1039                         $result[] = $m[1];
1040                     }
1041                     // multiline string
1042                     else {
1043                         // possible hash-comment after "text:"
1044                         if (preg_match('/^( |\t)*(#[^\n]+)?\n/', $str, $m)) {
1045                             $str = substr($str, strlen($m[0]));
1046                         }
1047                         // get text until alone dot in a line
1048                         if (preg_match('/^(.*)\r?\n\.\r?\n/sU', $str, $m)) {
1049                             $text = $m[1];
1050                             // remove dot-stuffing
1051                             $text = str_replace("\n..", "\n.", $text);
1052                             $str = substr($str, strlen($m[0]));
1053                         }
1054                         else {
1055                             $text = '';
1056                         }
1057
1058                         $result[] = $text;
1059                     }
1060                 }
1061
1062                 break;
1063             }
1064         }
1065
1066         return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;
1067     }
1068
1069 }