commit 1ce43f91fee51a7539cd3751799fe0d3d560d743
Author: Ohad Levy <ohadlevy@gmail.com>
Date:   Thu Jul 19 14:34:20 2012 +0300

    Export arguments of parameterized classes
    
    Use puppet/parser for a first class analysis of the class definitions.
    Using regexes would have been a nightmare.
    
    Exports a "params" sub-object whose keys are the parameter names and
    values are a best-effort convertion from AST leaves to native ruby
    types, before being exported as native JSON values.
    
    Compatible with Puppet 2.6 and 2.7, at least.
    
    Tests:
    - Manifest must now parse in order to extract classes from it,
      hence the fix in the test.
    - Functional tests

diff --git a/lib/proxy/puppet/puppet_class.rb b/lib/proxy/puppet/puppet_class.rb
index 8b64003..2055532 100644
--- a/lib/proxy/puppet/puppet_class.rb
+++ b/lib/proxy/puppet/puppet_class.rb
@@ -1,3 +1,5 @@
+require 'puppet/parser'
+
 module Proxy::Puppet
 
   class PuppetClass
@@ -6,25 +8,121 @@ module Proxy::Puppet
       # scans a given directory and its sub directory for puppet classes
       # returns an array of PuppetClass objects.
       def scan_directory directory
+        # Get a Puppet Parser to parse the manifest source
+        parser = Puppet::Parser::Parser.new Puppet::Node::Environment.new
         Dir.glob("#{directory}/*/manifests/**/*.pp").map do |manifest|
-          scan_manifest File.read(manifest)
+          scan_manifest File.read(manifest), manifest, parser
         end.compact.flatten
       end
 
-      def scan_manifest manifest
+      def scan_manifest manifest, filename = '', parser = nil
         klasses = []
-        manifest.each_line do |line|
-          if line.match(/^\s*class\s+([\w:-]*)/)
-            klasses << new($1) unless $1 == ""
+        # Get a Puppet Parser to parse the manifest source
+        parser ||= Puppet::Parser::Parser.new(Puppet::Node::Environment.new)
+        already_seen = Set.new parser.known_resource_types.hostclasses.keys
+        already_seen << '' # Prevent the toplevel "main" class from matching
+        ast = parser.parse manifest
+        # Get the parsed representation of the top most objects
+        hostclasses = ast.respond_to?(:instantiate) ? ast.instantiate('') : ast.hostclasses.values
+        hostclasses.each do |klass|
+          # Only look at classes
+          if klass.type == :hostclass and not already_seen.include? klass.namespace
+            params = {}
+            # Get parameters and eventual default values
+            klass.arguments.each do |name, value|
+              params[name] = ast_to_value(value) rescue nil
+            end
+            klasses << new(klass.namespace, params)
           end
         end
         klasses
+      rescue => e
+        puts "Error while parsing #{filename}: #{e}"
+        klasses
+      end
+
+      private
+      def ast_to_value value
+        unless value.class.name.start_with? "Puppet::Parser::AST::"
+          # Native Ruby types
+          case value
+            # Supported with exact JSON equivalent
+            when NilClass, String, Numeric, Array, Hash, FalseClass, TrueClass
+              value
+            when Struct
+              value.hash
+            when Enumerable
+              value.to_a
+            # Stringified
+            when Regexp # /(?:stringified)/
+              "/#{value.to_s}/"
+            when Symbol # stringified
+              value.to_s
+            else
+              raise TypeError
+          end
+        else
+          # Parser types
+          case value
+            # Supported with exact JSON equivalent
+            when Puppet::Parser::AST::Boolean, Puppet::Parser::AST::String
+              value.evaluate nil
+            # Supported with stringification
+            when Puppet::Parser::AST::Concat
+              # Note1: only simple content are supported, plus variables whose raw name is taken
+              # Note2: The variable substitution WON'T be done by Puppet from the ENC YAML output
+              value.value.map do |v|
+                case v
+                  when Puppet::Parser::AST::String
+                    v.evaluate nil
+                  when Puppet::Parser::AST::Variable
+                    "${#{v.value}}"
+                  else
+                    raise TypeError
+                end
+              end.join rescue nil
+            when Puppet::Parser::AST::Type
+              value.value
+            when Puppet::Parser::AST::Name
+              (Puppet::Parser::Scope.number?(value.value) or value.value)
+            when Puppet::Parser::AST::Undef # equivalent of nil
+              nil
+            # Depends on content
+            when Puppet::Parser::AST::ASTArray
+              value.inject([]) { |arr, v| (arr << ast_to_value(v)) rescue arr }
+            when Puppet::Parser::AST::ASTHash
+              Hash[value.value.each.inject([]) { |arr, (k,v)| (arr << [ast_to_value(k), ast_to_value(v)]) rescue arr }]
+            # Let's see if a raw evaluation works with no scope for any other type
+            else
+              if value.respond_to? :evaluate
+                # Can probably work for: (depending on the actual content)
+                # - Puppet::Parser::AST::ArithmeticOperator
+                # - Puppet::Parser::AST::ComparisonOperator
+                # - Puppet::Parser::AST::BooleanOperator
+                # - Puppet::Parser::AST::Minus
+                # - Puppet::Parser::AST::Not
+                # May work for:
+                # - Puppet::Parser::AST::InOperator
+                # - Puppet::Parser::AST::MatchOperator
+                # - Puppet::Parser::AST::Selector
+                # Probably won't work for
+                # - Puppet::Parser::AST::Variable
+                # - Puppet::Parser::AST::HashOrArrayAccess
+                # - Puppet::Parser::AST::ResourceReference
+                # - Puppet::Parser::AST::Function
+                value.evaluate nil
+              else
+                raise TypeError
+              end
+          end
+        end
       end
 
     end
 
-    def initialize name
-      @klass = name || raise("Must provide puppet class name")
+    def initialize name, params = {}
+      @klass  = name || raise("Must provide puppet class name")
+      @params = params
     end
 
     def to_s
@@ -41,6 +139,8 @@ module Proxy::Puppet
       has_module?(klass) ? klass[(klass.index("::")+2)..-1] : klass
     end
 
+    attr_reader :params
+
     private
     attr_reader :klass
 
diff --git a/lib/puppet_api.rb b/lib/puppet_api.rb
index f1be49b..79cf143 100644
--- a/lib/puppet_api.rb
+++ b/lib/puppet_api.rb
@@ -34,10 +34,10 @@ class SmartProxy
     begin
       env = Proxy::Puppet::Environment.find(params[:environment])
       log_halt 404, "Not found" unless env
-      env.classes.map{|k| {k.to_s => { :name => k.name, :module => k.module} } }.to_json
+      env.classes.map{|k| {k.to_s => { :name => k.name, :module => k.module, :params => k.params} } }.to_json
     rescue => e
       log_halt 406, "Failed to show puppet classes: #{e}"
     end
   end
 
-end
\ No newline at end of file
+end
diff --git a/test/puppet_class_test.rb b/test/puppet_class_test.rb
index f574fcc..b26f50f 100644
--- a/test/puppet_class_test.rb
+++ b/test/puppet_class_test.rb
@@ -2,6 +2,10 @@ require 'test/test_helper'
 
 class PuppetClassTest < Test::Unit::TestCase
 
+  def setup
+    Puppet::Node::Environment.clear
+  end
+
   def test_should_have_a_logger
     assert_respond_to Proxy::Puppet, :logger
   end
@@ -33,7 +37,7 @@ class PuppetClassTest < Test::Unit::TestCase
     manifest = <<-EOF
     class foreman::install {
       include 'x::y'
-
+    }
     EOF
     klasses =  Proxy::Puppet::PuppetClass.scan_manifest(manifest)
     assert_kind_of Array, klasses
@@ -52,7 +56,7 @@ class PuppetClassTest < Test::Unit::TestCase
     klasses =  Proxy::Puppet::PuppetClass.scan_manifest(manifest)
     assert klasses.empty?
   end
-    def test_should_find_multiple_class_in_a_manifest
+  def test_should_find_multiple_class_in_a_manifest
     manifest = <<-EOF
     class foreman::install {
       include 'x::y'
@@ -65,12 +69,18 @@ class PuppetClassTest < Test::Unit::TestCase
     klasses =  Proxy::Puppet::PuppetClass.scan_manifest(manifest)
     assert_kind_of Array, klasses
     assert_equal 2, klasses.size
+    klasses.sort! { |k1,k2| k1.name <=> k2.name }
+
+    klass = klasses.first
+
+    assert_equal "install", klass.name
+    assert_equal "foreman", klass.module
 
     klass = klasses.last
 
     assert_equal "params", klass.name
     assert_equal "foreman", klass.module
-    end
+  end
 
   def test_should_scan_a_dir
     klasses =  Proxy::Puppet::PuppetClass.scan_directory('/tmp/no_such_dir')
@@ -78,6 +88,76 @@ class PuppetClassTest < Test::Unit::TestCase
     assert klasses.empty?
   end
 
+  def test_should_extract_parameters__no_param_parenthesis
+    manifest = <<-EOF
+    class foreman::install {
+    }
+    EOF
+    klasses = Proxy::Puppet::PuppetClass.scan_manifest(manifest)
+    assert_kind_of Array, klasses
+    assert_equal 1, klasses.size
+    klass = klasses.first
+    assert_equal({}, klass.params)
+  end
+
+  def test_should_extract_parameters__empty_param_parenthesis
+    manifest = <<-EOF
+    class foreman::install () {
+    }
+    EOF
+    klasses = Proxy::Puppet::PuppetClass.scan_manifest(manifest)
+    assert_kind_of Array, klasses
+    assert_equal 1, klasses.size
+    klass = klasses.first
+    assert_equal({}, klass.params)
+  end
+
+  def test_should_extract_parameters__single_param_no_value
+    manifest = <<-EOF
+    class foreman::install ($mandatory) {
+    }
+    EOF
+    klasses = Proxy::Puppet::PuppetClass.scan_manifest(manifest)
+    assert_kind_of Array, klasses
+    assert_equal 1, klasses.size
+    klass = klasses.first
+    assert_equal({'mandatory' => nil}, klass.params)
+  end
+
+  def test_should_extract_parameters__type_coverage
+    # Note that all keys are string in Puppet
+    manifest = <<-EOF
+    class foreman::install (
+      $mandatory,
+      $emptyString = '',
+      $emptyStringDq = "",
+      $string = "foo",
+      $integer = 42,
+      $float = 3.14,
+      $array = ['', "", "foo", 42, 3.14],
+      $hash = { unquoted => '', "quoted" => "", 42 => "integer", 3.14 => "float", '' => 'empty' },
+      $complex = { array => ['','foo',42,3.14], hash => {foo=>"bar"}, mixed => [{foo=>bar},{bar=>"baz"}] }
+    ) {
+    }
+    EOF
+    klasses = Proxy::Puppet::PuppetClass.scan_manifest(manifest)
+    assert_kind_of Array, klasses
+    assert_equal 1, klasses.size
+    klass = klasses.first
+    assert_equal({
+      'mandatory' => nil,
+      'emptyString' => '',
+      'emptyStringDq' => '',
+      'string' => 'foo',
+      'integer' => 42,
+      'float' => 3.14,
+      'array' => ['', '', 'foo', 42, 3.14],
+      # All keys must be strings
+      'hash' => { 'unquoted' => '', 'quoted' => '', '42' => 'integer', '3.14' => 'float', '' => 'empty' },
+      'complex' => { 'array' => ['','foo',42,3.14], 'hash' => {'foo'=>'bar'}, 'mixed' => [{'foo'=>'bar'},{'bar'=>'baz'}] }
+    }, klass.params)
+  end
+
   #TODO add scans to a real puppet directory with modules
 
 end
