1#!/usr/bin/env python 2# SPDX-License-Identifier: GPL-2.0 3# -*- coding: utf-8; mode: python -*- 4# pylint: disable=C0209, C0301, E0401, R0022, R0902, R0903, R0912, R0914 5 6""" 7Implementation of the ``maintainers-include`` reST-directive. 8 9:copyright: Copyright (C) 2019 Kees Cook <keescook@chromium.org> 10:license: GPL Version 2, June 1991 see linux/COPYING for details. 11 12The ``maintainers-include`` reST-directive performs extensive parsing 13specific to the Linux kernel's standard "MAINTAINERS" file, in an 14effort to avoid needing to heavily mark up the original plain text. 15""" 16 17import os.path 18import re 19 20from glob import glob 21 22from docutils import statemachine 23from docutils.parsers.rst.directives.misc import Include 24 25# 26# Base URL for intersphinx-like links to maintainer profiles 27# 28KERNELDOC_URL = "https://docs.kernel.org/" 29 30__version__ = "1.0" 31 32maint_parser = None # pylint: disable=C0103 33 34JS_FILTER = """ 35(function() { 36 function filterTable(table) { 37 const filter = document.getElementById("filter-table").value.trim(); 38 const rows = table.querySelectorAll("tbody tr"); 39 for (let i = 0; i < rows.length; i++) { 40 const tds = rows[i].getElementsByTagName("td"); 41 let match = false; 42 for (let j = 0; j < tds.length; j++) { 43 const cellText = (tds[j].textContent || tds[j].innerText); 44 if (cellText.includes(filter)) { 45 match = true; 46 break; 47 } 48 } 49 rows[i].style.display = match ? "table-row" : "none"; 50 } 51 } 52 function addInput() { 53 const table = document.getElementById("maintainers-table"); 54 if (!table) return; 55 let input = document.getElementById("filter-table"); 56 if (!input) { 57 const filt_div = document.createElement('div'); 58 filt_div.innerHTML = ` 59 <p>Filter: 60 <input type="search" id="filter-table" placeholder="search string"/> 61 subsystem or property (case-sensitive) 62 </p> 63 `; 64 table.parentNode.insertBefore(filt_div, table); 65 const input = document.getElementById("filter-table") 66 input.addEventListener('input', () => filterTable(table)); 67 } 68 } 69 if (document.readyState === 'loading') { 70 document.addEventListener('DOMContentLoaded', addInput); 71 } else { 72 addInput(); 73 } 74})(); 75""" 76 77 78# Shamelessly stolen from docutils 79def ErrorString(exc): # pylint: disable=C0103, C0116 80 return f"{exc.__class__.__name}: {exc}" # pylint: disable=W0212 81 82class MaintainersParser: 83 """Parse MAINTAINERS file(s) content""" 84 85 def __init__(self, base_dir, app_dir, path): 86 self.path = path 87 88 # Poor man's state machine. 89 self.descriptions = False 90 self.maintainers = False 91 self.subsystems = False 92 93 self.subsystem_name = None 94 95 self.base_dir = base_dir 96 self.app_dir = app_dir 97 98 self.re_doc = re.compile(r'(Documentation/(\S*)\.rst)') 99 100 # 101 # Output variables with maintainers content to be stored 102 # 103 self.profile_toc = set() 104 self.profile_entries = {} 105 self.header = "" 106 self.maint_entries = {} 107 self.fields = {} 108 109 prev = None 110 with open(path, "r", encoding="utf-8") as fp: 111 for line in fp: 112 if self.descriptions: 113 self.parse_descriptions(line) 114 elif self.maintainers and not self.subsystems: 115 if re.search('^[A-Z0-9]', line): 116 self.subsystems = True 117 self.parse_subsystems(line) 118 else: 119 self.header += line 120 elif self.subsystems: 121 self.parse_subsystems(line) 122 else: 123 self.header += line 124 125 # Update the state machine when we find heading separators. 126 if line.startswith("----------"): 127 if prev.startswith("Descriptions"): 128 self.descriptions = True 129 if prev.startswith("Maintainers"): 130 self.maintainers = True 131 132 # Retain previous line for state machine transitions. 133 prev = line 134 135 def get_entries(self, text): 136 """Generate refs to ReST files in Documentation/""" 137 138 if "Documentation/" not in text: 139 return None 140 141 if "*" in text or "?" in text: 142 m = self.re_doc.search(text) 143 if not m: 144 return None 145 146 doc_list = glob(os.path.join(self.base_dir, m.group(1))) 147 else: 148 doc_list = [text] 149 150 entries = {} 151 for doc in doc_list: 152 m = self.re_doc.search(doc) 153 if m: 154 fname = m.group(1) 155 ename = m.group(2) 156 157 entry = os.path.relpath(self.base_dir + fname, self.app_dir) 158 entry = entry.removesuffix(".rst") 159 160 if entry.startswith("../"): 161 html = KERNELDOC_URL + ename + ".html" 162 entries[entry] = f'`{ename} <{html}>`_' 163 else: 164 entries[entry] = f':doc:`{ename} </{entry}>`' 165 166 return entries 167 168 def linkify(self, text): 169 """Return a list of doc files converted to cross-references""" 170 171 entries = self.get_entries(text) 172 if not entries: 173 return text 174 175 return self.re_doc.sub(", ".join(entries.values()), text) 176 177 def parse_descriptions(self, line): 178 """Handle contents of the descriptions section.""" 179 180 # Have we reached the end of the preformatted Descriptions text? 181 if line.startswith("Maintainers"): 182 self.descriptions = False 183 self.header += "\n" + line 184 return 185 186 # Look for and record field letter to field name mappings: 187 # R: Designated *reviewer*: FullName <address@domain> 188 m = re.match(r"\s+(\S):\s+(\S+)", line) 189 if m: 190 field = m.group(1) 191 details = m.group(2) 192 193 if field not in self.fields: 194 m = re.search(r"\*([^\*]+)\*", line) 195 if m: 196 self.fields[field] = m.group(1) 197 elif field in ['F', 'N', 'X', 'K']: 198 line = line.replace(details, f'``{details}``') 199 200 self.header += "| " + self.linkify(line) 201 202 203 def parse_subsystems(self, line): 204 """Handle contents of the per-subsystem sections.""" 205 206 # Drop needless input whitespace. 207 line = line.rstrip() 208 209 # Skip empty lines: subsystem parser adds them as needed. 210 if not line: 211 return 212 213 if line[1] != ':': 214 self.subsystem_name = re.sub(r"\s+", " ", self.linkify(line)) 215 return 216 217 # Render a subsystem field as: 218 # :Field: entry 219 # entry... 220 field, details = line.split(":", 1) 221 details = details.strip() 222 223 # 224 # Handle profile entries - either as files or as https refs 225 # 226 if field == "P": 227 entries = self.get_entries(details) 228 if entries: 229 for e, link in entries.items(): 230 if "html" not in link: 231 self.profile_toc.add(e) 232 233 self.profile_entries[self.subsystem_name] = link 234 235 details = ", ".join(entries.values()) 236 else: 237 match = re.match(r"(https?://.*)", details) 238 if match: 239 entry = match.group(1).strip() 240 self.profile_entries[self.subsystem_name] = entry 241 else: 242 self.profile_entries[self.subsystem_name] = f"``{details}``" 243 244 details = self.linkify(details) 245 else: 246 details = self.linkify(details) 247 248 # 249 # Mark paths (and regexes) as literal text for improved 250 # readability and to escape any escapes. 251 # 252 if field in ['F', 'N', 'X', 'K']: 253 # But only if not already marked :) 254 if ':doc:' not in details and "http" not in details: 255 details = '``%s``' % (details) 256 257 if self.subsystem_name not in self.maint_entries: 258 self.maint_entries[self.subsystem_name] = {} 259 260 if field not in self.maint_entries[self.subsystem_name]: 261 self.maint_entries[self.subsystem_name][field] = [] 262 263 self.maint_entries[self.subsystem_name][field].append(details) 264 265 self.field_prev = field 266 267 268class MaintainersInclude(Include): 269 """MaintainersInclude (``maintainers-include``) directive""" 270 271 required_arguments = 0 272 273 def emit(self): 274 """Parse all the MAINTAINERS lines into ReST for human-readability""" 275 path = maint_parser.path 276 output = ".. _maintainers:\n\n" 277 output += maint_parser.header 278 279 output += ".. _maintainers_table:\n\n" 280 output += ".. flat-table::\n" 281 output += " :header-rows: 1\n\n" 282 output += " * - Subsystem\n" 283 output += " - Properties\n\n" 284 285 self.state.document['maintainers_included'] = True 286 287 # Keep the last entry ("THE REST") in the end 288 entries = list(maint_parser.maint_entries.keys()) 289 entries = sorted(entries[:-1], key=str.casefold) + [entries[-1]] 290 291 for name in entries: 292 fields = maint_parser.maint_entries[name] 293 output += f" * - {name}\n" 294 tag = "-" 295 for field, lines in fields.items(): 296 field_name = maint_parser.fields.get(field, field) 297 298 output += f" {tag} :{field_name}:\n " 299 output += ",\n ".join(lines) + "\n" 300 tag = " " 301 302 output += "\n" 303 304 # For debugging the pre-rendered results... 305 #print(output, file=open("/tmp/MAINTAINERS.rst", "w")) 306 307 self.state.document.settings.record_dependencies.add(path) 308 self.state_machine.insert_input(statemachine.string2lines(output), path) 309 310 def run(self): 311 """Include the MAINTAINERS file as part of this reST file.""" 312 if not self.state.document.settings.file_insertion_enabled: 313 raise self.warning('"%s" directive disabled.' % self.name) 314 315 try: 316 self.emit() 317 except IOError as error: 318 raise self.severe('Problems with "%s" directive path:\n%s.' % 319 (self.name, ErrorString(error))) 320 321 return [] 322 323 324class MaintainersProfile(Include): 325 """Generate a list with all maintainer's profiles""" 326 327 required_arguments = 0 328 329 def emit(self): 330 """Parse all the MAINTAINERS lines looking for profile entries""" 331 env = self.state.document.settings.env 332 docdir = os.path.dirname(os.path.join(env.srcdir, env.docname)) 333 path = maint_parser.path 334 335 # 336 # Produce a list with all maintainer profiles, sorted by subsystem name 337 # 338 output = "" 339 for profile, entry in sorted(maint_parser.profile_entries.items()): 340 name = profile.title() 341 342 if entry.startswith("http"): 343 output += f"- `{name} <{entry}>`_\n" 344 elif entry.startswith("`"): 345 output += f"- {name}: {entry}\n" 346 self.warning(f"{profile}: Invalid 'P' tag: {entry}\n") 347 else: 348 output += f"- {entry}\n" 349 350 # 351 # Create a hidden TOC table with all profiles. That allows adding 352 # profiles without needing to add them on any index.rst file. 353 # 354 output += "\n.. toctree::\n" 355 output += " :hidden:\n\n" 356 357 for f in sorted(maint_parser.profile_toc): 358 fname = os.path.join(maint_parser.base_dir, "Documentation", f) 359 fname = os.path.relpath(fname, docdir) 360 output += f" {fname}\n" 361 362 output += "\n" 363 364 # For debugging the pre-rendered results... 365 #print(output, file=open("/tmp/profiles.rst", "w")) 366 367 self.state.document.settings.record_dependencies.add(path) 368 self.state_machine.insert_input(statemachine.string2lines(output), path) 369 370 def run(self): 371 """Include the MAINTAINERS file as part of this reST file.""" 372 if not self.state.document.settings.file_insertion_enabled: 373 raise self.warning('"%s" directive disabled.' % self.name) 374 375 try: 376 self.emit() 377 except IOError as error: 378 raise self.severe('Problems with "%s" directive path:\n%s.' % 379 (self.name, ErrorString(error))) 380 381 return [] 382 383 384# pylint: disable=W0613 385def add_filter_script(app, pagename, templatename, context, doctree): 386 """Add Filter javascript only to maintainers page""" 387 388 if doctree and doctree.get('maintainers_included'): 389 app.add_js_file(None, body=JS_FILTER) 390 391 392def setup(app): 393 """Setup Sphinx extension""" 394 global maint_parser # pylint: disable=W0603 395 396 app_dir = os.path.abspath(app.srcdir) 397 match = re.match(r"(.*/)Documentation", app_dir) 398 if not match: 399 raise ValueError('Documentation directory not found.') 400 401 base_dir = match.group(1) 402 path = os.path.join(base_dir, "MAINTAINERS") 403 404 maint_parser = MaintainersParser(base_dir, app_dir, path) 405 406 app.add_directive("maintainers-include", MaintainersInclude) 407 app.add_directive("maintainers-profile-toc", MaintainersProfile) 408 409 app.connect("html-page-context", add_filter_script) 410 411 return { 412 "version": __version__, 413 "parallel_read_safe": True, 414 "parallel_write_safe": True, 415 } 416