lib/haml/compiler.rb



# frozen_string_literal: true

require 'haml/attribute_builder'
require 'haml/attribute_compiler'
require 'haml/temple_line_counter'

module Haml
  class Compiler
    include Haml::Util

    attr_accessor :options

    def initialize(options)
      @options     = Options.wrap(options)
      @to_merge    = []
      @temple      = [:multi]
      @node        = nil
      @filters     = Filters.defined.merge(options[:filters])
      @attribute_compiler = AttributeCompiler.new(@options)
    end

    def call(node)
      compile(node)
      @temple
    end

    def compile(node)
      parent, @node = @node, node
      if node.children.empty?
        send(:"compile_#{node.type}")
      else
        send(:"compile_#{node.type}") {node.children.each {|c| compile c}}
      end
    ensure
      @node = parent
    end

    private

    def compile_root
      @output_line = 1
      yield if block_given?
      flush_merged_text
    end

    def compile_plain
      push_text("#{@node.value[:text]}\n")
    end

    def nuke_inner_whitespace?(node)
      if node.value && node.value[:nuke_inner_whitespace]
        true
      elsif node.parent
        nuke_inner_whitespace?(node.parent)
      else
        false
      end
    end

    def compile_script(&block)
      push_script(@node.value[:text],
                  :preserve_script       => @node.value[:preserve],
                  :escape_html           => @node.value[:escape_html],
                  :nuke_inner_whitespace => nuke_inner_whitespace?(@node),
                  &block)
    end

    def compile_silent_script
      return if @options.suppress_eval
      push_silent(@node.value[:text])
      keyword = @node.value[:keyword]

      if block_given?
        yield
        push_silent("end", :can_suppress) unless @node.value[:dont_push_end]
      elsif keyword == "end"
        if @node.parent.children.last.equal?(@node)
          # Since this "end" is ending the block,
          # we don't need to generate an additional one
          @node.parent.value[:dont_push_end] = true
        end
        # Don't restore dont_* for end because it isn't a conditional branch.
      end
    end

    def compile_haml_comment; end

    def compile_tag
      t = @node.value

      # Get rid of whitespace outside of the tag if we need to
      rstrip_buffer! if t[:nuke_outer_whitespace]

      if @options.suppress_eval
        object_ref = :nil
        parse = false
        value = t[:parse] ? nil : t[:value]
        dynamic_attributes = Haml::Parser::DynamicAttributes.new
      else
        object_ref = t[:object_ref]
        parse = t[:parse]
        value = t[:value]
        dynamic_attributes = t[:dynamic_attributes]
      end

      if @options[:trace]
        t[:attributes].merge!({"data-trace" => @options.filename.split('/views').last << ":" << @node.line.to_s})
      end

      push_text("<#{t[:name]}")
      push_temple(@attribute_compiler.compile(t[:attributes], object_ref, dynamic_attributes))
      push_text(
        if t[:self_closing] && @options.xhtml?
          " />#{"\n" unless t[:nuke_outer_whitespace]}"
        else
          ">#{"\n" unless (t[:self_closing] && @options.html?) ? t[:nuke_outer_whitespace] : (!block_given? || t[:preserve_tag] || t[:nuke_inner_whitespace])}"
        end
      )

      if value && !parse
        push_text("#{value}</#{t[:name]}>#{"\n" unless t[:nuke_outer_whitespace]}")
      end

      return if t[:self_closing]

      if value.nil?
        yield if block_given?
        rstrip_buffer! if t[:nuke_inner_whitespace]
        push_text("</#{t[:name]}>#{"\n" unless t[:nuke_outer_whitespace]}")
        return
      end

      if parse
        push_script(value, t.merge(:in_tag => true))
        push_text("</#{t[:name]}>#{"\n" unless t[:nuke_outer_whitespace]}")
      end
    end

    def compile_comment
      condition = "#{@node.value[:conditional]}>" if @node.value[:conditional]
      revealed = @node.value[:revealed]

      open = "<!--#{condition}#{'<!-->' if revealed}"

      close = "#{'<!--' if revealed}#{'<![endif]' if condition}-->"

      unless block_given?
        push_text("#{open} ")

        if @node.value[:parse]
          push_script(@node.value[:text], :in_tag => true, :nuke_inner_whitespace => true)
        else
          push_text(@node.value[:text])
        end

        push_text(" #{close}\n")
        return
      end

      push_text("#{open}\n")
      yield if block_given?
      push_text("#{close}\n")
    end

    def compile_doctype
      doctype = text_for_doctype
      push_text("#{doctype}\n") if doctype
    end

    def compile_filter
      unless filter = @filters[@node.value[:name]]
        name = @node.value[:name]
        if ["maruku", "textile"].include?(name)
          raise Error.new(Error.message(:install_haml_contrib, name), @node.line - 1)
        else
          raise Error.new(Error.message(:filter_not_defined, name), @node.line - 1)
        end
      end
      filter.internal_compile(self, @node.value[:text])
    end

    def text_for_doctype
      if @node.value[:type] == "xml"
        return nil if @options.html?
        wrapper = @options.attr_wrapper
        return "<?xml version=#{wrapper}1.0#{wrapper} encoding=#{wrapper}#{@node.value[:encoding] || "utf-8"}#{wrapper} ?>"
      end

      if @options.html5?
        '<!DOCTYPE html>'
      else
        if @options.xhtml?
          if @node.value[:version] == "1.1"
            '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">'
          elsif @node.value[:version] == "5"
            '<!DOCTYPE html>'
          else
            case @node.value[:type]
            when "strict";   '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
            when "frameset"; '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">'
            when "mobile";   '<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">'
            when "rdfa";     '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">'
            when "basic";    '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">'
            else             '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'
            end
          end

        elsif @options.html4?
          case @node.value[:type]
          when "strict";   '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">'
          when "frameset"; '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">'
          else             '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'
          end
        end
      end
    end

    # Evaluates `text` in the context of the scope object, but
    # does not output the result.
    def push_silent(text, can_suppress = false)
      flush_merged_text
      return if can_suppress && @options.suppress_eval?
      newline = (text == "end") ? ";" : "\n"
      @temple << [:code, "#{resolve_newlines}#{text}#{newline}"]
      @output_line = @output_line + text.count("\n") + newline.count("\n")
    end

    # Adds `text` to `@buffer`.
    def push_text(text)
      @to_merge << [:text, text]
    end

    def push_temple(temple)
      flush_merged_text
      @temple.concat([[:newline]] * resolve_newlines.count("\n"))
      @temple << temple
      @output_line += TempleLineCounter.count_lines(temple)
    end

    def flush_merged_text
      return if @to_merge.empty?

      @to_merge.each do |type, val|
        case type
        when :text
          @temple << [:static, val]
        when :script
          @temple << [:dynamic, val]
        else
          raise SyntaxError.new("[HAML BUG] Undefined entry in Haml::Compiler@to_merge.")
        end
      end

      @to_merge = []
    end

    # Causes `text` to be evaluated in the context of
    # the scope object and the result to be added to `@buffer`.
    #
    # If `opts[:preserve_script]` is true, Haml::Helpers#find_and_preserve is run on
    # the result before it is added to `@buffer`
    def push_script(text, opts = {})
      return if @options.suppress_eval?

      no_format = !(opts[:preserve_script] || opts[:preserve_tag] || opts[:escape_html])

      unless block_given?
        push_generated_script(no_format ? "(#{text}\n).to_s" : build_script_formatter("(#{text}\n)", opts))
        push_text("\n") unless opts[:in_tag] || opts[:nuke_inner_whitespace]
        return
      end

      flush_merged_text
      push_silent "haml_temp = #{text}"
      yield
      push_silent('end', :can_suppress) unless @node.value[:dont_push_end]
      @temple << [:dynamic, no_format ? 'haml_temp.to_s;' : build_script_formatter('haml_temp', opts)]
    end

    def build_script_formatter(text, opts)
      text = "(#{text}).to_s"
      if opts[:escape_html]
        text = "::Haml::Helpers.html_escape(#{text})"
      end
      if opts[:nuke_inner_whitespace]
        text = "(#{text}).strip"
      end
      if opts[:preserve_tag]
        text = "_hamlout.fix_textareas!(::Haml::Helpers.preserve(#{text}))"
      elsif opts[:preserve_script]
        text = "_hamlout.fix_textareas!(::Haml::Helpers.find_and_preserve(#{text}, _hamlout.options[:preserve]))"
      end
      "#{text};"
    end

    def push_generated_script(text)
      @to_merge << [:script, resolve_newlines + text]
      @output_line += text.count("\n")
    end

    def resolve_newlines
      diff = @node.line - @output_line
      return "" if diff <= 0
      @output_line = @node.line
      "\n" * diff
    end

    # Get rid of and whitespace at the end of the buffer
    # or the merged text
    def rstrip_buffer!(index = -1)
      last = @to_merge[index]
      if last.nil?
        push_silent("_hamlout.rstrip!", false)
        return
      end

      case last.first
      when :text
        last[1] = last[1].rstrip
        if last[1].empty?
          @to_merge.slice! index
          rstrip_buffer! index
        end
      when :script
        last[1].gsub!(/\(haml_temp, (.*?)\);$/, '(haml_temp.rstrip, \1);')
        rstrip_buffer! index - 1
      else
        raise SyntaxError.new("[HAML BUG] Undefined entry in Haml::Compiler@to_merge.")
      end
    end
  end
end