Skip to content

Commit 608c698

Browse files
yileikarthiknadig
andauthored
Support range formatting when using Black 23.11.0+. (#380)
Co-authored-by: Karthik Nadig <kanadig@microsoft.com>
1 parent 2c74263 commit 608c698

File tree

8 files changed

+165
-14
lines changed

8 files changed

+165
-14
lines changed

bundled/tool/lsp_server.py

+68-13
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import sys
1313
import sysconfig
1414
import traceback
15-
from typing import Any, Dict, List, Optional, Sequence
15+
from typing import Any, Dict, List, Optional, Sequence, Tuple
1616

1717

1818
# **********************************************************
@@ -89,6 +89,12 @@ def update_environ_path() -> None:
8989
# Minimum version of black supported.
9090
MIN_VERSION = "22.3.0"
9191

92+
# Minimum version of black that supports the `--line-ranges` CLI option.
93+
LINE_RANGES_MIN_VERSION = (23, 11, 0)
94+
95+
# Versions of black found by workspace
96+
VERSION_LOOKUP: Dict[str, Tuple[int, int, int]] = {}
97+
9298
# **********************************************************
9399
# Formatting features start here
94100
# **********************************************************
@@ -102,13 +108,52 @@ def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | Non
102108
return _formatting_helper(document)
103109

104110

105-
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_RANGE_FORMATTING)
106-
def range_formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | None:
107-
"""LSP handler for textDocument/formatting request."""
111+
@LSP_SERVER.feature(
112+
lsp.TEXT_DOCUMENT_RANGE_FORMATTING,
113+
lsp.DocumentRangeFormattingOptions(ranges_support=True),
114+
)
115+
def range_formatting(
116+
params: lsp.DocumentRangeFormattingParams,
117+
) -> list[lsp.TextEdit] | None:
118+
"""LSP handler for textDocument/rangeFormatting request."""
119+
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
120+
settings = _get_settings_by_document(document)
121+
version = VERSION_LOOKUP[settings["workspaceFS"]]
122+
123+
if version >= LINE_RANGES_MIN_VERSION:
124+
return _formatting_helper(
125+
document,
126+
args=[
127+
"--line-ranges",
128+
f"{params.range.start.line + 1}-{params.range.end.line + 1}",
129+
],
130+
)
131+
else:
132+
log_warning(
133+
"Black version earlier than 23.11.0 does not support range formatting. Formatting entire document."
134+
)
135+
return _formatting_helper(document)
136+
108137

109-
log_warning("Black does not support range formatting. Formatting entire document.")
138+
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_RANGES_FORMATTING)
139+
def ranges_formatting(
140+
params: lsp.DocumentRangesFormattingParams,
141+
) -> list[lsp.TextEdit] | None:
142+
"""LSP handler for textDocument/rangesFormatting request."""
110143
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
111-
return _formatting_helper(document)
144+
settings = _get_settings_by_document(document)
145+
version = VERSION_LOOKUP[settings["workspaceFS"]]
146+
147+
if version >= LINE_RANGES_MIN_VERSION:
148+
args = []
149+
for r in params.ranges:
150+
args += ["--line-ranges", f"{r.start.line + 1}-{r.end.line + 1}"]
151+
return _formatting_helper(document, args=args)
152+
else:
153+
log_warning(
154+
"Black version earlier than 23.11.0 does not support range formatting. Formatting entire document."
155+
)
156+
return _formatting_helper(document)
112157

113158

114159
def is_python(code: str, file_path: str) -> bool:
@@ -121,8 +166,11 @@ def is_python(code: str, file_path: str) -> bool:
121166
return True
122167

123168

124-
def _formatting_helper(document: workspace.Document) -> list[lsp.TextEdit] | None:
125-
extra_args = _get_args_by_file_extension(document)
169+
def _formatting_helper(
170+
document: workspace.Document, args: Sequence[str] = None
171+
) -> list[lsp.TextEdit] | None:
172+
args = [] if args is None else args
173+
extra_args = args + _get_args_by_file_extension(document)
126174
extra_args += ["--stdin-filename", _get_filename_for_black(document)]
127175
result = _run_tool_on_document(document, use_stdin=True, extra_args=extra_args)
128176
if result and result.stdout:
@@ -226,8 +274,6 @@ def initialize(params: lsp.InitializeParams) -> None:
226274
paths = "\r\n ".join(sys.path)
227275
log_to_output(f"sys.path used to run Server:\r\n {paths}")
228276

229-
_log_version_info()
230-
231277

232278
@LSP_SERVER.feature(lsp.EXIT)
233279
def on_exit(_params: Optional[Any] = None) -> None:
@@ -241,12 +287,13 @@ def on_shutdown(_params: Optional[Any] = None) -> None:
241287
jsonrpc.shutdown_json_rpc()
242288

243289

244-
def _log_version_info() -> None:
245-
for value in WORKSPACE_SETTINGS.values():
290+
def _update_workspace_settings_with_version_info(
291+
workspace_settings: dict[str, Any]
292+
) -> None:
293+
for settings in workspace_settings.values():
246294
try:
247295
from packaging.version import parse as parse_version
248296

249-
settings = copy.deepcopy(value)
250297
result = _run_tool(["--version"], settings)
251298
code_workspace = settings["workspaceFS"]
252299
log_to_output(
@@ -269,6 +316,11 @@ def _log_version_info() -> None:
269316

270317
version = parse_version(actual_version)
271318
min_version = parse_version(MIN_VERSION)
319+
VERSION_LOOKUP[code_workspace] = (
320+
version.major,
321+
version.minor,
322+
version.micro,
323+
)
272324

273325
if version < min_version:
274326
log_error(
@@ -281,6 +333,7 @@ def _log_version_info() -> None:
281333
f"SUPPORTED {TOOL_MODULE}>={min_version}\r\n"
282334
f"FOUND {TOOL_MODULE}=={actual_version}\r\n"
283335
)
336+
284337
except: # pylint: disable=bare-except
285338
log_to_output(
286339
f"Error while detecting black version:\r\n{traceback.format_exc()}"
@@ -318,6 +371,8 @@ def _update_workspace_settings(settings):
318371
"workspaceFS": key,
319372
}
320373

374+
_update_workspace_settings_with_version_info(WORKSPACE_SETTINGS)
375+
321376

322377
def _get_settings_by_path(file_path: pathlib.Path):
323378
workspaces = {s["workspaceFS"] for s in WORKSPACE_SETTINGS.values()}

noxfile.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,13 @@ def lint(session: nox.Session) -> None:
152152
# check formatting using black
153153
session.install("black")
154154
session.run("black", "--check", "./bundled/tool")
155-
session.run("black", "--check", "./src/test/python_tests")
155+
session.run(
156+
"black",
157+
"--check",
158+
"./src/test/python_tests",
159+
"--exclude",
160+
"test_data",
161+
)
156162
session.run("black", "--check", "noxfile.py")
157163

158164
# check typescript code

src/test/python_tests/lsp_test_client/session.py

+14
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,20 @@ def text_document_formatting(self, formatting_params):
143143
fut = self._send_request("textDocument/formatting", params=formatting_params)
144144
return fut.result()
145145

146+
def text_document_range_formatting(self, range_formatting_params):
147+
"""Sends text document references request to LSP server."""
148+
fut = self._send_request(
149+
"textDocument/rangeFormatting", params=range_formatting_params
150+
)
151+
return fut.result()
152+
153+
def text_document_ranges_formatting(self, ranges_formatting_params):
154+
"""Sends text document references request to LSP server."""
155+
fut = self._send_request(
156+
"textDocument/rangesFormatting", params=ranges_formatting_params
157+
)
158+
return fut.result()
159+
146160
def set_notification_callback(self, notification_name, callback):
147161
"""Set custom LS notification handler."""
148162
self._notification_callbacks[notification_name] = callback
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
x = [1, 2, 3, 4, 5]
2+
y = [1,2,3,4,5]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
x = [1,2,3,4,5]
2+
y = [1,2,3,4,5]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
w = [1, 2, 3, 4, 5]
2+
x = [1,2,3,4,5]
3+
y = [1, 2, 3, 4, 5]
4+
z = [1,2,3,4,5]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
w = [1,2,3,4,5]
2+
x = [1,2,3,4,5]
3+
y = [1,2,3,4,5]
4+
z = [1,2,3,4,5]

src/test/python_tests/test_formatting.py

+64
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,67 @@ def test_skipping_site_packages_files():
121121

122122
expected = None
123123
assert_that(actual, is_(expected))
124+
125+
126+
@pytest.mark.parametrize(
127+
"sample, ranges", [("sample4", "single-range"), ("sample5", "multi-range")]
128+
)
129+
def test_range_formatting(sample: str, ranges: str):
130+
"""Test formatting a python file."""
131+
FORMATTED_TEST_FILE_PATH = constants.TEST_DATA / sample / "sample.py"
132+
UNFORMATTED_TEST_FILE_PATH = constants.TEST_DATA / sample / "sample.unformatted"
133+
134+
contents = UNFORMATTED_TEST_FILE_PATH.read_text(encoding="utf-8")
135+
lines = contents.splitlines()
136+
137+
actual = []
138+
with utils.python_file(contents, UNFORMATTED_TEST_FILE_PATH.parent) as pf:
139+
uri = utils.as_uri(str(pf))
140+
141+
with session.LspSession() as ls_session:
142+
ls_session.initialize()
143+
ls_session.notify_did_open(
144+
{
145+
"textDocument": {
146+
"uri": uri,
147+
"languageId": "python",
148+
"version": 1,
149+
"text": contents,
150+
}
151+
}
152+
)
153+
154+
if ranges == "single-range":
155+
actual = ls_session.text_document_range_formatting(
156+
{
157+
"textDocument": {"uri": uri},
158+
# `options` is not used by black
159+
"options": {"tabSize": 4, "insertSpaces": True},
160+
"range": {
161+
"start": {"line": 0, "character": 0},
162+
"end": {"line": 0, "character": len(lines[0])},
163+
},
164+
}
165+
)
166+
else:
167+
actual = ls_session.text_document_ranges_formatting(
168+
{
169+
"textDocument": {"uri": uri},
170+
# `options` is not used by black
171+
"options": {"tabSize": 4, "insertSpaces": True},
172+
"ranges": [
173+
{
174+
"start": {"line": 0, "character": 0},
175+
"end": {"line": 0, "character": len(lines[0])},
176+
},
177+
{
178+
"start": {"line": 2, "character": 0},
179+
"end": {"line": 2, "character": len(lines[2])},
180+
},
181+
],
182+
}
183+
)
184+
185+
expected_text = FORMATTED_TEST_FILE_PATH.read_text(encoding="utf-8")
186+
actual_text = utils.apply_text_edits(contents, utils.destructure_text_edits(actual))
187+
assert_that(actual_text, is_(expected_text))

0 commit comments

Comments
 (0)