Repository: python-lsp/python-lsp-server
Pull request #134 (2021-12-09 04:21:48)

Add support for LSP formatting `options` parameter
This PR adds support for LSP formatting options https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#formattingOptions for the yapf

It adds explicit support for `tabSize` and `insertSpaces`, the rest of the options are not configurable in yapf. I did however add support for arbitrary configurations as specified in `[key: string]: boolean | integer | string;`.

I think this PR might break plugins as the API now passes `options` to formatter hooks, but not sure.

Nits are welcome as I don't normally read/write python.

krassowski: The tests are failing on CI:

```
>           style_config = style.CreateStyleFromConfig(style_config)
E           NameError: name 'style' is not defined
```
masad-frost: Thanks for waiting! Got a little busy getting married :D

I rebased the branch and fixed the error in the tests https://github.com/python-lsp/python-lsp-server/pull/134/commits/22db74e6ec9b3a47c1a866f0e8a65bd0735a9a08
masad-frost: Lint caught an issue where I wasn't passing `options` for range formatting!
ccordoba12: Hey @masad-frost, I fixed the linting errors reported here in another PR of mine.

Please let me know if you need to help to fix the merge conflicts so we can merge this one.
masad-frost: Done
masad-frost: Hmm, not sure why lint passes locally and crops up here only 
masad-frost: Hmm, not sure why lint passed locally. Hopefully this is the last of it

Patch:
diff --git a/README.md b/README.md
index 16948e5..4fe7403 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,9 @@ If the respective dependencies are found, the following optional providers will
 - [pydocstyle](https://github.com/PyCQA/pydocstyle) linter for docstring style checking (disabled by default)
 - [autopep8](https://github.com/hhatto/autopep8) for code formatting
 - [YAPF](https://github.com/google/yapf) for code formatting (preferred over autopep8)
+- [flake8](https://github.com/pycqa/flake8) for error checking (disabled by default)
+- [pylint](https://github.com/PyCQA/pylint) for code linting (disabled by default)
+- [preload](https://github.com/tfiers/preload) for heavy modules (not included by default)
 
 Optional providers can be installed using the `extras` syntax. To install [YAPF](https://github.com/google/yapf) formatting for example:
 
diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py
index 736cf93..d1a2458 100644
--- a/pylsp/hookspecs.py
+++ b/pylsp/hookspecs.py
@@ -80,12 +80,12 @@ def pylsp_folding_range(config, workspace, document):
 
 
 @hookspec(firstresult=True)
-def pylsp_format_document(config, workspace, document):
+def pylsp_format_document(config, workspace, document, options):
     pass
 
 
 @hookspec(firstresult=True)
-def pylsp_format_range(config, workspace, document, range):
+def pylsp_format_range(config, workspace, document, range, options):
     pass
 
 
diff --git a/pylsp/plugins/autopep8_format.py b/pylsp/plugins/autopep8_format.py
index 8915fb7..f605f83 100644
--- a/pylsp/plugins/autopep8_format.py
+++ b/pylsp/plugins/autopep8_format.py
@@ -13,13 +13,13 @@ log = logging.getLogger(__name__)
 
 
 @hookimpl(tryfirst=True)  # Prefer autopep8 over YAPF
-def pylsp_format_document(config, document):
+def pylsp_format_document(config, document, options=None):  # pylint: disable=unused-argument
     log.info("Formatting document %s with autopep8", document)
     return _format(config, document)
 
 
 @hookimpl(tryfirst=True)  # Prefer autopep8 over YAPF
-def pylsp_format_range(config, document, range):  # pylint: disable=redefined-builtin
+def pylsp_format_range(config, document, range, options=None):  # pylint: disable=redefined-builtin,unused-argument
     log.info("Formatting document %s in range %s with autopep8", document, range)
 
     # First we 'round' the range up/down to full lines only
diff --git a/pylsp/plugins/yapf_format.py b/pylsp/plugins/yapf_format.py
index 7c47781..e4267a7 100644
--- a/pylsp/plugins/yapf_format.py
+++ b/pylsp/plugins/yapf_format.py
@@ -4,7 +4,7 @@
 import logging
 import os
 
-from yapf.yapflib import file_resources
+from yapf.yapflib import file_resources, style
 from yapf.yapflib.yapf_api import FormatCode
 
 from pylsp import hookimpl
@@ -14,12 +14,12 @@ log = logging.getLogger(__name__)
 
 
 @hookimpl
-def pylsp_format_document(document):
-    return _format(document)
+def pylsp_format_document(document, options=None):
+    return _format(document, options=options)
 
 
 @hookimpl
-def pylsp_format_range(document, range):  # pylint: disable=redefined-builtin
+def pylsp_format_range(document, range, options=None):  # pylint: disable=redefined-builtin
     # First we 'round' the range up/down to full lines only
     range['start']['character'] = 0
     range['end']['line'] += 1
@@ -33,10 +33,10 @@ def pylsp_format_range(document, range):  # pylint: disable=redefined-builtin
 
     # Add 1 for 1-indexing vs LSP's 0-indexing
     lines = [(range['start']['line'] + 1, range['end']['line'] + 1)]
-    return _format(document, lines=lines)
+    return _format(document, lines=lines, options=options)
 
 
-def _format(document, lines=None):
+def _format(document, lines=None, options=None):
     # Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
     # and restore them below.
     replace_eols = False
@@ -46,13 +46,50 @@ def _format(document, lines=None):
         replace_eols = True
         source = source.replace(eol_chars, '\n')
 
+    # Get the default styles as a string
+    # for a preset configuration, i.e. "pep8"
+    style_config = file_resources.GetDefaultStyleForDir(
+        os.path.dirname(document.path)
+    )
+    if options is not None:
+        # We have options passed from LSP format request
+        # let's pass them to the formatter.
+        # First we want to get a dictionary of the preset style
+        # to pass instead of a string so that we can modify it
+        style_config = style.CreateStyleFromConfig(style_config)
+
+        use_tabs = style_config['USE_TABS']
+        indent_width = style_config['INDENT_WIDTH']
+
+        if options.get('tabSize') is not None:
+            indent_width = max(int(options.get('tabSize')), 1)
+
+        if options.get('insertSpaces') is not None:
+            # TODO is it guaranteed to be a boolean, or can it be a string?
+            use_tabs = not options.get('insertSpaces')
+
+            if use_tabs:
+                # Indent width doesn't make sense when using tabs
+                # the specifications state: "Size of a tab in spaces"
+                indent_width = 1
+
+        style_config['USE_TABS'] = use_tabs
+        style_config['INDENT_WIDTH'] = indent_width
+        style_config['CONTINUATION_INDENT_WIDTH'] = indent_width
+
+        for style_option, value in options.items():
+            # Apply arbitrary options passed as formatter options
+            if style_option not in style_config:
+                # ignore if it's not a known yapf config
+                continue
+
+            style_config[style_option] = value
+
     new_source, changed = FormatCode(
         source,
         lines=lines,
         filename=document.filename,
-        style_config=file_resources.GetDefaultStyleForDir(
-            os.path.dirname(document.path)
-        )
+        style_config=style_config
     )
 
     if not changed:
diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py
index a11f611..81e93bd 100644
--- a/pylsp/python_lsp.py
+++ b/pylsp/python_lsp.py
@@ -277,11 +277,11 @@ class PythonLSPServer(MethodDispatcher):
     def execute_command(self, command, arguments):
         return self._hook('pylsp_execute_command', command=command, arguments=arguments)
 
-    def format_document(self, doc_uri):
-        return self._hook('pylsp_format_document', doc_uri)
+    def format_document(self, doc_uri, options):
+        return self._hook('pylsp_format_document', doc_uri, options=options)
 
-    def format_range(self, doc_uri, range):
-        return self._hook('pylsp_format_range', doc_uri, range=range)
+    def format_range(self, doc_uri, range, options):
+        return self._hook('pylsp_format_range', doc_uri, range=range, options=options)
 
     def highlight(self, doc_uri, position):
         return flatten(self._hook('pylsp_document_highlight', doc_uri, position=position)) or None
@@ -362,9 +362,8 @@ class PythonLSPServer(MethodDispatcher):
     def m_text_document__document_symbol(self, textDocument=None, **_kwargs):
         return self.document_symbols(textDocument['uri'])
 
-    def m_text_document__formatting(self, textDocument=None, _options=None, **_kwargs):
-        # For now we're ignoring formatting options.
-        return self.format_document(textDocument['uri'])
+    def m_text_document__formatting(self, textDocument=None, options=None, **_kwargs):
+        return self.format_document(textDocument['uri'], options)
 
     def m_text_document__rename(self, textDocument=None, position=None, newName=None, **_kwargs):
         return self.rename(textDocument['uri'], position, newName)
@@ -372,9 +371,8 @@ class PythonLSPServer(MethodDispatcher):
     def m_text_document__folding_range(self, textDocument=None, **_kwargs):
         return self.folding(textDocument['uri'])
 
-    def m_text_document__range_formatting(self, textDocument=None, range=None, _options=None, **_kwargs):
-        # Again, we'll ignore formatting options for now.
-        return self.format_range(textDocument['uri'], range)
+    def m_text_document__range_formatting(self, textDocument=None, range=None, options=None, **_kwargs):
+        return self.format_range(textDocument['uri'], range, options)
 
     def m_text_document__references(self, textDocument=None, position=None, context=None, **_kwargs):
         exclude_declaration = not context['includeDeclaration']


Test patch:
diff --git a/test/plugins/test_yapf_format.py b/test/plugins/test_yapf_format.py
index 94f619e..cf4d965 100644
--- a/test/plugins/test_yapf_format.py
+++ b/test/plugins/test_yapf_format.py
@@ -21,6 +21,9 @@ B = ['h',
 """
 
 GOOD_DOC = """A = ['hello', 'world']\n"""
+FOUR_SPACE_DOC = """def hello():
+    pass
+"""
 
 
 def test_format(workspace):
@@ -68,3 +71,27 @@ def test_line_endings(workspace, newline):
     res = pylsp_format_document(doc)
 
     assert res[0]['newText'] == f'import os{newline}import sys{2 * newline}dict(a=1){newline}'
+
+
+def test_format_with_tab_size_option(workspace):
+    doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC)
+    res = pylsp_format_document(doc, {"tabSize": "8"})
+
+    assert len(res) == 1
+    assert res[0]['newText'] == FOUR_SPACE_DOC.replace("    ", "        ")
+
+
+def test_format_with_insert_spaces_option(workspace):
+    doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC)
+    res = pylsp_format_document(doc, {"insertSpaces": False})
+
+    assert len(res) == 1
+    assert res[0]['newText'] == FOUR_SPACE_DOC.replace("    ", "\t")
+
+
+def test_format_with_yapf_specific_option(workspace):
+    doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC)
+    res = pylsp_format_document(doc, {"USE_TABS": True})
+
+    assert len(res) == 1
+    assert res[0]['newText'] == FOUR_SPACE_DOC.replace("    ", "\t")