From 5156e8db104cd52f864d168bb27c964d3d35ac9e Mon Sep 17 00:00:00 2001 From: David Schmitt Date: Mon, 29 Jan 2018 16:16:02 +0000 Subject: [PATCH] (PDK-437) Add support for Resource API types --- lib/puppet-strings/yard/handlers.rb | 1 + .../yard/handlers/ruby/rsapi_handler.rb | 141 ++++++++++++ .../yard/handlers/ruby/rsapi_handler_spec.rb | 213 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 lib/puppet-strings/yard/handlers/ruby/rsapi_handler.rb create mode 100644 spec/unit/puppet-strings/yard/handlers/ruby/rsapi_handler_spec.rb diff --git a/lib/puppet-strings/yard/handlers.rb b/lib/puppet-strings/yard/handlers.rb index 8168343..af7aaa2 100644 --- a/lib/puppet-strings/yard/handlers.rb +++ b/lib/puppet-strings/yard/handlers.rb @@ -3,6 +3,7 @@ module PuppetStrings::Yard::Handlers # The module for custom Ruby YARD handlers. module Ruby require 'puppet-strings/yard/handlers/ruby/type_handler' + require 'puppet-strings/yard/handlers/ruby/rsapi_handler' require 'puppet-strings/yard/handlers/ruby/provider_handler' require 'puppet-strings/yard/handlers/ruby/function_handler' end diff --git a/lib/puppet-strings/yard/handlers/ruby/rsapi_handler.rb b/lib/puppet-strings/yard/handlers/ruby/rsapi_handler.rb new file mode 100644 index 0000000..de3b02d --- /dev/null +++ b/lib/puppet-strings/yard/handlers/ruby/rsapi_handler.rb @@ -0,0 +1,141 @@ +require 'puppet-strings/yard/handlers/helpers' +require 'puppet-strings/yard/handlers/ruby/base' +require 'puppet-strings/yard/code_objects' +require 'puppet-strings/yard/util' + +# Implements the handler for Puppet resource types written in Ruby. +class PuppetStrings::Yard::Handlers::Ruby::RsapiHandler < PuppetStrings::Yard::Handlers::Ruby::Base + # The default docstring when ensurable is used without given a docstring. + DEFAULT_ENSURABLE_DOCSTRING = 'The basic property that the resource should be in.'.freeze + + namespace_only + handles method_call(:register_type) + + process do + # Only accept calls to Puppet::ResourceApi + return unless statement.count > 1 + module_name = statement[0].source + return unless [ 'Puppet::ResourceApi' ].include? module_name + + schema = extract_schema + + # puts "Schema: #{schema.inspect}" + + object = PuppetStrings::Yard::CodeObjects::Type.new(schema['name']) + register object + + docstring = schema['docs'] + if docstring + register_docstring(object, PuppetStrings::Yard::Util.scrub_string(docstring.to_s), nil) + else + log.warn "Missing a description for Puppet resource type '#{object.name}' at #{statement.file}:#{statement.line}." + end + + # Populate the parameters/properties/features to the type + populate_type_data(object, schema) + + # Mark the type as public if it doesn't already have an api tag + object.add_tag YARD::Tags::Tag.new(:api, 'public') unless object.has_tag? :api + + # Warn if a summary longer than 140 characters was provided + PuppetStrings::Yard::Handlers::Helpers.validate_summary_tag(object) if object.has_tag? :summary + end + + private + + def raise_parse_error(msg, location = statement) + raise YARD::Parser::UndocumentableError, "#{msg} at #{location.file}:#{location.line}." if parameters.empty? + end + + # check that the params of the register_type call are key/value pairs. + def kv_arg_list?(params) + params.type == :list && params.children.count > 0 && params.children.first.type == :list && params.children.first.children.count > 0 && statement.parameters.children.first.children.first.type == :assoc + end + + def extract_schema + raise_parse_error("Expected list of key/value pairs as argument") unless kv_arg_list?(statement.parameters) + hash_from_node(statement.parameters.children.first) + end + + def value_from_node(node) + return nil unless node + + # puts "value from #{node.inspect}" + + case node.type + when :int + node.source.to_i + when :hash + hash_from_node(node) + when :var_ref + var_ref_from_node(node) + when :symbol, :symbol_literal, :label, :dyna_symbol, :string_literal + node_as_string(node) + else + raise_parse_error("unexpected construct #{node.type}") + end + end + + def hash_from_node(node) + return nil unless node + + # puts "hash from #{node.inspect}" + + kv_pairs = node.children.collect do |assoc| + [ value_from_node(assoc.children[0]), value_from_node(assoc.children[1]) ] + end + Hash[kv_pairs] + end + + def var_ref_from_node(node) + return nil unless node + + # puts "var_ref from #{node.inspect}" + + if node.children.first.type == :kw + case node.children.first.source + when "false" + return false + when "true" + return true + when "nil" + return nil + else + raise_parse_error("unexpected keyword '#{node.children.first.source}'") + end + end + raise_parse_error("unexpected variable") + end + + + def populate_type_data(object, schema) + return if schema['attributes'].nil? + + schema['attributes'].each do |name, definition| + # puts "Processing #{name}: #{definition.inspect}" + if ['parameter', 'namevar'].include? definition['behaviour'] + object.add_parameter(create_parameter(name, definition)) + else + object.add_property(create_property(name, definition)) + end + end + end + + def create_parameter(name, definition) + parameter = PuppetStrings::Yard::CodeObjects::Type::Parameter.new(name, definition['desc']) + set_values(definition, parameter) + parameter + end + + def create_property(name, definition) + property = PuppetStrings::Yard::CodeObjects::Type::Property.new(name, definition['desc']) + set_values(definition, property) + property + end + + def set_values(definition, object) + object.add(definition['type']) if definition.key? 'type' + object.default = definition['default'] if definition.key? 'default' + object.isnamevar = definition.key?('behaviour') && definition['behaviour'] == 'namevar' + end +end diff --git a/spec/unit/puppet-strings/yard/handlers/ruby/rsapi_handler_spec.rb b/spec/unit/puppet-strings/yard/handlers/ruby/rsapi_handler_spec.rb new file mode 100644 index 0000000..1e65a26 --- /dev/null +++ b/spec/unit/puppet-strings/yard/handlers/ruby/rsapi_handler_spec.rb @@ -0,0 +1,213 @@ +require 'spec_helper' +require 'puppet-strings/yard' + +describe PuppetStrings::Yard::Handlers::Ruby::RsapiHandler do + subject { + YARD::Parser::SourceParser.parse_string(source, :ruby) + YARD::Registry.all(:puppet_type) + } + + describe 'parsing source without a type definition' do + let(:source) { 'puts "hi"' } + + it 'no types should be in the registry' do + expect(subject.empty?).to eq(true) + end + end + + describe 'parsing a type with a missing description' do + let(:source) { <<-SOURCE +Puppet::ResourceApi.register_type( + name: 'database' +) +SOURCE + } + + it 'should log a warning' do + expect{ subject }.to output(/\[warn\]: Missing a description for Puppet resource type 'database' at \(stdin\):1\./).to_stdout_from_any_process + end + end + + describe 'parsing a type with a valid docstring assignment' do + let(:source) { <<-SOURCE +Puppet::ResourceApi.register_type( + name: 'database', + docs: 'An example database server resource type.', +) +SOURCE + } + + it 'should correctly detect the docstring' do + expect(subject.size).to eq(1) + object = subject.first + expect(object.docstring).to eq('An example database server resource type.') + end + end + + describe 'parsing a type with a docstring which uses ruby `%Q` notation' do + let(:source) { <<-'SOURCE' +test = 'hello world!' + +Puppet::ResourceApi.register_type( + name: 'database', + docs: %Q{This is a multi-line +doc in %Q with #{test}}, +) +SOURCE + } + + it 'should strip the `%Q{}` and render the interpolation expression literally' do + expect(subject.size).to eq(1) + object = subject.first + expect(object.docstring).to eq("This is a multi-line\ndoc in %Q with \#{test}") + end + end + + describe 'parsing a type definition' do + let(:source) { <<-SOURCE +# @!puppet.type.param [value1, value2] dynamic_param Documentation for a dynamic parameter. +# @!puppet.type.property [foo, bar] dynamic_prop Documentation for a dynamic property. +Puppet::ResourceApi.register_type( + name: 'database', + docs: 'An example database server resource type.', + attributes: { + ensure: { + type: 'Enum[present, absent, up, down]', + desc: 'What state the database should be in.', + default: 'up', + }, + address: { + type: 'String', + desc: 'The database server name.', + behaviour: :namevar, + }, + encrypt: { + type: 'Boolean', + desc: 'Whether or not to encrypt the database.', + default: false, + behaviour: :parameter, + }, + encryption_key: { + type: 'Optional[String]', + desc: 'The encryption key to use.', + behaviour: :parameter, + }, + backup: { + type: 'Enum[daily, monthly, never]', + desc: 'How often to backup the database.', + default: 'never', + behaviour: :parameter, + }, + file: { + type: 'String', + desc: 'The database file to use.', + }, + log_level: { + type: 'Enum[debug, warn, error]', + desc: 'The log level to use.', + default: 'warn', + }, + }, +) +SOURCE + } + + it 'should register a type object' do + expect(subject.size).to eq(1) + object = subject.first + expect(object).to be_a(PuppetStrings::Yard::CodeObjects::Type) + expect(object.namespace).to eq(PuppetStrings::Yard::CodeObjects::Types.instance) + expect(object.name).to eq(:database) + expect(object.docstring).to eq('An example database server resource type.') + expect(object.docstring.tags.size).to eq(1) + tags = object.docstring.tags(:api) + expect(tags.size).to eq(1) + expect(tags[0].text).to eq('public') + expect(object.properties.map(&:name)).to eq(['dynamic_prop', 'ensure', 'file', 'log_level']) + expect(object.properties.size).to eq(4) + expect(object.properties[0].name).to eq('dynamic_prop') + expect(object.properties[0].docstring).to eq('Documentation for a dynamic property.') + expect(object.properties[0].isnamevar).to eq(false) + expect(object.properties[0].values).to eq(%w(foo bar)) + expect(object.properties[1].name).to eq('ensure') + expect(object.properties[1].docstring).to eq('What state the database should be in.') + expect(object.properties[1].isnamevar).to eq(false) + expect(object.properties[1].default).to eq('up') + expect(object.properties[1].values).to eq(['Enum[present, absent, up, down]']) + expect(object.properties[1].aliases).to eq({}) + expect(object.properties[2].name).to eq('file') + expect(object.properties[2].docstring).to eq('The database file to use.') + expect(object.properties[2].isnamevar).to eq(false) + expect(object.properties[2].default).to be_nil + expect(object.properties[2].values).to eq(['String']) + expect(object.properties[2].aliases).to eq({}) + expect(object.properties[3].name).to eq('log_level') + expect(object.properties[3].docstring).to eq('The log level to use.') + expect(object.properties[3].isnamevar).to eq(false) + expect(object.properties[3].default).to eq('warn') + expect(object.properties[3].values).to eq(['Enum[debug, warn, error]']) + expect(object.properties[3].aliases).to eq({}) + expect(object.parameters.size).to eq(5) + expect(object.parameters[0].name).to eq('dynamic_param') + expect(object.parameters[0].docstring).to eq('Documentation for a dynamic parameter.') + expect(object.parameters[0].isnamevar).to eq(false) + expect(object.parameters[0].values).to eq(%w(value1 value2)) + expect(object.parameters[1].name).to eq('address') + expect(object.parameters[1].docstring).to eq('The database server name.') + expect(object.parameters[1].isnamevar).to eq(true) + expect(object.parameters[1].default).to be_nil + expect(object.parameters[1].values).to eq(['String']) + expect(object.parameters[1].aliases).to eq({}) + expect(object.parameters[2].name).to eq('encrypt') + expect(object.parameters[2].docstring).to eq('Whether or not to encrypt the database.') + expect(object.parameters[2].isnamevar).to eq(false) + expect(object.parameters[2].default).to eq(false) + expect(object.parameters[2].values).to eq(["Boolean"]) + expect(object.parameters[2].aliases).to eq({}) + expect(object.parameters[3].name).to eq('encryption_key') + expect(object.parameters[3].docstring).to eq('The encryption key to use.') + expect(object.parameters[3].isnamevar).to eq(false) + expect(object.parameters[3].default).to be_nil + expect(object.parameters[3].values).to eq(["Optional[String]"]) + expect(object.parameters[3].aliases).to eq({}) + expect(object.parameters[4].name).to eq('backup') + expect(object.parameters[4].docstring).to eq('How often to backup the database.') + expect(object.parameters[4].isnamevar).to eq(false) + expect(object.parameters[4].default).to eq('never') + expect(object.parameters[4].values).to eq(["Enum[daily, monthly, never]"]) + end + end + + describe 'parsing a type with a summary' do + context 'when the summary has fewer than 140 characters' do + let(:source) { <<-SOURCE +Puppet::ResourceApi.register_type( + name: 'database', + docs: '@summary A short summary.', +) +SOURCE + } + + it 'should parse the summary' do + expect{ subject }.to output('').to_stdout_from_any_process + expect(subject.size).to eq(1) + summary = subject.first.tags(:summary) + expect(summary.first.text).to eq('A short summary.') + end + end + + context 'when the summary has more than 140 characters' do + let(:source) { <<-SOURCE +Puppet::ResourceApi.register_type( + name: 'database', + docs: '@summary A short summary that is WAY TOO LONG. AHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH this is not what a summary is for! It should be fewer than 140 characters!!', +) +SOURCE + } + + it 'should log a warning' do + expect{ subject }.to output(/\[warn\]: The length of the summary for puppet_type 'database' exceeds the recommended limit of 140 characters./).to_stdout_from_any_process + end + end + end +end