glebm/i18n-tasks

View on GitHub
lib/i18n/tasks/data/tree/siblings.rb

Summary

Maintainability
D
1 day
Test Coverage
A
98%
# frozen_string_literal: true

require 'set'
require 'i18n/tasks/split_key'
require 'i18n/tasks/data/tree/nodes'

module I18n::Tasks::Data::Tree
  # Siblings represents a subtree sharing a common parent
  # in case of an empty parent (nil) it represents a forest
  # siblings' keys are unique
  class Siblings < Nodes # rubocop:disable Metrics/ClassLength
    include ::I18n::Tasks::SplitKey
    include ::I18n::Tasks::PluralKeys

    attr_reader :parent, :key_to_node

    def initialize(opts = {})
      super(nodes: opts[:nodes])
      @parent = opts[:parent] || first.try(:parent)
      @list.map! { |node| node.parent == @parent ? node : node.derive(parent: @parent) }
      @key_to_node = @list.each_with_object({}) { |node, h| h[node.key] = node }
      @warn_about_add_children_to_leaf = opts.fetch(:warn_about_add_children_to_leaf, true)
    end

    def attributes
      super.merge(parent: @parent)
    end

    def rename_key(key, new_key)
      node = key_to_node.delete(key)
      replace_node! node, node.derive(key: new_key)
      self
    end

    # @param from_pattern [Regexp]
    # @param to_pattern [Regexp]
    # @param root [Boolean]
    # @return {old key => new key}
    def mv_key!(from_pattern, to_pattern, root: false, retain: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
      moved_forest = Siblings.new
      moved_nodes = []
      old_key_to_new_key = {}
      nodes do |node|
        full_key = node.full_key(root: root)
        if from_pattern =~ full_key
          moved_nodes << node
          if to_pattern.empty?
            old_key_to_new_key[full_key] = nil
            next
          end
          match = $~
          new_key = to_pattern.gsub(/\\\d+/) { |m| match[m[1..].to_i] }
          old_key_to_new_key[full_key] = new_key
          moved_forest.merge!(Siblings.new.tap do |forest|
            forest[[(node.root.try(:key) unless root), new_key].compact.join('.')] =
              node.derive(key: split_key(new_key).last)
          end)
        end
      end
      # Adjust references
      # TODO: support nested references better
      nodes do |node|
        next unless node.reference?

        old_target = [(node.root.key if root), node.value.to_s].compact.join('.')
        new_target = old_key_to_new_key[old_target]
        if new_target
          new_target = new_target.sub(/\A[^.]*\./, '') if root
          node.value = new_target.to_sym
        end
      end
      remove_nodes_and_emptied_ancestors!(moved_nodes) unless retain
      merge! moved_forest
      old_key_to_new_key
    end

    def replace_node!(node, new_node)
      @list[@list.index(node)]  = new_node
      key_to_node[new_node.key] = new_node
    end

    # @return [Node] by full key
    def get(full_key)
      first_key, rest = split_key(full_key.to_s, 2)
      node            = key_to_node[first_key]
      node = node.children.try(:get, rest) if rest && node
      node
    end

    alias [] get

    # add or replace node by full key
    def set(full_key, node)
      fail 'value should be a I18n::Tasks::Data::Tree::Node' unless node.is_a?(Node)

      key_part, rest = split_key(full_key, 2)
      child          = key_to_node[key_part]

      if rest
        unless child
          child = Node.new(
            key: key_part,
            parent: parent,
            children: [],
            warn_about_add_children_to_leaf: @warn_about_add_children_to_leaf
          )
          append! child
        end
        unless child.children
          conditionally_warn_add_children_to_leaf(child, [])
          child.children = []
        end
        child.children.set rest, node
      else
        remove! child if child
        append! node
      end
      dirty!
      node
    end

    alias []= set

    # methods below change state

    def remove!(node)
      super
      key_to_node.delete(node.key)
      self
    end

    def append!(nodes)
      nodes = nodes.map do |node|
        fail "already has a child with key '#{node.key}'" if key_to_node.key?(node.key)

        key_to_node[node.key] = (node.parent == parent ? node : node.derive(parent: parent))
      end
      super(nodes)
      self
    end

    def append(nodes)
      derive.append!(nodes)
    end

    # @param on_leaves_merge [Proc] invoked when a leaf is merged with another leaf
    def merge!(nodes, on_leaves_merge: nil)
      nodes = Siblings.from_nested_hash(nodes) if nodes.is_a?(Hash)
      nodes.each do |node|
        merge_node! node, on_leaves_merge: on_leaves_merge
      end
      self
    end

    def merge(nodes)
      derive.merge!(nodes)
    end

    def subtract_keys(keys)
      remove_nodes_and_emptied_ancestors(find_nodes(keys))
    end

    def subtract_keys!(keys)
      remove_nodes_and_emptied_ancestors!(find_nodes(keys))
    end

    def subtract_by_key(other)
      subtract_keys other.key_names(root: true)
    end

    def subtract_by_key!(other)
      subtract_keys! other.key_names(root: true)
    end

    def set_root_key!(new_key, data = nil)
      return self if empty?

      rename_key first.key, new_key
      leaves { |node| node.data.merge! data } if data
      self
    end

    # @param on_leaves_merge [Proc] invoked when a leaf is merged with another leaf
    def merge_node!(node, on_leaves_merge: nil) # rubocop:disable Metrics/AbcSize
      if key_to_node.key?(node.key)
        our = key_to_node[node.key]
        return if our == node

        our.value = node.value if node.leaf?
        our.data.merge!(node.data) if node.data?
        if node.children?
          if our.children
            our.children.merge!(node.children)
          else
            conditionally_warn_add_children_to_leaf(our, node.children)
            our.children = node.children
          end
        elsif on_leaves_merge
          on_leaves_merge.call(our, node)
        end
      else
        @list << (key_to_node[node.key] = node.derive(parent: parent))
        dirty!
      end
    end

    # @param nodes [Enumerable] Modified in-place.
    def remove_nodes_and_emptied_ancestors(nodes)
      add_ancestors_that_only_contain_nodes! nodes
      select_nodes { |node| !nodes.include?(node) }
    end

    # @param nodes [Enumerable] Modified in-place.
    def remove_nodes_and_emptied_ancestors!(nodes)
      add_ancestors_that_only_contain_nodes! nodes
      select_nodes! { |node| !nodes.include?(node) }
    end

    private

    def find_nodes(keys)
      keys.each_with_object(Set.new) do |key, set|
        node = get(key)
        set << node if node
      end
    end

    # Adds all the ancestors that only contain the given nodes as descendants to the given nodes.
    # @param nodes [Set] Modified in-place.
    def add_ancestors_that_only_contain_nodes!(nodes)
      levels.reverse_each do |level_nodes|
        level_nodes.each { |node| nodes << node if node.children? && node.children.all? { |c| nodes.include?(c) } }
      end
    end

    def conditionally_warn_add_children_to_leaf(node, children)
      return unless @warn_about_add_children_to_leaf
      return if plural_forms?(children)

      warn_add_children_to_leaf(node)
    end

    def warn_add_children_to_leaf(node)
      ::I18n::Tasks::Logging.log_warn "'#{node.full_key}' was a leaf, now has children (value <- scope conflict)"
    end

    class << self
      include ::I18n::Tasks::SplitKey

      def null
        new
      end

      def build_forest(opts = {}, &block)
        opts[:nodes] ||= []
        parse_parent_opt!(opts)
        forest = Siblings.new(opts)
        yield(forest) if block
        # forest.parent.children = forest
        forest
      end

      # @param key_occurrences [I18n::Tasks::Scanners::KeyOccurrences]
      # @return [Siblings]
      def from_key_occurrences(key_occurrences)
        build_forest(warn_about_add_children_to_leaf: false) do |forest|
          key_occurrences.each do |key_occurrence|
            forest[key_occurrence.key] = ::I18n::Tasks::Data::Tree::Node.new(
              key: split_key(key_occurrence.key).last,
              data: { occurrences: key_occurrence.occurrences }
            )
          end
        end
      end

      def from_key_attr(key_attrs, opts = {}, &block)
        build_forest(opts) do |forest|
          key_attrs.each do |(full_key, attr)|
            fail "Invalid key #{full_key.inspect}" if full_key.end_with?('.')

            node = ::I18n::Tasks::Data::Tree::Node.new(**attr.merge(key: split_key(full_key).last))
            yield(full_key, node) if block
            forest[full_key] = node
          end
        end
      end

      def from_key_names(keys, opts = {}, &block)
        build_forest(opts) do |forest|
          keys.each do |full_key|
            node = ::I18n::Tasks::Data::Tree::Node.new(key: split_key(full_key).last)
            yield(full_key, node) if block
            forest[full_key] = node
          end
        end
      end

      # build forest from nested hash, e.g. {'es' => { 'common' => { name => 'Nombre', 'age' => 'Edad' } } }
      # this is the native i18n gem format
      def from_nested_hash(hash, opts = {})
        parse_parent_opt!(opts)
        fail I18n::Tasks::CommandError, "invalid tree #{hash.inspect}" unless hash.respond_to?(:map)

        opts[:nodes] = hash.map { |key, value| Node.from_key_value key, value }
        Siblings.new(opts)
      end

      alias [] from_nested_hash

      # build forest from [[Full Key, Value]]
      def from_flat_pairs(pairs)
        Siblings.new.tap do |siblings|
          pairs.each do |full_key, value|
            siblings[full_key] = ::I18n::Tasks::Data::Tree::Node.new(key: split_key(full_key).last, value: value)
          end
        end
      end

      private

      def parse_parent_opt!(opts)
        opts[:parent] = ::I18n::Tasks::Data::Tree::Node.new(key: opts[:parent_key]) if opts[:parent_key]
        opts[:parent] = ::I18n::Tasks::Data::Tree::Node.new(opts[:parent_attr]) if opts[:parent_attr]
        if opts[:parent_locale]
          opts[:parent] = ::I18n::Tasks::Data::Tree::Node.new(
            key: opts[:parent_locale], data: { locale: opts[:parent_locale] }
          )
        end
      end
    end
  end
end