export-enums.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. #!/usr/bin/env python3
  2. # Copyright © 2025 Pierre Le Marre <dev@wismill.eu>
  3. #
  4. # SPDX-License-Identifier: MIT
  5. import argparse
  6. import difflib
  7. import json
  8. import sys
  9. from pathlib import Path
  10. from typing import Any, Iterable, Optional, Union
  11. import jinja2
  12. from clang.cindex import Config, CursorKind, Index
  13. # Define the structure for extracted enum data
  14. EnumConstant = dict[str, Union[int, str]]
  15. EnumData = dict[str, list[EnumConstant]]
  16. SCRIPT = Path(__file__)
  17. ROOT = SCRIPT.parent.parent
  18. ENUMS_PATH = ROOT / "data" / "enums"
  19. HEADERS_PATH = ROOT / "include" / "xkbcommon"
  20. LIBXKBCOMMON_HEADERS = (
  21. HEADERS_PATH / "xkbcommon.h",
  22. HEADERS_PATH / "xkbcommon-compose.h",
  23. HEADERS_PATH / "xkbcommon-errors.h",
  24. HEADERS_PATH / "xkbcommon-features.h",
  25. )
  26. ALL_HEADERS = LIBXKBCOMMON_HEADERS
  27. # TODO: other headers
  28. # (
  29. # HEADERS_PATH / "xkbcommon-x11.h",
  30. # HEADERS_PATH / "xkbregistry.h",
  31. # )
  32. def get_enum_data(header_path: Path) -> Optional[EnumData]:
  33. """
  34. Parses a C header file and extracts all enums and their constants into a
  35. structured Python dictionary.
  36. Returns:
  37. dict: A dictionary where keys are enum names (or typedef names) and
  38. values are lists of constant dictionaries. Returns None on failure.
  39. """
  40. try:
  41. # Initialize Clang Index. Config.set_library_path must be called
  42. # before this if needed.
  43. index: Index = Index.create()
  44. except Exception as e:
  45. print(f"Error initializing libclang: {e}", file=sys.stderr)
  46. print(
  47. "Please ensure libclang is correctly installed and accessible.",
  48. file=sys.stderr,
  49. )
  50. return None
  51. # Parse arguments: standard C header mode and C11 standard.
  52. args: list[str] = ["-x", "c-header", "-std=c11"]
  53. if not header_path.exists():
  54. print(f"Error: Header file not found at '{header_path}'", file=sys.stderr)
  55. return None
  56. # Create the translation unit (TU)
  57. # libclang expects a string path, so we cast the Path object back to str
  58. tu: Any = index.parse(str(header_path), args=args)
  59. if not tu:
  60. print(
  61. f"Error: Failed to parse translation unit for '{header_path}'",
  62. file=sys.stderr,
  63. )
  64. return None
  65. enum_data: EnumData = {}
  66. def visit_node(cursor: Any) -> None:
  67. """Recursively visits the nodes in the AST."""
  68. if cursor.kind == CursorKind.ENUM_DECL:
  69. enum_name: str = cursor.displayname
  70. # Handle anonymous enums (try to find typedef parent)
  71. if not enum_name:
  72. parent: Any = cursor.semantic_parent
  73. if parent and parent.kind == CursorKind.TYPEDEF_DECL:
  74. enum_name = parent.displayname
  75. else:
  76. # Skip truly anonymous enums that aren’t typedef’d
  77. return
  78. constants: list[EnumConstant] = []
  79. # Extract constants
  80. for child in cursor.get_children():
  81. if child.kind == CursorKind.ENUM_CONSTANT_DECL:
  82. value: Union[int, Any] = child.enum_value
  83. constants.append(
  84. {
  85. "name": child.displayname,
  86. # Store value as its native type (int/str)
  87. "value": value if isinstance(value, int) else str(value),
  88. }
  89. )
  90. if constants:
  91. enum_data[enum_name] = constants
  92. # Recurse into children of the current node
  93. for child in cursor.get_children():
  94. visit_node(child)
  95. # Start the traversal from the root
  96. visit_node(tu.cursor)
  97. return enum_data
  98. def format_to_yaml(header_path: Path, enum_data: EnumData) -> str:
  99. """
  100. Formats the structured enum data into a YAML-like string.
  101. """
  102. output: list[str] = [
  103. f"# Extracted Enums from: {header_path.resolve().relative_to(ROOT)}\n"
  104. ]
  105. for enum_name, constants in enum_data.items():
  106. output.append(f"{enum_name}:")
  107. for constant in constants:
  108. output.append(f" - name: {constant['name']}")
  109. value_str: str = json.dumps(constant["value"])
  110. output.append(f" value: {value_str}")
  111. return "\n".join(output)
  112. def generate_c(env: jinja2.Environment, root: Path, file: Path, **data) -> None:
  113. """Generate a file from its Jinja2 template"""
  114. template_path = file.with_suffix(f"{file.suffix}.jinja")
  115. template = env.get_template(str(template_path))
  116. path = root / file
  117. with path.open("wt", encoding="utf-8") as fd:
  118. fd.writelines(template.generate(**data))
  119. def enum_name_from_feature(feature: str) -> str | None:
  120. if "_FEATURE_ENUM_" in feature:
  121. return feature.replace("_FEATURE_ENUM_", "_").lower()
  122. else:
  123. return None
  124. def update_command(args: argparse.Namespace) -> int:
  125. """
  126. Handles the 'update' subcommand: parses header files, update YAML and C files.
  127. """
  128. # Update YAML files
  129. enums: dict[Path, EnumData] = {}
  130. for header_path in ALL_HEADERS:
  131. yaml_path = ENUMS_PATH / header_path.with_suffix(".yaml").name
  132. if (enum_data := get_enum_data(header_path)) is not None:
  133. enums[header_path] = enum_data
  134. with yaml_path.open("wt", encoding="utf-8") as fd:
  135. fd.write(format_to_yaml(header_path, enum_data))
  136. fd.write("\n")
  137. else:
  138. return 1
  139. # Update C files
  140. template_loader = jinja2.FileSystemLoader(ROOT, encoding="utf-8")
  141. jinja_env = jinja2.Environment(
  142. loader=template_loader,
  143. keep_trailing_newline=True,
  144. trim_blocks=True,
  145. lstrip_blocks=True,
  146. extensions=["jinja2.ext.do"],
  147. )
  148. def is_flag_like(values: Iterable[EnumConstant]) -> bool:
  149. return all(v["value"] >= 0 and v["value"].bit_count() <= 1 for v in values)
  150. IMPLICIT_FLAGS = {
  151. "xkb_state_component",
  152. "xkb_state_match",
  153. }
  154. def is_flag_name(enum: str) -> bool:
  155. return enum.endswith("_flags") or enum in IMPLICIT_FLAGS
  156. def is_flag(enum: str, values: Iterable[EnumConstant]) -> bool:
  157. return is_flag_like(values) and is_flag_name(enum)
  158. jinja_env.globals["enum_name_from_feature"] = enum_name_from_feature
  159. jinja_env.globals["is_flag"] = is_flag
  160. jinja_env.globals["has_zero"] = lambda es: any(e["value"] == 0 for e in es)
  161. jinja_env.globals["has_values_mask"] = lambda es: all(
  162. e["value"] >= 0 and e["value"] < 16 for e in es
  163. )
  164. enum_data: EnumData = {}
  165. for header_path in LIBXKBCOMMON_HEADERS:
  166. enum_data.update(enums[header_path])
  167. generate_c(
  168. env=jinja_env,
  169. root=ROOT,
  170. script=SCRIPT.relative_to(ROOT),
  171. file=Path("src/features.c"),
  172. enum_data=enum_data,
  173. )
  174. generate_c(
  175. env=jinja_env,
  176. root=ROOT,
  177. script=SCRIPT.relative_to(ROOT),
  178. file=Path("src/features/enums.h"),
  179. enum_data=enum_data,
  180. )
  181. return 0
  182. def export_command(args: argparse.Namespace) -> int:
  183. """Handles the 'export' subcommand: parses file and prints YAML to stdout."""
  184. if (enum_data := get_enum_data(args.header_file)) is not None:
  185. print(format_to_yaml(args.header_file, enum_data))
  186. return 0
  187. else:
  188. return 1
  189. def check_xkb_enum(
  190. ref_enum: str, ref_enum_header_path: Path | None, header_path: Path, data: EnumData
  191. ) -> int:
  192. if ref_enum_header_path is not None:
  193. if (ref_data := get_enum_data(ref_enum_header_path)) is None:
  194. return 1
  195. else:
  196. ref_data = data
  197. # Enum xkb_feature should contain all other enums
  198. enums = set(enum for enum in data)
  199. for entry in ref_data[ref_enum]:
  200. if (enum := enum_name_from_feature(entry["name"])) is not None:
  201. enums.discard(enum)
  202. if enums:
  203. print(
  204. f"Error: missing entries in {ref_enum} for header {header_path}: {enums}",
  205. file=sys.stderr,
  206. )
  207. return 1
  208. return 0
  209. def check_header(header_path: Path, yaml_path: Path) -> int:
  210. extracted_data: Optional[EnumData] = get_enum_data(header_path)
  211. if extracted_data is None:
  212. return 1
  213. # Enum xkb_feature should contain all enums from libxkbcommon
  214. if header_path.name == "xkbcommon-features.h":
  215. ret = check_xkb_enum("xkb_feature", None, header_path, extracted_data)
  216. else:
  217. ret = check_xkb_enum(
  218. "xkb_feature",
  219. header_path.with_name("xkbcommon-features.h"),
  220. header_path,
  221. extracted_data,
  222. )
  223. if ret:
  224. return ret
  225. # Generate the YAML output string
  226. extracted_yaml: str = format_to_yaml(header_path, extracted_data).strip()
  227. try:
  228. expected_yaml: str = yaml_path.read_text().strip()
  229. except Exception as e:
  230. print(f"Error reading YAML file: {e}", file=sys.stderr)
  231. return 1
  232. # Comparison
  233. if extracted_yaml == expected_yaml:
  234. print(f"Check SUCCESS: Extracted enum structure matches '{yaml_path}'.")
  235. return 0
  236. else:
  237. print(
  238. f"Check FAILED: Extracted enum structure does NOT match '{yaml_path}'.",
  239. file=sys.stderr,
  240. )
  241. # Split strings into lines for difflib, ensuring a trailing newline for the last line
  242. expected = expected_yaml.splitlines(keepends=True)
  243. got = extracted_yaml.splitlines(keepends=True)
  244. # Generate the unified diff
  245. diff_lines = difflib.unified_diff(
  246. expected,
  247. got,
  248. fromfile=str(yaml_path),
  249. tofile=f"(result from parsing header: {args.header_file})",
  250. )
  251. print("\n--- Unified Difference ---", file=sys.stderr)
  252. for line in diff_lines:
  253. print(line.rstrip("\n"), file=sys.stderr)
  254. return 1
  255. def check_command(args: argparse.Namespace) -> int:
  256. """
  257. Handles the 'check' subcommand: parses file and compares generated YAML
  258. against a target YAML file.
  259. Returns:
  260. int: The exit code (0 for success, 1 for failure).
  261. """
  262. header_path: Path
  263. if (header_path := args.header_file) is not None:
  264. if (yaml_path := args.yaml_file) is None:
  265. yaml_path = ENUMS_PATH / header_path.with_suffix(".yaml").name
  266. return check_header(header_path, yaml_path)
  267. else:
  268. for header_path in ALL_HEADERS:
  269. yaml_path = ENUMS_PATH / header_path.with_suffix(".yaml").name
  270. if ret := check_header(header_path, yaml_path):
  271. return ret
  272. return 0
  273. if __name__ == "__main__":
  274. parser: argparse.ArgumentParser = argparse.ArgumentParser(
  275. description="A tool to extract and check C enums using libclang"
  276. )
  277. parser.add_argument(
  278. "--libclang-path",
  279. type=Path,
  280. default=None,
  281. help="Path to the directory containing the libclang library.",
  282. )
  283. # Setup subparsers for commands
  284. subparsers: Any = parser.add_subparsers(
  285. dest="command", required=True, help="Available subcommands"
  286. )
  287. # UPDATE Command
  288. parser_update: argparse.ArgumentParser = subparsers.add_parser(
  289. "update", help="Parse headers, update YAML and C files."
  290. )
  291. parser_update.set_defaults(func=update_command)
  292. # EXPORT Command
  293. parser_export: argparse.ArgumentParser = subparsers.add_parser(
  294. "export", help="Parse header and print the YAML output."
  295. )
  296. parser_export.add_argument(
  297. "header_file", type=Path, help="Path to the C header file (.h) to be parsed."
  298. )
  299. parser_export.set_defaults(func=export_command)
  300. # CHECK Command
  301. parser_check: argparse.ArgumentParser = subparsers.add_parser(
  302. "check", help="Parse header and compare generated YAML against a target file."
  303. )
  304. parser_check.add_argument(
  305. "--header-file",
  306. type=Path,
  307. required=False,
  308. help="Path to the C header file (.h) to be parsed.",
  309. )
  310. parser_check.add_argument(
  311. "--yaml-file",
  312. type=Path,
  313. required=False,
  314. help="Path to the expected YAML file to compare against.",
  315. )
  316. parser_check.set_defaults(func=check_command)
  317. args: argparse.Namespace = parser.parse_args()
  318. # 1. Handle global configuration (libclang path) before running the command function
  319. if args.libclang_path:
  320. print(
  321. f"Setting libclang library path to: {args.libclang_path}", file=sys.stderr
  322. )
  323. try:
  324. Config.set_library_path(str(args.libclang_path))
  325. except Exception as e:
  326. print(f"Warning: Failed to set libclang path: {e}", file=sys.stderr)
  327. # 2. Run the specific command function
  328. exit_code: int = args.func(args)
  329. sys.exit(exit_code)