Use PDM (#66)
This commit is contained in:
3
cmake_language_server/__init__.py
Normal file
3
cmake_language_server/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .version import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
365
cmake_language_server/api.py
Normal file
365
cmake_language_server/api.py
Normal file
@@ -0,0 +1,365 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Pattern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _tidy_doc(doc: str) -> str:
|
||||
doc = doc.strip()
|
||||
doc = re.sub(r":.+?:`(.+?)`", r"\1", doc)
|
||||
doc = re.sub(r"``([^`]+)``", r"`\1`", doc)
|
||||
doc = doc.replace("\n", " ")
|
||||
doc = doc.replace(". ", ". ")
|
||||
return doc
|
||||
|
||||
|
||||
class API(object):
|
||||
_cmake: str
|
||||
_build: Path
|
||||
_uuid: uuid.UUID
|
||||
_builtin_commands: Dict[str, str]
|
||||
_builtin_variables: Dict[str, str]
|
||||
_builtin_variable_template: Dict[Pattern[str], str]
|
||||
_builtin_modules: Dict[str, str]
|
||||
_targets: List[str]
|
||||
_cached_variables: Dict[str, str]
|
||||
_generated_list_parsed: bool
|
||||
|
||||
def __init__(self, cmake: str, build: Path):
|
||||
self._cmake = cmake
|
||||
self._build = Path(build)
|
||||
self._uuid = uuid.uuid4()
|
||||
|
||||
self._builtin_commands = {}
|
||||
self._builtin_variables = {}
|
||||
self._builtin_variable_template = {}
|
||||
self._builtin_modules = {}
|
||||
self._targets = []
|
||||
self._cached_variables = {}
|
||||
self._generated_list_parsed = False
|
||||
|
||||
def query(self) -> bool:
|
||||
"""Use CMake's file API to get JSON information about the build tree
|
||||
|
||||
Generates a JSON request file for the current build tree and runs
|
||||
CMake on the build tree. Deletes the request file immediately
|
||||
after.
|
||||
"""
|
||||
if not self.cmake_cache.exists():
|
||||
return False
|
||||
|
||||
self.query_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.query_json.open("w") as fp:
|
||||
fp.write(
|
||||
"""\
|
||||
{
|
||||
"requests": [
|
||||
{"kind": "codemodel", "version": 2},
|
||||
{"kind": "cache", "version": 2},
|
||||
{"kind": "cmakeFiles", "version": 1}
|
||||
]
|
||||
}"""
|
||||
)
|
||||
|
||||
proc = subprocess.run(
|
||||
[self._cmake, str(self._build)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
universal_newlines=True,
|
||||
)
|
||||
self.query_json.unlink()
|
||||
self.query_json.parent.rmdir()
|
||||
if proc.returncode != 0:
|
||||
logging.error(f"cmake exited with {proc.returncode}: {proc.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def read_reply(self) -> bool:
|
||||
"""Reads the CMake file API reply file and updates internal state
|
||||
|
||||
Reads the result of the previous query file and updates
|
||||
the targets, the cache entries and the cmake files.
|
||||
"""
|
||||
reply = self._build / ".cmake" / "api" / "v1" / "reply"
|
||||
indices = sorted(reply.glob("index-*.json"))
|
||||
if not indices:
|
||||
logger.error("no reply")
|
||||
return False
|
||||
with indices[-1].open() as fp:
|
||||
index = json.load(fp)
|
||||
try:
|
||||
responses = index["reply"][f"client-{self._uuid}"]["query.json"][
|
||||
"responses"
|
||||
]
|
||||
except KeyError:
|
||||
logger.error("no rensponse")
|
||||
return False
|
||||
for response in responses:
|
||||
if response["kind"] == "codemodel":
|
||||
self._read_codemodel(reply / response["jsonFile"])
|
||||
elif response["kind"] == "cache":
|
||||
self._read_cache(reply / response["jsonFile"])
|
||||
elif response["kind"] == "cmakeFiles":
|
||||
self._read_cmake_files(reply / response["jsonFile"])
|
||||
|
||||
return True
|
||||
|
||||
def _read_codemodel(self, codemodelpath: Path) -> None:
|
||||
with (codemodelpath).open() as fp:
|
||||
codemodel = json.load(fp)
|
||||
config = codemodel["configurations"][0]
|
||||
self._targets[:] = [x["name"] for x in config["targets"]]
|
||||
|
||||
def _read_cache(self, cachepath: Path) -> None:
|
||||
with cachepath.open() as fp:
|
||||
cache = json.load(fp)
|
||||
self._cached_variables.clear()
|
||||
for entry in cache["entries"]:
|
||||
name = entry["name"]
|
||||
value = self._truncate_variable(entry["value"])
|
||||
properties = {x["name"]: x["value"] for x in entry["properties"]}
|
||||
helpstring = properties.get("HELPSTRING", "")
|
||||
doc = []
|
||||
if helpstring:
|
||||
doc.append(helpstring)
|
||||
if value:
|
||||
doc.append(f"`{value}`")
|
||||
self._cached_variables[name] = "\n\n".join(doc)
|
||||
|
||||
def _read_cmake_files(self, jsonpath: Path) -> None:
|
||||
"""inspect CMake list files that are used during build generation"""
|
||||
|
||||
if not self._builtin_variables or self._generated_list_parsed:
|
||||
return
|
||||
|
||||
with jsonpath.open() as fp:
|
||||
cmake_files = json.load(fp)
|
||||
|
||||
# Inspect generated list files: Get the values of variables in each script
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
tmplist = Path(tmpdirname) / "dump.cmake"
|
||||
with tmplist.open("w") as fp:
|
||||
for listfile in cmake_files["inputs"]:
|
||||
if not listfile.get("isGenerated", False):
|
||||
continue
|
||||
path = listfile["path"]
|
||||
fp.write(f"include({path})\n")
|
||||
fp.write(
|
||||
"""
|
||||
get_cmake_property(variables VARIABLES)
|
||||
foreach (variable ${variables})
|
||||
message("${variable}=${${variable}}")
|
||||
endforeach()
|
||||
"""
|
||||
)
|
||||
p = subprocess.run(
|
||||
[self._cmake, "-P", str(tmplist)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=cmake_files["paths"]["source"],
|
||||
encoding="utf-8",
|
||||
universal_newlines=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
return
|
||||
|
||||
for line in p.stderr.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
if k.startswith("CMAKE_ARG"):
|
||||
continue
|
||||
v = self._truncate_variable(v)
|
||||
if k in self._builtin_variables:
|
||||
self._builtin_variables[k] += f"\n\n`{v}`"
|
||||
else:
|
||||
for pattern, doc in self._builtin_variable_template.items():
|
||||
if pattern.fullmatch(k):
|
||||
self._builtin_variables[k] = f"{doc}\n\n`{v}`"
|
||||
break
|
||||
else:
|
||||
# ignore variable with no document
|
||||
pass
|
||||
|
||||
self._generated_list_parsed = True
|
||||
|
||||
@property
|
||||
def query_json(self) -> Path:
|
||||
return (
|
||||
self._build
|
||||
/ ".cmake"
|
||||
/ "api"
|
||||
/ "v1"
|
||||
/ "query"
|
||||
/ f"client-{self._uuid}"
|
||||
/ "query.json"
|
||||
)
|
||||
|
||||
@property
|
||||
def cmake_cache(self) -> Path:
|
||||
return self._build / "CMakeCache.txt"
|
||||
|
||||
def parse_doc(self) -> None:
|
||||
self._parse_commands()
|
||||
self._parse_variables()
|
||||
self._parse_modules()
|
||||
|
||||
def _parse_commands(self) -> None:
|
||||
"""Load docs for builtin cmake functions
|
||||
|
||||
Loads the documentation for builtin cmake functions from the result
|
||||
of `$ cmake --help-commands`.
|
||||
"""
|
||||
p = subprocess.run(
|
||||
[self._cmake, "--help-commands"],
|
||||
stdout=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
return
|
||||
|
||||
matches = re.finditer(
|
||||
r"""
|
||||
(?P<command>.+)\n
|
||||
-+\n+?
|
||||
[\s\S]*?
|
||||
(?P<signature>(?P=command)\s*\([^)]*\))
|
||||
""",
|
||||
p.stdout,
|
||||
re.VERBOSE,
|
||||
)
|
||||
self._builtin_commands.clear()
|
||||
for match in matches:
|
||||
command = match.group("command")
|
||||
signature = match.group("signature")
|
||||
signature = re.sub(r"^ ", r"", signature, flags=re.MULTILINE)
|
||||
self._builtin_commands[command] = "```cmake\n" + signature + "\n```"
|
||||
|
||||
def _parse_variables(self) -> None:
|
||||
"""Load docs for builtin cmake variables
|
||||
|
||||
Loads the documentation for builtin cmake variables from
|
||||
the result of `$ cmake --help-variables`.
|
||||
"""
|
||||
p = subprocess.run(
|
||||
[self._cmake, "--help-variables"],
|
||||
stdout=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
return
|
||||
|
||||
matches = re.finditer(
|
||||
r"""
|
||||
(?P<variable>.+)\n
|
||||
-+\n\n
|
||||
(?P<doc>[\s\S]+?)(?:\n\n|$)
|
||||
""",
|
||||
p.stdout,
|
||||
re.VERBOSE,
|
||||
)
|
||||
self._builtin_variables.clear()
|
||||
for match in matches:
|
||||
variable = match.group("variable")
|
||||
doc = _tidy_doc(match.group("doc"))
|
||||
if variable == "CMAKE_MATCH_<n>":
|
||||
for i in range(10):
|
||||
self._builtin_variables[f"CMAKE_MATCH_{i}"] = doc
|
||||
elif "<" in variable:
|
||||
variable = re.sub(r"<[^>]+>", r"[^_]+", variable)
|
||||
pattern = re.compile(variable)
|
||||
self._builtin_variable_template[pattern] = doc
|
||||
else:
|
||||
self._builtin_variables[variable] = doc
|
||||
|
||||
def _parse_modules(self) -> None:
|
||||
"""Loads docs for all modules in the cmake distribution
|
||||
|
||||
Loads the documentation for cmake modules included in the
|
||||
distribution from the result of `$ cmake --help-modules`.
|
||||
"""
|
||||
p = subprocess.run(
|
||||
[self._cmake, "--help-modules"],
|
||||
stdout=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
return
|
||||
|
||||
matches = re.finditer(
|
||||
r"""
|
||||
(?P<module>.+)\n
|
||||
-+\n+?
|
||||
(?:(?P<header>\w[\w\s]+)\n\^+\n+?)?
|
||||
(?P<doc>(?:.|\n)*?\n\n)
|
||||
""",
|
||||
p.stdout + "\n\n",
|
||||
re.VERBOSE,
|
||||
)
|
||||
self._builtin_modules.clear()
|
||||
for match in matches:
|
||||
module = match.group("module")
|
||||
header = match.group("header")
|
||||
doc = _tidy_doc(match.group("doc"))
|
||||
if header != "Overview":
|
||||
doc = ""
|
||||
self._builtin_modules[module] = doc
|
||||
|
||||
def get_command_doc(self, command: str) -> Optional[str]:
|
||||
return self._builtin_commands.get(command)
|
||||
|
||||
def search_command(self, command: str) -> List[str]:
|
||||
command = command.lower()
|
||||
return [x for x in self._builtin_commands if x.startswith(command)]
|
||||
|
||||
def get_variable_doc(self, variable: str) -> Optional[str]:
|
||||
doc = self._cached_variables.get(variable)
|
||||
if doc:
|
||||
return doc
|
||||
return self._builtin_variables.get(variable)
|
||||
|
||||
def search_variable(self, variable: str) -> List[str]:
|
||||
cached = frozenset(x for x in self._cached_variables if x.startswith(variable))
|
||||
builtin = frozenset(
|
||||
x for x in self._builtin_variables if x.startswith(variable)
|
||||
)
|
||||
return list(cached | builtin)
|
||||
|
||||
def get_module_doc(self, module: str, package: bool) -> Optional[str]:
|
||||
if package:
|
||||
return self._builtin_modules.get("Find" + module)
|
||||
|
||||
return self._builtin_modules.get(module)
|
||||
|
||||
def search_module(self, module: str, package: bool) -> List[str]:
|
||||
if package:
|
||||
module = "Find" + module
|
||||
return [x[4:] for x in self._builtin_modules if x.startswith(module)]
|
||||
|
||||
return [
|
||||
x
|
||||
for x in self._builtin_modules
|
||||
if x.startswith(module) and not x.startswith("Find")
|
||||
]
|
||||
|
||||
def search_target(self, target: str) -> List[str]:
|
||||
return [x for x in self._targets if x.startswith(target)]
|
||||
|
||||
def _truncate_variable(self, v: str) -> str:
|
||||
width = 70
|
||||
return v[:width] + (v[width:] and "...")
|
||||
259
cmake_language_server/server.py
Normal file
259
cmake_language_server/server.py
Normal file
@@ -0,0 +1,259 @@
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from pygls.lsp.methods import (
|
||||
COMPLETION,
|
||||
FORMATTING,
|
||||
HOVER,
|
||||
INITIALIZE,
|
||||
INITIALIZED,
|
||||
TEXT_DOCUMENT_DID_SAVE,
|
||||
)
|
||||
from pygls.lsp.types import (
|
||||
CompletionItem,
|
||||
CompletionItemKind,
|
||||
CompletionList,
|
||||
CompletionOptions,
|
||||
CompletionParams,
|
||||
CompletionTriggerKind,
|
||||
DocumentFormattingParams,
|
||||
Hover,
|
||||
InitializeParams,
|
||||
MarkupContent,
|
||||
MarkupKind,
|
||||
Position,
|
||||
Range,
|
||||
TextDocumentPositionParams,
|
||||
TextDocumentSaveRegistrationOptions,
|
||||
TextEdit,
|
||||
)
|
||||
from pygls.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
|
||||
|
||||
cmake = getattr(opts, "cmakeExecutable", "cmake")
|
||||
builddir = getattr(opts, "buildDirectory", "")
|
||||
logging.info(f"cmakeExecutable={cmake}, buildDirectory={builddir}")
|
||||
|
||||
self._api = API(cmake, Path(builddir))
|
||||
self._api.parse_doc()
|
||||
|
||||
trigger_characters = ["{", "("]
|
||||
|
||||
@self.feature(
|
||||
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._api.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._api.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._api.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._api.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(FORMATTING)
|
||||
def formatting(
|
||||
params: DocumentFormattingParams,
|
||||
) -> Optional[List[TextEdit]]:
|
||||
doc = self.workspace.get_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(HOVER)
|
||||
def hover(params: TextDocumentPositionParams) -> Optional[Hover]:
|
||||
assert self._api is not None
|
||||
api = self._api
|
||||
|
||||
word = self._cursor_word(params.text_document.uri, params.position, True)
|
||||
if not word:
|
||||
return None
|
||||
|
||||
candidates: List[Callable[[str], Optional[str]]] = [
|
||||
lambda x: api.get_command_doc(x.lower()),
|
||||
lambda x: api.get_variable_doc(x),
|
||||
lambda x: api.get_module_doc(x, False),
|
||||
lambda x: api.get_module_doc(x, True),
|
||||
]
|
||||
for c in candidates:
|
||||
doc = c(word[0])
|
||||
if doc is None:
|
||||
continue
|
||||
return Hover(
|
||||
contents=MarkupContent(kind=MarkupKind.Markdown, value=doc),
|
||||
range=word[1],
|
||||
)
|
||||
return None
|
||||
|
||||
@self.thread()
|
||||
@self.feature(
|
||||
TEXT_DOCUMENT_DID_SAVE,
|
||||
TextDocumentSaveRegistrationOptions(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_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_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 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().start_io() # type: ignore
|
||||
1
cmake_language_server/version.py
Normal file
1
cmake_language_server/version.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "dev"
|
||||
Reference in New Issue
Block a user