enkessler/cuketagger

View on GitHub
lib/cuketagger/tagger.rb

Summary

Maintainability
A
1 hr
Test Coverage
module CukeTagger

  # The class responsible for tagging things
  class Tagger

    # The expected format of a cuketagger command
    USAGE = "#{File.basename $PROGRAM_NAME} [-v|--version] [-f|--force] [add|remove|replace]:TAG[:REPLACEMENT] [FILE[:LINE]]+".freeze # rubocop:disable Metrics/LineLength

    # Performs tagging based on the provided arguments
    def self.execute(args)
      new.execute(args)
    end

    # Performs tagging based on the provided arguments
    def execute(args)
      abort(USAGE) if args.empty? || args.first =~ /^(-h|--help)$/

      force = false

      args.each do |arg|
        case arg
          when /^-v|--version$/
            puts CukeTagger::Version
          when /^(.+?\.feature)((:\d+)*)$/
            add_feature Regexp.last_match(1), Regexp.last_match(2).to_s
          when /^(add|remove):(.+?)$/
            alterations << [Regexp.last_match(1).to_sym, Regexp.last_match(2)]
          when /^(replace):(.+?):(.+)$/
            alterations << [Regexp.last_match(1).to_sym, [Regexp.last_match(2), Regexp.last_match(3)]]
          when /^(-f|--force)$/
            force = true
          else
            abort(USAGE)
        end
      end

      alterations.uniq!

      files = features_to_change.map { |file, _line| file }.uniq
      files.each { |file| parse file, force }
    end


    private


    def parse(file_path, write)
      return unless feature_to_change?(file_path)

      content = File.readlines(file_path)

      feature_model = CukeModeler::FeatureFile.new(file_path).feature

      io = write ? File.open(file_path, 'w') : $stdout

      begin
        taggable_things = collect_taggable_models(feature_model)

        # Elements must be altered in the order that they appear in the file in order to
        # guarantee that any line adjustments are applied appropriately.
        taggable_things.sort! { |a, b| a.source_line <=> b.source_line }

        taggable_things.each do |thing|
          next unless thing_to_tag?(thing)

          alterations.each do |alteration|
            alter_thing(thing, alteration, content)
          end
        end

        content = content.join

        io.write(content)
      ensure
        io.close unless io == $stdout
      end
    end

    def should_alter?(uri, element)
      features_to_change.any? do |file, line|
        file == uri && (element.line == line || (line.nil? && element.is_a?(Gherkin::Formatter::Model::Feature)))
      end
    end

    def add_feature(path, lines)
      lines = lines.split(':')
      lines.delete ''

      if lines.empty?
        features_to_change << [path, nil]
      else
        lines.each do |line|
          features_to_change << [path, Integer(line)]
        end
      end
    end

    def alterations
      @alterations ||= []
    end

    # TODO: add warning if there are features that do not get changed (e.g. the user provided an incorrect
    # file/line number or replaces a non-existant tag)
    def features_to_change
      @features_to_change ||= Set.new
    end

    def feature_to_change?(file_name)
      features_to_change.any? { |name, _line_number| name == file_name }
    end

    def thing_to_tag?(thing)
      # TODO: pass in file name as well for performance?
      features_to_change.any? do |name, line_number|
        name_match   = (name == thing.get_ancestor(:feature_file).path)
        number_match = (thing.source_line == line_number)

        (name_match && number_match) || (name_match && thing.is_a?(CukeModeler::Feature) && line_number.nil?)
      end
    end

    def collect_taggable_models(feature_model)
      results = feature_model.query do
        select :model
        from scenarios, outlines, examples
      end
      [feature_model] + results.collect { |result| result[:model] }
    end

    def alter_thing(thing, alteration, content)
      case alteration.first
        when :add
          add_tag(thing, alteration.last, content)
        when :remove
          remove_tag(thing, alteration.last, content)
        when :replace
          replace_tag(thing, alteration.last.first, alteration.last.last, content)
        else
          raise "Unknown alteration type: #{alteration.first}"
      end
    end

    def replace_tag(thing, old_tag, new_tag, content)
      @file_offset  ||= Hash.new(0)
      @line_removed ||= {}

      relevant_tag = thing.tags.select { |tag_model| tag_model.name == "@#{old_tag}" }.first

      if relevant_tag
        insertion_index          = relevant_tag.source_line + @file_offset[thing.get_ancestor(:feature_file).path] - 1
        content[insertion_index] = content[insertion_index].sub("@#{old_tag}", "@#{new_tag}")
      else
        message = "expected \"@#{old_tag}\" at #{thing.get_ancestor(:feature_file).name}:#{thing.source_line}, skipping"
        $stderr.puts message
      end
    end

    def add_tag(thing, tag, content)
      @file_offset  ||= Hash.new(0)
      @line_removed ||= {}

      insertion_index = thing.source_line + @file_offset[thing.get_ancestor(:feature_file).path] - 2

      if new_line_needed?(thing, content, insertion_index)
        insertion_index += 1
        content.insert(insertion_index, '')

        @line_removed[thing]                                 = false # rubocop:disable Metrics/LineLength, Layout/SpaceAroundOperators, false positive
        @file_offset[thing.get_ancestor(:feature_file).path] += 1
      end

      empty_line = content[insertion_index].chomp =~ /^\s*$/
      trim_line(content, insertion_index, !empty_line)

      content[insertion_index] = content[insertion_index].chomp + "#{tag_spacing(content, insertion_index)}@#{tag}\n"
    end

    def remove_tag(thing, tag, content)
      @file_offset  ||= Hash.new(0)
      @line_removed ||= {}

      relevant_tag = thing.tags.select { |tag_model| tag_model.name == "@#{tag}" }.first

      return unless relevant_tag

      insertion_index          = relevant_tag.source_line + @file_offset[thing.get_ancestor(:feature_file).path] - 1
      content[insertion_index] = content[insertion_index].sub(/@#{Regexp.escape(tag)} ?/, '')

      trim_line(content, insertion_index, true)

      return unless content[insertion_index] =~ /^\s*$/

      content[insertion_index] = nil
      content.compact!
      @file_offset[thing.get_ancestor(:feature_file).path] -= 1
      @line_removed[thing]                                 = true # rubocop:disable Metrics/LineLength, Layout/SpaceAroundOperators, false positive
    end

    def trim_line(content, insertion_index, keep_indentation)
      line_match   = content[insertion_index].match(/^(\s*)(\S.*)?/)
      indentation  = line_match[1]
      line_content = line_match[2]

      trimmed_line = keep_indentation ? indentation : ''
      trimmed_line += line_content.squeeze(' ').strip if line_content
      trimmed_line = "#{trimmed_line.chomp}\n"

      content[insertion_index] = trimmed_line
    end

    def tag_spacing(content, insertion_index)
      if content[insertion_index] =~ /\S/
        ' '
      else
        next_line_leading_spaces = content[insertion_index + 1].match(/^(\s*)/)[1]
        ' ' * next_line_leading_spaces.length
      end
    end

    def new_line_needed?(thing, content, insertion_index)
      line_was_removed?(thing) || (non_tag_line?(content, insertion_index) && non_empty_line?(content, insertion_index))
    end

    def line_was_removed?(thing)
      @line_removed[thing]
    end

    def non_tag_line?(content, insertion_index)
      content[insertion_index] !~ /^\s*@/
    end

    def non_empty_line?(content, insertion_index)
      content[insertion_index] !~ /^\s*$/
    end

  end
end