jcraigk/story_key

View on GitHub
lib/story_key/encoder.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true
class StoryKey::Encoder < StoryKey::Base
  option :bitsize, default: -> {}
  option :format, default: -> {}
  option :key

  BASE58_REGEX = /\A[1-9A-Za-z]+\Z/
  DEC_REGEX = /\A\d+\Z/
  HEX_REGEX = /\A[\da-f]+\Z/
  BIN_REGEX = /\A[0-1]+\Z/
  COLORS = {
    adjective: 36,
    noun: 33,
    preposition: nil,
    slug: 31,
    verb: 35
  }.freeze

  def call
    @key = key.strip
    @format ||= StoryKey::FORMATS.first

    validate_format!
    validate_length!

    StoryKey::Story.new(phrases: text_phrases, text:, humanized:, tokenized:)
  end

  private

  def text_phrases
    raw_phrases.map { |str| remove_markup(str) }
  end

  def tokenized
    @tokenized ||= "#{StoryKey::VERSION_SLUG.downcase} #{token_str}"
  end

  def token_str
    entry_groups.flatten.map(&:token).join(' ')
  end

  def text
    @text ||= remove_markup(humanized).squish
  end

  def humanized
    @humanized ||= "#{version_str}#{newline}#{phrases.join(",\n")}."
  end

  def validate_length!
    return if bin_str.size <= StoryKey::MAX_BITSIZE
    raise StoryKey::KeyTooLarge, "Max input size is #{StoryKey::MAX_BITSIZE} bits"
  end

  def newline
    num_phrases == 1 ? ' ' : "\n"
  end

  def version_str
    "In #{colorize(StoryKey::VERSION_SLUG, COLORS[:slug])} I saw"
  end

  def last_segment_size
    bin_segments.last.size
  end

  # Checksum uses any remaining bits in last segment
  # Plus 6 more bits in the last word
  # Before the final 4 tail bits
  def checksum_bitsize
    (StoryKey::BITS_PER_ENTRY * 2) - (tail_bitsize + StoryKey::FOOTER_BITSIZE)
  end

  def tail_bitsize
    (bin_str.size % StoryKey::BITS_PER_ENTRY)
  end

  def num_phrases
    raw_phrases.size
  end

  def phrases
    raw_phrases.each_with_index.map do |phrase, idx|
      [].tap do |ary|
        if num_phrases > 1
          ary << "#{idx + 1}."
          ary << 'and' if idx == num_phrases - 1
        end
        ary << phrase
      end.join(' ')
    end
  end

  def highlight(entry)
    ary =
      if entry.preposition
        [entry.text.gsub(/\s#{entry.preposition}\Z/, ''), entry.preposition]
      else
        [entry.text]
      end
    main = colorize(ary[0], COLORS[entry.part_of_speech])
    prep = colorize(ary[1], COLORS[:preposition])
    "#{main} #{prep}".strip
  end

  def colorize(text, num)
    return text if text.blank? || num.blank?
    "\e[#{num}m#{text}\e[0m"
  end

  def raw_phrases
    @raw_phrases ||= entry_groups.map { |entries| grammatical_phrase(entries) }
  end

  def grammatical_phrase(entries)
    str = ''
    grammar = StoryKey::GRAMMAR[entries.size]
    grammar.each_with_index do |part_of_speech, idx|
      next if (entry = entries[idx]).blank?
      if add_article?(grammar, part_of_speech, idx, entries)
        str += "#{colorize(entry.text.indefinite_article, COLORS[:preposition])} "
      end
      str += "#{highlight(entry)} "
    end
    str.strip
  end

  # Always prefix modified noun with article
  # 'an envious Einstein kill Vader' vs
  # 'envious Einstein kill Vader'
  def add_article?(grammar, part_of_speech, idx, entries)
    noun_idx, force_countable =
      if part_of_speech == :adjective
        [idx + 1, true]
      elsif part_of_speech == :noun && grammar[idx - 1] != :adjective
        [idx, false]
      end
    force_countable || (noun_idx && entries[noun_idx].countable)
  end

  def entry_groups
    @entry_groups ||=
      decimals.each_slice(StoryKey::GRAMMAR.keys.max).to_a.map do |dec_group|
        dec_group.each_with_index.map do |decimal, idx|
          entry_from_decimal(StoryKey::GRAMMAR[dec_group.size], decimal, idx)
        end
      end
  end

  def entry_from_decimal(grammar, decimal, idx)
    part_of_speech = grammar[idx]
    entries = lex.entries[part_of_speech]
    entries[decimal].tap do
      # Shift entries to prevent repeats
      lex.entries[part_of_speech] =
        if decimal.zero?
          entries[1..]
        else
          entries[..(decimal - 1)] + entries[(decimal + 1)..]
        end
    end
  end

  def lex
    @lex ||= StoryKey::Lexicon.new
  end

  def decimals
    bin_segments.map do |str|
      StoryKey::Coercer.call(str:, from: :bin, to: :dec).to_i
    end
  end

  def bin_segments
    idx = 0
    parts = []
    while idx < binary_str.size
      parts << binary_str[idx..(idx + StoryKey::BITS_PER_ENTRY - 1)]
      idx += StoryKey::BITS_PER_ENTRY
    end
    parts
  end

  def validate_format!
    raise_invalid_key unless key.match?(format_regex)
  end

  def format_regex
    case format.to_sym
    when :bin then BIN_REGEX
    when :dec then DEC_REGEX
    when :hex then HEX_REGEX
    when :base58 then BASE58_REGEX
    else raise_invalid_format
    end
  end

  def binary_str
    @binary_str ||= bin_str + checksum + footer
  end

  def footer
    tail_bitsize.to_s(2).rjust(StoryKey::FOOTER_BITSIZE, '0')
  end

  def bin_str
    @bin_str ||= StoryKey::Coercer.call(str: key, bitsize:, from: format, to: :bin)
  end

  def checksum
    Digest::SHA256.hexdigest(bin_str)
                  .hex
                  .to_s(2)
                  .first(checksum_bitsize)
  end

  def raise_invalid_key
    raise StoryKey::InvalidFormat, "Invalid input for format '#{format}'"
  end

  def raise_invalid_format
    raise StoryKey::InvalidFormat, "Invalid format '#{format}'"
  end

  def remove_markup(str)
    str.gsub(/\e\[\d+m/, '').gsub(/\n\d+\./, '').delete("\n")
  end
end