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(m.group(1), root_dir=self.base_dir) 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 for name, fields in sorted(maint_parser.maint_entries.items()): 288 output += f" * - {name}\n" 289 tag = "-" 290 for field, lines in fields.items(): 291 field_name = maint_parser.fields.get(field, field) 292 293 output += f" {tag} :{field_name}:\n " 294 output += ",\n ".join(lines) + "\n" 295 tag = " " 296 297 output += "\n" 298 299 # For debugging the pre-rendered results... 300 #print(output, file=open("/tmp/MAINTAINERS.rst", "w")) 301 302 self.state.document.settings.record_dependencies.add(path) 303 self.state_machine.insert_input(statemachine.string2lines(output), path) 304 305 def run(self): 306 """Include the MAINTAINERS file as part of this reST file.""" 307 if not self.state.document.settings.file_insertion_enabled: 308 raise self.warning('"%s" directive disabled.' % self.name) 309 310 try: 311 self.emit() 312 except IOError as error: 313 raise self.severe('Problems with "%s" directive path:\n%s.' % 314 (self.name, ErrorString(error))) 315 316 return [] 317 318 319class MaintainersProfile(Include): 320 """Generate a list with all maintainer's profiles""" 321 322 required_arguments = 0 323 324 def emit(self): 325 """Parse all the MAINTAINERS lines looking for profile entries""" 326 env = self.state.document.settings.env 327 docdir = os.path.dirname(os.path.join(env.srcdir, env.docname)) 328 path = maint_parser.path 329 330 # 331 # Produce a list with all maintainer profiles, sorted by subsystem name 332 # 333 output = "" 334 for profile, entry in sorted(maint_parser.profile_entries.items()): 335 name = profile.title() 336 337 if entry.startswith("http"): 338 output += f"- `{name} <{entry}>`_\n" 339 elif entry.startswith("`"): 340 output += f"- {name}: {entry}\n" 341 self.warning(f"{profile}: Invalid 'P' tag: {entry}\n") 342 else: 343 output += f"- {entry}\n" 344 345 # 346 # Create a hidden TOC table with all profiles. That allows adding 347 # profiles without needing to add them on any index.rst file. 348 # 349 output += "\n.. toctree::\n" 350 output += " :hidden:\n\n" 351 352 for f in sorted(maint_parser.profile_toc): 353 fname = os.path.join(maint_parser.base_dir, "Documentation", f) 354 fname = os.path.relpath(fname, docdir) 355 output += f" {fname}\n" 356 357 output += "\n" 358 359 # For debugging the pre-rendered results... 360 #print(output, file=open("/tmp/profiles.rst", "w")) 361 362 self.state.document.settings.record_dependencies.add(path) 363 self.state_machine.insert_input(statemachine.string2lines(output), path) 364 365 def run(self): 366 """Include the MAINTAINERS file as part of this reST file.""" 367 if not self.state.document.settings.file_insertion_enabled: 368 raise self.warning('"%s" directive disabled.' % self.name) 369 370 try: 371 self.emit() 372 except IOError as error: 373 raise self.severe('Problems with "%s" directive path:\n%s.' % 374 (self.name, ErrorString(error))) 375 376 return [] 377 378 379# pylint: disable=W0613 380def add_filter_script(app, pagename, templatename, context, doctree): 381 """Add Filter javascript only to maintainers page""" 382 383 if doctree and doctree.get('maintainers_included'): 384 app.add_js_file(None, body=JS_FILTER) 385 386 387def setup(app): 388 """Setup Sphinx extension""" 389 global maint_parser # pylint: disable=W0603 390 391 app_dir = os.path.abspath(app.srcdir) 392 match = re.match(r"(.*/)Documentation", app_dir) 393 if not match: 394 raise ValueError('Documentation directory not found.') 395 396 base_dir = match.group(1) 397 path = os.path.join(base_dir, "MAINTAINERS") 398 399 maint_parser = MaintainersParser(base_dir, app_dir, path) 400 401 app.add_directive("maintainers-include", MaintainersInclude) 402 app.add_directive("maintainers-profile-toc", MaintainersProfile) 403 404 app.connect("html-page-context", add_filter_script) 405 406 return { 407 "version": __version__, 408 "parallel_read_safe": True, 409 "parallel_write_safe": True, 410 } 411