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.+)\n -+\n+? [\s\S]*? (?P(?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.+)\n -+\n\n (?P[\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_": 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.+)\n -+\n+? (?:(?P
\w[\w\s]+)\n\^+\n+?)? (?P(?:.|\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 "...")