diff --git a/lib/puppet-languageserver/manifest/document_symbol_provider.rb b/lib/puppet-languageserver/manifest/document_symbol_provider.rb index 7dffc87d..9060ffa5 100644 --- a/lib/puppet-languageserver/manifest/document_symbol_provider.rb +++ b/lib/puppet-languageserver/manifest/document_symbol_provider.rb @@ -16,7 +16,7 @@ def self.workspace_symbols(query, object_cache) 'kind' => LSP::SymbolKind::METHOD, 'location' => { 'uri' => PuppetLanguageServer::UriHelper.build_file_uri(item.source), - # Don't have char pos for functions so just pick extreme values + # Don't have char pos for types so just pick extreme values 'range' => LSP.create_range(item.line, 0, item.line, 1024) } ) @@ -38,11 +38,25 @@ def self.workspace_symbols(query, object_cache) 'kind' => LSP::SymbolKind::CLASS, 'location' => { 'uri' => PuppetLanguageServer::UriHelper.build_file_uri(item.source), - # Don't have char pos for functions so just pick extreme values + # Don't have char pos for classes so just pick extreme values 'range' => LSP.create_range(item.line, 0, item.line, 1024) } ) + when PuppetLanguageServer::Sidecar::Protocol::PuppetDataType + result << LSP::SymbolInformation.new( + 'name' => key_string, + 'kind' => LSP::SymbolKind::NAMESPACE, + 'location' => { + 'uri' => PuppetLanguageServer::UriHelper.build_file_uri(item.source), + # Don't have char pos for data types so just pick extreme values + 'range' => LSP.create_range(item.line, 0, item.line, 1024) + } + ) + + when PuppetLanguageServer::Sidecar::Protocol::Fact + # Do nothing + else PuppetLanguageServer.log_message(:warn, "[Manifest::DocumentSymbolProvider] Unknown object type #{item.class}") end diff --git a/lib/puppet-languageserver/message_handler.rb b/lib/puppet-languageserver/message_handler.rb index 551592be..81e8d236 100644 --- a/lib/puppet-languageserver/message_handler.rb +++ b/lib/puppet-languageserver/message_handler.rb @@ -383,7 +383,7 @@ def workspace_root_from_initialize_params(params) # We don't support multiple workspace folders yet, so just select the first one return UriHelper.uri_path(params['workspaceFolders'][0]['uri']) end - return UriHelper.uri_path(params['rootUri']) if params.key?('rootUri') + return UriHelper.uri_path(params['rootUri']) if params.key?('rootUri') && !params['rootUri'].nil? params['rootPath'] end end diff --git a/lib/puppet-languageserver/uri_helper.rb b/lib/puppet-languageserver/uri_helper.rb index ad081a18..c4f4700f 100644 --- a/lib/puppet-languageserver/uri_helper.rb +++ b/lib/puppet-languageserver/uri_helper.rb @@ -6,12 +6,15 @@ module PuppetLanguageServer module UriHelper def self.build_file_uri(path) - 'file://' + Puppet::Util.uri_encode(path.start_with?('/') ? path : '/' + path) + if path.nil? + nil + else + 'file://' + Puppet::Util.uri_encode(path.start_with?('/') ? path : '/' + path) + end end def self.uri_path(uri_string) - return nil if uri_string.nil? - Puppet::Util.uri_to_path(URI(uri_string)) + uri_string.nil? ? nil : Puppet::Util.uri_to_path(URI(uri_string)) end # Compares two URIs and returns the relative path diff --git a/lib/puppet_editor_services/protocol/json_rpc.rb b/lib/puppet_editor_services/protocol/json_rpc.rb index 74130393..d73b7569 100644 --- a/lib/puppet_editor_services/protocol/json_rpc.rb +++ b/lib/puppet_editor_services/protocol/json_rpc.rb @@ -138,6 +138,7 @@ def receive_json_message_as_string(content) def receive_json_message_as_hash(json_obj) # There's no need to convert it to an object quite yet # Need to validate that this is indeed a valid message + id = json_obj[KEY_ID] unless json_obj[KEY_JSONRPC] == JSONRPC_VERSION PuppetEditorServices.log_message(:error, 'Invalid JSON RPC version') reply_error id, CODE_INVALID_REQUEST, MSG_INVALID_REQ_JSONRPC @@ -159,7 +160,6 @@ def receive_json_message_as_hash(json_obj) end end - id = json_obj[KEY_ID] # Requests and Responses must have an ID that is either a string or integer if is_request || is_response unless id.is_a?(String) || id.is_a?(Integer) @@ -197,6 +197,10 @@ def receive_json_message_as_hash(json_obj) false end + def reply_error(id, code, message) + send_json_string ::PuppetEditorServices::Protocol::JsonRPCMessages.reply_error_by_id(id, code, message).to_json + end + # region Server-to-Client request/response methods def send_client_request(rpc_method, params) request = ::PuppetEditorServices::Protocol::JsonRPCMessages.new_request(client_request_id!, rpc_method, params) diff --git a/lib/puppet_editor_services/protocol/json_rpc_messages.rb b/lib/puppet_editor_services/protocol/json_rpc_messages.rb index 4f92d046..76ccaa52 100644 --- a/lib/puppet_editor_services/protocol/json_rpc_messages.rb +++ b/lib/puppet_editor_services/protocol/json_rpc_messages.rb @@ -169,10 +169,14 @@ def self.reply_result(request, result) end def self.reply_error(request, code, message) + reply_error_by_id(request.id, code, message) + end + + def self.reply_error_by_id(id, code, message) # Note - Strictly speaking the error should be typed object, however as this hidden behind # this method it's easier to just pass in a known hash construct ResponseMessage.new.from_h!( - 'id' => request.id, + 'id' => id, 'error' => { 'code' => code, 'message' => message diff --git a/spec/languageserver/acceptance/end_to_end_spec.rb b/spec/languageserver/acceptance/end_to_end_spec.rb index 43851a89..a9508a01 100644 --- a/spec/languageserver/acceptance/end_to_end_spec.rb +++ b/spec/languageserver/acceptance/end_to_end_spec.rb @@ -25,7 +25,7 @@ # Format range | X | - | - | # OnType Formatting | X | - | - | # Document Symbols | X | - | - | -# Workspace Symbols | - | | | +# Workspace Symbols | - | | X | describe 'End to End Testing' do before(:each) do @@ -321,10 +321,23 @@ def path_to_uri(path) @client.send_data(@client.puppetfile_getdependencies_request(@client.next_seq_id, puppetfile_uri)) expect(@client).to receive_message_with_request_id_within_timeout([@client.current_seq_id, 10]) result = @client.data_from_request_seq_id(@client.current_seq_id) - # Expect something to be returned - expect(result['result']).not_to be_nil - expect(result['result']['dependencies']).not_to be_nil - expect(result['result']['dependencies']).not_to be_empty + # Expect something to be returned + expect(result['result']).not_to be_nil + expect(result['result']['dependencies']).not_to be_nil + expect(result['result']['dependencies']).not_to be_empty + + # Workspace Symbols + @client.send_data(@client.workspace_symbols_request(@client.next_seq_id, '')) + expect(@client).to receive_message_with_request_id_within_timeout([@client.current_seq_id, 15]) + result = @client.data_from_request_seq_id(@client.current_seq_id) + # Expect something to be returned + expect(result['result']).not_to be_nil + # Should contain the default puppet user class + index = result['result'].find { |item| item['name'] == 'user' && item['kind'] == 6 } + expect(index).not_to be_nil + # Should contain a profile from the control-repo + index = result['result'].find { |item| item['name'] == 'profile::editorservices' && item['kind'] == 5 } + expect(index).not_to be_nil # Start shutdown process @client.clear_messages! diff --git a/spec/languageserver/editor_client.rb b/spec/languageserver/editor_client.rb new file mode 100644 index 00000000..44abede5 --- /dev/null +++ b/spec/languageserver/editor_client.rb @@ -0,0 +1,677 @@ +class EditorClient + attr_reader :received_messages + attr_accessor :debug + attr_accessor :client_settings + attr_accessor :document_list + + def initialize(host = nil, port = nil) + # TODO: Add connection attempt retries + @socket = TCPSocket.open(host, port) unless host.nil? || port.nil? + @buffer = [] + @received_messages = [] + @new_messages = false + @tx_seq_id = 0 + debug = false + @client_settings = default_client_settings + @document_list = {} + end + + def default_client_settings + { + 'puppet' => { + 'editorService' => { + 'enable' => true, + 'debugFilePath' => '', + 'featureFlags' => [], + 'formatOnType' => { 'enable' => false }, + 'hover' => { 'showMetadataInfo' => true }, + 'loglevel' => 'normal', + 'protocol' => 'tcp', # Not the default but that's what we use in testing + 'puppet' => { + 'confdir' => '', + 'environment' => '', + 'modulePath' => '', + 'vardir' => '', + 'version' => '', + }, + 'tcp' => { + 'address' => nil, + 'port' => nil + }, + 'timeout' => 10, + }, + 'format' => { 'enable' => true }, + 'installDirectory' => nil, + 'installType' => 'auto', + 'notification' => { + 'nodeGraph' => 'messagebox', + 'puppetResource' => 'messagebox' + }, + 'pdk' => { 'checkVersion' => true }, + 'titleBar' => { 'pdkNewModule.enable' => true }, + 'validate' => { 'resolvePuppetfiles' => true } + } + } + end + + # Have any new messages been received since data has been sent to the server + def new_messages? + @new_messages + end + + # Send data to the server + def send_data(json_string) + size = json_string.bytesize + @new_messages = false + puts "... Sent: #{json_string}" if self.debug + @socket.write("Content-Length: #{size}\r\n\r\n" + json_string) + end + + # The current sequence ID. Used when sending messages + def current_seq_id + @tx_seq_id + end + + # Return the next sequence ID + def next_seq_id + @tx_seq_id += 1 + end + + # Find the first message received that has the specfied request_sequence ID + # Used when trying to find responses to requests + def data_from_request_seq_id(request_seq_id) + received_messages.find { |item| item['id'] == request_seq_id} + end + + # Find the first message received that has the specfied notification + def data_from_notification_name(notification_name) + received_messages.find { |item| item['seq'] == nil && item['method'] == notification_name} + end + + # Drains and processes any data send from the server to the client + def read_data + output = [] + # Adapted from the PowerShell manager. Need to change it + read_from_stream(@socket, 0.5) { |s| output << s } + + # there's ultimately a bit of a race here + # read one more time after signal is received + read_from_stream(@socket, 0) { |s| output << s } + + # string has been binary up to this point, so force UTF-8 now + receive_data(output.join('').force_encoding(Encoding::UTF_8)) unless output.empty? + end + + # Closes the TCP connection + def close + @socket.close unless @socket.nil? + end + + # Is the conection closed? + def closed? + @socket.nil? ? false : @socket.closed? + end + + # Clear the received messages list + def clear_messages! + @received_messages = [] + end + + # Sends the client settings by the old legacy 'workspace/didChangeConfiguration' notification + def send_client_settings + content = ::JSON.generate({ + 'jsonrpc' => '2.0', + 'method' => 'workspace/didChangeConfiguration', + 'params' => { 'settings' => client_settings } + }) + send_data(content) + end + + def document_content(file_path) + uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) + document_list[uri].nil? ? nil : document_list[uri][:content].dup + end + + # ----------------------- LSP Messages + def puppet_getversion_request(seq_id) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'puppet/getVersion', + 'params' => {} + }) + end + + def puppet_getfacts_request(seq_id) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'puppet/getFacts', + 'params' => {} + }) + end + + def puppet_getresource_request(seq_id, type_name) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'puppet/getResource', + 'params' => { 'typename' => type_name } + }) + end + + def puppet_compilenodegraph_request(seq_id, uri) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'puppet/compileNodeGraph', + 'params' => { 'external' => uri } + }) + end + + def puppetfile_getdependencies_request(seq_id, uri) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'puppetfile/getDependencies', + 'params' => { 'uri' => uri } + }) + end + + def completion_request(seq_id, uri, line, char, trigger_kind = LSP::CompletionTriggerKind::INVOKED, trigger_character = nil) + hash = { + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/completion', + 'params' => { + 'textDocument' => { + 'uri' => uri, + }, + 'position' => { + 'line' => line, + 'character' => char, + }, + 'context' => { 'triggerKind' => trigger_kind } + } + } + hash['params']['context']['triggerCharacter'] = trigger_character unless trigger_character.nil? || trigger_kind != LSP::CompletionTriggerKind::TRIGGERCHARACTER + ::JSON.generate(hash) + end + + def completion_resolve_request(seq_id, item) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'completionItem/resolve', + 'params' => item + }) + end + + def did_change_notification(file_path, content) + uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) + raise "Document not yet opened #{file_path}" if document_list[uri].nil? + document_list[uri][:content] = content + document_list[uri][:version] += 1 + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'method' => 'textDocument/didChange', + 'params' => { + 'textDocument' => { + 'uri' => uri, + 'version' => document_list[uri][:version], + }, + 'contentChanges' => [{ 'text' => document_list[uri][:content] }] # Only use full document syncs + } + }) + end + + def did_open_notification(file_path, version) + uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) + document_list[uri] = { + :content => File.open(file_path, 'rb:UTF-8') { |f| f.read }, + :version => version, + :lang => 'puppet', + } + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'method' => 'textDocument/didOpen', + 'params' => { + 'textDocument' => { + 'uri' => uri, + 'languageId' => document_list[uri][:lang], + 'version' => document_list[uri][:version], + 'text' => document_list[uri][:content] + } + } + }) + end + + def document_symbols_request(seq_id, uri) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/documentSymbol', + 'params' => { + 'textDocument' => { + 'uri' => uri, + }, + } + }) + end + + def exit_notification + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'method' => 'exit' + }) + end + + def formatting_request(seq_id, uri) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/formatting', + 'params' => { + 'textDocument' => { 'uri' => uri }, + 'options' => { 'tabSize' => 2, 'insertSpaces' => true } + } + }) + end + + def hover_request(seq_id, uri, line, char) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/hover', + 'params' => { + 'textDocument' => { + 'uri' => uri, + }, + 'position' => { + 'line' => line, + 'character' => char, + } + } + }) + end + + def initialized_notification + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'method' => 'initialized', + 'params' => {} + }) + end + + def initialize_request(seq_id, workspace_path) + # TODO: RootPath/RootUri + # Based off of a VSCode 1.40.2 startup + # - Dynamic registration is turned off as it's too hard to mimic that. + # - Using the deprecated 'rootPath' and does not set a workspaceFolders entry + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'initialize', + 'params' => { + 'processId' => 26840, + 'rootPath' => workspace_path, + 'rootUri' => nil, + 'capabilities' => { + 'workspace' => { + 'applyEdit' => true, + 'workspaceEdit' => { + 'documentChanges' => true, + 'resourceOperations' => ['create', 'rename', 'delete'], + 'failureHandling' => 'textOnlyTransactional' + }, + 'didChangeConfiguration' => { + 'dynamicRegistration' => false + }, + 'didChangeWatchedFiles' => { + 'dynamicRegistration' => false + }, + 'symbol' => { + 'dynamicRegistration' => false, + 'symbolKind' => { + 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + } + }, + 'executeCommand' => { + 'dynamicRegistration' => false + }, + 'configuration' => true, + 'workspaceFolders' => true + }, + 'textDocument' => { + 'publishDiagnostics' => { + 'relatedInformation' => true + }, + 'synchronization' => { + 'dynamicRegistration' => false, + 'willSave' => true, + 'willSaveWaitUntil' => true, + 'didSave' => true + }, + 'completion' => { + 'dynamicRegistration' => false, + 'contextSupport' => true, + 'completionItem' => { + 'snippetSupport' => true, + 'commitCharactersSupport' => true, + 'documentationFormat' => ['markdown', 'plaintext'], + 'deprecatedSupport' => true, + 'preselectSupport' => true + }, + 'completionItemKind' => { + 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + } + }, + 'hover' => { + 'dynamicRegistration' => false, + 'contentFormat' => ['markdown', 'plaintext'] + }, + 'signatureHelp' => { + 'dynamicRegistration' => false, + 'signatureInformation' => { + 'documentationFormat' => ['markdown', 'plaintext'], + 'parameterInformation' => { + 'labelOffsetSupport' => true + } + } + }, + 'definition' => { + 'dynamicRegistration' => false, + 'linkSupport' => true + }, + 'references' => { + 'dynamicRegistration' => false + }, + 'documentHighlight' => { + 'dynamicRegistration' => false + }, + 'documentSymbol' => { + 'dynamicRegistration' => false, + 'symbolKind' => { + 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + 'hierarchicalDocumentSymbolSupport' => true + }, + 'codeAction' => { + 'dynamicRegistration' => false, + 'codeActionLiteralSupport' => { + 'codeActionKind' => { + 'valueSet' => ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports'] + } + } + }, + 'codeLens' => { + 'dynamicRegistration' => false + }, + 'formatting' => { + 'dynamicRegistration' => false + }, + 'rangeFormatting' => { + 'dynamicRegistration' => false + }, + 'onTypeFormatting' => { + 'dynamicRegistration' => false + }, + 'rename' => { + 'dynamicRegistration' => false, + 'prepareSupport' => true + }, + 'documentLink' => { + 'dynamicRegistration' => false + }, + 'typeDefinition' => { + 'dynamicRegistration' => false, + 'linkSupport' => true + }, + 'implementation' => { + 'dynamicRegistration' => false, + 'linkSupport' => true + }, + 'colorProvider' => { + 'dynamicRegistration' => false + }, + 'foldingRange' => { + 'dynamicRegistration' => false, + 'rangeLimit' => 5000, + 'lineFoldingOnly' => true + }, + 'declaration' => { + 'dynamicRegistration' => false, + 'linkSupport' => true + } + } + }, + 'trace' => 'off' + } + }) + end + + def ontype_format_request(seq_id, uri, line, char, character) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/onTypeFormatting', + 'params' => { + 'textDocument' => { + 'uri' => uri, + }, + 'position' => { + 'line' => line, + 'character' => char, + }, + 'ch' => character, + 'options' => { 'tabSize' => 2, 'insertSpaces' => true } + } + }) + end + + def range_formatting_request(seq_id, uri, from_line, from_char, to_line, to_char) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/rangeFormatting', + 'params' => { + 'textDocument' => { 'uri' => uri }, + 'range' => { + 'start' => { + 'line' => from_line, + 'character' => from_char + }, + 'end' => { + 'line' => to_line, + 'character' => to_char + } + }, + 'options' => { 'tabSize' => 2, 'insertSpaces' => true } + } + }) + end + + def shutdown_request(seq_id) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'shutdown', + }) + end + + def signture_help_request(seq_id, uri, line, char) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'textDocument/signatureHelp', + 'params' => { + 'textDocument' => { + 'uri' => uri, + }, + 'position' => { + 'line' => line, + 'character' => char, + } + } + }) + end + + def workspace_symbols_request(seq_id, query) + ::JSON.generate({ + 'jsonrpc' => '2.0', + 'id' => seq_id, + 'method' => 'workspace/symbol', + 'params' => { + 'query' => query, + } + }) + end + + # Synchronously wait the language server to finish loading the default information + def wait_for_puppet_loading(timeout = 5) + exit_by = Time.now + timeout + while exit_by > Time.now + seq_id = next_seq_id + send_data(puppet_getversion_request(seq_id)) + puts "... Waiting for puppet/getVersion response with everything loaded (timeout #{(exit_by - Time.now).truncate}s)" if self.debug + return false unless wait_for_message_with_request_id(seq_id, 5) + data = data_from_request_seq_id(seq_id) + + return true if data['result']['factsLoaded'] == true && + data['result']['functionsLoaded'] == true && + data['result']['typesLoaded'] == true && + data['result']['classesLoaded'] == true + + sleep(5) + end + false + end + + # Synchronously wait for a message with a specific request_id to appear + def wait_for_message_with_request_id(request_seq_id, timeout = 5) + exit_timeout = timeout + while exit_timeout > 0 do + puts "... Waiting for message with request id #{request_seq_id} (timeout #{exit_timeout}s)" if self.debug + raise 'Client has been closed' if self.closed? + self.read_data + if self.new_messages? + data = self.data_from_request_seq_id(request_seq_id) + return true unless data.nil? + end + sleep(1) + exit_timeout -= 1 + end + false + end + + # Synchronously wait for a message with a specific notification to appear + def wait_for_message_with_notification(notification, timeout = 5) + exit_timeout = timeout + while exit_timeout > 0 do + puts "... Waiting for message with notification '#{notification}' (timeout #{exit_timeout}s)" if self.debug + raise 'Client has been closed' if self.closed? + self.read_data + if self.new_messages? + data = self.data_from_notification_name(notification) + return true unless data.nil? + end + sleep(1) + exit_timeout -= 1 + end + false + end + + # Synchronously wait for the socket to be closed + def wait_close_within_timeout(timeout = 5) + exit_timeout = timeout + while exit_timeout > 0 do + puts "... Waiting for socket to close (timeout #{exit_timeout}s)" if self.debug + return true unless is_stream_valid?(@socket) + return true unless is_readable?(@socket) + sleep(1) + exit_timeout -= 1 + end + + false + end + + private + + def parse_data(data) + puts "... Received: #{data}" if self.debug + @received_messages << JSON.parse(data) + @new_messages = true + end + + def extract_headers(raw_header) + header = {} + raw_header.split("\r\n").each do |item| + name, value = item.split(':', 2) + + if name.casecmp('Content-Length').zero? + header['Content-Length'] = value.strip.to_i + elsif name.casecmp('Content-Type').zero? + header['Content-Length'] = value.strip + else + raise("Unknown header #{name} in Language Server message") + end + end + header + end + + def receive_data(data) + return if data.empty? + return if @state == :ignore + + @buffer += data.bytes + + while @buffer.length > 4 + # Check if we have enough data for the headers + # Need to find the first instance of '\r\n\r\n' + offset = 0 + while offset < @buffer.length - 4 + break if @buffer[offset] == 13 && @buffer[offset + 1] == 10 && @buffer[offset + 2] == 13 && @buffer[offset + 3] == 10 + offset += 1 + end + return unless offset < @buffer.length - 4 + + # Extract the headers + raw_header = @buffer.slice(0, offset).pack('C*').force_encoding('ASCII') # Note the headers are always ASCII encoded + headers = extract_headers(raw_header) + raise('Missing Content-Length header') if headers['Content-Length'].nil? + + # Now we have the headers and the content length, do we have enough data now + minimum_buf_length = offset + 3 + headers['Content-Length'] + 1 # Need to add one as we're converting from offset (zero based) to length (1 based) arrays + return if @buffer.length < minimum_buf_length + + # Extract the message content + content = @buffer.slice(offset + 3 + 1, headers['Content-Length']).pack('C*').force_encoding('utf-8') # TODO: default is utf-8. Need to enode based on Content-Type + # Purge the buffer + @buffer = @buffer.slice(minimum_buf_length, @buffer.length - minimum_buf_length) + @buffer = [] if @buffer.nil? + + parse_data(content) + end + end + + def is_stream_valid?(stream) + !stream.closed? && !stream.stat.nil? + rescue # Ignore an errors + false + end + + def is_readable?(stream, timeout = 0.5) + raise Errno::EPIPE if !is_stream_valid?(stream) + read_ready = IO.select([stream], [], [], timeout) + read_ready && stream == read_ready[0][0] && !stream.eof? + end + + def read_from_stream(stream, timeout = 0.1, &block) + if is_readable?(stream, timeout) + data = stream.readpartial(4096) + yield data unless data.nil? + end + + nil + end +end diff --git a/spec/languageserver/fixtures/control_repos/valid/site/profile/.gitkeep b/spec/languageserver/fixtures/control_repos/valid/site/profile/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/spec/languageserver/fixtures/control_repos/valid/site/profile/manifests/editorservices.pp b/spec/languageserver/fixtures/control_repos/valid/site/profile/manifests/editorservices.pp new file mode 100644 index 00000000..50b0d6c5 --- /dev/null +++ b/spec/languageserver/fixtures/control_repos/valid/site/profile/manifests/editorservices.pp @@ -0,0 +1,3 @@ +class profile::editorservices { + # Empty class +} diff --git a/spec/languageserver/spec_editor_client.rb b/spec/languageserver/spec_editor_client.rb index 0fcc66c3..f2cd0fdb 100644 --- a/spec/languageserver/spec_editor_client.rb +++ b/spec/languageserver/spec_editor_client.rb @@ -7,7 +7,7 @@ require 'socket' require 'json' require 'puppet-languageserver/uri_helper' - +require_relative 'editor_client' # Custom RSpec Matchers RSpec::Matchers.define :receive_message_with_request_id_within_timeout do |request_seq_id, timeout = 5| @@ -46,670 +46,3 @@ message end end - -class EditorClient - attr_reader :received_messages - attr_accessor :debug - attr_accessor :client_settings - attr_accessor :document_list - - def initialize(host = nil, port = nil) - # TODO: Add connection attempt retries - @socket = TCPSocket.open(host, port) unless host.nil? || port.nil? - @buffer = [] - @received_messages = [] - @new_messages = false - @tx_seq_id = 0 - debug = false - @client_settings = default_client_settings - @document_list = {} - end - - def default_client_settings - { - 'puppet' => { - 'editorService' => { - 'enable' => true, - 'debugFilePath' => '', - 'featureFlags' => [], - 'formatOnType' => { 'enable' => false }, - 'hover' => { 'showMetadataInfo' => true }, - 'loglevel' => 'normal', - 'protocol' => 'tcp', # Not the default but that's what we use in testing - 'puppet' => { - 'confdir' => '', - 'environment' => '', - 'modulePath' => '', - 'vardir' => '', - 'version' => '', - }, - 'tcp' => { - 'address' => nil, - 'port' => nil - }, - 'timeout' => 10, - }, - 'format' => { 'enable' => true }, - 'installDirectory' => nil, - 'installType' => 'auto', - 'notification' => { - 'nodeGraph' => 'messagebox', - 'puppetResource' => 'messagebox' - }, - 'pdk' => { 'checkVersion' => true }, - 'titleBar' => { 'pdkNewModule.enable' => true }, - 'validate' => { 'resolvePuppetfiles' => true } - } - } - end - - # Have any new messages been received since data has been sent to the server - def new_messages? - @new_messages - end - - # Send data to the server - def send_data(json_string) - size = json_string.bytesize - @new_messages = false - puts "... Sent: #{json_string}" if self.debug - @socket.write("Content-Length: #{size}\r\n\r\n" + json_string) - end - - # The current sequence ID. Used when sending messages - def current_seq_id - @tx_seq_id - end - - # Return the next sequence ID - def next_seq_id - @tx_seq_id += 1 - end - - # Find the first message received that has the specfied request_sequence ID - # Used when trying to find responses to requests - def data_from_request_seq_id(request_seq_id) - received_messages.find { |item| item['id'] == request_seq_id} - end - - # Find the first message received that has the specfied notification - def data_from_notification_name(notification_name) - received_messages.find { |item| item['seq'] == nil && item['method'] == notification_name} - end - - # Drains and processes any data send from the server to the client - def read_data - output = [] - # Adapted from the PowerShell manager. Need to change it - read_from_stream(@socket, 0.5) { |s| output << s } - - # there's ultimately a bit of a race here - # read one more time after signal is received - read_from_stream(@socket, 0) { |s| output << s } - - # string has been binary up to this point, so force UTF-8 now - receive_data(output.join('').force_encoding(Encoding::UTF_8)) unless output.empty? - end - - # Closes the TCP connection - def close - @socket.close - end - - # Is the conection closed? - def closed? - @socket.closed? - end - - # Clear the received messages list - def clear_messages! - @received_messages = [] - end - - # Sends the client settings by the old legacy 'workspace/didChangeConfiguration' notification - def send_client_settings - content = ::JSON.generate({ - 'jsonrpc' => '2.0', - 'method' => 'workspace/didChangeConfiguration', - 'params' => { 'settings' => client_settings } - }) - send_data(content) - end - - def document_content(file_path) - uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) - document_list[uri].nil? ? nil : document_list[uri][:content].dup - end - - # ----------------------- LSP Messages - def puppet_getversion_request(seq_id) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'puppet/getVersion', - 'params' => {} - }) - end - - def puppet_getfacts_request(seq_id) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'puppet/getFacts', - 'params' => {} - }) - end - - def puppet_getresource_request(seq_id, type_name) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'puppet/getResource', - 'params' => { 'typename' => type_name } - }) - end - - def puppet_compilenodegraph_request(seq_id, uri) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'puppet/compileNodeGraph', - 'params' => { 'external' => uri } - }) - end - - def puppetfile_getdependencies_request(seq_id, uri) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'puppetfile/getDependencies', - 'params' => { 'uri' => uri } - }) - end - - def completion_request(seq_id, uri, line, char, trigger_kind = LSP::CompletionTriggerKind::INVOKED, trigger_character = nil) - hash = { - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/completion', - 'params' => { - 'textDocument' => { - 'uri' => uri, - }, - 'position' => { - 'line' => line, - 'character' => char, - }, - 'context' => { 'triggerKind' => trigger_kind } - } - } - hash['params']['context']['triggerCharacter'] = trigger_character unless trigger_character.nil? || trigger_kind != LSP::CompletionTriggerKind::TRIGGERCHARACTER - ::JSON.generate(hash) - end - - def completion_resolve_request(seq_id, item) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'completionItem/resolve', - 'params' => item - }) - end - - def did_change_notification(file_path, content) - uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) - raise "Document not yet opened #{file_path}" if document_list[uri].nil? - document_list[uri][:content] = content - document_list[uri][:version] += 1 - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'method' => 'textDocument/didChange', - 'params' => { - 'textDocument' => { - 'uri' => uri, - 'version' => document_list[uri][:version], - }, - 'contentChanges' => [{ 'text' => document_list[uri][:content] }] # Only use full document syncs - } - }) - end - - def did_open_notification(file_path, version) - uri = PuppetLanguageServer::UriHelper.build_file_uri(file_path) - document_list[uri] = { - :content => File.open(file_path, 'rb:UTF-8') { |f| f.read }, - :version => version, - :lang => 'puppet', - } - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'method' => 'textDocument/didOpen', - 'params' => { - 'textDocument' => { - 'uri' => uri, - 'languageId' => document_list[uri][:lang], - 'version' => document_list[uri][:version], - 'text' => document_list[uri][:content] - } - } - }) - end - - def document_symbols_request(seq_id, uri) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/documentSymbol', - 'params' => { - 'textDocument' => { - 'uri' => uri, - }, - } - }) - end - - def exit_notification - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'method' => 'exit' - }) - end - - def formatting_request(seq_id, uri) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/formatting', - 'params' => { - 'textDocument' => { 'uri' => uri }, - 'options' => { 'tabSize' => 2, 'insertSpaces' => true } - } - }) - end - - def hover_request(seq_id, uri, line, char) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/hover', - 'params' => { - 'textDocument' => { - 'uri' => uri, - }, - 'position' => { - 'line' => line, - 'character' => char, - } - } - }) - end - - def initialized_notification - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'method' => 'initialized', - 'params' => {} - }) - end - - def initialize_request(seq_id, workspace_path) - # TODO: RootPath/RootUri - # Based off of a VSCode 1.40.2 startup - # Dynamic registration is turned off as it's too hard to mimic that. - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'initialize', - 'params' => { - 'processId' => 26840, - 'rootPath' => workspace_path, - 'rootUri' => nil, - 'capabilities' => { - 'workspace' => { - 'applyEdit' => true, - 'workspaceEdit' => { - 'documentChanges' => true, - 'resourceOperations' => ['create', 'rename', 'delete'], - 'failureHandling' => 'textOnlyTransactional' - }, - 'didChangeConfiguration' => { - 'dynamicRegistration' => false - }, - 'didChangeWatchedFiles' => { - 'dynamicRegistration' => false - }, - 'symbol' => { - 'dynamicRegistration' => false, - 'symbolKind' => { - 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] - } - }, - 'executeCommand' => { - 'dynamicRegistration' => false - }, - 'configuration' => true, - 'workspaceFolders' => true - }, - 'textDocument' => { - 'publishDiagnostics' => { - 'relatedInformation' => true - }, - 'synchronization' => { - 'dynamicRegistration' => false, - 'willSave' => true, - 'willSaveWaitUntil' => true, - 'didSave' => true - }, - 'completion' => { - 'dynamicRegistration' => false, - 'contextSupport' => true, - 'completionItem' => { - 'snippetSupport' => true, - 'commitCharactersSupport' => true, - 'documentationFormat' => ['markdown', 'plaintext'], - 'deprecatedSupport' => true, - 'preselectSupport' => true - }, - 'completionItemKind' => { - 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] - } - }, - 'hover' => { - 'dynamicRegistration' => false, - 'contentFormat' => ['markdown', 'plaintext'] - }, - 'signatureHelp' => { - 'dynamicRegistration' => false, - 'signatureInformation' => { - 'documentationFormat' => ['markdown', 'plaintext'], - 'parameterInformation' => { - 'labelOffsetSupport' => true - } - } - }, - 'definition' => { - 'dynamicRegistration' => false, - 'linkSupport' => true - }, - 'references' => { - 'dynamicRegistration' => false - }, - 'documentHighlight' => { - 'dynamicRegistration' => false - }, - 'documentSymbol' => { - 'dynamicRegistration' => false, - 'symbolKind' => { - 'valueSet' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] - }, - 'hierarchicalDocumentSymbolSupport' => true - }, - 'codeAction' => { - 'dynamicRegistration' => false, - 'codeActionLiteralSupport' => { - 'codeActionKind' => { - 'valueSet' => ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports'] - } - } - }, - 'codeLens' => { - 'dynamicRegistration' => false - }, - 'formatting' => { - 'dynamicRegistration' => false - }, - 'rangeFormatting' => { - 'dynamicRegistration' => false - }, - 'onTypeFormatting' => { - 'dynamicRegistration' => false - }, - 'rename' => { - 'dynamicRegistration' => false, - 'prepareSupport' => true - }, - 'documentLink' => { - 'dynamicRegistration' => false - }, - 'typeDefinition' => { - 'dynamicRegistration' => false, - 'linkSupport' => true - }, - 'implementation' => { - 'dynamicRegistration' => false, - 'linkSupport' => true - }, - 'colorProvider' => { - 'dynamicRegistration' => false - }, - 'foldingRange' => { - 'dynamicRegistration' => false, - 'rangeLimit' => 5000, - 'lineFoldingOnly' => true - }, - 'declaration' => { - 'dynamicRegistration' => false, - 'linkSupport' => true - } - } - }, - 'trace' => 'off', - 'workspaceFolders' => nil - } - }) - end - - def ontype_format_request(seq_id, uri, line, char, character) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/onTypeFormatting', - 'params' => { - 'textDocument' => { - 'uri' => uri, - }, - 'position' => { - 'line' => line, - 'character' => char, - }, - 'ch' => character, - 'options' => { 'tabSize' => 2, 'insertSpaces' => true } - } - }) - end - - def range_formatting_request(seq_id, uri, from_line, from_char, to_line, to_char) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/rangeFormatting', - 'params' => { - 'textDocument' => { 'uri' => uri }, - 'range' => { - 'start' => { - 'line' => from_line, - 'character' => from_char - }, - 'end' => { - 'line' => to_line, - 'character' => to_char - } - }, - 'options' => { 'tabSize' => 2, 'insertSpaces' => true } - } - }) - end - - def shutdown_request(seq_id) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'shutdown', - }) - end - - def signture_help_request(seq_id, uri, line, char) - ::JSON.generate({ - 'jsonrpc' => '2.0', - 'id' => seq_id, - 'method' => 'textDocument/signatureHelp', - 'params' => { - 'textDocument' => { - 'uri' => uri, - }, - 'position' => { - 'line' => line, - 'character' => char, - } - } - }) - end - - # Synchronously wait the language server to finish loading the default information - def wait_for_puppet_loading(timeout = 5) - exit_by = Time.now + timeout - while exit_by > Time.now - seq_id = next_seq_id - send_data(puppet_getversion_request(seq_id)) - puts "... Waiting for puppet/getVersion response with everything loaded (timeout #{(exit_by - Time.now).truncate}s)" if self.debug - return false unless wait_for_message_with_request_id(seq_id, 5) - data = data_from_request_seq_id(seq_id) - - return true if data['result']['factsLoaded'] == true && - data['result']['functionsLoaded'] == true && - data['result']['typesLoaded'] == true && - data['result']['classesLoaded'] == true - - sleep(5) - end - false - end - - # Synchronously wait for a message with a specific request_id to appear - def wait_for_message_with_request_id(request_seq_id, timeout = 5) - exit_timeout = timeout - while exit_timeout > 0 do - puts "... Waiting for message with request id #{request_seq_id} (timeout #{exit_timeout}s)" if self.debug - raise 'Client has been closed' if self.closed? - self.read_data - if self.new_messages? - data = self.data_from_request_seq_id(request_seq_id) - return true unless data.nil? - end - sleep(1) - exit_timeout -= 1 - end - false - end - - # Synchronously wait for a message with a specific notification to appear - def wait_for_message_with_notification(notification, timeout = 5) - exit_timeout = timeout - while exit_timeout > 0 do - puts "... Waiting for message with notification '#{notification}' (timeout #{exit_timeout}s)" if self.debug - raise 'Client has been closed' if self.closed? - self.read_data - if self.new_messages? - data = self.data_from_notification_name(notification) - return true unless data.nil? - end - sleep(1) - exit_timeout -= 1 - end - false - end - - # Synchronously wait for the socket to be closed - def wait_close_within_timeout(timeout = 5) - exit_timeout = timeout - while exit_timeout > 0 do - puts "... Waiting for socket to close (timeout #{exit_timeout}s)" if self.debug - return true unless is_stream_valid?(@socket) - return true unless is_readable?(@socket) - sleep(1) - exit_timeout -= 1 - end - - false - end - - private - - def parse_data(data) - puts "... Received: #{data}" if self.debug - @received_messages << JSON.parse(data) - @new_messages = true - end - - def extract_headers(raw_header) - header = {} - raw_header.split("\r\n").each do |item| - name, value = item.split(':', 2) - - if name.casecmp('Content-Length').zero? - header['Content-Length'] = value.strip.to_i - elsif name.casecmp('Content-Type').zero? - header['Content-Length'] = value.strip - else - raise("Unknown header #{name} in Language Server message") - end - end - header - end - - def receive_data(data) - return if data.empty? - return if @state == :ignore - - @buffer += data.bytes - - while @buffer.length > 4 - # Check if we have enough data for the headers - # Need to find the first instance of '\r\n\r\n' - offset = 0 - while offset < @buffer.length - 4 - break if @buffer[offset] == 13 && @buffer[offset + 1] == 10 && @buffer[offset + 2] == 13 && @buffer[offset + 3] == 10 - offset += 1 - end - return unless offset < @buffer.length - 4 - - # Extract the headers - raw_header = @buffer.slice(0, offset).pack('C*').force_encoding('ASCII') # Note the headers are always ASCII encoded - headers = extract_headers(raw_header) - raise('Missing Content-Length header') if headers['Content-Length'].nil? - - # Now we have the headers and the content length, do we have enough data now - minimum_buf_length = offset + 3 + headers['Content-Length'] + 1 # Need to add one as we're converting from offset (zero based) to length (1 based) arrays - return if @buffer.length < minimum_buf_length - - # Extract the message content - content = @buffer.slice(offset + 3 + 1, headers['Content-Length']).pack('C*').force_encoding('utf-8') # TODO: default is utf-8. Need to enode based on Content-Type - # Purge the buffer - @buffer = @buffer.slice(minimum_buf_length, @buffer.length - minimum_buf_length) - @buffer = [] if @buffer.nil? - - parse_data(content) - end - end - - def is_stream_valid?(stream) - !stream.closed? && !stream.stat.nil? - rescue # Ignore an errors - false - end - - def is_readable?(stream, timeout = 0.5) - raise Errno::EPIPE if !is_stream_valid?(stream) - read_ready = IO.select([stream], [], [], timeout) - read_ready && stream == read_ready[0][0] && !stream.eof? - end - - def read_from_stream(stream, timeout = 0.1, &block) - if is_readable?(stream, timeout) - data = stream.readpartial(4096) - yield data unless data.nil? - end - - nil - end -end diff --git a/spec/languageserver/spec_helper.rb b/spec/languageserver/spec_helper.rb index 4086beba..a52fdf9e 100644 --- a/spec/languageserver/spec_helper.rb +++ b/spec/languageserver/spec_helper.rb @@ -90,8 +90,9 @@ def random_sidecar_puppet_class(key = nil) result end -def random_sidecar_puppet_datatype +def random_sidecar_puppet_datatype(key = nil) result = add_random_basepuppetobject_values!(PuppetLanguageServer::Sidecar::Protocol::PuppetDataType.new()) + result.key = key unless key.nil? result.doc = 'doc' + rand(1000).to_s result.alias_of = "String[1, #{rand(255)}]" result.attributes << random_sidecar_puppet_datatype_attribute diff --git a/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb b/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb index cfa0bfca..9e1fcf37 100644 --- a/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb +++ b/spec/languageserver/unit/puppet-languageserver/manifest/document_symbol_provider_spec.rb @@ -50,6 +50,8 @@ cache.import_sidecar_list!([random_sidecar_puppet_class(:class1)], :class, origin) cache.import_sidecar_list!([random_sidecar_puppet_function(:func1)], :function, origin) cache.import_sidecar_list!([random_sidecar_puppet_type(:type1)], :type, origin) + cache.import_sidecar_list!([random_sidecar_puppet_datatype(:datatype1)], :datatype, origin) + cache.import_sidecar_list!([random_sidecar_fact(:fact1)], :fact, origin) end it 'should emit all known objects for an empty query' do @@ -58,10 +60,12 @@ expect(result[0]).to be_symbol_information('class1', LSP::SymbolKind::CLASS) expect(result[1]).to be_symbol_information('func1', LSP::SymbolKind::FUNCTION) expect(result[2]).to be_symbol_information('type1', LSP::SymbolKind::METHOD) + expect(result[3]).to be_symbol_information('datatype1', LSP::SymbolKind::NAMESPACE) all_cache_names = [] cache.all_objects { |key, _| all_cache_names << key.to_s } - expect(result.count).to eq(all_cache_names.count) + # Facts are not output + expect(result.count).to eq(all_cache_names.count - 1) end it 'should only emit objects that match a simple text query' do diff --git a/spec/languageserver/unit/puppet-languageserver/uri_helper_spec.rb b/spec/languageserver/unit/puppet-languageserver/uri_helper_spec.rb index 01115c97..3d57c43c 100644 --- a/spec/languageserver/unit/puppet-languageserver/uri_helper_spec.rb +++ b/spec/languageserver/unit/puppet-languageserver/uri_helper_spec.rb @@ -16,6 +16,10 @@ test = subject.build_file_uri('/opt/foo/foo.pp') expect(test).to eq('file:///opt/foo/foo.pp') end + it 'should return nil for nil uris' do + test = subject.build_file_uri(nil) + expect(test).to be_nil + end end describe '#relative_uri_path' do