| #!/usr/bin/env python |
| # SPDX-License-Identifier: GPL-2.0 |
| # -*- coding: utf-8; mode: python -*- |
| # pylint: disable=C0209, C0301, E0401, R0022, R0902, R0903, R0912, R0914 |
| |
| """ |
| Implementation of the ``maintainers-include`` reST-directive. |
| |
| :copyright: Copyright (C) 2019 Kees Cook <keescook@chromium.org> |
| :license: GPL Version 2, June 1991 see linux/COPYING for details. |
| |
| The ``maintainers-include`` reST-directive performs extensive parsing |
| specific to the Linux kernel's standard "MAINTAINERS" file, in an |
| effort to avoid needing to heavily mark up the original plain text. |
| """ |
| |
| import os.path |
| import re |
| |
| from glob import glob |
| |
| from docutils import statemachine |
| from docutils.parsers.rst.directives.misc import Include |
| |
| # |
| # Base URL for intersphinx-like links to maintainer profiles |
| # |
| KERNELDOC_URL = "https://docs.kernel.org/" |
| |
| __version__ = "1.0" |
| |
| maint_parser = None # pylint: disable=C0103 |
| |
| JS_FILTER = """ |
| (function() { |
| function filterTable(table) { |
| const filter = document.getElementById("filter-table").value.trim(); |
| const rows = table.querySelectorAll("tbody tr"); |
| for (let i = 0; i < rows.length; i++) { |
| const tds = rows[i].getElementsByTagName("td"); |
| let match = false; |
| for (let j = 0; j < tds.length; j++) { |
| const cellText = (tds[j].textContent || tds[j].innerText); |
| if (cellText.includes(filter)) { |
| match = true; |
| break; |
| } |
| } |
| rows[i].style.display = match ? "table-row" : "none"; |
| } |
| } |
| function addInput() { |
| const table = document.getElementById("maintainers-table"); |
| if (!table) return; |
| let input = document.getElementById("filter-table"); |
| if (!input) { |
| const filt_div = document.createElement('div'); |
| filt_div.innerHTML = ` |
| <p>Filter: |
| <input type="search" id="filter-table" placeholder="search string"/> |
| subsystem or property (case-sensitive) |
| </p> |
| `; |
| table.parentNode.insertBefore(filt_div, table); |
| const input = document.getElementById("filter-table") |
| input.addEventListener('input', () => filterTable(table)); |
| } |
| } |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', addInput); |
| } else { |
| addInput(); |
| } |
| })(); |
| """ |
| |
| |
| # Shamelessly stolen from docutils |
| def ErrorString(exc): # pylint: disable=C0103, C0116 |
| return f"{exc.__class__.__name}: {exc}" # pylint: disable=W0212 |
| |
| class MaintainersParser: |
| """Parse MAINTAINERS file(s) content""" |
| |
| def __init__(self, base_dir, app_dir, path): |
| self.path = path |
| |
| # Poor man's state machine. |
| self.descriptions = False |
| self.maintainers = False |
| self.subsystems = False |
| |
| self.subsystem_name = None |
| |
| self.base_dir = base_dir |
| self.app_dir = app_dir |
| |
| self.re_doc = re.compile(r'(Documentation/(\S*)\.rst)') |
| |
| # |
| # Output variables with maintainers content to be stored |
| # |
| self.profile_toc = set() |
| self.profile_entries = {} |
| self.header = "" |
| self.maint_entries = {} |
| self.fields = {} |
| |
| prev = None |
| with open(path, "r", encoding="utf-8") as fp: |
| for line in fp: |
| if self.descriptions: |
| self.parse_descriptions(line) |
| elif self.maintainers and not self.subsystems: |
| if re.search('^[A-Z0-9]', line): |
| self.subsystems = True |
| self.parse_subsystems(line) |
| else: |
| self.header += line |
| elif self.subsystems: |
| self.parse_subsystems(line) |
| else: |
| self.header += line |
| |
| # Update the state machine when we find heading separators. |
| if line.startswith("----------"): |
| if prev.startswith("Descriptions"): |
| self.descriptions = True |
| if prev.startswith("Maintainers"): |
| self.maintainers = True |
| |
| # Retain previous line for state machine transitions. |
| prev = line |
| |
| def get_entries(self, text): |
| """Generate refs to ReST files in Documentation/""" |
| |
| if "Documentation/" not in text: |
| return None |
| |
| if "*" in text or "?" in text: |
| m = self.re_doc.search(text) |
| if not m: |
| return None |
| |
| doc_list = glob(os.path.join(self.base_dir, m.group(1))) |
| else: |
| doc_list = [text] |
| |
| entries = {} |
| for doc in doc_list: |
| m = self.re_doc.search(doc) |
| if m: |
| fname = m.group(1) |
| ename = m.group(2) |
| |
| entry = os.path.relpath(self.base_dir + fname, self.app_dir) |
| entry = entry.removesuffix(".rst") |
| |
| if entry.startswith("../"): |
| html = KERNELDOC_URL + ename + ".html" |
| entries[entry] = f'`{ename} <{html}>`_' |
| else: |
| entries[entry] = f':doc:`{ename} </{entry}>`' |
| |
| return entries |
| |
| def linkify(self, text): |
| """Return a list of doc files converted to cross-references""" |
| |
| entries = self.get_entries(text) |
| if not entries: |
| return text |
| |
| return self.re_doc.sub(", ".join(entries.values()), text) |
| |
| def parse_descriptions(self, line): |
| """Handle contents of the descriptions section.""" |
| |
| # Have we reached the end of the preformatted Descriptions text? |
| if line.startswith("Maintainers"): |
| self.descriptions = False |
| self.header += "\n" + line |
| return |
| |
| # Look for and record field letter to field name mappings: |
| # R: Designated *reviewer*: FullName <address@domain> |
| m = re.match(r"\s+(\S):\s+(\S+)", line) |
| if m: |
| field = m.group(1) |
| details = m.group(2) |
| |
| if field not in self.fields: |
| m = re.search(r"\*([^\*]+)\*", line) |
| if m: |
| self.fields[field] = m.group(1) |
| elif field in ['F', 'N', 'X', 'K']: |
| line = line.replace(details, f'``{details}``') |
| |
| self.header += "| " + self.linkify(line) |
| |
| |
| def parse_subsystems(self, line): |
| """Handle contents of the per-subsystem sections.""" |
| |
| # Drop needless input whitespace. |
| line = line.rstrip() |
| |
| # Skip empty lines: subsystem parser adds them as needed. |
| if not line: |
| return |
| |
| if line[1] != ':': |
| self.subsystem_name = re.sub(r"\s+", " ", self.linkify(line)) |
| return |
| |
| # Render a subsystem field as: |
| # :Field: entry |
| # entry... |
| field, details = line.split(":", 1) |
| details = details.strip() |
| |
| # |
| # Handle profile entries - either as files or as https refs |
| # |
| if field == "P": |
| entries = self.get_entries(details) |
| if entries: |
| for e, link in entries.items(): |
| if "html" not in link: |
| self.profile_toc.add(e) |
| |
| self.profile_entries[self.subsystem_name] = link |
| |
| details = ", ".join(entries.values()) |
| else: |
| match = re.match(r"(https?://.*)", details) |
| if match: |
| entry = match.group(1).strip() |
| self.profile_entries[self.subsystem_name] = entry |
| else: |
| self.profile_entries[self.subsystem_name] = f"``{details}``" |
| |
| details = self.linkify(details) |
| else: |
| details = self.linkify(details) |
| |
| # |
| # Mark paths (and regexes) as literal text for improved |
| # readability and to escape any escapes. |
| # |
| if field in ['F', 'N', 'X', 'K']: |
| # But only if not already marked :) |
| if ':doc:' not in details and "http" not in details: |
| details = '``%s``' % (details) |
| |
| if self.subsystem_name not in self.maint_entries: |
| self.maint_entries[self.subsystem_name] = {} |
| |
| if field not in self.maint_entries[self.subsystem_name]: |
| self.maint_entries[self.subsystem_name][field] = [] |
| |
| self.maint_entries[self.subsystem_name][field].append(details) |
| |
| self.field_prev = field |
| |
| |
| class MaintainersInclude(Include): |
| """MaintainersInclude (``maintainers-include``) directive""" |
| |
| required_arguments = 0 |
| |
| def emit(self): |
| """Parse all the MAINTAINERS lines into ReST for human-readability""" |
| path = maint_parser.path |
| output = ".. _maintainers:\n\n" |
| output += maint_parser.header |
| |
| output += ".. _maintainers_table:\n\n" |
| output += ".. flat-table::\n" |
| output += " :header-rows: 1\n\n" |
| output += " * - Subsystem\n" |
| output += " - Properties\n\n" |
| |
| self.state.document['maintainers_included'] = True |
| |
| # Keep the last entry ("THE REST") in the end |
| entries = list(maint_parser.maint_entries.keys()) |
| entries = sorted(entries[:-1], key=str.casefold) + [entries[-1]] |
| |
| for name in entries: |
| fields = maint_parser.maint_entries[name] |
| output += f" * - {name}\n" |
| tag = "-" |
| for field, lines in fields.items(): |
| field_name = maint_parser.fields.get(field, field) |
| |
| output += f" {tag} :{field_name}:\n " |
| output += ",\n ".join(lines) + "\n" |
| tag = " " |
| |
| output += "\n" |
| |
| # For debugging the pre-rendered results... |
| #print(output, file=open("/tmp/MAINTAINERS.rst", "w")) |
| |
| self.state.document.settings.record_dependencies.add(path) |
| self.state_machine.insert_input(statemachine.string2lines(output), path) |
| |
| def run(self): |
| """Include the MAINTAINERS file as part of this reST file.""" |
| if not self.state.document.settings.file_insertion_enabled: |
| raise self.warning('"%s" directive disabled.' % self.name) |
| |
| try: |
| self.emit() |
| except IOError as error: |
| raise self.severe('Problems with "%s" directive path:\n%s.' % |
| (self.name, ErrorString(error))) |
| |
| return [] |
| |
| |
| class MaintainersProfile(Include): |
| """Generate a list with all maintainer's profiles""" |
| |
| required_arguments = 0 |
| |
| def emit(self): |
| """Parse all the MAINTAINERS lines looking for profile entries""" |
| env = self.state.document.settings.env |
| docdir = os.path.dirname(os.path.join(env.srcdir, env.docname)) |
| path = maint_parser.path |
| |
| # |
| # Produce a list with all maintainer profiles, sorted by subsystem name |
| # |
| output = "" |
| for profile, entry in sorted(maint_parser.profile_entries.items()): |
| name = profile.title() |
| |
| if entry.startswith("http"): |
| output += f"- `{name} <{entry}>`_\n" |
| elif entry.startswith("`"): |
| output += f"- {name}: {entry}\n" |
| self.warning(f"{profile}: Invalid 'P' tag: {entry}\n") |
| else: |
| output += f"- {entry}\n" |
| |
| # |
| # Create a hidden TOC table with all profiles. That allows adding |
| # profiles without needing to add them on any index.rst file. |
| # |
| output += "\n.. toctree::\n" |
| output += " :hidden:\n\n" |
| |
| for f in sorted(maint_parser.profile_toc): |
| fname = os.path.join(maint_parser.base_dir, "Documentation", f) |
| fname = os.path.relpath(fname, docdir) |
| output += f" {fname}\n" |
| |
| output += "\n" |
| |
| # For debugging the pre-rendered results... |
| #print(output, file=open("/tmp/profiles.rst", "w")) |
| |
| self.state.document.settings.record_dependencies.add(path) |
| self.state_machine.insert_input(statemachine.string2lines(output), path) |
| |
| def run(self): |
| """Include the MAINTAINERS file as part of this reST file.""" |
| if not self.state.document.settings.file_insertion_enabled: |
| raise self.warning('"%s" directive disabled.' % self.name) |
| |
| try: |
| self.emit() |
| except IOError as error: |
| raise self.severe('Problems with "%s" directive path:\n%s.' % |
| (self.name, ErrorString(error))) |
| |
| return [] |
| |
| |
| # pylint: disable=W0613 |
| def add_filter_script(app, pagename, templatename, context, doctree): |
| """Add Filter javascript only to maintainers page""" |
| |
| if doctree and doctree.get('maintainers_included'): |
| app.add_js_file(None, body=JS_FILTER) |
| |
| |
| def setup(app): |
| """Setup Sphinx extension""" |
| global maint_parser # pylint: disable=W0603 |
| |
| app_dir = os.path.abspath(app.srcdir) |
| match = re.match(r"(.*/)Documentation", app_dir) |
| if not match: |
| raise ValueError('Documentation directory not found.') |
| |
| base_dir = match.group(1) |
| path = os.path.join(base_dir, "MAINTAINERS") |
| |
| maint_parser = MaintainersParser(base_dir, app_dir, path) |
| |
| app.add_directive("maintainers-include", MaintainersInclude) |
| app.add_directive("maintainers-profile-toc", MaintainersProfile) |
| |
| app.connect("html-page-context", add_filter_script) |
| |
| return { |
| "version": __version__, |
| "parallel_read_safe": True, |
| "parallel_write_safe": True, |
| } |