Linux kernel mirror (for testing) git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
kernel os linux

tools/docs/get_feat.py: convert get_feat.pl to Python

As we want to call Python code directly at the Sphinx extension,
convert get_feat.pl to Python.

The code was made to be (almost) bug-compatible with the Perl
version, with two exceptions:

1. Currently, Perl script outputs a wrong table if arch is set
to a non-existing value;

2. the ReST table output when --feat is used without --arch
has an invalid format, as the number of characters for the
table delimiters are wrong.

Those two bugs were fixed while testing the conversion.

Additionally, another caveat was solved:
the output when --feat is used without arch and the feature
doesn't exist doesn't contain an empty table anymore.

Signed-off-by: Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
Signed-off-by: Jonathan Corbet <corbet@lwn.net>
Message-ID: <03c26cee1ec567804735a33047e625ef5ab7bfa8.1763492868.git.mchehab+huawei@kernel.org>

authored by

Mauro Carvalho Chehab and committed by
Jonathan Corbet
caa642bf 55fb2d57

+724
+5
Documentation/sphinx/kernel_feat.py
··· 42 42 from docutils.parsers.rst import directives, Directive 43 43 from sphinx.util.docutils import switch_source_input 44 44 45 + srctree = os.path.abspath(os.environ["srctree"]) 46 + sys.path.insert(0, os.path.join(srctree, "tools/docs/lib")) 47 + 48 + from parse_features import ParseFeature # pylint: disable=C0413 49 + 45 50 def ErrorString(exc): # Shamelessly stolen from docutils 46 51 return f'{exc.__class__.__name}: {exc}' 47 52
+225
tools/docs/get_feat.py
··· 1 + #!/usr/bin/env python3 2 + # pylint: disable=R0902,R0911,R0912,R0914,R0915 3 + # Copyright(c) 2025: Mauro Carvalho Chehab <mchehab@kernel.org>. 4 + # SPDX-License-Identifier: GPL-2.0 5 + 6 + 7 + """ 8 + Parse the Linux Feature files and produce a ReST book. 9 + """ 10 + 11 + import argparse 12 + import os 13 + import subprocess 14 + import sys 15 + 16 + from pprint import pprint 17 + 18 + LIB_DIR = "../../tools/lib/python" 19 + SRC_DIR = os.path.dirname(os.path.realpath(__file__)) 20 + 21 + sys.path.insert(0, os.path.join(SRC_DIR, LIB_DIR)) 22 + 23 + from feat.parse_features import ParseFeature # pylint: disable=C0413 24 + 25 + SRCTREE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../..") 26 + DEFAULT_DIR = "Documentation/features" 27 + 28 + 29 + class GetFeature: 30 + """Helper class to parse feature parsing parameters""" 31 + 32 + @staticmethod 33 + def get_current_arch(): 34 + """Detects the current architecture""" 35 + 36 + proc = subprocess.run(["uname", "-m"], check=True, 37 + capture_output=True, text=True) 38 + 39 + arch = proc.stdout.strip() 40 + if arch in ["x86_64", "i386"]: 41 + arch = "x86" 42 + elif arch == "s390x": 43 + arch = "s390" 44 + 45 + return arch 46 + 47 + def run_parser(self, args): 48 + """Execute the feature parser""" 49 + 50 + feat = ParseFeature(args.directory, args.debug, args.enable_fname) 51 + data = feat.parse() 52 + 53 + if args.debug > 2: 54 + pprint(data) 55 + 56 + return feat 57 + 58 + def run_rest(self, args): 59 + """ 60 + Generate tables in ReST format. Three types of tables are 61 + supported, depending on the calling arguments: 62 + 63 + - neither feature nor arch is passed: generates a full matrix; 64 + - arch provided: generates a table of supported tables for the 65 + guiven architecture, eventually filtered by feature; 66 + - only feature provided: generates a table with feature details, 67 + showing what architectures it is implemented. 68 + """ 69 + 70 + feat = self.run_parser(args) 71 + 72 + if args.arch: 73 + rst = feat.output_arch_table(args.arch, args.feat) 74 + elif args.feat: 75 + rst = feat.output_feature(args.feat) 76 + else: 77 + rst = feat.output_matrix() 78 + 79 + print(rst) 80 + 81 + def run_current(self, args): 82 + """ 83 + Instead of using a --arch parameter, get feature for the current 84 + architecture. 85 + """ 86 + 87 + args.arch = self.get_current_arch() 88 + 89 + self.run_rest(args) 90 + 91 + def run_list(self, args): 92 + """ 93 + Generate a list of features for a given architecture, in a format 94 + parseable by other scripts. The output format is not ReST. 95 + """ 96 + 97 + if not args.arch: 98 + args.arch = self.get_current_arch() 99 + 100 + feat = self.run_parser(args) 101 + msg = feat.list_arch_features(args.arch, args.feat) 102 + 103 + print(msg) 104 + 105 + def parse_arch(self, parser): 106 + """Add a --arch parsing argument""" 107 + 108 + parser.add_argument("--arch", 109 + help="Output features for an specific" 110 + " architecture, optionally filtering for a " 111 + "single specific feature.") 112 + 113 + def parse_feat(self, parser): 114 + """Add a --feat parsing argument""" 115 + 116 + parser.add_argument("--feat", "--feature", 117 + help="Output features for a single specific " 118 + "feature.") 119 + 120 + 121 + def current_args(self, subparsers): 122 + """Implementscurrent argparse subparser""" 123 + 124 + parser = subparsers.add_parser("current", 125 + formatter_class=argparse.RawTextHelpFormatter, 126 + description="Output table in ReST " 127 + "compatible ASCII format " 128 + "with features for this " 129 + "machine's architecture") 130 + 131 + self.parse_feat(parser) 132 + parser.set_defaults(func=self.run_current) 133 + 134 + def rest_args(self, subparsers): 135 + """Implement rest argparse subparser""" 136 + 137 + parser = subparsers.add_parser("rest", 138 + formatter_class=argparse.RawTextHelpFormatter, 139 + description="Output table(s) in ReST " 140 + "compatible ASCII format " 141 + "with features in ReST " 142 + "markup language. The " 143 + "output is affected by " 144 + "--arch or --feat/--feature" 145 + " flags.") 146 + 147 + self.parse_arch(parser) 148 + self.parse_feat(parser) 149 + parser.set_defaults(func=self.run_rest) 150 + 151 + def list_args(self, subparsers): 152 + """Implement list argparse subparser""" 153 + 154 + parser = subparsers.add_parser("list", 155 + formatter_class=argparse.RawTextHelpFormatter, 156 + description="List features for this " 157 + "machine's architecture, " 158 + "using an easier to parse " 159 + "format. The output is " 160 + "affected by --arch flag.") 161 + 162 + self.parse_arch(parser) 163 + self.parse_feat(parser) 164 + parser.set_defaults(func=self.run_list) 165 + 166 + def validate_args(self, subparsers): 167 + """Implement validate argparse subparser""" 168 + 169 + parser = subparsers.add_parser("validate", 170 + formatter_class=argparse.RawTextHelpFormatter, 171 + description="Validate the contents of " 172 + "the files under " 173 + f"{DEFAULT_DIR}.") 174 + 175 + parser.set_defaults(func=self.run_parser) 176 + 177 + def parser(self): 178 + """ 179 + Create an arparse with common options and several subparsers 180 + """ 181 + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) 182 + 183 + parser.add_argument("-d", "--debug", action="count", default=0, 184 + help="Put the script in verbose mode, useful for " 185 + "debugging. Can be called multiple times, to " 186 + "increase verbosity.") 187 + 188 + parser.add_argument("--directory", "--dir", default=DEFAULT_DIR, 189 + help="Changes the location of the Feature files. " 190 + f"By default, it uses the {DEFAULT_DIR} " 191 + "directory.") 192 + 193 + parser.add_argument("--enable-fname", action="store_true", 194 + help="Prints the file name of the feature files. " 195 + "This can be used in order to track " 196 + "dependencies during documentation build.") 197 + 198 + subparsers = parser.add_subparsers() 199 + 200 + self.current_args(subparsers) 201 + self.rest_args(subparsers) 202 + self.list_args(subparsers) 203 + self.validate_args(subparsers) 204 + 205 + args = parser.parse_args() 206 + 207 + return args 208 + 209 + 210 + def main(): 211 + """Main program""" 212 + 213 + feat = GetFeature() 214 + 215 + args = feat.parser() 216 + 217 + if "func" in args: 218 + args.func(args) 219 + else: 220 + sys.exit(f"Please specify a valid command for {sys.argv[0]}") 221 + 222 + 223 + # Call main method 224 + if __name__ == "__main__": 225 + main()
+494
tools/lib/python/feat/parse_features.py
··· 1 + #!/usr/bin/env python3 2 + # pylint: disable=R0902,R0911,R0912,R0914,R0915 3 + # Copyright(c) 2025: Mauro Carvalho Chehab <mchehab@kernel.org>. 4 + # SPDX-License-Identifier: GPL-2.0 5 + 6 + 7 + """ 8 + Library to parse the Linux Feature files and produce a ReST book. 9 + """ 10 + 11 + import os 12 + import re 13 + import sys 14 + 15 + from glob import iglob 16 + 17 + 18 + class ParseFeature: 19 + """ 20 + Parses Documentation/features, allowing to generate ReST documentation 21 + from it. 22 + """ 23 + 24 + h_name = "Feature" 25 + h_kconfig = "Kconfig" 26 + h_description = "Description" 27 + h_subsys = "Subsystem" 28 + h_status = "Status" 29 + h_arch = "Architecture" 30 + 31 + # Sort order for status. Others will be mapped at the end. 32 + status_map = { 33 + "ok": 0, 34 + "TODO": 1, 35 + "N/A": 2, 36 + # The only missing status is "..", which was mapped as "---", 37 + # as this is an special ReST cell value. Let it get the 38 + # default order (99). 39 + } 40 + 41 + def __init__(self, prefix, debug=0, enable_fname=False): 42 + """ 43 + Sets internal variables 44 + """ 45 + 46 + self.prefix = prefix 47 + self.debug = debug 48 + self.enable_fname = enable_fname 49 + 50 + self.data = {} 51 + 52 + # Initial maximum values use just the headers 53 + self.max_size_name = len(self.h_name) 54 + self.max_size_kconfig = len(self.h_kconfig) 55 + self.max_size_description = len(self.h_description) 56 + self.max_size_desc_word = 0 57 + self.max_size_subsys = len(self.h_subsys) 58 + self.max_size_status = len(self.h_status) 59 + self.max_size_arch = len(self.h_arch) 60 + self.max_size_arch_with_header = self.max_size_arch + self.max_size_arch 61 + self.description_size = 1 62 + 63 + self.msg = "" 64 + 65 + def emit(self, msg="", end="\n"): 66 + self.msg += msg + end 67 + 68 + def parse_error(self, fname, ln, msg, data=None): 69 + """ 70 + Displays an error message, printing file name and line 71 + """ 72 + 73 + if ln: 74 + fname += f"#{ln}" 75 + 76 + print(f"Warning: file {fname}: {msg}", file=sys.stderr, end="") 77 + 78 + if data: 79 + data = data.rstrip() 80 + print(f":\n\t{data}", file=sys.stderr) 81 + else: 82 + print("", file=sys.stderr) 83 + 84 + def parse_feat_file(self, fname): 85 + """Parses a single arch-support.txt feature file""" 86 + 87 + if os.path.isdir(fname): 88 + return 89 + 90 + base = os.path.basename(fname) 91 + 92 + if base != "arch-support.txt": 93 + if self.debug: 94 + print(f"ignoring {fname}", file=sys.stderr) 95 + return 96 + 97 + subsys = os.path.dirname(fname).split("/")[-2] 98 + self.max_size_subsys = max(self.max_size_subsys, len(subsys)) 99 + 100 + feature_name = "" 101 + kconfig = "" 102 + description = "" 103 + comments = "" 104 + arch_table = {} 105 + 106 + if self.debug > 1: 107 + print(f"Opening {fname}", file=sys.stderr) 108 + 109 + if self.enable_fname: 110 + full_fname = os.path.abspath(fname) 111 + self.emit(f".. FILE {full_fname}") 112 + 113 + with open(fname, encoding="utf-8") as f: 114 + for ln, line in enumerate(f, start=1): 115 + line = line.strip() 116 + 117 + match = re.match(r"^\#\s+Feature\s+name:\s*(.*\S)", line) 118 + if match: 119 + feature_name = match.group(1) 120 + 121 + self.max_size_name = max(self.max_size_name, 122 + len(feature_name)) 123 + continue 124 + 125 + match = re.match(r"^\#\s+Kconfig:\s*(.*\S)", line) 126 + if match: 127 + kconfig = match.group(1) 128 + 129 + self.max_size_kconfig = max(self.max_size_kconfig, 130 + len(kconfig)) 131 + continue 132 + 133 + match = re.match(r"^\#\s+description:\s*(.*\S)", line) 134 + if match: 135 + description = match.group(1) 136 + 137 + self.max_size_description = max(self.max_size_description, 138 + len(description)) 139 + 140 + words = re.split(r"\s+", line)[1:] 141 + for word in words: 142 + self.max_size_desc_word = max(self.max_size_desc_word, 143 + len(word)) 144 + 145 + continue 146 + 147 + if re.search(r"^\\s*$", line): 148 + continue 149 + 150 + if re.match(r"^\s*\-+\s*$", line): 151 + continue 152 + 153 + if re.search(r"^\s*\|\s*arch\s*\|\s*status\s*\|\s*$", line): 154 + continue 155 + 156 + match = re.match(r"^\#\s*(.*)$", line) 157 + if match: 158 + comments += match.group(1) 159 + continue 160 + 161 + match = re.match(r"^\s*\|\s*(\S+):\s*\|\s*(\S+)\s*\|\s*$", line) 162 + if match: 163 + arch = match.group(1) 164 + status = match.group(2) 165 + 166 + self.max_size_status = max(self.max_size_status, 167 + len(status)) 168 + self.max_size_arch = max(self.max_size_arch, len(arch)) 169 + 170 + if status == "..": 171 + status = "---" 172 + 173 + arch_table[arch] = status 174 + 175 + continue 176 + 177 + self.parse_error(fname, ln, "Line is invalid", line) 178 + 179 + if not feature_name: 180 + self.parse_error(fname, 0, "Feature name not found") 181 + return 182 + if not subsys: 183 + self.parse_error(fname, 0, "Subsystem not found") 184 + return 185 + if not kconfig: 186 + self.parse_error(fname, 0, "Kconfig not found") 187 + return 188 + if not description: 189 + self.parse_error(fname, 0, "Description not found") 190 + return 191 + if not arch_table: 192 + self.parse_error(fname, 0, "Architecture table not found") 193 + return 194 + 195 + self.data[feature_name] = { 196 + "where": fname, 197 + "subsys": subsys, 198 + "kconfig": kconfig, 199 + "description": description, 200 + "comments": comments, 201 + "table": arch_table, 202 + } 203 + 204 + self.max_size_arch_with_header = self.max_size_arch + len(self.h_arch) 205 + 206 + def parse(self): 207 + """Parses all arch-support.txt feature files inside self.prefix""" 208 + 209 + path = os.path.expanduser(self.prefix) 210 + 211 + if self.debug > 2: 212 + print(f"Running parser for {path}") 213 + 214 + example_path = os.path.join(path, "arch-support.txt") 215 + 216 + for fname in iglob(os.path.join(path, "**"), recursive=True): 217 + if fname != example_path: 218 + self.parse_feat_file(fname) 219 + 220 + return self.data 221 + 222 + def output_arch_table(self, arch, feat=None): 223 + """ 224 + Output feature(s) for a given architecture. 225 + """ 226 + 227 + title = f"Feature status on {arch} architecture" 228 + 229 + self.emit("=" * len(title)) 230 + self.emit(title) 231 + self.emit("=" * len(title)) 232 + self.emit() 233 + 234 + self.emit("=" * self.max_size_subsys + " ", end="") 235 + self.emit("=" * self.max_size_name + " ", end="") 236 + self.emit("=" * self.max_size_kconfig + " ", end="") 237 + self.emit("=" * self.max_size_status + " ", end="") 238 + self.emit("=" * self.max_size_description) 239 + 240 + self.emit(f"{self.h_subsys:<{self.max_size_subsys}} ", end="") 241 + self.emit(f"{self.h_name:<{self.max_size_name}} ", end="") 242 + self.emit(f"{self.h_kconfig:<{self.max_size_kconfig}} ", end="") 243 + self.emit(f"{self.h_status:<{self.max_size_status}} ", end="") 244 + self.emit(f"{self.h_description:<{self.max_size_description}}") 245 + 246 + self.emit("=" * self.max_size_subsys + " ", end="") 247 + self.emit("=" * self.max_size_name + " ", end="") 248 + self.emit("=" * self.max_size_kconfig + " ", end="") 249 + self.emit("=" * self.max_size_status + " ", end="") 250 + self.emit("=" * self.max_size_description) 251 + 252 + sorted_features = sorted(self.data.keys(), 253 + key=lambda x: (self.data[x]["subsys"], 254 + x.lower())) 255 + 256 + for name in sorted_features: 257 + if feat and name != feat: 258 + continue 259 + 260 + arch_table = self.data[name]["table"] 261 + 262 + if not arch in arch_table: 263 + continue 264 + 265 + self.emit(f"{self.data[name]['subsys']:<{self.max_size_subsys}} ", 266 + end="") 267 + self.emit(f"{name:<{self.max_size_name}} ", end="") 268 + self.emit(f"{self.data[name]['kconfig']:<{self.max_size_kconfig}} ", 269 + end="") 270 + self.emit(f"{arch_table[arch]:<{self.max_size_status}} ", 271 + end="") 272 + self.emit(f"{self.data[name]['description']}") 273 + 274 + self.emit("=" * self.max_size_subsys + " ", end="") 275 + self.emit("=" * self.max_size_name + " ", end="") 276 + self.emit("=" * self.max_size_kconfig + " ", end="") 277 + self.emit("=" * self.max_size_status + " ", end="") 278 + self.emit("=" * self.max_size_description) 279 + 280 + return self.msg 281 + 282 + def output_feature(self, feat): 283 + """ 284 + Output a feature on all architectures 285 + """ 286 + 287 + title = f"Feature {feat}" 288 + 289 + self.emit("=" * len(title)) 290 + self.emit(title) 291 + self.emit("=" * len(title)) 292 + self.emit() 293 + 294 + if not feat in self.data: 295 + return 296 + 297 + if self.data[feat]["subsys"]: 298 + self.emit(f":Subsystem: {self.data[feat]['subsys']}") 299 + if self.data[feat]["kconfig"]: 300 + self.emit(f":Kconfig: {self.data[feat]['kconfig']}") 301 + 302 + desc = self.data[feat]["description"] 303 + desc = desc[0].upper() + desc[1:] 304 + desc = desc.rstrip(". \t") 305 + self.emit(f"\n{desc}.\n") 306 + 307 + com = self.data[feat]["comments"].strip() 308 + if com: 309 + self.emit("Comments") 310 + self.emit("--------") 311 + self.emit(f"\n{com}\n") 312 + 313 + self.emit("=" * self.max_size_arch + " ", end="") 314 + self.emit("=" * self.max_size_status) 315 + 316 + self.emit(f"{self.h_arch:<{self.max_size_arch}} ", end="") 317 + self.emit(f"{self.h_status:<{self.max_size_status}}") 318 + 319 + self.emit("=" * self.max_size_arch + " ", end="") 320 + self.emit("=" * self.max_size_status) 321 + 322 + arch_table = self.data[feat]["table"] 323 + for arch in sorted(arch_table.keys()): 324 + self.emit(f"{arch:<{self.max_size_arch}} ", end="") 325 + self.emit(f"{arch_table[arch]:<{self.max_size_status}}") 326 + 327 + self.emit("=" * self.max_size_arch + " ", end="") 328 + self.emit("=" * self.max_size_status) 329 + 330 + return self.msg 331 + 332 + def matrix_lines(self, desc_size, max_size_status, header): 333 + """ 334 + Helper function to split element tables at the output matrix 335 + """ 336 + 337 + if header: 338 + ln_marker = "=" 339 + else: 340 + ln_marker = "-" 341 + 342 + self.emit("+" + ln_marker * self.max_size_name + "+", end="") 343 + self.emit(ln_marker * desc_size, end="") 344 + self.emit("+" + ln_marker * max_size_status + "+") 345 + 346 + def output_matrix(self): 347 + """ 348 + Generates a set of tables, groped by subsystem, containing 349 + what's the feature state on each architecture. 350 + """ 351 + 352 + title = "Feature status on all architectures" 353 + 354 + self.emit("=" * len(title)) 355 + self.emit(title) 356 + self.emit("=" * len(title)) 357 + self.emit() 358 + 359 + desc_title = f"{self.h_kconfig} / {self.h_description}" 360 + 361 + desc_size = self.max_size_kconfig + 4 362 + if not self.description_size: 363 + desc_size = max(self.max_size_description, desc_size) 364 + else: 365 + desc_size = max(self.description_size, desc_size) 366 + 367 + desc_size = max(self.max_size_desc_word, desc_size, len(desc_title)) 368 + 369 + notcompat = "Not compatible" 370 + self.max_size_status = max(self.max_size_status, len(notcompat)) 371 + 372 + min_status_size = self.max_size_status + self.max_size_arch + 4 373 + max_size_status = max(min_status_size, self.max_size_status) 374 + 375 + h_status_per_arch = "Status per architecture" 376 + max_size_status = max(max_size_status, len(h_status_per_arch)) 377 + 378 + cur_subsys = None 379 + for name in sorted(self.data.keys(), 380 + key=lambda x: (self.data[x]["subsys"], x.lower())): 381 + if not cur_subsys or cur_subsys != self.data[name]["subsys"]: 382 + if cur_subsys: 383 + self.emit() 384 + 385 + cur_subsys = self.data[name]["subsys"] 386 + 387 + title = f"Subsystem: {cur_subsys}" 388 + self.emit(title) 389 + self.emit("=" * len(title)) 390 + self.emit() 391 + 392 + self.matrix_lines(desc_size, max_size_status, 0) 393 + 394 + self.emit(f"|{self.h_name:<{self.max_size_name}}", end="") 395 + self.emit(f"|{desc_title:<{desc_size}}", end="") 396 + self.emit(f"|{h_status_per_arch:<{max_size_status}}|") 397 + 398 + self.matrix_lines(desc_size, max_size_status, 1) 399 + 400 + lines = [] 401 + descs = [] 402 + cur_status = "" 403 + line = "" 404 + 405 + arch_table = sorted(self.data[name]["table"].items(), 406 + key=lambda x: (self.status_map.get(x[1], 99), 407 + x[0].lower())) 408 + 409 + for arch, status in arch_table: 410 + if status == "---": 411 + status = notcompat 412 + 413 + if status != cur_status: 414 + if line != "": 415 + lines.append(line) 416 + line = "" 417 + line = f"- **{status}**: {arch}" 418 + elif len(line) + len(arch) + 2 < max_size_status: 419 + line += f", {arch}" 420 + else: 421 + lines.append(line) 422 + line = f" {arch}" 423 + cur_status = status 424 + 425 + if line != "": 426 + lines.append(line) 427 + 428 + description = self.data[name]["description"] 429 + while len(description) > desc_size: 430 + desc_line = description[:desc_size] 431 + 432 + last_space = desc_line.rfind(" ") 433 + if last_space != -1: 434 + desc_line = desc_line[:last_space] 435 + descs.append(desc_line) 436 + description = description[last_space + 1:] 437 + else: 438 + desc_line = desc_line[:-1] 439 + descs.append(desc_line + "\\") 440 + description = description[len(desc_line):] 441 + 442 + if description: 443 + descs.append(description) 444 + 445 + while len(lines) < 2 + len(descs): 446 + lines.append("") 447 + 448 + for ln, line in enumerate(lines): 449 + col = ["", ""] 450 + 451 + if not ln: 452 + col[0] = name 453 + col[1] = f"``{self.data[name]['kconfig']}``" 454 + else: 455 + if ln >= 2 and descs: 456 + col[1] = descs.pop(0) 457 + 458 + self.emit(f"|{col[0]:<{self.max_size_name}}", end="") 459 + self.emit(f"|{col[1]:<{desc_size}}", end="") 460 + self.emit(f"|{line:<{max_size_status}}|") 461 + 462 + self.matrix_lines(desc_size, max_size_status, 0) 463 + 464 + return self.msg 465 + 466 + def list_arch_features(self, arch, feat): 467 + """ 468 + Print a matrix of kernel feature support for the chosen architecture. 469 + """ 470 + self.emit("#") 471 + self.emit(f"# Kernel feature support matrix of the '{arch}' architecture:") 472 + self.emit("#") 473 + 474 + # Sort by subsystem, then by feature name (case‑insensitive) 475 + for name in sorted(self.data.keys(), 476 + key=lambda n: (self.data[n]["subsys"].lower(), 477 + n.lower())): 478 + if feat and name != feat: 479 + continue 480 + 481 + feature = self.data[name] 482 + arch_table = feature["table"] 483 + status = arch_table.get(arch, "") 484 + status = " " * ((4 - len(status)) // 2) + status 485 + 486 + self.emit(f"{feature['subsys']:>{self.max_size_subsys + 1}}/ ", 487 + end="") 488 + self.emit(f"{name:<{self.max_size_name}}: ", end="") 489 + self.emit(f"{status:<5}| ", end="") 490 + self.emit(f"{feature['kconfig']:>{self.max_size_kconfig}} ", 491 + end="") 492 + self.emit(f"# {feature['description']}") 493 + 494 + return self.msg