diff options
Diffstat (limited to 'scripts/keysym-generator.py')
-rwxr-xr-x | scripts/keysym-generator.py | 429 |
1 files changed, 429 insertions, 0 deletions
diff --git a/scripts/keysym-generator.py b/scripts/keysym-generator.py new file mode 100755 index 0000000..6bcde61 --- /dev/null +++ b/scripts/keysym-generator.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +# +# SPDX-License-Identifier: MIT +# +# This script checks XF86keysym.h for the reserved evdev keysym range and/or +# appends new keysym to that range. An up-to-date libevdev must be +# available to guarantee the correct keycode ranges and names. +# +# Run with --help for usage information. +# +# +# File is formatted with Python Black + +import argparse +import logging +import sys +import re +import libevdev +import subprocess +from pathlib import Path + +logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") +logger = logging.getLogger("ksgen") + +start_token = re.compile(r"#define _EVDEVK.*") +end_token = re.compile(r"#undef _EVDEVK\n") + + +def die(msg): + logger.critical(msg) + sys.exit(1) + + +class Kernel(object): + """ + Wrapper around the kernel git tree to simplify searching for when a + particular keycode was introduced. + """ + + def __init__(self, repo): + self.repo = repo + + exitcode, stdout, stderr = self.git_command("git branch --show-current") + if exitcode != 0: + die(f"{stderr}") + if stdout.strip() != "master": + die(f"Kernel repo must be on the master branch (current: {stdout.strip()})") + + exitcode, stdout, stderr = self.git_command("git tag --sort=version:refname") + tags = stdout.split("\n") + self.versions = list( + filter(lambda v: re.match(r"^v[2-6]\.[0-9]+(\.[0-9]+)?$", v), tags) + ) + logger.debug(f"Kernel versions: {', '.join(self.versions)}") + + def git_command(self, cmd): + """ + Takes a single-string git command and runs it in the repo. + + Returns the tuple (exitcode, stdout, stderr) + """ + # logger.debug(f"git command: {cmd}") + try: + result = subprocess.run( + cmd.split(" "), cwd=self.repo, capture_output=True, encoding="utf8" + ) + if result.returncode == 128: + die(f"{result.stderr}") + + return result.returncode, result.stdout, result.stderr + except FileNotFoundError: + die(f"{self.repo} is not a git repository") + + def introduced_in_version(self, string): + """ + Search this repo for the first version with string in the headers. + + Returns the kernel version number (e.g. "v5.10") or None + """ + + # The fastest approach is to git grep every version for the string + # and return the first. Using git log -G and then git tag --contains + # is an order of magnitude slower. + def found_in_version(v): + cmd = f"git grep -E \\<{string}\\> {v} -- include/" + exitcode, _, _ = self.git_command(cmd) + return exitcode == 0 + + def bisect(iterable, func): + """ + Return the first element in iterable for which func + returns True. + """ + # bias to speed things up: most keycodes will be in the first + # kernel version + if func(iterable[0]): + return iterable[0] + + lo, hi = 0, len(iterable) + while lo < hi: + mid = (lo + hi) // 2 + if func(iterable[mid]): + hi = mid + else: + lo = mid + 1 + return iterable[hi] + + version = bisect(self.versions, found_in_version) + logger.debug(f"Bisected {string} to {version}") + # 2.6.11 doesn't count, that's the start of git + return version if version != self.versions[0] else None + + +def generate_keysym_line(code, kernel, kver_list=[]): + """ + Generate the line to append to the keysym file. + + This format is semi-ABI, scripts rely on the format of this line (e.g. in + xkeyboard-config). + """ + evcode = libevdev.evbit(libevdev.EV_KEY.value, code) + if not evcode.is_defined: # codes without a #define in the kernel + return None + if evcode.name.startswith("BTN_"): + return None + + name = "".join([s.capitalize() for s in evcode.name[4:].lower().split("_")]) + keysym = f"XF86XK_{name}" + tabs = 4 - len(keysym) // 8 + kver = kernel.introduced_in_version(evcode.name) or " " + if kver_list: + from fnmatch import fnmatch + + allowed_kvers = [v.strip() for v in kver_list.split(",")] + for allowed in allowed_kvers: + if fnmatch(kver, allowed): + break + else: # no match + return None + + return f"#define {keysym}{' ' * tabs}_EVDEVK(0x{code:03X}) /* {kver:5s} {evcode.name} */" + + +def verify(ns): + """ + Verify that the XF86keysym.h file follows the requirements. Since we expect + the header file to be parsed by outside scripts, the requirements for the format + are quite strict, including things like correct-case hex codes. + """ + + # No other keysym must use this range + reserved_range = re.compile(r"#define.*0x10081.*") + normal_range = re.compile(r"#define.*0x1008.*") + + # This is the full pattern we expect. + expected_pattern = re.compile( + r"#define XF86XK_\w+\t+_EVDEVK\(0x([0-9A-F]{3})\)\t+/\* (v[2-6]\.[0-9]+(\.[0-9]+)?)? +KEY_\w+ \*/" + ) + # This is the comment pattern we expect + expected_comment_pattern = re.compile( + r"/\* Use: \w+\t+_EVDEVK\(0x([0-9A-F]{3})\)\t+ (v[2-6]\.[0-9]+(\.[0-9]+)?)? +KEY_\w+ \*/" + ) + + # Some patterns to spot specific errors, just so we can print useful errors + define = re.compile(r"^#define .*") + name_pattern = re.compile(r"#define (XF86XK_[^\s]*)") + tab_check = re.compile(r"#define \w+(\s+)[^\s]+(\s+)") + hex_pattern = re.compile(r".*0x([a-f0-9]+).*", re.I) + comment_format = re.compile(r".*/\* ([^\s]+)?\s+(\w+)") + kver_format = re.compile(r"v[2-6]\.[0-9]+(\.[0-9]+)?") + + in_evdev_codes_section = False + had_evdev_codes_section = False + success = True + + all_defines = [] + + class ParserError(Exception): + pass + + def error(msg, line): + raise ParserError(f"{msg} in '{line.strip()}'") + + last_keycode = 0 + for line in open(ns.header): + try: + if not in_evdev_codes_section: + if re.match(start_token, line): + in_evdev_codes_section = True + had_evdev_codes_section = True + continue + + if re.match(reserved_range, line): + error("Using reserved range", line) + match = re.match(name_pattern, line) + if match: + all_defines.append(match.group(1)) + else: + # Within the evdev defines section + if re.match(end_token, line): + in_evdev_codes_section = False + continue + + # Comments we only search for a hex pattern and where there is one present + # we only check for uppercase format, ordering and update our last_keycode. + if not re.match(define, line): + match = re.match(expected_comment_pattern, line) + if match: + if match.group(1) != match.group(1).upper(): + error( + f"Hex code 0x{match.group(1)} must be uppercase", line + ) + if match.group(1): + keycode = int(match.group(1), 16) + if keycode < last_keycode: + error("Keycode must be ascending", line) + if keycode == last_keycode: + error("Duplicate keycode", line) + last_keycode = keycode + elif re.match(hex_pattern, line): + logger.warning(f"Unexpected hex code in {line}") + continue + + # Anything below here is a #define line + # Let's check for specific errors + if re.match(normal_range, line): + error("Define must use _EVDEVK", line) + + match = re.match(name_pattern, line) + if match: + if match.group(1) in all_defines: + error("Duplicate define", line) + all_defines.append(match.group(1)) + else: + error("Typo", line) + + match = re.match(hex_pattern, line) + if not match: + error("No hex code", line) + if match.group(1) != match.group(1).upper(): + error(f"Hex code 0x{match.group(1)} must be uppercase", line) + + tabs = re.match(tab_check, line) + if not tabs: # bug + error("Matching error", line) + if " " in tabs.group(1) or " " in tabs.group(2): + error("Use tabs, not spaces", line) + + comment = re.match(comment_format, line) + if not comment: + error("Invalid comment format", line) + kver = comment.group(1) + if kver and not re.match(kver_format, kver): + error("Invalid kernel version format", line) + + keyname = comment.group(2) + if not keyname.startswith("KEY_") or keyname.upper() != keyname: + error("Kernel keycode name invalid", line) + + # This could be an old libevdev + if keyname not in [c.name for c in libevdev.EV_KEY.codes]: + logger.warning(f"Unknown kernel keycode name {keyname}") + + # Check the full expected format, no better error messages + # available if this fails + match = re.match(expected_pattern, line) + if not match: + error("Failed match", line) + + keycode = int(match.group(1), 16) + if keycode < last_keycode: + error("Keycode must be ascending", line) + if keycode == last_keycode: + error("Duplicate keycode", line) + + # May cause a false positive for old libevdev if KEY_MAX is bumped + if keycode < 0x0A0 or keycode > libevdev.EV_KEY.KEY_MAX.value: + error("Keycode outside range", line) + + last_keycode = keycode + except ParserError as e: + logger.error(e) + success = False + + if not had_evdev_codes_section: + logger.error("Unable to locate EVDEVK section") + success = False + elif in_evdev_codes_section: + logger.error("Unterminated EVDEVK section") + success = False + + if success: + logger.info("Verification succeeded") + + return 0 if success else 1 + + +def add_keysyms(ns): + """ + Print a new XF86keysym.h file, adding any *missing* keycodes to the existing file. + """ + if verify(ns) != 0: + die("Header file verification failed") + + # If verification succeeds, we can be a bit more lenient here because we already know + # what the format of the field is. Specifically, we're searching for + # 3-digit hexcode in brackets and use that as keycode. + pattern = re.compile(r".*_EVDEVK\((0x[a-fA-F0-9]{3})\).*") + max_code = max( + [ + c.value + for c in libevdev.EV_KEY.codes + if c.is_defined + and c != libevdev.EV_KEY.KEY_MAX + and not c.name.startswith("BTN") + ] + ) + + def defined_keycodes(path): + """ + Returns an iterator to the next #defined (or otherwise mentioned) + keycode, all other lines (including the returned one) are passed + through to printf. + """ + with open(path) as fd: + in_evdev_codes_section = False + + for line in fd: + if not in_evdev_codes_section: + if re.match(start_token, line): + in_evdev_codes_section = True + # passthrough for all other lines + print(line, end="") + else: + if re.match(r"#undef _EVDEVK\n", line): + in_evdev_codes_section = False + yield max_code + else: + match = re.match(pattern, line) + if match: + logger.debug(f"Found keycode in {line.strip()}") + yield int(match.group(1), 16) + print(line, end="") + + kernel = Kernel(ns.kernel_git_tree) + prev_code = 255 - 8 # the last keycode we can map directly in X + for code in defined_keycodes(ns.header): + for missing in range(prev_code + 1, code): + newline = generate_keysym_line( + missing, kernel, kver_list=ns.kernel_versions + ) + if newline: + print(newline) + prev_code = code + + return 0 + + +def find_xf86keysym_header(): + """ + Search for the XF86keysym.h file in the current tree or use the system one + as last resort. This is a convenience function for running the script + locally, it should not be relied on in the CI. + """ + paths = tuple(Path.cwd().glob("**/XF86keysym.h")) + if not paths: + path = Path("/usr/include/X11/XF86keysym.h") + if not path.exists(): + die("Unable to find XF86keysym.h in CWD or /usr") + else: + if len(paths) > 1: + die("Multiple XF86keysym.h in CWD, please use --header") + path = paths[0] + + logger.info(f"Using header file {path}") + return path + + +def main(): + parser = argparse.ArgumentParser(description="Keysym parser script") + parser.add_argument("--verbose", "-v", action="count", default=0) + parser.add_argument( + "--header", + type=str, + default=None, + help="Path to the XF86Keysym.h header file (default: search $CWD)", + ) + + subparsers = parser.add_subparsers(help="command-specific help", dest="command") + parser_verify = subparsers.add_parser( + "verify", help="Verify the XF86keysym.h matches requirements" + ) + parser_verify.set_defaults(func=verify) + + parser_generate = subparsers.add_parser( + "add-keysyms", help="Add missing keysyms to the existing ones" + ) + parser_generate.add_argument( + "--kernel-git-tree", + type=str, + default=None, + required=True, + help="Path to a kernel git repo, required to find git tags", + ) + parser_generate.add_argument( + "--kernel-versions", + type=str, + default=[], + required=False, + help="Comma-separated list of kernel versions to limit ourselves to (e.g. 'v5.10,v5.9'). Supports fnmatch.", + ) + parser_generate.set_defaults(func=add_keysyms) + ns = parser.parse_args() + + logger.setLevel( + {2: logging.DEBUG, 1: logging.INFO, 0: logging.WARNING}.get(ns.verbose, 2) + ) + + if not ns.header: + ns.header = find_xf86keysym_header() + + if ns.command is None: + parser.error("Invalid or missing command") + + sys.exit(ns.func(ns)) + + +if __name__ == "__main__": + main() |