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
142 changes: 129 additions & 13 deletions lib/puppet-languageserver/language_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,34 @@

module PuppetLanguageServer
class LanguageClient
def initialize
attr_reader :message_router

# Client settings
attr_reader :format_on_type

def initialize(message_router)
@message_router = message_router
@client_capabilites = {}

# Internal registry of dynamic registrations and their current state
# @registrations[ <[String] method_name>] = [
# {
# :id => [String] Request ID. Used for de-registration
# :registered => [Boolean] true | false
# :state => [Enum] :pending | :complete
# }
# ]
@registrations = {}

# Default settings
@format_on_type = false
end

def client_capability(*names)
safe_hash_traverse(@client_capabilites, *names)
end

def send_configuration_request(message_router)
def send_configuration_request
params = LSP::ConfigurationParams.new.from_h!('items' => [])
params.items << LSP::ConfigurationItem.new.from_h!('section' => 'puppet')

Expand All @@ -22,42 +41,139 @@ def parse_lsp_initialize!(initialize_params = {})
@client_capabilites = initialize_params['capabilities']
end

# Settings could be a hash or an array of hash
def parse_lsp_configuration_settings!(settings = [{}])
# TODO: Future use. Actually do something with the settings
# settings = [settings] unless settings.is_a?(Hash)
# settings.each do |hash|
# end
def parse_lsp_configuration_settings!(settings = {})
# format on type
value = safe_hash_traverse(settings, 'puppet', 'editorService', 'formatOnType', 'enable')
unless value.nil? || to_boolean(value) == @format_on_type # rubocop:disable Style/GuardClause Ummm no.
# Is dynamic registration available?
if client_capability('textDocument', 'onTypeFormatting', 'dynamicRegistration') == true
if value
register_capability('textDocument/onTypeFormatting', PuppetLanguageServer::ServerCapabilites.document_on_type_formatting_options)
else
unregister_capability('textDocument/onTypeFormatting')
end
end
@format_on_type = value
end
end

def capability_registrations(method)
return [{ :registered => false, :state => :complete }] if @registrations[method].nil? || @registrations[method].empty?
@registrations[method].dup
end

def register_capability(message_router, method, options = {})
id = SecureRandom.uuid
def register_capability(method, options = {})
id = new_request_id

PuppetLanguageServer.log_message(:info, "Attempting to dynamically register the #{method} method with id #{id}")

if @registrations[method] && @registrations[method].select { |i| i[:state] == :pending }.count > 0
# The protocol doesn't specify whether this is allowed and is probably per client specific. For the moment we will allow
# the registration to be sent but log a message that something may be wrong.
PuppetLanguageServer.log_message(:warn, "A dynamic registration/deregistration for the #{method} method is already in progress")
end

params = LSP::RegistrationParams.new.from_h!('registrations' => [])
params.registrations << LSP::Registration.new.from_h!('id' => id, 'method' => method, 'registerOptions' => options)
# Note - Don't put more than one method per request even though you can. It makes decoding errors much harder!

@registrations[method] = [] if @registrations[method].nil?
@registrations[method] << { :registered => false, :state => :pending, :id => id }

message_router.json_rpc_handler.send_client_request('client/registerCapability', params)
true
end

def parse_register_capability_response!(message_router, _response, original_request)
def unregister_capability(method)
if @registrations[method].nil?
PuppetLanguageServer.log_message(:debug, "No registrations to deregister for the #{method}")
return true
end

params = LSP::UnregistrationParams.new.from_h!('unregisterations' => [])
@registrations[method].each do |reg|
next if reg[:id].nil?
PuppetLanguageServer.log_message(:warn, "A dynamic registration/deregistration for the #{method} method, with id #{reg[:id]} is already in progress") if reg[:state] == :pending
# Ignore registrations that don't need to be unregistered
next if reg[:state] == :complete && !reg[:registered]
params.unregisterations << LSP::Unregistration.new.from_h!('id' => reg[:id], 'method' => method)
reg[:state] = :pending
end

if params.unregisterations.count.zero?
PuppetLanguageServer.log_message(:debug, "Nothing to deregister for the #{method} method")
return true
end

message_router.json_rpc_handler.send_client_request('client/unregisterCapability', params)
true
end

def parse_register_capability_response!(response, original_request)
raise 'Response is not from client/registerCapability request' unless original_request['method'] == 'client/registerCapability'

unless response.key?('result')
original_request['params'].registrations.each do |reg|
# Mark the registration as completed and failed
@registrations[reg.method__lsp] = [] if @registrations[reg.method__lsp].nil?
@registrations[reg.method__lsp].select { |i| i[:id] == reg.id }.each { |i| i[:registered] = false; i[:state] = :complete } # rubocop:disable Style/Semicolon This is fine
end
return true
end

original_request['params'].registrations.each do |reg|
PuppetLanguageServer.log_message(:info, "Succesfully dynamically registered the #{reg.method__lsp} method")

# Mark the registration as completed and succesful
@registrations[reg.method__lsp] = [] if @registrations[reg.method__lsp].nil?
@registrations[reg.method__lsp].select { |i| i[:id] == reg.id }.each { |i| i[:registered] = true; i[:state] = :complete } # rubocop:disable Style/Semicolon This is fine

# If we just registered the workspace/didChangeConfiguration method then
# also trigger a configuration request to get the initial state
send_configuration_request(message_router) if reg.method__lsp == 'workspace/didChangeConfiguration'
send_configuration_request if reg.method__lsp == 'workspace/didChangeConfiguration'
end

true
end

def parse_unregister_capability_response!(response, original_request)
raise 'Response is not from client/unregisterCapability request' unless original_request['method'] == 'client/unregisterCapability'

unless response.key?('result')
original_request['params'].unregisterations.each do |reg|
# Mark the registration as completed and failed
@registrations[reg.method__lsp] = [] if @registrations[reg.method__lsp].nil?
@registrations[reg.method__lsp].select { |i| i[:id] == reg.id && i[:registered] }.each { |i| i[:state] = :complete }
@registrations[reg.method__lsp].delete_if { |i| i[:id] == reg.id && !i[:registered] }
end
return true
end

original_request['params'].unregisterations.each do |reg|
PuppetLanguageServer.log_message(:info, "Succesfully dynamically unregistered the #{reg.method__lsp} method")

# Remove registrations
@registrations[reg.method__lsp] = [] if @registrations[reg.method__lsp].nil?
@registrations[reg.method__lsp].delete_if { |i| i[:id] == reg.id }
end

true
end

private

def to_boolean(value)
return false if value.nil? || value == false
return true if value == true
value.to_s =~ %r{^(true|t|yes|y|1)$/i}
end

def new_request_id
SecureRandom.uuid
end

def safe_hash_traverse(hash, *names)
return nil if names.empty?
return nil if names.empty? || hash.nil? || hash.empty?
item = nil
loop do
name = names.shift
Expand Down
137 changes: 137 additions & 0 deletions lib/puppet-languageserver/manifest/format_on_type_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

require 'puppet-lint'

module PuppetLanguageServer
module Manifest
class FormatOnTypeProvider
class << self
def instance
@instance ||= new
end
end

def format(content, line, char, trigger_character, formatting_options)
result = []
# Abort if the user has pressed something other than `>`
return result unless trigger_character == '>'
# Abort if the formatting is tab based. Can't do that yet
return result unless formatting_options['insertSpaces'] == true
# Abort if content is too big
return result if content.length > 4096

lexer = PuppetLint::Lexer.new
tokens = lexer.tokenise(content)

# Find where in the manifest the cursor is
cursor_token = find_token_by_location(tokens, line, char)
return result if cursor_token.nil?
# The cursor should be at the end of a hashrocket, otherwise exit
return result unless cursor_token.type == :FARROW

# Find the start of the hash with respect to the cursor
start_brace = cursor_token.prev_token_of(:LBRACE, skip_blocks: true)
# Find the end of the hash with respect to the cursor
end_brace = cursor_token.next_token_of(:RBRACE, skip_blocks: true)

# The line count between the start and end brace needs to be at least 2 lines. Otherwise there's nothing to align to
return result if end_brace.nil? || start_brace.nil? || end_brace.line - start_brace.line <= 2

# Find all hashrockets '=>' between the hash braces, ignoring nested hashes
farrows = []
farrow_token = start_brace
lines = []
loop do
farrow_token = farrow_token.next_token_of(:FARROW, skip_blocks: true)
# if there are no more hashrockets, or we've gone past the end_brace, we can exit the loop
break if farrow_token.nil? || farrow_token.line > end_brace.line
# if there's a hashrocket AFTER the closing brace (why?) then we can also exit the loop
break if farrow_token.line == end_brace.line && farrow_token.character > end_brace.character
# Check for multiple hashrockets on the same line. If we find some, then we can't do any automated indentation
return result if lines.include?(farrow_token.line)
lines << farrow_token.line
farrows << { token: farrow_token }
end

# Now we have a list of farrows, time for figure out the indentation marks
farrows.each do |item|
item.merge!(calculate_indentation_info(item[:token]))
end

# Now we have the list of indentations we can find the biggest
max_indent = -1
farrows.each do |info|
max_indent = info[:indent] if info[:indent] > max_indent
end
# No valid indentations found
return result if max_indent == -1

# Now we have the indent size, generate all of the required TextEdits
farrows.each do |info|
# Ignore invalid hashrockets
next if info[:indent] == -1
end_name_token = info[:name_token].column + info[:name_token].to_manifest.length
begin_farrow_token = info[:token].column
new_whitespace = max_indent - end_name_token
# If the whitespace is already what we want, then ignore it.
next if begin_farrow_token - end_name_token == new_whitespace

# Create the TextEdit
result << LSP::TextEdit.new.from_h!(
'newText' => ' ' * new_whitespace,
'range' => LSP.create_range(info[:token].line - 1, end_name_token - 1, info[:token].line - 1, begin_farrow_token - 1)
)
end
result
end

private

VALID_TOKEN_TYPES = %i[NAME STRING SSTRING].freeze

def find_token_by_location(tokens, line, character)
return nil if tokens.empty?
# Puppet Lint uses base 1, but LSP is base 0, so adjust accordingly
cursor_line = line + 1
cursor_column = character + 1
idx = -1
while idx < tokens.count
idx += 1
# if the token is on previous lines keep looking...
next if tokens[idx].line < cursor_line
# return nil if we skipped over the line we need
return nil if tokens[idx].line > cursor_line
# return nil if we skipped over the character position we need
return nil if tokens[idx].column > cursor_column
# return the token if it starts on the cursor column we are interested in
return tokens[idx] if tokens[idx].column == cursor_column
end_column = tokens[idx].column + tokens[idx].to_manifest.length
# return the token it the cursor column is within the token string
return tokens[idx] if cursor_column <= end_column
# otherwise, keep on searching
end
nil
end

def calculate_indentation_info(farrow_token)
result = { indent: -1 }
# This is not a valid hashrocket if there's no previous tokens
return result if farrow_token.prev_token.nil?
if VALID_TOKEN_TYPES.include?(farrow_token.prev_token.type)
# Someone forgot the whitespace! e.g. ensure=>
result[:indent] = farrow_token.column + 1
result[:name_token] = farrow_token.prev_token
return result
end
if farrow_token.prev_token.type == :WHITESPACE
# If the whitespace has no previous token (which shouldn't happen) or the thing before the whitespace is not a property name this it not a valid hashrocket
return result if farrow_token.prev_token.prev_token.nil?
return result unless VALID_TOKEN_TYPES.include?(farrow_token.prev_token.prev_token.type)
result[:name_token] = farrow_token.prev_token.prev_token
result[:indent] = farrow_token.prev_token.column + 1 # The indent is the whitespace column + 1
end
result
end
end
end
end
Loading