File: yapf_format.py

package info (click to toggle)
python-lsp-server 1.12.0-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 796 kB
  • sloc: python: 7,791; sh: 12; makefile: 4
file content (199 lines) | stat: -rw-r--r-- 6,785 bytes parent folder | download
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# Copyright 2017-2020 Palantir Technologies, Inc.
# Copyright 2021- Python Language Server Contributors.

import logging
import os

import whatthepatch
from yapf.yapflib import file_resources, style
from yapf.yapflib.yapf_api import FormatCode

from pylsp import hookimpl
from pylsp._utils import get_eol_chars

log = logging.getLogger(__name__)


@hookimpl
def pylsp_format_document(workspace, document, options):
    log.info("Formatting document %s with yapf", document)
    with workspace.report_progress("format: yapf"):
        return _format(document, options=options)


@hookimpl
def pylsp_format_range(document, range, options):
    log.info("Formatting document %s in range %s with yapf", document, range)
    # First we 'round' the range up/down to full lines only
    range["start"]["character"] = 0
    range["end"]["line"] += 1
    range["end"]["character"] = 0

    # From Yapf docs:
    # lines: (list of tuples of integers) A list of tuples of lines, [start, end],
    #   that we want to format. The lines are 1-based indexed. It can be used by
    #   third-party code (e.g., IDEs) when reformatting a snippet of code rather
    #   than a whole file.

    # Add 1 for 1-indexing vs LSP's 0-indexing
    lines = [(range["start"]["line"] + 1, range["end"]["line"] + 1)]
    return _format(document, lines=lines, options=options)


def get_style_config(document_path, options=None):
    # Exclude file if it follows the patterns for that
    exclude_patterns_from_ignore_file = file_resources.GetExcludePatternsForDir(
        os.getcwd()
    )
    if file_resources.IsIgnored(document_path, exclude_patterns_from_ignore_file):
        return []

    # 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 None:
        return style_config

    # 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

    return style_config


def diff_to_text_edits(diff, eol_chars):
    # To keep things simple our text edits will be line based.
    # We will also return the edits uncompacted, meaning a
    # line replacement will come in as a line remove followed
    # by a line add instead of a line replace.
    text_edits = []
    # keep track of line number since additions
    # don't include the line number it's being added
    # to in diffs. lsp is 0-indexed so we'll start with -1
    prev_line_no = -1

    for change in diff.changes:
        if change.old and change.new:
            # old and new are the same line, no change
            # diffs are 1-indexed
            prev_line_no = change.old - 1
        elif change.new:
            # addition
            text_edits.append(
                {
                    "range": {
                        "start": {"line": prev_line_no + 1, "character": 0},
                        "end": {"line": prev_line_no + 1, "character": 0},
                    },
                    "newText": change.line + eol_chars,
                }
            )
        elif change.old:
            # remove
            lsp_line_no = change.old - 1
            text_edits.append(
                {
                    "range": {
                        "start": {"line": lsp_line_no, "character": 0},
                        "end": {
                            # From LSP spec:
                            # If you want to specify a range that contains a line
                            # including the line ending character(s) then use an
                            # end position denoting the start of the next line.
                            "line": lsp_line_no + 1,
                            "character": 0,
                        },
                    },
                    "newText": "",
                }
            )
            prev_line_no = lsp_line_no

    return text_edits


def ensure_eof_new_line(document, eol_chars, text_edits):
    # diffs don't include EOF newline https://github.com/google/yapf/issues/1008
    # we'll add it ourselves if our document doesn't already have it and the diff
    # does not change the last line.
    if document.source.endswith(eol_chars):
        return

    lines = document.lines
    last_line_number = len(lines) - 1

    if text_edits and text_edits[-1]["range"]["start"]["line"] >= last_line_number:
        return

    text_edits.append(
        {
            "range": {
                "start": {"line": last_line_number, "character": 0},
                "end": {"line": last_line_number + 1, "character": 0},
            },
            "newText": lines[-1] + eol_chars,
        }
    )


def _format(document, lines=None, options=None):
    source = document.source
    # Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
    # and restore them below when adding new lines
    eol_chars = get_eol_chars(source)
    if eol_chars in ["\r", "\r\n"]:
        source = source.replace(eol_chars, "\n")
    else:
        eol_chars = "\n"

    style_config = get_style_config(document_path=document.path, options=options)

    diff_txt, changed = FormatCode(
        source,
        lines=lines,
        filename=document.filename,
        print_diff=True,
        style_config=style_config,
    )

    if not changed:
        return []

    patch_generator = whatthepatch.parse_patch(diff_txt)
    diff = next(patch_generator)
    patch_generator.close()

    text_edits = diff_to_text_edits(diff=diff, eol_chars=eol_chars)

    ensure_eof_new_line(document=document, eol_chars=eol_chars, text_edits=text_edits)

    return text_edits