lib/asciidoctor/reader.rb



# frozen_string_literal: true
module Asciidoctor
# Public: Methods for retrieving lines from AsciiDoc source files
class Reader
  include Logging

  class Cursor
    attr_reader :file, :dir, :path, :lineno

    def initialize file, dir = nil, path = nil, lineno = 1
      @file, @dir, @path, @lineno = file, dir, path, lineno
    end

    def advance num
      @lineno += num
    end

    def line_info
      %(#{@path}: line #{@lineno})
    end

    alias to_s line_info
  end

  attr_reader :file
  attr_reader :dir
  attr_reader :path

  # Public: Get the 1-based offset of the current line.
  attr_reader :lineno

  # Public: Get the document source as a String Array of lines.
  attr_reader :source_lines

  # Public: Control whether lines are processed using Reader#process_line on first visit (default: true)
  attr_accessor :process_lines

  # Public: Indicates that the end of the reader was reached with a delimited block still open.
  attr_accessor :unterminated

  # Public: Initialize the Reader object
  def initialize data = nil, cursor = nil, opts = {}
    if !cursor
      @file = nil
      @dir = '.'
      @path = '<stdin>'
      @lineno = 1
    elsif ::String === cursor
      @file = cursor
      @dir, @path = ::File.split @file
      @lineno = 1
    else
      if (@file = cursor.file)
        @dir = cursor.dir || (::File.dirname @file)
        @path = cursor.path || (::File.basename @file)
      else
        @dir = cursor.dir || '.'
        @path = cursor.path || '<stdin>'
      end
      @lineno = cursor.lineno || 1
    end
    @lines = (@source_lines = prepare_lines data, opts).reverse
    @mark = nil
    @look_ahead = 0
    @process_lines = true
    @unescape_next_line = false
    @unterminated = nil
    @saved = nil
  end

  # Public: Check whether there are any lines left to read.
  #
  # If a previous call to this method resulted in a value of false,
  # immediately returned the cached value. Otherwise, delegate to
  # peek_line to determine if there is a next line available.
  #
  # Returns True if there are more lines, False if there are not.
  def has_more_lines?
    if @lines.empty?
      @look_ahead = 0
      false
    else
      true
    end
  end

  # Public: Check whether this reader is empty (contains no lines)
  #
  # Returns true if there are no more lines to peek, otherwise false.
  def empty?
    if @lines.empty?
      @look_ahead = 0
      true
    else
      false
    end
  end
  alias eof? empty?

  # Public: Peek at the next line and check if it's empty (i.e., whitespace only)
  #
  # This method Does not consume the line from the stack.
  #
  # Returns True if the there are no more lines or if the next line is empty
  def next_line_empty?
    peek_line.nil_or_empty?
  end

  # Public: Peek at the next line of source data. Processes the line if not
  # already marked as processed, but does not consume it.
  #
  # This method will probe the reader for more lines. If there is a next line
  # that has not previously been visited, the line is passed to the
  # Reader#process_line method to be initialized. This call gives
  # sub-classes the opportunity to do preprocessing. If the return value of
  # the Reader#process_line is nil, the data is assumed to be changed and
  # Reader#peek_line is invoked again to perform further processing.
  #
  # If has_more_lines? is called immediately before peek_line, the direct flag
  # is implicitly true (since the line is flagged as visited).
  #
  # direct  - A Boolean flag to bypasses the check for more lines and immediately
  #           returns the first element of the internal @lines Array. (default: false)
  #
  # Returns the next line of the source data as a String if there are lines remaining.
  # Returns nothing if there is no more data.
  def peek_line direct = false
    while true
      next_line = @lines[-1]
      if direct || @look_ahead > 0
        return @unescape_next_line ? (next_line.slice 1, next_line.length) : next_line
      elsif next_line
        # FIXME the problem with this approach is that we aren't
        # retaining the modified line (hence the @unescape_next_line tweak)
        # perhaps we need a stack of proxied lines
        if (line = process_line next_line)
          return line
        end
      else
        @look_ahead = 0
        return
      end
    end
  end

  # Public: Peek at the next multiple lines of source data. Processes the lines if not
  # already marked as processed, but does not consume them.
  #
  # This method delegates to Reader#read_line to process and collect the line, then
  # restores the lines to the stack before returning them. This allows the lines to
  # be processed and marked as such so that subsequent reads will not need to process
  # the lines again.
  #
  # num    - The positive Integer number of lines to peek or nil to peek all lines (default: nil).
  # direct - A Boolean indicating whether processing should be disabled when reading lines (default: false).
  #
  # Returns A String Array of the next multiple lines of source data, or an empty Array
  # if there are no more lines in this Reader.
  def peek_lines num = nil, direct = false
    old_look_ahead = @look_ahead
    result = []
    (num || MAX_INT).times do
      if (line = direct ? shift : read_line)
        result << line
      else
        @lineno -= 1 if direct
        break
      end
    end

    unless result.empty?
      unshift_all result
      @look_ahead = old_look_ahead if direct
    end

    result
  end

  # Public: Get the next line of source data. Consumes the line returned.
  #
  # Returns the String of the next line of the source data if data is present.
  # Returns nothing if there is no more data.
  def read_line
    # has_more_lines? triggers preprocessor
    shift if @look_ahead > 0 || has_more_lines?
  end

  # Public: Get the remaining lines of source data.
  #
  # This method calls Reader#read_line repeatedly until all lines are consumed
  # and returns the lines as a String Array. This method differs from
  # Reader#lines in that it processes each line in turn, hence triggering
  # any preprocessors implemented in sub-classes.
  #
  # Returns the lines read as a String Array
  def read_lines
    lines = []
    # has_more_lines? triggers preprocessor
    lines << shift while has_more_lines?
    lines
  end
  alias readlines read_lines

  # Public: Get the remaining lines of source data joined as a String.
  #
  # Delegates to Reader#read_lines, then joins the result.
  #
  # Returns the lines read joined as a String
  def read
    read_lines.join LF
  end

  # Public: Advance to the next line by discarding the line at the front of the stack
  #
  # Returns a Boolean indicating whether there was a line to discard.
  def advance
    shift ? true : false
  end

  # Public: Push the String line onto the beginning of the Array of source data.
  #
  # A line pushed on the reader using this method is not processed again. The
  # method assumes the line was previously retrieved from the reader or does
  # not otherwise contain preprocessor directives. Therefore, it is marked as
  # processed immediately.
  #
  # line_to_restore - the line to restore onto the stack
  #
  # Returns nothing.
  def unshift_line line_to_restore
    unshift line_to_restore
    nil
  end
  alias restore_line unshift_line

  # Public: Push an Array of lines onto the front of the Array of source data.
  #
  # Lines pushed on the reader using this method are not processed again. The
  # method assumes the lines were previously retrieved from the reader or do
  # not otherwise contain preprocessor directives. Therefore, they are marked
  # as processed immediately.
  #
  # Returns nothing.
  def unshift_lines lines_to_restore
    unshift_all lines_to_restore
  end
  alias restore_lines unshift_lines

  # Public: Replace the next line with the specified line.
  #
  # Calls Reader#advance to consume the current line, then calls
  # Reader#unshift to push the replacement onto the top of the
  # line stack.
  #
  # replacement - The String line to put in place of the next line (i.e., the line at the cursor).
  #
  # Returns true.
  def replace_next_line replacement
    shift
    unshift replacement
    true
  end
  # deprecated
  alias replace_line replace_next_line

  # Public: Skip blank lines at the cursor.
  #
  # Examples
  #
  #   reader.lines
  #   => ["", "", "Foo", "Bar", ""]
  #   reader.skip_blank_lines
  #   => 2
  #   reader.lines
  #   => ["Foo", "Bar", ""]
  #
  # Returns the [Integer] number of lines skipped or nothing if all lines have
  # been consumed (even if lines were skipped by this method).
  def skip_blank_lines
    return if empty?

    num_skipped = 0
    # optimized code for shortest execution path
    while (next_line = peek_line)
      if next_line.empty?
        shift
        num_skipped += 1
      else
        return num_skipped
      end
    end
  end

  # Public: Skip consecutive comment lines and block comments.
  #
  # Examples
  #   @lines
  #   => ["// foo", "bar"]
  #
  #   comment_lines = skip_comment_lines
  #   => nil
  #
  #   @lines
  #   => ["bar"]
  #
  # Returns nothing
  def skip_comment_lines
    return if empty?

    while (next_line = peek_line) && !next_line.empty?
      if next_line.start_with? '//'
        if next_line.start_with? '///'
          if (ll = next_line.length) > 3 && next_line == '/' * ll
            read_lines_until terminator: next_line, skip_first_line: true, read_last_line: true, skip_processing: true, context: :comment
          else
            break
          end
        else
          shift
        end
      else
        break
      end
    end

    nil
  end

  # Public: Skip consecutive comment lines and return them.
  #
  # This method assumes the reader only contains simple lines (no blocks).
  def skip_line_comments
    return [] if empty?

    comment_lines = []
    # optimized code for shortest execution path
    while (next_line = peek_line) && !next_line.empty?
      if next_line.start_with? '//'
        comment_lines << shift
      else
        break
      end
    end

    comment_lines
  end

  # Public: Advance to the end of the reader, consuming all remaining lines
  #
  # Returns nothing.
  def terminate
    @lineno += @lines.size
    @lines.clear
    @look_ahead = 0
    nil
  end

  # Public: Return all the lines from `@lines` until we (1) run out them,
  #   (2) find a blank line with `break_on_blank_lines: true`, or (3) find
  #   a line for which the given block evals to true.
  #
  # options - an optional Hash of processing options:
  #           * :terminator may be used to specify the contents of the line
  #               at which the reader should stop
  #           * :break_on_blank_lines may be used to specify to break on
  #               blank lines
  #           * :break_on_list_continuation may be used to specify to break
  #               on a list continuation line
  #           * :skip_first_line may be used to tell the reader to advance
  #               beyond the first line before beginning the scan
  #           * :preserve_last_line may be used to specify that the String
  #               causing the method to stop processing lines should be
  #               pushed back onto the `lines` Array.
  #           * :read_last_line may be used to specify that the String
  #               causing the method to stop processing lines should be
  #               included in the lines being returned
  #           * :skip_line_comments may be used to look for and skip
  #               line comments
  #           * :skip_processing is used to disable line (pre)processing
  #               for the duration of this method
  #
  # Returns the Array of lines forming the next segment.
  #
  # Examples
  #
  #   data = [
  #     "First line\n",
  #     "Second line\n",
  #     "\n",
  #     "Third line\n",
  #   ]
  #   reader = Reader.new data, nil, normalize: true
  #
  #   reader.read_lines_until
  #   => ["First line", "Second line"]
  def read_lines_until options = {}
    result = []
    if @process_lines && options[:skip_processing]
      @process_lines = false
      restore_process_lines = true
    end
    if (terminator = options[:terminator])
      start_cursor = options[:cursor] || cursor
      break_on_blank_lines = false
      break_on_list_continuation = false
    else
      break_on_blank_lines = options[:break_on_blank_lines]
      break_on_list_continuation = options[:break_on_list_continuation]
    end
    skip_comments = options[:skip_line_comments]
    line_read = line_restored = nil
    shift if options[:skip_first_line]
    while (line = read_line)
      if terminator ? line == terminator : ((break_on_blank_lines && line.empty?) ||
          (break_on_list_continuation && line_read && line == LIST_CONTINUATION && (options[:preserve_last_line] = true)) ||
          (block_given? && (yield line)))
        result << line if options[:read_last_line]
        if options[:preserve_last_line]
          unshift line
          line_restored = true
        end
        break
      end
      unless skip_comments && (line.start_with? '//') && !(line.start_with? '///')
        result << line
        line_read = true
      end
    end
    if restore_process_lines
      @process_lines = true
      @look_ahead -= 1 if line_restored && !terminator
    end
    if terminator && terminator != line && (context = options.fetch :context, terminator)
      start_cursor = cursor_at_mark if start_cursor == :at_mark
      logger.warn message_with_context %(unterminated #{context} block), source_location: start_cursor
      @unterminated = true
    end
    result
  end

  # Internal: Shift the line off the stack and increment the lineno
  #
  # This method can be used directly when you've already called peek_line
  # and determined that you do, in fact, want to pluck that line off the stack.
  # Use read_line if the line hasn't (or many not have been) visited yet.
  #
  # Returns The String line at the top of the stack
  def shift
    @lineno += 1
    @look_ahead -= 1 unless @look_ahead == 0
    @lines.pop
  end

  # Internal: Restore the line to the stack and decrement the lineno
  def unshift line
    @lineno -= 1
    @look_ahead += 1
    @lines.push line
    nil
  end

  if ::RUBY_ENGINE == 'jruby'
    # Internal: Restore the lines to the stack and decrement the lineno
    def unshift_all lines_to_restore
      @lineno -= lines_to_restore.size
      @look_ahead += lines_to_restore.size
      if lines_to_restore.respond_to? :reverse
        @lines.push(*lines_to_restore.reverse)
      else
        lines_to_restore.reverse_each {|it| @lines.push it }
      end
      nil
    end
  else
    # Internal: Restore the lines to the stack and decrement the lineno
    def unshift_all lines_to_restore
      @lineno -= lines_to_restore.size
      @look_ahead += lines_to_restore.size
      @lines.push(*lines_to_restore.reverse)
      nil
    end
  end

  def cursor
    Cursor.new @file, @dir, @path, @lineno
  end

  def cursor_at_line lineno
    Cursor.new @file, @dir, @path, lineno
  end

  def cursor_at_mark
    @mark ? Cursor.new(*@mark) : cursor
  end

  def cursor_before_mark
    if @mark
      m_file, m_dir, m_path, m_lineno = @mark
      Cursor.new m_file, m_dir, m_path, m_lineno - 1
    else
      Cursor.new @file, @dir, @path, @lineno - 1
    end
  end

  def cursor_at_prev_line
    Cursor.new @file, @dir, @path, @lineno - 1
  end

  def mark
    @mark = @file, @dir, @path, @lineno
  end

  # Public: Get information about the last line read, including file name and line number.
  #
  # Returns A String summary of the last line read
  def line_info
    %(#{@path}: line #{@lineno})
  end

  # Public: Get a copy of the remaining Array of String lines managed by this Reader
  #
  # Returns A copy of the String Array of lines remaining in this Reader
  def lines
    @lines.reverse
  end

  # Public: Get a copy of the remaining lines managed by this Reader joined as a String
  def string
    @lines.reverse.join LF
  end

  # Public: Get the source lines for this Reader joined as a String
  def source
    @source_lines.join LF
  end

  # Internal: Save the state of the reader at cursor
  def save
    @saved = {}.tap do |accum|
      instance_variables.each do |name|
        unless name == :@saved || name == :@source_lines
          accum[name] = ::Array === (val = instance_variable_get name) ? (val.drop 0) : val
        end
      end
    end
    nil
  end

  # Internal: Restore the state of the reader at cursor
  def restore_save
    if @saved
      @saved.each do |name, val|
        instance_variable_set name, val
      end
      @saved = nil
    end
  end

  # Internal: Discard a previous saved state
  def discard_save
    @saved = nil
  end

  def to_s
    %(#<#{self.class}@#{object_id} {path: #{@path.inspect}, line: #{@lineno}}>)
  end

  private

  # Internal: Prepare the source data for parsing.
  #
  # Converts the source data into an Array of lines ready for parsing. If the +:normalize+ option is set, this method
  # coerces the encoding of each line to UTF-8 and strips trailing whitespace, including the newline. (This whitespace
  # cleaning is very important to how Asciidoctor works). Subclasses may choose to perform additional preparation.
  #
  # data - A String Array or String of source data to be normalized.
  # opts - A Hash of options to control how lines are prepared.
  #        :normalize - Enables line normalization, which coerces the encoding to UTF-8 and removes trailing whitespace;
  #        :rstrip removes all trailing whitespace; :chomp removes trailing newline only (optional, not set).
  #
  # Returns A String Array of source lines. If the source data is an Array, this method returns a copy.
  def prepare_lines data, opts = {}
    if (normalize = opts[:normalize])
      ::Array === data ? (Helpers.prepare_source_array data, normalize != :chomp) : (Helpers.prepare_source_string data, normalize != :chomp)
    elsif ::Array === data
      data.drop 0
    elsif data
      data.chomp.split LF, -1
    else
      []
    end
  rescue
    if (::Array === data ? data.join : data.to_s).valid_encoding?
      raise
    else
      raise ::ArgumentError, 'source is either binary or contains invalid Unicode data'
    end
  end

  # Internal: Processes a previously unvisited line
  #
  # By default, this method marks the line as processed
  # by incrementing the look_ahead counter and returns
  # the line unmodified.
  #
  # Returns The String line the Reader should make available to the next
  # invocation of Reader#read_line or nil if the Reader should drop the line,
  # advance to the next line and process it.
  def process_line line
    @look_ahead += 1 if @process_lines
    line
  end
end

# Public: Methods for retrieving lines from AsciiDoc source files, evaluating preprocessor
# directives as each line is read off the Array of lines.
class PreprocessorReader < Reader
  attr_reader :include_stack

  # Public: Initialize the PreprocessorReader object
  def initialize document, data = nil, cursor = nil, opts = {}
    @document = document
    super data, cursor, opts
    if (default_include_depth = (document.attributes['max-include-depth'] || 64).to_i) > 0
      # track absolute max depth, current max depth for comparing to include stack size, and relative max depth for reporting
      @maxdepth = { abs: default_include_depth, curr: default_include_depth, rel: default_include_depth }
    else
      # if @maxdepth is not set, built-in include functionality is disabled
      @maxdepth = nil
    end
    @include_stack = []
    @includes = document.catalog[:includes]
    @skipping = false
    @conditional_stack = []
    @include_processor_extensions = nil
  end

  # (see Reader#has_more_lines?)
  def has_more_lines?
    peek_line ? true : false
  end

  # (see Reader#empty?)
  def empty?
    peek_line ? false : true
  end
  alias eof? empty?

  # Public: Override the Reader#peek_line method to pop the include
  # stack if the last line has been reached and there's at least
  # one include on the stack.
  #
  # Returns the next line of the source data as a String if there are lines remaining
  # in the current include context or a parent include context.
  # Returns nothing if there are no more lines remaining and the include stack is empty.
  def peek_line direct = false
    if (line = super)
      line
    elsif @include_stack.empty?
      nil
    else
      pop_include
      peek_line direct
    end
  end

  # Public: Push source onto the front of the reader and switch the context
  # based on the file, document-relative path and line information given.
  #
  # This method is typically used in an IncludeProcessor to add source
  # read from the target specified.
  #
  # Examples
  #
  #    path = 'partial.adoc'
  #    file = File.expand_path path
  #    data = File.read file
  #    reader.push_include data, file, path
  #
  # Returns this Reader object.
  def push_include data, file = nil, path = nil, lineno = 1, attributes = {}
    @include_stack << [@lines, @file, @dir, @path, @lineno, @maxdepth, @process_lines]
    if (@file = file)
      # NOTE if file is not a string, assume it's a URI
      if ::String === file
        @dir = ::File.dirname file
      elsif RUBY_ENGINE_OPAL
        @dir = ::URI.parse ::File.dirname(file = file.to_s)
      else
        # NOTE this intentionally throws an error if URI has no path
        (@dir = file.dup).path = (dir = ::File.dirname file.path) == '/' ? '' : dir
        file = file.to_s
      end
      @path = (path ||= ::File.basename file)
      # only process lines in AsciiDoc files
      if (@process_lines = file.end_with?(*ASCIIDOC_EXTENSIONS.keys))
        # NOTE registering the include with a nil value tracks it while not making it visible to interdocument xrefs
        @includes[path.slice 0, (path.rindex '.')] ||= attributes['partial-option'] ? nil : true
      end
    else
      @dir = '.'
      # we don't know what file type we have, so assume AsciiDoc
      @process_lines = true
      if (@path = path)
        # NOTE registering the include with a nil value tracks it while not making it visible to interdocument xrefs
        @includes[Helpers.rootname path] ||= attributes['partial-option'] ? nil : true
      else
        @path = '<stdin>'
      end
    end

    @lineno = lineno

    if @maxdepth && (attributes.key? 'depth')
      if (rel_maxdepth = attributes['depth'].to_i) > 0
        if (curr_maxdepth = @include_stack.size + rel_maxdepth) > (abs_maxdepth = @maxdepth[:abs])
          # if relative depth exceeds absolute max depth, effectively ignore relative depth request
          curr_maxdepth = rel_maxdepth = abs_maxdepth
        end
        @maxdepth = { abs: abs_maxdepth, curr: curr_maxdepth, rel: rel_maxdepth }
      else
        @maxdepth = { abs: @maxdepth[:abs], curr: @include_stack.size, rel: 0 }
      end
    end

    # effectively fill the buffer
    if (@lines = prepare_lines data, normalize: @process_lines || :chomp, condense: false, indent: attributes['indent']).empty?
      pop_include
    else
      # FIXME we eventually want to handle leveloffset without affecting the lines
      if attributes.key? 'leveloffset'
        @lines = [((leveloffset = @document.attr 'leveloffset') ? %(:leveloffset: #{leveloffset}) : ':leveloffset!:'), ''] + @lines.reverse + ['', %(:leveloffset: #{attributes['leveloffset']})]
        # compensate for these extra lines at the top
        @lineno -= 2
      else
        @lines.reverse!
      end

      # FIXME kind of a hack
      #Document::AttributeEntry.new('infile', @file).save_to_next_block @document
      #Document::AttributeEntry.new('indir', @dir).save_to_next_block @document
      @look_ahead = 0
    end
    self
  end

  def include_depth
    @include_stack.size
  end

  # Public: Reports whether pushing an include on the include stack exceeds the max include depth.
  #
  # Returns nil if no max depth is set and includes are disabled (max-include-depth=0), false if the current max depth
  # will not be exceeded, and the relative max include depth if the current max depth will be exceed.
  def exceeds_max_depth?
    @maxdepth && @include_stack.size >= @maxdepth[:curr] && @maxdepth[:rel]
  end
  alias exceeded_max_depth? exceeds_max_depth?

  # TODO Document this override
  # also, we now have the field in the super class, so perhaps
  # just implement the logic there?
  def shift
    if @unescape_next_line
      @unescape_next_line = false
      (line = super).slice 1, line.length
    else
      super
    end
  end

  def include_processors?
    if @include_processor_extensions.nil?
      if @document.extensions? && @document.extensions.include_processors?
        !!(@include_processor_extensions = @document.extensions.include_processors)
      else
        @include_processor_extensions = false
      end
    else
      @include_processor_extensions != false
    end
  end

  def create_include_cursor file, path, lineno
    if ::String === file
      dir = ::File.dirname file
    elsif RUBY_ENGINE_OPAL
      dir = ::File.dirname(file = file.to_s)
    else
      dir = (dir = ::File.dirname file.path) == '' ? '/' : dir
      file = file.to_s
    end
    Cursor.new file, dir, path, lineno
  end

  def to_s
    %(#<#{self.class}@#{object_id} {path: #{@path.inspect}, line: #{@lineno}, include depth: #{@include_stack.size}, include stack: [#{@include_stack.map {|inc| inc.to_s }.join ', '}]}>)
  end

  private

  def prepare_lines data, opts = {}
    result = super

    # QUESTION should this work for AsciiDoc table cell content? Currently it does not.
    if @document && @document.attributes['skip-front-matter'] && (front_matter = skip_front_matter! result)
      @document.attributes['front-matter'] = front_matter.join LF
    end

    if opts.fetch :condense, true
      result.pop while (last = result[-1]) && last.empty?
    end

    Parser.adjust_indentation! result, opts[:indent].to_i, (@document.attr 'tabsize').to_i if opts[:indent]

    result
  end

  def process_line line
    return line unless @process_lines

    if line.empty?
      @look_ahead += 1
      return line
    end

    # NOTE highly optimized
    if line.end_with?(']') && !line.start_with?('[') && line.include?('::')
      if (line.include? 'if') && ConditionalDirectiveRx =~ line
        # if escaped, mark as processed and return line unescaped
        if $1 == '\\'
          @unescape_next_line = true
          @look_ahead += 1
          line.slice 1, line.length
        elsif preprocess_conditional_directive $2, $3, $4, $5
          # move the pointer past the conditional line
          shift
          # treat next line as uncharted territory
          nil
        else
          # the line was not a valid conditional line
          # mark it as visited and return it
          @look_ahead += 1
          line
        end
      elsif @skipping
        shift
        nil
      elsif (line.start_with? 'inc', '\\inc') && IncludeDirectiveRx =~ line
        # if escaped, mark as processed and return line unescaped
        if $1 == '\\'
          @unescape_next_line = true
          @look_ahead += 1
          line.slice 1, line.length
        # QUESTION should we strip whitespace from raw attributes in Substitutors#parse_attributes? (check perf)
        elsif preprocess_include_directive $2, $3
          # peek again since the content has changed
          nil
        else
          # the line was not a valid include line and is unchanged
          # mark it as visited and return it
          @look_ahead += 1
          line
        end
      else
        # NOTE optimization to inline super
        @look_ahead += 1
        line
      end
    elsif @skipping
      shift
      nil
    else
      # NOTE optimization to inline super
      @look_ahead += 1
      line
    end
  end

  # Internal: Preprocess the directive to conditionally include or exclude content.
  #
  # Preprocess the conditional directive (ifdef, ifndef, ifeval, endif) under
  # the cursor. If Reader is currently skipping content, then simply track the
  # open and close delimiters of any nested conditional blocks. If Reader is
  # not skipping, mark whether the condition is satisfied and continue
  # preprocessing recursively until the next line of available content is
  # found.
  #
  # keyword   - The conditional inclusion directive (ifdef, ifndef, ifeval, endif)
  # target    - The target, which is the name of one or more attributes that are
  #             used in the condition (blank in the case of the ifeval directive)
  # delimiter - The conditional delimiter for multiple attributes ('+' means all
  #             attributes must be defined or undefined, ',' means any of the attributes
  #             can be defined or undefined.
  # text      - The text associated with this directive (occurring between the square brackets)
  #             Used for a single-line conditional block in the case of the ifdef or
  #             ifndef directives, and for the conditional expression for the ifeval directive.
  #
  # Returns a Boolean indicating whether the cursor should be advanced
  def preprocess_conditional_directive keyword, target, delimiter, text
    # attributes are case insensitive
    target = target.downcase unless (no_target = target.empty?)

    if keyword == 'endif'
      if text
        logger.error message_with_context %(malformed preprocessor directive - text not permitted: endif::#{target}[#{text}]), source_location: cursor
      elsif @conditional_stack.empty?
        logger.error message_with_context %(unmatched preprocessor directive: endif::#{target}[]), source_location: cursor
      elsif no_target || target == (pair = @conditional_stack[-1])[:target]
        @conditional_stack.pop
        @skipping = @conditional_stack.empty? ? false : @conditional_stack[-1][:skipping]
      else
        logger.error message_with_context %(mismatched preprocessor directive: endif::#{target}[], expected endif::#{pair[:target]}[]), source_location: cursor
      end
      return true
    elsif @skipping
      skip = false
    else
      # QUESTION any way to wrap ifdef & ifndef logic up together?
      case keyword
      when 'ifdef'
        if no_target
          logger.error message_with_context %(malformed preprocessor directive - missing target: ifdef::[#{text}]), source_location: cursor
          return true
        end
        case delimiter
        when ','
          # skip if no attribute is defined
          skip = target.split(',', -1).none? {|name| @document.attributes.key? name }
        when '+'
          # skip if any attribute is undefined
          skip = target.split('+', -1).any? {|name| !@document.attributes.key? name }
        else
          # if the attribute is undefined, then skip
          skip = !@document.attributes.key?(target)
        end
      when 'ifndef'
        if no_target
          logger.error message_with_context %(malformed preprocessor directive - missing target: ifndef::[#{text}]), source_location: cursor
          return true
        end
        case delimiter
        when ','
          # skip if any attribute is defined
          skip = target.split(',', -1).any? {|name| @document.attributes.key? name }
        when '+'
          # skip if all attributes are defined
          skip = target.split('+', -1).all? {|name| @document.attributes.key? name }
        else
          # if the attribute is defined, then skip
          skip = @document.attributes.key?(target)
        end
      when 'ifeval'
        if no_target
          # the text in brackets must match a conditional expression
          if text && EvalExpressionRx =~ text.strip
            # NOTE assignments must happen before call to resolve_expr_val for compatibility with Opal
            lhs = $1
            # regex enforces a restricted set of math-related operations (==, !=, <=, >=, <, >)
            op = $2
            rhs = $3
            skip = ((resolve_expr_val lhs).send op, (resolve_expr_val rhs)) ? false : true rescue true
          else
            logger.error message_with_context %(malformed preprocessor directive - #{text ? 'invalid expression' : 'missing expression'}: ifeval::[#{text}]), source_location: cursor
            return true
          end
        else
          logger.error message_with_context %(malformed preprocessor directive - target not permitted: ifeval::#{target}[#{text}]), source_location: cursor
          return true
        end
      end
    end

    # conditional inclusion block
    if keyword == 'ifeval' || !text
      @skipping = true if skip
      @conditional_stack << { target: target, skip: skip, skipping: @skipping }
    # single line conditional inclusion
    else
      unless @skipping || skip
        replace_next_line text.rstrip
        # HACK push dummy line to stand in for the opening conditional directive that's subsequently dropped
        unshift ''
        # NOTE force line to be processed again if it looks like an include directive
        # QUESTION should we just call preprocess_include_directive here?
        @look_ahead -= 1 if text.start_with? 'include::'
      end
    end

    true
  end

  # Internal: Preprocess the directive to include lines from another document.
  #
  # Preprocess the directive to include the target document. The scenarios
  # are as follows:
  #
  # If SafeMode is SECURE or greater, the directive is ignore and the include
  # directive line is emitted verbatim.
  #
  # Otherwise, if an include processor is specified pass the target and
  # attributes to that processor and expect an Array of String lines in return.
  #
  # Otherwise, if the max depth is greater than 0, and is not exceeded by the
  # stack size, normalize the target path and read the lines onto the beginning
  # of the Array of source data.
  #
  # If none of the above apply, emit the include directive line verbatim.
  #
  # target   - The unsubstituted String name of the target document to include as specified in the
  #            target slot of the include directive.
  # attrlist - An attribute list String, which is the text between the square brackets of the
  #            include directive.
  #
  # Returns a [Boolean] indicating whether the line under the cursor was changed. To skip over the
  # directive, call shift and return true.
  def preprocess_include_directive target, attrlist
    doc = @document
    if ((expanded_target = target).include? ATTR_REF_HEAD) &&
        (expanded_target = doc.sub_attributes target, attribute_missing: ((attr_missing = doc.attributes['attribute-missing'] || Compliance.attribute_missing) == 'warn' ? 'drop-line' : attr_missing)).empty?
      if attr_missing == 'drop-line' && (doc.sub_attributes target + ' ', attribute_missing: 'drop-line', drop_line_severity: :ignore).empty?
        logger.info { message_with_context %(include dropped due to missing attribute: include::#{target}[#{attrlist}]), source_location: cursor }
        shift
        true
      elsif (doc.parse_attributes attrlist, [], sub_input: true)['optional-option']
        logger.info { message_with_context %(optional include dropped #{attr_missing == 'warn' && (doc.sub_attributes target + ' ', attribute_missing: 'drop-line', drop_line_severity: :ignore).empty? ? 'due to missing attribute' : 'because resolved target is blank'}: include::#{target}[#{attrlist}]), source_location: cursor }
        shift
        true
      else
        logger.warn message_with_context %(include dropped #{attr_missing == 'warn' && (doc.sub_attributes target + ' ', attribute_missing: 'drop-line', drop_line_severity: :ignore).empty? ? 'due to missing attribute' : 'because resolved target is blank'}: include::#{target}[#{attrlist}]), source_location: cursor
        # QUESTION should this line include target or expanded_target (or escaped target?)
        replace_next_line %(Unresolved directive in #{@path} - include::#{target}[#{attrlist}])
      end
    elsif include_processors? && (ext = @include_processor_extensions.find {|candidate| candidate.instance.handles? expanded_target })
      shift
      # FIXME parse attributes only if requested by extension
      ext.process_method[doc, self, expanded_target, (doc.parse_attributes attrlist, [], sub_input: true)]
      true
    # if running in SafeMode::SECURE or greater, don't process this directive
    # however, be friendly and at least make it a link to the source document
    elsif doc.safe >= SafeMode::SECURE
      # FIXME we don't want to use a link macro if we are in a verbatim context
      replace_next_line %(link:#{expanded_target}[role=include])
    elsif @maxdepth
      if @include_stack.size >= @maxdepth[:curr]
        logger.error message_with_context %(maximum include depth of #{@maxdepth[:rel]} exceeded), source_location: cursor
        return
      end

      parsed_attrs = doc.parse_attributes attrlist, [], sub_input: true
      inc_path, target_type, relpath = resolve_include_path expanded_target, attrlist, parsed_attrs
      case target_type
      when :file
        reader = ::File.method :open
        read_mode = FILE_READ_MODE
      when :uri
        reader = ::OpenURI.method :open_uri
        read_mode = URI_READ_MODE
      else
        # NOTE if target_type is not set, inc_path is a boolean to skip over (false) or reevaluate (true) the current line
        return inc_path
      end

      if (enc = parsed_attrs['encoding']) && (::Encoding.find enc rescue nil)
        (read_mode_params = read_mode.split ':')[1] = enc
        read_mode = read_mode_params.join ':'
      end unless RUBY_ENGINE_OPAL

      inc_linenos = inc_tags = nil
      # NOTE attrlist is nil if missing from include directive
      if attrlist
        if parsed_attrs.key? 'lines'
          inc_linenos = []
          (split_delimited_value parsed_attrs['lines']).each do |linedef|
            if linedef.include? '..'
              from, _, to = linedef.partition '..'
              inc_linenos += (to.empty? || (to = to.to_i) < 0) ? [from.to_i, ::Float::INFINITY] : (from.to_i..to).to_a
            else
              inc_linenos << linedef.to_i
            end
          end
          inc_linenos = inc_linenos.empty? ? nil : inc_linenos.sort.uniq
        elsif parsed_attrs.key? 'tag'
          unless (tag = parsed_attrs['tag']).empty? || tag == '!'
            inc_tags = (tag.start_with? '!') ? { (tag.slice 1, tag.length) => false } : { tag => true }
          end
        elsif parsed_attrs.key? 'tags'
          inc_tags = {}
          (split_delimited_value parsed_attrs['tags']).each do |tagdef|
            if tagdef.start_with? '!'
              inc_tags[tagdef.slice 1, tagdef.length] = false
            else
              inc_tags[tagdef] = true
            end unless tagdef.empty? || tagdef == '!'
          end
          inc_tags = nil if inc_tags.empty?
        end
      end

      if inc_linenos
        inc_lines, inc_offset, inc_lineno = [], nil, 0
        begin
          reader.call inc_path, read_mode do |f|
            select_remaining = nil
            f.each_line do |l|
              inc_lineno += 1
              if select_remaining || (::Float === (select = inc_linenos[0]) && (select_remaining = select.infinite?))
                # NOTE record line where we started selecting
                inc_offset ||= inc_lineno
                inc_lines << l
              else
                if select == inc_lineno
                  # NOTE record line where we started selecting
                  inc_offset ||= inc_lineno
                  inc_lines << l
                  inc_linenos.shift
                end
                break if inc_linenos.empty?
              end
            end
          end
        rescue
          logger.error message_with_context %(include #{target_type} not readable: #{inc_path}), source_location: cursor
          return replace_next_line %(Unresolved directive in #{@path} - include::#{expanded_target}[#{attrlist}])
        end
        shift
        # FIXME not accounting for skipped lines in reader line numbering
        if inc_offset
          parsed_attrs['partial-option'] = ''
          push_include inc_lines, inc_path, relpath, inc_offset, parsed_attrs
        end
      elsif inc_tags
        inc_lines, inc_offset, inc_lineno, tag_stack, tags_selected, active_tag = [], nil, 0, [], ::Set.new, nil
        if inc_tags.key? '**'
          select = base_select = inc_tags.delete '**'
          if inc_tags.key? '*'
            wildcard = inc_tags.delete '*'
          elsif !select && inc_tags.values.first == false
            wildcard = true
          end
        elsif inc_tags.key? '*'
          if inc_tags.keys.first == '*'
            select = base_select = !(wildcard = inc_tags.delete '*')
          else
            select = base_select = false
            wildcard = inc_tags.delete '*'
          end
        else
          select = base_select = !(inc_tags.value? true)
        end
        begin
          reader.call inc_path, read_mode do |f|
            dbl_co, dbl_sb = '::', '[]'
            f.each_line do |l|
              inc_lineno += 1
              if (l.include? dbl_co) && (l.include? dbl_sb) && TagDirectiveRx =~ l
                this_tag = $2
                if $1 # end tag
                  if this_tag == active_tag
                    tag_stack.pop
                    active_tag, select = tag_stack.empty? ? [nil, base_select] : tag_stack[-1]
                  elsif inc_tags.key? this_tag
                    include_cursor = create_include_cursor inc_path, expanded_target, inc_lineno
                    if (idx = tag_stack.rindex {|key,| key == this_tag })
                      idx == 0 ? tag_stack.shift : (tag_stack.delete_at idx)
                      logger.warn message_with_context %(mismatched end tag (expected '#{active_tag}' but found '#{this_tag}') at line #{inc_lineno} of include #{target_type}: #{inc_path}), source_location: cursor, include_location: include_cursor
                    else
                      logger.warn message_with_context %(unexpected end tag '#{this_tag}' at line #{inc_lineno} of include #{target_type}: #{inc_path}), source_location: cursor, include_location: include_cursor
                    end
                  end
                elsif inc_tags.key? this_tag
                  tags_selected << this_tag if (select = inc_tags[this_tag])
                  # QUESTION should we prevent tag from being selected when enclosing tag is excluded?
                  tag_stack << [(active_tag = this_tag), select, inc_lineno]
                elsif !wildcard.nil?
                  select = active_tag && !select ? false : wildcard
                  tag_stack << [(active_tag = this_tag), select, inc_lineno]
                end
              elsif select
                # NOTE record the line where we started selecting
                inc_offset ||= inc_lineno
                inc_lines << l
              end
            end
          end
        rescue
          logger.error message_with_context %(include #{target_type} not readable: #{inc_path}), source_location: cursor
          return replace_next_line %(Unresolved directive in #{@path} - include::#{expanded_target}[#{attrlist}])
        end
        unless tag_stack.empty?
          tag_stack.each do |tag_name, _, tag_lineno|
            logger.warn message_with_context %(detected unclosed tag '#{tag_name}' starting at line #{tag_lineno} of include #{target_type}: #{inc_path}), source_location: cursor, include_location: (create_include_cursor inc_path, expanded_target, tag_lineno)
          end
        end
        unless (missing_tags = inc_tags.keep_if {|_, v| v }.keys - tags_selected.to_a).empty?
          logger.warn message_with_context %(tag#{missing_tags.size > 1 ? 's' : ''} '#{missing_tags.join ', '}' not found in include #{target_type}: #{inc_path}), source_location: cursor
        end
        shift
        if inc_offset
          parsed_attrs['partial-option'] = '' unless base_select && wildcard != false && inc_tags.empty?
          # FIXME not accounting for skipped lines in reader line numbering
          push_include inc_lines, inc_path, relpath, inc_offset, parsed_attrs
        end
      else
        inc_content = nil
        begin
          # NOTE read content before shift so cursor is only advanced if IO operation succeeds
          inc_content = reader.call(inc_path, read_mode) {|f| f.read }
          shift
        rescue
          logger.error message_with_context %(include #{target_type} not readable: #{inc_path}), source_location: cursor
          return replace_next_line %(Unresolved directive in #{@path} - include::#{expanded_target}[#{attrlist}])
        end
        push_include inc_content, inc_path, relpath, 1, parsed_attrs
      end
      true
    end
  end

  # Internal: Resolve the target of an include directive.
  #
  # An internal method to resolve the target of an include directive. This method must return an
  # Array containing the resolved (absolute) path of the target, the target type (:file or :uri),
  # and the path of the target relative to the outermost document. Alternately, the method may
  # return a boolean to halt processing of the include directive line and to indicate whether the
  # cursor should be advanced beyond this line (true) or the line should be reprocessed (false).
  #
  # This method is overridden in Asciidoctor.js to resolve the target of an include in the browser
  # environment.
  #
  # target     - A String containing the unresolved include target.
  #              (Attribute references in target value have already been resolved).
  # attrlist   - An attribute list String (i.e., the text between the square brackets).
  # attributes - A Hash of attributes parsed from attrlist.
  #
  # Returns An Array containing the resolved (absolute) include path, the target type, and the path
  # relative to the outermost document. May also return a boolean to halt processing of the include.
  def resolve_include_path target, attrlist, attributes
    doc = @document
    if (Helpers.uriish? target) || (::String === @dir ? nil : (target = %(#{@dir}/#{target})))
      return replace_next_line %(link:#{target}[role=include]) unless doc.attr? 'allow-uri-read'
      if doc.attr? 'cache-uri'
        # caching requires the open-uri-cached gem to be installed
        # processing will be automatically aborted if these libraries can't be opened
        Helpers.require_library 'open-uri/cached', 'open-uri-cached' unless defined? ::OpenURI::Cache
      elsif !RUBY_ENGINE_OPAL
        # autoload open-uri
        ::OpenURI
      end
      [(::URI.parse target), :uri, target]
    else
      # include file is resolved relative to dir of current include, or base_dir if within original docfile
      inc_path = doc.normalize_system_path target, @dir, nil, target_name: 'include file'
      unless ::File.file? inc_path
        if attributes['optional-option']
          logger.info { message_with_context %(optional include dropped because include file not found: #{inc_path}), source_location: cursor }
          shift
          return true
        else
          logger.error message_with_context %(include file not found: #{inc_path}), source_location: cursor
          return replace_next_line %(Unresolved directive in #{@path} - include::#{target}[#{attrlist}])
        end
      end
      # NOTE relpath is the path relative to the root document (or base_dir, if set)
      # QUESTION should we move relative_path method to Document
      relpath = doc.path_resolver.relative_path inc_path, doc.base_dir
      [inc_path, :file, relpath]
    end
  end

  def pop_include
    unless @include_stack.empty?
      @lines, @file, @dir, @path, @lineno, @maxdepth, @process_lines = @include_stack.pop
      # FIXME kind of a hack
      #Document::AttributeEntry.new('infile', @file).save_to_next_block @document
      #Document::AttributeEntry.new('indir', ::File.dirname(@file)).save_to_next_block @document
      @look_ahead = 0
      nil
    end
  end

  # Private: Split delimited value on comma (if found), otherwise semi-colon
  def split_delimited_value val
    (val.include? ',') ? (val.split ',') : (val.split ';')
  end

  # Private: Ignore front-matter, commonly used in static site generators
  def skip_front_matter! data, increment_linenos = true
    return unless (delim = data[0]) == '---'
    original_data = data.drop 0
    data.shift
    front_matter = []
    @lineno += 1 if increment_linenos
    until (eof = data.empty?) || data[0] == delim
      front_matter << data.shift
      @lineno += 1 if increment_linenos
    end
    if eof
      data.unshift(*original_data)
      @lineno -= original_data.size if increment_linenos
      return
    end
    data.shift
    @lineno += 1 if increment_linenos
    front_matter
  end

  # Private: Resolve the value of one side of the expression
  #
  # Examples
  #
  #   expr = '"value"'
  #   resolve_expr_val expr
  #   # => "value"
  #
  #   expr = '"value'
  #   resolve_expr_val expr
  #   # => "\"value"
  #
  #   expr = '"{undefined}"'
  #   resolve_expr_val expr
  #   # => ""
  #
  #   expr = '{undefined}'
  #   resolve_expr_val expr
  #   # => nil
  #
  #   expr = '2'
  #   resolve_expr_val expr
  #   # => 2
  #
  #   @document.attributes['name'] = 'value'
  #   expr = '"{name}"'
  #   resolve_expr_val expr
  #   # => "value"
  #
  # Returns The value of the expression, coerced to the appropriate type
  def resolve_expr_val val
    if ((val.start_with? '"') && (val.end_with? '"')) ||
        ((val.start_with? '\'') && (val.end_with? '\''))
      quoted = true
      val = val.slice 1, (val.length - 1)
    else
      quoted = false
    end

    # QUESTION should we substitute first?
    # QUESTION should we also require string to be single quoted (like block attribute values?)
    val = @document.sub_attributes val, attribute_missing: 'drop' if val.include? ATTR_REF_HEAD

    if quoted
      val
    elsif val.empty?
      nil
    elsif val == 'true'
      true
    elsif val == 'false'
      false
    elsif val.rstrip.empty?
      ' '
    elsif val.include? '.'
      val.to_f
    else
      # fallback to coercing to integer, since we
      # require string values to be explicitly quoted
      val.to_i
    end
  end
end
end