This commit is contained in:
Regen
2023-01-08 16:39:41 +09:00
committed by GitHub
parent bff7990e7d
commit 2a5983f9aa
15 changed files with 723 additions and 501 deletions

View File

@@ -0,0 +1,3 @@
from .version import __version__
__all__ = ["__version__"]

View 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 "...")

View 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

View File

@@ -0,0 +1 @@
__version__ = "dev"