/ src / library / MarkUpScanner.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library;

/**
 * Reads markup one character at a time so a parser can hunt for the next
 * token without copying the rest of the document. It keeps the source and
 * a single cursor position; every read returns a small span cut from the
 * source rather than a rebuilt tail, so a parser layered on top uses
 * memory proportional to how deeply blocks are nested, not to the length
 * of what remains.
 *
 * The set of characters that can begin a block is supplied to the
 * constructor, so the same scanner drives different markup flavors: a wiki
 * parser passes the characters that open wiki blocks, a markdown parser
 * passes markdown's, and so on.
 *
 * @author Chris Pollett
 */
class MarkUpScanner
{
    /**
     * The whole markup document being scanned.
     * @var string
     */
    private $source;
    /**
     * Byte offset of the cursor into the source.
     * @var int
     */
    private $position;
    /**
     * Length of the source in bytes, cached so it is not recomputed.
     * @var int
     */
    private $length;
    /**
     * Characters that, when first on a line, may begin a new block (for
     * example headings, list items, indents). Which ones apply is a
     * property of the markup flavor, so the parser supplies them.
     * @var string
     */
    private $block_start_chars;
    /**
     * Sets up a scanner over a piece of markup, first normalizing its
     * line endings to plain newlines so a stray carriage return cannot
     * ride along at the end of a line and defeat a block matcher.
     *
     * @param string $source the markup to scan
     * @param string $block_start_chars each character that may start a
     *     block for this markup flavor, concatenated (for wiki, for
     *     instance, the heading, list, indent, rule, brace, and tag
     *     openers)
     */
    public function __construct($source, $block_start_chars)
    {
        $this->source = str_replace(["\r\n", "\r"], "\n", $source);
        $this->position = 0;
        $this->length = strlen($this->source);
        $this->block_start_chars = $block_start_chars;
    }
    /**
     * Whether the cursor has reached the end of the source.
     * @return bool true if nothing remains to read
     */
    public function atEnd()
    {
        return $this->position >= $this->length;
    }
    /**
     * The current cursor offset, so a caller can remember a spot and
     * later return to it.
     * @return int the offset into the source
     */
    public function tell()
    {
        return $this->position;
    }
    /**
     * Moves the cursor back to a remembered offset.
     * @param int $offset an offset previously returned by tell
     * @return void
     */
    public function seek($offset)
    {
        $this->position = $offset;
    }
    /**
     * Looks at a character near the cursor without consuming it.
     * @param int $ahead how many characters past the cursor to look
     * @return string the character, or the empty string past the end
     */
    public function peek($ahead = 0)
    {
        $at = $this->position + $ahead;
        return $at < $this->length ? $this->source[$at] : "";
    }
    /**
     * Moves the cursor forward.
     * @param int $count how many characters to consume
     * @return void
     */
    public function advance($count = 1)
    {
        $this->position += $count;
    }
    /**
     * Whether the source at the cursor begins with the given text.
     * @param string $text the text to compare against
     * @return bool true if the next characters match text exactly
     */
    public function startsWith($text)
    {
        return substr_compare($this->source, $text, $this->position,
            strlen($text)) === 0;
    }
    /**
     * Whether the cursor sits at the start of a line, meaning it is at
     * the very beginning of the source or just past a newline.
     * @return bool true at a line start
     */
    public function atLineStart()
    {
        return $this->position === 0 ||
            $this->source[$this->position - 1] === "\n";
    }
    /**
     * Returns the source from the cursor to the end without moving the
     * cursor, so a caller can inspect all that remains to be read.
     *
     * @return string the unread remainder of the source
     */
    public function rest()
    {
        return substr($this->source, $this->position);
    }
    /**
     * Returns the whole source the scanner runs over, so a caller that
     * works from an offset can be handed the text and the cursor rather
     * than a fresh copy of all that remains. The cursor is not moved.
     * @return string the full source
     */
    public function source()
    {
        return $this->source;
    }
    /**
     * Returns a whole line at or ahead of the cursor without moving it and
     * without copying all that remains, so a caller can look at a line, or
     * the one just below it, in time set by the length of that line rather
     * than by the length of the rest of the source. This is what keeps a
     * scan that peeks a line per step linear overall. The ahead count is in
     * lines past the cursor: zero is the line the cursor sits on, one is the
     * line below it.
     *
     * @param int $ahead how many lines past the cursor to return
     * @return string the requested line without its newline, or the empty
     *     string when there is no such line
     */
    public function peekLine($ahead = 0)
    {
        $start = $this->position;
        for ($i = 0; $i < $ahead; $i++) {
            $newline = strpos($this->source, "\n", $start);
            if ($newline === false) {
                return "";
            }
            $start = $newline + 1;
        }
        if ($start >= $this->length) {
            return "";
        }
        $newline = strpos($this->source, "\n", $start);
        $end = ($newline === false) ? $this->length : $newline;
        return substr($this->source, $start, $end - $start);
    }
    /**
     * Consumes characters until one of the stop characters (or a newline,
     * or the end of the source) is reached, and returns what was consumed.
     * The stop characters themselves are left for the caller to inspect.
     *
     * @param string $stops each character that ends the run, concatenated;
     *     a newline always ends the run as well
     * @return string the characters read before the stop
     */
    public function readUntil($stops)
    {
        $start = $this->position;
        while ($this->position < $this->length) {
            $current = $this->source[$this->position];
            if ($current === "\n" || strpos($stops, $current) !== false) {
                break;
            }
            $this->position++;
        }
        return substr($this->source, $start, $this->position - $start);
    }
    /**
     * Consumes the rest of the current line and returns it without the
     * trailing newline, leaving the cursor at the start of the next line.
     * @return string the line's text
     */
    public function readLine()
    {
        $start = $this->position;
        while ($this->position < $this->length &&
            $this->source[$this->position] !== "\n") {
            $this->position++;
        }
        $line = substr($this->source, $start, $this->position - $start);
        if ($this->position < $this->length) {
            $this->position++;
        }
        return $line;
    }
    /**
     * Consumes a run of the same character and reports how many there
     * were, used for markers made of repeated characters such as a run of
     * equals signs opening a heading or of colons setting an indent depth.
     *
     * @param string $character the single character to count
     * @return int how many copies were consumed
     */
    public function readRepeat($character)
    {
        $count = 0;
        while ($this->position < $this->length &&
            $this->source[$this->position] === $character) {
            $this->position++;
            $count++;
        }
        return $count;
    }
    /**
     * Skips a run of blank lines, leaving the cursor at the first line
     * that has content or at the end of the source.
     * @return void
     */
    public function skipBlankLines()
    {
        while ($this->position < $this->length) {
            $mark = $this->position;
            while ($this->position < $this->length &&
                ($this->source[$this->position] === " " ||
                $this->source[$this->position] === "\t")) {
                $this->position++;
            }
            if ($this->position < $this->length &&
                $this->source[$this->position] === "\n") {
                $this->position++;
            } else if ($this->position < $this->length) {
                $this->position = $mark;
                break;
            } else {
                break;
            }
        }
    }
    /**
     * Whether the character at the cursor is one that can begin a block
     * for this markup flavor, the test a parser uses to decide whether a
     * fresh line continues the current block or starts a new one.
     * @return bool true if the cursor's character may open a block
     */
    public function atBlockStartChar()
    {
        if ($this->position >= $this->length) {
            return false;
        }
        return strpos($this->block_start_chars,
            $this->source[$this->position]) !== false;
    }
    /**
     * Whether the line at the cursor is blank, that is empty or only
     * spaces and tabs before its newline, tested without consuming
     * anything so a parser can decide a blank line ends the current block.
     * @return bool true when the current line has no visible content
     */
    public function atBlankLine()
    {
        $at = $this->position;
        while ($at < $this->length && ($this->source[$at] === " " ||
            $this->source[$at] === "\t")) {
            $at++;
        }
        return $at >= $this->length || $this->source[$at] === "\n";
    }
    /**
     * From a cursor sitting on an opening double brace, consumes through
     * the matching close, counting nested double braces so an inner pair
     * does not close the outer one, and returns the text between them.
     * When there is no match the cursor is left where it began and false
     * is returned, so the caller can treat the braces as ordinary text.
     *
     * @return string|false the text between the braces, or false when the
     *      opening brace is never matched
     */
    public function matchBraces()
    {
        $start = $this->position;
        $depth = 0;
        while ($this->position < $this->length - 1) {
            if ($this->source[$this->position] === "{" &&
                $this->source[$this->position + 1] === "{") {
                $depth++;
                $this->position += 2;
            } else if ($this->source[$this->position] === "}" &&
                $this->source[$this->position + 1] === "}") {
                $depth--;
                $this->position += 2;
                if ($depth === 0) {
                    return substr($this->source, $start + 2,
                        $this->position - $start - 4);
                }
            } else {
                $this->position++;
            }
        }
        $this->position = $start;
        return false;
    }
}
X