Repository: spyder-ide/spyder
Pull request: PR: LSP fixes, debugging, and minor code cleanup
<!--- Before submitting your pull request, --->
<!--- please complete as much as practicable of the following checklist: --->

### Pull Request Checklist

* [x] Read and followed this repo's [Contributing Guidelines](https://github.com/spyder-ide/spyder/blob/master/CONTRIBUTING.md)
* [x] Based this PR on the latest version of the correct branch (master or 3.x)
* [x] Followed [PEP 8](https://www.python.org/dev/peps/pep-0008/) code style
* [x] Ensured this pull request hasn't eliminated unrelated blank lines/spaces,
      modified the ``spyder/defaults`` directory, or added new icons/assets
* [x] Wrote at least one-line docstrings following PEP 257for any new functions
* [ ] Added at least one unit test covering the changes, if at all possible
* [x] Described the changes and the motivation for them below
* [x] Noted what issue(s) this pull request resolves, creating one if needed
* [ ] Included a screenshot, if this PR makes any visible changes to the UI


### Developer Certificate of Origin Affirmation

By submitting this Pull Request or typing my name below, I affirm the
[Developer Certificate of Origin](https://developercertificate.org/)
with respect to both the content of the contribution itself and this post,
and understand I am releasing it under Spyder's MIT (Expat) license.


<!--- TYPE YOUR GITHUB USERNAME AFTER THE FOLLOWING STATEMENT ---!>
I certify the above statement is true and correct: @emanspeaks 

<!--- Note that you (not Spyder) retain copyright ownership of your work, --->
<!--- and may license it to other parties under the terms of your choice. --->
<!--- Contact us before incorporating any content from external projects. --->


## Description of Changes

<!--- Explain what you've done and why. --->
* Provided some additional logging improvements to diagnose LSP problems.  
* Added more aggressive LSP syncing for editor changes
* Fixes to allow multiple language servers to run simultaneously
* Added new file extensions for modern Fortran

### Issue(s) Resolved

<!--- Pull requests should typically resolve one, and preferably only one --->
<!--- outstanding issue; create a new one if no relevant issue exists. --->
<!--- List the issue(s) below, in the form "Fixes #1234"; one per line. --->

Fixes #8172


<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Warnings and errors from PyLS don't go away after fixing them, even when saving file
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

## Problem Description

PyLS errors and warnings (e.g. from pycodestyle, pyflakes, etc) don't go away after fixing their cause; even after saving and waiting 10-20+ seconds, they only disappear when deleting the entire line. This does not occur, obviously, in Spyder 3.

### What steps reproduce the problem?

1. Write a line of code that prompts a PyLS (pycodestyle, pyflakes, etc) error or warning. Save.
2. Modify line to fix error/warning. Save.
3. Delete entire line. Save.

### What is the expected output? What do you see instead?

Expected error to go away after step 2. Instead, only disappeared after step 3.


## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: master (latest as of the post date)
* Python version: 3.6.6
* Qt version: 5.9.6
* PyQt version: 5.9.2
* Operating System name/version: Windows 8.1 x64

PyLS 0.19.0 (``pip`` install in conda env)

Hints from issue discussion:
I added the following line to `spyder.plugins.editor.widgets.editor.save` inside the try block, around line 1670: `finfo.editor.document_did_change()` 

That fixed it for me.
There is also call to `notify_save()` above that, but that call effectively does nothing because `will_save_notify` defaults to False in the `CodeEditor` constructor.  Also because it looks like PyLS isn't coded to respond to the `willSave` request.  PyLS is, however, configured to respond to `didSave`, which calls the linter.  

I'd recommend updating `notify_save` method in the CodeEditor to submit a `didSave` request instead of `willSave`.  And either remove the `if will_save_notify` wrapper  or make it default to True.

Thanks @emanspeaks for digging into this one! Could you prepare a PR with your recommendations? Thanks!
@ccordoba12 just an updated, I have been working on this.  I have a number of LSP-related updates I am trying to gather up into one PR once I get everything working.
Thanks a lot for your help!

Commit message: forgot to include lspmanager in commit

Patch to review:
diff --git a/spyder/config/utils.py b/spyder/config/utils.py
index 308b570bef0..3a5402e2831 100644
--- a/spyder/config/utils.py
+++ b/spyder/config/utils.py
@@ -27,11 +27,12 @@
     (_("C files"), ('.c', '.h')),
     (_("C++ files"), ('.cc', '.cpp', '.cxx', '.h', '.hh', '.hpp', '.hxx')),
     (_("OpenCL files"), ('.cl', )),
-    (_("Fortran files"), ('.f', '.for', '.f77', '.f90', '.f95', '.f2k')),
+    (_("Fortran files"), ('.f', '.for', '.f77', '.f90', '.f95', '.f2k',
+                          '.f03', '.f08')),
     (_("IDL files"), ('.pro', )),
     (_("MATLAB files"), ('.m', )),
     (_("Julia files"), ('.jl',)),
-    (_("Yaml files"), ('.yaml','.yml',)),
+    (_("Yaml files"), ('.yaml', '.yml',)),
     (_("Patch and diff files"), ('.patch', '.diff', '.rej')),
     (_("Batch files"), ('.bat', '.cmd')),
     (_("Text files"), ('.txt',)),
diff --git a/spyder/plugins/editor/lsp/client.py b/spyder/plugins/editor/lsp/client.py
index 582da96b160..9b93e2a246f 100644
--- a/spyder/plugins/editor/lsp/client.py
+++ b/spyder/plugins/editor/lsp/client.py
@@ -21,7 +21,7 @@
 from qtpy.QtCore import QObject, Signal, QSocketNotifier, Slot
 import zmq
 
-from spyder.py3compat import PY2, getcwd
+from spyder.py3compat import PY2
 from spyder.config.base import get_conf_path, get_debug_level
 from spyder.plugins.editor.lsp import (
     CLIENT_CAPABILITES, SERVER_CAPABILITES, TRACE,
@@ -30,6 +30,7 @@
 from spyder.plugins.editor.lsp.decorators import (
     send_request, class_register, handles)
 from spyder.plugins.editor.lsp.providers import LSPMethodProviderMixIn
+from spyder.utils.misc import getcwd_or_home
 
 if PY2:
     import pathlib2 as pathlib
@@ -60,7 +61,7 @@ class LSPClient(QObject, LSPMethodProviderMixIn):
 
     def __init__(self, parent, server_args_fmt='',
                  server_settings={}, external_server=False,
-                 folder=getcwd(), language='python',
+                 folder=getcwd_or_home(), language='python',
                  plugin_configurations={}):
         QObject.__init__(self)
         # LSPMethodProviderMixIn.__init__(self)
@@ -99,7 +100,7 @@ def __init__(self, parent, server_args_fmt='',
         self.server_args += server_args.split(' ')
         self.transport_args += transport_args.split(' ')
         self.transport_args += ['--folder', folder]
-        self.transport_args += ['--transport-debug', str(get_debug_level())]
+        self.transport_args += ['--transport-debug', get_debug_level()]
 
     def start(self):
         self.zmq_out_socket = self.context.socket(zmq.PAIR)
@@ -112,7 +113,7 @@ def start(self):
 
         self.lsp_server_log = subprocess.PIPE
         if get_debug_level() > 0:
-            lsp_server_file = 'lsp_server_logfile.log'
+            lsp_server_file = 'lsp_server_{0}.log'.format(self.language)
             log_file = get_conf_path(osp.join('lsp_logs', lsp_server_file))
             if not osp.exists(osp.dirname(log_file)):
                 os.makedirs(osp.dirname(log_file))
@@ -134,7 +135,7 @@ def start(self):
         self.stdout_log = subprocess.PIPE
         self.stderr_log = subprocess.PIPE
         if get_debug_level() > 0:
-            stderr_log_file = 'lsp_client_{0}.log'.format(self.language)
+            stderr_log_file = 'lsp_transport_{0}_err.log'.format(self.language)
             log_file = get_conf_path(osp.join('lsp_logs', stderr_log_file))
             if not osp.exists(osp.dirname(log_file)):
                 os.makedirs(osp.dirname(log_file))
@@ -143,7 +144,9 @@ def start(self):
         new_env = dict(os.environ)
         python_path = os.pathsep.join(sys.path)[1:]
         new_env['PYTHONPATH'] = python_path
-        self.transport_args = map(str, self.transport_args)
+        self.transport_args = list(map(str, self.transport_args))
+        logger.info('Starting transport: {0}'.format(
+            ' '.join(self.transport_args)))
         self.transport_client = subprocess.Popen(self.transport_args,
                                                  stdout=self.stdout_log,
                                                  stderr=self.stderr_log,
@@ -202,6 +205,12 @@ def on_msg_received(self):
                 except KeyError:
                     pass
 
+                if 'error' in resp:
+                    logger.debug(
+                        '{} response error: {}'.format(self.language,
+                                                       repr(resp['error'])
+                                                       ))
+
                 if 'method' in resp:
                     if resp['method'][0] != '$':
                         if resp['method'] in self.handler_registry:
@@ -223,7 +232,7 @@ def on_msg_received(self):
                                 self.req_status.pop(req_id)
                                 if req_id in self.req_reply:
                                     self.req_reply.pop(req_id)
-            except zmq.ZMQError as e:
+            except zmq.ZMQError:
                 self.notifier.setEnabled(True)
                 return
 
diff --git a/spyder/plugins/editor/lsp/providers/__init__.py b/spyder/plugins/editor/lsp/providers/__init__.py
index 46ed0d9f78c..656c9fe3ad5 100644
--- a/spyder/plugins/editor/lsp/providers/__init__.py
+++ b/spyder/plugins/editor/lsp/providers/__init__.py
@@ -7,7 +7,8 @@
 """Spyder Language Server Protocol Client method providers."""
 
 from .document import DocumentProvider
+from .window import WindowProvider
 
 
-class LSPMethodProviderMixIn(DocumentProvider):
+class LSPMethodProviderMixIn(DocumentProvider, WindowProvider):
     pass
diff --git a/spyder/plugins/editor/lsp/providers/document.py b/spyder/plugins/editor/lsp/providers/document.py
index 39530fd0b55..07118efa7d0 100644
--- a/spyder/plugins/editor/lsp/providers/document.py
+++ b/spyder/plugins/editor/lsp/providers/document.py
@@ -43,11 +43,14 @@ def register_file(self, filename, codeeditor):
     def process_document_diagnostics(self, response, *args):
         uri = response['uri']
         diagnostics = response['diagnostics']
-        callbacks = self.watched_files[uri]
-        for callback in callbacks:
-            callback.handle_response(
-                LSPRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS,
-                {'params': diagnostics})
+        if uri in self.watched_files:
+            callbacks = self.watched_files[uri]
+            for callback in callbacks:
+                callback.handle_response(
+                    LSPRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS,
+                    {'params': diagnostics})
+        else:
+            logger.debug("Received diagnotics for file not open: " + uri)
 
     @send_request(
         method=LSPRequestTypes.DOCUMENT_DID_CHANGE, requires_response=False)
@@ -207,6 +210,24 @@ def document_will_save_notification(self, params):
         }
         return params
 
+    @send_request(method=LSPRequestTypes.DOCUMENT_DID_SAVE,
+                  requires_response=False)
+    def document_did_save_notification(self, params):
+        """
+        Handles the textDocument/didSave message received from an LSP server
+        """
+        text = None
+        if 'text' in params:
+            text = params['text']
+        params = {
+            'textDocument': {
+                'uri': path_as_uri(params['file'])
+            }
+        }
+        if text is not None:
+            params['text'] = text
+        return params
+
     @send_request(method=LSPRequestTypes.DOCUMENT_DID_CLOSE,
                   requires_response=False)
     def document_did_close(self, params):
diff --git a/spyder/plugins/editor/lsp/providers/window.py b/spyder/plugins/editor/lsp/providers/window.py
new file mode 100644
index 00000000000..5924182c7c4
--- /dev/null
+++ b/spyder/plugins/editor/lsp/providers/window.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""Spyder Language Server Protocol Client window handler routines."""
+
+import logging
+
+from spyder.plugins.editor.lsp import LSPRequestTypes
+from spyder.plugins.editor.lsp.decorators import handles  # , send_request
+
+logger = logging.getLogger(__name__)
+
+
+class WindowProvider:
+    @handles(LSPRequestTypes.WINDOW_SHOW_MESSAGE)
+    def process_show_message(self, response, *args):
+        """Handles window/showMessage notifications from LSP server"""
+        logger.debug("Received showMessage: %r" % response)
diff --git a/spyder/plugins/editor/lsp/transport/consumer.py b/spyder/plugins/editor/lsp/transport/consumer.py
index ca0d8820cc8..4e86460dc0f 100644
--- a/spyder/plugins/editor/lsp/transport/consumer.py
+++ b/spyder/plugins/editor/lsp/transport/consumer.py
@@ -51,7 +51,6 @@ def initialize(self, sock, zmq_sock, req_status):
         self.zmq_sock = zmq_sock
         self.req_status = req_status
 
-
     def read_posix(self):
         self.expect.expect('\r\n\r\n', timeout=None)
         headers = self.expect.before
@@ -88,6 +87,7 @@ def expect_windows(self):
         content_length = int(headers[b'Content-Length'])
         pending_bytes = content_length - len(buffer)
         while pending_bytes > 0:
+            LOGGER.debug('Pending bytes...' + str(pending_bytes))
             recv = self.socket.recv(min(1024, pending_bytes))
             buffer += recv
             pending_bytes -= len(recv)
@@ -111,8 +111,8 @@ def run(self):
                     LOGGER.debug(body)
                     self.zmq_sock.send_pyobj(body)
                     LOGGER.debug('Message sent')
-            except socket.error:
-                pass
+            except socket.error as e:
+                LOGGER.error(e)
         LOGGER.debug('Thread stopped.')
 
     def parse_headers(self, headers):
diff --git a/spyder/plugins/editor/lsp/transport/main.py b/spyder/plugins/editor/lsp/transport/main.py
index 2ef48215e92..b3d1d3e9347 100644
--- a/spyder/plugins/editor/lsp/transport/main.py
+++ b/spyder/plugins/editor/lsp/transport/main.py
@@ -22,13 +22,11 @@
 from spyder.py3compat import getcwd
 from producer import LanguageServerClient
 
-
+LOGGER = logging.getLogger(__name__)
 WINDOWS = os.name == 'nt'
 
-
 parser = argparse.ArgumentParser(
     description='ZMQ Python-based MS Language-Server v3.0 client for Spyder')
-
 parser.add_argument('--zmq-in-port',
                     default=7000,
                     help="ZMQ (in) port to be contacted")
@@ -55,22 +53,23 @@
                     default=0,
                     type=int,
                     help='Verbosity level for log messages')
-
 args, unknownargs = parser.parse_known_args()
 
-LOG_FORMAT = ('%(levelname) -10s %(asctime)s %(name) -30s %(funcName) '
-              '-35s %(lineno) -5d: %(message)s')
-
 
-LEVELS = ['ERROR', 'WARNING', 'INFO', 'DEBUG']
-LEVEL = logging.getLevelName(LEVELS[args.transport_debug])
+def logger_init(level):
+    """
+    Initialize the logger for this thread.  Sets the log level to ERROR (0),
+    WARNING (1), INFO (2), or DEBUG (3), depending on the argument `level`.
+    """
+    levellist = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
+    handler = logging.StreamHandler()
+    fmt = ('%(levelname) -10s %(asctime)s %(name) -30s %(funcName) '
+           '-35s %(lineno) -5d: %(message)s')
+    handler.setFormatter(logging.Formatter(fmt))
+    logger = logging.root
+    logger.addHandler(handler)
+    logger.setLevel(levellist[level])
 
-logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
-logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
-logging.basicConfig(level=logging.ERROR, format=LOG_FORMAT)
-
-LOGGER = logging.getLogger(__name__)
-LOGGER.setLevel(LEVEL)
 
 class TerminateSignal(Exception):
     """Terminal exception descriptor."""
@@ -104,6 +103,7 @@ def restore(self):
 
 
 if __name__ == '__main__':
+    logger_init(args.transport_debug)
     process = psutil.Process()
     sig_manager = SignalManager()
     client = LanguageServerClient(host=args.server_host,
diff --git a/spyder/plugins/editor/lsp/transport/producer.py b/spyder/plugins/editor/lsp/transport/producer.py
index ba3bf20ee4e..dbb86843907 100644
--- a/spyder/plugins/editor/lsp/transport/producer.py
+++ b/spyder/plugins/editor/lsp/transport/producer.py
@@ -67,12 +67,14 @@ def __init__(self, host='127.0.0.1', port=2087, workspace=getcwd(),
 
         initial_time = time.time()
         while not connected:
+            LOGGER.info('Attempting LSP server connection {0}:{1}'.format(
+                        self.host, self.port))
             try:
                 self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                 self.socket.connect((self.host, int(self.port)))
                 connected = True
-            except Exception:
-                pass
+            except Exception as e:
+                LOGGER.info('Connection error: {}'.format(e))
 
             if time.time() - initial_time > self.MAX_TIMEOUT_TIME:
                 break
@@ -93,6 +95,7 @@ def __init__(self, host='127.0.0.1', port=2087, workspace=getcwd(),
         self.zmq_in_socket.connect("tcp://localhost:{0}".format(zmq_in_port))
         self.zmq_out_socket = self.context.socket(zmq.PAIR)
         self.zmq_out_socket.connect("tcp://localhost:{0}".format(zmq_out_port))
+        LOGGER.info('Sending server_ready...')
         self.zmq_out_socket.send_pyobj({'id': -1, 'method': 'server_ready',
                                         'params': {}})
 
@@ -102,8 +105,8 @@ def __init__(self, host='127.0.0.1', port=2087, workspace=getcwd(),
                                        self.req_status)
 
     def start(self):
-        LOGGER.info('Ready to recieve/attend requests and responses!')
         self.reading_thread.start()
+        LOGGER.info('Ready to receive/attend requests and responses!')
 
     def stop(self):
         # LOGGER.info('Sending shutdown instruction to server')
diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py
index 2b8c9768268..3cc4b78b6c0 100644
--- a/spyder/plugins/editor/plugin.py
+++ b/spyder/plugins/editor/plugin.py
@@ -119,7 +119,7 @@ def get_icon(self):
 
     def setup_page(self):
         template_btn = self.create_button(_("Edit template for new modules"),
-                                    self.plugin.edit_template)
+                                          self.plugin.edit_template)
 
         interface_group = QGroupBox(_("Interface"))
         newcb = self.create_checkbox
@@ -128,7 +128,7 @@ def setup_page(self):
                 _("Show selector for classes and functions"),
                 'show_class_func_dropdown')
         showindentguides_box = newcb(_("Show Indent Guides"),
-                                      'indent_guides')
+                                     'indent_guides')
 
         interface_layout = QVBoxLayout()
         interface_layout.addWidget(showtabbar_box)
@@ -141,7 +141,8 @@ def setup_page(self):
         blanks_box = newcb(_("Show blank spaces"), 'blank_spaces')
         edgeline_box = newcb(_("Show vertical lines after"), 'edge_line')
         edgeline_edit = self.create_lineedit("", 'edge_line_columns',
-                                             tip="Enter values separated by commas ','",
+                                             tip=("Enter values separated by "
+                                                  "commas ','"),
                                              alignment=Qt.Horizontal,
                                              regex="[0-9]+(,[0-9]+)*")
         edgeline_edit_label = QLabel(_("characters"))
@@ -155,10 +156,11 @@ def setup_page(self):
         currentcell_box = newcb(_("Highlight current cell"),
                                 'highlight_current_cell')
         occurrence_box = newcb(_("Highlight occurrences after"),
-                              'occurrence_highlighting')
+                               'occurrence_highlighting')
         occurrence_spin = self.create_spinbox("", _(" ms"),
-                                             'occurrence_highlighting/timeout',
-                                             min_=100, max_=1000000, step=100)
+                                              ('occurrence_highlighting'
+                                               '/timeout'),
+                                              min_=100, max_=1000000, step=100)
         occurrence_box.toggled.connect(occurrence_spin.spinbox.setEnabled)
         occurrence_box.toggled.connect(occurrence_spin.slabel.setEnabled)
         occurrence_spin.spinbox.setEnabled(
@@ -209,10 +211,12 @@ def setup_page(self):
                                    'codecompletion/enter_key')
             calltips_box = newcb(_("Display balloon tips"), 'calltips')
             gotodef_box = newcb(_("Link to object definition"),
-                  'go_to_definition',
-                  tip=_("If this option is enabled, clicking on an object\n"
-                        "name (left-click + Ctrl key) will go this object\n"
-                        "definition (if resolved)."))
+                                'go_to_definition',
+                                tip=_("If this option is enabled, clicking on "
+                                      "an object\n"
+                                      "name (left-click + Ctrl key) will go "
+                                      "this object\n"
+                                      "definition (if resolved)."))
         else:
             rope_label = QLabel(_("<b>Warning:</b><br>"
                                   "The Python module <i>rope</i> is not "
@@ -223,26 +227,29 @@ def setup_page(self):
 
         sourcecode_group = QGroupBox(_("Source code"))
         closepar_box = newcb(_("Automatic insertion of parentheses, braces "
-                                                               "and brackets"),
+                               "and brackets"),
                              'close_parentheses')
         close_quotes_box = newcb(_("Automatic insertion of closing quotes"),
-                             'close_quotes')
+                                 'close_quotes')
         add_colons_box = newcb(_("Automatic insertion of colons after 'for', "
-                                                          "'if', 'def', etc"),
+                                 "'if', 'def', etc"),
                                'add_colons')
         autounindent_box = newcb(_("Automatic indentation after 'else', "
                                    "'elif', etc."), 'auto_unindent')
         indent_chars_box = self.create_combobox(_("Indentation characters: "),
-                                        ((_("2 spaces"), '*  *'),
-                                         (_("3 spaces"), '*   *'),
-                                         (_("4 spaces"), '*    *'),
-                                         (_("5 spaces"), '*     *'),
-                                         (_("6 spaces"), '*      *'),
-                                         (_("7 spaces"), '*       *'),
-                                         (_("8 spaces"), '*        *'),
-                                         (_("Tabulations"), '*\t*')), 'indent_chars')
+                                                ((_("2 spaces"), '*  *'),
+                                                 (_("3 spaces"), '*   *'),
+                                                 (_("4 spaces"), '*    *'),
+                                                 (_("5 spaces"), '*     *'),
+                                                 (_("6 spaces"), '*      *'),
+                                                 (_("7 spaces"), '*       *'),
+                                                 (_("8 spaces"), '*        *'),
+                                                 (_("Tabulations"), '*\t*')),
+                                                'indent_chars')
         tabwidth_spin = self.create_spinbox(_("Tab stop width:"), _("spaces"),
-                                            'tab_stop_width_spaces', 4, 1, 8, 1)
+                                            'tab_stop_width_spaces', 4, 1, 8,
+                                            1)
+
         def enable_tabwidth_spin(index):
             if index == 7:  # Tabulations
                 tabwidth_spin.plabel.setEnabled(True)
@@ -251,20 +258,25 @@ def enable_tabwidth_spin(index):
                 tabwidth_spin.plabel.setEnabled(False)
                 tabwidth_spin.spinbox.setEnabled(False)
 
-        indent_chars_box.combobox.currentIndexChanged.connect(enable_tabwidth_spin)
+        indent_chars_box.combobox.currentIndexChanged.connect(
+            enable_tabwidth_spin)
 
         tab_mode_box = newcb(_("Tab always indent"),
-                      'tab_always_indent', default=False,
-                      tip=_("If enabled, pressing Tab will always indent,\n"
-                            "even when the cursor is not at the beginning\n"
-                            "of a line (when this option is enabled, code\n"
-                            "completion may be triggered using the alternate\n"
-                            "shortcut: Ctrl+Space)"))
+                             'tab_always_indent', default=False,
+                             tip=_("If enabled, pressing Tab will always "
+                                   "indent,\n"
+                                   "even when the cursor is not at the "
+                                   "beginning\n"
+                                   "of a line (when this option is enabled, "
+                                   "code\n"
+                                   "completion may be triggered using the "
+                                   "alternate\n"
+                                   "shortcut: Ctrl+Space)"))
         ibackspace_box = newcb(_("Intelligent backspace"),
                                'intelligent_backspace', default=True)
         removetrail_box = newcb(_("Automatically remove trailing spaces "
                                   "when saving files"),
-                               'always_remove_trailing_spaces', default=False)
+                                'always_remove_trailing_spaces', default=False)
 
         analysis_group = QGroupBox(_("Analysis"))
         pep_url = '<a href="https://www.python.org/dev/peps/pep-0008">PEP8</a>'
@@ -274,38 +286,43 @@ def enable_tabwidth_spin(index):
         is_pep8 = codeanalysis.get_checker_executable(
                 'pycodestyle') is not None
         pyflakes_box = newcb(_("Real-time code analysis"),
-                      'code_analysis/pyflakes', default=True,
-                      tip=_("<p>If enabled, Python source code will be analyzed "
-                            "using pyflakes, lines containing errors or "
-                            "warnings will be highlighted.</p>"
-                            "<p><u>Note</u>: add <b>analysis:ignore</b> in "
-                            "a comment to ignore code analysis "
-                            "warnings.</p>"))
+                             'code_analysis/pyflakes', default=True,
+                             tip=_("<p>If enabled, Python source code will be "
+                                   "analyzed "
+                                   "using pyflakes, lines containing errors "
+                                   "or warnings will be highlighted.</p>"
+                                   "<p><u>Note</u>: "
+                                   "add <b>analysis:ignore</b> in "
+                                   "a comment to ignore code analysis "
+                                   "warnings.</p>"))
         pyflakes_box.setEnabled(is_pyflakes)
         if not is_pyflakes:
             pyflakes_box.setToolTip(_("Code analysis requires pyflakes %s+") %
                                     codeanalysis.PYFLAKES_REQVER)
         pep8_box = newcb(_("Real-time code style analysis"),
-                      'code_analysis/pep8', default=False,
-                      tip=_("<p>If enabled, Python source code will be analyzed "
-                            "using pycodestyle, lines that are not following PEP8 "
-                            "style guide will be highlighted.</p>"
-                            "<p><u>Note</u>: add <b>analysis:ignore</b> in "
-                            "a comment to ignore style analysis "
-                            "warnings.</p>"))
+                         'code_analysis/pep8', default=False,
+                         tip=_("<p>If enabled, Python source code will be "
+                               "analyzed "
+                               "using pycodestyle, lines that are not "
+                               "following PEP8 "
+                               "style guide will be highlighted.</p>"
+                               "<p><u>Note</u>: add <b>analysis:ignore</b> in "
+                               "a comment to ignore style analysis "
+                               "warnings.</p>"))
         pep8_box.setEnabled(is_pep8)
         todolist_box = newcb(_("Code annotations (TODO, FIXME, XXX, HINT, TIP,"
                                " @todo, HACK, BUG, OPTIMIZE, !!!, ???)"),
                              'todo_list', default=True)
         realtime_radio = self.create_radiobutton(
                                             _("Perform analysis when "
-                                                    "saving file and every"),
+                                              "saving file and every"),
                                             'realtime_analysis', True)
         saveonly_radio = self.create_radiobutton(
                                             _("Perform analysis only "
-                                                    "when saving file"),
+                                              "when saving file"),
                                             'onsave_analysis')
-        af_spin = self.create_spinbox("", _(" ms"), 'realtime_analysis/timeout',
+        af_spin = self.create_spinbox("", _(" ms"),
+                                      'realtime_analysis/timeout',
                                       min_=100, max_=1000000, step=100)
         af_layout = QHBoxLayout()
         af_layout.addWidget(realtime_radio)
@@ -381,9 +398,11 @@ def enable_tabwidth_spin(index):
                              )
         convert_eol_on_save_combo = self.create_combobox("",
                                                          eol_combo_choices,
-                                                         'convert_eol_on_save_to',
+                                                         ('convert_eol_on_'
+                                                          'save_to'),
                                                          )
-        convert_eol_on_save_box.toggled.connect(convert_eol_on_save_combo.setEnabled)
+        convert_eol_on_save_box.toggled.connect(
+                convert_eol_on_save_combo.setEnabled)
         convert_eol_on_save_combo.setEnabled(
                 self.get_option('convert_eol_on_save'))
 
@@ -402,7 +421,8 @@ def enable_tabwidth_spin(index):
                     _("Display"))
         tabs.addTab(self.create_tab(introspection_group, analysis_group),
                     _("Code Introspection/Analysis"))
-        tabs.addTab(self.create_tab(template_btn, run_group, run_selection_group,
+        tabs.addTab(self.create_tab(template_btn, run_group,
+                                    run_selection_group,
                                     sourcecode_group, eol_group),
                     _("Advanced settings"))
 
@@ -419,7 +439,7 @@ class Editor(SpyderPluginWidget):
     CONFIGWIDGET_CLASS = EditorConfigPage
     TEMPFILE_PATH = get_conf_path('temp.py')
     TEMPLATE_PATH = get_conf_path('template.py')
-    DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute
+    DISABLE_ACTIONS_WHEN_HIDDEN = False  # SpyderPluginWidget class attribute
 
     # Signals
     run_in_current_ipyclient = Signal(str, str, str, bool, bool, bool, bool)
diff --git a/spyder/plugins/editor/utils/languages.py b/spyder/plugins/editor/utils/languages.py
index fdf67425c26..2155aa4adeb 100644
--- a/spyder/plugins/editor/utils/languages.py
+++ b/spyder/plugins/editor/utils/languages.py
@@ -13,7 +13,7 @@
                  'Cython': ('pyx', 'pxi', 'pxd'),
                  'Enaml': ('enaml',),
                  'Fortran77': ('f', 'for', 'f77'),
-                 'Fortran': ('f90', 'f95', 'f2k'),
+                 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'),
                  'Idl': ('pro',),
                  'Diff': ('diff', 'patch', 'rej'),
                  'GetText': ('po', 'pot'),
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
index 4b8a2063b2d..ea44eb3928d 100644
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -76,13 +76,13 @@
     request, handles, class_register)
 from spyder.plugins.editor.lsp import (
     LSPRequestTypes, TextDocumentSyncKind, DiagnosticSeverity,
-    TextDocumentSaveReason)
+    )
 from spyder.api.panel import Panel
 
 try:
     import nbformat as nbformat
     from nbconvert import PythonExporter as nbexporter
-except:
+except Exception:
     nbformat = None  # analysis:ignore
 
 
@@ -918,15 +918,14 @@ def handle_go_to_definition(self, position):
 
     # ------------- LSP: Save/close file -----------------------------------
 
-    @request(method=LSPRequestTypes.DOCUMENT_WILL_SAVE,
+    @request(method=LSPRequestTypes.DOCUMENT_DID_SAVE,
              requires_response=False)
     def notify_save(self):
-        if self.will_save_notify:
-            params = {
-                'file': self.filename,
-                'reason': TextDocumentSaveReason.MANUAL
-            }
-            return params
+        # self.document_did_change()
+        params = {'file': self.filename}
+        if self.save_include_text:
+            params['text'] = self.toPlainText()
+        return params
 
     @request(method=LSPRequestTypes.DOCUMENT_DID_CLOSE,
              requires_response=False)
@@ -1140,6 +1139,7 @@ def remove_trailing_spaces(self):
                 break
             cursor.movePosition(QTextCursor.NextBlock)
         cursor.endEditBlock()
+        self.document_did_change()
 
     def fix_indentation(self):
         """Replace tabs by spaces."""
@@ -1150,6 +1150,7 @@ def fix_indentation(self):
             # to benefit from QTextEdit's undo/redo feature.
             self.selectAll()
             self.insertPlainText(text_after)
+            self.document_did_change()
 
     def get_current_object(self):
         """Return current object (string) """
@@ -1167,6 +1168,7 @@ def delete(self):
                 cursor.setPosition(position + 1, QTextCursor.KeepAnchor)
             self.setTextCursor(cursor)
         self.remove_selected_text()
+        self.document_did_change()
 
     #------Find occurrences
     def __find_first(self, text):
@@ -1567,6 +1569,7 @@ def append(self, text):
         cursor = self.textCursor()
         cursor.movePosition(QTextCursor.End)
         cursor.insertText(text)
+        self.document_did_change()
 
     @Slot()
     def paste(self):
@@ -1590,16 +1593,16 @@ def undo(self):
         """Reimplement undo to decrease text version number."""
         if self.document().isUndoAvailable():
             self.text_version -= 1
+            TextEditBaseWidget.undo(self)
             self.document_did_change('')
-        TextEditBaseWidget.undo(self)
 
     @Slot()
     def redo(self):
         """Reimplement redo to increase text version number."""
         if self.document().isRedoAvailable():
-            self.document_did_change('text')
             self.text_version += 1
-        TextEditBaseWidget.redo(self)
+            TextEditBaseWidget.redo(self)
+            self.document_did_change('text')
 
     def get_block_data(self, block):
         """Return block data (from syntax highlighter)"""
@@ -1612,10 +1615,9 @@ def get_fold_level(self, block_nb):
         block = self.document().findBlockByNumber(block_nb)
         return self.get_block_data(block).fold_level
 
-
-#===============================================================================
+# =============================================================================
 #    High-level editor features
-#===============================================================================
+# =============================================================================
     @Slot()
     def center_cursor_on_next_focus(self):
         """QPlainTextEdit's "centerCursor" requires the widget to be visible"""
@@ -1659,7 +1661,7 @@ def process_code_analysis(self, check_results):
         cursor = self.textCursor()
         document = self.document()
         for diagnostic in check_results:
-            source = diagnostic['source']
+            source = diagnostic.get('source', '')
             msg_range = diagnostic['range']
             start = msg_range['start']
             end = msg_range['end']
@@ -1838,6 +1840,7 @@ def add_prefix(self, prefix):
                 cursor.movePosition(QTextCursor.NextWord)
             cursor.insertText(prefix)
             cursor.endEditBlock()
+        self.document_did_change()
 
     def __spaces_for_prefix(self):
         """Find the less indented level of text."""
@@ -1930,6 +1933,7 @@ def remove_prefix(self, prefix):
             cursor.movePosition(QTextCursor.StartOfBlock)
             line_text = to_text_string(cursor.block().text())
             self.__remove_prefix(prefix, cursor, line_text)
+        self.document_did_change()
 
     def __remove_prefix(self, prefix, cursor, line_text):
         """Handle the removal of the prefix for a single line."""
@@ -1970,6 +1974,7 @@ def __remove_prefix(self, prefix, cursor, line_text):
             cursor.movePosition(QTextCursor.Right,
                                 QTextCursor.KeepAnchor, len(prefix))
             cursor.removeSelectedText()
+        self.document_did_change()
 
     def __even_number_of_spaces(self, line_text, group=0):
         """
@@ -1985,14 +1990,13 @@ def __number_of_spaces(self, line_text, group=0):
         if len(spaces) - 1 >= group:
             return len(spaces[group])
 
-
     def fix_indent(self, *args, **kwargs):
         """Indent line according to the preferences"""
         if self.is_python_like():
             return self.fix_indent_smart(*args, **kwargs)
         else:
             return self.simple_indentation(*args, **kwargs)
-
+        self.document_did_change()
 
     def simple_indentation(self, forward=True, **kwargs):
         """
@@ -2011,7 +2015,6 @@ def simple_indentation(self, forward=True, **kwargs):
         cursor.insertText(indentation)
         return False  # simple indentation don't fix indentation
 
-
     def fix_indent_smart(self, forward=True, comment_or_string=False):
         """
         Fix indentation (Python only, no text selection)
@@ -2251,6 +2254,7 @@ def indent(self, force=False):
                 self.insert_text(" "*(length-(len(leading_text) % length)))
             else:
                 self.insert_text(self.indent_chars)
+        self.document_did_change()
 
     def indent_or_replace(self):
         """Indent or replace by 4 spaces depending on selection and tab mode"""
@@ -2365,6 +2369,7 @@ def transform_to_uppercase(self):
         s = selected_text.upper()
         cursor.insertText(s)
         self.set_cursor_position(prev_pos)
+        self.document_did_change()
 
     def transform_to_lowercase(self):
         """Change to lowercase current line or selection."""
@@ -2380,6 +2385,7 @@ def transform_to_lowercase(self):
         s = selected_text.lower()
         cursor.insertText(s)
         self.set_cursor_position(prev_pos)
+        self.document_did_change()
 
     def blockcomment(self):
         """Block comment current line or selection."""
@@ -2411,6 +2417,7 @@ def blockcomment(self):
         cursor.movePosition(QTextCursor.StartOfBlock)
         cursor.insertText(comline)
         cursor.endEditBlock()
+        self.document_did_change()
 
     def unblockcomment(self):
         """Un-block comment current line or selection."""
@@ -2421,6 +2428,7 @@ def unblockcomment(self):
             unblockcomment =  self.__unblockcomment(compatibility=True)
         else:
             return unblockcomment
+        self.document_did_change()
 
     def __unblockcomment(self, compatibility=False):
         """Un-block comment current line or selection helper."""
@@ -2488,6 +2496,7 @@ def kill_line_end(self):
                                 QTextCursor.KeepAnchor)
         self._kill_ring.kill_cursor(cursor)
         self.setTextCursor(cursor)
+        self.document_did_change()
 
     def kill_line_start(self):
         """Kill the text on the current line from the cursor backward"""
@@ -2497,6 +2506,7 @@ def kill_line_start(self):
                             QTextCursor.KeepAnchor)
         self._kill_ring.kill_cursor(cursor)
         self.setTextCursor(cursor)
+        self.document_did_change()
 
     def _get_word_start_cursor(self, position):
         """Find the start of the word to the left of the given position. If a
@@ -2540,6 +2550,7 @@ def kill_prev_word(self):
         cursor.setPosition(position, QTextCursor.KeepAnchor)
         self._kill_ring.kill_cursor(cursor)
         self.setTextCursor(cursor)
+        self.document_did_change()
 
     def kill_next_word(self):
         """Kill the next word"""
@@ -2548,6 +2559,7 @@ def kill_next_word(self):
         cursor.setPosition(position, QTextCursor.KeepAnchor)
         self._kill_ring.kill_cursor(cursor)
         self.setTextCursor(cursor)
+        self.document_did_change()
 
     #------Autoinsertion of quotes/colons
     def __get_current_color(self, cursor=None):
@@ -3030,8 +3042,7 @@ def mouseMoveEvent(self, event):
         if self.go_to_definition_enabled and \
            event.modifiers() & Qt.ControlModifier:
             text = self.get_word_at(event.pos())
-            if text and (self.is_python_like())\
-               and not sourcecode.is_keyword(to_text_string(text)):
+            if (text and not sourcecode.is_keyword(to_text_string(text))):
                 if not self.__cursor_changed:
                     QApplication.setOverrideCursor(
                                                 QCursor(Qt.PointingHandCursor))
@@ -3039,10 +3050,11 @@ def mouseMoveEvent(self, event):
                 cursor = self.cursorForPosition(event.pos())
                 cursor.select(QTextCursor.WordUnderCursor)
                 self.clear_extra_selections('ctrl_click')
-                self.__highlight_selection('ctrl_click', cursor, update=True,
-                                foreground_color=self.ctrl_click_color,
-                                underline_color=self.ctrl_click_color,
-                                underline_style=QTextCharFormat.SingleUnderline)
+                self.__highlight_selection(
+                    'ctrl_click', cursor, update=True,
+                    foreground_color=self.ctrl_click_color,
+                    underline_color=self.ctrl_click_color,
+                    underline_style=QTextCharFormat.SingleUnderline)
                 event.accept()
                 return
         if self.__cursor_changed:
@@ -3105,16 +3117,15 @@ def contextMenuEvent(self, event):
         nonempty_selection = self.has_selected_text()
         self.copy_action.setEnabled(nonempty_selection)
         self.cut_action.setEnabled(nonempty_selection)
-        self.clear_all_output_action.setVisible(self.is_json() and \
+        self.clear_all_output_action.setVisible(self.is_json() and
                                                 nbformat is not None)
-        self.ipynb_convert_action.setVisible(self.is_json() and \
+        self.ipynb_convert_action.setVisible(self.is_json() and
                                              nbformat is not None)
         self.run_cell_action.setVisible(self.is_python())
         self.run_cell_and_advance_action.setVisible(self.is_python())
         self.run_selection_action.setVisible(self.is_python())
         self.re_run_last_cell_action.setVisible(self.is_python())
-        self.gotodef_action.setVisible(self.go_to_definition_enabled \
-                                       and self.is_python_like())
+        self.gotodef_action.setVisible(self.go_to_definition_enabled)
 
         # Code duplication go_to_definition_from_cursor and mouse_move_event
         cursor = self.textCursor()
@@ -3123,8 +3134,8 @@ def contextMenuEvent(self, event):
             cursor.select(QTextCursor.WordUnderCursor)
             text = to_text_string(cursor.selectedText())
 
-        self.undo_action.setEnabled( self.document().isUndoAvailable())
-        self.redo_action.setEnabled( self.document().isRedoAvailable())
+        self.undo_action.setEnabled(self.document().isUndoAvailable())
+        self.redo_action.setEnabled(self.document().isRedoAvailable())
         menu = self.menu
         if self.isReadOnly():
             menu = self.readonly_menu
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
index 2abca65ac7c..f51e2757c18 100644
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -1650,7 +1650,6 @@ def save(self, index=None, force=False):
             self.set_os_eol_chars(osname=osname)
         txt = to_text_string(finfo.editor.get_text_with_eol())
         try:
-            finfo.editor.notify_save()
             finfo.encoding = encoding.write(txt, finfo.filename,
                                             finfo.encoding)
             finfo.newly_created = False
@@ -1678,6 +1677,8 @@ def save(self, index=None, force=False):
             finfo.editor.rehighlight()
 
             self._refresh_outlineexplorer(index)
+
+            finfo.editor.notify_save()
             return True
         except EnvironmentError as error:
             self.msgbox = QMessageBox(
diff --git a/spyder/plugins/lspmanager.py b/spyder/plugins/lspmanager.py
index aad10d78c01..7d4129ba8e6 100644
--- a/spyder/plugins/lspmanager.py
+++ b/spyder/plugins/lspmanager.py
@@ -28,7 +28,7 @@
 
 # Local imports
 from spyder.config.main import CONF
-from spyder.utils.misc import select_port
+from spyder.utils.misc import select_port, getcwd_or_home
 from spyder.utils import icon_manager as ima
 from spyder.config.base import _
 from spyder.utils.programs import find_program
@@ -43,7 +43,8 @@
 LSP_LANGUAGES = [
     'C#', 'CSS/LESS/SASS', 'Go', 'GraphQL', 'Groovy', 'Haxe', 'HTML',
     'Java', 'JavaScript', 'JSON', 'Julia', 'OCaml', 'PHP',
-    'Python', 'Rust', 'Scala', 'Swift', 'TypeScript', 'Erlang'
+    'Python', 'Rust', 'Scala', 'Swift', 'TypeScript', 'Erlang',
+    'Fortran'
 ]
 LSP_LANGUAGE_NAME = {x.lower(): x for x in LSP_LANGUAGES}
 
@@ -311,13 +312,13 @@ def validate(self):
             json.loads(self.conf_input.toPlainText())
             try:
                 self.json_label.setText(self.JSON_VALID)
-            except:
+            except Exception:
                 pass
         except (ValueError, json.decoder.JSONDecodeError):
             try:
                 self.json_label.setText(self.JSON_INVALID)
                 self.button_ok.setEnabled(False)
-            except:
+            except Exception:
                 pass
 
     def form_status(self, status):
@@ -374,7 +375,7 @@ def set_local_options(self, enabled):
             self.args_input.setEnabled(False)
         try:
             self.validate()
-        except:
+        except Exception:
             pass
 
     def get_options(self):
@@ -701,6 +702,31 @@ def register_file(self, language, filename, signal):
             else:
                 language_client.register_file(filename, signal)
 
+    def get_root_path(self):
+        """
+        Gets the root path to pass to the LSP servers, i.e. project path
+        or cwd
+        """
+        path = None
+        if self.main and self.main.projects:
+            path = self.main.projects.get_active_project_path()
+        if not path:
+            path = getcwd_or_home()
+        return path
+
+    @Slot()
+    def reinit_all_lsp_clients(self):
+        """
+        Sends a new initialize message to each LSP server when the project
+        path has changed so they can update the respective server root paths.
+        """
+        for language_client in self.clients.values():
+            if language_client['status'] == self.RUNNING:
+                folder = self.get_root_path()
+                inst = language_client['instance']
+                inst.folder = folder
+                inst.initialize()
+
     def start_lsp_client(self, language):
         started = False
         if language in self.clients:
@@ -717,6 +743,7 @@ def start_lsp_client(self, language):
                     config['port'] = port
                 language_client['instance'] = LSPClient(
                     self, config['args'], config, config['external'],
+                    folder=self.get_root_path(),
                     plugin_configurations=config.get('configurations', {}),
                     language=language)
 
diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py
index 381bcad9d23..e8caff29582 100644
--- a/spyder/plugins/projects/plugin.py
+++ b/spyder/plugins/projects/plugin.py
@@ -115,6 +115,8 @@ def register_plugin(self):
         """Register plugin in Spyder's main window"""
         ipyconsole = self.main.ipyconsole
         treewidget = self.explorer.treewidget
+        lspmgr = self.main.lspmanager
+
         self.main.add_dockwidget(self)
         self.explorer.sig_open_file.connect(self.main.open_file)
 
@@ -140,6 +142,7 @@ def register_plugin(self):
             lambda v: self.main.workingdirectory.chdir(v))
         self.sig_project_loaded.connect(
             lambda v: self.main.set_window_title())
+        self.sig_project_loaded.connect(lspmgr.reinit_all_lsp_clients)
         self.sig_project_loaded.connect(
             lambda v: self.main.editor.setup_open_files())
         self.sig_project_loaded.connect(self.update_explorer)
@@ -148,6 +151,7 @@ def register_plugin(self):
                 self.get_last_working_dir()))
         self.sig_project_closed.connect(
             lambda v: self.main.set_window_title())
+        self.sig_project_closed.connect(lspmgr.reinit_all_lsp_clients)
         self.sig_project_closed.connect(
             lambda v: self.main.editor.setup_open_files())
         self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)
diff --git a/spyder/utils/misc.py b/spyder/utils/misc.py
index 54d9579633f..458f6369ab5 100644
--- a/spyder/utils/misc.py
+++ b/spyder/utils/misc.py
@@ -100,7 +100,8 @@ def count_lines(path, extensions=None, excluded_dirnames=None):
     if extensions is None:
         extensions = ['.py', '.pyw', '.ipy', '.enaml', '.c', '.h', '.cpp',
                       '.hpp', '.inc', '.', '.hh', '.hxx', '.cc', '.cxx',
-                      '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k']
+                      '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k',
+                      '.f03', '.f08']
     if excluded_dirnames is None:
         excluded_dirnames = ['build', 'dist', '.hg', '.svn']
     def get_filelines(path):
@@ -226,7 +227,7 @@ def add_pathlist_to_PYTHONPATH(env, pathlist, drop_env=False,
     # PyQt API 1/2 compatibility-related tests:
     assert isinstance(env, list)
     assert all([is_text_string(path) for path in env])
-    
+
     pypath = "PYTHONPATH"
     pathstr = os.pathsep.join(pathlist)
     if os.environ.get(pypath) is not None and not drop_env:


Code review comments:
diff_hunk: @@ -202,6 +205,12 @@ def on_msg_received(self):
                 except KeyError:
                     pass
 
+                if 'error' in resp:
+                    logger.debug(
+                        '{} response error: {}'.format(self.language,
path: spyder/plugins/editor/lsp/client.py
text: @user2:
response error: -> Response Error:

The indentation is correct, but it would be way cleaner and more concise IMO to simply do:

```
logger.debug('{} Response Error: {}'
             .format(self.language, repr(resp['error'])))
```

Assuming @user1 agrees. See my comments elsewhere about this.

diff_hunk: @@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""Spyder Language Server Protocol Client window handler routines."""
+
+import logging
+
+from spyder.plugins.editor.lsp import LSPRequestTypes
+from spyder.plugins.editor.lsp.decorators import handles  # , send_request
+
+logger = logging.getLogger(__name__)
+
+
+class WindowProvider:
+    @handles(LSPRequestTypes.WINDOW_SHOW_MESSAGE)
+    def process_show_message(self, response, *args):
+        """Handles window/showMessage notifications from LSP server"""
path: spyder/plugins/editor/lsp/providers/window.py
text: @user1:
Per PEP 257 docstring lines must end with a period.

diff_hunk: @@ -67,12 +67,14 @@ def __init__(self, host='127.0.0.1', port=2087, workspace=getcwd(),
 
         initial_time = time.time()
         while not connected:
+            LOGGER.info('Attempting LSP server connection {0}:{1}'.format(
+                        self.host, self.port))
path: spyder/plugins/editor/lsp/transport/producer.py
text: @user2:
This isn't consistent with how you handled this same situation [here](https://github.com/spyder-ide/spyder/pull/8257/files#diff-dc717b13a5ed64534b7ea67b31dd269bR148) and you used yet another indentation convention [here](https://github.com/spyder-ide/spyder/pull/8257/files#diff-dc717b13a5ed64534b7ea67b31dd269bR209). 

IMO, what looks cleanest and I've generally seen [recommended](https://stackoverflow.com/questions/8683178/chained-method-calls-indentation-style-in-python) is to put ``.format(...))`` on the second line, like so:

```
LOGGER.info('Attempting LSP server connection {0}:{1}'
            .format(self.host, self.port))
```

but I'm not sure if @user1 has a specific preference or more authoritative knowledge.

diff_hunk: @@ -209,10 +211,12 @@ def setup_page(self):
                                    'codecompletion/enter_key')
             calltips_box = newcb(_("Display balloon tips"), 'calltips')
             gotodef_box = newcb(_("Link to object definition"),
-                  'go_to_definition',
-                  tip=_("If this option is enabled, clicking on an object\n"
-                        "name (left-click + Ctrl key) will go this object\n"
-                        "definition (if resolved)."))
+                                'go_to_definition',
+                                tip=_("If this option is enabled, clicking on "
+                                      "an object\n"
+                                      "name (left-click + Ctrl key) will go "
+                                      "this object\n"
path: spyder/plugins/editor/plugin.py
text: @user1:
-> "go *to* this object definition" (typo is present in the original)

@user1:
These errors are much easier to make when text strings are so broken up like this, FYI.

diff_hunk: @@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""Spyder Language Server Protocol Client window handler routines."""
+
+import logging
+
+from spyder.plugins.editor.lsp import LSPRequestTypes
+from spyder.plugins.editor.lsp.decorators import handles  # , send_request
path: spyder/plugins/editor/lsp/providers/window.py
text: @user2:
Remove comment following @user1 's previous requests?