mirror of
https://github.com/lvgl/lvgl.git
synced 2026-10-06 19:00:49 +08:00
567 lines
17 KiB
Python
567 lines
17 KiB
Python
"""
|
|
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 <ref> 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:]}")
|