1<?php
2/**
3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved.
4 *
5 * This source code is licensed under the GPL license found in the
6 * COPYING  file in the root directory of this source tree.
7 *
8 * @license  GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html)
9 * @author   ComboStrap <support@combostrap.com>
10 *
11 */
12
13namespace ComboStrap;
14
15
16use Doku_Handler;
17use dokuwiki\Extension\SyntaxPlugin;
18use dokuwiki\Parsing\Parser;
19use syntax_plugin_combo_media;
20
21/**
22 * Class CallStack
23 * @package ComboStrap
24 *
25 * This is a class that manipulate the call stack.
26 *
27 * A call stack is composed of call (ie array)
28 * A tag is a call that has a state {@link DOKU_LEXER_ENTER} or {@link DOKU_LEXER_SPECIAL}
29 * An opening call is a call with the {@link DOKU_LEXER_ENTER}
30 * An closing call is a call with the {@link DOKU_LEXER_EXIT}
31 *
32 * You can move on the stack with the function:
33 *   * {@link CallStack::next()}
34 *   * {@link CallStack::previous()}
35 *   * `MoveTo`. example: {@link CallStack::moveToPreviousCorrespondingOpeningCall()}
36 *
37 *
38 */
39class CallStack
40{
41
42    const TAG_STATE = [DOKU_LEXER_SPECIAL, DOKU_LEXER_ENTER];
43
44    const CANONICAL = "support";
45
46    /**
47     * The type of callstack
48     *   * main is the normal
49     *   * writer is when there is a temporary call stack from the writer
50     */
51    const CALLSTACK_WRITER = "writer";
52    const CALLSTACK_MAIN = "main";
53    public const MESSAGE_PREFIX_CALLSTACK_NOT_CONFORM = "Your DokuWiki installation is too old or a writer plugin does not conform";
54
55    private $handler;
56
57    /**
58     * The max key of the calls
59     * @var int|null
60     */
61    private $maxIndex = 0;
62
63    /**
64     * @var array the call stack
65     */
66    private $callStack = [];
67
68    /**
69     * A pointer to keep the information
70     * if we have gone to far in the stack
71     * (because you lost the fact that you are outside
72     * the boundary, ie you can do a {@link \prev}` after that a {@link \next} return false
73     * @var bool
74     * If true, we are at the offset: end of th array + 1
75     */
76    private $endWasReached = false;
77    /**
78     * If true, we are at the offset: start of th array - 1
79     * You can use {@link CallStack::next()}
80     * @var bool
81     */
82    private $startWasReached = false;
83
84    /**
85     * @var string the type of callstack
86     */
87    private $callStackType = "unknown";
88
89    /**
90     * A callstack is a pointer implementation to manipulate
91     * the {@link Doku_Handler::$calls call stack of the handler}
92     *
93     * When you create a callstack object, the pointer
94     * is located at the end.
95     *
96     * If you want to reset the pointer, you need
97     * to call the {@link CallStack::closeAndResetPointer()} function
98     *
99     * @param \Doku_Handler
100     */
101    public function __construct(&$handler)
102    {
103        $this->handler = $handler;
104
105        /**
106         * A temporary Call stack is created in the writer
107         * for list, table, blockquote
108         *
109         * But third party plugin can overwrite the writer
110         * to capture the call
111         *
112         * See the
113         * https://www.dokuwiki.org/devel:parser#handler_token_methods
114         * for an example with a list component
115         *
116         */
117        $headErrorMessage = self::MESSAGE_PREFIX_CALLSTACK_NOT_CONFORM;
118        if (!method_exists($handler, 'getCallWriter')) {
119            $class = get_class($handler);
120            LogUtility::msg("$headErrorMessage. The handler ($class) provided cannot manipulate the callstack (ie the function getCallWriter does not exist).", LogUtility::LVL_MSG_ERROR);
121            return;
122        }
123        $callWriter = $handler->getCallWriter();
124
125        /**
126         * Check the calls property
127         */
128        $callWriterClass = get_class($callWriter);
129        $callsPropertyFromCallWriterExists = true;
130        try {
131            $rp = new \ReflectionProperty($callWriterClass, "calls");
132            if ($rp->isPrivate()) {
133                LogUtility::msg("$headErrorMessage. The call writer ($callWriterClass) provided cannot manipulate the callstack (ie the calls of the call writer are private).", LogUtility::LVL_MSG_ERROR);
134                return;
135            }
136        } catch (\ReflectionException $e) {
137            $callsPropertyFromCallWriterExists = false;
138        }
139
140        /**
141         * The calls
142         */
143        if ($callsPropertyFromCallWriterExists) {
144
145            $writerCalls = &$callWriter->calls;
146            $this->callStack = &$writerCalls;
147            $this->callStackType = self::CALLSTACK_WRITER;
148
149        } else {
150
151            /**
152             * Check the calls property of the handler
153             */
154            $handlerClass = get_class($handler);
155            try {
156                $rp = new \ReflectionProperty($handlerClass, "calls");
157                if ($rp->isPrivate()) {
158                    LogUtility::msg("$headErrorMessage. The handler ($handlerClass) provided cannot manipulate the callstack (ie the calls of the handler are private).", LogUtility::LVL_MSG_ERROR);
159                    return;
160                }
161            } catch (\ReflectionException $e) {
162                LogUtility::msg("$headErrorMessage. The handler ($handlerClass) provided cannot manipulate the callstack (ie the handler does not have any calls property).", LogUtility::LVL_MSG_ERROR);
163                return;
164            }
165
166            /**
167             * Initiate the callstack
168             */
169            $this->callStack = &$handler->calls;
170            $this->callStackType = self::CALLSTACK_MAIN;
171
172        }
173
174        $this->maxIndex = ArrayUtility::array_key_last($this->callStack);
175        $this->moveToEnd();
176
177
178    }
179
180    public
181    static function createFromMarkup($marki)
182    {
183
184        $modes = p_get_parsermodes();
185        $handler = new Doku_Handler();
186        $parser = new Parser($handler);
187
188        //add modes to parser
189        foreach ($modes as $mode) {
190            $parser->addMode($mode['mode'], $mode['obj']);
191        }
192        $parser->parse($marki);
193        return self::createFromHandler($handler);
194
195    }
196
197    /**
198     * Reset the pointer
199     */
200    public
201    function closeAndResetPointer()
202    {
203        reset($this->callStack);
204    }
205
206    /**
207     * Delete from the call stack
208     * @param $calls
209     * @param $start
210     * @param $end
211     */
212    public
213    static function deleteCalls(&$calls, $start, $end)
214    {
215        for ($i = $start; $i <= $end; $i++) {
216            unset($calls[$i]);
217        }
218    }
219
220    /**
221     * @param array $calls
222     * @param integer $position
223     * @param array $callStackToInsert
224     */
225    public
226    static function insertCallStackUpWards(&$calls, $position, $callStackToInsert)
227    {
228
229        array_splice($calls, $position, 0, $callStackToInsert);
230
231    }
232
233    /**
234     * A callstack pointer based implementation
235     * that starts at the end
236     * @param Doku_Handler $handler
237     * @return CallStack
238     */
239    public
240    static function createFromHandler(&$handler)
241    {
242        return new CallStack($handler);
243    }
244
245
246    /**
247     * Process the EOL call to the end of stack
248     * replacing them with paragraph call
249     *
250     * A sort of {@link Block::process()} but only from a tag
251     * to the end of the current stack
252     *
253     * This function is used basically in the {@link DOKU_LEXER_EXIT}
254     * state of {@link SyntaxPlugin::handle()} to create paragraph
255     * with the class given as parameter
256     *
257     * @param $attributes - the attributes in an callstack array form passed to the paragraph
258     */
259    public
260    function processEolToEndStack($attributes = [])
261    {
262
263        \syntax_plugin_combo_para::fromEolToParagraphUntilEndOfStack($this, $attributes);
264
265    }
266
267    /**
268     * Delete the call where the pointer is
269     * And go to the previous position
270     *
271     * This function can be used in a next loop
272     *
273     * @return Call the deleted call
274     */
275    public
276    function deleteActualCallAndPrevious(): ?Call
277    {
278
279        $actualCall = $this->getActualCall();
280
281        $offset = $this->getActualOffset();
282        array_splice($this->callStack, $offset, 1, []);
283
284        /**
285         * Move to the next element (array splice reset the pointer)
286         * if there is a eol as, we delete it
287         * otherwise we may end up with two eol
288         * and this is an empty paragraph
289         */
290        $this->moveToOffset($offset);
291        if (!$this->isPointerAtEnd()) {
292            if ($this->getActualCall()->getTagName() == 'eol') {
293                array_splice($this->callStack, $offset, 1, []);
294            }
295        }
296
297        /**
298         * Move to the previous element
299         */
300        $this->moveToOffset($offset - 1);
301
302        return $actualCall;
303
304    }
305
306    /**
307     * @return Call - get a reference to the actual call
308     * This function returns a {@link Call call} object
309     * by reference, meaning that every update will also modify the element
310     * in the stack
311     */
312    public
313    function getActualCall()
314    {
315        if ($this->endWasReached) {
316            LogUtility::msg("The actual call cannot be ask because the end of the stack was reached", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
317            return null;
318        }
319        if ($this->startWasReached) {
320            LogUtility::msg("The actual call cannot be ask because the start of the stack was reached", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
321            return null;
322        }
323        $actualCallKey = key($this->callStack);
324        $actualCallArray = &$this->callStack[$actualCallKey];
325        return new Call($actualCallArray, $actualCallKey);
326
327    }
328
329    /**
330     * put the pointer one position further
331     * false if at the end
332     * @return false|Call
333     */
334    public
335    function next()
336    {
337        if ($this->startWasReached) {
338            $this->startWasReached = false;
339            $result = reset($this->callStack);
340            if ($result === false) {
341                return false;
342            } else {
343                return $this->getActualCall();
344            }
345        } else {
346            $next = next($this->callStack);
347            if ($next === false) {
348                $this->endWasReached = true;
349                return $next;
350            } else {
351                return $this->getActualCall();
352            }
353        }
354
355    }
356
357    /**
358     *
359     * From an exit call, move the corresponding Opening call
360     *
361     * This is used mostly in {@link SyntaxPlugin::handle()} from a {@link DOKU_LEXER_EXIT}
362     * to retrieve the {@link DOKU_LEXER_ENTER} call
363     *
364     * @return bool|Call
365     */
366    public
367    function moveToPreviousCorrespondingOpeningCall()
368    {
369
370        /**
371         * Edgde case
372         */
373        if (empty($this->callStack)) {
374            return false;
375        }
376
377        if (!$this->endWasReached) {
378            $actualCall = $this->getActualCall();
379            $actualState = $actualCall->getState();
380            if ($actualState != DOKU_LEXER_EXIT) {
381                /**
382                 * Check if we are at the end of the stack
383                 */
384                LogUtility::msg("You are not at the end of stack and you are not on a opening tag, you can't ask for the opening tag." . $actualState, LogUtility::LVL_MSG_ERROR, "support");
385                return false;
386            }
387        }
388        $level = 0;
389        while ($actualCall = $this->previous()) {
390
391            $state = $actualCall->getState();
392            switch ($state) {
393                case DOKU_LEXER_ENTER:
394                    $level++;
395                    break;
396                case DOKU_LEXER_EXIT:
397                    $level--;
398                    break;
399            }
400            if ($level > 0) {
401                break;
402            }
403
404        }
405        if ($level > 0) {
406            return $actualCall;
407        } else {
408            return false;
409        }
410    }
411
412
413    /**
414     * @return Call|false the previous call or false if there is no more previous call
415     */
416    public
417    function previous()
418    {
419        if ($this->endWasReached) {
420            $this->endWasReached = false;
421            $return = end($this->callStack);
422            if ($return == false) {
423                // empty array (first call on the stack)
424                return false;
425            } else {
426                return $this->getActualCall();
427            }
428        } else {
429            $prev = prev($this->callStack);
430            if ($prev === false) {
431                $this->startWasReached = true;
432                return $prev;
433            } else {
434                return $this->getActualCall();
435            }
436        }
437
438    }
439
440    /**
441     * Return the first enter or special child call (ie a tag)
442     * @return Call|false
443     */
444    public
445    function moveToFirstChildTag()
446    {
447        $found = false;
448        while ($this->next()) {
449
450            $actualCall = $this->getActualCall();
451            $state = $actualCall->getState();
452            switch ($state) {
453                case DOKU_LEXER_ENTER:
454                case DOKU_LEXER_SPECIAL:
455                    $found = true;
456                    break 2;
457                case DOKU_LEXER_EXIT:
458                    break 2;
459            }
460
461        }
462        if ($found) {
463            return $this->getActualCall();
464        } else {
465            return false;
466        }
467
468
469    }
470
471    /**
472     * The end is the one after the last element
473     */
474    public
475    function moveToEnd()
476    {
477        if ($this->startWasReached) {
478            $this->startWasReached = false;
479        }
480        end($this->callStack);
481        $this->next();
482    }
483
484    /**
485     * On the same level
486     */
487    public
488    function moveToNextSiblingTag()
489    {
490
491        /**
492         * Edgde case
493         */
494        if (empty($this->callStack)) {
495            return false;
496        }
497
498        $actualCall = $this->getActualCall();
499        $actualState = $actualCall->getState();
500        if (!in_array($actualState, CallStack::TAG_STATE)) {
501            LogUtility::msg("A next sibling can be asked only from a tag call. The state is " . $actualState, LogUtility::LVL_MSG_ERROR, "support");
502            return false;
503        }
504        $level = 0;
505        while ($this->next()) {
506
507            $actualCall = $this->getActualCall();
508            $state = $actualCall->getState();
509            switch ($state) {
510                case DOKU_LEXER_ENTER:
511                case DOKU_LEXER_SPECIAL:
512                    $level++;
513                    break;
514                case DOKU_LEXER_EXIT:
515                    $level--;
516                    break;
517            }
518
519            if ($level == 0 && in_array($state, self::TAG_STATE)) {
520                break;
521            }
522        }
523        if ($level == 0 && !$this->endWasReached) {
524            return $this->getActualCall();
525        } else {
526            return false;
527        }
528    }
529
530    /**
531     * @param Call $call
532     * @return Call the inserted call
533     */
534    public
535    function insertBefore(Call $call): Call
536    {
537        if ($this->endWasReached) {
538
539            $this->callStack[] = $call->toCallArray();
540
541        } else {
542
543            $offset = $this->getActualOffset();
544            array_splice($this->callStack, $offset, 0, [$call->toCallArray()]);
545            // array splice reset the pointer
546            // we move it to the actual element (ie the key is offset +1)
547            $this->moveToOffset($offset + 1);
548
549        }
550        return $call;
551    }
552
553    /**
554     * Move pointer by offset
555     * @param $offset
556     */
557    private
558    function moveToOffset($offset)
559    {
560        $this->resetPointer();
561        for ($i = 0; $i < $offset; $i++) {
562            $result = $this->next();
563            if ($result === false) {
564                break;
565            }
566        }
567    }
568
569    /**
570     * Move pointer by key
571     * @param $targetKey
572     */
573    private
574    function moveToKey($targetKey)
575    {
576        $this->resetPointer();
577        for ($i = 0; $i < $targetKey; $i++) {
578            next($this->callStack);
579        }
580        $actualKey = key($this->callStack);
581        if ($actualKey != $targetKey) {
582            LogUtility::msg("The target key ($targetKey) is not equal to the actual key ($actualKey). The moveToKey was not successful");
583        }
584    }
585
586    /**
587     * Insert After. The pointer stays at the current state.
588     * If you don't need to process the call that you just
589     * inserted, you may want to call {@link CallStack::next()}
590     * @param Call $call
591     */
592    public
593    function insertAfter($call)
594    {
595        $actualKey = key($this->callStack);
596        if ($actualKey == null) {
597            if ($this->endWasReached == true) {
598                $this->callStack[] = $call->toCallArray();
599            } else {
600                LogUtility::msg("Callstack: Actual key is null, we can't insert after null");
601            }
602        } else {
603            $offset = array_search($actualKey, array_keys($this->callStack), true);
604            array_splice($this->callStack, $offset + 1, 0, [$call->toCallArray()]);
605            // array splice reset the pointer
606            // we move it to the actual element
607            $this->moveToKey($actualKey);
608        }
609    }
610
611    public
612    function getActualKey()
613    {
614        return key($this->callStack);
615    }
616
617    /**
618     * Insert an EOL call if the next call is not an EOL
619     * This is to enforce an new paragraph
620     */
621    public
622    function insertEolIfNextCallIsNotEolOrBlock()
623    {
624        if (!$this->isPointerAtEnd()) {
625            $nextCall = $this->next();
626            if ($nextCall != false) {
627                if ($nextCall->getTagName() != "eol" && $nextCall->getDisplay() != "block") {
628                    $this->insertBefore(
629                        Call::createNativeCall("eol")
630                    );
631                    // move on the eol
632                    $this->previous();
633                }
634                // move back
635                $this->previous();
636            }
637        }
638    }
639
640    private
641    function isPointerAtEnd()
642    {
643        return $this->endWasReached;
644    }
645
646    public
647    function &getHandler()
648    {
649        return $this->handler;
650    }
651
652    /**
653     * Return The offset (not the key):
654     *   * starting at 0 for the first element
655     *   * 1 for the second ...
656     *
657     * @return false|int|string
658     */
659    private
660    function getActualOffset()
661    {
662        $actualKey = key($this->callStack);
663        return array_search($actualKey, array_keys($this->callStack), true);
664    }
665
666    private
667    function resetPointer()
668    {
669        reset($this->callStack);
670        $this->endWasReached = false;
671    }
672
673    public
674    function moveToStart()
675    {
676        $this->resetPointer();
677        $this->previous();
678    }
679
680    /**
681     * @return Call|false the parent call or false if there is no parent
682     * If you are on an {@link DOKU_LEXER_EXIT} state, you should
683     * call first the {@link CallStack::moveToPreviousCorrespondingOpeningCall()}
684     */
685    public function moveToParent()
686    {
687
688        /**
689         * Case when we start from the exit state element
690         * We go first to the opening tag
691         * because the algorithm is level based.
692         *
693         * When the end is reached, there is no call
694         * (this not the {@link end php end} but one further
695         */
696        if (!$this->endWasReached && !$this->startWasReached && $this->getActualCall()->getState() == DOKU_LEXER_EXIT) {
697
698            $this->moveToPreviousCorrespondingOpeningCall();
699
700        }
701
702
703        /**
704         * We are in a parent when the tree level is negative
705         */
706        $treeLevel = 0;
707        while ($actualCall = $this->previous()) {
708
709            /**
710             * Add
711             * would become a parent on its enter state
712             */
713            $actualCallState = $actualCall->getState();
714            switch ($actualCallState) {
715                case DOKU_LEXER_ENTER:
716                    $treeLevel = $treeLevel - 1;
717                    break;
718                case DOKU_LEXER_EXIT:
719                    /**
720                     * When the tag has a sibling with an exit tag
721                     */
722                    $treeLevel = $treeLevel + 1;
723                    break;
724            }
725
726            /**
727             * The breaking statement
728             */
729            if ($treeLevel < 0) {
730                break;
731            }
732
733        }
734        return $actualCall;
735
736
737    }
738
739    /**
740     * Delete the anchor link to the image (ie the lightbox)
741     *
742     * This is used in navigation and for instance
743     * in heading
744     */
745    public function processNoLinkOnImageToEndStack()
746    {
747        while ($this->next()) {
748            $actualCall = $this->getActualCall();
749            if ($actualCall->getTagName() == syntax_plugin_combo_media::TAG) {
750                $actualCall->addAttribute(MediaLink::LINKING_KEY, MediaLink::LINKING_NOLINK_VALUE);
751            }
752        }
753    }
754
755    /**
756     * Append instructions to the callstack (ie at the end)
757     * @param array $instructions
758     */
759    public function appendInstructionsFromNativeArray($instructions)
760    {
761        array_splice($this->callStack, count($this->callStack), 0, $instructions);
762    }
763
764    /**
765     * @param Call $call
766     */
767    public function appendCallAtTheEnd($call)
768    {
769        $this->callStack[] = $call->toCallArray();
770    }
771
772    public function moveToPreviousSiblingTag()
773    {
774        /**
775         * Edge case
776         */
777        if (empty($this->callStack)) {
778            return false;
779        }
780
781        if (!$this->endWasReached) {
782            $actualCall = $this->getActualCall();
783            $actualState = $actualCall->getState();
784            if (!in_array($actualState, CallStack::TAG_STATE)) {
785                LogUtility::msg("A previous sibling can be asked only from a tag call. The state is " . $actualState, LogUtility::LVL_MSG_ERROR, "support");
786                return false;
787            }
788        }
789        $level = 0;
790        while ($this->previous()) {
791
792            $actualCall = $this->getActualCall();
793            $state = $actualCall->getState();
794            switch ($state) {
795                case DOKU_LEXER_ENTER:
796                case DOKU_LEXER_SPECIAL:
797                    $level++;
798                    break;
799                case DOKU_LEXER_EXIT:
800                    $level--;
801                    break;
802            }
803
804            if ($level == 0 && in_array($state, self::TAG_STATE)) {
805                break;
806            }
807        }
808        if ($level == 0 && !$this->startWasReached) {
809            return $this->getActualCall();
810        } else {
811            return false;
812        }
813    }
814
815    /**
816     * Delete all calls after the passed call
817     *
818     * It's used in syntax generator that:
819     *   * capture the children callstack at the end,
820     *   * delete it
821     *   * and use it to generate more calls.
822     *
823     * @param Call $call
824     */
825    public function deleteAllCallsAfter(Call $call)
826    {
827        $key = $call->getKey();
828        $offset = array_search($key, array_keys($this->callStack), true);
829        if ($offset !== false) {
830            /**
831             * We delete from the next
832             * {@link array_splice()} delete also the given offset
833             */
834            array_splice($this->callStack, $offset + 1);
835        } else {
836            LogUtility::msg("The call ($call) could not be found in the callStack. We couldn't therefore delete the calls after");
837        }
838
839    }
840
841    /**
842     * @param Call[] $calls
843     */
844    public function appendInstructionsFromCallObjects($calls)
845    {
846        foreach ($calls as $call) {
847            $this->appendCallAtTheEnd($call);
848        }
849
850    }
851
852    /**
853     *
854     * @return int|mixed - the last position on the callstack
855     * If you are at the end of the callstack after a full parsing,
856     * this should be the length of the string of the page
857     */
858    public function getLastCharacterPosition()
859    {
860        $offset = $this->getActualOffset();
861
862        $lastEndPosition = 0;
863        $this->moveToEnd();
864        while ($actualCall = $this->previous()) {
865            // p_open and p_close have always a position value of 0
866            $lastEndPosition = $actualCall->getLastMatchedCharacterPosition();
867            if ($lastEndPosition !== 0) {
868                break;
869            }
870        }
871        if ($offset == null) {
872            $this->moveToEnd();
873        } else {
874            $this->moveToOffset($offset);
875        }
876        return $lastEndPosition;
877
878    }
879
880
881}
882