Skip to content

Commit

Permalink
Add basic textDocument/Hover support
Browse files Browse the repository at this point in the history
  • Loading branch information
psacawa committed Feb 17, 2024
1 parent 84653f4 commit 30cf97a
Show file tree
Hide file tree
Showing 2 changed files with 156 additions and 20 deletions.
60 changes: 51 additions & 9 deletions systemd_language_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from argparse import ArgumentParser
from pathlib import Path
import logging
import sys
import os.path

from pygls.server import LanguageServer
from pygls.workspace import TextDocument
from lsprotocol.types import (
INITIALIZE,
TEXT_DOCUMENT_COMPLETION,
Expand All @@ -14,12 +17,17 @@
CompletionItem,
CompletionItemKind,
CompletionOptions,
Hover,
Range,
Position,
HoverParams,
InitializedParams,
)
from .unit import (
get_current_section,
get_unit_type,
get_directives,
get_documentation_content,
UnitType,
UnitFileSection,
unit_type_to_unit_file_section,
Expand All @@ -31,13 +39,23 @@
handler = logging.FileHandler(filename="systemd_language_server.log")
handler.setLevel(logging.DEBUG)
logger.addHandler(handler)
server = LanguageServer("systemd-language-server", "v0.1")

logging.basicConfig(
filename="systemd_language_server.log", filemode="w", level=logging.DEBUG
)


class SystemdLanguageServer(LanguageServer):
has_pandoc: bool = False

def __init__(self, *args, **kwargs):
has_pandoc = os.path.exists("/bin/pandoc")
super().__init__(*args, **kwargs)


server = SystemdLanguageServer("systemd-language-server", "v0.1")


@server.feature(INITIALIZE)
def initialize(params: InitializedParams):
pass
Expand All @@ -57,8 +75,13 @@ def complete_unit_file_section(params: CompletionParams, unit_type: UnitType):
return CompletionList(is_incomplete=False, items=items)


def complete_directive_property(params: CompletionParams):
# TODO 10/02/20 psacawa: finish this
def complete_directive_property(
params: CompletionParams,
unit_type: UnitType,
section: UnitFileSection | None,
current_line: str,
):
directive = current_line.split("=")[0]
pass


Expand All @@ -81,30 +104,49 @@ def complete_directive(
@server.feature(
TEXT_DOCUMENT_COMPLETION, CompletionOptions(trigger_characters=["[', '="])
)
def text_document_completion(params: CompletionParams) -> CompletionList | None:
def textDocument_completion(params: CompletionParams) -> CompletionList | None:
"""Complete systemd unit properties. Determine the required completion type and
dispatch it."""
items = []
uri = params.text_document.uri
document = server.workspace.get_document(uri)
current_line = document.lines[params.position.line].strip()
unit_type = UnitType(Path(uri).suffix.strip("."))
unit_type = get_unit_type(document)
section = get_current_section(document, params.position)
logger.debug(f"{unit_type=} {section=}")

if current_line == "[":
return complete_unit_file_section(params, unit_type)
elif "=" not in current_line:
return complete_directive(params, unit_type, section, current_line)
else:
return None
elif len(current_line.split("=")) == 2:
return complete_directive_property(params, unit_type, section, current_line)


def range_for_directive(document: TextDocument, position: Position) -> Range:
"""Range indicating directive (before =)"""
current_line = document.lines[position.line].strip()
idx = current_line.find("=")
return Range(Position(position.line, 0), Position(position.line, idx - 1))


@server.feature(TEXT_DOCUMENT_HOVER)
def text_document_hover(params: HoverParams):
"""Help for directives."""
def textDocument_hover(params: HoverParams):
"""Help for unit file directives."""
document = server.workspace.get_document(params.text_document.uri)
current_line = document.lines[params.position.line].strip()
unit_type = get_unit_type(document)
section = get_current_section(document, params.position)

if "=" in current_line:
directive = current_line.split("=")[0]
hover_range = range_for_directive(document, params.position)
contents = get_documentation_content(
directive, unit_type, section, server.has_pandoc
)
if contents is None:
return None
return Hover(contents=contents, range=hover_range)


def get_parser():
Expand Down
116 changes: 105 additions & 11 deletions systemd_language_server/unit.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import re
from enum import Enum, auto
from itertools import islice
from enum import Enum
from io import StringIO
import logging
from glob import glob
from pathlib import Path
import subprocess

from lxml import etree # type: ignore
from pygls.workspace import TextDocument
from lsprotocol.types import Position
from lsprotocol.types import Position, MarkupKind, MarkupContent

SECTION_HEADER_PROG = re.compile(r"^\[(?P<name>\w+)\]$")

from .constants import (
systemd_unit_directives,
Expand All @@ -23,6 +26,18 @@
systemd_kill_directives,
)

# The ultimate source for information on unit files is the docbook files distributed with
# systemd. Therefore, the following data is managed by the language server:
# - unit type
# - unit file sections
# - directives
# - directive values
# - which docbook (.xml) directives are documented in
# Data is resolved at runtime, to the extent possible, therefore docbooks are bundled
# with systemd-language-server and parsed as required.

SECTION_HEADER_PROG = re.compile(r"^\[(?P<name>\w+)\]$")


class UnitType(Enum):
service = "service"
Expand All @@ -37,6 +52,14 @@ class UnitType(Enum):
slice = "slice"
scope = "scope"

def is_execable(self):
return self in [
UnitType.service,
UnitType.socket,
UnitType.mount,
UnitType.swap,
]


class UnitFileSection(Enum):
unit = "Unit"
Expand All @@ -45,11 +68,28 @@ class UnitFileSection(Enum):
socket = "Socket"
mount = "Mount"
automount = "Automount"
scope = "Scope"
swap = "Swap"
path = "Path"
timer = "Timer"


_assets_dir = Path(__file__).absolute().parent / "assets"
docbooks = glob("*.xml", root_dir=_assets_dir)

# dict mapping docbook documentation file to the list of systemd unit directives
# documented within
directives = dict()


def initialize_directive():
for filename in docbooks:
docbook_file = _assets_dir / filename
tree = etree.parse(open(docbook_file).read())
directives[filename] = tree.xpath()
# TODO 10/02/20 psacawa: finish this


def unit_type_to_unit_file_section(ut: UnitType) -> UnitFileSection | None:
try:
return UnitFileSection(ut.value.capitalize())
Expand All @@ -72,9 +112,66 @@ def unit_file_section_to_unit_type(ufs: UnitFileSection) -> UnitType | None:
UnitFileSection.automount: systemd_automount_directives,
UnitFileSection.swap: systemd_swap_directives,
UnitFileSection.path: systemd_path_directives,
UnitFileSection.scope: systemd_scope_directives,
}


def convert_to_markdown(raw_varlistentry: bytes):
"""Use pandoc to convert docbook entry to markdown"""
argv = "pandoc --from=docbook --to markdown -".split()
proc = subprocess.run(argv, input=raw_varlistentry, stdout=subprocess.PIPE)
return proc.stdout.decode()


def get_documentation_content(
directive: str,
unit_type: UnitType,
section: UnitFileSection | None,
markdown_available=False,
) -> MarkupContent | None:
"""Get documentation for unit file directive."""
docbooks = get_manual_sections(unit_type, section)
for manual in docbooks:
filepath = _assets_dir / manual
logging.debug(f"{filepath=}")
stream = StringIO(open(filepath).read())
tree = etree.parse(stream)
for varlistentry in tree.xpath("//varlistentry"):
directives_in_varlist: list[str] = [
varname.text.strip("=")
for varname in varlistentry.findall(".//term/varname")
]
logging.debug(f"{directive=} {directives_in_varlist}")
if directive not in directives_in_varlist:
continue
logging.info(f"Found {directive=} in {manual=}")
raw_varlistentry = etree.tostring(varlistentry)
value = bytes()
kind: MarkupKind
if markdown_available:
kind = MarkupKind.Markdown
value = convert_to_markdown(raw_varlistentry)
else:
kind = MarkupKind.PlainText
value = "".join((varlistentry.itertext()))

return MarkupContent(kind=kind, value=value)
return None


def get_manual_sections(unit_type: UnitType, section: UnitFileSection | None):
"""Determine which docbook to search for documentation, based on unit type and file
section. If no section is provided, search liberally, search liberally."""
if section in [UnitFileSection.unit, UnitFileSection.install]:
return ["systemd.{}.xml".format(section.value.lower())]
ret = ["systemd.{}.xml".format(unit_type.value.lower())]
if section is None:
ret += ["systemd.unit.xml", "systemd.install.xml"]
if unit_type.is_execable():
ret += ["systemd.exec.xml", "systemd.kill.xml"]
return ret


def get_directives(unit_type: UnitType, section: UnitFileSection | None) -> list[str]:
# Two variants: i) the current unit file section is known, ii) it isn't (e.g. buffer
# has no section headers yet). If it is, we supply completions value for the unit
Expand All @@ -94,16 +191,13 @@ def get_directives(unit_type: UnitType, section: UnitFileSection | None) -> list
else:
directives = directive_dict[section]

if unit_type in [
UnitType.service,
UnitType.socket,
UnitType.mount,
UnitType.swap,
]:
if unit_type.is_execable():
directives += systemd_exec_directives + systemd_kill_directives
return directives

return []

def get_unit_type(document):
return UnitType(Path(document.uri).suffix.strip("."))


def get_current_section(
Expand Down

0 comments on commit 30cf97a

Please sign in to comment.