lib/toys/module_lookup.rb
# frozen_string_literal: true require "monitor" module Toys ## # A helper module that provides methods to do module lookups. This is # used to obtain named helpers, middleware, and templates from the # respective modules. # class ModuleLookup class << self ## # Convert the given string to a path element. Specifically, converts # to `lower_snake_case`. # # @param str [String,Symbol] String to convert. # @return [String] Converted string # def to_path_name(str) str = str.to_s.sub(/^_/, "").sub(/_$/, "").gsub(/_+/, "_") while str.sub!(/([^_])([A-Z])/, "\\1_\\2") do end str.downcase end ## # Convert the given string to a module name. Specifically, converts # to `UpperCamelCase`, and then to a symbol. # # @param str [String,Symbol] String to convert. # @return [Symbol] Converted name # def to_module_name(str) str = str.to_s.sub(/^_/, "").sub(/_$/, "").gsub(/_+/, "_") str.to_s.gsub(/(?:^|_)([a-zA-Z])/) { ::Regexp.last_match(1).upcase }.to_sym end ## # Given a require path, return the module expected to be defined. # # @param path [String] File path, delimited by forward slash # @return [Module] The module loaded from that path # def path_to_module(path) path.split("/").reduce(::Object) do |running_mod, seg| mod_name = to_module_name(seg) unless running_mod.constants.include?(mod_name) raise ::NameError, "Module #{running_mod.name}::#{mod_name} not found" end running_mod.const_get(mod_name) end end end ## # Create an empty ModuleLookup # def initialize @mutex = ::Monitor.new @paths = [] @paths_locked = false end ## # Add a lookup path for modules. # # @param path_base [String] The base require path # @param module_base [Module] The base module, or `nil` (the default) to # infer a default from the path base. # @param high_priority [Boolean] If true, add to the head of the lookup # path, otherwise add to the end. # @return [self] # def add_path(path_base, module_base: nil, high_priority: false) module_base ||= ModuleLookup.path_to_module(path_base) @mutex.synchronize do raise "You cannot add a path after a lookup has already occurred." if @paths_locked if high_priority @paths.unshift([path_base, module_base]) else @paths << [path_base, module_base] end end self end ## # Obtain a named module. Returns `nil` if the name is not present. # # @param name [String,Symbol] The name of the module to return. # @return [Module] The specified module # def lookup(name) @mutex.synchronize do @paths_locked = true @paths.each do |path_base, module_base| path = "#{path_base}/#{ModuleLookup.to_path_name(name)}" begin require path rescue ::LoadError next end mod_name = ModuleLookup.to_module_name(name) unless module_base.constants.include?(mod_name) raise ::NameError, "File #{path.inspect} did not define #{module_base.name}::#{mod_name}" end return module_base.const_get(mod_name) end end nil end end end