sphinx-java-autodoc 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sphinx_java_autodoc/__init__.py +239 -0
- sphinx_java_autodoc/__main__.py +67 -0
- sphinx_java_autodoc/domain.py +387 -0
- sphinx_java_autodoc/extractor.py +254 -0
- sphinx_java_autodoc/java/SphinxDoclet.java +1036 -0
- sphinx_java_autodoc/javadoc.py +45 -0
- sphinx_java_autodoc/model.py +173 -0
- sphinx_java_autodoc/render.py +469 -0
- sphinx_java_autodoc/static/java-autodoc.css +28 -0
- sphinx_java_autodoc-0.2.0.dist-info/METADATA +109 -0
- sphinx_java_autodoc-0.2.0.dist-info/RECORD +14 -0
- sphinx_java_autodoc-0.2.0.dist-info/WHEEL +4 -0
- sphinx_java_autodoc-0.2.0.dist-info/entry_points.txt +2 -0
- sphinx_java_autodoc-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""External Javadoc links: symbols documented on another site's standard Javadoc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote, urlsplit
|
|
6
|
+
|
|
7
|
+
from sphinx.errors import ExtensionError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_mapping(mapping: dict[str, str]) -> None:
|
|
11
|
+
"""Validates ``java_external_javadoc`` once, before any link is resolved."""
|
|
12
|
+
for prefix, url in mapping.items():
|
|
13
|
+
if not prefix or urlsplit(url).scheme not in {"http", "https"}:
|
|
14
|
+
raise ExtensionError(
|
|
15
|
+
"java_external_javadoc must map package prefixes to http(s) Javadoc roots."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def external_url(
|
|
20
|
+
target: str, spelling: str, metadata: dict[str, str], mapping: dict[str, str]
|
|
21
|
+
) -> str | None:
|
|
22
|
+
"""The Javadoc URL for a canonical ``target`` under the longest matching prefix.
|
|
23
|
+
|
|
24
|
+
``metadata`` is the compiler's path, fragment and module for the target; without
|
|
25
|
+
it, the URL is derived from the reference ``spelling`` and the prefix must be
|
|
26
|
+
the exact package.
|
|
27
|
+
"""
|
|
28
|
+
for prefix, base in sorted(mapping.items(), key=lambda item: -len(item[0])):
|
|
29
|
+
if target != prefix and not target.startswith(prefix.rstrip(".") + "."):
|
|
30
|
+
continue
|
|
31
|
+
if metadata.get("path"):
|
|
32
|
+
path = metadata["path"]
|
|
33
|
+
fragment = metadata.get("fragment", "")
|
|
34
|
+
base = base.replace("{module}", metadata.get("module", ""))
|
|
35
|
+
else:
|
|
36
|
+
if "{module}" in base:
|
|
37
|
+
continue
|
|
38
|
+
owner, _, fragment = spelling.partition("#")
|
|
39
|
+
package = prefix.rstrip(".")
|
|
40
|
+
path = package.replace(".", "/") + "/" + owner[len(package) + 1 :] + ".html"
|
|
41
|
+
url = base.rstrip("/") + "/" + quote(path, safe="/.$")
|
|
42
|
+
if fragment:
|
|
43
|
+
url += "#" + quote(fragment, safe="(),.[]$")
|
|
44
|
+
return url
|
|
45
|
+
return None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The API model: one extraction's validated declarations, looked up by symbol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from sphinx.errors import ExtensionError
|
|
10
|
+
|
|
11
|
+
# Member groups in display order, as section headings of generated type pages.
|
|
12
|
+
MEMBER_GROUPS = {
|
|
13
|
+
"constructor": "Constructors",
|
|
14
|
+
"field": "Fields and constants",
|
|
15
|
+
"method": "Methods",
|
|
16
|
+
}
|
|
17
|
+
TYPE_GROUPS = {
|
|
18
|
+
"interface": "Interfaces",
|
|
19
|
+
"class": "Classes",
|
|
20
|
+
"enum": "Enums",
|
|
21
|
+
"record": "Records",
|
|
22
|
+
"annotation_type": "Annotation types",
|
|
23
|
+
}
|
|
24
|
+
# The extracted model, indexed by prepare() on every build. It stays out of the pickled
|
|
25
|
+
# environment; forked parallel workers inherit it.
|
|
26
|
+
SYMBOLS: dict[str, ET.Element] = {}
|
|
27
|
+
PACKAGES: dict[str, ET.Element] = {}
|
|
28
|
+
REFERENCES: dict[str, dict[str, str]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_model(path: Path) -> ET.Element:
|
|
32
|
+
"""Validates XML schema 1 at the process boundary and returns its root."""
|
|
33
|
+
try:
|
|
34
|
+
data = path.read_bytes().decode("utf-8")
|
|
35
|
+
if "<!DOCTYPE" in data.upper() or "<!ENTITY" in data.upper():
|
|
36
|
+
raise ValueError("DTD and entity declarations are not allowed")
|
|
37
|
+
root = ET.fromstring(data)
|
|
38
|
+
if root.tag != "api" or root.get("schema") != "1":
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"expected API XML schema 1; regenerate with sphinx-java-autodoc"
|
|
41
|
+
)
|
|
42
|
+
packages = [p.get("name") for p in root.findall("package")]
|
|
43
|
+
qualified = r"[\w$]+(?:\.[\w$]+)*"
|
|
44
|
+
if any(p is None or (p and not re.fullmatch(qualified, p)) for p in packages):
|
|
45
|
+
raise ValueError("invalid package name")
|
|
46
|
+
if len(set(packages)) != len(packages):
|
|
47
|
+
raise ValueError("duplicate package")
|
|
48
|
+
owners = {id(m): t for t in root.findall("type") for m in t.findall("member")}
|
|
49
|
+
ids = set()
|
|
50
|
+
for node in root.findall("type") + root.findall("type/member"):
|
|
51
|
+
for key in ("id", "name", "kind", "visibility", "package"):
|
|
52
|
+
if key not in node.attrib:
|
|
53
|
+
raise ValueError(f"{node.tag} is missing {key}")
|
|
54
|
+
if node.find("sig") is None:
|
|
55
|
+
raise ValueError(f"{node.tag} is missing sig")
|
|
56
|
+
if node.attrib["id"] in ids:
|
|
57
|
+
raise ValueError(f"duplicate symbol {node.attrib['id']}")
|
|
58
|
+
ids.add(node.attrib["id"])
|
|
59
|
+
if node.get("visibility") not in {"public", "protected"}:
|
|
60
|
+
raise ValueError("invalid visibility")
|
|
61
|
+
symbol, name, kind, package = (
|
|
62
|
+
node.get(key) for key in ("id", "name", "kind", "package")
|
|
63
|
+
)
|
|
64
|
+
if not name or not "".join(node.find("sig").itertext()).strip():
|
|
65
|
+
raise ValueError("empty declaration name or signature")
|
|
66
|
+
if package not in packages:
|
|
67
|
+
raise ValueError(f"undeclared package {package!r} for {symbol}")
|
|
68
|
+
if node.tag == "type":
|
|
69
|
+
if kind not in {
|
|
70
|
+
"class",
|
|
71
|
+
"interface",
|
|
72
|
+
"enum",
|
|
73
|
+
"record",
|
|
74
|
+
"annotation_type",
|
|
75
|
+
}:
|
|
76
|
+
raise ValueError(f"invalid type kind {kind!r}")
|
|
77
|
+
local = symbol.removeprefix(package + ".") if package else symbol
|
|
78
|
+
if (
|
|
79
|
+
not re.fullmatch(qualified, local)
|
|
80
|
+
or local.rsplit(".", 1)[-1] != name
|
|
81
|
+
or (package and not symbol.startswith(package + "."))
|
|
82
|
+
):
|
|
83
|
+
raise ValueError(f"invalid type identity {symbol!r}")
|
|
84
|
+
else:
|
|
85
|
+
owner = owners[id(node)]
|
|
86
|
+
if package != owner.get("package") or not symbol.startswith(
|
|
87
|
+
owner.get("id") + "#"
|
|
88
|
+
):
|
|
89
|
+
raise ValueError(f"invalid member owner for {symbol!r}")
|
|
90
|
+
suffix = symbol.split("#", 1)[1]
|
|
91
|
+
if kind in {"method", "constructor"}:
|
|
92
|
+
parameter = qualified + r"(?:\[\])*"
|
|
93
|
+
pattern = (
|
|
94
|
+
re.escape(name) + rf"\((?:{parameter}(?:,{parameter})*)?\)"
|
|
95
|
+
)
|
|
96
|
+
valid = re.fullmatch(pattern, suffix) and (
|
|
97
|
+
name == "<init>"
|
|
98
|
+
if kind == "constructor"
|
|
99
|
+
else re.fullmatch(r"[\w$]+", name)
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
valid = (
|
|
103
|
+
kind in {"field", "enum_constant", "record_component"}
|
|
104
|
+
and suffix == name
|
|
105
|
+
and re.fullmatch(r"[\w$]+", name)
|
|
106
|
+
)
|
|
107
|
+
if not valid:
|
|
108
|
+
raise ValueError(f"invalid member identity or kind for {symbol!r}")
|
|
109
|
+
tokens = {"k", "kt", "t", "q", "n", "tv", "pname", "v", "r"}
|
|
110
|
+
for sig in root.findall("type/sig") + root.findall("type/member/sig"):
|
|
111
|
+
for token in sig.iter():
|
|
112
|
+
if token is sig or token.tag == "param":
|
|
113
|
+
continue
|
|
114
|
+
if token.tag == "params":
|
|
115
|
+
if any(child.tag != "param" for child in token):
|
|
116
|
+
raise ValueError("invalid signature parameter list")
|
|
117
|
+
elif token.tag not in tokens or len(token):
|
|
118
|
+
raise ValueError(f"invalid signature token {token.tag!r}")
|
|
119
|
+
elif token.tag == "r" and not re.fullmatch(
|
|
120
|
+
qualified, token.get("target", "")
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("invalid signature type reference")
|
|
123
|
+
return root
|
|
124
|
+
except (OSError, ET.ParseError, ValueError) as exc:
|
|
125
|
+
raise ExtensionError(f"Java autodoc: invalid model {path}: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def use_model(root: ET.Element) -> None:
|
|
129
|
+
SYMBOLS.clear()
|
|
130
|
+
SYMBOLS.update(
|
|
131
|
+
(e.get("id"), e) for e in root.findall("type") + root.findall("type/member")
|
|
132
|
+
)
|
|
133
|
+
PACKAGES.clear()
|
|
134
|
+
PACKAGES.update((p.get("name"), p) for p in root.findall("package"))
|
|
135
|
+
REFERENCES.clear()
|
|
136
|
+
REFERENCES.update((n.get("target"), dict(n.attrib)) for n in root.iter("link"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def visible(element: ET.Element, visibility: str) -> bool:
|
|
140
|
+
return visibility == "protected" or element.get("visibility") == "public"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def member_group(member: ET.Element) -> str:
|
|
144
|
+
kind = member.get("kind")
|
|
145
|
+
return kind if kind in {"constructor", "method"} else "field"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def grouped(members) -> dict[str, list[ET.Element]]:
|
|
149
|
+
"""Members by member group, in display order."""
|
|
150
|
+
groups = {group: [] for group in MEMBER_GROUPS}
|
|
151
|
+
for member in members:
|
|
152
|
+
groups[member_group(member)].append(member)
|
|
153
|
+
return {group: found for group, found in groups.items() if found}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def member_label(symbol: str) -> str:
|
|
157
|
+
"""A member's name with simple parameter types, as in Javadoc summaries."""
|
|
158
|
+
owner, _, rest = symbol.partition("#")
|
|
159
|
+
name, paren, parameters = rest.partition("(")
|
|
160
|
+
if name == "<init>":
|
|
161
|
+
name = owner.rpartition(".")[2]
|
|
162
|
+
if not paren:
|
|
163
|
+
return name
|
|
164
|
+
simple = [p.rpartition(".")[2] for p in parameters.rstrip(")").split(",") if p]
|
|
165
|
+
return f"{name}({', '.join(simple)})"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def local_name(element: ET.Element) -> str:
|
|
169
|
+
"""A type without its package, or a member's label."""
|
|
170
|
+
symbol, package = element.get("id"), element.get("package")
|
|
171
|
+
if element.tag == "member":
|
|
172
|
+
return member_label(symbol)
|
|
173
|
+
return symbol[len(package) + 1 :] if package else symbol
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Map compiler-produced comment tokens to portable docutils nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from docutils import nodes
|
|
10
|
+
from sphinx import addnodes
|
|
11
|
+
from sphinx.util import logging
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
TABLE_TAGS = {"table", "caption", "thead", "tbody", "tfoot", "tr", "th", "td"}
|
|
15
|
+
TABLE_SECTIONS = {"thead", "tbody", "tfoot"}
|
|
16
|
+
# Presentation attributes are dropped; Sphinx themes style native tables.
|
|
17
|
+
ATTRIBUTES = {
|
|
18
|
+
"pre": {"lang"},
|
|
19
|
+
"table": {"class", "summary", "border"},
|
|
20
|
+
"th": {"colspan", "rowspan", "scope"},
|
|
21
|
+
"td": {"colspan", "rowspan", "scope"},
|
|
22
|
+
}
|
|
23
|
+
# Unsupported block elements still end the current paragraph.
|
|
24
|
+
BLOCK_LEVEL = {
|
|
25
|
+
*(f"h{level}" for level in range(1, 7)),
|
|
26
|
+
*("address", "article", "aside", "center", "dd", "div", "dl", "dt", "figcaption"),
|
|
27
|
+
*("figure", "footer", "header", "hr", "main", "nav", "section"),
|
|
28
|
+
}
|
|
29
|
+
# HTML limits for cell spans.
|
|
30
|
+
SPAN_LIMITS = {"colspan": 1000, "rowspan": 65534}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reference(token: ET.Element, children: list[nodes.Node]) -> nodes.Node:
|
|
34
|
+
node = addnodes.pending_xref(
|
|
35
|
+
"",
|
|
36
|
+
refdomain="java",
|
|
37
|
+
reftype=token.get("role", "obj"),
|
|
38
|
+
reftarget=token.get("target", ""),
|
|
39
|
+
refexplicit=True,
|
|
40
|
+
refwarn=True,
|
|
41
|
+
)
|
|
42
|
+
node["java:external"] = dict(token.attrib)
|
|
43
|
+
if token.get("plain") == "true":
|
|
44
|
+
node.extend(children)
|
|
45
|
+
else:
|
|
46
|
+
node += nodes.literal("", "", *children)
|
|
47
|
+
return node
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def comment(part: ET.Element, location, quiet: bool = False) -> list[nodes.Node]:
|
|
51
|
+
"""Renders comment tokens; ``quiet`` skips warnings for repeated renderings."""
|
|
52
|
+
root = nodes.container()
|
|
53
|
+
stack: list[tuple[str, nodes.Element]] = [("root", root)]
|
|
54
|
+
block_tags = {"p", "ul", "ol", "li", "pre", "blockquote"}
|
|
55
|
+
supported = block_tags | TABLE_TAGS | {"code", "b", "strong", "i", "em", "a", "br"}
|
|
56
|
+
head_rows: set[int] = set()
|
|
57
|
+
header_cells: set[int] = set()
|
|
58
|
+
# Paragraphs from <p> or ended by a block element take no further inline content.
|
|
59
|
+
sealed: set[int] = set()
|
|
60
|
+
|
|
61
|
+
def warn(message):
|
|
62
|
+
if not quiet:
|
|
63
|
+
LOGGER.warning(message, location=location, type="java", subtype="html")
|
|
64
|
+
|
|
65
|
+
def close(tag):
|
|
66
|
+
for i in range(len(stack) - 1, 0, -1):
|
|
67
|
+
if stack[i][0] == tag:
|
|
68
|
+
del stack[i:]
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
def break_paragraph():
|
|
72
|
+
close("p")
|
|
73
|
+
current = stack[-1][1]
|
|
74
|
+
if current.children and isinstance(current.children[-1], nodes.paragraph):
|
|
75
|
+
sealed.add(id(current.children[-1]))
|
|
76
|
+
|
|
77
|
+
def unwind(tags):
|
|
78
|
+
"""Closes elements above the innermost open tag in ``tags`` and returns it."""
|
|
79
|
+
for i in range(len(stack) - 1, 0, -1):
|
|
80
|
+
if stack[i][0] in tags:
|
|
81
|
+
del stack[i + 1 :]
|
|
82
|
+
return stack[i][0]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def span(attrs, name):
|
|
86
|
+
value = attrs.get(name, "1")
|
|
87
|
+
if not value.strip().isdigit() or int(value) < 1:
|
|
88
|
+
warn(
|
|
89
|
+
f"Invalid {name} {value!r} on a Javadoc table cell; use a positive integer."
|
|
90
|
+
)
|
|
91
|
+
return 1
|
|
92
|
+
return min(int(value), SPAN_LIMITS[name])
|
|
93
|
+
|
|
94
|
+
def table_start(name, attrs):
|
|
95
|
+
"""Opens table structure; HTML's optional end tags close implicitly."""
|
|
96
|
+
close("p")
|
|
97
|
+
if name == "table":
|
|
98
|
+
node = nodes.table(classes=["colwidths-auto", "java-table"])
|
|
99
|
+
stack[-1][1].append(node)
|
|
100
|
+
return node
|
|
101
|
+
table = next((node for tag, node in reversed(stack) if tag == "table"), None)
|
|
102
|
+
if table is None:
|
|
103
|
+
warn(f"Javadoc <{name}> needs a surrounding <table>.")
|
|
104
|
+
return None
|
|
105
|
+
if name == "caption" or name in TABLE_SECTIONS:
|
|
106
|
+
unwind({"table"})
|
|
107
|
+
if name == "caption":
|
|
108
|
+
if table.children and isinstance(table.children[0], nodes.title):
|
|
109
|
+
warn("Javadoc <table> has more than one <caption>.")
|
|
110
|
+
return None
|
|
111
|
+
node = nodes.title()
|
|
112
|
+
table.insert(0, node)
|
|
113
|
+
return node
|
|
114
|
+
if name in TABLE_SECTIONS:
|
|
115
|
+
return table
|
|
116
|
+
section = unwind({"table", *TABLE_SECTIONS, "tr"})
|
|
117
|
+
if name == "tr" or section != "tr":
|
|
118
|
+
unwind({"table", *TABLE_SECTIONS})
|
|
119
|
+
row = nodes.row()
|
|
120
|
+
table += row
|
|
121
|
+
if stack[-1][0] == "thead":
|
|
122
|
+
head_rows.add(id(row))
|
|
123
|
+
if name == "tr":
|
|
124
|
+
return row
|
|
125
|
+
stack.append(("tr", row))
|
|
126
|
+
node = nodes.entry()
|
|
127
|
+
if (cols := span(attrs, "colspan")) > 1:
|
|
128
|
+
node["morecols"] = cols - 1
|
|
129
|
+
if (rows := span(attrs, "rowspan")) > 1:
|
|
130
|
+
node["morerows"] = rows - 1
|
|
131
|
+
if name == "th":
|
|
132
|
+
header_cells.add(id(node))
|
|
133
|
+
stack[-1][1].append(node)
|
|
134
|
+
return node
|
|
135
|
+
|
|
136
|
+
def table_finish(table):
|
|
137
|
+
"""Lays out collected rows on a grid as a docutils table with a header."""
|
|
138
|
+
rows = [child for child in table.children if isinstance(child, nodes.row)]
|
|
139
|
+
if not rows:
|
|
140
|
+
warn("Javadoc <table> has no rows; use <tr> with <td> or <th> cells.")
|
|
141
|
+
table.parent.remove(table)
|
|
142
|
+
return
|
|
143
|
+
occupied: set[tuple[int, int]] = set()
|
|
144
|
+
widths: dict[int, int] = {}
|
|
145
|
+
for r, row in enumerate(rows):
|
|
146
|
+
c = 0
|
|
147
|
+
for entry in row.children:
|
|
148
|
+
while (r, c) in occupied:
|
|
149
|
+
c += 1
|
|
150
|
+
if not entry.get("morecols"):
|
|
151
|
+
# The text builder adds quotes or asterisks around inline markup.
|
|
152
|
+
markup = entry.findall(
|
|
153
|
+
lambda n: isinstance(
|
|
154
|
+
n, (nodes.literal, nodes.emphasis, nodes.strong)
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
width = max(map(len, entry.astext().splitlines() or [""]))
|
|
158
|
+
width += 2 * len(list(markup))
|
|
159
|
+
widths[c] = max(widths.get(c, 1), min(width, 40))
|
|
160
|
+
entry["morerows"] = min(entry.get("morerows", 0), len(rows) - r - 1)
|
|
161
|
+
for dr in range(entry.get("morerows", 0) + 1):
|
|
162
|
+
for dc in range(entry.get("morecols", 0) + 1):
|
|
163
|
+
occupied.add((r + dr, c + dc))
|
|
164
|
+
c += entry.get("morecols", 0) + 1
|
|
165
|
+
columns = max(c for _, c in occupied) + 1
|
|
166
|
+
for r, row in enumerate(rows):
|
|
167
|
+
row.extend(nodes.entry() for c in range(columns) if (r, c) not in occupied)
|
|
168
|
+
head = 0
|
|
169
|
+
while head < len(rows) and (
|
|
170
|
+
id(rows[head]) in head_rows
|
|
171
|
+
or rows[head].children
|
|
172
|
+
and all(id(entry) in header_cells for entry in rows[head].children)
|
|
173
|
+
):
|
|
174
|
+
head += 1
|
|
175
|
+
if head == len(rows):
|
|
176
|
+
head = 0
|
|
177
|
+
group = nodes.tgroup(cols=columns)
|
|
178
|
+
# Text and LaTeX builders size columns from colwidth; HTML sizes automatically.
|
|
179
|
+
group.extend(nodes.colspec(colwidth=widths.get(c, 1)) for c in range(columns))
|
|
180
|
+
if head:
|
|
181
|
+
group += nodes.thead("", *rows[:head])
|
|
182
|
+
group += nodes.tbody("", *rows[head:])
|
|
183
|
+
table.children = [c for c in table.children if isinstance(c, nodes.title)]
|
|
184
|
+
table += group
|
|
185
|
+
# Themes style tables differently, but all leave a plain wrapper alone, so the
|
|
186
|
+
# wrapper is what scrolls a wide table.
|
|
187
|
+
wrapper = nodes.container(classes=["java-table-wrapper"])
|
|
188
|
+
table.parent.replace(table, wrapper)
|
|
189
|
+
wrapper += table
|
|
190
|
+
|
|
191
|
+
def append(node):
|
|
192
|
+
current = stack[-1][1]
|
|
193
|
+
if isinstance(current, nodes.literal_block):
|
|
194
|
+
current += nodes.Text(node.astext())
|
|
195
|
+
return
|
|
196
|
+
if isinstance(current, (nodes.table, nodes.row)):
|
|
197
|
+
if node.astext().strip():
|
|
198
|
+
warn("Javadoc table content must be inside <td> or <th>.")
|
|
199
|
+
return
|
|
200
|
+
if isinstance(
|
|
201
|
+
current, (nodes.container, nodes.list_item, nodes.block_quote, nodes.entry)
|
|
202
|
+
):
|
|
203
|
+
last = current.children[-1] if current.children else None
|
|
204
|
+
if not isinstance(last, nodes.paragraph) or id(last) in sealed:
|
|
205
|
+
# Whitespace between blocks does not start a paragraph.
|
|
206
|
+
if isinstance(node, nodes.Text) and not node.astext().strip():
|
|
207
|
+
return
|
|
208
|
+
current += nodes.paragraph()
|
|
209
|
+
current.children[-1] += node
|
|
210
|
+
elif isinstance(current, (nodes.bullet_list, nodes.enumerated_list)):
|
|
211
|
+
if node.astext().strip():
|
|
212
|
+
warn("Javadoc list content must be inside <li>.")
|
|
213
|
+
else:
|
|
214
|
+
current += node
|
|
215
|
+
|
|
216
|
+
def tokens(items):
|
|
217
|
+
for token in items:
|
|
218
|
+
tag = token.tag
|
|
219
|
+
if tag in {"text", "literal", "code"}:
|
|
220
|
+
value = token.text or ""
|
|
221
|
+
if (
|
|
222
|
+
not any(isinstance(n, nodes.literal_block) for _, n in stack)
|
|
223
|
+
and tag == "text"
|
|
224
|
+
):
|
|
225
|
+
value = re.sub(r"\s+", " ", value)
|
|
226
|
+
if tag == "code":
|
|
227
|
+
append(nodes.literal("", value))
|
|
228
|
+
elif tag == "literal":
|
|
229
|
+
# Verbatim text such as {@literal} and {@value} keeps straight quotes.
|
|
230
|
+
append(nodes.inline("", value, support_smartquotes=False))
|
|
231
|
+
else:
|
|
232
|
+
append(nodes.Text(value))
|
|
233
|
+
elif tag == "link":
|
|
234
|
+
label = comment(token, location, quiet)
|
|
235
|
+
inline = [child for p in label for child in p.children]
|
|
236
|
+
append(reference(token, inline))
|
|
237
|
+
elif tag == "start":
|
|
238
|
+
name = token.get("name", "").lower()
|
|
239
|
+
attrs = {a.get("name"): a.text or "" for a in token}
|
|
240
|
+
if name not in supported:
|
|
241
|
+
warn(
|
|
242
|
+
f"Unsupported Javadoc HTML <{name}>; use paragraphs, lists, tables, emphasis, links or <pre>."
|
|
243
|
+
)
|
|
244
|
+
if name in BLOCK_LEVEL:
|
|
245
|
+
break_paragraph()
|
|
246
|
+
continue
|
|
247
|
+
if any(k not in ATTRIBUTES.get(name, {"href"}) for k in attrs):
|
|
248
|
+
warn(
|
|
249
|
+
f"Javadoc HTML attributes on <{name}> are not supported except href on links, lang on pre and table structure; move layout and assets to a guide."
|
|
250
|
+
)
|
|
251
|
+
if name == "br":
|
|
252
|
+
append(
|
|
253
|
+
nodes.Text("\n")
|
|
254
|
+
if isinstance(stack[-1][1], nodes.literal_block)
|
|
255
|
+
else nodes.line()
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
if any(isinstance(n, nodes.literal_block) for _, n in stack):
|
|
259
|
+
if name != "code":
|
|
260
|
+
warn(
|
|
261
|
+
f"Unsupported nested HTML <{name}> inside <pre>; use escaped code."
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
if name in TABLE_TAGS:
|
|
265
|
+
node = table_start(name, attrs)
|
|
266
|
+
if node is not None and token.get("closed") != "true":
|
|
267
|
+
stack.append((name, node))
|
|
268
|
+
continue
|
|
269
|
+
if name in block_tags:
|
|
270
|
+
if isinstance(stack[-1][1], (nodes.table, nodes.row)):
|
|
271
|
+
warn(
|
|
272
|
+
f"Javadoc <{name}> in a table must be inside <td> or <th>."
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
close("p")
|
|
276
|
+
if name == "li":
|
|
277
|
+
for open_tag, _ in reversed(stack):
|
|
278
|
+
if open_tag in {"ul", "ol"}:
|
|
279
|
+
break
|
|
280
|
+
if open_tag == "li":
|
|
281
|
+
close("li")
|
|
282
|
+
break
|
|
283
|
+
if name == "p":
|
|
284
|
+
node = nodes.paragraph()
|
|
285
|
+
sealed.add(id(node))
|
|
286
|
+
elif name == "ul":
|
|
287
|
+
node = nodes.bullet_list()
|
|
288
|
+
elif name == "ol":
|
|
289
|
+
node = nodes.enumerated_list(enumtype="arabic", start=1)
|
|
290
|
+
elif name == "li":
|
|
291
|
+
node = nodes.list_item()
|
|
292
|
+
elif name == "pre":
|
|
293
|
+
node = nodes.literal_block(
|
|
294
|
+
"", "", language=attrs.get("lang", "java")
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
node = nodes.block_quote()
|
|
298
|
+
if name == "li" and not isinstance(
|
|
299
|
+
stack[-1][1], (nodes.bullet_list, nodes.enumerated_list)
|
|
300
|
+
):
|
|
301
|
+
warn("Javadoc <li> needs a surrounding <ul> or <ol>.")
|
|
302
|
+
continue
|
|
303
|
+
stack[-1][1].append(node)
|
|
304
|
+
else:
|
|
305
|
+
if name in {"b", "strong"}:
|
|
306
|
+
node = nodes.strong()
|
|
307
|
+
elif name in {"i", "em"}:
|
|
308
|
+
node = nodes.emphasis()
|
|
309
|
+
elif name == "code":
|
|
310
|
+
node = nodes.literal()
|
|
311
|
+
else:
|
|
312
|
+
href = attrs.get("href", "")
|
|
313
|
+
if urlsplit(href).scheme not in {"https", "http", "mailto"}:
|
|
314
|
+
warn(
|
|
315
|
+
f"Unsupported Javadoc HTML link {href!r}; use a Java {{@link}} or an absolute https URL."
|
|
316
|
+
)
|
|
317
|
+
node = nodes.inline()
|
|
318
|
+
else:
|
|
319
|
+
node = nodes.reference("", "", refuri=href)
|
|
320
|
+
append(node)
|
|
321
|
+
if token.get("closed") != "true":
|
|
322
|
+
stack.append((name, node))
|
|
323
|
+
elif tag == "end":
|
|
324
|
+
name = token.get("name", "").lower()
|
|
325
|
+
if name in BLOCK_LEVEL:
|
|
326
|
+
break_paragraph()
|
|
327
|
+
close(name)
|
|
328
|
+
else:
|
|
329
|
+
warn(f"Unsupported XML comment token {tag}; regenerate the model.")
|
|
330
|
+
|
|
331
|
+
tokens(part)
|
|
332
|
+
for table in reversed(list(root.findall(nodes.table))):
|
|
333
|
+
table_finish(table)
|
|
334
|
+
|
|
335
|
+
def split_lines(element):
|
|
336
|
+
lines = [element.copy()]
|
|
337
|
+
for child in element.children:
|
|
338
|
+
if isinstance(child, nodes.line):
|
|
339
|
+
lines.append(element.copy())
|
|
340
|
+
elif isinstance(child, nodes.Text):
|
|
341
|
+
lines[-1] += child
|
|
342
|
+
else:
|
|
343
|
+
fragments = split_lines(child)
|
|
344
|
+
lines[-1] += fragments[0]
|
|
345
|
+
for fragment in fragments[1:]:
|
|
346
|
+
lines.append(element.copy())
|
|
347
|
+
lines[-1] += fragment
|
|
348
|
+
return lines
|
|
349
|
+
|
|
350
|
+
for p in list(root.findall(nodes.paragraph)):
|
|
351
|
+
if any(p.findall(nodes.line)):
|
|
352
|
+
p.replace_self(
|
|
353
|
+
nodes.line_block(
|
|
354
|
+
"", *[nodes.line("", "", *line.children) for line in split_lines(p)]
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
elif not p.astext().strip():
|
|
358
|
+
p.parent.remove(p)
|
|
359
|
+
return root.children
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Sphinx's deprecated directive labels its text with these classes.
|
|
363
|
+
CHANGE_CLASSES = ["versionmodified", "deprecated"]
|
|
364
|
+
# Javadoc's default first sentence ends at a period followed by whitespace.
|
|
365
|
+
SENTENCE_END = re.compile(r"\.(?=\s|$)")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def deprecated(element: ET.Element) -> bool:
|
|
369
|
+
# Only annotations directly on the declaration, not on parameters or in values.
|
|
370
|
+
return element.find("doc/part[@kind='deprecated']") is not None or (
|
|
371
|
+
element.find("sig/r[@target='java.lang.Deprecated']") is not None
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def summary(element: ET.Element, location) -> list[nodes.Node]:
|
|
376
|
+
"""Returns the first sentence of the description as inline nodes."""
|
|
377
|
+
body = element.find("doc/part[@kind='body']")
|
|
378
|
+
content = comment(body, location, quiet=True) if body is not None else []
|
|
379
|
+
if not content or not isinstance(content[0], nodes.paragraph):
|
|
380
|
+
return []
|
|
381
|
+
done = False
|
|
382
|
+
|
|
383
|
+
def truncate(node):
|
|
384
|
+
nonlocal done
|
|
385
|
+
if isinstance(node, nodes.Text):
|
|
386
|
+
text = node.astext()
|
|
387
|
+
if match := SENTENCE_END.search(text):
|
|
388
|
+
done = True
|
|
389
|
+
text = text[: match.end()]
|
|
390
|
+
return nodes.Text(text)
|
|
391
|
+
copy = node.copy()
|
|
392
|
+
for child in node.children:
|
|
393
|
+
copy += truncate(child)
|
|
394
|
+
if done:
|
|
395
|
+
break
|
|
396
|
+
return copy
|
|
397
|
+
|
|
398
|
+
sentence = truncate(content[0])
|
|
399
|
+
# The full description reports unresolved links; the summary repeats them quietly.
|
|
400
|
+
for reference in sentence.findall(addnodes.pending_xref):
|
|
401
|
+
reference["java:quiet"] = True
|
|
402
|
+
return sentence.children
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def quiet_reference(app, env, node, contnode):
|
|
406
|
+
"""Sphinx missing-reference handler: summary links fall back to their text."""
|
|
407
|
+
return contnode if node.get("java:quiet") else None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
FIELD_TITLES = {
|
|
411
|
+
"param": "Parameters",
|
|
412
|
+
"typeparam": "Type parameters",
|
|
413
|
+
"return": "Returns",
|
|
414
|
+
"throws": "Throws",
|
|
415
|
+
"see": "See also",
|
|
416
|
+
"since": "Since",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def field(kind: str, items) -> nodes.field:
|
|
421
|
+
"""One field per kind; labelled items read "name – description", as in Sphinx."""
|
|
422
|
+
bodies = []
|
|
423
|
+
for label, content in items:
|
|
424
|
+
content = list(content)
|
|
425
|
+
if label:
|
|
426
|
+
if content and isinstance(content[0], nodes.paragraph):
|
|
427
|
+
content[0].insert(0, [nodes.strong("", label), nodes.Text(" – ")])
|
|
428
|
+
else:
|
|
429
|
+
content.insert(0, nodes.paragraph("", "", nodes.strong("", label)))
|
|
430
|
+
bodies.append(content)
|
|
431
|
+
if len(bodies) == 1:
|
|
432
|
+
body = nodes.field_body("", *bodies[0])
|
|
433
|
+
else:
|
|
434
|
+
body = nodes.field_body(
|
|
435
|
+
"", nodes.bullet_list("", *(nodes.list_item("", *b) for b in bodies))
|
|
436
|
+
)
|
|
437
|
+
return nodes.field("", nodes.field_name("", FIELD_TITLES.get(kind, kind)), body)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def documentation(element: ET.Element, location) -> list[nodes.Node]:
|
|
441
|
+
result: list[nodes.Node] = []
|
|
442
|
+
fields = nodes.field_list()
|
|
443
|
+
notice = None
|
|
444
|
+
fields_by_kind: dict[str, list] = {}
|
|
445
|
+
if deprecated(element):
|
|
446
|
+
# The structure of Sphinx's deprecated directive, which themes style.
|
|
447
|
+
notice = addnodes.versionmodified(type="deprecated", version="")
|
|
448
|
+
result.append(notice)
|
|
449
|
+
for part in element.findall("doc/part"):
|
|
450
|
+
content = comment(part, location)
|
|
451
|
+
kind = part.get("kind")
|
|
452
|
+
if kind == "body":
|
|
453
|
+
result.extend(content)
|
|
454
|
+
elif kind == "deprecated":
|
|
455
|
+
notice.extend(content)
|
|
456
|
+
else:
|
|
457
|
+
fields_by_kind.setdefault(kind, []).append((part.get("label"), content))
|
|
458
|
+
for kind, items in fields_by_kind.items():
|
|
459
|
+
fields += field(kind, items)
|
|
460
|
+
if notice is not None:
|
|
461
|
+
if notice.children and isinstance(notice[0], nodes.paragraph):
|
|
462
|
+
label = "Deprecated: "
|
|
463
|
+
notice[0].insert(0, nodes.inline("", label, classes=CHANGE_CLASSES))
|
|
464
|
+
else:
|
|
465
|
+
label = nodes.inline("", "Deprecated.", classes=CHANGE_CLASSES)
|
|
466
|
+
notice.insert(0, nodes.paragraph("", "", label))
|
|
467
|
+
if fields.children:
|
|
468
|
+
result.append(fields)
|
|
469
|
+
return result
|