lib/tap/env.rb in tap-0.9.1 vs lib/tap/env.rb in tap-0.10.0

- old
+ new

@@ -1,335 +1,640 @@ require 'tap/root' -require 'singleton' -autoload(:PP, "pp") +require 'tap/support/constant' +require 'tap/support/summary' +require 'tap/support/manifest' module Tap - # == Under Construction - # - # Env manages configuration of the Tap execution environment, including the - # specification of gems that should be available through the tap command. + #-- + # 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 - # A variety of configuration loading/handling methods for use in - # conjuction with Tap::Env, to aid in configuring the running - # environment for Tap. - module Configuration - module_function + @@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 - # Templates the input filepath using ERB then loads it as YAML. - # Returns an empty hash if the file doesn't exist, or loads to - # nil or false (as for an empty file). Raises an error if the - # filepath doesn't load to a hash. - def read_config(filepath) - return {} if !File.exists?(filepath) || File.directory?(filepath) + # 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) - input = ERB.new(File.read(filepath)).result - config = YAML.load(input) + 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 - case config - when Hash then config - when nil, false then {} - else - raise "expected hash from config file: #{filepath}" + 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 - # Partitions a configuration hash into environment, execution, - # and application configurations, as determined by ENV_CONFIG_KEYS - # and EXE_CONFIG_KEYS. All non-env, non-exe configurations are - # considered application configurations. - def partition_configs(hash, *sets) - partitions = Array.new(sets.length + 1) { Hash.new } + def instance_for(path) + path = pathify(path) + instances.has_key?(path) ? instances[path] : instantiate(path) + end - hash.each_pair do |key, value| - index = 0 - sets.each do |keys| - break if keys.include?(key) - index += 1 - end - - partitions[index][key] = value - 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) - partitions + # 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 - # Joins the input configuration hashes, concatenating - # values for matching keys. Values will be made into - # arrays if they are not so already; duplicate values - # are removed from the result on a key-per-key basis. - def join_configs(*configs) - merge = {} - configs.each do |hash| - hash.each_pair do |key, values| - values = [values] unless values.kind_of?(Array) - (merge[key] ||= []).concat(values) - 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 - merge.values.each {|values| values.uniq! } - merge 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_<name> 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 - include Configuration - include Singleton + # 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" - # Currently these are ALWAYS included. - DEFAULT_CONFIG = { - "load_paths" => ["lib"], - "load_once_paths" => [], - "config_paths" => [], - "command_paths" => ["cmd"], - "gems" => [], - "generator_paths" => ["lib/generators"] - } + # The default task file path + DEFAULT_TASK_FILE = "tapfile.rb" - attr_reader :config + # 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 - def initialize - @config = nil - @logger = nil - reset + # 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"] - def debug_setup - $DEBUG = true - logger.level = Logger::DEBUG - end + # Designate paths for discovering and executing commands. + path_config :command_paths, ["cmd"] - def rails_setup(app=Tap::App.instance) - Object.const_set('RAILS_ROOT', app.root) - Object.const_set('RAILS_DEFAULT_LOGGER', app.logger) - Dependencies.log_activity = app.debug? + # 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 - def rake_setup(argv=ARGV, app=Tap::App.instance) - Tap::Support.autoload(:Rake, 'tap/support/rake') - - # setup - app.extend Tap::Support::Rake - rake = Rake.application - options = rake.options + manifest(:commands, :command_paths, "**/*.rb") - # merge options down from app - app.options.marshal_dump.each_pair do |key, value| - options.send("#{key}=", value) + 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 - options.silent = true + end - # run as if from command line using argv - current_argv = ARGV.dup - begin - ARGV.concat(argv) - - # now follow the same protocol as - # in run, handling options - rake.init - rake.load_rakefile - ensure - ARGV.clear - ARGV.concat(current_argv) - end + def initialize(config={}, root=Tap::Root.new, logger=nil) + @root = root + @logger = logger + @envs = [] + @active = false + @manifests = {} + @manifested = [] - rake + # initialize these for reset_env + @gems = [] + @env_paths = [] + + initialize_config(config) end - # Resets Env. Load paths (load_paths and load_once_paths) are - # not reset unless dependencies==true; in which case Dependencies - # are cleared before load paths are cleared. The load paths added - # to $LOAD_PATH are not cleared. + # 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. # - # Generally not recommended. - def reset - unless @config == nil - $LOAD_PATH.delete_if {|path| config['load_paths'].include?(path) } - - Dependencies.clear - Dependencies.load_paths.delete_if {|path| config['load_paths'].include?(path) } - Dependencies.load_once_paths.delete_if {|path| config['load_once_paths'].include?(path) } + # 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 - - @config = {} - DEFAULT_CONFIG.keys.each do |key| - @config[key] = [] + 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 - - # 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 + + # Passes each nested env to the block in order, starting with self. + def each + envs(true).each {|e| yield(e) } end - # Configures the specified App using the configurations in config_file. - # Loading of environement configurations occcurs via load_env_config; - # all environment paths are resolved using the app, after the app has - # been configured. + # Passes each nested env to the block in reverse order, ending with self. + def reverse_each + envs(true).reverse_each {|e| yield(e) } + end - # Loads environment configurations from the specified path. If a directory - # is given as path, then the DEFAULT_CONFIG_FILE relative to that location - # will be loaded. The loading cycle recurses as specified by the configurations. + # 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: # - # Configuration paths are expanded relative to the parent directory - # of the loaded file. Raises an error if non-env configuration are - # found (as determined by Tap::Env::Configurtion::ENV_CONFIG_KEYS). - def load_config(path, root=Tap::Root.new, &block) # :yields: non_env_configs - path = File.join(path, DEFAULT_CONFIG_FILE) if File.directory?(path) - path = File.expand_path(path) - - # prevent infinite looping - config_paths = config['config_paths'] - return false if config_paths.include?(path) + # * 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 - # load config - log(:load_config, path, Logger::DEBUG) - config_paths << path + # partiton config into its parts + env_configs = {} + root_configs = {} + other_configs = {} - config = read_config(path) - config['root'] = File.dirname(path) unless config['root'] - - configure(config, root, &block) - end + env_configurations = self.class.configurations + root_configurations = root.class.configurations + overrides.each_pair do |key, value| + key = key.to_sym - #-- - # Note: always yields to the block, even if non_env_configs is empty - def configure(config, root=Tap::Root.new, &block) # :yields: non_env_configs - root_configs, env_configs, other_configs = partition_configs(config, ['root', 'directories', 'absolute_paths'], DEFAULT_CONFIG.keys) - env_configs = join_configs(DEFAULT_CONFIG, env_configs) + 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) - # assign root configs - root.send(:assign_paths, - root_configs['root'] || root.root, - root_configs['directories'] || root.directories, - root_configs['absolute_paths'] || root.absolute_paths) + # reconfigure self + super(env_configs) - # handle unknown configs (handle before setting - # env configs in case the configs modify root) + # 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 - # load gems and configurations - gem_paths = env_configs.delete('gems').collect do |gem_name| - full_gem_path(gem_name) - end - config_paths = env_configs.delete('config_paths') + gem_paths - config_paths.each {|path| load_config(root[path]) } + 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? - # assign env configs - env_configs.each_pair do |key, value| - case key - when 'load_paths' - assign_paths(root, value, self.config[key], $LOAD_PATH, Dependencies.load_paths) - when 'load_once_paths' - assign_paths(root, value, self.config[key], Dependencies.load_once_paths) - when /_paths$/ - assign_paths(root, value, self.config[key]) - else - handle_unknown_env_config(root, key, value) + @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 - # Loads env configurations from a gem, specifically from - # gemspec.full_gem_path. A gem version can be specified - # in the name, like 'gem >= 1.2'. - def full_gem_path(gem_name) - # figure the version of the gem, by default >= 0.0.0 - gem_name =~ /^([^<=>]*)(.*)$/ - name, version = $1, $2 - version = ">= 0.0.0" if version.empty? - - # load the gem and get the spec - gem(name, version) - spec = Gem.loaded_specs[name] + # 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? - if spec == nil - log(:warn, "unknown gem: #{gem_name}", Logger::WARN) + # remove load paths from load_path_targets + load_path_targets.each do |target| + load_paths.each do |path| + target.delete(path) + end end - spec.full_gem_path + # 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 - # Loads the config for the specified gem. A gem version can be - # specified in the name, see full_gem_path. - def load_gem(gem_name) - load_config(full_gem_path(gem_name)) + # Return true if self has been activated. + def active? + @active end - # Returns the path to all DEFAULT_CONFIG_FILEs for installed gems. - # If latest==true, then only the config files for the latest gem - # specs will be returned (ie for the most current version of a - # gem). - def gem_config_files(latest=true) - if latest - Gem.source_index.latest_specs.collect do |spec| - config_file = File.join(spec.full_gem_path, DEFAULT_CONFIG_FILE) - File.exists?(config_file) ? config_file : nil - end.compact - else - Gem.path.collect do |dir| - Dir.glob( File.join(dir, "gems/*", DEFAULT_CONFIG_FILE) ) - end.flatten.uniq - end + # Cycles through all items yielded by the iterate_<name> 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 - # Loads the config files discovered by gem_config_files(true). - def discover_gems - gem_config_files.collect do |config_file| - load_config(config_file) + def find(name, pattern) + manifest(name) do |key, path| + return path if Root.minimal_match?(key, pattern) end + nil end - # Searches for and returns all .rb files under each of the command_paths - # as well as the default tap commands. Commands with conflicting names - # raise an error; however, user commands are allowed to override the - # default tap commands and will NOT raise an error. - def commands - commands = {} - config['command_paths'].each do |path| - pattern = File.join(path, "**/*.rb") - - Dir.glob(pattern).each do |file| - cmd = Tap::App.relative_filepath(path, file).chomp(".rb") - raise "command name confict: #{cmd}" if commands.include?(cmd) - commands[cmd] = file + 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 - - # allow all other scripts to override default scripts - # (hence do this second) - tap_command_dir = File.expand_path(File.join( File.dirname(__FILE__), "cmd")) - Dir.glob( tap_command_dir + "/**/*.rb" ).each do |file| - cmd = Tap::App.relative_filepath(tap_command_dir, file).chomp(".rb") - commands[cmd] = file unless commands.include?(cmd) + + 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 - commands + 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 - def assign_paths(root, paths, *targets) - paths = paths.collect {|path| root[path]} - targets.each do |array| - paths.reverse_each do |path| - array.unshift(path) - end - array.uniq! + # 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 - def handle_unknown_env_config(key, value) - raise "unknown env config: #{key}" + # 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 \ No newline at end of file