diff --git a/scripts/lvgl_api/__init__.py b/scripts/lvgl_api/__init__.py new file mode 100644 index 0000000000..662028edc8 --- /dev/null +++ b/scripts/lvgl_api/__init__.py @@ -0,0 +1,3 @@ +from .lvgl_api import PublicApi, Function, doxygen_available + +__all__ = ["PublicApi", "Function", "doxygen_available"] diff --git a/scripts/lvgl_api/lvgl_api.py b/scripts/lvgl_api/lvgl_api.py new file mode 100644 index 0000000000..57f2f639da --- /dev/null +++ b/scripts/lvgl_api/lvgl_api.py @@ -0,0 +1,566 @@ +""" +Model of LVGL's public API, built from Doxygen's XML output. + +From this file you can query these per function: + +1. The return type +2. The parameter list +3. The Doxygen comment +4. The header the function is declared in +5. The file and line range where the body of the function resides + + from lvgl_api import PublicApi + + api = PublicApi.parse(repo_root) + for func in api.public_functions(): + for param in func.params: + if param.is_pointer_like: + ... + body = func.body(repo_root) + +Requires the `doxygen` executable. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path + +PUBLIC_INCLUDE_DIR = "include/lvgl" + +DOXYFILE_TEMPLATE = """\ +@INCLUDE = {docs_doxyfile} + +PROJECT_NAME = lvgl-api +OUTPUT_DIRECTORY = {out_dir} +STRIP_FROM_PATH = {repo_root} +INPUT = {inputs} +RECURSIVE = YES +FILE_PATTERNS = lv*.h lv*.c lv*.cpp lv*.hpp +EXTRACT_ALL = YES +EXTRACT_STATIC = YES +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +XML_PROGRAMLISTING = NO +SOURCE_BROWSER = NO +ALIASES = +QUIET = YES +WARNINGS = NO +WARN_IF_UNDOCUMENTED = NO +WARN_IF_DOC_ERROR = NO +WARN_AS_ERROR = NO +WARN_LOGFILE = {out_dir}/doxygen_warnings.txt +""" + + +def _text(el) -> str: + """Flattened text of an element, including nested nodes.""" + if el is None: + return "" + return " ".join("".join(el.itertext()).split()) + + +def _split_args(argsstring: str) -> list[str] | None: + """Split Doxygen's normalised `(a, b, c)` into its parameters.""" + s = (argsstring or "").strip() + if not s.startswith("("): + return None + end = s.rfind(")") + if end == -1: + return None + parts, cur, depth = [], "", 0 + for ch in s[1:end]: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if ch == "," and depth == 0: + parts.append(cur) + cur = "" + else: + cur += ch + parts.append(cur) + return parts + + +def _name_of_arg(arg: str) -> str | None: + """The identifier a Doxygen-normalised parameter declares, if any.""" + a = arg.strip() + if not a or a in ("void", "..."): + return None + fp = re.search(r"\(\s*\*+\s*(\w+)\s*\)", a) # function pointer + if fp: + return fp.group(1) + a = re.sub(r"\[[^\]]*\]\s*$", "", a).strip() # trailing [] + m = re.search(r"(\w+)\s*$", a) + return m.group(1) if m else None + + +@dataclass +class Param: + name: str | None + type: str + doc: str = "" + + @property + def is_varargs(self) -> bool: + return self.type.strip() == "..." + + @property + def is_array(self) -> bool: + return bool(re.search(r"\[[^\]]*\]\s*$", self.type)) + + @property + def is_function_pointer(self) -> bool: + return bool(re.search(r"\(\s*\*+\s*\w*\s*\)\s*\(", self.type)) + + @property + def is_pointer(self) -> bool: + return "*" in self.type and not self.is_function_pointer + + @property + def is_pointer_like(self) -> bool: + """Passed as an address the callee may dereference.""" + if self.is_varargs: + return False + return self.is_pointer or self.is_array or self.is_function_pointer + + +@dataclass +class Function: + name: str + return_type: str + params: list[Param] + header: str | None # repo-relative, None when not declared in a header + header_line: int | None + body_file: str | None # repo-relative + body_start: int | None + body_end: int | None + brief: str = "" + detail: str = "" + from_definition: bool = False + decl_params: list[Param] = field(default_factory=list) + decl_return_type: str = "" + + @property + def declaration(self) -> tuple[str, list[Param]]: + """The public signature: (return type, parameters) as declared.""" + return ( + self.decl_return_type or self.return_type, + self.decl_params or self.params, + ) + + @property + def is_public(self) -> bool: + return bool(self.header and self.header.startswith(PUBLIC_INCLUDE_DIR)) + + @property + def has_body(self) -> bool: + return bool(self.body_file and self.body_start and self.body_end) + + def body(self, repo_root: Path) -> str: + """The implementation, sliced out of the source by line range.""" + if not self.has_body: + return "" + path = repo_root / self.body_file + try: + lines = path.read_text(errors="ignore").splitlines() + except OSError: + return "" + return "\n".join(lines[self.body_start - 1 : self.body_end]) + + def param(self, name: str) -> Param | None: + for p in self.params: + if p.name == name: + return p + return None + + +@dataclass +class EnumMember: + name: str + value: str = "" + + +@dataclass +class Enum: + name: str + members: list[EnumMember] + header: str | None + + @property + def is_public(self) -> bool: + return bool(self.header and self.header.startswith(PUBLIC_INCLUDE_DIR)) + + +@dataclass +class Field: + name: str + type: str + + +@dataclass +class Struct: + name: str + kind: str # "struct" or "union" + fields: list[Field] + header: str | None + + @property + def is_public(self) -> bool: + return bool(self.header and self.header.startswith(PUBLIC_INCLUDE_DIR)) + + +@dataclass +class Typedef: + name: str + type: str + header: str | None + + @property + def is_public(self) -> bool: + return bool(self.header and self.header.startswith(PUBLIC_INCLUDE_DIR)) + + +@dataclass +class PublicApi: + functions: dict[str, Function] = field(default_factory=dict) + enums: dict[str, Enum] = field(default_factory=dict) + structs: dict[str, Struct] = field(default_factory=dict) + typedefs: dict[str, Typedef] = field(default_factory=dict) + + def public_functions(self) -> list[Function]: + return [f for f in self.functions.values() if f.is_public] + + def public_enums(self) -> list[Enum]: + return [e for e in self.enums.values() if e.is_public] + + def public_structs(self) -> list[Struct]: + return [s for s in self.structs.values() if s.is_public] + + def public_typedefs(self) -> list[Typedef]: + return [t for t in self.typedefs.values() if t.is_public] + + # -- construction ---------------------------------------------------- + + @classmethod + def parse( + cls, + repo_root: Path, + xml_dir: Path | None = None, + inputs: tuple[str, ...] = (PUBLIC_INCLUDE_DIR, "src"), + ) -> "PublicApi": + """Build the model, running Doxygen unless `xml_dir` is supplied.""" + if xml_dir is not None: + return cls._from_xml(Path(xml_dir), repo_root) + with tempfile.TemporaryDirectory(prefix="lvgl-api-") as tmp: + out_dir = Path(tmp) + root = Path(repo_root) + run_doxygen(root, out_dir, inputs) + return cls._from_xml(out_dir / "xml", root) + + @classmethod + def _from_xml(cls, xml_dir: Path, repo_root: Path) -> "PublicApi": + api = cls() + root = repo_root.resolve() + if not xml_dir.is_dir(): + raise RuntimeError(f"not a Doxygen XML directory: {xml_dir}") + + paths = [ + path + for path in sorted(xml_dir.glob("*.xml")) + if path.name not in ("index.xml", "Doxyfile.xml") + ] + if not paths: + raise RuntimeError(f"no Doxygen XML files in {xml_dir}") + + for path in paths: + try: + tree = ET.parse(path) + except ET.ParseError as e: + raise RuntimeError(f"{path}: malformed Doxygen XML: {e}") from e + for member in tree.iter("memberdef"): + kind = member.get("kind") + if kind == "function": + func = _function_from_memberdef(member, root) + if func is not None: + api.functions[func.name] = _merge( + api.functions.get(func.name), func + ) + elif kind == "enum": + en = _enum_from_memberdef(member, root) + if en is not None: + api.enums.setdefault(en.name, en) + elif kind == "typedef": + td = _typedef_from_memberdef(member, root) + if td is not None: + api.typedefs.setdefault(td.name, td) + + for compound in tree.iter("compounddef"): + st = _struct_from_compounddef(compound, root) + if st is not None: + api.structs.setdefault(st.name, st) + return api + + +def _relative(path: str | None, root: Path) -> str | None: + """Normalise a Doxygen path to one relative to `root`. + + STRIP_FROM_PATH makes Doxygen emit paths already relative to the tree, so + they must be resolved against `root` -- not against the process working + directory, which is a different tree when auditing a worktree. + """ + if not path: + return None + p = Path(path) + if not p.is_absolute(): + p = root / p + try: + return os.path.relpath(p, root).replace(os.sep, "/") + except ValueError: + return str(path) + + +def _function_from_memberdef(member, root: Path) -> Function | None: + name = member.findtext("name") + if not name: + return None + loc = member.find("location") + if loc is None: + return None + + arg_parts = _split_args(member.findtext("argsstring") or "") + decl_params = member.findall("param") + docs = _param_docs(member) + + params: list[Param] = [] + for i, p in enumerate(decl_params): + ptype = _text(p.find("type")) + pname = p.findtext("declname") or p.findtext("defname") + if arg_parts and i < len(arg_parts): + from_args = _name_of_arg(arg_parts[i]) + if from_args: + pname = from_args + if p.find("array") is not None or re.search( + r"\[[^\]]*\]\s*$", arg_parts[i].strip() + ): + ptype += "[]" + params.append(Param(name=pname, type=ptype, doc=docs.get(pname or "", ""))) + + declfile = _relative(loc.get("declfile") or loc.get("file"), root) + declline = loc.get("declline") or loc.get("line") + file_attr = loc.get("file") + bodyfile = loc.get("bodyfile") + return Function( + name=name, + return_type=_text(member.find("type")), + params=params, + header=declfile if (declfile or "").endswith((".h", ".hpp")) else None, + header_line=int(declline) if declline and declline.isdigit() else None, + body_file=_relative(loc.get("bodyfile"), root), + body_start=( + int(loc.get("bodystart")) + if (loc.get("bodystart") or "").isdigit() + else None + ), + body_end=( + int(loc.get("bodyend")) if (loc.get("bodyend") or "").isdigit() else None + ), + brief=_text(member.find("briefdescription")), + detail=_text(member.find("detaileddescription")), + from_definition=bool(file_attr and bodyfile and file_attr == bodyfile), + ) + + +def _header_of(member, root: Path) -> str | None: + loc = member.find("location") + if loc is None: + return None + path = _relative(loc.get("declfile") or loc.get("file"), root) + return path if (path or "").endswith((".h", ".hpp")) else None + + +def _enum_from_memberdef(member, root: Path) -> Enum | None: + name = member.findtext("name") + if not name or name.startswith("@"): # anonymous + return None + members = [ + EnumMember( + name=v.findtext("name") or "", + value=_text(v.find("initializer")).lstrip("= ").strip(), + ) + for v in member.findall("enumvalue") + ] + return Enum(name=name, members=members, header=_header_of(member, root)) + + +def _typedef_from_memberdef(member, root: Path) -> Typedef | None: + name = member.findtext("name") + if not name: + return None + type_ = _text(member.find("type")) + args = (member.findtext("argsstring") or "").strip() + return Typedef( + name=name, type=(type_ + args).strip(), header=_header_of(member, root) + ) + + +def _struct_from_compounddef(compound, root: Path) -> Struct | None: + kind = compound.get("kind") + if kind not in ("struct", "union"): + return None + name = compound.findtext("compoundname") + if not name or "@" in name: + return None + loc = compound.find("location") + header = None + if loc is not None: + path = _relative(loc.get("file"), root) + header = path if (path or "").endswith((".h", ".hpp")) else None + fields = [] + for m in compound.iter("memberdef"): + if m.get("kind") != "variable": + continue + fname = m.findtext("name") + if not fname: + continue + ftype = _text(m.find("type")) + (m.findtext("argsstring") or "") + fields.append(Field(name=fname, type=" ".join(ftype.split()))) + return Struct(name=name, kind=kind, fields=fields, header=header) + + +def _combine_docs(have: str, extra: str) -> str: + """Fold `extra` into `have`, keeping whichever text says more.""" + if not extra or extra in have: + return have + if have and have in extra: + return extra + return f"{have} {extra}".strip() + + +def _param_docs(member) -> dict[str, str]: + """Per-parameter documentation, keyed by the name used in the comment.""" + out: dict[str, str] = {} + detail = member.find("detaileddescription") + if detail is None: + return out + for item in detail.iter("parameteritem"): + desc = _text(item.find("parameterdescription")) + for nm in item.iter("parametername"): + name = _text(nm) + if name: + out[name] = _combine_docs(out.get(name, ""), desc) + return out + + +def _merge(prev: Function | None, new: Function) -> Function: + """Doxygen emits a function once per file that mentions it; combine them. + + The declaration's entry knows the public header and carries the Doxygen + comment; the definition's entry carries the parameter names the body uses. + Take each from whichever entry actually has it. + """ + if prev is None: + return new + + # the public header always wins over a private one + def _better_header(a: Function, b: Function): + for cand in (a, b): + if cand.header and cand.header.startswith(PUBLIC_INCLUDE_DIR): + return cand.header, cand.header_line + for cand in (a, b): + if cand.header: + return cand.header, cand.header_line + return None, None + + header, header_line = _better_header(prev, new) + + definition = next((f for f in (prev, new) if f.from_definition), None) + declaration = next( + ( + f + for f in (prev, new) + if not f.from_definition + and f.header + and f.header.startswith(PUBLIC_INCLUDE_DIR) + ), + None, + ) + documented = next((f for f in (prev, new) if f.detail), None) or prev + with_body = next((f for f in (prev, new) if f.has_body), prev) + + params = definition.params if definition else (prev.params or new.params) + decl_params = ( + declaration.params + if declaration + else (prev.decl_params or new.decl_params or params) + ) + decl_return_type = ( + declaration.return_type + if declaration + else (prev.decl_return_type or new.decl_return_type) + ) + + for src in (declaration, documented): + if src is None or src is definition: + continue + for i, p in enumerate(params): + if i < len(src.params): + p.doc = _combine_docs(p.doc, src.params[i].doc) + + return Function( + name=prev.name, + return_type=(definition or prev).return_type or new.return_type, + params=params, + header=header, + header_line=header_line, + body_file=with_body.body_file, + body_start=with_body.body_start, + body_end=with_body.body_end, + brief=documented.brief or prev.brief or new.brief, + detail=documented.detail or prev.detail or new.detail, + from_definition=bool(definition), + decl_params=decl_params, + decl_return_type=decl_return_type, + ) + + +def doxygen_available() -> bool: + return shutil.which("doxygen") is not None + + +def run_doxygen(repo_root: Path, out_dir: Path, inputs) -> None: + """Generate Doxygen XML for `repo_root` into `out_dir`.""" + if not doxygen_available(): + raise RuntimeError( + "doxygen not found on PATH. Install it (apt install doxygen) or pass " + "an existing XML directory." + ) + docs_doxyfile = repo_root / "docs" / "Doxyfile" + if not docs_doxyfile.is_file(): + raise RuntimeError(f"missing {docs_doxyfile}") + + doxyfile = out_dir / "Doxyfile" + doxyfile.write_text( + DOXYFILE_TEMPLATE.format( + docs_doxyfile=docs_doxyfile, + out_dir=out_dir, + repo_root=repo_root, + inputs=" ".join(str(repo_root / i) for i in inputs), + ) + ) + proc = subprocess.run( + ["doxygen", str(doxyfile)], + cwd=repo_root, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(f"doxygen failed:\n{proc.stderr[-2000:]}")