1#!/usr/bin/env python3
2#
3# Builds a tree view of a symbols file (showing all includes)
4#
5# This file is formatted with Python Black
6
7import argparse
8import pathlib
9import os
10from pyparsing import (
11    Word,
12    Literal,
13    LineEnd,
14    OneOrMore,
15    oneOf,
16    Or,
17    And,
18    QuotedString,
19    Regex,
20    cppStyleComment,
21    alphanums,
22    Optional,
23    ParseException,
24)
25
26xkb_basedir = None
27
28
29class XkbSymbols:
30    def __init__(self, file, name):
31        self.file = file  # Path to the file this section came from
32        self.layout = file.name  # XKb - filename is the layout name
33        self.name = name
34        self.includes = []
35
36    def __str__(self):
37        return f"{self.layout}({self.name}): {self.includes}"
38
39
40class XkbLoader:
41    """
42    Wrapper class to avoid loading the same symbols file over and over
43    again.
44    """
45
46    class XkbParserException(Exception):
47        pass
48
49    _instance = None
50
51    def __init__(self, xkb_basedir):
52        self.xkb_basedir = xkb_basedir
53        self.loaded = {}
54
55    @classmethod
56    def create(cls, xkb_basedir):
57        assert cls._instance is None
58        cls._instance = XkbLoader(xkb_basedir)
59
60    @classmethod
61    def instance(cls):
62        assert cls._instance is not None
63        return cls._instance
64
65    @classmethod
66    def load_symbols(cls, file):
67        return cls.instance().load_symbols_file(file)
68
69    def load_symbols_file(self, file):
70        file = self.xkb_basedir / file
71        try:
72            return self.loaded[file]
73        except KeyError:
74            pass
75
76        sections = []
77
78        def quoted(name):
79            return QuotedString(quoteChar='"', unquoteResults=True)
80
81        # Callback, toks[0] is "foo" for xkb_symbols "foo"
82        def new_symbols_section(name, loc, toks):
83            assert len(toks) == 1
84            sections.append(XkbSymbols(file, toks[0]))
85
86        # Callback, toks[0] is "foo(bar)" for include "foo(bar)"
87        def append_includes(name, loc, toks):
88            assert len(toks) == 1
89            sections[-1].includes.append(toks[0])
90
91        EOL = LineEnd().suppress()
92        SECTIONTYPE = (
93            "default",
94            "partial",
95            "hidden",
96            "alphanumeric_keys",
97            "modifier_keys",
98            "keypad_keys",
99            "function_keys",
100            "alternate_group",
101        )
102        NAME = quoted("name").setParseAction(new_symbols_section)
103        INCLUDE = (
104            lit("include") + quoted("include").setParseAction(append_includes) + EOL
105        )
106        # We only care about includes
107        OTHERLINE = And([~lit("};"), ~lit("include") + Regex(".*")]) + EOL
108
109        with open(file) as fd:
110            types = OneOrMore(oneOf(SECTIONTYPE)).suppress()
111            include_or_other = Or([INCLUDE, OTHERLINE.suppress()])
112            section = (
113                types
114                + lit("xkb_symbols")
115                + NAME
116                + lit("{")
117                + OneOrMore(include_or_other)
118                + lit("};")
119            )
120            grammar = OneOrMore(section)
121            grammar.ignore(cppStyleComment)
122            try:
123                result = grammar.parseFile(fd)
124            except ParseException as e:
125                raise XkbLoader.XkbParserException(str(e))
126
127        self.loaded[file] = sections
128
129        return sections
130
131
132def lit(string):
133    return Literal(string).suppress()
134
135
136def print_section(s, filter_section=None, indent=0):
137    if filter_section and s.name != filter_section:
138        return
139
140    layout = Word(alphanums + "_/").setResultsName("layout")
141    variant = Optional(
142        lit("(") + Word(alphanums + "_").setResultsName("variant") + lit(")")
143    )
144    grammar = layout + variant
145
146    prefix = ""
147    if indent > 0:
148        prefix = " " * (indent - 2) + "|-> "
149    print(f"{prefix}{s.layout}({s.name})")
150    for include in s.includes:
151        result = grammar.parseString(include)
152        # Should really find the "default" section but for this script
153        # hardcoding "basic" is good enough
154        layout, variant = result.layout, result.variant or "basic"
155
156        # include "foo(bar)" means file "foo", section bar
157        includefile = xkb_basedir / layout
158        include_sections = XkbLoader.load_symbols(layout)
159        for include_section in include_sections:
160            print_section(include_section, filter_section=variant, indent=indent + 4)
161
162
163def list_sections(sections, filter_section=None, indent=0):
164    for section in sections:
165        print_section(section, filter_section)
166
167
168if __name__ == "__main__":
169    parser = argparse.ArgumentParser(description="XKB symbol tree viewer")
170    parser.add_argument(
171        "file",
172        metavar="file-or-directory",
173        type=pathlib.Path,
174        help="The XKB symbols file or directory",
175    )
176    parser.add_argument(
177        "section", type=str, default=None, nargs="?", help="The section (optional)"
178    )
179    ns = parser.parse_args()
180
181    if ns.file.is_dir():
182        xkb_basedir = ns.file.resolve()
183        files = sorted([f for f in ns.file.iterdir() if not f.is_dir()])
184    else:
185        # Note: this requires that the file given on the cmdline is not one of
186        # the sun_vdr/de or others inside a subdirectory. meh.
187        xkb_basedir = ns.file.parent.resolve()
188        files = [ns.file]
189
190    XkbLoader.create(xkb_basedir)
191
192    for file in files:
193        try:
194            sections = XkbLoader.load_symbols(file.resolve())
195            list_sections(sections, filter_section=ns.section)
196        except XkbLoader.XkbParserException:
197            pass
198