xref: /linux/Documentation/sphinx/maintainers_include.py (revision f69322f8f8bde6513be5ce09883f825c67df284e)
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