diff --git a/lib/puppet/parser/functions.rb b/lib/puppet/parser/functions.rb index d7ea3e0a9..87bf5de1f 100644 --- a/lib/puppet/parser/functions.rb +++ b/lib/puppet/parser/functions.rb @@ -1,249 +1,255 @@ require 'puppet/util/autoload' require 'puppet/parser/scope' # A module for managing parser functions. Each specified function # is added to a central module that then gets included into the Scope # class. # # @api public module Puppet::Parser::Functions Environment = Puppet::Node::Environment class << self include Puppet::Util end # Reset the list of loaded functions. # # @api private def self.reset @functions = Hash.new { |h,k| h[k] = {} } @modules = Hash.new # Runs a newfunction to create a function for each of the log levels Puppet::Util::Log.levels.each do |level| newfunction(level, :environment => Puppet.lookup(:root_environment), :doc => "Log a message on the server at level #{level.to_s}.") do |vals| send(level, vals.join(" ")) end end end # Accessor for singleton autoloader # # @api private def self.autoloader @autoloader ||= Puppet::Util::Autoload.new( self, "puppet/parser/functions", :wrap => false ) end # Get the module that functions are mixed into corresponding to an # environment # # @api private def self.environment_module(env) @modules[env.name] ||= Module.new end # Create a new Puppet DSL function. # # **The {newfunction} method provides a public API.** # # This method is used both internally inside of Puppet to define parser # functions. For example, template() is defined in # {file:lib/puppet/parser/functions/template.rb template.rb} using the # {newfunction} method. Third party Puppet modules such as # [stdlib](https://forge.puppetlabs.com/puppetlabs/stdlib) use this method to # extend the behavior and functionality of Puppet. # # See also [Docs: Custom # Functions](http://docs.puppetlabs.com/guides/custom_functions.html) # # @example Define a new Puppet DSL Function # >> Puppet::Parser::Functions.newfunction(:double, :arity => 1, # :doc => "Doubles an object, typically a number or string.", # :type => :rvalue) {|i| i[0]*2 } # => {:arity=>1, :type=>:rvalue, # :name=>"function_double", # :doc=>"Doubles an object, typically a number or string."} # # @example Invoke the double function from irb as is done in RSpec examples: # >> require 'puppet_spec/scope' # >> scope = PuppetSpec::Scope.create_test_scope_for_node('example') # => Scope() # >> scope.function_double([2]) # => 4 # >> scope.function_double([4]) # => 8 # >> scope.function_double([]) # ArgumentError: double(): Wrong number of arguments given (0 for 1) # >> scope.function_double([4,8]) # ArgumentError: double(): Wrong number of arguments given (2 for 1) # >> scope.function_double(["hello"]) # => "hellohello" # # @param [Symbol] name the name of the function represented as a ruby Symbol. # The {newfunction} method will define a Ruby method based on this name on # the parser scope instance. # # @param [Proc] block the block provided to the {newfunction} method will be # executed when the Puppet DSL function is evaluated during catalog # compilation. The arguments to the function will be passed as an array to # the first argument of the block. The return value of the block will be # the return value of the Puppet DSL function for `:rvalue` functions. # # @option options [:rvalue, :statement] :type (:statement) the type of function. # Either `:rvalue` for functions that return a value, or `:statement` for # functions that do not return a value. # # @option options [String] :doc ('') the documentation for the function. # This string will be extracted by documentation generation tools. # # @option options [Integer] :arity (-1) the # [arity](http://en.wikipedia.org/wiki/Arity) of the function. When # specified as a positive integer the function is expected to receive # _exactly_ the specified number of arguments. When specified as a # negative number, the function is expected to receive _at least_ the # absolute value of the specified number of arguments incremented by one. # For example, a function with an arity of `-4` is expected to receive at # minimum 3 arguments. A function with the default arity of `-1` accepts # zero or more arguments. A function with an arity of 2 must be provided # with exactly two arguments, no more and no less. Added in Puppet 3.1.0. # # @option options [Puppet::Node::Environment] :environment (nil) can # explicitly pass the environment we wanted the function added to. Only used # to set logging functions in root environment # # @return [Hash] describing the function. # # @api public def self.newfunction(name, options = {}, &block) + # Short circuit this call when 4x "biff" is in effect to allow the new loader system to load + # and define the function a different way. + # + if Puppet[:biff] + return Puppet::Pops::Loader::RubyLegacyFunctionInstantiator.legacy_newfunction(name, options, &block) + end name = name.intern environment = options[:environment] || Puppet.lookup(:current_environment) Puppet.warning "Overwriting previous definition for function #{name}" if get_function(name, environment) arity = options[:arity] || -1 ftype = options[:type] || :statement unless ftype == :statement or ftype == :rvalue raise Puppet::DevError, "Invalid statement type #{ftype.inspect}" end # the block must be installed as a method because it may use "return", # which is not allowed from procs. real_fname = "real_function_#{name}" environment_module(environment).send(:define_method, real_fname, &block) fname = "function_#{name}" environment_module(environment).send(:define_method, fname) do |*args| Puppet::Util::Profiler.profile("Called #{name}") do if args[0].is_a? Array if arity >= 0 and args[0].size != arity raise ArgumentError, "#{name}(): Wrong number of arguments given (#{args[0].size} for #{arity})" elsif arity < 0 and args[0].size < (arity+1).abs raise ArgumentError, "#{name}(): Wrong number of arguments given (#{args[0].size} for minimum #{(arity+1).abs})" end self.send(real_fname, args[0]) else raise ArgumentError, "custom functions must be called with a single array that contains the arguments. For example, function_example([1]) instead of function_example(1)" end end end func = {:arity => arity, :type => ftype, :name => fname} func[:doc] = options[:doc] if options[:doc] add_function(name, func, environment) func end # Determine if a function is defined # # @param [Symbol] name the function # @param [Puppet::Node::Environment] environment the environment to find the function in # # @return [Symbol, false] The name of the function if it's defined, # otherwise false. # # @api public def self.function(name, environment = Puppet.lookup(:current_environment)) name = name.intern func = nil unless func = get_function(name, environment) autoloader.load(name, environment) func = get_function(name, environment) end if func func[:name] else false end end def self.functiondocs(environment = Puppet.lookup(:current_environment)) autoloader.loadall ret = "" merged_functions(environment).sort { |a,b| a[0].to_s <=> b[0].to_s }.each do |name, hash| ret << "#{name}\n#{"-" * name.to_s.length}\n" if hash[:doc] ret << Puppet::Util::Docs.scrub(hash[:doc]) else ret << "Undocumented.\n" end ret << "\n\n- *Type*: #{hash[:type]}\n\n" end ret end # Determine whether a given function returns a value. # # @param [Symbol] name the function # @param [Puppet::Node::Environment] environment The environment to find the function in # @return [Boolean] whether it is an rvalue function # # @api public def self.rvalue?(name, environment = Puppet.lookup(:current_environment)) func = get_function(name, environment) func ? func[:type] == :rvalue : false end # Return the number of arguments a function expects. # # @param [Symbol] name the function # @param [Puppet::Node::Environment] environment The environment to find the function in # @return [Integer] The arity of the function. See {newfunction} for # the meaning of negative values. # # @api public def self.arity(name, environment = Puppet.lookup(:current_environment)) func = get_function(name, environment) func ? func[:arity] : -1 end class << self private def merged_functions(environment) @functions[Puppet.lookup(:root_environment)].merge(@functions[environment]) end def get_function(name, environment) name = name.intern merged_functions(environment)[name] end def add_function(name, func, environment) name = name.intern @functions[environment][name] = func end end end diff --git a/lib/puppet/pops/loader.rb b/lib/puppet/pops/loader.rb new file mode 100644 index 000000000..00f8be741 --- /dev/null +++ b/lib/puppet/pops/loader.rb @@ -0,0 +1,138 @@ +# A loader is responsible for loading "types" (actually +# "instantiable and executable objects in the puppet language" which +# are type, hostclass, definition and function. +# +# The main method for users of a loader is the `load` method, which returns a previously loaded entity +# of a given type/name, and searches and loads the entity if not already loaded. +# +# @api public +# +class Puppet::Pops::Loader + + # Produces the value associated with the given name if already loaded, or available for loading + # by this loader, one of its parents, or other loaders visible to this loader. + # This is the method an external party should use to "get" the named element. + # + # An implementor of this method should first check if the given name is already loaded by self, or a parent + # loader, and if so return that result. If not, it should call #find to perform the loading. + # + # @param type [:Symbol] - the type to load + # @param name [String, Symbol] - the name of the entity to load + # + # @api public + # + def load(type, name) + load_typed(TypedName.new(type, name)) + end + + # The same as load, but acts on a type/name combination. + # + # @param typed_name [TypedName] - the type, name combination to lookup + # + # @api public + # + def load_typed(typed_name) + raise NotImplementedError.new + end + + # Produces the value associated with the given name if defined in this loader, or nil if not defined. + # This lookup does not trigger any loading, or search of the given name. + # An implementor of this method may not search or look up in any other loader, and it may not + # define the name. + # + # @param typed_name [TypedName] - the type, name combination to lookup + # + # @api private + # + def [] (typed_name) + raise NotImplementedError.new + end + + # Searches for the given name in this loaders context (parents have already searched their context(s) without + # producing a result when this method is called). + # + # @param typed_name [TypedName] - the type, name combination to lookup + # + # @api private + # + def find(typed_name) + raise NotImplementedError.new + end + + # Returns the parent of the loader, or nil, if this is the top most loader. This implementation returns nil. + def parent + nil + end + + # Binds a value to a name. The name should not start with '::', but may contain multiple segments. + # + # @param type [:Symbol] - the type of the entity being set + # @param name [String, Symbol] - the name of the entity being set + # @param origin [URI, #uri, String] - the origin of the set entity, a URI, or provider of URI, or URI in string form + # + # @api private + # + def set_entry(type, name, value, origin = nil) + raise NotImplementedError.new + end + + # Produces a NamedEntry if a value is bound to the given name, or nil if nothing is bound. + # + # @param typed_name [TypedName] - the type, name combination to lookup + # + # @api private + # + def get_entry(typed_name) + raise NotImplementedError.new + end + + # An entry for one entity loaded by the loader. + # + class NamedEntry + attr_reader :status + attr_reader :typed_name + attr_reader :value + attr_reader :origin + + def initialize(status, typed_name, value, origin) + @status = status + @name = typed_name + @value = value + @origin = origin + freeze() + end + end + + # A name/type combination that can be used as a compound hash key + # + class TypedName + attr_reader :type + attr_reader :name + def initialize(type, name) + @type = type + @name = Puppet::Pops::Utils.relativize_name(name) + + # Not allowed to have numeric names - 0, 010, 0x10, 1.2 etc + if Puppet::Pops::Utils.is_numeric?(@name) + raise ArgumentError, "Illegal attempt to use a numeric name '#{name}' at #{origin_label(origin)}." + end + + freeze() + end + + def hash + [self.class, type, name] + end + + def ==(o) + o.class == self.class && type == o.type && name == o.name + end + + alias eql? == + + def to_s + "#{type}/#{name}" + end + end +end + diff --git a/lib/puppet/pops/loader/puppet_function_instantiator.rb b/lib/puppet/pops/loader/puppet_function_instantiator.rb new file mode 100644 index 000000000..16e5b29f4 --- /dev/null +++ b/lib/puppet/pops/loader/puppet_function_instantiator.rb @@ -0,0 +1,97 @@ +# The PuppetFunctionInstantiator instantiates a Puppet::Functions::Function given a Puppet Programming language +# source that when called evaluates the Puppet logic it contains. +# +class Puppet::Pops::Loader::PuppetFunctionInstantiator + # Produces an instance of the Function class with the given typed_name, or fails with an error if the + # given puppet source does not produce this instance when evaluated. + # + # @param loader [T.B.D] The loader the function is associated with + # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load + # @param source_ref [URI, String] a reference to the source / origin of the puppet code to evaluate + # @param pp_code_string [String] puppet code in a string + # + # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader + # + def self.create(loader, typed_name, source_ref, pp_code_string) + parser = Puppet::Pops::Parser::EvaluatingParser.new() + + # parse and validate + result = parser.parse_string(pp_code_string, source_ref) + # Only one function is allowed (and no other definitions) + case result.model.definitions.size + when 0 + raise ArgumentError, "The code loaded from #{source_ref} does not define the function #{typed_name.name} - it is empty." + when 1 + # ok + else + raise ArgumentError, "The code loaded from #{source_ref} must contain only the function #{typed_name.name} - it has additional definitions." + end + the_function_definition = result.model.definitions[0] + # TODO: There is no FunctionExpression yet + unless the_function_definition.is_a?(Puppet::Pops::Model::FunctionDefinition) + raise ArgumentError, "The code loaded from #{source_ref} does not define the function #{typed_name.name} - no function found." + end + unless the_function_definition.name == typed_name.name + expected = typed_name.name + actual = the_function_definition.name + raise ArgumentError, "The code loaded from #{source_ref} produced function with the wrong name, expected #{expected}, actual #{actual}" + end + unless result.model().body == the_function_definition + raise ArgumentError, "The code loaded from #{source_ref} contains additional logic - can only contain the function #{typed_name.name}" + end + + # TODO: Cheating wrt. scope - assuming it is found in the context + closure_scope = Puppet.lookup(:global_scope) { {} } + + created = create_function_class(the_function_definition, closure_scope) + # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things + # when calling functions etc. + # It should be bound to global scope + + created.new(closure_scope, loader) + end + + def self.create_function_class(function_definition, closure_scope) + method_name = :"#{function_definition.name.split(/::/).slice(-1)}" + closure = Puppet::Pops::Evaluator::Closure.new( + Puppet::Pops::Evaluator::EvaluatorImpl.new(), + function_definition, + closure_scope) + required_optional = function_definition.parameters.reduce([0, 0]) do |memo, p| + if p.value.nil? + memo[0] += 1 + else + memo[1] += 1 + end + memo + end + min_arg_count = required_optional[0] + max_arg_count = required_optional[0] + required_optional[1] + + # Create a 4x function wrapper around the Puppet Function + created_function_class = Puppet::Functions.create_function(function_definition.name) do + # Define the method that is called from dispatch - this method just changes a call + # with multiple unknown arguments to passing all in an array (since this is expected in the closure API. + # + # TODO: The closure will call the evaluator.call method which will again match args with parameters. + # This can be done a better way later - unifying the two concepts - a function instance is really the same + # as the current evaluator closure for lambdas, only that it also binds an evaluator. This could perhaps + # be a specialization of Function... with a special dispatch + # + define_method(:__relay__call__) do |*args| + closure.call(nil, *args) + end + + # Define a dispatch that performs argument type/count checking + # + dispatch :__relay__call__ do + # Use Puppet Type Object (not Optional[Object] since the 3x API passes undef as empty string). + param(optional(object), 'args') + # Specify arg count (transformed from FunctionDefinition.parameters, no types, or varargs yet) + arg_count(min_arg_count, max_arg_count) + end + end + created_function_class + + end +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/ruby_function_instantiator.rb b/lib/puppet/pops/loader/ruby_function_instantiator.rb new file mode 100644 index 000000000..175e916db --- /dev/null +++ b/lib/puppet/pops/loader/ruby_function_instantiator.rb @@ -0,0 +1,34 @@ +# The RubyFunctionInstantiator instantiates a Puppet::Functions::Function given the ruby source +# that calls Puppet::Functions.create_function. +# +class Puppet::Pops::Loader::RubyFunctionInstantiator + # Produces an instance of the Function class with the given typed_name, or fails with an error if the + # given ruby source does not produce this instance when evaluated. + # + # @param loader [T.B.D] The loader the function is associated with + # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load + # @param source_ref [URI, String] a reference to the source / origin of the ruby code to evaluate + # @param ruby_code_string [String] ruby code in a string + # + # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader + # + def self.create(loader, typed_name, source_ref, ruby_code_string) + unless ruby_code_string.is_a?(String) && ruby_code_string =~ /Puppet\:\:Functions\.create_function/ + raise ArgumentError, "The code loaded from #{source_ref} does not seem to be a Puppet 4x API function - no create_function call." + end + created = eval(ruby_code_string) + unless created.is_a?(Class) + raise ArgumentError, "The code loaded from #{source_ref} did not produce a Function class when evaluated. Got '#{created.class}'" + end + unless created.name.to_s == typed_name.name() + raise ArgumentError, "The code loaded from #{source_ref} produced mis-matched name, expected '#{typed_name.name}', got #{created.name}" + end + # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things + # when calling functions etc. + # It should be bound to global scope + + # TODO: Cheating wrt. scope - assuming it is found in the context + closure_scope = Puppet.lookup(:global_scope) { {} } + created.new(closure_scope, loader) + end +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb b/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb new file mode 100644 index 000000000..cb7083d1d --- /dev/null +++ b/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb @@ -0,0 +1,109 @@ +# The RubyLegacyFunctionInstantiator loads a 3x function and turns it into a 4x function +# that is called with 3x semantics (values are transformed to be 3x compliant). +# +# The code is loaded from a string obtained by reading the 3x function ruby code into a string +# and then passing it to the loaders class method `create`. When Puppet[:biff] == true, the +# 3x Puppet::Parser::Function.newfunction method relays back to this function loader's +# class method legacy_newfunction which creates a Puppet::Functions class wrapping the +# 3x function's block into a method in a function class derived from Puppet::Function. +# This class is then returned, and the Legacy loader continues the same way as it does +# for a 4x function. +# +# TODO: Wrapping of Scope +# The 3x function expects itself to be Scope. It passes itself as scope to other parts of the runtime, +# it expects to find all sorts of information in itself, get/set variables, get compiler, get environment +# etc. +# TODO: Transformation of arguments to 3x compliant objects +# +class Puppet::Pops::Loader::RubyLegacyFunctionInstantiator + + # Produces an instance of the Function class with the given typed_name, or fails with an error if the + # given ruby source does not produce this instance when evaluated. + # + # @param loader [T.B.D] The loader the function is associated with + # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load + # @param source_ref [URI, String] a reference to the source / origin of the ruby code to evaluate + # @param ruby_code_string [String] ruby code in a string + # + # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader + # + def self.create(loader, typed_name, source_ref, ruby_code_string) + # Old Ruby API supports calling a method via :: + # this must also be checked as well as call with '.' + # + unless ruby_code_string.is_a?(String) && ruby_code_string =~ /Puppet\:\:Parser\:\:Functions(?:\.|\:\:)newfunction/ + raise ArgumentError, "The code loaded from #{source_ref} does not seem to be a Puppet 3x API function - no newfunction call." + end + + # The evaluation of the 3x function creation source should result in a call to the legacy_newfunction + # + created = eval(ruby_code_string) + unless created.is_a?(Class) + raise ArgumentError, "The code loaded from #{source_ref} did not produce a Function class when evaluated. Got '#{created.class}'" + end + unless created.name.to_s == typed_name.name() + raise ArgumentError, "The code loaded from #{source_ref} produced mis-matched name, expected '#{typed_name.name}', got #{created.name}" + end + # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things + # when calling functions etc. + # It should be bound to global scope + + # TODO: Cheating wrt. scope - assuming it is found in the context + closure_scope = Puppet.lookup(:global_scope) { {} } + created.new(closure_scope, loader) + end + + # This is a new implementation of the method that is used in 3x to create a function. + # The arguments are the same as those passed to Puppet::Parser::Functions.newfunction, hence its + # deviation from regular method naming practice. + # + def self.legacy_newfunction(name, options, &block) + + # 3x api allows arity to be specified, if unspecified it is 0 or more arguments + # arity >= 0, is an exact count + # airty < 0 is the number of required arguments -1 (i.e. -1 is 0 or more) + # (there is no upper cap, there is no support for optional values, or defaults) + # + arity = options[:arity] || -1 + if arity >= 0 + min_arg_count = arity + max_arg_count = arity + else + min_arg_count = (arity + 1).abs + # infinity + max_arg_count = :default + end + + # Create a 4x function wrapper around the 3x Function + created_function_class = Puppet::Functions.create_function(name) do + # define a method on the new Function class with the same name as the function, but + # padded with __ because the function may represent a ruby method with the same name that + # expects to have inherited from Kernel, and then Object. + # (This can otherwise lead to infinite recursion, or that an ArgumentError is raised). + # + __name__ = :"__#{name}__" + define_method(__name__, &block) + + # Define the method that is called from dispatch - this method just changes a call + # with multiple unknown arguments to passing all in an array (since this is expected in the 3x API). + # We want the call to be checked for type and number of arguments so cannot call the function + # defined by the block directly since it is defined to take a single argument. + # + define_method(:__relay__call__) do |*args| + # dup the args since the function may destroy them + # TODO: Should convert arguments to 3x, now :undef is send to the function + send(__name__, args.dup) + end + + # Define a dispatch that performs argument type/count checking + # + dispatch :__relay__call__ do + # Use Puppet Type Object (not Optional[Object] since the 3x API passes undef as empty string). + param object, 'args' + # Specify arg count (transformed from 3x function arity specification). + arg_count(min_arg_count, max_arg_count) + end + end + created_function_class + end +end