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