diff --git a/Gemfile b/Gemfile index 6b6ec8d6..65f9258e 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,10 @@ group :development do else gem 'puppet', :require => false end + # TODO: This should be vendored into Editor Services after it is no longer a feature flag + # The puppet-strings gem is not available in the Puppet Agent, but is in the PDK. We add it to the + # Gemfile here for testing and development. + gem "puppet-strings", "~> 2.0", :require => false case RUBY_PLATFORM when /darwin/ diff --git a/lib/puppet-languageserver-sidecar/cache/base.rb b/lib/puppet-languageserver-sidecar/cache/base.rb index 65246cd6..e14e4440 100644 --- a/lib/puppet-languageserver-sidecar/cache/base.rb +++ b/lib/puppet-languageserver-sidecar/cache/base.rb @@ -5,6 +5,7 @@ module Cache CLASSES_SECTION = 'classes' FUNCTIONS_SECTION = 'functions' TYPES_SECTION = 'types' + PUPPETSTRINGS_SECTION = 'puppetstrings' class Base attr_reader :cache_options @@ -24,6 +25,12 @@ def load(_absolute_path, _section) def save(_absolute_path, _section, _content_string) raise NotImplementedError end + + # WARNING - This method is only intended for testing the cache + # and should not be used for normal operations + def clear! + raise NotImplementedError + end end end end diff --git a/lib/puppet-languageserver-sidecar/cache/filesystem.rb b/lib/puppet-languageserver-sidecar/cache/filesystem.rb index 128141b0..676e75f1 100644 --- a/lib/puppet-languageserver-sidecar/cache/filesystem.rb +++ b/lib/puppet-languageserver-sidecar/cache/filesystem.rb @@ -15,7 +15,7 @@ def initialize(_options = {}) begin Dir.mkdir(@cache_dir) unless Dir.exist?(@cache_dir) rescue Errno::ENOENT => e - PuppetLanguageServerSidecar.log_message(:error, "[PuppetLanguageServerSidecar::Cache::File] An error occured while creating file cache. Disabling cache: #{e}") + PuppetLanguageServerSidecar.log_message(:error, "[PuppetLanguageServerSidecar::Cache::FileSystem] An error occured while creating file cache. Disabling cache: #{e}") @cache_dir = nil end end @@ -37,20 +37,20 @@ def load(absolute_path, section) # Check that this is from the same language server version unless json_obj['sidecar_version'] == PuppetLanguageServerSidecar.version - PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::load] Error loading #{absolute_path}: Expected sidecar_version version #{PuppetLanguageServerSidecar.version} but found #{json_obj['sidecar_version']}") + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::Cache::FileSystem.load] Error loading #{absolute_path}: Expected sidecar_version version #{PuppetLanguageServerSidecar.version} but found #{json_obj['sidecar_version']}") return nil end # Check that the source file hash matches content_hash = calculate_hash(absolute_path) if json_obj['file_hash'] != content_hash - PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::load] Error loading #{absolute_path}: Expected file_hash of #{content_hash} but found #{json_obj['file_hash']}") + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::Cache::FileSystem.load] Error loading #{absolute_path}: Expected file_hash of #{content_hash} but found #{json_obj['file_hash']}") return nil end - PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::load] Loading #{absolute_path} from cache") + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::Cache::FileSystem.load] Loading #{absolute_path} from cache") json_obj['data'] rescue RuntimeError => e - PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::load] Error loading #{absolute_path}: #{e}") + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::Cache::FileSystem.load] Error loading #{absolute_path}: #{e}") raise end @@ -67,9 +67,16 @@ def save(absolute_path, section, content_string) content['path'] = absolute_path content['section'] = section + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetLanguageServerSidecar::Cache::FileSystem.save] Saving #{absolute_path} to cache") save_file(cache_file, content.to_json) end + def clear! + return unless active? + PuppetLanguageServerSidecar.log_message(:warn, '[PuppetLanguageServerSidecar::Cache::FileSystem.clear] Filesystem based cache is being cleared') + FileUtils.rm(Dir.glob(File.join(cache_dir, '*')), :force => true) + end + private def file_key(filepath, section) diff --git a/lib/puppet-languageserver-sidecar/cache/null.rb b/lib/puppet-languageserver-sidecar/cache/null.rb index e4a0cec6..2b743125 100644 --- a/lib/puppet-languageserver-sidecar/cache/null.rb +++ b/lib/puppet-languageserver-sidecar/cache/null.rb @@ -18,6 +18,10 @@ def load(*) def save(*) true end + + def clear! + nil + end end end end diff --git a/lib/puppet-languageserver-sidecar/puppet_helper_puppetstrings.rb b/lib/puppet-languageserver-sidecar/puppet_helper_puppetstrings.rb new file mode 100644 index 00000000..a54ab690 --- /dev/null +++ b/lib/puppet-languageserver-sidecar/puppet_helper_puppetstrings.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require 'puppet/indirector/face' + +module PuppetLanguageServerSidecar + module PuppetHelper + SIDECAR_PUPPET_ENVIRONMENT = 'sidecarenvironment' + + def self.path_has_child?(path, child) + # Doesn't matter what the child is, if the path is nil it's true. + return true if path.nil? + return false if path.length >= child.length + + value = child.slice(0, path.length) + return true if value.casecmp(path).zero? + false + end + + # Resource Face + def self.get_puppet_resource(typename, title = nil) + result = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new + if title.nil? + resources = Puppet::Face[:resource, '0.0.1'].search(typename) + else + resources = Puppet::Face[:resource, '0.0.1'].find("#{typename}/#{title}") + end + return result if resources.nil? + resources = [resources] unless resources.is_a?(Array) + prune_resource_parameters(resources).each do |item| + obj = PuppetLanguageServer::Sidecar::Protocol::Resource.new + obj.manifest = item.to_manifest + result << obj + end + result + end + + # Puppet Strings loading + def self.available_documentation_types + [:function] + end + + # Retrieve objects via the Puppet 4 API loaders + def self.retrieve_via_puppet_strings(cache, options = {}) + PuppetLanguageServerSidecar.log_message(:debug, '[PuppetHelper::retrieve_via_puppet_strings] Starting') + + object_types = options[:object_types].nil? ? available_documentation_types : options[:object_types] + object_types.select! { |i| available_documentation_types.include?(i) } + + result = {} + return result if object_types.empty? + + result[:functions] = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new if object_types.include?(:function) + + current_env = current_environment + for_agent = options[:for_agent].nil? ? true : options[:for_agent] + loaders = Puppet::Pops::Loaders.new(current_env, for_agent) + + paths = [] + paths.concat(discover_type_paths(:function, loaders)) if object_types.include?(:function) + + paths.each do |path| + next unless path_has_child?(options[:root_path], path) + file_doc = PuppetLanguageServerSidecar::PuppetStringsHelper.file_documentation(path, cache) + next if file_doc.nil? + + if object_types.include?(:function) # rubocop:disable Style/IfUnlessModifier This reads better + file_doc.functions.each { |item| result[:functions] << item } + end + end + + # Remove Puppet3 functions which have a Puppet4 function already loaded + if object_types.include?(:function) + pup4_functions = result[:functions].select { |i| i.function_version == 4 }.map { |i| i.key } + result[:functions].reject! { |i| i.function_version == 3 && pup4_functions.include?(i.key) } + end + + result.each { |key, item| PuppetLanguageServerSidecar.log_message(:debug, "[PuppetHelper::retrieve_via_puppet_strings] Finished loading #{item.count} #{key}") } + result + end + + def self.discover_type_paths(type, loaders) + loaders.private_environment_loader.discover_paths(type) + end + + # Class and Defined Type loading + def self.retrieve_classes(cache, options = {}) + PuppetLanguageServerSidecar.log_message(:debug, '[PuppetHelper::retrieve_classes] Starting') + + # TODO: Can probably do this better, but this works. + current_env = current_environment + module_path_list = current_env + .modules + .select { |mod| Dir.exist?(File.join(mod.path, 'manifests')) } + .map { |mod| mod.path } + manifest_path_list = module_path_list.map { |mod_path| File.join(mod_path, 'manifests') } + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetHelper::retrieve_classes] Loading classes from #{module_path_list}") + + # Find and parse all manifests in the manifest paths + classes = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new + manifest_path_list.each do |manifest_path| + Dir.glob("#{manifest_path}/**/*.pp").each do |manifest_file| + begin + if path_has_child?(options[:root_path], manifest_file) # rubocop:disable Style/IfUnlessModifier Nicer to read like this + classes.concat(load_classes_from_manifest(cache, manifest_file)) + end + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:error, "[PuppetHelper::retrieve_classes] Error loading manifest #{manifest_file}: #{e} #{e.backtrace}") + end + end + end + + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetHelper::retrieve_classes] Finished loading #{classes.count} classes") + classes + end + + def self.retrieve_types(cache, options = {}) + PuppetLanguageServerSidecar.log_message(:debug, '[PuppetHelper::retrieve_types] Starting') + + # From https://github.com/puppetlabs/puppet/blob/ebd96213cab43bb2a8071b7ac0206c3ed0be8e58/lib/puppet/metatype/manager.rb#L182-L189 + autoloader = Puppet::Util::Autoload.new(self, 'puppet/type') + current_env = current_environment + types = PuppetLanguageServer::Sidecar::Protocol::PuppetTypeList.new + + # This is an expensive call + if autoloader.method(:files_to_load).arity.zero? + params = [] + else + params = [current_env] + end + autoloader.files_to_load(*params).each do |file| + name = file.gsub(autoloader.path + '/', '') + begin + expanded_name = autoloader.expand(name) + absolute_name = Puppet::Util::Autoload.get_file(expanded_name, current_env) + raise("Could not find absolute path of type #{name}") if absolute_name.nil? + if path_has_child?(options[:root_path], absolute_name) # rubocop:disable Style/IfUnlessModifier Nicer to read like this + types.concat(load_type_file(cache, name, absolute_name, autoloader, current_env)) + end + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:error, "[PuppetHelper::retrieve_types] Error loading type #{file}: #{e} #{e.backtrace}") + end + end + + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetHelper::retrieve_types] Finished loading #{types.count} type/s") + + types + end + + # Private functions + + def self.prune_resource_parameters(resources) + # From https://github.com/puppetlabs/puppet/blob/488661d84e54904124514ab9e4500e81b10f84d1/lib/puppet/application/resource.rb#L146-L148 + if resources.is_a?(Array) + resources.map(&:prune_parameters) + else + resources.prune_parameters + end + end + private_class_method :prune_resource_parameters + + def self.current_environment + begin + env = Puppet.lookup(:environments).get!(Puppet.settings[:environment]) + return env unless env.nil? + rescue Puppet::Environments::EnvironmentNotFound + PuppetLanguageServerSidecar.log_message(:warning, "[PuppetHelper::current_environment] Unable to load environment #{Puppet.settings[:environment]}") + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:warning, "[PuppetHelper::current_environment] Error loading environment #{Puppet.settings[:environment]}: #{e}") + end + Puppet.lookup(:current_environment) + end + private_class_method :current_environment + + def self.load_classes_from_manifest(cache, manifest_file) + class_info = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new + + if cache.active? + cached_result = cache.load(manifest_file, PuppetLanguageServerSidecar::Cache::CLASSES_SECTION) + unless cached_result.nil? + begin + class_info.from_json!(cached_result) + return class_info + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:warn, "[PuppetHelper::load_classes_from_manifest] Error while deserializing #{manifest_file} from cache: #{e}") + class_info = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new + end + end + end + + file_content = File.open(manifest_file, 'r:UTF-8') { |f| f.read } + + parser = Puppet::Pops::Parser::Parser.new + result = nil + begin + result = parser.parse_string(file_content, '') + rescue Puppet::ParseErrorWithIssue + # Any parsing errors means we can't inspect the document + return class_info + end + + # Enumerate the entire AST looking for classes and defined types + # TODO: Need to learn how to read the help/docs for hover support + if result.model.respond_to? :eAllContents + # Puppet 4 AST + result.model.eAllContents.select do |item| + puppet_class = {} + case item.class.to_s + when 'Puppet::Pops::Model::HostClassDefinition' + puppet_class['type'] = :class + when 'Puppet::Pops::Model::ResourceTypeDefinition' + puppet_class['type'] = :typedefinition + else + next + end + puppet_class['name'] = item.name + puppet_class['doc'] = nil + puppet_class['parameters'] = item.parameters + puppet_class['source'] = manifest_file + puppet_class['line'] = result.locator.line_for_offset(item.offset) - 1 + puppet_class['char'] = result.locator.offset_on_line(item.offset) + + obj = PuppetLanguageServerSidecar::Protocol::PuppetClass.from_puppet(item.name, puppet_class, result.locator) + class_info << obj + end + else + result.model._pcore_all_contents([]) do |item| + puppet_class = {} + case item.class.to_s + when 'Puppet::Pops::Model::HostClassDefinition' + puppet_class['type'] = :class + when 'Puppet::Pops::Model::ResourceTypeDefinition' + puppet_class['type'] = :typedefinition + else + next + end + puppet_class['name'] = item.name + puppet_class['doc'] = nil + puppet_class['parameters'] = item.parameters + puppet_class['source'] = manifest_file + puppet_class['line'] = item.line + puppet_class['char'] = item.pos + obj = PuppetLanguageServerSidecar::Protocol::PuppetClass.from_puppet(item.name, puppet_class, item.locator) + class_info << obj + end + end + cache.save(manifest_file, PuppetLanguageServerSidecar::Cache::CLASSES_SECTION, class_info.to_json) if cache.active? + + class_info + end + private_class_method :load_classes_from_manifest + + def self.load_type_file(cache, name, absolute_name, autoloader, env) + types = PuppetLanguageServer::Sidecar::Protocol::PuppetTypeList.new + if cache.active? + cached_result = cache.load(absolute_name, PuppetLanguageServerSidecar::Cache::TYPES_SECTION) + unless cached_result.nil? + begin + types.from_json!(cached_result) + return types + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:warn, "[PuppetHelper::load_type_file] Error while deserializing #{absolute_name} from cache: #{e}") + types = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new + end + end + end + + # Get the list of currently loaded types + loaded_types = [] + # Due to PUP-8301, if no types have been loaded yet then Puppet::Type.eachtype + # will throw instead of not yielding. + begin + Puppet::Type.eachtype { |item| loaded_types << item.name } + rescue NoMethodError => e + # Detect PUP-8301 + if e.respond_to?(:receiver) + raise unless e.name == :each && e.receiver.nil? + else + raise unless e.name == :each && e.message =~ /nil:NilClass/ + end + end + + unless autoloader.loaded?(name) + # This is an expensive call + unless autoloader.load(name, env) # rubocop:disable Style/IfUnlessModifier Nicer to read like this + PuppetLanguageServerSidecar.log_message(:error, "[PuppetHelper::load_type_file] type #{absolute_name} did not load") + end + end + + # Find the types that were loaded + # Due to PUP-8301, if no types have been loaded yet then Puppet::Type.eachtype + # will throw instead of not yielding. + begin + Puppet::Type.eachtype do |item| + next if loaded_types.include?(item.name) + # Ignore the internal only Puppet Types + next if item.name == :component || item.name == :whit + obj = PuppetLanguageServerSidecar::Protocol::PuppetType.from_puppet(item.name, item) + # TODO: Need to use calling_source in the cache backing store + # Perhaps I should be incrementally adding items to the cache instead of batch mode? + obj.calling_source = absolute_name + types << obj + end + rescue NoMethodError => e + # Detect PUP-8301 + if e.respond_to?(:receiver) + raise unless e.name == :each && e.receiver.nil? + else + raise unless e.name == :each && e.message =~ /nil:NilClass/ + end + end + PuppetLanguageServerSidecar.log_message(:warn, "[PuppetHelper::load_type_file] type #{absolute_name} did not load any types") if types.empty? + cache.save(absolute_name, PuppetLanguageServerSidecar::Cache::TYPES_SECTION, types.to_json) if cache.active? + + types + end + private_class_method :load_type_file + end +end diff --git a/lib/puppet-languageserver-sidecar/puppet_modulepath_monkey_patches.rb b/lib/puppet-languageserver-sidecar/puppet_modulepath_monkey_patches.rb index 56c121b6..c056e3f4 100644 --- a/lib/puppet-languageserver-sidecar/puppet_modulepath_monkey_patches.rb +++ b/lib/puppet-languageserver-sidecar/puppet_modulepath_monkey_patches.rb @@ -57,13 +57,23 @@ def create_workspace_module_object(path) begin metadata = workspace_load_json(File.read(md_file, :encoding => 'utf-8')) return nil if metadata['name'].nil? - # TODO : Need to rip out the module name + # Extract the actual module name + if Puppet::Module.is_module_directory_name?(metadata['name']) + module_name = metadata['name'] + elsif Puppet::Module.is_module_namespaced_name?(metadata['name']) + # Based on regex at https://github.com/puppetlabs/puppet/blob/f5ca8c05174c944f783cfd0b18582e2160b77d0e/lib/puppet/module.rb#L54 + result = /^[a-zA-Z0-9]+[-]([a-z][a-z0-9_]*)$/.match(metadata['name']) + module_name = result[1] + else + # TODO: This is an invalid puppet module name in the metadata.json. Should we log an error/warning? + return nil + end # The Puppet::Module initializer was changed in # https://github.com/puppetlabs/puppet/commit/935c0311dbaf1df03937822525c36b26de5390ef # We need to switch the creation based on whether the modules_strict_semver? method is available - return Puppet::Module.new(metadata['name'], path, self, modules_strict_semver?) if respond_to?('modules_strict_semver?') - return Puppet::Module.new(metadata['name'], path, self) + return Puppet::Module.new(module_name, path, self, modules_strict_semver?) if respond_to?('modules_strict_semver?') + return Puppet::Module.new(module_name, path, self) rescue StandardError return nil end diff --git a/lib/puppet-languageserver-sidecar/puppet_monkey_patches_puppetstrings.rb b/lib/puppet-languageserver-sidecar/puppet_monkey_patches_puppetstrings.rb new file mode 100644 index 00000000..4b092a17 --- /dev/null +++ b/lib/puppet-languageserver-sidecar/puppet_monkey_patches_puppetstrings.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# Add an additional method on Puppet Types to store their source location +require 'puppet/type' +module Puppet + class Type + class << self + attr_accessor :_source_location + end + end +end + +# Monkey Patch type loading so we can inject the source location information +require 'puppet/metatype/manager' +module Puppet + module MetaType + module Manager + alias_method :original_newtype, :newtype + def newtype(name, options = {}, &block) + result = original_newtype(name, options, &block) + + if block_given? && !block.source_location.nil? + result._source_location = { + :source => block.source_location[0], + :line => block.source_location[1] - 1 # Convert to a zero based line number system + } + end + result + end + end + end +end + +if Gem::Version.new(Puppet.version) >= Gem::Version.new('6.0.0') + # Due to PUP-9509, need to monkey patch the cache loader + # This need to be guarded on Puppet 6.0.0+ + require 'puppet/pops/loader/module_loaders' + module Puppet + module Pops + module Loader + module ModuleLoaders + def self.cached_loader_from(parent_loader, loaders) + LibRootedFileBased.new(parent_loader, + loaders, + NAMESPACE_WILDCARD, + Puppet[:libdir], + 'cached_puppet_lib', + %i[func_4x func_3x datatype]) + end + end + end + end + end +end + +module Puppet + module Pops + module Loader + class Loader + def discover_paths(type, name_authority = Pcore::RUNTIME_NAME_AUTHORITY) + if parent.nil? + [] + else + parent.discover_paths(type, name_authority) + end + end + end + end + end +end + +module Puppet + module Pops + module Loader + class DependencyLoader + def discover_paths(type, name_authority = Pcore::RUNTIME_NAME_AUTHORITY) + result = [] + + @dependency_loaders.each { |loader| result.concat(loader.discover_paths(type, name_authority)) } + result.concat(super) + result.uniq + end + end + end + end +end + +module Puppet + module Pops + module Loader + module ModuleLoaders + class AbstractPathBasedModuleLoader + def discover_paths(type, name_authority = Pcore::RUNTIME_NAME_AUTHORITY) + result = [] + if name_authority == Pcore::RUNTIME_NAME_AUTHORITY + smart_paths.effective_paths(type).each do |sp| + relative_paths(sp).each do |rp| + result << File.join(sp.generic_path, rp) + end + end + end + result.concat(super) + result.uniq + end + end + end + end + end +end + +# MUST BE LAST!!!!!! +# Suppress any warning messages to STDOUT. It can pollute stdout when running in STDIO mode +Puppet::Util::Log.newdesttype :null_logger do + def handle(msg) + PuppetLanguageServerSidecar.log_message(:debug, "[PUPPET LOG] [#{msg.level}] #{msg.message}") + end +end diff --git a/lib/puppet-languageserver-sidecar/puppet_strings_helper.rb b/lib/puppet-languageserver-sidecar/puppet_strings_helper.rb new file mode 100644 index 00000000..79b699dd --- /dev/null +++ b/lib/puppet-languageserver-sidecar/puppet_strings_helper.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +module PuppetLanguageServerSidecar + module PuppetStringsHelper + # Returns a FileDocumentation object for a given path + # + # @param [String] path The absolute path to the file that will be documented + # @param [PuppetLanguageServerSidecar::Cache] cache A Sidecar cache which stores already parsed documents as serialised FileDocumentation objects + # @return [FileDocumentation, nil] Returns the documentation for the path, or nil if it cannot be extracted + def self.file_documentation(path, cache = nil) + return nil unless require_puppet_strings + @helper_cache = FileDocumentationCache.new if @helper_cache.nil? + return @helper_cache.document(path) if @helper_cache.path_exists?(path) + + # Load from the permanent cache + @helper_cache.populate_from_sidecar_cache!(path, cache) unless cache.nil? || !cache.active? + return @helper_cache.document(path) if @helper_cache.path_exists?(path) + + PuppetLanguageServerSidecar.log_message(:debug, "[PuppetStringsHelper::file_documentation] Fetching documentation for #{path}") + + setup_yard! + + # For now, assume a single file path + search_patterns = [path] + + # Format the arguments to YARD + args = ['doc'] + args << '--no-output' + args << '--quiet' + args << '--no-stats' + args << '--no-progress' + args << '--no-save' + args << '--api public' + args << '--api private' + args << '--no-api' + args += search_patterns + + # Run YARD + ::YARD::CLI::Yardoc.run(*args) + + # Populate the documentation cache from the YARD information + @helper_cache.populate_from_yard_registry! + + # Save to the permanent cache + @helper_cache.save_to_sidecar_cache(path, cache) unless cache.nil? || !cache.active? + + # Return the documentation details + @helper_cache.document(path) + end + + def self.require_puppet_strings + return @puppet_strings_loaded unless @puppet_strings_loaded.nil? + begin + require 'puppet-strings' + require 'puppet-strings/yard' + require 'puppet-strings/json' + @puppet_strings_loaded = true + rescue LoadError => e + PuppetLanguageServerSidecar.log_message(:error, "[PuppetStringsHelper::require_puppet_strings] Unable to load puppet-strings gem: #{e}") + @puppet_strings_loaded = false + end + @puppet_strings_loaded + end + private_class_method :require_puppet_strings + + def self.setup_yard! + unless @yard_setup # rubocop:disable Style/GuardClause + ::PuppetStrings::Yard.setup! + @yard_setup = true + end + end + private_class_method :setup_yard! + end + + class FileDocumentationCache + def initialize + # Hash of <[String] path, FileDocumentation> objects + @cache = {} + end + + def path_exists?(path) + @cache.key?(path) + end + + def document(path) + @cache[path] + end + + def populate_from_yard_registry! + # Extract all of the information + # Ref - https://github.com/puppetlabs/puppet-strings/blob/87a8e10f45bfeb7b6b8e766324bfb126de59f791/lib/puppet-strings/json.rb#L10-L16 + populate_functions_from_yard_registry! + end + + def populate_from_sidecar_cache!(path, cache) + cached_result = cache.load(path, PuppetLanguageServerSidecar::Cache::PUPPETSTRINGS_SECTION) + unless cached_result.nil? # rubocop:disable Style/GuardClause Reads better this way + begin + obj = FileDocumentation.new.from_json!(cached_result) + @cache[path] = obj + rescue StandardError => e + PuppetLanguageServerSidecar.log_message(:warn, "[FileDocumentationCache::populate_from_sidecar_cache!] Error while deserializing #{path} from cache: #{e}") + end + end + end + + def save_to_sidecar_cache(path, cache) + cache.save(path, PuppetLanguageServerSidecar::Cache::PUPPETSTRINGS_SECTION, document(path).to_json) if cache.active? + end + + private + + def populate_functions_from_yard_registry! + ::YARD::Registry.all(:puppet_function).map(&:to_hash).each do |item| + source_path = item[:file] + func_name = item[:name].to_s + @cache[source_path] = FileDocumentation.new(source_path) if @cache[source_path].nil? + + obj = PuppetLanguageServer::Sidecar::Protocol::PuppetFunction.new + obj.key = func_name + obj.source = item[:file] + obj.calling_source = obj.source + obj.line = item[:line] + obj.doc = item[:docstring][:text] + obj.arity = -1 # We don't care about arity + obj.function_version = item[:type] == 'ruby4x' ? 4 : 3 + + # Try and determine the function call site from the source file + char = item[:source].index(":#{func_name}") + unless char.nil? + obj.char = char + obj.length = func_name.length + 1 + end + + case item[:type] + when 'ruby3x' + obj.function_version = 3 + # This is a bit hacky but it works (probably). Puppet-Strings doesn't rip this information out, but you do have the + # the source to query + obj.type = item[:source].match(/:rvalue/) ? :rvalue : :statement + when 'ruby4x' + obj.function_version = 4 + # All ruby functions are statements + obj.type = :statement + else + PuppetLanguageServerSidecar.log_message(:error, "[#{self.class}] Unknown function type #{item[:type]}") + end + + @cache[source_path].functions << obj + end + end + end + + class FileDocumentation + # The path to file that has been documented + attr_accessor :path + + # PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList object holding all functions + attr_accessor :functions + + def initialize(path = nil) + @path = path + @functions = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new + end + + # Serialisation + def to_h + { + 'path' => path, + 'functions' => functions + } + end + + def to_json(*options) + JSON.generate(to_h, options) + end + + # Deserialisation + def from_json!(json_string) + obj = JSON.parse(json_string) + + obj.keys.each do |key| + case key + when 'path' + # Simple deserialised object types + self.instance_variable_set("@#{key}", obj[key]) # rubocop:disable Style/RedundantSelf Reads better this way + else + # Sidecar protocol list object types + prop = self.instance_variable_get("@#{key}") # rubocop:disable Style/RedundantSelf Reads better this way + + obj[key].each do |child_hash| + child = prop.child_type.new + # Let the sidecar deserialise for us + prop << child.from_h!(child_hash) + end + end + end + self + end + end +end diff --git a/lib/puppet-languageserver-sidecar/puppet_strings_monkey_patches.rb b/lib/puppet-languageserver-sidecar/puppet_strings_monkey_patches.rb new file mode 100644 index 00000000..297cf198 --- /dev/null +++ b/lib/puppet-languageserver-sidecar/puppet_strings_monkey_patches.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'yard/logging' +module YARD + class Logger < ::Logger + # Suppress ANY output + def self.instance(_pipe = STDOUT) + @logger ||= new(nil) + end + + # Suppress ANY progress indicators + def show_progress + false + end + end +end diff --git a/lib/puppet-languageserver/sidecar_protocol.rb b/lib/puppet-languageserver/sidecar_protocol.rb index 6e680370..7e448429 100644 --- a/lib/puppet-languageserver/sidecar_protocol.rb +++ b/lib/puppet-languageserver/sidecar_protocol.rb @@ -154,12 +154,15 @@ class PuppetFunction < BasePuppetObject attr_accessor :doc attr_accessor :arity attr_accessor :type + # The version of this function, typically 3 or 4. + attr_accessor :function_version def to_h super.to_h.merge( - 'doc' => doc, - 'arity' => arity, - 'type' => type + 'doc' => doc, + 'arity' => arity, + 'type' => type, + 'function_version' => function_version ) end @@ -169,6 +172,7 @@ def from_h!(value) self.doc = value['doc'] self.arity = value['arity'] self.type = value['type'].intern + self.function_version = value['function_version'] self end end diff --git a/lib/puppet_languageserver_sidecar.rb b/lib/puppet_languageserver_sidecar.rb index 0564a365..5bcbfdd8 100644 --- a/lib/puppet_languageserver_sidecar.rb +++ b/lib/puppet_languageserver_sidecar.rb @@ -27,6 +27,15 @@ def self.version PuppetEditorServices.version end + def self.configure_featureflags(flags) + @flags = flags + end + + def self.featureflag?(flagname) + return false if @flags.nil? || @flags.empty? + @flags.include?(flagname) + end + def self.require_gems(options) original_verbose = $VERBOSE $VERBOSE = nil @@ -45,16 +54,45 @@ def self.require_gems(options) require 'puppet' - %w[ + # Validate the feature flags + unless options[:flags].nil? || options[:flags].empty? + flags = options[:flags] + log_message(:debug, "Detected feature flags [#{options[:flags].join(', ')}]") + + strings_gem = Gem::Specification.select { |item| item.name.casecmp('puppet-strings').zero? } + if flags.include?('puppetstrings') && strings_gem.count.zero? + # The puppetstrings flag is only valid when the puppet-strings gem is available + PuppetEditorServices.log_message(:error, "The feature flag 'puppetstrings' has been specified but it is not capable due to Puppet Strings being unavailable. Turning off the flag.") + flags -= ['puppetstrings'] + end + if flags.include?('puppetstrings') && Gem::Version.new(Puppet.version) < Gem::Version.new('6.0.0') + # The puppetstrings flag is only valid on Puppet 6.0.0+ + PuppetEditorServices.log_message(:error, "The feature flag 'puppetstrings' has been specified but it is not capable due to low Puppet version (< 6). Turning off the flag.") + flags -= ['puppetstrings'] + end + configure_featureflags(flags) + end + + require_list = %w[ cache/base cache/null cache/filesystem - puppet_helper puppet_parser_helper - puppet_monkey_patches sidecar_protocol_extensions workspace - ].each do |lib| + ] + + # Load files based on feature flags + if featureflag?('puppetstrings') + require_list << 'puppet_helper_puppetstrings' + require_list << 'puppet_monkey_patches_puppetstrings' + require_list << 'puppet_strings_helper' + else + require_list << 'puppet_helper' + require_list << 'puppet_monkey_patches' + end + + require_list.each do |lib| begin require "puppet-languageserver-sidecar/#{lib}" rescue LoadError @@ -184,8 +222,6 @@ def self.init_puppet_sidecar(options) def self.inject_workspace_as_module return false unless PuppetLanguageServerSidecar::Workspace.has_module_metadata? - Puppet.settings[:basemodulepath] = Puppet.settings[:basemodulepath] + ';' + PuppetLanguageServerSidecar::Workspace.root_path - %w[puppet_modulepath_monkey_patches].each do |lib| begin require "puppet-languageserver-sidecar/#{lib}" @@ -216,6 +252,8 @@ def self.inject_workspace_as_environment end def self.execute(options) + use_puppet_strings = featureflag?('puppetstrings') + case options[:action].downcase when 'noop' [] @@ -226,7 +264,11 @@ def self.execute(options) when 'default_functions' cache = options[:disable_cache] ? PuppetLanguageServerSidecar::Cache::Null.new : PuppetLanguageServerSidecar::Cache::FileSystem.new - PuppetLanguageServerSidecar::PuppetHelper.retrieve_functions(cache) + if use_puppet_strings + PuppetLanguageServerSidecar::PuppetHelper.retrieve_via_puppet_strings(cache, :object_types => [:function])[:functions] + else + PuppetLanguageServerSidecar::PuppetHelper.retrieve_functions(cache) + end when 'default_types' cache = options[:disable_cache] ? PuppetLanguageServerSidecar::Cache::Null.new : PuppetLanguageServerSidecar::Cache::FileSystem.new @@ -266,8 +308,16 @@ def self.execute(options) when 'workspace_functions' null_cache = PuppetLanguageServerSidecar::Cache::Null.new return nil unless inject_workspace_as_module || inject_workspace_as_environment - PuppetLanguageServerSidecar::PuppetHelper.retrieve_functions(null_cache, - :root_path => PuppetLanguageServerSidecar::Workspace.root_path) + + if use_puppet_strings + cache = options[:disable_cache] ? PuppetLanguageServerSidecar::Cache::Null.new : PuppetLanguageServerSidecar::Cache::FileSystem.new + PuppetLanguageServerSidecar::PuppetHelper.retrieve_via_puppet_strings(cache, + :object_types => [:function], + :root_path => PuppetLanguageServerSidecar::Workspace.root_path)[:functions] + else + PuppetLanguageServerSidecar::PuppetHelper.retrieve_functions(null_cache, + :root_path => PuppetLanguageServerSidecar::Workspace.root_path) + end when 'workspace_types' null_cache = PuppetLanguageServerSidecar::Cache::Null.new diff --git a/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/default_pup4_function.rb b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/default_pup4_function.rb new file mode 100644 index 00000000..cd958a9d --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/default_pup4_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API +# This should be loaded +Puppet::Functions.create_function(:default_pup4_function) do + # @return [Array] + def default_pup4_function + 'default_pup4_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/environment/default_env_pup4_function.rb b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/environment/default_env_pup4_function.rb new file mode 100644 index 00000000..32accc47 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/environment/default_env_pup4_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API +# This should be loaded in the environment namespace +Puppet::Functions.create_function(:'environment::default_env_pup4_function') do + # @return [Array] + def default_env_pup4_function + 'default_env_pup4_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/modname/default_mod_pup4_function.rb b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/modname/default_mod_pup4_function.rb new file mode 100644 index 00000000..422ea373 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/real_agent/cache/lib/puppet/functions/modname/default_mod_pup4_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API +# This should be loaded in the module called 'modname' namespace +Puppet::Functions.create_function(:'modname::default_mod_pup4_function') do + # @return [Array] + def default_mod_pup4_function + 'default_env_pup4_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/badname/pup4_function.rb b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/badname/pup4_function.rb new file mode 100644 index 00000000..453e94ec --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/badname/pup4_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# This should not be loaded in the environment namespace +Puppet::Functions.create_function(:'badname::pup4_function') do + # @return [Array] + def pup4_function + 'pup4_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/profile/pup4_envprofile_function.rb b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/profile/pup4_envprofile_function.rb new file mode 100644 index 00000000..250d4fb1 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/profile/pup4_envprofile_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# This should be loaded in the module namespace +Puppet::Functions.create_function(:'profile::pup4_envprofile_function') do + # @return [Array] + def pup4_envprofile_function + 'pup4_envprofile_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfile.rb b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfile.rb new file mode 100644 index 00000000..3fcc02a3 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfile.rb @@ -0,0 +1,10 @@ +require 'a_bad_gem_that_does_not_exist' + +# Example function using the Puppet 4 API in a module +# This should not be loaded +Puppet::Functions.create_function(:pup4_env_badfile) do + # @return [Array] + def pup4_env_badfile + 'pup4_env_badfile result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfunction.rb b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfunction.rb new file mode 100644 index 00000000..ad31fdec --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_badfunction.rb @@ -0,0 +1,9 @@ +# Example function using the Puppet 4 API in a module +# This should be loaded but never actually successfully invoke +Puppet::Functions.create_function(:pup4_env_badfunction) do + # @return [Array] + def pup4_env_badfunction + require 'a_bad_gem_that_does_not_exist' + 'pup4_env_badfunction result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_function.rb b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_function.rb new file mode 100644 index 00000000..88435708 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_environment_workspace/site/profile/lib/puppet/functions/pup4_env_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# ??? This should be loaded as global namespace function +Puppet::Functions.create_function(:pup4_env_function) do + # @return [Array] + def pup4_env_function + 'pup4_env_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/badname/fixture_pup4_env_function.rb b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/badname/fixture_pup4_env_function.rb new file mode 100644 index 00000000..af2e8753 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/badname/fixture_pup4_env_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# This should not be loaded in the module namespace +Puppet::Functions.create_function(:'badname::fixture_pup4_badname_function') do + # @return [Array] + def fixture_pup4_badname_function + 'fixture_pup4_badname_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfile.rb b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfile.rb new file mode 100644 index 00000000..1f429c7f --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfile.rb @@ -0,0 +1,10 @@ +require 'a_bad_gem_that_does_not_exist' + +# Example function using the Puppet 4 API in a module +# This should not be loaded +Puppet::Functions.create_function(:fixture_pup4_badfile) do + # @return [Array] + def fixture_pup4_badfile + 'fixture_pup4_badfile result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfunction.rb b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfunction.rb new file mode 100644 index 00000000..14f04645 --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_badfunction.rb @@ -0,0 +1,9 @@ +# Example function using the Puppet 4 API in a module +# This should be loaded but never actually successfully invoke +Puppet::Functions.create_function(:fixture_pup4_badfunction) do + # @return [Array] + def fixture_pup4_badfunction + require 'a_bad_gem_that_does_not_exist' + 'fixture_pup4_badfunction result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_function.rb b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_function.rb new file mode 100644 index 00000000..9999aafe --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/fixture_pup4_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# This should be loaded as global namespace function +Puppet::Functions.create_function(:fixture_pup4_function) do + # @return [Array] + def fixture_pup4_function + 'fixture_pup4_function result' + end +end diff --git a/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/valid/fixture_pup4_mod_function.rb b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/valid/fixture_pup4_mod_function.rb new file mode 100644 index 00000000..8f60b77d --- /dev/null +++ b/spec/languageserver-sidecar/fixtures/valid_module_workspace/lib/puppet/functions/valid/fixture_pup4_mod_function.rb @@ -0,0 +1,8 @@ +# Example function using the Puppet 4 API in a module +# This should be loaded in the module namespace +Puppet::Functions.create_function(:'valid::fixture_pup4_mod_function') do + # @return [Array] + def fixture_pup4_mod_function + 'fixture_pup4_mod_function result' + end +end diff --git a/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/featureflag_puppetstrings/featureflag_puppetstrings_spec.rb b/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/featureflag_puppetstrings/featureflag_puppetstrings_spec.rb new file mode 100644 index 00000000..a5cecbaa --- /dev/null +++ b/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/featureflag_puppetstrings/featureflag_puppetstrings_spec.rb @@ -0,0 +1,436 @@ +require 'spec_helper' +require 'open3' +require 'tempfile' + +describe 'PuppetLanguageServerSidecar with Feature Flag puppetstrings', :if => Gem::Version.new(Puppet.version) >= Gem::Version.new('6.0.0') do + def run_sidecar(cmd_options) + # Append the feature flag + cmd_options << '--feature-flag=puppetstrings' + + # Append the puppet test-fixtures + cmd_options << '--puppet-settings' + cmd_options << "--vardir,#{File.join($fixtures_dir, 'real_agent', 'cache')},--confdir,#{File.join($fixtures_dir, 'real_agent', 'confdir')}" + + cmd = ['ruby', 'puppet-languageserver-sidecar'].concat(cmd_options) + stdout, _stderr, status = Open3.capture3(*cmd) + + raise "Expected exit code of 0, but got #{status.exitstatus} #{_stderr}" unless status.exitstatus.zero? + return stdout.bytes.pack('U*') + end + + def child_with_key(array, key) + idx = array.index { |item| item.key == key } + return idx.nil? ? nil : array[idx] + end + + def with_temporary_file(content) + tempfile = Tempfile.new("langserver-sidecar") + tempfile.open + + tempfile.write(content) + + tempfile.close + + yield tempfile.path + ensure + tempfile.delete if tempfile + end + + RSpec::Matchers.define :contain_child_with_key do |key| + match do |actual| + !(actual.index { |item| item.key == key }).nil? + end + + failure_message do |actual| + "expected that #{actual.class.to_s} would contain a child with key #{key}" + end + end + + def should_not_contain_default_functions(deserial) + # These functions should not appear in the deserialised list as they're part + # of the default function set, not the workspace. + # + # They are defined in the fixtures/real_agent/cache/lib/... + expect(deserial).to_not contain_child_with_key(:default_cache_function) + expect(deserial).to_not contain_child_with_key(:default_pup4_function) + expect(deserial).to_not contain_child_with_key(:'environment::default_env_pup4_function') + expect(deserial).to_not contain_child_with_key(:'modname::default_mod_pup4_function') + end + + before(:each) do + # Purge the File Cache + cache = PuppetLanguageServerSidecar::Cache::FileSystem.new + cache.clear! + end + + after(:all) do + # Purge the File Cache + cache = PuppetLanguageServerSidecar::Cache::FileSystem.new + cache.clear! + end + + def expect_empty_cache + cache = PuppetLanguageServerSidecar::Cache::FileSystem.new + expect(Dir.exists?(cache.cache_dir)).to eq(true), "Expected the cache directory #{cache.cache_dir} to exist" + expect(Dir.glob(File.join(cache.cache_dir,'*')).count).to eq(0), "Expected the cache directory #{cache.cache_dir} to be empty" + end + + def expect_populated_cache + cache = PuppetLanguageServerSidecar::Cache::FileSystem.new + expect(Dir.glob(File.join(cache.cache_dir,'*')).count).to be > 0, "Expected the cache directory #{cache.cache_dir} to be populated" + end + + def expect_same_array_content(a, b) + expect(a.count).to eq(b.count), "Expected array with #{b.count} items to have #{a.count} items" + + a.each_with_index do |item, index| + expect(item.to_json).to eq(b[index].to_json), "Expected item at index #{index} to have content #{item.to_json} but got #{b[index].to_json}" + end + end + + describe 'when running default_classes action' do + let (:cmd_options) { ['--action', 'default_classes'] } + + it 'should return a deserializable class list with default classes' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.count).to be > 0 + + # There are no default classes in Puppet, so only check for ones in the environment + # These are defined in the fixtures/real_agent/environments/testfixtures/modules/defaultmodule + expect(deserial).to contain_child_with_key(:defaultdefinedtype) + expect(deserial).to contain_child_with_key(:defaultmodule) + end + end + + describe 'when running default_functions action' do + let (:cmd_options) { ['--action', 'default_functions'] } + + it 'should return a cachable deserializable function list with default functions' do + expect_empty_cache + + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.count).to be > 0 + + # These functions always exist + expect(deserial).to contain_child_with_key(:notice) + expect(deserial).to contain_child_with_key(:alert) + + # These are defined in the fixtures/real_agent/cache/lib/puppet/parser/functions + expect(deserial).to contain_child_with_key(:default_cache_function) + # These are defined in the fixtures/real_agent/cache/lib/puppet/functions + expect(deserial).to contain_child_with_key(:default_pup4_function) + # These are defined in the fixtures/real_agent/cache/lib/puppet/functions/environment (Special environent namespace) + expect(deserial).to contain_child_with_key(:'environment::default_env_pup4_function') + # These are defined in the fixtures/real_agent/cache/lib/puppet/functions/modname (module namespaced function) + expect(deserial).to contain_child_with_key(:'modname::default_mod_pup4_function') + + # Now run using cached information + expect_populated_cache + + result2 = run_sidecar(cmd_options) + deserial2 = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new() + expect { deserial2.from_json!(result2) }.to_not raise_error + + # The second result should be the same as the first + expect_same_array_content(deserial, deserial2) + end + end + + describe 'when running default_types action' do + let (:cmd_options) { ['--action', 'default_types'] } + + it 'should return a deserializable type list with default types' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetTypeList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.count).to be > 0 + + # These types always exist + expect(deserial).to contain_child_with_key(:user) + expect(deserial).to contain_child_with_key(:group) + expect(deserial).to contain_child_with_key(:package) + expect(deserial).to contain_child_with_key(:service) + + # These are defined in the fixtures/real_agent/cache/lib/puppet/type + expect(deserial).to contain_child_with_key(:default_type) + end + end + + context 'given a workspace containing a module' do + # Test fixtures used is fixtures/valid_module_workspace + let(:workspace) { File.join($fixtures_dir, 'valid_module_workspace') } + + describe 'when running node_graph action' do + let (:cmd_options) { ['--action', 'node_graph', '--local-workspace', workspace] } + + it 'should return a deserializable node graph' do + # The fixture type is only present in the local workspace + with_temporary_file("fixture { 'test':\n}") do |filepath| + action_params = PuppetLanguageServer::Sidecar::Protocol::ActionParams.new() + action_params['source'] = filepath + + result = run_sidecar(cmd_options.concat(['--action-parameters', action_params.to_json])) + + deserial = PuppetLanguageServer::Sidecar::Protocol::NodeGraph.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.dot_content).to match(/Fixture\[test\]/) + expect(deserial.error_content.to_s).to eq('') + end + end + end + + describe 'when running workspace_classes action' do + let (:cmd_options) { ['--action', 'workspace_classes', '--local-workspace', workspace] } + + it 'should return a deserializable class list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + # Classes + expect(deserial).to contain_child_with_key(:valid) + expect(deserial).to contain_child_with_key(:"valid::nested::anotherclass") + # Defined Types + expect(deserial).to contain_child_with_key(:deftypeone) + end + end + + describe 'when running workspace_functions action' do + let (:cmd_options) { ['--action', 'workspace_functions', '--local-workspace', workspace] } + + it 'should return a deserializable function list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + should_not_contain_default_functions(deserial) + + # Puppet 3 API Functions + expect(deserial).to_not contain_child_with_key(:badfile) + expect(deserial).to contain_child_with_key(:bad_function) + expect(deserial).to contain_child_with_key(:fixture_function) + + # Puppet 4 API Functions + # The strings based parser will still see 'fixture_pup4_badfile' because it's never _actually_ loaded + # in Puppet therefore it will never error + expect(deserial).to contain_child_with_key(:fixture_pup4_badfile) + # The strings based parser will still see 'badname::fixture_pup4_badname_function' because it's never _actually_ loaded + # in Puppet therefore it will never error + expect(deserial).to contain_child_with_key(:'badname::fixture_pup4_badname_function') + expect(deserial).to contain_child_with_key(:fixture_pup4_function) + expect(deserial).to contain_child_with_key(:'valid::fixture_pup4_mod_function') + expect(deserial).to contain_child_with_key(:fixture_pup4_badfunction) + + # Make sure the function has the right properties + func = child_with_key(deserial, :fixture_function) + expect(func.doc).to eq('doc_fixture_function') + expect(func.source).to match(/valid_module_workspace/) + + # Make sure the function has the right properties + func = child_with_key(deserial, :fixture_pup4_function) + expect(func.doc).to match(/Example function using the Puppet 4 API in a module/) + expect(func.source).to match(/valid_module_workspace/) + end + end + + describe 'when running workspace_types action' do + let (:cmd_options) { ['--action', 'workspace_types', '--local-workspace', workspace] } + + it 'should return a deserializable type list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetTypeList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial).to contain_child_with_key(:fixture) + + # Make sure the type has the right properties + obj = child_with_key(deserial, :fixture) + expect(obj.doc).to eq('doc_type_fixture') + expect(obj.source).to match(/valid_module_workspace/) + # Make sure the type attributes are correct + expect(obj.attributes.key?(:name)).to be true + expect(obj.attributes.key?(:when)).to be true + expect(obj.attributes[:name][:type]).to eq(:param) + expect(obj.attributes[:name][:doc]).to eq("name_parameter\n\n") + expect(obj.attributes[:name][:required?]).to be true + expect(obj.attributes[:when][:type]).to eq(:property) + expect(obj.attributes[:when][:doc]).to eq("when_property\n\n") + expect(obj.attributes[:when][:required?]).to be_nil + end + end + end + + context 'given a workspace containing an environment.conf' do + # Test fixtures used is fixtures/valid_environment_workspace + let(:workspace) { File.join($fixtures_dir, 'valid_environment_workspace') } + + describe 'when running node_graph action' do + let (:cmd_options) { ['--action', 'node_graph', '--local-workspace', workspace] } + + it 'should return a deserializable node graph' do + # The envtype type is only present in the local workspace + with_temporary_file("envtype { 'test':\n}") do |filepath| + action_params = PuppetLanguageServer::Sidecar::Protocol::ActionParams.new() + action_params['source'] = filepath + + result = run_sidecar(cmd_options.concat(['--action-parameters', action_params.to_json])) + + deserial = PuppetLanguageServer::Sidecar::Protocol::NodeGraph.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.dot_content).to match(/Envtype\[test\]/) + expect(deserial.error_content.to_s).to eq('') + end + end + end + + describe 'when running workspace_classes action' do + let (:cmd_options) { ['--action', 'workspace_classes', '--local-workspace', workspace] } + + it 'should return a deserializable class list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetClassList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + # Classes + expect(deserial).to contain_child_with_key(:"role::base") + expect(deserial).to contain_child_with_key(:"profile::main") + # Defined Types + expect(deserial).to contain_child_with_key(:envdeftype) + + # Make sure the class has the right properties + obj = child_with_key(deserial, :envdeftype) + expect(obj.doc).to be_nil # We don't yet get documentation for classes or defined types + expect(obj.parameters['ensure']).to_not be_nil + expect(obj.parameters['ensure'][:type]).to eq('String') + expect(obj.source).to match(/valid_environment_workspace/) + end + end + + describe 'when running workspace_functions action' do + let (:cmd_options) { ['--action', 'workspace_functions', '--local-workspace', workspace] } + + it 'should return a deserializable function list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + should_not_contain_default_functions(deserial) + + # The strings based parser will still see 'pup4_env_badfile' because it's never _actually_ loaded + # in Puppet therefore it will never error + expect(deserial).to contain_child_with_key(:pup4_env_badfile) + # The strings based parser will still see 'badname::pup4_function' because it's never _actually_ loaded + # in Puppet therefore it will never error + expect(deserial).to contain_child_with_key(:'badname::pup4_function') + expect(deserial).to contain_child_with_key(:env_function) + expect(deserial).to contain_child_with_key(:pup4_env_function) + expect(deserial).to contain_child_with_key(:pup4_env_badfunction) + expect(deserial).to contain_child_with_key(:'profile::pup4_envprofile_function') + + # Make sure the function has the right properties + func = child_with_key(deserial, :env_function) + expect(func.doc).to eq('doc_env_function') + expect(func.source).to match(/valid_environment_workspace/) + + # Make sure the function has the right properties + func = child_with_key(deserial, :pup4_env_function) + expect(func.doc).to match(/Example function using the Puppet 4 API in a module/) + expect(func.source).to match(/valid_environment_workspace/) + end + end + + describe 'when running workspace_types action' do + let (:cmd_options) { ['--action', 'workspace_types', '--local-workspace', workspace] } + + it 'should return a deserializable type list with the named fixtures' do + result = run_sidecar(cmd_options) + deserial = PuppetLanguageServer::Sidecar::Protocol::PuppetTypeList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial).to contain_child_with_key(:envtype) + + # Make sure the type has the right properties + obj = child_with_key(deserial, :envtype) + expect(obj.doc).to eq('doc_type_fixture') + expect(obj.source).to match(/valid_environment_workspace/) + # Make sure the type attributes are correct + expect(obj.attributes.key?(:name)).to be true + expect(obj.attributes.key?(:when)).to be true + expect(obj.attributes[:name][:type]).to eq(:param) + expect(obj.attributes[:name][:doc]).to eq("name_env_parameter\n\n") + expect(obj.attributes[:name][:required?]).to be true + expect(obj.attributes[:when][:type]).to eq(:property) + expect(obj.attributes[:when][:doc]).to eq("when_env_property\n\n") + expect(obj.attributes[:when][:required?]).to be_nil + end + end + end + + describe 'when running node_graph action' do + let (:cmd_options) { ['--action', 'node_graph'] } + + it 'should return a deserializable node graph' do + with_temporary_file("user { 'test':\nensure => present\n}") do |filepath| + action_params = PuppetLanguageServer::Sidecar::Protocol::ActionParams.new() + action_params['source'] = filepath + + result = run_sidecar(cmd_options.concat(['--action-parameters', action_params.to_json])) + + deserial = PuppetLanguageServer::Sidecar::Protocol::NodeGraph.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.dot_content).to_not eq('') + expect(deserial.error_content.to_s).to eq('') + end + end + end + + describe 'when running resource_list action' do + let (:cmd_options) { ['--action', 'resource_list'] } + + context 'for a resource with no title' do + let (:action_params) { + value = PuppetLanguageServer::Sidecar::Protocol::ActionParams.new() + value['typename'] = 'user' + value + } + + it 'should return a deserializable resource list' do + result = run_sidecar(cmd_options.concat(['--action-parameters', action_params.to_json])) + deserial = PuppetLanguageServer::Sidecar::Protocol::ResourceList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.count).to be > 0 + end + end + + context 'for a resource with a title' do + let (:action_params) { + # This may do odd things with non ASCII usernames on Windows + current_username = ENV['USER'] || ENV['USERNAME'] + + value = PuppetLanguageServer::Sidecar::Protocol::ActionParams.new() + value['typename'] = 'user' + value['title'] = current_username + value + } + + it 'should return a deserializable resource list with a single item' do + result = run_sidecar(cmd_options.concat(['--action-parameters', action_params.to_json])) + deserial = PuppetLanguageServer::Sidecar::Protocol::ResourceList.new() + expect { deserial.from_json!(result) }.to_not raise_error + + expect(deserial.count).to be 1 + end + end + end +end diff --git a/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/puppet-languageserver-sidecar_spec.rb b/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/puppet-languageserver-sidecar_spec.rb index 139111a0..e6874c42 100644 --- a/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/puppet-languageserver-sidecar_spec.rb +++ b/spec/languageserver-sidecar/integration/puppet-languageserver-sidecar/puppet-languageserver-sidecar_spec.rb @@ -2,49 +2,49 @@ require 'open3' require 'tempfile' -def run_sidecar(cmd_options) - cmd_options << '--no-cache' - - # Append the puppet test-fixtures - cmd_options << '--puppet-settings' - cmd_options << "--vardir,#{File.join($fixtures_dir, 'real_agent', 'cache')},--confdir,#{File.join($fixtures_dir, 'real_agent', 'confdir')}" +describe 'PuppetLanguageServerSidecar' do + def run_sidecar(cmd_options) + cmd_options << '--no-cache' - cmd = ['ruby', 'puppet-languageserver-sidecar'].concat(cmd_options) - stdout, _stderr, status = Open3.capture3(*cmd) + # Append the puppet test-fixtures + cmd_options << '--puppet-settings' + cmd_options << "--vardir,#{File.join($fixtures_dir, 'real_agent', 'cache')},--confdir,#{File.join($fixtures_dir, 'real_agent', 'confdir')}" - raise "Expected exit code of 0, but got #{status.exitstatus} #{_stderr}" unless status.exitstatus.zero? - return stdout.bytes.pack('U*') -end + cmd = ['ruby', 'puppet-languageserver-sidecar'].concat(cmd_options) + stdout, _stderr, status = Open3.capture3(*cmd) -def child_with_key(array, key) - idx = array.index { |item| item.key == key } - return idx.nil? ? nil : array[idx] -end + raise "Expected exit code of 0, but got #{status.exitstatus} #{_stderr}" unless status.exitstatus.zero? + return stdout.bytes.pack('U*') + end -def with_temporary_file(content) - tempfile = Tempfile.new("langserver-sidecar") - tempfile.open + def child_with_key(array, key) + idx = array.index { |item| item.key == key } + return idx.nil? ? nil : array[idx] + end - tempfile.write(content) + def with_temporary_file(content) + tempfile = Tempfile.new("langserver-sidecar") + tempfile.open - tempfile.close + tempfile.write(content) - yield tempfile.path -ensure - tempfile.delete if tempfile -end + tempfile.close -RSpec::Matchers.define :contain_child_with_key do |key| - match do |actual| - !(actual.index { |item| item.key == key }).nil? + yield tempfile.path + ensure + tempfile.delete if tempfile end - failure_message do |actual| - "expected that #{actual.class.to_s} would contain a child with key #{key}" + RSpec::Matchers.define :contain_child_with_key do |key| + match do |actual| + !(actual.index { |item| item.key == key }).nil? + end + + failure_message do |actual| + "expected that #{actual.class.to_s} would contain a child with key #{key}" + end end -end -describe 'PuppetLanguageServerSidecar' do describe 'when running default_classes action' do let (:cmd_options) { ['--action', 'default_classes'] } diff --git a/spec/languageserver/spec_helper.rb b/spec/languageserver/spec_helper.rb index e3e938a0..dfe4795b 100644 --- a/spec/languageserver/spec_helper.rb +++ b/spec/languageserver/spec_helper.rb @@ -70,6 +70,7 @@ def random_sidecar_puppet_function result.doc = 'doc' + rand(1000).to_s result.arity = rand(1000) result.type = ('type' + rand(1000).to_s).intern + result.function_version = rand(1) + 3 result end diff --git a/spec/languageserver/unit/puppet-languageserver/sidecar_protocol_spec.rb b/spec/languageserver/unit/puppet-languageserver/sidecar_protocol_spec.rb index 041dfdab..8aaea921 100644 --- a/spec/languageserver/unit/puppet-languageserver/sidecar_protocol_spec.rb +++ b/spec/languageserver/unit/puppet-languageserver/sidecar_protocol_spec.rb @@ -55,7 +55,7 @@ basepuppetobject_properties = [:key, :calling_source, :source, :line, :char, :length] nodegraph_properties = [:dot_content, :error_content] puppetclass_properties = [:doc, :parameters] - puppetfunction_properties = [:doc, :arity, :type] + puppetfunction_properties = [:doc, :arity, :type, :function_version] puppettype_properties = [:doc, :attributes] resource_properties = [:manifest]