sds/haml-lint

View on GitHub
lib/haml_lint/document.rb

Summary

Maintainability
A
35 mins
Test Coverage
# frozen_string_literal: true

require 'haml_lint/adapter'

module HamlLint
  # Represents a parsed Haml document and its associated metadata.
  class Document
    # File name given to source code parsed from just a string.
    STRING_SOURCE = '(string)'

    # @return [HamlLint::Configuration] Configuration used to parse template
    attr_reader :config

    # @return [String] Haml template file path
    attr_reader :file

    # @return [Boolean] true if source changes (from autocorrect) should be written to stdout instead of disk
    attr_reader :write_to_stdout

    # @return [HamlLint::Tree::Node] Root of the parse tree
    attr_reader :tree

    # @return [String] original source code
    attr_reader :source

    # @return [Array<String>] original source code as an array of lines
    attr_reader :source_lines

    # @return [Boolean] true if the source was changed (by autocorrect)
    attr_reader :source_was_changed

    # @return [String] the indentation used in the file
    attr_reader :indentation

    attr_reader :unescape_interpolation_to_original_cache

    # Parses the specified Haml code into a {Document}.
    #
    # @param source [String] Haml code to parse
    # @param options [Hash]
    # @option options :file [String] file name of document that was parsed
    # @option options :write_to_stdout [Boolean] true if source changes should be written to stdout
    # @raise [Haml::Parser::Error] if there was a problem parsing the document
    def initialize(source, options)
      @config = options[:config]
      @file = options.fetch(:file, STRING_SOURCE)
      @write_to_stdout = options[:write_to_stdout]
      @source_was_changed = false
      process_source(source)
    end

    # Returns the last non empty line of the document or 1 if all lines are empty
    #
    # @return [Integer] last non empty line of the document or 1 if all lines are empty
    def last_non_empty_line
      index = source_lines.rindex { |l| !l.empty? }
      (index || 0) + 1
    end

    # Reparses the new source and remember that the document was changed
    # Used when auto-correct does changes to the file. If the source hasn't changed,
    # then the document will not be marked as changed.
    #
    # If the new_source fails to parse, automatically reparses the previous source
    # to bring the document back to how it should be before re-raising the parse exception
    #
    # @param source [String] Haml code to parse
    def change_source(new_source)
      return if new_source == @source
      check_new_source_compatible(new_source)

      old_source = @source
      begin
        process_source(new_source)
        @source_was_changed = true
      rescue HamlLint::Exceptions::ParseError
        # Reprocess the previous_source so that other linters can work on this document
        # object from a clean slate
        process_source(old_source)
        raise
      end
      nil
    end

    def write_to_disk!
      return unless @source_was_changed
      if file == STRING_SOURCE
        raise HamlLint::Exceptions::InvalidFilePath, 'Cannot write without :file option'
      end
      if @write_to_stdout
        $stdout << unstrip_frontmatter(source)
      else
        File.write(file, unstrip_frontmatter(source))
      end
      @source_was_changed = false
    end

    private

    # @param source [String] Haml code to parse
    # @raise [HamlLint::Exceptions::ParseError] if there was a problem parsing
    def process_source(source) # rubocop:disable Metrics/MethodLength
      @source = process_encoding(source)
      @source = strip_frontmatter(source)
      # the -1 is to keep the empty strings at the end of the array when the source
      # ended with multiple new-lines
      @source_lines = @source.split(/\r\n|\r|\n/, -1)
      adapter = HamlLint::Adapter.detect_class.new(@source)
      parsed_tree = adapter.parse
      @indentation = adapter.send(:parser).instance_variable_get(:@indentation)
      @tree = process_tree(parsed_tree)
      @unescape_interpolation_to_original_cache =
        Haml::Util.unescape_interpolation_to_original_cache_take_and_wipe
    rescue Haml::Error => e
      location = if e.line
                   "#{@file}:#{e.line}"
                 else
                   @file
                 end
      msg = if ENV['HAML_LINT_DEBUG'] == 'true'
              "#{location} (DEBUG: source follows) - #{e.message}\n#{source}\n------"
            else
              "#{location} - #{e.message}"
            end
      error = HamlLint::Exceptions::ParseError.new(msg, e.line)
      raise error
    end

    # Processes the {Haml::Parser::ParseNode} tree and returns a tree composed
    # of friendlier {HamlLint::Tree::Node}s.
    #
    # @param original_tree [Haml::Parser::ParseNode]
    # @return [Haml::Tree::Node]
    def process_tree(original_tree)
      # Remove the trailing empty HAML comment that the parser creates to signal
      # the end of the HAML document
      if Gem::Requirement.new('~> 4.0.0').satisfied_by?(Gem.loaded_specs['haml'].version)
        original_tree.children.pop
      end

      @node_transformer = HamlLint::NodeTransformer.new(self)
      convert_tree(original_tree)
    end

    # Converts a HAML parse tree to a tree of {HamlLint::Tree::Node} objects.
    #
    # This provides a cleaner interface with which the linters can interact with
    # the parse tree.
    #
    # @param haml_node [Haml::Parser::ParseNode]
    # @param parent [Haml::Tree::Node]
    # @return [Haml::Tree::Node]
    def convert_tree(haml_node, parent = nil)
      new_node = @node_transformer.transform(haml_node)
      new_node.parent = parent

      new_node.children = haml_node.children.map do |child|
        convert_tree(child, new_node)
      end

      new_node
    end

    # Ensures source code is interpreted as UTF-8.
    #
    # This is necessary as sometimes Ruby guesses the encoding of a file
    # incorrectly, for example if the LC_ALL environment variable is set to "C".
    # @see http://unix.stackexchange.com/a/87763
    #
    # @param source [String]
    # @return [String] source encoded with UTF-8 encoding
    def process_encoding(source)
      source.force_encoding(Encoding::UTF_8)
    end

    # Removes YAML frontmatter
    def strip_frontmatter(source)
      frontmatter = /
        # From the start of the string
        \A
        # First-capture match --- followed by optional whitespace up
        # to a newline then 0 or more chars followed by an optional newline.
        # This matches the --- and the contents of the frontmatter
        (---\s*\n.*?\n?)
        # From the start of the line
        ^
        # Second capture match --- or ... followed by optional whitespace
        # and newline. This matches the closing --- for the frontmatter.
        (---|\.\.\.)\s*$\n?/mx

      if config['skip_frontmatter'] && match = source.match(frontmatter)
        @stripped_frontmatter = match[0]
        @nb_newlines_for_frontmatter = match[0].count("\n")
        source.sub!(frontmatter, "\n" * @nb_newlines_for_frontmatter)
      end

      source
    end

    def check_new_source_compatible(new_source)
      if @stripped_frontmatter && !new_source.start_with?("\n" * @nb_newlines_for_frontmatter)
        raise HamlLint::Exceptions::IncompatibleNewSource,
              "Internal error: new_source doesn't start with enough newlines for the Front Matter that was stripped"
      end
    end

    def unstrip_frontmatter(source)
      return source unless @stripped_frontmatter
      check_new_source_compatible(source)

      source.sub("\n" * @nb_newlines_for_frontmatter, @stripped_frontmatter)
    end
  end
end