lib/crass/scanner.rb



# encoding: utf-8
require 'strscan'

module Crass

  # Similar to a StringScanner, but with extra functionality needed to tokenize
  # CSS while preserving the original text.
  class Scanner
    # Current character, or `nil` if the scanner hasn't yet consumed a
    # character, or is at the end of the string.
    attr_reader :current

    # Current marker position. Use {#marked} to get the substring between
    # {#marker} and {#pos}.
    attr_accessor :marker

    # Position of the next character that will be consumed. This is a character
    # position, not a byte position, so it accounts for multi-byte characters.
    #
    # Byte offsets (used internally for fast substring extraction) are tracked
    # separately by the underlying StringScanner, whose `pos` always reflects
    # the byte offset corresponding to this character position.
    attr_accessor :pos

    # String being scanned.
    attr_reader :string

    # Creates a Scanner instance for the given _input_ string or IO instance.
    def initialize(input)
      @string  = input.is_a?(IO) ? input.read : input.to_s
      @scanner = StringScanner.new(@string)

      reset
    end

    # Consumes the next character and returns it, advancing the pointer, or
    # an empty string if the end of the string has been reached.
    def consume
      if @pos < @len
        @pos    += 1
        @current = @scanner.getch
      else
        ''
      end
    end

    # Consumes the rest of the string and returns it, advancing the pointer to
    # the end of the string. Returns an empty string is the end of the string
    # has already been reached.
    def consume_rest
      result = @scanner.rest

      # `StringScanner#rest` does not advance the scan pointer, so move it to
      # the end of the input to keep the byte offset in sync with {#pos}. This
      # ensures a subsequent {#marked} extracts the correct substring.
      @scanner.terminate

      @current = result[-1]
      @pos     = @len

      result
    end

    # Returns `true` if the end of the string has been reached, `false`
    # otherwise.
    def eos?
      @pos == @len
    end

    # Sets the marker to the position of the next character that will be
    # consumed.
    def mark
      @byte_marker = @scanner.pos
      @marker      = @pos
    end

    # Returns the substring between {#marker} and {#pos}, without altering the
    # pointer.
    def marked
      # Extract the marked text using byte offsets rather than character
      # offsets. Slicing the original string by character offset is O(n) on
      # multi-byte input (Ruby must translate the character index into a byte
      # index), which makes tokenizing non-ASCII input superlinear. Byte slicing
      # is O(length) regardless of how far into the string we are.
      @string.byteslice(@byte_marker, @scanner.pos - @byte_marker) || ''
    end

    # Returns up to _length_ characters starting at the current position, but
    # doesn't consume them. The number of characters returned may be less than
    # _length_ if the end of the string is reached.
    def peek(length = 1)
      # Grab the bytes for up to _length_ characters and then take the first
      # _length_ characters. A UTF-8 character is at most four bytes, so `length
      # * 4` bytes always contains at least _length_ whole characters when that
      # many remain. This avoids the O(n) character-offset slice that
      # `@string[pos, length]` would otherwise perform on multi-byte input.
      @string.byteslice(@scanner.pos, length * 4).slice(0, length) || ''
    end

    # Moves the pointer back one character without changing the value of
    # {#current}. The next call to {#consume} will re-consume the current
    # character.
    def reconsume
      @scanner.unscan
      @pos -= 1 if @pos > 0
    end

    # Resets the pointer to the beginning of the string.
    def reset
      @scanner.reset

      @byte_marker = 0
      @current     = nil
      @len         = @string.size
      @marker      = 0
      @pos         = 0
    end

    # Tries to match _pattern_ at the current position. If it matches, the
    # matched substring will be returned and the pointer will be advanced.
    # Otherwise, `nil` will be returned.
    def scan(pattern)
      if match = @scanner.scan(pattern)
        @pos     += match.size
        @current  = match[-1]
      end

      match
    end

    # Scans the string until the _pattern_ is matched. Returns the substring up
    # to and including the end of the match, and advances the pointer. If there
    # is no match, `nil` is returned and the pointer is not advanced.
    def scan_until(pattern)
      if match = @scanner.scan_until(pattern)
        @pos     += match.size
        @current  = match[-1]
      end

      match
    end
  end

end