diff --git a/examples/servers/.vscode/settings.json b/examples/servers/.vscode/settings.json index c2b0d23b..2637154c 100644 --- a/examples/servers/.vscode/settings.json +++ b/examples/servers/.vscode/settings.json @@ -1,21 +1,25 @@ { + "[plaintext]": { + // Uncomment to enable `textDocument/onTypeFormatting` requests + //"editor.formatOnType": true + }, // Uncomment to override Python interpreter used. // "pygls.server.pythonPath": "/path/to/python", "pygls.server.debug": false, // "pygls.server.debugHost": "localhost", // "pygls.server.debugPort": 5678, - "pygls.server.launchScript": "json_server.py", + "pygls.server.launchScript": "formatting.py", "pygls.trace.server": "off", "pygls.client.documentSelector": [ - { - "scheme": "file", - "language": "json" - } - // Uncomment to use code_actions or inlay_hints servers // { // "scheme": "file", - // "language": "plaintext" + // "language": "json" // } + // Uncomment to use code_actions or inlay_hints servers + { + "scheme": "file", + "language": "plaintext" + } ], // "pygls.jsonServer.exampleConfiguration": "some value here", } diff --git a/examples/servers/README.md b/examples/servers/README.md index c6baea41..97d299b7 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -5,8 +5,14 @@ | `code_actions.py` | `sums.txt` | Evaluate sums via a code action | | `code_lens.py` | `sums.txt` | Evaluate sums via a code lens | | `colors.py` | `colors.txt` | Provides a visual representation of color values and even a color picker in supported clients | +| `formatting.py`| `table.txt`| Implements whole document, selection only and as-you-type formatting for markdown like tables [^1] [^2] | | `goto.py` | `code.txt` | Implements the various "Goto X" requests in the specification | | `hover.py` | `dates.txt` | Opens a popup showing the date underneath the cursor in multiple formats | | `inlay_hints.py` | `sums.txt` | Use inlay hints to show the binary representation of numbers in the file | | `publish_diagnostics.py` | `sums.txt` | Use "push-model" diagnostics to highlight missing or incorrect answers | | `pull_diagnostics.py` | `sums.txt` | Use "pull-model" diagnostics to highlight missing or incorrect answers | + + +[^1]: To enable as-you-type formatting, be sure to uncomment the `editor.formatOnType` option in `.vscode/settings.json` + +[^2]: This server is enough to demonstrate the bare minimum required to implement these methods be sure to check the contents of the `params` object for all the additional options you shoud be considering! diff --git a/examples/servers/formatting.py b/examples/servers/formatting.py new file mode 100644 index 00000000..85a3af0b --- /dev/null +++ b/examples/servers/formatting.py @@ -0,0 +1,178 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +import logging +from typing import Dict +from typing import List +from typing import Optional + +import attrs +from lsprotocol import types + +from pygls.server import LanguageServer +from pygls.workspace import TextDocument + + +@attrs.define +class Row: + """Represents a row in the table""" + + cells: List[str] + cell_widths: List[int] + line_number: int + + +server = LanguageServer("formatting-server", "v1") + + +@server.feature(types.TEXT_DOCUMENT_FORMATTING) +def format_document(ls: LanguageServer, params: types.DocumentFormattingParams): + """Format the entire document""" + logging.debug("%s", params) + + doc = ls.workspace.get_text_document(params.text_document.uri) + rows = parse_document(doc) + return format_table(rows) + + +@server.feature(types.TEXT_DOCUMENT_RANGE_FORMATTING) +def format_range(ls: LanguageServer, params: types.DocumentRangeFormattingParams): + """Format the given range within a document""" + logging.debug("%s", params) + + doc = ls.workspace.get_text_document(params.text_document.uri) + rows = parse_document(doc, params.range) + return format_table(rows, params.range) + + +@server.feature( + types.TEXT_DOCUMENT_ON_TYPE_FORMATTING, + types.DocumentOnTypeFormattingOptions(first_trigger_character="|"), +) +def format_on_type(ls: LanguageServer, params: types.DocumentOnTypeFormattingParams): + """Format the document while the user is typing""" + logging.debug("%s", params) + + doc = ls.workspace.get_text_document(params.text_document.uri) + rows = parse_document(doc) + return format_table(rows) + + +def format_table( + rows: List[Row], range_: Optional[types.Range] = None +) -> List[types.TextEdit]: + """Format the given table, returning the list of edits to make to the document. + + If range is given, this method will only modify the document within the specified + range. + """ + edits: List[types.TextEdit] = [] + + # Determine max widths + columns: Dict[int, int] = {} + for row in rows: + for idx, cell in enumerate(row.cells): + columns[idx] = max(len(cell), columns.get(idx, 0)) + + # Format the table. + cell_padding = 2 + for row in rows: + # Only process the lines within the specified range. + if skip_line(row.line_number, range_): + continue + + if len(row.cells) == 0: + # If there are no cells on the row, then this must be a separator row + cells: List[str] = [] + empty_cells = [ + "-" * (columns[i] + cell_padding) for i in range(len(columns)) + ] + else: + # Otherwise ensure that each row has a consistent number of cells + empty_cells = [" " for _ in range(len(columns) - len(row.cells))] + cells = [ + c.center(columns[i] + cell_padding) for i, c in enumerate(row.cells) + ] + + line = f"|{'|'.join([*cells, *empty_cells])}|\n" + edits.append( + types.TextEdit( + range=types.Range( + start=types.Position(line=row.line_number, character=0), + end=types.Position(line=row.line_number + 1, character=0), + ), + new_text=line, + ) + ) + + return edits + + +def parse_document( + document: TextDocument, range_: Optional[types.Range] = None +) -> List[Row]: + """Parse the given document into a list of table rows. + + If range_ is given, only consider lines within the range part of the table. + """ + rows: List[Row] = [] + for linum, line in enumerate(document.lines): + if skip_line(linum, range_): + continue + + line = line.strip() + cells = [c.strip() for c in line.split("|")] + + if line.startswith("|"): + cells.pop(0) + + if line.endswith("|"): + cells.pop(-1) + + chars = set() + for c in cells: + chars.update(set(c)) + + logging.debug("%s: %s", chars, cells) + + if chars == {"-"}: + # Check for a separator row, use an empty list to represent it. + cells = [] + + elif len(cells) == 0: + continue + + row = Row(cells=cells, line_number=linum, cell_widths=[len(c) for c in cells]) + + logging.debug("%s", row) + rows.append(row) + + return rows + + +def skip_line(line: int, range_: Optional[types.Range]) -> bool: + """Given a range, determine if we should skip the given line number.""" + + if range_ is None: + return False + + return any([line < range_.start.line, line > range_.end.line]) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + + server.start_io() diff --git a/examples/servers/workspace/table.txt b/examples/servers/workspace/table.txt new file mode 100644 index 00000000..8b6ea7c9 --- /dev/null +++ b/examples/servers/workspace/table.txt @@ -0,0 +1,3 @@ +|a|b| +|-|-| +|apple|banana| diff --git a/tests/e2e/test_formatting.py b/tests/e2e/test_formatting.py new file mode 100644 index 00000000..11833eb8 --- /dev/null +++ b/tests/e2e/test_formatting.py @@ -0,0 +1,191 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +from __future__ import annotations + +import typing + +import pytest +import pytest_asyncio +from lsprotocol import types + +if typing.TYPE_CHECKING: + from typing import Tuple + + from pygls.lsp.client import BaseLanguageClient + + +@pytest_asyncio.fixture(scope="module") +async def formatting(get_client_for): + async for result in get_client_for("formatting.py"): + yield result + + +def range_from_str(range_: str) -> types.Range: + start, end = range_.split("-") + start_line, start_char = start.split(":") + end_line, end_char = end.split(":") + + return types.Range( + start=types.Position(line=int(start_line), character=int(start_char)), + end=types.Position(line=int(end_line), character=int(end_char)), + ) + + +@pytest.mark.asyncio(scope="module") +async def test_document_format( + formatting: Tuple[BaseLanguageClient, types.InitializeResult], uri_for +): + """Ensure that the example document format server method is working as expected.""" + client, initialize_result = formatting + + format_options = initialize_result.capabilities.document_formatting_provider + assert format_options is True + + test_uri = uri_for("table.txt") + response = await client.text_document_formatting_async( + types.DocumentFormattingParams( + text_document=types.TextDocumentIdentifier(uri=test_uri), + options=types.FormattingOptions(tab_size=4, insert_spaces=True), + ) + ) + + assert response == [ + types.TextEdit( + new_text="| a | b |\n", range=range_from_str("0:0-1:0") + ), + types.TextEdit( + new_text="|-------|--------|\n", range=range_from_str("1:0-2:0") + ), + types.TextEdit( + new_text="| apple | banana |\n", range=range_from_str("2:0-3:0") + ), + ] + + +@pytest.mark.asyncio(scope="module") +async def test_document_range_format_one( + formatting: Tuple[BaseLanguageClient, types.InitializeResult], uri_for +): + """Ensure that the example range format server method is working as expected.""" + client, initialize_result = formatting + + format_options = initialize_result.capabilities.document_range_formatting_provider + assert format_options is True + + test_uri = uri_for("table.txt") + response = await client.text_document_range_formatting_async( + types.DocumentRangeFormattingParams( + text_document=types.TextDocumentIdentifier(uri=test_uri), + options=types.FormattingOptions(tab_size=4, insert_spaces=True), + range=range_from_str("0:0-1:5"), + ) + ) + + assert response == [ + types.TextEdit(new_text="| a | b |\n", range=range_from_str("0:0-1:0")), + types.TextEdit(new_text="|---|---|\n", range=range_from_str("1:0-2:0")), + ] + + +@pytest.mark.asyncio(scope="module") +async def test_document_range_format_two( + formatting: Tuple[BaseLanguageClient, types.InitializeResult], uri_for +): + """Ensure that the example range format server method is working as expected.""" + client, initialize_result = formatting + + format_options = initialize_result.capabilities.document_range_formatting_provider + assert format_options is True + + test_uri = uri_for("table.txt") + response = await client.text_document_range_formatting_async( + types.DocumentRangeFormattingParams( + text_document=types.TextDocumentIdentifier(uri=test_uri), + options=types.FormattingOptions(tab_size=4, insert_spaces=True), + range=range_from_str("1:0-2:14"), + ) + ) + + assert response == [ + types.TextEdit( + new_text="|-------|--------|\n", range=range_from_str("1:0-2:0") + ), + types.TextEdit( + new_text="| apple | banana |\n", range=range_from_str("2:0-3:0") + ), + ] + + +@pytest.mark.asyncio(scope="module") +async def test_document_on_type_format( + formatting: Tuple[BaseLanguageClient, types.InitializeResult], path_for, uri_for +): + """Ensure that the example on type format server method is working as expected.""" + client, initialize_result = formatting + + format_options = initialize_result.capabilities.document_on_type_formatting_provider + assert format_options == types.DocumentOnTypeFormattingOptions( + first_trigger_character="|" + ) + + test_path = path_for("table.txt") + test_uri = uri_for("table.txt") + + # Open and replace the contents of the table.txt file. + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + uri=test_uri, version=1 + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text="|header one| header two |\n|-|", + range=range_from_str("0:0-3:0"), + ) + ], + ) + ) + + # Ask the server to format the recently typed text. + response = await client.text_document_on_type_formatting_async( + types.DocumentOnTypeFormattingParams( + text_document=types.TextDocumentIdentifier(uri=test_uri), + position=types.Position(line=1, character=2), + ch="|", + options=types.FormattingOptions(tab_size=4, insert_spaces=True), + ) + ) + + assert response == [ + types.TextEdit( + new_text="| header one | header two |\n", range=range_from_str("0:0-1:0") + ), + types.TextEdit( + new_text="|------------|------------|\n", range=range_from_str("1:0-2:0") + ), + ] diff --git a/tests/lsp/test_formatting.py b/tests/lsp/test_formatting.py deleted file mode 100644 index 0c3fbd66..00000000 --- a/tests/lsp/test_formatting.py +++ /dev/null @@ -1,108 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ - -from typing import List, Optional - -from lsprotocol.types import TEXT_DOCUMENT_FORMATTING -from lsprotocol.types import ( - DocumentFormattingOptions, - DocumentFormattingParams, - FormattingOptions, - Position, - Range, - TextDocumentIdentifier, - TextEdit, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_FORMATTING, - DocumentFormattingOptions(), - ) - def f(params: DocumentFormattingParams) -> Optional[List[TextEdit]]: - if params.text_document.uri == "file://return.list": - return [ - TextEdit( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - new_text="text", - ) - ] - else: - return None - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.document_formatting_provider - - -@ConfiguredLS.decorate() -def test_document_formatting_return_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_FORMATTING, - DocumentFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.list"), - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response - - assert response[0].new_text == "text" - assert response[0].range.start.line == 0 - assert response[0].range.start.character == 0 - assert response[0].range.end.line == 1 - assert response[0].range.end.character == 1 - - -@ConfiguredLS.decorate() -def test_document_formatting_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_FORMATTING, - DocumentFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response is None diff --git a/tests/lsp/test_on_type_formatting.py b/tests/lsp/test_on_type_formatting.py deleted file mode 100644 index 2e7adc9b..00000000 --- a/tests/lsp/test_on_type_formatting.py +++ /dev/null @@ -1,122 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ - -from typing import List, Optional - -from lsprotocol.types import TEXT_DOCUMENT_ON_TYPE_FORMATTING -from lsprotocol.types import ( - DocumentOnTypeFormattingOptions, - DocumentOnTypeFormattingParams, - FormattingOptions, - Position, - Range, - TextDocumentIdentifier, - TextEdit, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_ON_TYPE_FORMATTING, - DocumentOnTypeFormattingOptions( - first_trigger_character=":", - more_trigger_character=[",", "."], - ), - ) - def f(params: DocumentOnTypeFormattingParams) -> Optional[List[TextEdit]]: - if params.text_document.uri == "file://return.list": - return [ - TextEdit( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - new_text="text", - ) - ] - else: - return None - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.document_on_type_formatting_provider - assert ( - capabilities.document_on_type_formatting_provider.first_trigger_character == ":" - ) - assert capabilities.document_on_type_formatting_provider.more_trigger_character == [ - ",", - ".", - ] - - -@ConfiguredLS.decorate() -def test_on_type_formatting_return_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_ON_TYPE_FORMATTING, - DocumentOnTypeFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.list"), - position=Position(line=0, character=0), - ch=":", - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response - - assert response[0].new_text == "text" - assert response[0].range.start.line == 0 - assert response[0].range.start.character == 0 - assert response[0].range.end.line == 1 - assert response[0].range.end.character == 1 - - -@ConfiguredLS.decorate() -def test_on_type_formatting_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_ON_TYPE_FORMATTING, - DocumentOnTypeFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - position=Position(line=0, character=0), - ch=":", - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response is None diff --git a/tests/lsp/test_range_formatting.py b/tests/lsp/test_range_formatting.py deleted file mode 100644 index e7a118cc..00000000 --- a/tests/lsp/test_range_formatting.py +++ /dev/null @@ -1,116 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ - -from typing import List, Optional - -from lsprotocol.types import TEXT_DOCUMENT_RANGE_FORMATTING -from lsprotocol.types import ( - DocumentRangeFormattingOptions, - DocumentRangeFormattingParams, - FormattingOptions, - Position, - Range, - TextDocumentIdentifier, - TextEdit, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_RANGE_FORMATTING, - DocumentRangeFormattingOptions(), - ) - def f(params: DocumentRangeFormattingParams) -> Optional[List[TextEdit]]: - if params.text_document.uri == "file://return.list": - return [ - TextEdit( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - new_text="text", - ) - ] - else: - return None - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.document_range_formatting_provider - - -@ConfiguredLS.decorate() -def test_range_formatting_return_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_RANGE_FORMATTING, - DocumentRangeFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.list"), - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response - - assert response[0].new_text == "text" - assert response[0].range.start.line == 0 - assert response[0].range.start.character == 0 - assert response[0].range.end.line == 1 - assert response[0].range.end.character == 1 - - -@ConfiguredLS.decorate() -def test_range_formatting_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_RANGE_FORMATTING, - DocumentRangeFormattingParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - options=FormattingOptions( - tab_size=2, - insert_spaces=True, - trim_trailing_whitespace=True, - insert_final_newline=True, - trim_final_newlines=True, - ), - ), - ).result() - - assert response is None