From 80878b947fc5c194c5d5301ddfe69f98fef0114c Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 5 Feb 2024 20:20:58 +0100 Subject: [PATCH] Range formatting support (#373) --- ruff_lsp/server.py | 65 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_format.py | 52 +++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/ruff_lsp/server.py b/ruff_lsp/server.py index 35a6ab7..0d204cb 100755 --- a/ruff_lsp/server.py +++ b/ruff_lsp/server.py @@ -30,6 +30,7 @@ TEXT_DOCUMENT_DID_SAVE, TEXT_DOCUMENT_FORMATTING, TEXT_DOCUMENT_HOVER, + TEXT_DOCUMENT_RANGE_FORMATTING, AnnotatedTextEdit, ClientCapabilities, CodeAction, @@ -49,6 +50,8 @@ DidSaveNotebookDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, + DocumentRangeFormattingParams, + DocumentRangeFormattingRegistrationOptions, Hover, HoverParams, InitializeParams, @@ -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 @@ -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. @@ -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 @@ -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 [] @@ -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", @@ -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}") diff --git a/tests/test_format.py b/tests/test_format.py index 21ec62e..153e37e 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -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, @@ -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( @@ -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) + )