require 'tap/root' require 'tap/support/constant' require 'tap/support/summary' require 'tap/support/manifest' module Tap #-- # Note that gems and env_paths reset envs -- custom modifications to envs will be lost # whenever these configs are reset. class Env include Support::Configurable include Enumerable @@instance = nil @@instances = {} @@manifests = {} class << self # Returns the active instance of Env. def instance @@instance end # A hash of (path, Env instance) pairs, generated by Env#instantiate. Used # to prevent infinite loops of Env dependencies by assigning a single Env # to a given path. def instances @@instances end # Creates a new Env for the specified path and adds it to Env#instances, or # returns the existing instance for the path. Paths can point to an env config # file, or to a directory. If a directory is provided, instantiate treats # path as the DEFAULT_CONFIG_FILE in that directory. All paths are expanded. # # e1 = Env.instantiate("./path/to/config.yml") # e2 = Env.instantiate("./path/to/dir") # # Env.instances # # => { # # File.expand_path("./path/to/config.yml") => e1, # # File.expand_path("./path/to/dir/#{Tap::Env::DEFAULT_CONFIG_FILE}") => e2 } # # The Env is initialized using configurations read from the env config file using # load_config, and a Root initialized to the config file directory. An instance # will be initialized regardless of whether the config file or directory exists. def instantiate(path_or_root, default_config={}, logger=nil) path = path_or_root.kind_of?(Root) ? path_or_root.root : path_or_root path = pathify(path) begin root = path_or_root.kind_of?(Root) ? path_or_root : Root.new(File.dirname(path)) config = default_config.merge(load_config(path)) # note the assignment of env to instances MUST occur before # reconfigure to prevent infinite looping (instances[path] = Env.new({}, root, logger)).reconfigure(config) do |unhandled_configs| yield(unhandled_configs) if block_given? end rescue(Exception) raise Env::ConfigError.new($!, path) end end def pathify(path) if File.directory?(path) || (!File.exists?(path) && File.extname(path) == "") path = File.join(path, DEFAULT_CONFIG_FILE) end File.expand_path(path) end def instance_for(path) path = pathify(path) instances.has_key?(path) ? instances[path] : instantiate(path) end # Returns the gemspec for the specified gem. A gem version # can be specified in the name, like 'gem >= 1.2'. The gem # will be activated using +gem+ if necessary. def gemspec(gem_name) return gem_name if gem_name.kind_of?(Gem::Specification) # figure the version of the gem, by default >= 0.0.0 gem_name.to_s =~ /^([^<=>]*)(.*)$/ name, version = $1.strip, $2 version = ">= 0.0.0" if version.empty? return nil if name.empty? # load the gem and get the spec gem(name, version) Gem.loaded_specs[name] end # Returns the gem name for all installed gems with a DEFAULT_CONFIG_FILE. # If latest==true, then only the names for the most current gem specs # will be returned. def known_gems(latest=true) index = latest ? Gem.source_index.latest_specs : Gem.source_index.gems.collect {|(name, spec)| spec } index.select do |spec| File.exists?(File.join(spec.full_gem_path, DEFAULT_CONFIG_FILE)) || File.exists?(File.join(spec.full_gem_path, DEFAULT_TASK_FILE)) end.sort end protected # Defines a config that raises an error if set when the # instance is active. static_config MUST take a block # and raises an error if a block is not given. def static_config(key, value=nil, &block) raise ArgumentError.new("active config requires block") unless block_given? instance_variable = "@#{key}".to_sym config_attr(key, value) do |input| check_configurable instance_variable_set(instance_variable, block.call(input)) end end # Defines a config that collects the input into a unique, # compact array where each member has been resolved using # root[]. In short, ['lib', nil, 'lib', 'alt] becomes # [root['lib'], root['alt']]. # # Single and nil arguments are allowed; they are arrayified # and handled as above. Path configs raise an error if # modified when the instance is active. def path_config(key, value=[]) instance_variable = "@#{key}".to_sym config_attr(key, value) do |input| check_configurable instance_variable_set(instance_variable, [*input].compact.collect {|path| root[path]}.uniq) end end #-- # To manifest simply requires an glob_ method which # yields each (key, path) pair for the manifested object in # a predictable order. # #-- # Alternate implementation would create the manifest for each individual # env, then merge the manifests. On the plus side, each env would then # carry it's own slice of the manifest without having to recalculate. # On the down side, the merging would have to occur in some separate # method that cannot be defined here. def manifest(name, paths_key, pattern, &block) return manifest(name, paths_key, pattern) do |context, path| [[path.chomp(File.extname(path)), path]] end unless block_given? glob_method = Support::Manifest.glob_method(name) module_eval %Q{ def #{glob_method} paths = [] self.#{paths_key}.each do |manifest_path| root.glob(manifest_path, "#{pattern}").each do |path| next if File.directory?(path) paths << [manifest_path, path] end end paths.sort_by {|mp, p| File.basename(p)} end } map_method = Support::Manifest.map_method(name) define_method(map_method, &block) protected glob_method, map_method end end # The global config file path GLOBAL_CONFIG_FILE = File.join(Gem.user_home, ".tap.yml") # The default config file path DEFAULT_CONFIG_FILE = "tap.yml" # The default task file path DEFAULT_TASK_FILE = "tapfile.rb" # The Root directory structure for self. attr_reader :root # Gets or sets the logger for self attr_accessor :logger # A hash of the manifests for self. attr_reader :manifests # Specify gems to load as nested Envs. Gems may be specified # by name and/or version, like 'gemname >= 1.2'; by default the # latest version of the gem is selected. # # Gems are immediately loaded (via gem) through this method. #-- # Note that the gems are resolved to gemspecs using Env.gemspec, # so self.gems returns an array of gemspecs. config_attr :gems, [] do |input| check_configurable @gems = [*input].compact.collect do |gem_name| spec = Env.gemspec(gem_name) case spec when nil then log(:warn, "unknown gem: #{gem_name}", Logger::WARN) else Env.instance_for(spec.full_gem_path) end spec end.uniq reset_envs end # Specify configuration files to load as nested Envs. config_attr :env_paths, [] do |input| check_configurable @env_paths = [*input].compact.collect do |path| Env.instance_for(root[path]).env_path end.uniq reset_envs end # Designate load paths. If use_dependencies == true, then # load_paths will be used for automatic loading of modules # through the active_support Dependencies module. path_config :load_paths, ["lib"] # Designate paths for discovering and executing commands. path_config :command_paths, ["cmd"] # Designate paths for discovering generators. path_config :generator_paths, ["lib"] manifest(:tasks, :load_paths, "**/*.rb") do |load_path, path| next unless document = Support::Lazydoc.scan_doc(path, 'manifest') document.const_names.collect do |const_name| if const_name.empty? key = root.relative_filepath(load_path, path).chomp('.rb') [key, Support::Constant.new(key.camelize, path)] else [const_name.underscore, Support::Constant.new(const_name, path)] end end end manifest(:commands, :command_paths, "**/*.rb") manifest(:generators, :generator_paths, '**/*_generator.rb') do |load_path, path| dirname = File.dirname(path) next unless "#{File.basename(dirname)}_generator.rb" == File.basename(path) next unless document = Support::Lazydoc.scan_doc(path, 'generator') document.const_names.collect do |const_name| if const_name.empty? key = root.relative_filepath(load_path, dirname) [key, Support::Constant.new((key + '_generator').camelize, path)] else [const_name.underscore, Support::Constant.new(const_name, path)] end end end def initialize(config={}, root=Tap::Root.new, logger=nil) @root = root @logger = logger @envs = [] @active = false @manifests = {} @manifested = [] # initialize these for reset_env @gems = [] @env_paths = [] initialize_config(config) end # Sets envs removing duplicates and instances of self. def envs=(envs) @envs = envs.uniq.delete_if {|e| e == self } @envs.freeze @flat_envs = nil end # An array of nested Envs, by default comprised of the # env_path + gem environments (in that order). These # nested Envs are activated/deactivated with self. # # Returns a flattened array of the unique nested envs # when flat == true. def envs(flat=false) flat ? (@flat_envs ||= self.flatten_envs.freeze) : @envs end # Unshifts env onto envs, removing duplicates. # Self cannot be unshifted onto self. def unshift(env) unless env == self || envs[0] == env self.envs = envs.dup.unshift(env) end envs end # Pushes env onto envs, removing duplicates. # Self cannot be pushed onto self. def push(env) unless env == self || envs[-1] == env envs = self.envs.reject {|e| e == env } self.envs = envs.push(env) end envs end # Passes each nested env to the block in order, starting with self. def each envs(true).each {|e| yield(e) } end # Passes each nested env to the block in reverse order, ending with self. def reverse_each envs(true).reverse_each {|e| yield(e) } end # Returns the total number of unique envs nested in self (including self). def count envs(true).length end # Returns a list of arrays that receive load_paths on activate, # by default [$LOAD_PATH]. If use_dependencies == true, then # Dependencies.load_paths will also be included. def load_path_targets [$LOAD_PATH] end # Processes and resets the input configurations for both root # and self. Reconfiguration consists of the following steps: # # * partition overrides into env, root, and other configs # * reconfigure root with the root configs # * reconfigure self with the env configs # * yield other configs to the block (if given) # # Reconfigure will always yields to the block, even if there # are no non-root, non-env configurations. Unspecified # configurations are NOT reconfigured. (Note this means # that existing path configurations like load_paths will # not automatically be reset using reconfigured root.) def reconfigure(overrides={}) check_configurable # partiton config into its parts env_configs = {} root_configs = {} other_configs = {} env_configurations = self.class.configurations root_configurations = root.class.configurations overrides.each_pair do |key, value| key = key.to_sym partition = case when env_configurations.key?(key) then env_configs when root_configurations.key?(key) then root_configs else other_configs end partition[key] = value end # reconfigure root so it can resolve path_configs root.reconfigure(root_configs) # reconfigure self super(env_configs) # handle other configs case when block_given? yield(other_configs) when !other_configs.empty? log(:warn, "ignoring non-env configs: #{other_configs.keys.join(',')}", Logger::DEBUG) end self end # Returns the path for self in Env.instances. def env_path Env.instances.each_pair {|path, env| return path if env == self } nil end # Logs the action and message at the input level (default INFO). # Logging is suppressed if no logger is set. def log(action, msg="", level=Logger::INFO) logger.add(level, msg, action.to_s) if logger end # Activates self by unshifting load_paths for self to the load_path_targets. # Once active, self can be referenced from Env.instance and the current # configurations are frozen. Env.instance is deactivated, if set, before # self is activated. Returns true if activate succeeded, or false if self # is already active. def activate return false if active? @active = true @@instance = self unless @@instance # freeze array configs like load_paths config.each_pair do |key, value| case value when Array then value.freeze end end # activate nested envs envs.reverse_each do |env| env.activate end # add load paths to load_path_targets load_path_targets.each do |target| load_paths.reverse_each do |path| target.unshift(path) end target.uniq! end true end # Deactivates self by clearing manifests and deleting load_paths for self # from the load_path_targets. Env.instance will no longer reference self # and the configurations are unfrozen (using duplication). # # Returns true if deactivate succeeded, or false if self is not active. def deactivate return false unless active? # remove load paths from load_path_targets load_path_targets.each do |target| load_paths.each do |path| target.delete(path) end end # unfreeze array configs by duplicating self.config.class_config.each_pair do |key, value| value = send(key) case value when Array then instance_variable_set("@#{key}", value.dup) end end @active = false @manifests.clear @@instance = nil if @@instance == self # dectivate nested envs envs.reverse_each do |env| env.deactivate end true end # Return true if self has been activated. def active? @active end # Cycles through all items yielded by the iterate_ method and # adds each to the manifests[name] hash. Freezes the hash when complete. # Simply returns the manifests[name] hash if frozen. def manifest(name) manifest = manifests[name] ||= Support::Manifest.new(name, self) manifest.entries.each do |key, path| yield(key, path) end if block_given? manifest.each_path do |context, path| next unless keys = send(manifest.map_method, context, path) keys.each {|entry| manifest.store(entry) } keys.each {|key, value| yield(key, value) } if block_given? end unless manifest.complete? manifest end def find(name, pattern) manifest(name) do |key, path| return path if Root.minimal_match?(key, pattern) end nil end def search(name, pattern) return find(name, pattern) if name == :envs envs = case pattern when /^(.*):([^:]+)$/ env_pattern = $1 pattern = $2 find(:envs, env_pattern) or raise(ArgumentError, "could not find env: #{env_pattern}") else manifest(:envs).values end envs.each do |env| if result = env.find(name, pattern) return result end end nil end def summary(name) summary = Support::Summary.new manifest(:envs).mini_map.each do |(key, env)| summary.add(key, env, env.manifest(name).mini_map) end summary end def summarize(name, &block) lines = summary(name).lines(&block) lines << "=== no #{name} found" if lines.empty? lines.join("\n") end def inspect(brief=false) brief ? "#<#{self.class}:#{object_id} root='#{root.root}'>" : super() end def to_s inspect(true) end #-- # Under construction #++ def handle_error(err) case when $DEBUG puts err.message puts puts err.backtrace else puts err.message end end protected # Iterates over each nested env, yielding the root path and env. # This is the manifest method for envs. def manifest_glob_envs collect {|env| [env.root.root, env] }.sort_by {|root, env| File.basename(root) } end def manifest_map(context, path) [[context, path]] end alias default_manifest_glob_tasks manifest_glob_tasks def manifest_glob_tasks paths = default_manifest_glob_tasks # very odd behaviors -- # * OS X is case-insensitive, apparently. Tapfile.rb and tapfile.rb are the same. # * require 'tapfile' does not work # * require 'tapfile.rb' works # * load 'tapfile' works # root.glob(:root, DEFAULT_TASK_FILE).each do |path| next if File.directory?(path) paths.unshift [root.root, path] end paths end # Raises an error if self is already active (and hence, configurations # should not be modified) def check_configurable raise "path configurations are disabled when active" if active? end # Resets envs using the current env_paths and gems. def reset_envs self.envs = env_paths.collect do |path| Env.instance_for(path) end + gems.collect do |spec| Env.instance_for(spec.full_gem_path) end end # Recursively iterates through envs collecting all envs into # the target. The result is a unique array of all nested # envs, in order, beginning with self. def flatten_envs(target=[]) unless target.include?(self) target << self envs.each do |env| env.flatten_envs(target) end end target end # Raised when there is a Env-level configuration error. class ConfigError < StandardError attr_reader :original_error, :env_path def initialize(original_error, env_path) @original_error = original_error @env_path = env_path super() end def message "Configuration error: #{original_error.message}\n" + ($DEBUG ? "#{original_error.backtrace}\n" : "") + "Check '#{env_path}' configurations" end end end end