require 'yaml' require File.join( File.dirname(__FILE__), 'exceptions' ) module MasterView # An InvalidPathError that is raised when an invalid directory path # is encountered on the directive load path. class InvalidDirectivePathError < InvalidPathError # the directive load path entry containing the invalid directory path def path_entry @dpe || nil end def initialize( dpe, err_msg ) super(dpe.dir_path, err_msg) @dpe = dpe end end # The directive load path supports configuring a MasterView application # with a list of directories from which directives are loaded. # # A MasterView application is always configured with the standard set of # built-in directives, customarily referenced in templates in the # mv: namespace. Additional directives can be used by # appending directories containing the directive implementations # to the MasterView load path specified by the Configuration. # module DirectiveLoadPath # File name of metadata specs file in a directives directory # Contains yaml specifications for directive implementations METADATA_SPECS_FILE_NAME = '.metadata' DEBUG_MD_DIR_SPECS = false #:nodoc: ##DEBUG## @@path_registry = {} #:nodoc: #cattr_reader :path_registry def self.path_registry #:nodoc: # special hook for test cases fiddling with system state @@path_registry end # Answer the directives load path specs for the current load path def self.current @@path_registry[:current] end # Answer the default directives load path specs def self.default_path_specs #:nodoc: @@path_registry[:default] end # Install the configured load path def self.default_path_specs=(load_path) #:nodoc: @@path_registry[:default] = load_path.nil? ? nil : clone_path(load_path) if load_path && ! @@path_registry.has_key?(:current) @@path_registry[:current] = clone_path(load_path) end end # hook for test cases so they can ensure a clean system state def self.reset_current() #:nodoc: load_path = self.default_path_specs @@path_registry[:current] = load_path.nil? ? nil : clone_path(load_path) end # Answer a clone copy of a directive load path # # Does a deep_copy to ensure a clean copy of options maps # def self.clone_path(path) path.collect{ | dpe | dpe.deep_copy } end # A PathEntry contains the specification for a directory # on the MasterView directives load path. # class PathEntry # Answer a deep copy so we have a proper clone of the options. def self.copy_options(options) raise ArgumentError, "PathEntry options must be a hash: #{options.inspect}" if ! options.is_a?(Hash) Marshal.load(Marshal.dump(options)) end # The path name of a directory containing directive implementations # # The pathname is normalized into an absolute path by the validation checking. # attr_reader :dir_path attr_reader :options # Define an entry for a directive path with the path name of # a directory containing directive implementation classes to be loaded # into the MasterView processing configuration. # # Optionally specify options for the directives loaded # from this directory: # # :default - metadata defaults # # Metadata defaults extend or override any defaults specified # in the dir_path/.metadata file, if defined, allowing application # customization of the default defaults. # def initialize( dir_path, options=nil ) @dir_path = dir_path @options = options.nil? ? {} : self.class.copy_options(options) end def inspect #:nodoc: abbrev_class_name = self.class.name.split('::') abbrev_class_name = abbrev_class_name[1..abbrev_class_name.size].join('::') "#{abbrev_class_name}('#{dir_path}', options=#{options.inspect})" end # Answer a deep copy so we have a proper clone of the options. def deep_copy() Marshal.load(Marshal.dump(self)) end # Answer whether the directory exists def exists? File.directory?(dir_path) end # The metadata defaults for this directory, if any # # Metadata defaults specified on a load path entry supplement # any .metadata options specified in the directory itself by # overridding and extending any options defined statically in # the directives directory. # def metadata_defaults options.fetch(:default, {}) end # Answer whether directives loaded from this directory use the masterview # namespace by default. # # This option is ordinarily specified only for builtin masterview directives. # def use_masterview_namespace #:nodoc: options.fetch(:use_masterview_namespace, false) end # Answer whether directives loaded from this directory use the masterview # extensions directive namespace by default. # def use_extensions_namespace #:nodoc: ! use_masterview_namespace end # Validate the directory path entry specification. # # Ensures that the directory exists and that the entry specification # is normalized to an absolute pathname. def validate if ! File.directory?(dir_path) err_msg = "Invalid directive load path directory: '#{dir_path}'" raise InvalidDirectivePathError.new(self, err_msg) end if @options.has_key?(:default) md_defaults = @options[:default] # raises ArgumentError if props are bad; has side effect of normalizing the prop values DirectiveMetadata.validate_metadata_props! md_defaults end end # ensure normalized representation with absolute pathnames and standard options def normalize #:nodoc: @dir_path = File.expand_path( @dir_path ) #assert ! (['/', '\\'].contains?dir_path[-1..-1]) #tbd: (recursively) ensure keys are symbolized? @options[:default] = {} if ! @options.has_key?(:default) #md_defaults entries were already normalized by side effects of the validation checker end # Load the metadata specifications for a directives directory def load_metadata_specs(options={}) create_if_not_defined = options.fetch(:create_if_not_defined, true) md_file_path = "#{dir_path}/#{METADATA_SPECS_FILE_NAME}" dir_has_md_specs = File.file?( md_file_path ) if dir_has_md_specs STDOUT.puts "\n###Loading directive metadata specs: #{md_file_path}" if DEBUG_MD_DIR_SPECS md_config_specs = DirectiveLoadPath.load_metadata_specs(md_file_path) md_config_specs[:default] ||= {} # ensure that there are default specs STDOUT.puts "...md_config_specs=#{md_config_specs.inspect}" if DEBUG_MD_DIR_SPECS elsif create_if_not_defined md_config_specs = { :default => {} } else return nil end md_defaults = md_config_specs[:default] if ! md_defaults.has_key?( :description ) md_defaults[:description] = "MasterView directive from #{dir_path}" end # raises ArgumentError if props are bad; has side effect of normalizing the prop values DirectiveMetadata.validate_metadata_props! md_defaults md_config_specs end end # A directive load Path is a list of PathEntry specifications # for directories containing MasterView directive implementations. # class Path include Enumerable def initialize() #:nodoc: #??(entries=[]) @entries = [] #entries.each { | entry_spec | self << entry_spec } end def [](index) @entries[index] end def []=(index, entry) raise ArgumentError, "Invalid entry for {self.class.name}: #{entry.inspect}" if ! entry.is_a?(PathEntry) @entries[index] = entry end def <<(entry) if entry.is_a?(String) entry = PathEntry.new(entry) elsif entry.is_a?(Array) && entry.size == 2 && entry[0].is_a?(String) && entry[1].is_a?(Hash) # wow, we're working WAY too hard at this compatability stuff entry = PathEntry.new(entry[0], entry[1]) else raise ArgumentError, "Invalid entry for {self.class.name}: #{entry.inspect}" if ! entry.is_a?(PathEntry) end @entries << entry end def size @entries.size end def empty? @entries.empty? end # Calls the given block once for each entry specification on the path, # passing the element as parameter. def each @entries.each { |dpe| yield(dpe) } self end # Calls the given block once for each entry specification on the path, # passing the element's index as parameter. def each_index @entries.each_index { |index| yield(index) } self end def include?(dir_path) dir_path = dir_path.is_a?( PathEntry ) ? dir_path.dir_path : File.expand_path(dir_path) dpe = self.detect{ |dpe| dpe.dir_path == dir_path } ! dpe.nil? end # Answer the directory paths def directory_paths dir_paths = [] self.each { |dpe| dir_paths << dpe.dir_path } dir_paths end # Answer a copy of a directive load path # # Does a deep_copy to ensure a clean copy of options maps # def copy() @entries.collect{ | dpe | dpe.deep_copy } end end # Answer a clean path with a single entry for each directory # and path entries validated and normalize. # # Specify option :dup_policy ::= { :merge || :use_latest } # for handling options on dup entries (merge or use the # last-defined options). # # Optionally provide a block arg for handling validation exceptions # def self.clean_path(path, options) dup_policy = options.fetch( :dup_policy, :use_latest ) unique_paths = [] path_entries = {} path.each { | dpe | begin dpe.validate() dpe.normalize() dir_path = dpe.dir_path if ! unique_paths.include?(dir_path) unique_paths << dir_path else # dup entry - we've already got this dir_path registered if dup_policy == :merge prev_options = path_entries[dir_path].options options = prev_options.merge(dpe.options) else # :use_latest options = dpe.options # last-registered path entry wins end # replace path entry with merged or replaced options dpe = PathEntry.new(dir_path, options) end path_entries[dir_path] = dpe rescue InvalidDirectivePathError => ex if block_given? yield(ex) else raise end end } clean_path = Path.new unique_paths.each { | dir_path | clean_path << path_entries[dir_path] } clean_path end # Load a metadata specifications file. # # Normalizes options hash keys to symbols. # Should ordinarily be used through PathEntry.load_metadata_specs. # def self.load_metadata_specs(file_path) md_config_specs = YAML::load(IO.read(file_path)) #?what does yaml do with an empy file?? return {} if md_config_specs.nil? #assert md_config_specs.is_a?(Hash) symbolize_options_hash_keys(md_config_specs) end # do in-place symbolization of keys; recurse def self.symbolize_options_hash_keys(data) #:nodoc: #data.symbolize_keys! original_keys = data.keys() original_keys.each { | key | value = data[key] symbolizing_value = value.is_a?(Hash) value = symbolize_options_hash_keys(value) if symbolizing_value if ! key.is_a?(Symbol) data[key.to_sym] = value data.delete(key) elsif symbolizing_value data[key] = value end } data end # Compute the metadata default specs for a directives directory # Path entry options override/extend directory .metadata specs def self.compute_md_defaults(default_md_specs, dir_md_defaults, path_entry_defaults) md_defaults = {} md_defaults.merge! default_md_specs # we always start with the app-wide defaults md_defaults.merge! dir_md_defaults # bring in specs from the directory .metadata md_defaults.merge! path_entry_defaults # and allow app ovveride on path entry config # raises ArgumentError if props are bad # should be superfluous because we've already done this on each individual set of defaults DirectiveMetadata.validate_metadata_props! md_defaults md_defaults end end end