keysyms.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. #!/usr/bin/env python3
  2. # Copyright © 2026 Pierre Le Marre <dev@wismill.eu>
  3. # SPDX-License-Identifier: MIT
  4. """
  5. Utils to parse the keysyms headers
  6. """
  7. from __future__ import annotations
  8. import re
  9. from collections import defaultdict
  10. from dataclasses import dataclass
  11. from enum import Enum, StrEnum, auto, unique
  12. from pathlib import Path
  13. from typing import ClassVar, Generator, Iterable, Self, cast
  14. @dataclass
  15. class UnicodeCodePoint:
  16. cp: int
  17. char: str
  18. name: str
  19. @property
  20. def pretty_cp(self) -> str:
  21. return f"U+{self.cp:04X}"
  22. @property
  23. def markdown_cp(self) -> str:
  24. return f"`{self.pretty_cp}` {self.name}"
  25. def some_char(self, printable: bool | None = None) -> str:
  26. if printable is None or (not printable ^ self.char.isprintable()):
  27. return self.char
  28. else:
  29. return ""
  30. @unique
  31. class DeprecationReason(Enum):
  32. TYPO = auto()
  33. UNICODE_MISMATCH = auto()
  34. LEGACY_ALIAS = auto()
  35. IMPLICIT_ALIAS = auto()
  36. """
  37. Implicit deprecation: the keysym has already been defined with a previous
  38. name, and the present name has not been declared explicitly as an alias.
  39. """
  40. UNKNOWN = auto()
  41. @classmethod
  42. def parse(cls, raw: str) -> Self:
  43. raw_ = raw.casefold()
  44. if raw_.startswith("misspell") or "typo" in raw_:
  45. return cls.TYPO
  46. else:
  47. raise ValueError(f"Unknown deprecation reason: “{raw}”")
  48. @unique
  49. class Semantics(Enum):
  50. Default = auto()
  51. ComputerNumpad = auto()
  52. OtherKeypad = auto()
  53. @unique
  54. class KeysymCategory(StrEnum):
  55. Special = "Special"
  56. Latin1 = "Latin-1"
  57. Legacy = "Legacy"
  58. Function = "Function"
  59. Unicode = "Unicode"
  60. Vendor = "Vendor"
  61. @classmethod
  62. def from_keysym(cls, value: int) -> Self:
  63. if value == 0 or value == 0x00FFFFFF:
  64. return cls.Special
  65. elif (0x20 <= value <= 0x7E) or (0xA0 <= value <= 0xFF):
  66. return cls.Latin1
  67. elif (0x0100 <= value <= 0x13FF) or (0x0200 <= value <= 0x20FF):
  68. return cls.Legacy
  69. elif 0xFD00 <= value <= 0xFFFF:
  70. return cls.Function
  71. elif 0x01000000 <= value <= 0x0110FFFF:
  72. return cls.Unicode
  73. elif 0x10000000 <= value <= 0x1FFFFFFF:
  74. return cls.Vendor
  75. else:
  76. raise ValueError(value)
  77. @dataclass
  78. class Keysym:
  79. value: int
  80. name: str
  81. _canonical: Self | None
  82. """The canonical name if the name is an alias"""
  83. _preferred: Self | None
  84. """The preferred name if deprecated"""
  85. char: UnicodeCodePoint | None
  86. char_semantics: Semantics
  87. char_aliases: list[Self]
  88. aliases: list[Self]
  89. deprecation: DeprecationReason | None
  90. comment: str
  91. DUMMY_VALUE: ClassVar[int] = -1
  92. KEYSYM_ENTRY_PATTERN: ClassVar[re.Pattern[str]] = re.compile(
  93. r"""
  94. ^\#define\s+
  95. XKB_KEY_(?P<name>\w+)\s+
  96. (?P<value>0x[0-9a-fA-F]+)\s*
  97. (?:/\*(?P<comment>.*)\*/)?
  98. """,
  99. re.VERBOSE,
  100. )
  101. UNICODE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(
  102. r"""
  103. (?:(?P<alt_semantics><)|(?P<deprecated>\())?
  104. U\+(?P<code_point>[0-9a-fA-F]{4,})
  105. \s+
  106. (?P<name>(?:\w|-)+(?:\s+(?:\w|-)+)*)
  107. (?(alt_semantics)>)(?(deprecated)\))
  108. # TODO: alt semantics category
  109. """,
  110. re.VERBOSE,
  111. )
  112. DEPRECATION_ALIAS_PATTERN: ClassVar[re.Pattern[str]] = re.compile(
  113. r"""
  114. (?:
  115. \s+
  116. (?:
  117. non-deprecated |
  118. (?P<deprecated>deprecated)
  119. )
  120. )?
  121. (?:\s+alias\s+for\s+(?P<alias_target>\w+))?
  122. (?:
  123. (?:
  124. : |
  125. \s+(?P<parenthesis>\()
  126. )
  127. (?P<reason>.+)
  128. (?(parenthesis)\))
  129. )?
  130. """,
  131. re.VERBOSE | re.IGNORECASE,
  132. )
  133. @classmethod
  134. def new(cls, name: str, value: int) -> Self:
  135. return cls(
  136. name=name,
  137. value=value,
  138. char=None,
  139. char_semantics=Semantics.Default,
  140. char_aliases=[],
  141. _canonical=None,
  142. _preferred=None,
  143. aliases=[],
  144. deprecation=None,
  145. comment="",
  146. )
  147. @classmethod
  148. def parse(cls, raw: str) -> Self | None:
  149. if (m := cls.KEYSYM_ENTRY_PATTERN.match(raw)) is None:
  150. return None
  151. value = int(m.group("value"), 16)
  152. name = m.group("name")
  153. alias_target: Keysym | None = None
  154. deprecation: DeprecationReason | None = None
  155. char: UnicodeCodePoint | None = None
  156. char_semantics: Semantics = Semantics.Default
  157. if comment := m.group("comment"):
  158. if m := cls.UNICODE_PATTERN.search(comment):
  159. cp = int(m.group("code_point"), 16)
  160. char = UnicodeCodePoint(cp=cp, char=chr(cp), name=m.group("name"))
  161. if m.group("alt_semantics"):
  162. if name.startswith("KP_"):
  163. char_semantics = Semantics.ComputerNumpad
  164. elif name.startswith("XF86Numeric"):
  165. char_semantics = Semantics.OtherKeypad
  166. else:
  167. raise ValueError(f"Unknown semantics for: {name}")
  168. else:
  169. char_semantics = Semantics.Default
  170. if m.group("deprecated"):
  171. deprecation = DeprecationReason.UNICODE_MISMATCH
  172. elif m := cls.DEPRECATION_ALIAS_PATTERN.match(comment):
  173. if target := m.group("alias_target"):
  174. alias_target = cls.new(name=target, value=cls.DUMMY_VALUE)
  175. if m.group("deprecated"):
  176. if alias_target is not None:
  177. deprecation = DeprecationReason.LEGACY_ALIAS
  178. elif reason := m.group("reason"):
  179. deprecation = DeprecationReason.parse(reason)
  180. else:
  181. deprecation = DeprecationReason.UNKNOWN
  182. return cls(
  183. name=name,
  184. value=value,
  185. char=char,
  186. char_semantics=char_semantics,
  187. char_aliases=[],
  188. _canonical=None,
  189. _preferred=alias_target,
  190. aliases=[],
  191. deprecation=deprecation,
  192. comment=comment,
  193. )
  194. @property
  195. def canonical(self) -> Self:
  196. if self._canonical is None:
  197. raise ValueError(self)
  198. else:
  199. return self._canonical
  200. @property
  201. def is_canonical(self) -> bool:
  202. return self._canonical is self
  203. @property
  204. def preferred(self) -> Self:
  205. if self._preferred is None:
  206. raise ValueError(self)
  207. else:
  208. return self._preferred
  209. @property
  210. def is_preferred(self) -> bool:
  211. return self._preferred is self
  212. @property
  213. def deprecated(self) -> bool:
  214. """Deprecated name"""
  215. return self.deprecation is not None
  216. @property
  217. def deprecated_keysym(self) -> bool:
  218. """Deprecated keysym"""
  219. return (
  220. (self.deprecated and all(k.deprecated for k in self.aliases))
  221. if self.is_canonical
  222. else self.canonical.deprecated_keysym
  223. )
  224. @property
  225. def is_dummy(self) -> bool:
  226. return self.value == self.DUMMY_VALUE
  227. @property
  228. def pretty_value(self) -> str:
  229. return f"{self.value:#06x}"
  230. @property
  231. def macro(self) -> str:
  232. return f"XKB_KEY_{self.name}"
  233. @property
  234. def category(self) -> KeysymCategory:
  235. return KeysymCategory.from_keysym(self.value)
  236. @dataclass
  237. class Keysyms:
  238. all: list[Keysym]
  239. by_value: dict[int, list[Keysym]]
  240. by_name: dict[str, Keysym]
  241. by_char: dict[int, list[Keysym]]
  242. @classmethod
  243. def _parse(cls, raw: Iterable[str]) -> Generator[Keysym | str, None, Self]:
  244. keysyms = cls(
  245. all=[],
  246. by_value=defaultdict(list),
  247. by_name={},
  248. by_char=defaultdict(list),
  249. )
  250. # Parse all the keysyms
  251. for line, k in ((line_, Keysym.parse(line_)) for line_ in raw):
  252. if k is None:
  253. yield line
  254. else:
  255. yield k
  256. keysyms.all.append(k)
  257. for keysym in keysyms.all:
  258. if previous := keysyms.by_value.get(keysym.value):
  259. # There are some previous names with this value
  260. assert keysym._canonical is None
  261. # First name is the canonical name
  262. canonical = previous[0]
  263. if (
  264. keysym._preferred is None # implicit alias
  265. and keysym.deprecation is None
  266. and any(k.deprecation is None for k in previous)
  267. ):
  268. # Implicit alias with at least one previous non-deprecated name
  269. keysym.deprecation = DeprecationReason.IMPLICIT_ALIAS
  270. elif (
  271. keysym._preferred is None # implicit alias
  272. and keysym.deprecation is DeprecationReason.UNKNOWN
  273. ):
  274. keysym.deprecation = DeprecationReason.LEGACY_ALIAS
  275. keysym._canonical = canonical
  276. # Aliases
  277. keysym.aliases.append(canonical)
  278. canonical.aliases.append(keysym)
  279. # Preferred name is the first non-explicit alias and non-deprecated name
  280. if (
  281. canonical._preferred is not None
  282. and not canonical._preferred.is_dummy
  283. ):
  284. # Preferred name already resolved
  285. if (
  286. keysym._preferred is not None
  287. and keysym._preferred.name != canonical._preferred.name
  288. ):
  289. # Explicit alias does not point to the preferred name
  290. assert keysym._preferred.is_dummy, (
  291. canonical._preferred,
  292. keysym,
  293. )
  294. raise ValueError((canonical._preferred, keysym))
  295. elif (
  296. keysym.char is not None
  297. and keysym.char != canonical._preferred.char
  298. ):
  299. # New keysym and the preferred name have distinct chars
  300. raise ValueError((canonical._preferred, keysym))
  301. else:
  302. keysym._preferred = canonical._preferred
  303. elif keysym._preferred is None and keysym.deprecation is None:
  304. # New preferred name
  305. keysym._preferred = keysym
  306. for k in previous:
  307. if (
  308. k._preferred is not None
  309. and k._preferred.name != keysym.name
  310. ):
  311. # Explicit alias does not point to the preferred name
  312. raise ValueError((keysym, k))
  313. elif k.char is not None:
  314. # The first char definition should be in the preferred name
  315. raise ValueError((keysym, k))
  316. else:
  317. k._preferred = keysym
  318. else:
  319. # First name is the canonical name
  320. keysym._canonical = keysym
  321. if keysym._preferred is None and keysym.deprecation is None:
  322. keysym._preferred = keysym
  323. keysyms.by_value[keysym.value].append(keysym)
  324. if conflict := keysyms.by_name.get(keysym.name):
  325. raise ValueError(f"Name conflict: {keysym} conflicts with {conflict}")
  326. keysyms.by_name[keysym.name] = keysym
  327. # Resolve pending preferred names
  328. for ks in keysyms.by_value.values():
  329. canonical = ks[0]
  330. assert canonical.is_canonical, ks
  331. if len(ks) == 1:
  332. # Check that a keysym with a single name has no explicit alias
  333. if canonical._preferred is not None:
  334. if canonical._preferred.is_dummy:
  335. raise ValueError(canonical)
  336. else:
  337. assert canonical._preferred is canonical
  338. else:
  339. # No choice!
  340. canonical._preferred = canonical
  341. elif canonical._preferred is None or canonical._preferred.is_dummy:
  342. preferred: Keysym | None = None
  343. for k in ks:
  344. if k.deprecated and k._preferred is not None:
  345. continue
  346. elif not k.deprecated and k._preferred is None:
  347. # Missed in the initialization!
  348. raise ValueError(ks)
  349. elif preferred is None or (
  350. not k.deprecated and preferred.deprecated
  351. ):
  352. # First or better candidate
  353. preferred = k
  354. if preferred is None:
  355. raise ValueError(ks)
  356. for k in ks:
  357. k._preferred = preferred
  358. # Now that canonical names are resolved, process chars
  359. for keysym in sorted(
  360. # Skip non-canonical keysyms and keysyms without associated char
  361. (k for k in keysyms.all if k.is_canonical and k.char is not None),
  362. # Sort by ascending code point and keysym value
  363. key=lambda k: (cast(UnicodeCodePoint, k.char).cp, k.value),
  364. ):
  365. cp = cast(UnicodeCodePoint, keysym.char).cp
  366. if previous := keysyms.by_char.get(cp):
  367. for k in previous:
  368. keysym.char_aliases.append(k)
  369. k.char_aliases.append(keysym)
  370. keysyms.by_char[cp].append(keysym)
  371. return keysyms
  372. @classmethod
  373. def parse(cls, raw: Iterable[str]) -> Self:
  374. gen = cls._parse(raw)
  375. try:
  376. while True:
  377. next(gen)
  378. except StopIteration as e:
  379. return e.value
  380. @classmethod
  381. def parse_iter(cls, raw: Iterable[str]) -> Iterable[Keysym | str]:
  382. # Iterate all the file to resolve the keysym
  383. acc: list[Keysym | str] = []
  384. gen = cls._parse(raw)
  385. while True:
  386. try:
  387. acc.append(next(gen))
  388. except StopIteration:
  389. break
  390. # Yielf the resolved keysym
  391. yield from acc
  392. @classmethod
  393. def parse_file(cls, path: Path) -> Self:
  394. with path.open("rt", encoding="utf-8") as f:
  395. return cls.parse(f)
  396. @classmethod
  397. def parse_iter_file(cls, path: Path) -> Iterable[Keysym | str]:
  398. with path.open("rt", encoding="utf-8") as f:
  399. yield from cls.parse_iter(f)