keysym-generator.py revision d63b911f
1d63b911fSmrg#!/usr/bin/env python3 2d63b911fSmrg# 3d63b911fSmrg# SPDX-License-Identifier: MIT 4d63b911fSmrg# 5d63b911fSmrg# This script checks XF86keysym.h for the reserved evdev keysym range and/or 6d63b911fSmrg# appends new keysym to that range. An up-to-date libevdev must be 7d63b911fSmrg# available to guarantee the correct keycode ranges and names. 8d63b911fSmrg# 9d63b911fSmrg# Run with --help for usage information. 10d63b911fSmrg# 11d63b911fSmrg# 12d63b911fSmrg# File is formatted with Python Black 13d63b911fSmrg 14d63b911fSmrgimport argparse 15d63b911fSmrgimport logging 16d63b911fSmrgimport sys 17d63b911fSmrgimport re 18d63b911fSmrgimport libevdev 19d63b911fSmrgimport subprocess 20d63b911fSmrgfrom pathlib import Path 21d63b911fSmrg 22d63b911fSmrglogging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") 23d63b911fSmrglogger = logging.getLogger("ksgen") 24d63b911fSmrg 25d63b911fSmrgstart_token = re.compile(r"#define _EVDEVK.*") 26d63b911fSmrgend_token = re.compile(r"#undef _EVDEVK\n") 27d63b911fSmrg 28d63b911fSmrg 29d63b911fSmrgdef die(msg): 30d63b911fSmrg logger.critical(msg) 31d63b911fSmrg sys.exit(1) 32d63b911fSmrg 33d63b911fSmrg 34d63b911fSmrgdef all_keysyms(directory): 35d63b911fSmrg """ 36d63b911fSmrg Extract the key names for all keysyms we have in our repo and return 37d63b911fSmrg them as list. 38d63b911fSmrg """ 39d63b911fSmrg keysym_names = [] 40d63b911fSmrg pattern = re.compile(r"^#define\s+(?P<name>\w+)\s+(0x[0-9A-Fa-f]+)") 41d63b911fSmrg for path in directory.glob("*keysym*.h"): 42d63b911fSmrg with open(path) as fd: 43d63b911fSmrg for line in fd: 44d63b911fSmrg match = re.match(pattern, line) 45d63b911fSmrg if match: 46d63b911fSmrg keysym_names.append(match.group("name")) 47d63b911fSmrg return keysym_names 48d63b911fSmrg 49d63b911fSmrg 50d63b911fSmrgclass Kernel(object): 51d63b911fSmrg """ 52d63b911fSmrg Wrapper around the kernel git tree to simplify searching for when a 53d63b911fSmrg particular keycode was introduced. 54d63b911fSmrg """ 55d63b911fSmrg 56d63b911fSmrg def __init__(self, repo): 57d63b911fSmrg self.repo = repo 58d63b911fSmrg 59d63b911fSmrg exitcode, stdout, stderr = self.git_command("git branch --show-current") 60d63b911fSmrg if exitcode != 0: 61d63b911fSmrg die(f"{stderr}") 62d63b911fSmrg if stdout.strip() != "master": 63d63b911fSmrg die(f"Kernel repo must be on the master branch (current: {stdout.strip()})") 64d63b911fSmrg 65d63b911fSmrg exitcode, stdout, stderr = self.git_command("git tag --sort=version:refname") 66d63b911fSmrg tags = stdout.split("\n") 67d63b911fSmrg self.versions = list( 68d63b911fSmrg filter(lambda v: re.match(r"^v[2-6]\.[0-9]+(\.[0-9]+)?$", v), tags) 69d63b911fSmrg ) 70d63b911fSmrg logger.debug(f"Kernel versions: {', '.join(self.versions)}") 71d63b911fSmrg 72d63b911fSmrg def git_command(self, cmd): 73d63b911fSmrg """ 74d63b911fSmrg Takes a single-string git command and runs it in the repo. 75d63b911fSmrg 76d63b911fSmrg Returns the tuple (exitcode, stdout, stderr) 77d63b911fSmrg """ 78d63b911fSmrg # logger.debug(f"git command: {cmd}") 79d63b911fSmrg try: 80d63b911fSmrg result = subprocess.run( 81d63b911fSmrg cmd.split(" "), cwd=self.repo, capture_output=True, encoding="utf8" 82d63b911fSmrg ) 83d63b911fSmrg if result.returncode == 128: 84d63b911fSmrg die(f"{result.stderr}") 85d63b911fSmrg 86d63b911fSmrg return result.returncode, result.stdout, result.stderr 87d63b911fSmrg except FileNotFoundError: 88d63b911fSmrg die(f"{self.repo} is not a git repository") 89d63b911fSmrg 90d63b911fSmrg def introduced_in_version(self, string): 91d63b911fSmrg """ 92d63b911fSmrg Search this repo for the first version with string in the headers. 93d63b911fSmrg 94d63b911fSmrg Returns the kernel version number (e.g. "v5.10") or None 95d63b911fSmrg """ 96d63b911fSmrg 97d63b911fSmrg # The fastest approach is to git grep every version for the string 98d63b911fSmrg # and return the first. Using git log -G and then git tag --contains 99d63b911fSmrg # is an order of magnitude slower. 100d63b911fSmrg def found_in_version(v): 101d63b911fSmrg cmd = f"git grep -E \\<{string}\\> {v} -- include/" 102d63b911fSmrg exitcode, _, _ = self.git_command(cmd) 103d63b911fSmrg return exitcode == 0 104d63b911fSmrg 105d63b911fSmrg def bisect(iterable, func): 106d63b911fSmrg """ 107d63b911fSmrg Return the first element in iterable for which func 108d63b911fSmrg returns True. 109d63b911fSmrg """ 110d63b911fSmrg # bias to speed things up: most keycodes will be in the first 111d63b911fSmrg # kernel version 112d63b911fSmrg if func(iterable[0]): 113d63b911fSmrg return iterable[0] 114d63b911fSmrg 115d63b911fSmrg lo, hi = 0, len(iterable) 116d63b911fSmrg while lo < hi: 117d63b911fSmrg mid = (lo + hi) // 2 118d63b911fSmrg if func(iterable[mid]): 119d63b911fSmrg hi = mid 120d63b911fSmrg else: 121d63b911fSmrg lo = mid + 1 122d63b911fSmrg return iterable[hi] 123d63b911fSmrg 124d63b911fSmrg version = bisect(self.versions, found_in_version) 125d63b911fSmrg logger.debug(f"Bisected {string} to {version}") 126d63b911fSmrg # 2.6.11 doesn't count, that's the start of git 127d63b911fSmrg return version if version != self.versions[0] else None 128d63b911fSmrg 129d63b911fSmrg 130d63b911fSmrgdef generate_keysym_line(code, kernel, kver_list=[]): 131d63b911fSmrg """ 132d63b911fSmrg Generate the line to append to the keysym file. 133d63b911fSmrg 134d63b911fSmrg This format is semi-ABI, scripts rely on the format of this line (e.g. in 135d63b911fSmrg xkeyboard-config). 136d63b911fSmrg """ 137d63b911fSmrg evcode = libevdev.evbit(libevdev.EV_KEY.value, code) 138d63b911fSmrg if not evcode.is_defined: # codes without a #define in the kernel 139d63b911fSmrg return None 140d63b911fSmrg if evcode.name.startswith("BTN_"): 141d63b911fSmrg return None 142d63b911fSmrg 143d63b911fSmrg name = "".join([s.capitalize() for s in evcode.name[4:].lower().split("_")]) 144d63b911fSmrg keysym = f"XF86XK_{name}" 145d63b911fSmrg tabs = 4 - len(keysym) // 8 146d63b911fSmrg kver = kernel.introduced_in_version(evcode.name) or " " 147d63b911fSmrg if kver_list: 148d63b911fSmrg from fnmatch import fnmatch 149d63b911fSmrg 150d63b911fSmrg allowed_kvers = [v.strip() for v in kver_list.split(",")] 151d63b911fSmrg for allowed in allowed_kvers: 152d63b911fSmrg if fnmatch(kver, allowed): 153d63b911fSmrg break 154d63b911fSmrg else: # no match 155d63b911fSmrg return None 156d63b911fSmrg 157d63b911fSmrg return f"#define {keysym}{' ' * tabs}_EVDEVK(0x{code:03X}) /* {kver:5s} {evcode.name} */" 158d63b911fSmrg 159d63b911fSmrg 160d63b911fSmrgdef verify(ns): 161d63b911fSmrg """ 162d63b911fSmrg Verify that the XF86keysym.h file follows the requirements. Since we expect 163d63b911fSmrg the header file to be parsed by outside scripts, the requirements for the format 164d63b911fSmrg are quite strict, including things like correct-case hex codes. 165d63b911fSmrg """ 166d63b911fSmrg 167d63b911fSmrg # No other keysym must use this range 168d63b911fSmrg reserved_range = re.compile(r"#define.*0x10081.*") 169d63b911fSmrg normal_range = re.compile(r"#define.*0x1008.*") 170d63b911fSmrg 171d63b911fSmrg # This is the full pattern we expect. 172d63b911fSmrg expected_pattern = re.compile( 173d63b911fSmrg r"#define XF86XK_\w+\t+_EVDEVK\(0x([0-9A-F]{3})\)\t+/\* (v[2-6]\.[0-9]+(\.[0-9]+)?)? +KEY_\w+ \*/" 174d63b911fSmrg ) 175d63b911fSmrg # This is the comment pattern we expect 176d63b911fSmrg expected_comment_pattern = re.compile( 177d63b911fSmrg r"/\* Use: (?P<name>\w+)\t+_EVDEVK\(0x(?P<value>[0-9A-F]{3})\)\t+ (v[2-6]\.[0-9]+(\.[0-9]+)?)? +KEY_\w+ \*/" 178d63b911fSmrg ) 179d63b911fSmrg 180d63b911fSmrg # Some patterns to spot specific errors, just so we can print useful errors 181d63b911fSmrg define = re.compile(r"^#define .*") 182d63b911fSmrg name_pattern = re.compile(r"#define (XF86XK_[^\s]*)") 183d63b911fSmrg tab_check = re.compile(r"#define \w+(\s+)[^\s]+(\s+)") 184d63b911fSmrg hex_pattern = re.compile(r".*0x([a-f0-9]+).*", re.I) 185d63b911fSmrg comment_format = re.compile(r".*/\* ([^\s]+)?\s+(\w+)") 186d63b911fSmrg kver_format = re.compile(r"v[2-6]\.[0-9]+(\.[0-9]+)?") 187d63b911fSmrg 188d63b911fSmrg in_evdev_codes_section = False 189d63b911fSmrg had_evdev_codes_section = False 190d63b911fSmrg success = True 191d63b911fSmrg 192d63b911fSmrg all_defines = [] 193d63b911fSmrg 194d63b911fSmrg all_keysym_names = all_keysyms(ns.header.parent) 195d63b911fSmrg 196d63b911fSmrg class ParserError(Exception): 197d63b911fSmrg pass 198d63b911fSmrg 199d63b911fSmrg def error(msg, line): 200d63b911fSmrg raise ParserError(f"{msg} in '{line.strip()}'") 201d63b911fSmrg 202d63b911fSmrg last_keycode = 0 203d63b911fSmrg for line in open(ns.header): 204d63b911fSmrg try: 205d63b911fSmrg if not in_evdev_codes_section: 206d63b911fSmrg if re.match(start_token, line): 207d63b911fSmrg in_evdev_codes_section = True 208d63b911fSmrg had_evdev_codes_section = True 209d63b911fSmrg continue 210d63b911fSmrg 211d63b911fSmrg if re.match(reserved_range, line): 212d63b911fSmrg error("Using reserved range", line) 213d63b911fSmrg match = re.match(name_pattern, line) 214d63b911fSmrg if match: 215d63b911fSmrg all_defines.append(match.group(1)) 216d63b911fSmrg else: 217d63b911fSmrg # Within the evdev defines section 218d63b911fSmrg if re.match(end_token, line): 219d63b911fSmrg in_evdev_codes_section = False 220d63b911fSmrg continue 221d63b911fSmrg 222d63b911fSmrg # Comments we only search for a hex pattern and where there is one present 223d63b911fSmrg # we only check for uppercase format, ordering and update our last_keycode. 224d63b911fSmrg if not re.match(define, line): 225d63b911fSmrg match = re.match(expected_comment_pattern, line) 226d63b911fSmrg if match: 227d63b911fSmrg hexcode = match.group("value") 228d63b911fSmrg if hexcode != hexcode.upper(): 229d63b911fSmrg error(f"Hex code 0x{hexcode} must be uppercase", line) 230d63b911fSmrg if hexcode: 231d63b911fSmrg keycode = int(hexcode, 16) 232d63b911fSmrg if keycode < last_keycode: 233d63b911fSmrg error("Keycode must be ascending", line) 234d63b911fSmrg if keycode == last_keycode: 235d63b911fSmrg error("Duplicate keycode", line) 236d63b911fSmrg last_keycode = keycode 237d63b911fSmrg 238d63b911fSmrg name = match.group("name") 239d63b911fSmrg if name not in all_keysym_names: 240d63b911fSmrg error(f"Unknown keysym {name}", line) 241d63b911fSmrg elif re.match(hex_pattern, line): 242d63b911fSmrg logger.warning(f"Unexpected hex code in {line}") 243d63b911fSmrg continue 244d63b911fSmrg 245d63b911fSmrg # Anything below here is a #define line 246d63b911fSmrg # Let's check for specific errors 247d63b911fSmrg if re.match(normal_range, line): 248d63b911fSmrg error("Define must use _EVDEVK", line) 249d63b911fSmrg 250d63b911fSmrg match = re.match(name_pattern, line) 251d63b911fSmrg if match: 252d63b911fSmrg if match.group(1) in all_defines: 253d63b911fSmrg error("Duplicate define", line) 254d63b911fSmrg all_defines.append(match.group(1)) 255d63b911fSmrg else: 256d63b911fSmrg error("Typo", line) 257d63b911fSmrg 258d63b911fSmrg match = re.match(hex_pattern, line) 259d63b911fSmrg if not match: 260d63b911fSmrg error("No hex code", line) 261d63b911fSmrg if match.group(1) != match.group(1).upper(): 262d63b911fSmrg error(f"Hex code 0x{match.group(1)} must be uppercase", line) 263d63b911fSmrg 264d63b911fSmrg tabs = re.match(tab_check, line) 265d63b911fSmrg if not tabs: # bug 266d63b911fSmrg error("Matching error", line) 267d63b911fSmrg if " " in tabs.group(1) or " " in tabs.group(2): 268d63b911fSmrg error("Use tabs, not spaces", line) 269d63b911fSmrg 270d63b911fSmrg comment = re.match(comment_format, line) 271d63b911fSmrg if not comment: 272d63b911fSmrg error("Invalid comment format", line) 273d63b911fSmrg kver = comment.group(1) 274d63b911fSmrg if kver and not re.match(kver_format, kver): 275d63b911fSmrg error("Invalid kernel version format", line) 276d63b911fSmrg 277d63b911fSmrg keyname = comment.group(2) 278d63b911fSmrg if not keyname.startswith("KEY_") or keyname.upper() != keyname: 279d63b911fSmrg error("Kernel keycode name invalid", line) 280d63b911fSmrg 281d63b911fSmrg # This could be an old libevdev 282d63b911fSmrg if keyname not in [c.name for c in libevdev.EV_KEY.codes]: 283d63b911fSmrg logger.warning(f"Unknown kernel keycode name {keyname}") 284d63b911fSmrg 285d63b911fSmrg # Check the full expected format, no better error messages 286d63b911fSmrg # available if this fails 287d63b911fSmrg match = re.match(expected_pattern, line) 288d63b911fSmrg if not match: 289d63b911fSmrg error("Failed match", line) 290d63b911fSmrg 291d63b911fSmrg keycode = int(match.group(1), 16) 292d63b911fSmrg if keycode < last_keycode: 293d63b911fSmrg error("Keycode must be ascending", line) 294d63b911fSmrg if keycode == last_keycode: 295d63b911fSmrg error("Duplicate keycode", line) 296d63b911fSmrg 297d63b911fSmrg # May cause a false positive for old libevdev if KEY_MAX is bumped 298d63b911fSmrg if keycode < 0x0A0 or keycode > libevdev.EV_KEY.KEY_MAX.value: 299d63b911fSmrg error("Keycode outside range", line) 300d63b911fSmrg 301d63b911fSmrg last_keycode = keycode 302d63b911fSmrg except ParserError as e: 303d63b911fSmrg logger.error(e) 304d63b911fSmrg success = False 305d63b911fSmrg 306d63b911fSmrg if not had_evdev_codes_section: 307d63b911fSmrg logger.error("Unable to locate EVDEVK section") 308d63b911fSmrg success = False 309d63b911fSmrg elif in_evdev_codes_section: 310d63b911fSmrg logger.error("Unterminated EVDEVK section") 311d63b911fSmrg success = False 312d63b911fSmrg 313d63b911fSmrg if success: 314d63b911fSmrg logger.info("Verification succeeded") 315d63b911fSmrg 316d63b911fSmrg return 0 if success else 1 317d63b911fSmrg 318d63b911fSmrg 319d63b911fSmrgdef add_keysyms(ns): 320d63b911fSmrg """ 321d63b911fSmrg Print a new XF86keysym.h file, adding any *missing* keycodes to the existing file. 322d63b911fSmrg """ 323d63b911fSmrg if verify(ns) != 0: 324d63b911fSmrg die("Header file verification failed") 325d63b911fSmrg 326d63b911fSmrg # If verification succeeds, we can be a bit more lenient here because we already know 327d63b911fSmrg # what the format of the field is. Specifically, we're searching for 328d63b911fSmrg # 3-digit hexcode in brackets and use that as keycode. 329d63b911fSmrg pattern = re.compile(r".*_EVDEVK\((0x[a-fA-F0-9]{3})\).*") 330d63b911fSmrg max_code = max( 331d63b911fSmrg [ 332d63b911fSmrg c.value 333d63b911fSmrg for c in libevdev.EV_KEY.codes 334d63b911fSmrg if c.is_defined 335d63b911fSmrg and c != libevdev.EV_KEY.KEY_MAX 336d63b911fSmrg and not c.name.startswith("BTN") 337d63b911fSmrg ] 338d63b911fSmrg ) 339d63b911fSmrg 340d63b911fSmrg def defined_keycodes(path): 341d63b911fSmrg """ 342d63b911fSmrg Returns an iterator to the next #defined (or otherwise mentioned) 343d63b911fSmrg keycode, all other lines (including the returned one) are passed 344d63b911fSmrg through to printf. 345d63b911fSmrg """ 346d63b911fSmrg with open(path) as fd: 347d63b911fSmrg in_evdev_codes_section = False 348d63b911fSmrg 349d63b911fSmrg for line in fd: 350d63b911fSmrg if not in_evdev_codes_section: 351d63b911fSmrg if re.match(start_token, line): 352d63b911fSmrg in_evdev_codes_section = True 353d63b911fSmrg # passthrough for all other lines 354d63b911fSmrg print(line, end="") 355d63b911fSmrg else: 356d63b911fSmrg if re.match(r"#undef _EVDEVK\n", line): 357d63b911fSmrg in_evdev_codes_section = False 358d63b911fSmrg yield max_code 359d63b911fSmrg else: 360d63b911fSmrg match = re.match(pattern, line) 361d63b911fSmrg if match: 362d63b911fSmrg logger.debug(f"Found keycode in {line.strip()}") 363d63b911fSmrg yield int(match.group(1), 16) 364d63b911fSmrg print(line, end="") 365d63b911fSmrg 366d63b911fSmrg kernel = Kernel(ns.kernel_git_tree) 367d63b911fSmrg prev_code = 255 - 8 # the last keycode we can map directly in X 368d63b911fSmrg for code in defined_keycodes(ns.header): 369d63b911fSmrg for missing in range(prev_code + 1, code): 370d63b911fSmrg newline = generate_keysym_line( 371d63b911fSmrg missing, kernel, kver_list=ns.kernel_versions 372d63b911fSmrg ) 373d63b911fSmrg if newline: 374d63b911fSmrg print(newline) 375d63b911fSmrg prev_code = code 376d63b911fSmrg 377d63b911fSmrg return 0 378d63b911fSmrg 379d63b911fSmrg 380d63b911fSmrgdef find_xf86keysym_header(): 381d63b911fSmrg """ 382d63b911fSmrg Search for the XF86keysym.h file in the current tree or use the system one 383d63b911fSmrg as last resort. This is a convenience function for running the script 384d63b911fSmrg locally, it should not be relied on in the CI. 385d63b911fSmrg """ 386d63b911fSmrg paths = tuple(Path.cwd().glob("**/XF86keysym.h")) 387d63b911fSmrg if not paths: 388d63b911fSmrg path = Path("/usr/include/X11/XF86keysym.h") 389d63b911fSmrg if not path.exists(): 390d63b911fSmrg die("Unable to find XF86keysym.h in CWD or /usr") 391d63b911fSmrg else: 392d63b911fSmrg if len(paths) > 1: 393d63b911fSmrg die("Multiple XF86keysym.h in CWD, please use --header") 394d63b911fSmrg path = paths[0] 395d63b911fSmrg 396d63b911fSmrg logger.info(f"Using header file {path}") 397d63b911fSmrg return path 398d63b911fSmrg 399d63b911fSmrg 400d63b911fSmrgdef main(): 401d63b911fSmrg parser = argparse.ArgumentParser(description="Keysym parser script") 402d63b911fSmrg parser.add_argument("--verbose", "-v", action="count", default=0) 403d63b911fSmrg parser.add_argument( 404d63b911fSmrg "--header", 405d63b911fSmrg type=str, 406d63b911fSmrg default=None, 407d63b911fSmrg help="Path to the XF86Keysym.h header file (default: search $CWD)", 408d63b911fSmrg ) 409d63b911fSmrg 410d63b911fSmrg subparsers = parser.add_subparsers(help="command-specific help", dest="command") 411d63b911fSmrg parser_verify = subparsers.add_parser( 412d63b911fSmrg "verify", help="Verify the XF86keysym.h matches requirements" 413d63b911fSmrg ) 414d63b911fSmrg parser_verify.set_defaults(func=verify) 415d63b911fSmrg 416d63b911fSmrg parser_generate = subparsers.add_parser( 417d63b911fSmrg "add-keysyms", help="Add missing keysyms to the existing ones" 418d63b911fSmrg ) 419d63b911fSmrg parser_generate.add_argument( 420d63b911fSmrg "--kernel-git-tree", 421d63b911fSmrg type=str, 422d63b911fSmrg default=None, 423d63b911fSmrg required=True, 424d63b911fSmrg help="Path to a kernel git repo, required to find git tags", 425d63b911fSmrg ) 426d63b911fSmrg parser_generate.add_argument( 427d63b911fSmrg "--kernel-versions", 428d63b911fSmrg type=str, 429d63b911fSmrg default=[], 430d63b911fSmrg required=False, 431d63b911fSmrg help="Comma-separated list of kernel versions to limit ourselves to (e.g. 'v5.10,v5.9'). Supports fnmatch.", 432d63b911fSmrg ) 433d63b911fSmrg parser_generate.set_defaults(func=add_keysyms) 434d63b911fSmrg ns = parser.parse_args() 435d63b911fSmrg 436d63b911fSmrg logger.setLevel( 437d63b911fSmrg {2: logging.DEBUG, 1: logging.INFO, 0: logging.WARNING}.get(ns.verbose, 2) 438d63b911fSmrg ) 439d63b911fSmrg 440d63b911fSmrg if not ns.header: 441d63b911fSmrg ns.header = find_xf86keysym_header() 442d63b911fSmrg else: 443d63b911fSmrg ns.header = Path(ns.header) 444d63b911fSmrg 445d63b911fSmrg if ns.command is None: 446d63b911fSmrg parser.error("Invalid or missing command") 447d63b911fSmrg 448d63b911fSmrg sys.exit(ns.func(ns)) 449d63b911fSmrg 450d63b911fSmrg 451d63b911fSmrgif __name__ == "__main__": 452d63b911fSmrg main() 453