xref: /linux/Documentation/sphinx/maintainers_include.py (revision 17a92946d7c06ed07b77d4fb1873d68eac28ae08)
1#!/usr/bin/env python
2# SPDX-License-Identifier: GPL-2.0
3# -*- coding: utf-8; mode: python -*-
4# pylint: disable=R0903, C0330, R0914, R0912, E0401
5
6"""
7    maintainers-include
8    ~~~~~~~~~~~~~~~~~~~
9
10    Implementation of the ``maintainers-include`` reST-directive.
11
12    :copyright:  Copyright (C) 2019  Kees Cook <keescook@chromium.org>
13    :license:    GPL Version 2, June 1991 see linux/COPYING for details.
14
15    The ``maintainers-include`` reST-directive performs extensive parsing
16    specific to the Linux kernel's standard "MAINTAINERS" file, in an
17    effort to avoid needing to heavily mark up the original plain text.
18"""
19
20import sys
21import re
22import os.path
23
24from glob import glob
25
26from docutils import statemachine
27from docutils.parsers.rst import Directive
28from docutils.parsers.rst.directives.misc import Include
29
30#
31# Base URL for intersphinx-like links to maintainer profiles
32#
33KERNELDOC_URL = "https://docs.kernel.org/"
34
35def ErrorString(exc):  # Shamelessly stolen from docutils
36    return f'{exc.__class__.__name}: {exc}'
37
38__version__  = '1.0'
39
40maint_parser = None
41
42class MaintainersParser:
43    """Parse MAINTAINERS file(s) content"""
44
45    def __init__(self, app_dir, path):
46        self.path = path
47        self.profile_toc = set()
48        self.profile_entries = {}
49
50        result = list()
51        result.append(".. _maintainers:")
52        result.append("")
53
54        # Poor man's state machine.
55        descriptions = False
56        maintainers = False
57        subsystems = False
58
59        # Field letter to field name mapping.
60        field_letter = None
61        fields = dict()
62
63        prev = None
64        field_prev = ""
65        field_content = ""
66        subsystem_name = None
67
68        base_dir, doc_dir, sphinx_dir = app_dir.partition("Documentation")
69
70        for line in open(path):
71            # Have we reached the end of the preformatted Descriptions text?
72            if descriptions and line.startswith('Maintainers'):
73                descriptions = False
74                # Ensure a blank line following the last "|"-prefixed line.
75                result.append("")
76
77            # Start subsystem processing? This is to skip processing the text
78            # between the Maintainers heading and the first subsystem name.
79            if maintainers and not subsystems:
80                if re.search('^[A-Z0-9]', line):
81                    subsystems = True
82
83            # Drop needless input whitespace.
84            line = line.rstrip()
85
86            #
87            # Handle profile entries - either as files or as https refs
88            #
89            match = re.match(rf"P:\s*({doc_dir})(/\S+)\.rst", line)
90            if match:
91                name = "".join(match.groups())
92                entry = os.path.relpath(base_dir + name, app_dir)
93
94                full_name = os.path.join(base_dir, name)
95                path = os.path.relpath(full_name, app_dir)
96                #
97                # When SPHINXDIRS is used, it will try to reference files
98                # outside srctree, causing warnings. To avoid that, point
99                # to the latest official documentation
100                #
101                if path.startswith("../"):
102                    entry = KERNELDOC_URL + match.group(2) + ".html"
103                else:
104                    entry = "/" + entry
105
106                if "*" in entry:
107                    for e in glob(entry):
108                        self.profile_toc.add(e)
109                        self.profile_entries[subsystem_name] = e
110                else:
111                    self.profile_toc.add(entry)
112                    self.profile_entries[subsystem_name] = entry
113            else:
114                match = re.match(r"P:\s*(https?://.*)", line)
115                if match:
116                    entry = match.group(1).strip()
117                    self.profile_entries[subsystem_name] = entry
118
119            # Linkify all non-wildcard refs to ReST files in Documentation/.
120            pat = r'(Documentation/([^\s\?\*]*)\.rst)'
121            m = re.search(pat, line)
122            if m:
123                # maintainers.rst is in a subdirectory, so include "../".
124                line = re.sub(pat, ':doc:`%s <../%s>`' % (m.group(2), m.group(2)), line)
125
126            # Check state machine for output rendering behavior.
127            output = None
128            if descriptions:
129                # Escape the escapes in preformatted text.
130                output = "| %s" % (line.replace("\\", "\\\\"))
131                # Look for and record field letter to field name mappings:
132                #   R: Designated *reviewer*: FullName <address@domain>
133                m = re.search(r"\s(\S):\s", line)
134                if m:
135                    field_letter = m.group(1)
136                if field_letter and not field_letter in fields:
137                    m = re.search(r"\*([^\*]+)\*", line)
138                    if m:
139                        fields[field_letter] = m.group(1)
140            elif subsystems:
141                # Skip empty lines: subsystem parser adds them as needed.
142                if len(line) == 0:
143                    continue
144                # Subsystem fields are batched into "field_content"
145                if line[1] != ':':
146                    # Render a subsystem entry as:
147                    #   SUBSYSTEM NAME
148                    #   ~~~~~~~~~~~~~~
149
150                    # Flush pending field content.
151                    output = field_content + "\n\n"
152                    field_content = ""
153
154                    subsystem_name = line.title()
155
156                    # Collapse whitespace in subsystem name.
157                    heading = re.sub(r"\s+", " ", line)
158                    output = output + "%s\n%s" % (heading, "~" * len(heading))
159                    field_prev = ""
160                else:
161                    # Render a subsystem field as:
162                    #   :Field: entry
163                    #           entry...
164                    field, details = line.split(':', 1)
165                    details = details.strip()
166
167                    # Mark paths (and regexes) as literal text for improved
168                    # readability and to escape any escapes.
169                    if field in ['F', 'N', 'X', 'K']:
170                        # But only if not already marked :)
171                        if not ':doc:' in details:
172                            details = '``%s``' % (details)
173
174                    # Comma separate email field continuations.
175                    if field == field_prev and field_prev in ['M', 'R', 'L']:
176                        field_content = field_content + ","
177
178                    # Do not repeat field names, so that field entries
179                    # will be collapsed together.
180                    if field != field_prev:
181                        output = field_content + "\n"
182                        field_content = ":%s:" % (fields.get(field, field))
183                    field_content = field_content + "\n\t%s" % (details)
184                    field_prev = field
185            else:
186                output = line
187
188            # Re-split on any added newlines in any above parsing.
189            if output != None:
190                for separated in output.split('\n'):
191                    result.append(separated)
192
193            # Update the state machine when we find heading separators.
194            if line.startswith('----------'):
195                if prev.startswith('Descriptions'):
196                    descriptions = True
197                if prev.startswith('Maintainers'):
198                    maintainers = True
199
200            # Retain previous line for state machine transitions.
201            prev = line
202
203        # Flush pending field contents.
204        if field_content != "":
205            for separated in field_content.split('\n'):
206                result.append(separated)
207
208        self.output = "\n".join(result)
209
210        # Create a TOC class
211
212class MaintainersInclude(Include):
213    """MaintainersInclude (``maintainers-include``) directive"""
214    required_arguments = 0
215
216    def emit(self):
217        """Parse all the MAINTAINERS lines into ReST for human-readability"""
218        global maint_parser
219
220        path = maint_parser.path
221        output = maint_parser.output
222
223        # For debugging the pre-rendered results...
224        #print(output, file=open("/tmp/MAINTAINERS.rst", "w"))
225
226        self.state.document.settings.record_dependencies.add(path)
227        self.state_machine.insert_input(statemachine.string2lines(output), path)
228
229    def run(self):
230        """Include the MAINTAINERS file as part of this reST file."""
231        if not self.state.document.settings.file_insertion_enabled:
232            raise self.warning('"%s" directive disabled.' % self.name)
233
234        try:
235            lines = self.emit()
236        except IOError as error:
237            raise self.severe('Problems with "%s" directive path:\n%s.' %
238                      (self.name, ErrorString(error)))
239
240        return []
241
242class MaintainersProfile(Include):
243    required_arguments = 0
244
245    def emit(self):
246        """Parse all the MAINTAINERS lines looking for profile entries"""
247        global maint_parser
248
249        path = maint_parser.path
250
251        #
252        # Produce a list with all maintainer profiles, sorted by subsystem name
253        #
254        output = ""
255        for profile, entry in sorted(maint_parser.profile_entries.items()):
256            if entry.startswith("http"):
257                output += f"- `{profile} <{entry}>`_\n"
258            else:
259                output += f"- :doc:`{profile} <{entry}>`\n"
260
261        #
262        # Create a hidden TOC table with all profiles. That allows adding
263        # profiles without needing to add them on any index.rst file.
264        #
265        output += "\n.. toctree::\n"
266        output += "   :hidden:\n\n"
267
268        for fname in maint_parser.profile_toc:
269            output += f"   {fname}\n"
270
271        output += "\n"
272
273        self.state.document.settings.record_dependencies.add(path)
274        self.state_machine.insert_input(statemachine.string2lines(output), path)
275
276    def run(self):
277        """Include the MAINTAINERS file as part of this reST file."""
278        if not self.state.document.settings.file_insertion_enabled:
279            raise self.warning('"%s" directive disabled.' % self.name)
280
281        try:
282            lines = self.emit()
283        except IOError as error:
284            raise self.severe('Problems with "%s" directive path:\n%s.' %
285                      (self.name, ErrorString(error)))
286
287        return []
288
289def setup(app):
290    global maint_parser
291
292    #
293    # NOTE: we're using os.fspath() here because of a Sphinx warning:
294    #   RemovedInSphinx90Warning: Sphinx 9 will drop support for representing paths as strings. Use "pathlib.Path" or "os.fspath" instead.
295    #
296    app_dir = os.fspath(app.srcdir)
297    srctree = os.path.abspath(os.environ["srctree"])
298    path = os.path.join(srctree, "MAINTAINERS")
299
300    maint_parser = MaintainersParser(app_dir, path)
301
302    app.add_directive("maintainers-include", MaintainersInclude)
303    app.add_directive("maintainers-profile-toc", MaintainersProfile)
304    return dict(
305        version = __version__,
306        parallel_read_safe = True,
307        parallel_write_safe = True
308    )
309