projects/linux·blob master · refs

418 lines · 13.2 KiB

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 <[email protected]>
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'/{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 if not name:
349 name = entry
350
351 output += f"- :doc:`{name} <{entry}>`\n"
352
353 #
354 # Create a hidden TOC table with all profiles. That allows adding
355 # profiles without needing to add them on any index.rst file.
356 #
357 output += "\n.. toctree::\n"
358 output += " :hidden:\n\n"
359
360 for f in sorted(maint_parser.profile_toc):
361 fname = os.path.join(maint_parser.base_dir, "Documentation", f)
362 fname = os.path.relpath(fname, docdir)
363 output += f" {fname}\n"
364
365 output += "\n"
366
367 # For debugging the pre-rendered results...
368 #print(output, file=open("/tmp/profiles.rst", "w"))
369
370 self.state.document.settings.record_dependencies.add(path)
371 self.state_machine.insert_input(statemachine.string2lines(output), path)
372
373 def run(self):
374 """Include the MAINTAINERS file as part of this reST file."""
375 if not self.state.document.settings.file_insertion_enabled:
376 raise self.warning('"%s" directive disabled.' % self.name)
377
378 try:
379 self.emit()
380 except IOError as error:
381 raise self.severe('Problems with "%s" directive path:\n%s.' %
382 (self.name, ErrorString(error)))
383
384 return []
385
386
387# pylint: disable=W0613
388def add_filter_script(app, pagename, templatename, context, doctree):
389 """Add Filter javascript only to maintainers page"""
390
391 if doctree and doctree.get('maintainers_included'):
392 app.add_js_file(None, body=JS_FILTER)
393
394
395def setup(app):
396 """Setup Sphinx extension"""
397 global maint_parser # pylint: disable=W0603
398
399 app_dir = os.path.abspath(app.srcdir)
400 match = re.match(r"(.*/)Documentation", app_dir)
401 if not match:
402 raise ValueError('Documentation directory not found.')
403
404 base_dir = match.group(1)
405 path = os.path.join(base_dir, "MAINTAINERS")
406
407 maint_parser = MaintainersParser(base_dir, app_dir, path)
408
409 app.add_directive("maintainers-include", MaintainersInclude)
410 app.add_directive("maintainers-profile-toc", MaintainersProfile)
411
412 app.connect("html-page-context", add_filter_script)
413
414 return {
415 "version": __version__,
416 "parallel_read_safe": True,
417 "parallel_write_safe": True,
418 }