Skip to content

Commit

Permalink
Range formatting support (#373)
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser authored Feb 5, 2024
1 parent 4c23bcb commit 80878b9
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 5 deletions.
65 changes: 62 additions & 3 deletions ruff_lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
TEXT_DOCUMENT_DID_SAVE,
TEXT_DOCUMENT_FORMATTING,
TEXT_DOCUMENT_HOVER,
TEXT_DOCUMENT_RANGE_FORMATTING,
AnnotatedTextEdit,
ClientCapabilities,
CodeAction,
Expand All @@ -49,6 +50,8 @@
DidSaveNotebookDocumentParams,
DidSaveTextDocumentParams,
DocumentFormattingParams,
DocumentRangeFormattingParams,
DocumentRangeFormattingRegistrationOptions,
Hover,
HoverParams,
InitializeParams,
Expand All @@ -63,13 +66,16 @@
NotebookDocumentSyncOptionsNotebookSelectorType2CellsType,
OptionalVersionedTextDocumentIdentifier,
Position,
PositionEncodingKind,
Range,
TextDocumentEdit,
TextDocumentFilter_Type1,
TextEdit,
WorkspaceEdit,
)
from packaging.specifiers import SpecifierSet, Version
from pygls import server, uris, workspace
from pygls.workspace.position_codec import PositionCodec
from typing_extensions import Literal, Self, TypedDict, assert_never

from ruff_lsp import __version__, utils
Expand Down Expand Up @@ -140,6 +146,7 @@ class VersionModified(NamedTuple):
# Require at least Ruff v0.0.291 for formatting, but allow older versions for linting.
VERSION_REQUIREMENT_FORMATTER = SpecifierSet(">=0.0.291")
VERSION_REQUIREMENT_LINTER = SpecifierSet(">=0.0.189")
VERSION_REQUIREMENT_RANGE_FORMATTING = SpecifierSet(">=0.2.1")
# Version requirement for use of the `--output-format` option
VERSION_REQUIREMENT_OUTPUT_FORMAT = SpecifierSet(">=0.0.291")
# Version requirement after which Ruff avoids writing empty output for excluded files.
Expand Down Expand Up @@ -1209,14 +1216,47 @@ async def apply_format(arguments: tuple[TextDocument]):

@LSP_SERVER.feature(TEXT_DOCUMENT_FORMATTING)
async def format_document(params: DocumentFormattingParams) -> list[TextEdit] | None:
return await _format_document_impl(params, None)


@LSP_SERVER.feature(
TEXT_DOCUMENT_RANGE_FORMATTING,
DocumentRangeFormattingRegistrationOptions(
document_selector=[
TextDocumentFilter_Type1(language="python", scheme="file"),
TextDocumentFilter_Type1(language="python", scheme="untitled"),
],
ranges_support=False,
work_done_progress=False,
),
)
async def format_document_range(
params: DocumentRangeFormattingParams,
) -> list[TextEdit] | None:
return await _format_document_impl(
DocumentFormattingParams(
params.text_document, params.options, params.work_done_token
),
params.range,
)


async def _format_document_impl(
params: DocumentFormattingParams, range: Range | None
) -> list[TextEdit] | None:
# For a Jupyter Notebook, this request can only format a single cell as the
# request itself can only act on a text document. A cell in a Notebook is
# represented as a text document. The "Notebook: Format notebook" action calls
# this request for every cell.
document = Document.from_cell_or_text_uri(params.text_document.uri)

settings = _get_settings_by_document(document.path)

result = await _run_format_on_document(document, settings)
# We don't support range formatting of notebooks yet but VS Code
# doesn't seem to respect the document filter. For now, format the entire cell.
range = None if document.kind is DocumentKind.Cell else range

result = await _run_format_on_document(document, settings, range)
if result is None:
return None

Expand Down Expand Up @@ -1375,6 +1415,7 @@ def _fixed_source_to_edits(
fixed_source = "".join(fixed_source)

new_source = _match_line_endings(original_source, fixed_source)

if new_source == original_source:
return []

Expand Down Expand Up @@ -1855,14 +1896,19 @@ async def _run_check_on_document(


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

executable = _find_ruff_binary(settings, VERSION_REQUIREMENT_FORMATTER)
version_requirement = (
VERSION_REQUIREMENT_FORMATTER
if format_range is None
else VERSION_REQUIREMENT_RANGE_FORMATTING
)
executable = _find_ruff_binary(settings, version_requirement)
argv: list[str] = [
"format",
"--force-exclude",
Expand All @@ -1871,6 +1917,19 @@ async def _run_format_on_document(
document.path,
]

if format_range:
codec = PositionCodec(PositionEncodingKind.Utf16)
format_range = codec.range_from_client_units(
document.source.splitlines(True), format_range
)

argv.extend(
[
"--range",
f"{format_range.start.line + 1}:{format_range.start.character + 1}-{format_range.end.line + 1}:{format_range.end.character + 1}", # noqa: E501
]
)

for arg in settings.get("format", {}).get("args", []):
if arg in UNSUPPORTED_FORMAT_ARGS:
log_to_output(f"Ignoring unsupported argument: {arg}")
Expand Down
52 changes: 50 additions & 2 deletions tests/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from ruff_lsp.server import (
VERSION_REQUIREMENT_FORMATTER,
VERSION_REQUIREMENT_RANGE_FORMATTING,
Document,
_fixed_source_to_edits,
_get_settings_by_document,
Expand Down Expand Up @@ -41,7 +42,7 @@ async def test_format(tmp_path, ruff_version: Version):
)

with handle_unsupported:
result = await _run_format_on_document(document, settings)
result = await _run_format_on_document(document, settings, None)
assert result is not None
assert result.exit_code == 0
[edit] = _fixed_source_to_edits(
Expand Down Expand Up @@ -74,6 +75,53 @@ async def test_format_code_with_syntax_error(tmp_path, ruff_version: Version):
)

with handle_unsupported:
result = await _run_format_on_document(document, settings)
result = await _run_format_on_document(document, settings, None)
assert result is not None
assert result.exit_code == 2


@pytest.mark.asyncio
async def test_format_range(tmp_path, ruff_version: Version):
original = """x = 1
print( "Formatted")
print ("Not formatted")
"""

expected = """print("Formatted")\n"""

test_file = tmp_path.joinpath("main.py")
test_file.write_text(original)
uri = utils.as_uri(str(test_file))

workspace = Workspace(str(tmp_path))
document = Document.from_text_document(workspace.get_text_document(uri))
settings = _get_settings_by_document(document.path)

handle_unsupported = (
pytest.raises(RuntimeError, match=f"Ruff .* required, but found {ruff_version}")
if not VERSION_REQUIREMENT_RANGE_FORMATTING.contains(ruff_version)
else nullcontext()
)

with handle_unsupported:
result = await _run_format_on_document(
document,
settings,
Range(
start=Position(line=1, character=0),
end=(Position(line=4, character=19)),
),
)
assert result is not None
assert result.exit_code == 0
[edit] = _fixed_source_to_edits(
original_source=document.source, fixed_source=result.stdout.decode("utf-8")
)
assert edit.new_text == expected
assert edit.range == Range(
start=Position(line=3, character=0), end=Position(line=5, character=0)
)

0 comments on commit 80878b9

Please sign in to comment.