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.
@@ -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