Skip to content
This repository was archived by the owner on Dec 16, 2025. It is now read-only.

Commit 0490f62

Browse files
committed
Range formatting support
1 parent 2e67c8e commit 0490f62

2 files changed

Lines changed: 117 additions & 6 deletions

File tree

ruff_lsp/server.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
TEXT_DOCUMENT_DID_SAVE,
3232
TEXT_DOCUMENT_FORMATTING,
3333
TEXT_DOCUMENT_HOVER,
34+
TEXT_DOCUMENT_RANGE_FORMATTING,
3435
AnnotatedTextEdit,
3536
ClientCapabilities,
3637
CodeAction,
@@ -50,6 +51,8 @@
5051
DidSaveNotebookDocumentParams,
5152
DidSaveTextDocumentParams,
5253
DocumentFormattingParams,
54+
DocumentRangeFormattingParams,
55+
DocumentRangeFormattingRegistrationOptions,
5356
Hover,
5457
HoverParams,
5558
InitializeParams,
@@ -64,13 +67,16 @@
6467
NotebookDocumentSyncOptionsNotebookSelectorType2CellsType,
6568
OptionalVersionedTextDocumentIdentifier,
6669
Position,
70+
PositionEncodingKind,
6771
Range,
6872
TextDocumentEdit,
73+
TextDocumentFilter_Type1,
6974
TextEdit,
7075
WorkspaceEdit,
7176
)
7277
from packaging.specifiers import SpecifierSet, Version
7378
from pygls import server, uris, workspace
79+
from pygls.workspace.position_codec import PositionCodec
7480
from typing_extensions import Literal, Self, TypedDict, assert_never
7581

7682
from ruff_lsp import __version__, utils
@@ -141,6 +147,7 @@ class VersionModified(NamedTuple):
141147
# Require at least Ruff v0.0.291 for formatting, but allow older versions for linting.
142148
VERSION_REQUIREMENT_FORMATTER = SpecifierSet(">=0.0.291,<0.2.0")
143149
VERSION_REQUIREMENT_LINTER = SpecifierSet(">=0.0.189,<0.2.0")
150+
VERSION_REQUIREMENT_RANGE_FORMATTING = SpecifierSet(">=0.2.0")
144151
# Version requirement for use of the `--output-format` option
145152
VERSION_REQUIREMENT_OUTPUT_FORMAT = SpecifierSet(">=0.0.291,<0.2.0")
146153
# Version requirement after which Ruff avoids writing empty output for excluded files.
@@ -1209,15 +1216,21 @@ async def apply_format(arguments: tuple[TextDocument]):
12091216

12101217

12111218
@LSP_SERVER.feature(TEXT_DOCUMENT_FORMATTING)
1212-
async def format_document(params: DocumentFormattingParams) -> list[TextEdit] | None:
1219+
async def format_document(
1220+
params: DocumentFormattingParams, range: Range | None = None
1221+
) -> list[TextEdit] | None:
12131222
# For a Jupyter Notebook, this request can only format a single cell as the
12141223
# request itself can only act on a text document. A cell in a Notebook is
12151224
# represented as a text document. The "Notebook: Format notebook" action calls
12161225
# this request for every cell.
12171226
document = Document.from_cell_or_text_uri(params.text_document.uri)
12181227
settings = _get_settings_by_document(document.path)
12191228

1220-
result = await _run_format_on_document(document, settings)
1229+
# We don't support range formatting of notebooks yet but VS Code
1230+
# doesn't seem to respect the document filter. For now, format the entire cell.
1231+
range = None if document.kind is DocumentKind.Cell else range
1232+
1233+
result = await _run_format_on_document(document, settings, range)
12211234
if result is None:
12221235
return None
12231236

@@ -1241,6 +1254,28 @@ async def format_document(params: DocumentFormattingParams) -> list[TextEdit] |
12411254
)
12421255

12431256

1257+
@LSP_SERVER.feature(
1258+
TEXT_DOCUMENT_RANGE_FORMATTING,
1259+
DocumentRangeFormattingRegistrationOptions(
1260+
document_selector=[
1261+
TextDocumentFilter_Type1(language="python", scheme="file"),
1262+
TextDocumentFilter_Type1(language="python", scheme="untitled"),
1263+
],
1264+
ranges_support=False,
1265+
work_done_progress=False,
1266+
),
1267+
)
1268+
async def format_document_range(
1269+
params: DocumentRangeFormattingParams,
1270+
) -> list[TextEdit] | None:
1271+
return await format_document(
1272+
DocumentFormattingParams(
1273+
params.text_document, params.options, params.work_done_token
1274+
),
1275+
params.range,
1276+
)
1277+
1278+
12441279
async def _fix_document_impl(
12451280
document: Document,
12461281
settings: WorkspaceSettings,
@@ -1824,14 +1859,19 @@ async def _run_check_on_document(
18241859

18251860

18261861
async def _run_format_on_document(
1827-
document: Document, settings: WorkspaceSettings
1862+
document: Document, settings: WorkspaceSettings, format_range: Range | None = None
18281863
) -> ExecutableResult | None:
18291864
"""Runs the Ruff `format` subcommand on the given document source."""
18301865
if settings.get("ignoreStandardLibrary", True) and document.is_stdlib_file():
18311866
log_warning(f"Skipping standard library file: {document.path}")
18321867
return None
18331868

1834-
executable = _find_ruff_binary(settings, VERSION_REQUIREMENT_FORMATTER)
1869+
version_requirement = (
1870+
VERSION_REQUIREMENT_FORMATTER
1871+
if format_range is None
1872+
else VERSION_REQUIREMENT_RANGE_FORMATTING
1873+
)
1874+
executable = _find_ruff_binary(settings, version_requirement)
18351875
argv: list[str] = [
18361876
"format",
18371877
"--force-exclude",
@@ -1840,6 +1880,25 @@ async def _run_format_on_document(
18401880
document.path,
18411881
]
18421882

1883+
if format_range:
1884+
# Convert the start and end to character offsets instead of line:column
1885+
lines = document.source.splitlines(True)
1886+
codec = PositionCodec(PositionEncodingKind.Utf16)
1887+
range = codec.range_from_client_units(lines, format_range)
1888+
start = format_range.start.character
1889+
end = format_range.end.character
1890+
1891+
for index, line in enumerate(lines):
1892+
if index < range.start.line:
1893+
start += len(line)
1894+
1895+
if index < range.end.line:
1896+
end += len(line)
1897+
else:
1898+
break
1899+
1900+
argv.extend(["--range-start", str(start), "--range-end", str(end)])
1901+
18431902
for arg in settings.get("format", {}).get("args", []):
18441903
if arg in UNSUPPORTED_FORMAT_ARGS:
18451904
log_to_output(f"Ignoring unsupported argument: {arg}")

tests/test_format.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from contextlib import nullcontext
44

55
import pytest
6+
from lsprotocol.types import Position, Range
67
from packaging.version import Version
78
from pygls.workspace import Workspace
89

910
from ruff_lsp.server import (
1011
VERSION_REQUIREMENT_FORMATTER,
12+
VERSION_REQUIREMENT_RANGE_FORMATTING,
1113
Document,
1214
_fixed_source_to_edits,
1315
_get_settings_by_document,
@@ -40,7 +42,7 @@ async def test_format(tmp_path, ruff_version: Version):
4042
)
4143

4244
with handle_unsupported:
43-
result = await _run_format_on_document(document, settings)
45+
result = await _run_format_on_document(document, settings, None)
4446
assert result is not None
4547
assert result.exit_code == 0
4648
[edit] = _fixed_source_to_edits(
@@ -70,6 +72,56 @@ async def test_format_code_with_syntax_error(tmp_path, ruff_version: Version):
7072
)
7173

7274
with handle_unsupported:
73-
result = await _run_format_on_document(document, settings)
75+
result = await _run_format_on_document(document, settings, None)
7476
assert result is not None
7577
assert result.exit_code == 2
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_format_range(tmp_path, ruff_version: Version):
82+
original = """x = 1
83+
84+
85+
86+
print( "Formatted")
87+
88+
print ("Not formatted")
89+
"""
90+
91+
expected = """x = 1
92+
93+
94+
print("Formatted")
95+
96+
print ("Not formatted")
97+
"""
98+
99+
test_file = tmp_path.joinpath("main.py")
100+
test_file.write_text(original)
101+
uri = utils.as_uri(str(test_file))
102+
103+
workspace = Workspace(str(tmp_path))
104+
document = Document.from_text_document(workspace.get_text_document(uri))
105+
settings = _get_settings_by_document(document.path)
106+
107+
handle_unsupported = (
108+
pytest.raises(RuntimeError, match=f"Ruff .* required, but found {ruff_version}")
109+
if not VERSION_REQUIREMENT_RANGE_FORMATTING.contains(ruff_version)
110+
else nullcontext()
111+
)
112+
113+
with handle_unsupported:
114+
result = await _run_format_on_document(
115+
document,
116+
settings,
117+
Range(
118+
start=Position(line=1, character=0),
119+
end=(Position(line=4, character=19)),
120+
),
121+
)
122+
assert result is not None
123+
assert result.exit_code == 0
124+
[edit] = _fixed_source_to_edits(
125+
original_source=document.source, fixed_source=result.stdout.decode("utf-8")
126+
)
127+
assert edit.new_text == expected

0 commit comments

Comments
 (0)