From e75c81d97213472401c169064679f9f35696faf9 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Thu, 13 Jun 2019 21:45:11 +0800 Subject: [PATCH 1/5] (maint) Update LSP Base class serialisation Previously when serialising LSP classes it was possible to throw errors on Arrays of simple types e.g. [1, 2], because the array element Integer doesn't have a .to_h method. This commit updates the serialiser to only call .to_h if the item actually supports it. --- lib/lsp/lsp_base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lsp/lsp_base.rb b/lib/lsp/lsp_base.rb index dfe26213..7cff601e 100644 --- a/lib/lsp/lsp_base.rb +++ b/lib/lsp/lsp_base.rb @@ -16,7 +16,7 @@ def to_h item_value = send(name) if item_value.is_a?(Array) # Convert the items in the array .to_h - item_value = item_value.map { |item| item.to_h } + item_value = item_value.map { |item| item.respond_to?(:to_h) ? item.to_h : item } elsif !item_value.nil? && item_value.respond_to?(:to_h) item_value = item_value.to_h end From da6c963e7dfa475609f8f24f3846fc16caf05c12 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Tue, 11 Jun 2019 15:49:54 +0800 Subject: [PATCH 2/5] (maint) Minor fixes to Puppet Parser Helper Previously the line offset calculation used error handling as a form of branching, however this is not needed. This commit: * Instead of using error handling, uses the standard respond_to? method to detect if the method exists * Adds a commented out code block which is useful during development only --- .../puppet_parser_helper.rb | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/puppet-languageserver/puppet_parser_helper.rb b/lib/puppet-languageserver/puppet_parser_helper.rb index a31e076f..6ff0bf9b 100644 --- a/lib/puppet-languageserver/puppet_parser_helper.rb +++ b/lib/puppet-languageserver/puppet_parser_helper.rb @@ -130,9 +130,9 @@ def self.object_under_cursor(content, line_num, char_num, options) # [0, 14, 34, 36] means line number 2 starts at absolute offset 34 # Once we know the line offset, we can simply add on the char_num to get the absolute offset # If during paring we modified the source we may need to change the cursor location - begin + if result.respond_to?(:line_offsets) line_offset = result.line_offsets[line_num] - rescue StandardError + else line_offset = result['locator'].line_index[line_num] end # Typically we're completing after something was typed, so go back one char @@ -187,5 +187,20 @@ def self.construct_path(item) def self.check_for_valid_item(item, abs_offset, disallowed_classes) item.respond_to?(:offset) && !item.offset.nil? && !item.length.nil? && abs_offset >= item.offset && abs_offset <= item.offset + item.length && !disallowed_classes.include?(item.class) end + + # This method is only required during development or debugging. Visualising the AST tree can be difficult + # so this method just prints it to the console. + # def self.recurse_showast(item, abs_offset, disallowed_classes, depth = 0) + # output = " " * depth + # output += check_for_valid_item(item, abs_offset, disallowed_classes) ? 'X ' : ' ' + # output += "#{item.class.to_s} (#{item.object_id})" + # if item.respond_to?(:offset) + # output += " (Off-#{item.offset}:#{item.offset + item.length} Pos-#{item.line}:#{item.pos} Len-#{item.length}) ~#{item.locator.extract_text(item.offset, item.length)}~" + # end + # puts output + # item._pcore_contents do |child| + # recurse_showast(child, abs_offset, disallowed_classes, depth + 1) + # end + # end end end From f7d4a2590c7fd9d91dba7841cea808ab8106f102 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Tue, 11 Jun 2019 15:51:24 +0800 Subject: [PATCH 3/5] (GH-144) Add Locator to Puppet Parser Helper response Previously the object_under_cursor method only returned the AST object and the path to the object. However some providers may also require a locator because Puppet 4 does not expose the Locator on the AST object, only on the Factory. This commit adds the return struct for the object_under_cursor method to also return the Locator. No test changes are required. --- lib/puppet-languageserver/puppet_parser_helper.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/puppet-languageserver/puppet_parser_helper.rb b/lib/puppet-languageserver/puppet_parser_helper.rb index 6ff0bf9b..c3ed245f 100644 --- a/lib/puppet-languageserver/puppet_parser_helper.rb +++ b/lib/puppet-languageserver/puppet_parser_helper.rb @@ -144,7 +144,8 @@ def self.object_under_cursor(content, line_num, char_num, options) # # TODO: Should probably walk the AST and only look for the deepest child, but integer sorting # is so much easier and faster. - model_path_struct = Struct.new(:model, :path) + model_path_locator_struct = Struct.new(:model, :path, :locator) + valid_models = [] if result.model.respond_to? :eAllContents valid_models = result.model.eAllContents.select do |item| @@ -156,7 +157,7 @@ def self.object_under_cursor(content, line_num, char_num, options) path = [] result.model._pcore_all_contents(path) do |item| if check_for_valid_item(item, abs_offset, options[:disallowed_classes]) # rubocop:disable Style/IfUnlessModifier Nicer to read like this - valid_models.push(model_path_struct.new(item, path.dup)) + valid_models.push(model_path_locator_struct.new(item, path.dup)) end end @@ -164,13 +165,14 @@ def self.object_under_cursor(content, line_num, char_num, options) end # nil means the root of the document return nil if valid_models.empty? - item = valid_models[0] + response = valid_models[0] - if item.respond_to? :eAllContents # rubocop:disable Style/IfUnlessModifier Nicer to read like this - item = model_path_struct.new(item, construct_path(item)) + if response.respond_to? :eAllContents # rubocop:disable Style/IfUnlessModifier Nicer to read like this + response = model_path_locator_struct.new(response, construct_path(response)) end - item + response.locator = result.model.locator + response end def self.construct_path(item) From d6cc4fc3736ee78907edbc842390e2160e3f4f12 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Tue, 11 Jun 2019 15:45:01 +0800 Subject: [PATCH 4/5] (GH-144) Add optional remove_trigger_char to Puppet Parser Helper Previously the object_under_cursor always assumed it should remove the trigger character when trying to figure out the object under the cursor. While this works for things like completion and hover, it does not for signature help. This commit updates the options hash with a new key called remove_trigger_char which by default removes the character during detection. Just like the default behaviour. This commit does not require any test changes as behaviour has not changed. --- lib/puppet-languageserver/puppet_parser_helper.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/puppet-languageserver/puppet_parser_helper.rb b/lib/puppet-languageserver/puppet_parser_helper.rb index c3ed245f..e0003c60 100644 --- a/lib/puppet-languageserver/puppet_parser_helper.rb +++ b/lib/puppet-languageserver/puppet_parser_helper.rb @@ -60,9 +60,10 @@ def self.get_line_at(content, line_offsets, line_num) def self.object_under_cursor(content, line_num, char_num, options) options = { - :multiple_attempts => false, - :disallowed_classes => [], - :tasks_mode => false + :multiple_attempts => false, + :disallowed_classes => [], + :tasks_mode => false, + :remove_trigger_char => true }.merge(options) # Use Puppet to generate the AST @@ -135,8 +136,9 @@ def self.object_under_cursor(content, line_num, char_num, options) else line_offset = result['locator'].line_index[line_num] end - # Typically we're completing after something was typed, so go back one char - abs_offset = line_offset + char_num + move_offset - 1 + abs_offset = line_offset + char_num + move_offset + # Typically we're completing after something was typed, so go back one char by default + abs_offset -= 1 if options[:remove_trigger_char] # Enumerate the AST looking for items that span the line/char we want. # Once we have all valid items, sort them by the smallest span. Typically the smallest span From dfc6c468817c8b8551bdab890c1097b903ec4c33 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Tue, 4 Jun 2019 16:44:37 +0800 Subject: [PATCH 5/5] (GH-144) Add signature help provider feature This commit adds a new Signature Help provider for puppet manifest files. It takes the information from the Puppet 4 API metadata (signatures) and uses the Puppet Parser Helper to figure out which signature and then which parameter in that signature is being queried by the Language Client. * Adds a new signature_provider.rb for manifests and adds many test scenarios to ensure the provider behaves as expected * Updates the message router to route the new messages appropriately * Updates the server capabilities to tell the client we support the signature provider, and what keys will trigger the provider --- .../manifest/signature_provider.rb | 167 ++++++++++ lib/puppet-languageserver/message_router.rb | 17 ++ lib/puppet-languageserver/providers.rb | 1 + .../server_capabilities.rb | 5 +- .../manifest/signature_provider_spec.rb | 287 ++++++++++++++++++ 5 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 lib/puppet-languageserver/manifest/signature_provider.rb create mode 100644 spec/languageserver/integration/puppet-languageserver/manifest/signature_provider_spec.rb diff --git a/lib/puppet-languageserver/manifest/signature_provider.rb b/lib/puppet-languageserver/manifest/signature_provider.rb new file mode 100644 index 00000000..92e44a0e --- /dev/null +++ b/lib/puppet-languageserver/manifest/signature_provider.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +module PuppetLanguageServer + module Manifest + module SignatureProvider + def self.signature_help(content, line_num, char_num, options = {}) + options = { + :tasks_mode => false + }.merge(options) + + result = PuppetLanguageServer::PuppetParserHelper.object_under_cursor(content, line_num, char_num, + :multiple_attempts => false, + :tasks_mode => options[:tasks_mode], + :remove_trigger_char => false) + response = LSP::SignatureHelp.new.from_h!('signatures' => [], 'activeSignature' => nil, 'activeParameter' => nil) + # We are in the root of the document so no signatures here. + return response if result.nil? + + item = result[:model] + path = result[:path] + locator = result[:locator] + + function_ast_object = nil + # Try and find the acutal function object within the AST + if item.class.to_s == 'Puppet::Pops::Model::CallNamedFunctionExpression' + function_ast_object = item + else + # Try and find the function with the AST tree + distance_up_ast = -1 + function_ast_object = path[distance_up_ast] + while !function_ast_object.nil? && function_ast_object.class.to_s != 'Puppet::Pops::Model::CallNamedFunctionExpression' + distance_up_ast -= 1 + function_ast_object = path[distance_up_ast] + end + raise "Unable to find suitable parent object for object of type #{item.class}" if function_ast_object.nil? + end + + function_name = function_ast_object.functor_expr.value + raise 'Could not determine the function name' if function_name.nil? + + # Convert line and char nums (base 0) to an absolute offset within the document + # result.line_offsets contains an array of the offsets on a per line basis e.g. + # [0, 14, 34, 36] means line number 2 starts at absolute offset 34 + # Once we know the line offset, we can simply add on the char_num to get the absolute offset + if function_ast_object.respond_to?(:locator) + line_offset = function_ast_object.locator.line_index[line_num] + else + line_offset = locator.line_index[line_num] + end + + abs_offset = line_offset + char_num + # We need to use offsets here in case functions span lines + param_number = param_number_from_ast(abs_offset, function_ast_object, locator) + raise 'Cursor is not within the function expression' if param_number.nil? + + func_info = PuppetLanguageServer::PuppetHelper.function(function_name) + raise "Function #{function_name} does not exist" if func_info.nil? + + func_info.signatures.each do |sig| + lsp_sig = LSP::SignatureInformation.new.from_h!( + 'label' => sig.key, + 'documentation' => sig.doc, + 'parameters' => [] + ) + + sig.parameters.each do |param| + lsp_sig.parameters << LSP::ParameterInformation.new.from_h!( + 'label' => param.signature_key_offset.nil? || param.signature_key_length.nil? ? param.name : [param.signature_key_offset, param.signature_key_offset + param.signature_key_length], + 'documentation' => param.doc + ) + end + + response.signatures << lsp_sig + end + + # Now figure out the first signature which could have the same or more than the number of arguments in the function call + func_arg_count = function_ast_object.arguments.count + signature_number = func_info.signatures.find_index { |sig| sig.parameters.count >= func_arg_count } + + # If we still don't know the signature number then assume it's the first one + signature_number = 0 if signature_number.nil? && func_info.signatures.count > 0 + + response.activeSignature = signature_number unless signature_number.nil? + response.activeParameter = param_number + + response + end + + def self.param_number_from_ast(char_offset, function_ast_object, locator) + # Figuring out which parameter the cursor is in is a little tricky. For example: + # + # func_two_param( $param1 , $param2 ) + # |------------------------------------| Locator for the entire function + # |-------------| Locator for the function expression + # |-----| Locator for function.arguments[0] child (contains it's children) + # |-----| Locator for function.arguments[1] child (contains it's children) + # + # Importantly, whitespace isn't included in the AST + + function_offset = function_ast_object.offset + function_length = function_ast_object.length + functor_expr_offset = function_ast_object.functor_expr.offset + functor_expr_length = function_ast_object.functor_expr.length + + # Shouldn't happen but, safety first! + return nil if function_offset.nil? || function_length.nil? || functor_expr_offset.nil? || functor_expr_length.nil? + + # Is the cursor on the function name or the opening bracket? then we are not in any parameters + return nil if char_offset <= functor_expr_offset + functor_expr_length + # Is the cursor on or beyond the closing bracket? then we are not in any parameters + return nil if char_offset >= function_offset + function_length + # Does the function even have arguments? then the cursor HAS to be in the first parameter + return 0 if function_ast_object.arguments.count.zero? + # Is the cursor within any of the function argument locations? if so, return the parameter number we're in + param_number = function_ast_object.arguments.find_index { |arg| char_offset >= arg.offset && char_offset <= arg.offset + arg.length } + return param_number unless param_number.nil? + + # So now we know the char_offset exists outside any of the locators. Check the extremities + # Is it before the first argument? if so then the parameter number has to be zero + return 0 if char_offset < function_ast_object.arguments[0].offset + + last_arg_index = function_ast_object.arguments.count - 1 + last_offset = function_ast_object.arguments[last_arg_index].offset + function_ast_object.arguments[last_arg_index].length + if char_offset > last_offset + # We know that the cursor is after the last argument, but before the closing bracket. + # Therefore the before_index is the last argument in the list, and the after_index is one more than that + before_index = last_arg_index + after_index = last_arg_index + 1 + before_offset = function_ast_object.arguments[before_index].offset + before_length = function_ast_object.arguments[before_index].length + # But we need to get the text after the last argument, to the closing bracket + locator_args = [ + last_offset, + function_offset + function_length - last_offset - 1 # Find the difference from the entire function length and the last offset and subtract 1 for the closing bracket as we don't need it + ] + else + # Now we now the char_offset exists between two existing locators. Determine the location by finding which argument is AFTER the cursor + after_index = function_ast_object.arguments.find_index { |arg| char_offset < arg.offset } + return nil if after_index.nil? || after_index.zero? # This should never happen but, you never know. + before_index = after_index - 1 + + # Now we know between which arguments (before_index and after_index) the char_offset lies + before_length = function_ast_object.arguments[before_index].length + before_offset = function_ast_object.arguments[before_index].offset + after_offset = function_ast_object.arguments[after_index].offset + + # Determine the text between the two arguments + locator_args = [ + before_offset + before_length, # From the end the begin_index + after_offset - before_offset - before_length # to the start of the after_index + ] + end + between_text = function_ast_object.respond_to?(:locator) ? function_ast_object.locator.extract_text(*locator_args) : locator.extract_text(*locator_args) + + # Now we have the text between the two arguments, determine where the comma is + comma_index = between_text.index(',') + # If there is no comma then it has to be the before_index i.e. + # ..., $param ) <--- the space before the closing bracket would have a between_text of ' ' + return before_index if comma_index.nil? + + # If the char_offset is after the comma then choose the after_index otherwise choose the before_index + char_offset > before_offset + before_length + comma_index ? after_index : before_index + end + private_class_method :param_number_from_ast + end + end +end diff --git a/lib/puppet-languageserver/message_router.rb b/lib/puppet-languageserver/message_router.rb index ffe36fbc..7aeb2882 100644 --- a/lib/puppet-languageserver/message_router.rb +++ b/lib/puppet-languageserver/message_router.rb @@ -179,6 +179,23 @@ def receive_request(request) request.reply_result(nil) end + when 'textDocument/signatureHelp' + file_uri = request.params['textDocument']['uri'] + line_num = request.params['position']['line'] + char_num = request.params['position']['character'] + content = documents.document(file_uri) + begin + case documents.document_type(file_uri) + when :manifest + request.reply_result(PuppetLanguageServer::Manifest::SignatureProvider.signature_help(content, line_num, char_num, :tasks_mode => PuppetLanguageServer::DocumentStore.module_plan_file?(file_uri))) + else + raise "Unable to provide signatures on #{file_uri}" + end + rescue StandardError => e + PuppetLanguageServer.log_message(:error, "(textDocument/signatureHelp) #{e}") + request.reply_result(nil) + end + when 'workspace/symbol' begin result = [] diff --git a/lib/puppet-languageserver/providers.rb b/lib/puppet-languageserver/providers.rb index 1fd99e1b..874b08d9 100644 --- a/lib/puppet-languageserver/providers.rb +++ b/lib/puppet-languageserver/providers.rb @@ -5,6 +5,7 @@ manifest/completion_provider manifest/definition_provider manifest/document_symbol_provider + manifest/signature_provider manifest/validation_provider manifest/hover_provider puppetfile/r10k/module/base diff --git a/lib/puppet-languageserver/server_capabilities.rb b/lib/puppet-languageserver/server_capabilities.rb index d418d168..3ada953d 100644 --- a/lib/puppet-languageserver/server_capabilities.rb +++ b/lib/puppet-languageserver/server_capabilities.rb @@ -14,7 +14,10 @@ def self.capabilities }, 'definitionProvider' => true, 'documentSymbolProvider' => true, - 'workspaceSymbolProvider' => true + 'workspaceSymbolProvider' => true, + 'signatureHelpProvider' => { + 'triggerCharacters' => ['(', ','] + } } end diff --git a/spec/languageserver/integration/puppet-languageserver/manifest/signature_provider_spec.rb b/spec/languageserver/integration/puppet-languageserver/manifest/signature_provider_spec.rb new file mode 100644 index 00000000..f0094347 --- /dev/null +++ b/spec/languageserver/integration/puppet-languageserver/manifest/signature_provider_spec.rb @@ -0,0 +1,287 @@ +require 'spec_helper' + +def pretty_string(value) + value.nil? ? 'nil' : value.to_s +end + +describe 'signature_provider' do + let(:subject) { PuppetLanguageServer::Manifest::SignatureProvider } + + before(:all) do + wait_for_puppet_loading + end + + before(:each) do + # Prepopulate the Object Cache with workspace objects + # Functions + list = PuppetLanguageServer::Sidecar::Protocol::PuppetFunctionList.new + list << PuppetLanguageServer::Sidecar::Protocol::PuppetFunction.new.from_h!({ + 'key' => 'func_two_param', + 'doc' => 'documentation for func_two_param', + 'function_version' => 4, + 'signatures' => [ + { + 'key' => 'func_two_param(Any $p1)', + 'doc' => 'first func_two_param signature', + 'return_types' => ['Any'], + 'parameters' => [ + { 'name' => 'p1', 'types' => ['Any'], 'doc' => 'p1 documentation' }, + ] + }, + { + 'key' => 'func_two_param(Any $p1, Any $p2)', + 'doc' => 'second func_two_param signature', + 'return_types' => ['Any'], + 'parameters' => [ + { 'name' => 'p1', 'types' => ['Any'], 'doc' => 'p1 documentation' }, + { 'name' => 'p2', 'types' => ['Any'], 'doc' => 'p2 documentation' }, + ] + }, + ] + }) + list << PuppetLanguageServer::Sidecar::Protocol::PuppetFunction.new.from_h!({ + 'key' => 'func_three_param', + 'doc' => 'documentation for func_three_param', + 'function_version' => 4, + 'signatures' => [ + { + 'key' => 'func_three_param(Any $p1, Any $p2, Any $p3)', + 'doc' => 'first func_three_param signature', + 'return_types' => ['Any'], + 'parameters' => [ + { 'name' => 'p1', 'types' => ['Any'], 'doc' => 'p1 documentation' }, + { 'name' => 'p2', 'types' => ['Any'], 'doc' => 'p2 documentation' }, + { 'name' => 'p3', 'types' => ['Any'], 'doc' => 'p3 documentation' } + ] + }, + ] + }) + PuppetLanguageServer::PuppetHelper.cache.import_sidecar_list!(list, :function, :workspace) + end + + after(:each) do + # Clear out the Object Cache of workspace objects + PuppetLanguageServer::PuppetHelper.cache.import_sidecar_list!([], :function, :workspace) + end + + describe '#signature_help' do + context "Given a simple valid manifest with a function with three parameters" do + let(:content) { "#something\nfunc_three_param( $param1 , $param2 ,\'param3\') \n#somethingelse" } + + [ + { :name => 'after the start bracket', :character => 17, :activeParameter => 0 }, + { :name => 'within the first parameter', :character => 20, :activeParameter => 0 }, + { :name => 'before first comma', :character => 26, :activeParameter => 0 }, + { :name => 'after first comma', :character => 28, :activeParameter => 1 }, + { :name => 'within the second parameter', :character => 34, :activeParameter => 1 }, + { :name => 'after second parameter', :character => 37, :activeParameter => 1 }, + { :name => 'after second comma', :character => 40, :activeParameter => 2 }, + { :name => 'within the third parameter', :character => 44, :activeParameter => 2 }, + { :name => 'before the end bracket', :character => 48, :activeParameter => 2 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it "should use the first signature" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(0) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + + context "Given a simple valid manifest with a function with two parameters" do + context "When supplying only one parameter" do + let(:content) { "#something\nfunc_two_param( $param1 ) \n#somethingelse" } + + [ + { :name => 'middle of first parameter', :character => 20, :activeParameter => 0, :activeSignature => 0 }, + ].each do |testcase| + describe "When the cursor is in #{testcase[:name]}" do + it "should have an active signature of #{pretty_string(testcase[:activeSignature])}" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(testcase[:activeSignature]) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + + context "When supplying two parameters" do + let(:content) { "#something\nfunc_two_param( $param1 , $param2 ) \n#somethingelse" } + + [ + { :name => 'within the first parameter', :character => 20, :activeParameter => 0, :activeSignature => 1 }, + { :name => 'within the second parameter', :character => 29, :activeParameter => 1, :activeSignature => 1 }, + ].each do |testcase| + describe "When the cursor is in #{testcase[:name]}" do + it "should have an active signature of #{pretty_string(testcase[:activeSignature])}" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(testcase[:activeSignature]) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + end + + context "Given a manifest part way through editing at the end" do + let(:content) { 'func_three_param( $param1 , ) ' } + + [ + { :name => 'after the start bracket', :character => 17, :activeParameter => 0 }, + { :name => 'within the first parameter', :character => 20, :activeParameter => 0 }, + { :name => 'before first comma', :character => 26, :activeParameter => 0 }, + { :name => 'after first comma', :character => 28, :activeParameter => 1 }, + { :name => 'before the end bracket', :character => 30, :activeParameter => 1 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it "should use the first signature" do + result = subject.signature_help(content, 0, testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(0) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, 0, testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + + context "Given a manifest with nested function calls, across multiple lines" do + let(:content) { "func_three_param( $param1 ,\n func_two_param( $nest1 , $nest2) ,\n $param3\n) " } + + describe "Within the first function" do + [ + { :name => 'after the start bracket', :line => 0, :character => 17, :activeParameter => 0, :activeSignature => 0 }, + { :name => 'within the first parameter', :line => 0, :character => 22, :activeParameter => 0, :activeSignature => 0 }, + { :name => 'before first comma', :line => 0, :character => 26, :activeParameter => 0, :activeSignature => 0 }, + { :name => 'after first comma', :line => 0, :character => 28, :activeParameter => 1, :activeSignature => 0 }, + { :name => 'before second comma', :line => 1, :character => 35, :activeParameter => 1, :activeSignature => 0 }, + { :name => 'after second comma', :line => 1, :character => 36, :activeParameter => 2, :activeSignature => 0 }, + { :name => 'within the third parameter', :line => 2, :character => 4, :activeParameter => 2, :activeSignature => 0 }, + { :name => 'before the end bracket', :line => 3, :character => 0, :activeParameter => 2, :activeSignature => 0 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it "should return signatures for the first function" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.signatures.count).to be > 0 + expect(result.signatures[0].documentation).to match(/func_three_param/) + end + + it "should have an active signature of #{pretty_string(testcase[:activeSignature])}" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(testcase[:activeSignature]) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + + describe "Within the second function" do + [ + { :name => 'after the start bracket', :line => 1, :character => 17, :activeParameter => 0, :activeSignature => 1 }, + { :name => 'within the first parameter', :line => 1, :character => 19, :activeParameter => 0, :activeSignature => 1 }, + { :name => 'before first comma', :line => 1, :character => 24, :activeParameter => 0, :activeSignature => 1 }, + { :name => 'after first comma', :line => 1, :character => 26, :activeParameter => 1, :activeSignature => 1 }, + { :name => 'within the second parameter', :line => 1, :character => 30, :activeParameter => 1, :activeSignature => 1 }, + { :name => 'before the end bracket', :line => 1, :character => 33, :activeParameter => 1, :activeSignature => 1 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it "should return signatures for the second function" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.signatures.count).to be > 0 + expect(result.signatures[0].documentation).to match(/func_two_param/) + end + + it "should have an active signature of #{pretty_string(testcase[:activeSignature])}" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.activeSignature).to eq(testcase[:activeSignature]) + end + + it "should have an active parameter of #{pretty_string(testcase[:activeParameter])}" do + result = subject.signature_help(content, testcase[:line], testcase[:character], { :tasks_mode => true}) + + expect(result.activeParameter).to eq(testcase[:activeParameter]) + end + end + end + end + end + + context "Given an invalid manifest" do + [ + { :name => 'an empty first parameter', :manifest => 'func_three_param( , $param1 ) ' }, + { :name => 'a missing middle parameter', :manifest => 'func_three_param($param1 , , ) ' }, + ].each do |testcase| + describe "When the manifest has #{testcase[:name]}" do + it "should raise a runtime error" do + expect {subject.signature_help(testcase[:manifest], 0, 18, { :tasks_mode => true}) }.to raise_error(RuntimeError) + end + end + end + end + + context 'Given an invalid character location' do + describe 'With a single function in the manifest' do + let(:content) { "#something\nfunc_three_param( $param1 , $param2 ,\'param3\') \n#somethingelse" } + + [ + { :name => 'on function name', :character => 3 }, + { :name => 'before the start bracket', :character => 16 }, + { :name => 'after the end bracket', :character => 49 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it 'should raise a runtime error' do + expect {subject.signature_help(content, 1, testcase[:character], { :tasks_mode => true}) }.to raise_error(RuntimeError) + end + end + end + end + + describe 'With a nested function in the manifest' do + let(:content) { 'func_three_param( $param1 , func_two_param( $nest1 , $nest2) , $param3)' } + + [ + { :name => 'on outer function name', :character => 8 }, + { :name => 'on nested function name', :character => 36 }, + ].each do |testcase| + describe "When the cursor is #{testcase[:name]}" do + it 'should raise a runtime error' do + expect {subject.signature_help(content, 0, testcase[:character], { :tasks_mode => true}) }.to raise_error(RuntimeError) + end + end + end + end + end + end +end