Document Formatting¶
This implements the various formatting requests from the specification
textDocument/formatting: format the entire document.
textDocument/rangeFormatting: format just the given range within a document.
textDocument/onTypeFormatting: format the document while the user is actively typing.
These are typically invoked by the client when the user asks their editor to format the
document or as part of automatic triggers (e.g. format on save).
Depending on the client, the user may need to do some additional configuration to enable
some of these methods e.g. setting editor.formatOnType in VSCode to enable
textDocument/onTypeFormatting.
This server implements basic formatting of Markdown style tables.
The implementation is a little buggy in that the resulting table might not be what you expect (fixes welcome!), but it should be enough to demonstrate the expected interaction between client and server.
import logging
from typing import Dict
from typing import List
from typing import Optional
import attrs
from lsprotocol import types
from pygls.cli import start_server
from pygls.lsp.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")
start_server(server)