Files

288 lines
10 KiB
Python

import logging
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from lsprotocol.types import (
INITIALIZE,
INITIALIZED,
TEXT_DOCUMENT_COMPLETION,
TEXT_DOCUMENT_DID_SAVE,
TEXT_DOCUMENT_FORMATTING,
TEXT_DOCUMENT_HOVER,
WORKSPACE_DID_CHANGE_CONFIGURATION,
CompletionItem,
CompletionItemKind,
CompletionList,
CompletionOptions,
CompletionParams,
CompletionTriggerKind,
DidChangeConfigurationParams,
DocumentFormattingParams,
Hover,
InitializeParams,
MarkupContent,
MarkupKind,
Position,
Range,
SaveOptions,
TextDocumentPositionParams,
TextEdit,
)
from pygls.lsp.server import LanguageServer
from .api import API
logger = logging.getLogger(__name__)
class CMakeLanguageServer(LanguageServer):
_api: Optional[API]
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self._api = None
@self.feature(INITIALIZE)
def initialize(params: InitializeParams) -> None:
opts = params.initialization_options or {}
cmake = opts.get("cmakeExecutable", "cmake")
builddir = opts.get("buildDirectory", "")
logging.info(f"cmakeExecutable={cmake}, buildDirectory={builddir}")
self._api = API(cmake, Path(builddir))
self._api.parse_doc()
@self.feature(WORKSPACE_DID_CHANGE_CONFIGURATION)
def workspace_did_change_configuration(
params: DidChangeConfigurationParams,
) -> None:
settings = params.settings or {}
assert self._api is not None
if opts := settings.get("initialization_options"):
cmake = opts.get("cmakeExecutable", self._api._cmake)
builddir = opts.get("buildDirectory", self._api._build.as_posix())
logging.info(f"cmakeExecutable={cmake}, buildDirectory={builddir}")
api = API(cmake, Path(builddir))
api.parse_doc()
self._api = api
run_cmake()
trigger_characters = ["{", "("]
@self.feature(
TEXT_DOCUMENT_COMPLETION,
CompletionOptions(trigger_characters=trigger_characters),
)
def completions(params: CompletionParams) -> CompletionList:
assert self._api is not None
if (
params.context is not None
and params.context.trigger_kind
== CompletionTriggerKind.TriggerCharacter
):
token = ""
trigger = params.context.trigger_character
else:
line = self._cursor_line(params.text_document.uri, params.position)
idx = params.position.character - 1
if 0 <= idx < len(line) and line[idx] in trigger_characters:
token = ""
trigger = line[idx]
else:
word = self._cursor_word(
params.text_document.uri, params.position, False
)
token = "" if word is None else word[0]
trigger = None
items: List[CompletionItem] = []
if trigger is None:
commands = self._api.search_command(token)
items.extend(
CompletionItem(
label=x,
kind=CompletionItemKind.Function,
documentation=self._get_command_doc(x),
insert_text=x,
)
for x in commands
)
if trigger is None or trigger == "{":
variables = self._api.search_variable(token)
items.extend(
CompletionItem(
label=x,
kind=CompletionItemKind.Variable,
documentation=self._get_variable_doc(x),
insert_text=x,
)
for x in variables
)
if trigger is None:
targets = self._api.search_target(token)
items.extend(
CompletionItem(
label=x, kind=CompletionItemKind.Class, insert_text=x
)
for x in targets
)
if trigger == "(":
func = self._cursor_function(params.text_document.uri, params.position)
if func is not None:
func = func.lower()
if func == "include":
modules = self._api.search_module(token, False)
items.extend(
CompletionItem(
label=x,
kind=CompletionItemKind.Module,
documentation=self._get_module_doc(x, False),
insert_text=x,
)
for x in modules
)
elif func == "find_package":
modules = self._api.search_module(token, True)
items.extend(
CompletionItem(
label=x,
kind=CompletionItemKind.Module,
documentation=self._get_module_doc(x, True),
insert_text=x,
)
for x in modules
)
return CompletionList(is_incomplete=False, items=items)
if shutil.which("cmake-format") is not None:
@self.feature(TEXT_DOCUMENT_FORMATTING)
def formatting(
params: DocumentFormattingParams,
) -> Optional[List[TextEdit]]:
doc = self.workspace.get_text_document(params.text_document.uri)
content = doc.source
formatted = subprocess.check_output(
["cmake-format", "-"],
cwd=str(Path(doc.path).parent),
input=content,
universal_newlines=True,
)
lines = content.count("\n")
return [
TextEdit(
range=Range(
start=Position(line=0, character=0),
end=Position(line=lines + 1, character=0),
),
new_text=formatted,
)
]
@self.feature(TEXT_DOCUMENT_HOVER)
def hover(params: TextDocumentPositionParams) -> Optional[Hover]:
word = self._cursor_word(params.text_document.uri, params.position, True)
if not word:
return None
candidates: List[Callable[[str], Optional[MarkupContent]]] = [
lambda x: self._get_command_doc(x.lower()),
lambda x: self._get_variable_doc(x),
lambda x: self._get_module_doc(x, False),
lambda x: self._get_module_doc(x, True),
]
for c in candidates:
doc = c(word[0])
if doc is None:
continue
return Hover(contents=doc, range=word[1])
return None
@self.thread()
@self.feature(
TEXT_DOCUMENT_DID_SAVE,
SaveOptions(include_text=False),
)
@self.feature(INITIALIZED)
def run_cmake(*args: Any) -> None:
assert self._api is not None
if self._api.query():
self._api.read_reply()
def _cursor_function(self, uri: str, position: Position) -> Optional[str]:
doc = self.workspace.get_text_document(uri)
lines = doc.source.split("\n")[: position.line + 1]
lines[-1] = lines[-1][: position.character - 1].strip()
words = re.split(r"[\s\n()]+", "\n".join(lines))
return words[-1] if words else None
def _cursor_line(self, uri: str, position: Position) -> str:
doc = self.workspace.get_text_document(uri)
content = doc.source
line = content.split("\n")[position.line]
return str(line)
def _cursor_word(
self, uri: str, position: Position, include_all: bool = True
) -> Optional[Tuple[str, Range]]:
line = self._cursor_line(uri, position)
cursor = position.character
for m in re.finditer(r"\w+", line):
end = m.end() if include_all else cursor
if m.start() <= cursor <= m.end():
word = (
line[m.start() : end],
Range(
start=Position(line=position.line, character=m.start()),
end=Position(line=position.line, character=end),
),
)
return word
return None
def _get_command_doc(self, command: str) -> Optional[MarkupContent]:
assert self._api is not None
docs = self._api.get_command_doc(command)
return None if docs is None else MarkupContent(MarkupKind.Markdown, docs)
def _get_variable_doc(self, variable: str) -> Optional[MarkupContent]:
assert self._api is not None
docs = self._api.get_variable_doc(variable)
return None if docs is None else MarkupContent(MarkupKind.Markdown, docs)
def _get_module_doc(self, module: str, package: bool) -> Optional[MarkupContent]:
assert self._api is not None
docs = self._api.get_module_doc(module, package)
return None if docs is None else MarkupContent(MarkupKind.Markdown, docs)
def main() -> None:
from argparse import ArgumentParser
from . import __version__
parser = ArgumentParser(description="CMake Language Server")
parser.add_argument(
"--version", action="version", version=f"%(prog)s {__version__}"
)
parser.parse_args()
logging.basicConfig(level=logging.INFO)
logging.getLogger("pygls").setLevel(logging.WARNING)
CMakeLanguageServer("cmake-language-server", __version__).start_io()