lib/random-words/generator.rb



#!/usr/bin/env ruby
# frozen_string_literal: true

# Random Sentence Generator
# This script generates random sentences using a variety of words and structures.
# It includes nouns, verbs, adjectives, adverbs, articles, clauses, and more.
module RandomWords
  # Random character, word, and sentence generator
  class Generator
    # @return [Array<String>] arrays of elements of speech
    attr_accessor :nouns, :verbs, :passive_verbs, :adverbs, :adjectives, :articles, :clauses, :subordinate_conjunctions,
                  :terminators, :numbers, :plural_nouns, :plural_verbs, :plural_articles

    # @return [Integer] Number of sentences in paragraphs
    attr_reader :paragraph_length

    # @return [Symbol] Sentence length (:short, :medium, :long, :very_long)
    attr_reader :sentence_length

    # @return [Symbol] Dictionary in use
    attr_reader :source

    # @return [Boolean] Testing mode
    attr_accessor :testing

    # Define the default sentence parts
    # These parts will be used to generate random sentences and character strings
    SENTENCE_PARTS = %w[random_article random_adjective random_noun random_adverb random_verb random_adjective
                        random_verb random_adverb].freeze

    # Initialize the generator with a source and options
    # @param source [Symbol] The source of the words (e.g., :english)
    # @param _options [Hash] Options for the generator (e.g., length, paragraph_length)
    # @example
    #   generator = RandomWords::Generator.new(:english, sentence_length: :medium, paragraph_length: 5)
    #   generator.source = :french
    #   generator.lengths = { short: 50, medium: 150 }
    #   generator.sentence_length = :long
    #   generator.paragraph_length = 3
    def initialize(source = :english, options = {})
      @source = source
      @nouns = from_file('nouns-singular.txt')
      @plural_nouns = from_file('nouns-plural.txt')
      @verbs = from_file('verbs-singular.txt')
      @plural_verbs = from_file('verbs-plural.txt')
      @passive_verbs = from_file('verbs-passive.txt')
      @adverbs = from_file('adverbs.txt')
      @adjectives = from_file('adjectives.txt')
      @articles = from_file('articles-singular.txt')
      @plural_articles = from_file('articles-plural.txt')
      @clauses = from_file('clauses.txt')
      @subordinate_conjunctions = from_file('conjunctions-subordinate.txt')

      @numbers = from_file('numbers.txt')

      @options = {
        sentence_length: :medium,
        paragraph_length: 5
      }
      @options.merge!(options) if options.is_a?(Hash)
      @sentence_length = @options[:sentence_length]
      @paragraph_length = @options[:paragraph_length]
      lengths
    end

    # Define number of sentences in paragraphs
    # @param length [Integer] The number of sentences in the paragraph
    def paragraph_length=(length)
      raise ArgumentError, 'Paragraph length must be a positive integer' unless length.is_a?(Integer) && length.positive?

      @paragraph_length = length
    end

    # Define sentence length
    # @param length [Symbol] :short, :medium, :long, or :very_long
    def sentence_length=(length)
      to_set = case length.to_s
               when /^s/
                 :short
               when /^m/
                 :medium
               when /^l/
                 :long
               when /^v/
                 :very_long
               else
                 raise ArgumentError, "Invalid length: #{length}. Use :short, :medium, :long, or :very_long."
               end
      @sentence_length = to_set
    end

    # Bad init method for testing purposes
    # @!visibility private
    def bad_init
      @nouns = from_file('nouns-noent.txt')
    end

    # define all lengths for testing purposes
    # @!visibility private
    def define_all_lengths
      @lengths = {
        short: 60,
        medium: 200,
        long: 300,
        very_long: 500
      }
      res = []
      res << define_length(:short)
      res << define_length(:medium)
      res << define_length(:long)
      res << define_length(:very_long)
      res
    end

    # Define a bad length for testing purposes
    # @!visibility private
    def bad_length
      define_length(:bad_length)
    end

    # Test random generators
    # These methods are used to test the random generation of words and sentences
    # @!visibility private
    def test_random
      @testing = true
      res = []
      res << random_noun
      res << random_verb
      res << random_adjective
      res << random_adverb
      res << random_article
      res << random_article_for_noun('apple')
      res << random_article_for_noun('apples')
      res << random_article_for_noun('banana')
      res << random_article_for_noun('bananas')
      res << random_plural_article
      res << random_clause
      res << random_subordinate_conjunction
      res << random_number_with_plural
      res << random_conjunction
      res << random_passive_verb
      res << random_plural_noun
      res << random_plural_verb
      res << generate_additional_clauses.join(' ')
      res
    end

    # Define a new source dictionary and re-initialize
    def source=(new_source)
      initialize(new_source)
    end

    # Refactored lengths and lengths= methods
    # This method returns the lengths of sentences
    # The default lengths are set to the following values:
    # short: 60, medium: 200, long: 300, very_long: 500
    def lengths
      @lengths ||= { short: 60, medium: 200, long: 300, very_long: 500 }
    end

    # This method allows you to set new lengths for the sentences
    # It merges the new lengths with the existing ones
    # Example: lengths = { short: 50, medium: 150 }
    # @param new_lengths [Hash] A hash containing the new lengths for the sentences
    # @return [Hash] The updated lengths hash
    # @example
    #   lengths = { short: 50, medium: 150 }
    def lengths=(new_lengths)
      @lengths = lengths.merge(new_lengths)
    end

    # Generate a random word
    # @return [String] A randomly generated word
    def word
      generate_word
    end

    # Generate a set number of random words
    # @param number [Integer] The number of words to generate
    def words(number)
      result = SENTENCE_PARTS.cycle.take(number).map { |part| send(part.to_sym) }.take(number)
      result.map do |word|
        word.split(/ /).last
      end.join(' ').compress
    end

    # Generate a series of random words up to a specified length
    # @param min [Integer] The minimum length of the generated string
    # @param max [Integer] (Optional) The maximum length of the generated string
    # @param whole_words [Boolean] (Optional) Whether to generate whole words or not
    # @param dead_switch [Integer] (Optional) A counter to prevent infinite loops
    # @return [String] The generated string of random words
    # @example
    #   characters(50) # Generates a string with at least 50 characters
    #   characters(50, 100) # Generates a string with between 50 and 100 characters
    #   characters(50, whole_words: false) # Generates a string with 50 characters allowing word truncation
    def characters(min, max = nil, whole_words: true, whitespace: true, dead_switch: 0)
      result = ''
      max ||= min
      raise ArgumentError, 'Infinite loop detected' if dead_switch > 20

      whole_words = false if dead_switch > 15

      space = whitespace ? ' ' : ''
      current_part = 0
      while result.length < max && result.length < min
        word = send(SENTENCE_PARTS[current_part].to_sym)
        word.gsub!(/ +/, '') unless whitespace
        current_part = (current_part + 1) % SENTENCE_PARTS.length
        new_result = "#{result}#{space}#{word}".compress

        if new_result.length > max
          return handle_overflow(OverflowConfig.new(new_result, result, min, max, whole_words, whitespace,
                                                    dead_switch))
        end
        return new_result if new_result.length == max

        result = new_result
      end

      result.strip
    end

    # Generate a random sentence
    # @param length [Integer] The desired length of the sentence in characters
    # @return [String] A randomly generated sentence
    def sentence(length = nil)
      generate_combined_sentence(length)
    end

    # Generate a specified number of random sentences
    # @param number [Integer] The number of sentences to generate
    # @return [Array] An array of generated sentences
    # @example
    #   sentences(5) # Generates an array of 5 random sentences
    def sentences(number)
      Array.new(number) { generate_combined_sentence }
    end

    # Generate a random sentence, combining multiple sentences if necessary
    # @param length [Symbol] The desired length of the sentence, :short, :medium, :long, or :very_long
    # @return [String] A randomly generated sentence
    # This method generates a random sentence and checks its length.
    # If the length is less than the defined length, it combines it with another sentence.
    # The final sentence is returned with proper capitalization and termination.
    # @example
    #   generate_combined_sentence # Generates a combined sentence
    def generate_combined_sentence(length = nil)
      length ||= define_length(@sentence_length)
      sentence = generate_sentence
      return sentence.capitalize.compress.terminate if sentence.length > length

      while sentence.length < length
        # Generate a random number of sentences to combine
        new_sentence = generate_sentence(length / 2)

        # Combine the sentences with random conjunctions
        sentence = "#{sentence.strip.no_term}, #{random_conjunction} #{new_sentence}"
      end

      sentence.capitalize.compress.terminate
    end

    # Generate a random paragraph
    # @param length [Integer] The desired number of sentences in the paragraph
    # @return [String] A randomly generated paragraph
    # This method generates a random paragraph by combining multiple sentences.
    # It uses the generate_combined_sentence method to create each sentence.
    # @see #generate_combined_sentence
    def paragraph(length = @paragraph_length)
      sentences = []
      length.times do
        sentences << generate_combined_sentence
      end
      sentences.join(' ').strip.compress
    end

    private

    # Handle overflow when the new result exceeds the maximum length
    OverflowConfig = Struct.new(:new_result, :result, :min, :max, :whole_words, :whitespace, :dead_switch)

    def handle_overflow(config)
      space = config.whitespace ? ' ' : ''
      needed = config.max - config.result.compress.length - space.length
      config.min = config.max if config.min > config.max

      if needed > 1
        options = nouns_of_length(needed)
        return "#{config.result}#{space}#{options.sample}".compress unless options.empty?
      end

      if config.whole_words
        return characters(config.min, config.max, whole_words: config.whole_words, whitespace: config.whitespace,
                                                  dead_switch: config.dead_switch + 1)
      end

      truncated = config.new_result.compress[0...config.max]
      truncated.strip if truncated.strip.length.between?(config.min, config.max)
    end

    # Generate a random sentence with a specified length
    # @param length [Integer] The desired length of the sentence
    # @return [String] A randomly generated sentence
    # This method generates a random sentence with a specified length.
    # It has a 20% chance of including a random number with a plural noun and a main clause.
    # The sentence is constructed using various components such as nouns, verbs, adjectives, and adverbs.
    # @example
    #   generate_sentence(100) # Generates a sentence with a length of 100 characters
    #   generate_sentence # Generates a sentence with the default length
    def generate_sentence(length = nil)
      length ||= define_length(@sentence_length)
      sentence_components = []

      # Randomly decide if we include a plural noun with a number
      sentence_components << random_number_with_plural if roll(20) # 20% chance to include a plural noun

      # Construct main clause
      sentence_components << generate_main_clause

      # Include any additional clauses
      # sentence_components.concat(generate_additional_clauses)

      # while sentence_components.join(' ').strip.length < length
      #   # Randomly include a subordinate conjunction
      #   additional_clauses = generate_additional_clauses
      #   sentence_components.concat(additional_clauses)
      #   sentence_components.map!(&:strip)
      #   break if sentence_components.join(' ').length >= length

      #   conjunction = random_subordinate_conjunction.strip
      #   sentence_components.unshift(conjunction.capitalize) # Place conjunction at the start
      # end

      # Join all parts into a single sentence
      sentence_components.join(' ').strip.compress
    end

    # Load words from a dictionary file
    # Files are plain text with one word or phrase per line
    # The @source variable defines which dictionary to be used and should be defined before calling this method
    # @param filename [String] The name of the file to load
    # @return [Array] An array of words loaded from the file
    # @example
    #   from_file('nouns.txt') # Loads words from words/[source]/nouns.txt
    def from_file(filename)
      filename = "#{filename.sub(/\.txt$/, '')}.txt"
      path = File.join(__dir__, 'words', @source.to_s, filename)

      path = File.join(__dir__, 'words', 'english', filename) unless File.exist?(path)

      File.read(path).split("\n").map(&:strip) # Changed from split_lines to split("\n")
    rescue Errno::ENOENT
      warn "File not found: #{filename}"
      []
    end

    # Convert a length symbol to a specific length value
    # @param length [Symbol] The length symbol (:short, :medium, :long, or :very_long)
    # @return [Integer] The corresponding length value
    # @example
    #  define_length(:short) # Returns the length for :short
    def define_length(length)
      case length
      when :short
        @lengths[:short] || 60
      when :medium
        @lengths[:medium] || 200
      when :long
        @lengths[:long] || 300
      when :very_long
        @lengths[:very_long] || 500
      else
        raise ArgumentError, "Invalid length: #{length}. Use :short, :medium, or :long."
      end
    end

    # Get a list of nouns matching a specific length
    # @param length [Integer] The desired length of the nouns
    # @return [Array] An array of nouns with the specified length
    # @example
    #  nouns_of_length(5) # Returns an array of nouns with a length of 5 characters
    def nouns_of_length(length)
      nouns.select { |word| word.length == length }
    end

    # Generate a random conjunction
    # @return [String] A randomly selected conjunction
    # @example
    #  random_conjunction # Returns a random conjunction
    def random_conjunction
      %w[and or but].sample
    end

    # Generate a random number with a plural noun
    # @return [String] A string containing a number and a plural noun
    # Number names will be sourced from the numbers.txt file and provided in written not numeric form
    # @example
    #   random_number_with_plural # Returns a string like "three cats"
    def random_number_with_plural
      number = numbers.sample
      "#{number} #{random_plural_noun}"
    end

    # Generate a random noun
    # @return [String] A randomly selected noun
    def random_noun
      nouns.sample
    end

    # Generate a random plural noun
    # @return [String] A randomly selected plural noun
    def random_plural_noun
      plural_nouns.sample
    end

    # Generate a random verb
    # @return [String] A randomly selected verb
    def random_verb
      verbs.sample
    end

    # Generate a random plural verb
    # @return [String] A randomly selected plural verb
    def random_plural_verb
      plural_verbs.sample
    end

    # Generate a random passive verb
    # @return [String] A randomly selected passive verb
    def random_passive_verb
      passive_verbs.sample
    end

    # Generate a random adverb
    # @return [String] A randomly selected adverb
    def random_adverb
      adverbs.sample
    end

    # Generate a random adjective
    # @return [String] A randomly selected adjective
    def random_adjective
      adjectives.sample
    end

    # Generate a random article
    # @return [String] A randomly selected article
    def random_article
      articles.sample
    end

    # Generate a random article for a noun
    # @param noun [String] The noun for which to generate an article
    # @return [String] A randomly selected article for the given noun
    # This method checks if the noun is plural and selects an appropriate article.
    # If the noun starts with a vowel, it uses 'an' instead of 'a'.
    # @example
    #   random_article_for_noun('apple') # Returns 'an'
    def random_article_for_noun(noun)
      article = plural_nouns.include?(noun) ? random_plural_article : random_article
      article = 'an' if @testing
      if noun.start_with?(/[aeiou]/i) && article =~ /^an?$/
        article = 'an' if article == 'a'
      elsif article == 'an'
        article = 'a'
      end
      article
    end

    # Generate a random plural article
    # @return [String] A randomly selected plural article
    def random_plural_article
      plural_articles.sample
    end

    # Generate a random clause
    # @return [String] A randomly selected clause
    def random_clause
      clauses.sample
    end

    # Generate a random subordinate conjunction
    # @return [String] A randomly selected subordinate conjunction
    def random_subordinate_conjunction
      subordinate_conjunctions.sample
    end

    # Generate a random main clause
    # @return [String] A randomly generated main clause
    # This method constructs a main clause using a random number of words.
    # It randomly selects a noun, verb, adjective, and adverb to create a "coherent" sentence.
    # It has a 50% chance of including a random number with a plural noun.
    # It has a 20% chance of including a random clause at the end.
    # @example
    #   generate_main_clause # Returns a random main clause
    def generate_main_clause
      beginning = if roll(50)
                    "#{random_number_with_plural} #{random_adverb} #{random_plural_verb}"
                  else
                    noun = random_noun
                    "#{random_article_for_noun(noun)} #{random_adjective} #{noun} #{random_adverb} #{random_verb}"
                  end

      tail = roll(20) ? ", #{random_clause}" : ''
      "#{beginning.strip}#{tail.strip}"
    end

    # Simplified generate_additional_clauses
    def generate_additional_clauses
      Array.new(rand(1..2)) { random_clause }
    end

    # Roll a random number to determine if an action should occur
    # @param percent [Integer] 1-100 percent chance of the action occurring (1-100)
    # @return [Boolean] True if the action occurs, false otherwise
    # @example
    #   roll(50) # 50% chance of returning true
    def roll(percent)
      rand(1..100) <= percent
    end

    # Generate a random word
    # @return [String] A randomly generated word
    # This method constructs a random word using various components such as articles, adjectives, nouns, verbs, and adverbs.
    # It randomly decides whether to include each component based on a 50% chance.
    # @example
    #   generate_word # Returns a random word
    def generate_word
      send(SENTENCE_PARTS.sample)
    end
  end
end