Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/lsp/lsp_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions lib/puppet-languageserver/manifest/signature_provider.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions lib/puppet-languageserver/message_router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
1 change: 1 addition & 0 deletions lib/puppet-languageserver/providers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 32 additions & 13 deletions lib/puppet-languageserver/puppet_parser_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,21 +131,23 @@ 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
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
# is the most specific object in the AST
#
# 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|
Expand All @@ -156,21 +159,22 @@ 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

valid_models.sort! { |a, b| a[:model].length - b[:model].length }
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)
Expand All @@ -187,5 +191,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
5 changes: 4 additions & 1 deletion lib/puppet-languageserver/server_capabilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ def self.capabilities
},
'definitionProvider' => true,
'documentSymbolProvider' => true,
'workspaceSymbolProvider' => true
'workspaceSymbolProvider' => true,
'signatureHelpProvider' => {
'triggerCharacters' => ['(', ',']
}
}
end

Expand Down
Loading