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()