Merge branch 'mauro' into docs-mw

This series improve the output at process/maintainers: instead of a
pure enriched text, the maintainer's file content is now converted
to a table, and has gained a javascript to allow filtering entries.

The initial patches change the logic to split parsing from
output generation. Then, everything is stored into a dict at
the parsing phase, and ona header description variable.

This way, it is easier to adjust the output handler to produce
a more structured document. Right now, the entries are sorted
alphabetically, per subsystem's name(*).

(*) Currently, MAINTAINERS file has several entries not sorted.
    One has to run:

	 scripts/parse-maintainers.pl --input MAINTAINERS --output MAINTAINERS.new

    to sort it.
This commit is contained in:
Jonathan Corbet 2026-05-15 08:06:56 -06:00
commit 5677cae653
2 changed files with 293 additions and 191 deletions

View File

@ -1,30 +1,25 @@
#!/usr/bin/env python
# SPDX-License-Identifier: GPL-2.0
# -*- coding: utf-8; mode: python -*-
# pylint: disable=R0903, C0330, R0914, R0912, E0401
# pylint: disable=C0209, C0301, E0401, R0022, R0902, R0903, R0912, R0914
"""
maintainers-include
~~~~~~~~~~~~~~~~~~~
Implementation of the ``maintainers-include`` reST-directive.
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.
: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.
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 sys
import re
import os.path
import re
from glob import glob
from docutils import statemachine
from docutils.parsers.rst import Directive
from docutils.parsers.rst.directives.misc import Include
#
@ -32,193 +27,274 @@ from docutils.parsers.rst.directives.misc import Include
#
KERNELDOC_URL = "https://docs.kernel.org/"
def ErrorString(exc): # Shamelessly stolen from docutils
return f'{exc.__class__.__name}: {exc}'
__version__ = "1.0"
__version__ = '1.0'
maint_parser = None # pylint: disable=C0103
maint_parser = None
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, app_dir, path):
def __init__(self, base_dir, app_dir, path):
self.path = path
self.profile_toc = set()
self.profile_entries = {}
result = list()
result.append(".. _maintainers:")
result.append("")
# Poor man's state machine.
descriptions = False
maintainers = False
subsystems = False
self.descriptions = False
self.maintainers = False
self.subsystems = False
# Field letter to field name mapping.
field_letter = None
fields = dict()
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
field_prev = ""
field_content = ""
subsystem_name = None
base_dir, doc_dir, sphinx_dir = app_dir.partition("Documentation")
for line in open(path):
# Have we reached the end of the preformatted Descriptions text?
if descriptions and line.startswith('Maintainers'):
descriptions = False
# Ensure a blank line following the last "|"-prefixed line.
result.append("")
# Start subsystem processing? This is to skip processing the text
# between the Maintainers heading and the first subsystem name.
if maintainers and not subsystems:
if re.search('^[A-Z0-9]', line):
subsystems = True
# Drop needless input whitespace.
line = line.rstrip()
#
# Handle profile entries - either as files or as https refs
#
match = re.match(rf"P:\s*({doc_dir})(/\S+)\.rst", line)
if match:
name = "".join(match.groups())
entry = os.path.relpath(base_dir + name, app_dir)
full_name = os.path.join(base_dir, name)
path = os.path.relpath(full_name, app_dir)
#
# When SPHINXDIRS is used, it will try to reference files
# outside srctree, causing warnings. To avoid that, point
# to the latest official documentation
#
if path.startswith("../"):
entry = KERNELDOC_URL + match.group(2) + ".html"
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:
entry = "/" + entry
self.header += line
if "*" in entry:
for e in glob(entry):
# 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(m.group(1), root_dir=self.base_dir)
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[subsystem_name] = e
else:
self.profile_toc.add(entry)
self.profile_entries[subsystem_name] = entry
self.profile_entries[self.subsystem_name] = link
details = ", ".join(entries.values())
else:
match = re.match(r"P:\s*(https?://.*)", line)
match = re.match(r"(https?://.*)", details)
if match:
entry = match.group(1).strip()
self.profile_entries[subsystem_name] = entry
# Linkify all non-wildcard refs to ReST files in Documentation/.
pat = r'(Documentation/([^\s\?\*]*)\.rst)'
m = re.search(pat, line)
if m:
# maintainers.rst is in a subdirectory, so include "../".
line = re.sub(pat, ':doc:`%s <../%s>`' % (m.group(2), m.group(2)), line)
# Check state machine for output rendering behavior.
output = None
if descriptions:
# Escape the escapes in preformatted text.
output = "| %s" % (line.replace("\\", "\\\\"))
# Look for and record field letter to field name mappings:
# R: Designated *reviewer*: FullName <address@domain>
m = re.search(r"\s(\S):\s", line)
if m:
field_letter = m.group(1)
if field_letter and not field_letter in fields:
m = re.search(r"\*([^\*]+)\*", line)
if m:
fields[field_letter] = m.group(1)
elif subsystems:
# Skip empty lines: subsystem parser adds them as needed.
if len(line) == 0:
continue
# Subsystem fields are batched into "field_content"
if line[1] != ':':
# Render a subsystem entry as:
# SUBSYSTEM NAME
# ~~~~~~~~~~~~~~
# Flush pending field content.
output = field_content + "\n\n"
field_content = ""
subsystem_name = line.title()
# Collapse whitespace in subsystem name.
heading = re.sub(r"\s+", " ", line)
output = output + "%s\n%s" % (heading, "~" * len(heading))
field_prev = ""
self.profile_entries[self.subsystem_name] = entry
else:
# Render a subsystem field as:
# :Field: entry
# entry...
field, details = line.split(':', 1)
details = details.strip()
self.profile_entries[self.subsystem_name] = f"``{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 not ':doc:' in details:
details = '``%s``' % (details)
details = self.linkify(details)
else:
details = self.linkify(details)
# Comma separate email field continuations.
if field == field_prev and field_prev in ['M', 'R', 'L']:
field_content = field_content + ","
#
# 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)
# Do not repeat field names, so that field entries
# will be collapsed together.
if field != field_prev:
output = field_content + "\n"
field_content = ":%s:" % (fields.get(field, field))
field_content = field_content + "\n\t%s" % (details)
field_prev = field
else:
output = line
if self.subsystem_name not in self.maint_entries:
self.maint_entries[self.subsystem_name] = {}
# Re-split on any added newlines in any above parsing.
if output != None:
for separated in output.split('\n'):
result.append(separated)
if field not in self.maint_entries[self.subsystem_name]:
self.maint_entries[self.subsystem_name][field] = []
# Update the state machine when we find heading separators.
if line.startswith('----------'):
if prev.startswith('Descriptions'):
descriptions = True
if prev.startswith('Maintainers'):
maintainers = True
self.maint_entries[self.subsystem_name][field].append(details)
# Retain previous line for state machine transitions.
prev = line
self.field_prev = field
# Flush pending field contents.
if field_content != "":
for separated in field_content.split('\n'):
result.append(separated)
self.output = "\n".join(result)
# Create a TOC class
class MaintainersInclude(Include):
"""MaintainersInclude (``maintainers-include``) directive"""
required_arguments = 0
def emit(self):
"""Parse all the MAINTAINERS lines into ReST for human-readability"""
global maint_parser
path = maint_parser.path
output = maint_parser.output
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
for name, fields in sorted(maint_parser.maint_entries.items()):
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"))
@ -232,20 +308,23 @@ class MaintainersInclude(Include):
raise self.warning('"%s" directive disabled.' % self.name)
try:
lines = self.emit()
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"""
global maint_parser
env = self.state.document.settings.env
docdir = os.path.dirname(os.path.join(env.srcdir, env.docname))
path = maint_parser.path
#
@ -253,10 +332,15 @@ class MaintainersProfile(Include):
#
output = ""
for profile, entry in sorted(maint_parser.profile_entries.items()):
name = profile.title()
if entry.startswith("http"):
output += f"- `{profile} <{entry}>`_\n"
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"- :doc:`{profile} <{entry}>`\n"
output += f"- {entry}\n"
#
# Create a hidden TOC table with all profiles. That allows adding
@ -265,11 +349,16 @@ class MaintainersProfile(Include):
output += "\n.. toctree::\n"
output += " :hidden:\n\n"
for fname in maint_parser.profile_toc:
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)
@ -279,30 +368,43 @@ class MaintainersProfile(Include):
raise self.warning('"%s" directive disabled.' % self.name)
try:
lines = self.emit()
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):
global maint_parser
"""Setup Sphinx extension"""
global maint_parser # pylint: disable=W0603
#
# NOTE: we're using os.fspath() here because of a Sphinx warning:
# RemovedInSphinx90Warning: Sphinx 9 will drop support for representing paths as strings. Use "pathlib.Path" or "os.fspath" instead.
#
app_dir = os.fspath(app.srcdir)
srctree = os.path.abspath(os.environ["srctree"])
path = os.path.join(srctree, "MAINTAINERS")
app_dir = os.path.abspath(app.srcdir)
match = re.match(r"(.*/)Documentation", app_dir)
if not match:
raise ValueError('Documentation directory not found.')
maint_parser = MaintainersParser(app_dir, path)
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)
return dict(
version = __version__,
parallel_read_safe = True,
parallel_write_safe = True
)
app.connect("html-page-context", add_filter_script)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View File

@ -25,9 +25,9 @@ Descriptions of section entries and preferred order
C: URI for *chat* protocol, server and channel where developers
usually hang out, for example irc://server/channel.
P: *Subsystem Profile* document for more details submitting
patches to the given subsystem. This is either an in-tree file,
or a URI. See Documentation/maintainer/maintainer-entry-profile.rst
for details.
patches to the given subsystem. This is either an in-tree .rst file
inside Documentation/, or a URI.
See Documentation/maintainer/maintainer-entry-profile.rst for details.
T: *SCM* tree type and location.
Type is one of: git, hg, quilt, stgit, topgit
F: *Files* and directories wildcard patterns.
@ -23402,7 +23402,7 @@ S: Maintained
W: https://rust-for-linux.com/pin-init
B: https://github.com/Rust-for-Linux/pin-init/issues
C: zulip://rust-for-linux.zulipchat.com
P: rust/pin-init/CONTRIBUTING.md
P: https://github.com/Rust-for-Linux/pin-init/blob/main/CONTRIBUTING.md
T: git https://github.com/Rust-for-Linux/linux.git pin-init-next
F: rust/kernel/init.rs
F: rust/pin-init/