Remove `productionlist` hard-coding in writers (#13326)

The ``productionlist`` directive operates in a line-based context, creating an ``addnodes.productionlist`` container
of ``addnodes.production`` nodes, with one per production in the directive. However, the full state of the abstract
document tree is not included in the produced nodes, with each builder/translator implementing a different way
of appending the fixed separator ``::=`` and justifying the displayed text.

This should not happen in the writer, and hard-coding such details hampers flexibility when documenting different
abstract grammars. We move the specific form of the ``.. productionlist::`` directive to the logic in the directive body
and have the writers apply minimal custom logic.

LaTeX changes written by Jean-François B.
This commit is contained in:
Adam Turner 2025-02-13 00:16:26 +00:00 committed by GitHub
parent bb68e72333
commit c49d925b4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 229 additions and 162 deletions

View File

@ -108,6 +108,9 @@ Features added
Patch by Jakob Lykke Andersen and Adam Turner. Patch by Jakob Lykke Andersen and Adam Turner.
* #11280: Add ability to skip a particular section using the ``no-search`` class. * #11280: Add ability to skip a particular section using the ``no-search`` class.
Patch by Will Lachance. Patch by Will Lachance.
* #13326: Remove hardcoding from handling :class:`~sphinx.addnodes.productionlist`
nodes in all writers, to improve flexibility.
Patch by Adam Turner.
Bugs fixed Bugs fixed
---------- ----------

View File

@ -1642,49 +1642,51 @@ Grammar production displays
--------------------------- ---------------------------
Special markup is available for displaying the productions of a formal grammar. Special markup is available for displaying the productions of a formal grammar.
The markup is simple and does not attempt to model all aspects of BNF (or any The markup is simple and does not attempt to model all aspects of BNF_
derived forms), but provides enough to allow context-free grammars to be (or any derived forms), but provides enough to allow context-free grammars
displayed in a way that causes uses of a symbol to be rendered as hyperlinks to to be displayed in a way that causes uses of a symbol to be rendered
the definition of the symbol. There is this directive: as hyperlinks to the definition of the symbol.
There is this directive:
.. rst:directive:: .. productionlist:: [productionGroup] .. _BNF: https://en.wikipedia.org/wiki/Backus%E2%80%93Naur_form
This directive is used to enclose a group of productions. Each production .. rst:directive:: .. productionlist:: [production_group]
is given on a single line and consists of a name, separated by a colon from
the following definition. If the definition spans multiple lines, each This directive is used to enclose a group of productions.
continuation line must begin with a colon placed at the same column as in Each production is given on a single line and consists of a name,
the first line. separated by a colon from the following definition.
If the definition spans multiple lines, each continuation line
must begin with a colon placed at the same column as in the first line.
Blank lines are not allowed within ``productionlist`` directive arguments. Blank lines are not allowed within ``productionlist`` directive arguments.
The definition can contain token names which are marked as interpreted text The optional *production_group* directive argument serves to distinguish
(e.g., "``sum ::= `integer` "+" `integer```") -- this generates different sets of production lists that belong to different grammars.
cross-references to the productions of these tokens. Outside of the Multiple production lists with the same *production_group*
production list, you can reference to token productions using thus define rules in the same scope.
:rst:role:`token`. This can also be used to split the description of a long or complex grammar
accross multiple ``productionlist`` directives with the same *production_group*.
The *productionGroup* argument to :rst:dir:`productionlist` serves to The definition can contain token names which are marked as interpreted text,
distinguish different sets of production lists that belong to different (e.g. "``sum ::= `integer` "+" `integer```"),
grammars. Multiple production lists with the same *productionGroup* thus to generate cross-references to the productions of these tokens.
define rules in the same scope. Such cross-references implicitly refer to productions from the current group.
To reference a production from another grammar, the token name
must be prefixed with the group name and a colon, e.g. "``other-group:sum``".
If the group of the token should not be shown in the production,
it can be prefixed by a tilde, e.g., "``~other-group:sum``".
To refer to a production from an unnamed grammar,
the token should be prefixed by a colon, e.g., "``:sum``".
No further reStructuredText parsing is done in the production,
so that special characters (``*``, ``|``, etc) do not need to be escaped.
Inside of the production list, tokens implicitly refer to productions Token productions can be cross-referenced outwith the production list
from the current group. You can refer to the production of another by using the :rst:role:`token` role.
grammar by prefixing the token with its group name and a colon, e.g, If you have used a *production_group* argument,
"``otherGroup:sum``". If the group of the token should not be shown in the token name must be prefixed with the group name and a colon,
the production, it can be prefixed by a tilde, e.g., e.g., "``my_group:sum``" instead of just "``sum``".
"``~otherGroup:sum``". To refer to a production from an unnamed Standard :ref:`cross-referencing modifiers <xref-modifiers>`
grammar, the token should be prefixed by a colon, e.g., "``:sum``". may be used with the ``:token:`` role,
such as custom link text and suppressing the group name with a tilde (``~``).
Outside of the production list,
if you have given a *productionGroup* argument you must prefix the
token name in the cross-reference with the group name and a colon,
e.g., "``myGroup:sum``" instead of just "``sum``".
If the group should not be shown in the title of the link either
an explicit title can be given (e.g., "``myTitle <myGroup:sum>``"),
or the target can be prefixed with a tilde (e.g., "``~myGroup:sum``").
Note that no further reStructuredText parsing is done in the production,
so that you don't have to escape ``*`` or ``|`` characters.
The following is an example taken from the Python Reference Manual:: The following is an example taken from the Python Reference Manual::

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import operator
import re import re
from copy import copy from copy import copy
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
@ -22,7 +23,7 @@ from sphinx.util.nodes import clean_astext, make_id, make_refnode
from sphinx.util.parsing import nested_parse_to_nodes from sphinx.util.parsing import nested_parse_to_nodes
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Set from collections.abc import Callable, Iterable, Iterator, MutableSequence, Set
from typing import Any, ClassVar, Final from typing import Any, ClassVar, Final
from docutils.nodes import Element, Node, system_message from docutils.nodes import Element, Node, system_message
@ -553,7 +554,7 @@ class Glossary(SphinxDirective):
return [*messages, node] return [*messages, node]
def token_xrefs(text: str, production_group: str = '') -> list[Node]: def token_xrefs(text: str, production_group: str = '') -> Iterable[Node]:
if len(production_group) != 0: if len(production_group) != 0:
production_group += ':' production_group += ':'
retnodes: list[Node] = [] retnodes: list[Node] = []
@ -596,43 +597,107 @@ class ProductionList(SphinxDirective):
final_argument_whitespace = True final_argument_whitespace = True
option_spec: ClassVar[OptionSpec] = {} option_spec: ClassVar[OptionSpec] = {}
# The backslash handling is from ObjectDescription.get_signatures
_nl_escape_re: Final = re.compile(r'\\\n')
# Get 'name' from triples of rawsource, name, definition (tokens)
_name_getter = operator.itemgetter(1)
def run(self) -> list[Node]: def run(self) -> list[Node]:
domain = self.env.domains.standard_domain name_getter = self._name_getter
node: Element = addnodes.productionlist() lines = self._nl_escape_re.sub('', self.arguments[0]).splitlines()
# Extract production_group argument.
# Must be before extracting production definition triples.
production_group = self.production_group(lines=lines, options=self.options)
production_lines = list(self.production_definitions(lines))
max_name_len = max(map(len, map(name_getter, production_lines)))
node_location = self.get_location()
productions = [
self.make_production(
rawsource=rule,
name=name,
tokens=tokens,
production_group=production_group,
max_len=max_name_len,
location=node_location,
)
for rule, name, tokens in production_lines
]
node = addnodes.productionlist('', *productions)
self.set_source_info(node) self.set_source_info(node)
# The backslash handling is from ObjectDescription.get_signatures
nl_escape_re = re.compile(r'\\\n')
lines = nl_escape_re.sub('', self.arguments[0]).split('\n')
production_group = ''
first_rule_seen = False
for rule in lines:
if not first_rule_seen and ':' not in rule:
production_group = rule.strip()
continue
first_rule_seen = True
try:
name, tokens = rule.split(':', 1)
except ValueError:
break
subnode = addnodes.production(rule)
name = name.strip()
subnode['tokenname'] = name
if subnode['tokenname']:
prefix = 'grammar-token-%s' % production_group
node_id = make_id(self.env, self.state.document, prefix, name)
subnode['ids'].append(node_id)
self.state.document.note_implicit_target(subnode, subnode)
if len(production_group) != 0:
obj_name = f'{production_group}:{name}'
else:
obj_name = name
domain.note_object('token', obj_name, node_id, location=node)
subnode.extend(token_xrefs(tokens, production_group=production_group))
node.append(subnode)
return [node] return [node]
@staticmethod
def production_group(
*,
lines: MutableSequence[str],
options: dict[str, Any], # NoQA: ARG004
) -> str:
# get production_group
if not lines or ':' in lines[0]:
return ''
production_group = lines[0].strip()
lines[:] = lines[1:]
return production_group
@staticmethod
def production_definitions(
lines: Iterable[str], /
) -> Iterator[tuple[str, str, str]]:
"""Yield triples of rawsource, name, definition (tokens)."""
for line in lines:
if ':' not in line:
break
name, _, tokens = line.partition(':')
yield line, name.strip(), tokens.strip()
def make_production(
self,
*,
rawsource: str,
name: str,
tokens: str,
production_group: str,
max_len: int,
location: str,
) -> addnodes.production:
production_node = addnodes.production(rawsource, tokenname=name)
if name:
production_node += self.make_name_target(
name=name, production_group=production_group, location=location
)
production_node.append(self.separator_node(name=name, max_len=max_len))
production_node += token_xrefs(text=tokens, production_group=production_group)
production_node.append(nodes.Text('\n'))
return production_node
def make_name_target(
self,
*,
name: str,
production_group: str,
location: str,
) -> addnodes.literal_strong:
"""Make a link target for the given production."""
name_node = addnodes.literal_strong(name, name)
prefix = f'grammar-token-{production_group}'
node_id = make_id(self.env, self.state.document, prefix, name)
name_node['ids'].append(node_id)
self.state.document.note_implicit_target(name_node, name_node)
obj_name = f'{production_group}:{name}' if production_group else name
std = self.env.domains.standard_domain
std.note_object('token', obj_name, node_id, location=location)
return name_node
@staticmethod
def separator_node(*, name: str, max_len: int) -> nodes.Text:
"""Return seperator between 'name' and 'tokens'."""
if name:
return nodes.Text(' ::= '.rjust(max_len - len(name) + 5))
return nodes.Text(' ' * (max_len + 5))
class TokenXRefRole(XRefRole): class TokenXRefRole(XRefRole):
def process_link( def process_link(

View File

@ -1,7 +1,7 @@
%% MODULE RELEASE DATA AND OBJECT DESCRIPTIONS %% MODULE RELEASE DATA AND OBJECT DESCRIPTIONS
% %
% change this info string if making any custom modification % change this info string if making any custom modification
\ProvidesPackage{sphinxlatexobjects}[2023/07/23 documentation environments] \ProvidesPackage{sphinxlatexobjects}[2025/02/11 documentation environments]
% Provides support for this output mark-up from Sphinx latex writer: % Provides support for this output mark-up from Sphinx latex writer:
% %
@ -279,18 +279,37 @@
\newcommand{\pysigstopmultiline}{\sphinxsigismultilinefalse\itemsep\sphinxsignaturesep}% \newcommand{\pysigstopmultiline}{\sphinxsigismultilinefalse\itemsep\sphinxsignaturesep}%
% Production lists % Production lists
% This simply outputs the lines as is, in monospace font. Refers #13326.
% (the left padding for multi-line alignment is from the nodes themselves,
% and latex is configured below to obey such horizontal whitespace).
%
% - The legacy code used longtable and hardcoded the separator as ::=
% via dedicated macros defined by the environment itself.
% - Here the separator is part of the node. Any extra LaTeX mark-up would
% have to originate from the writer itself to decorate it.
% - The legacy code used strangely \parindent and \indent. Possibly
% (unchecked) due to an earlier tabular usage, but a longtable does not
% work in paragraph mode, so \parindent was without effect and
% \indent only caused some extra blank line above display.
% - The table had some whitespace on its left, which we imitate here via
% \parindent usage (which works in our context...).
% %
\newenvironment{productionlist}{% \newenvironment{productionlist}{%
% \def\sphinxoptional##1{{\Large[}##1{\Large]}} \bigskip % imitate close enough legacy vertical whitespace, which was
\def\production##1##2{\\\sphinxcode{\sphinxupquote{##1}}&::=&\sphinxcode{\sphinxupquote{##2}}}% % visibly excessive
\def\productioncont##1{\\& &\sphinxcode{\sphinxupquote{##1}}}% \ttfamily % needed for space tokens to have same width as letters
\parindent=2em \parindent1em % width of a "quad", font-dependent, usually circa width of 2
\indent % letters
\setlength{\LTpre}{0pt}% \obeylines % line in = line out
\setlength{\LTpost}{0pt}% \parskip\z@skip % prevent the parskip vertical whitespace between lines,
\begin{longtable}[l]{lcl} % which are technically to LaTeX now each its own paragraph
\@vobeyspaces % obey whitespace
% now a technicality to, only locally to this environment, prevent the
% suppression of indentation of first line, if it comes right after
% \section. Cf package indentfirst from which the code is borrowed.
\let\@afterindentfalse\@afterindenttrue\@afterindenttrue
}{% }{%
\end{longtable} \par % does not hurt...
} }
% Definition lists; requested by AMK for HOWTO documents. Probably useful % Definition lists; requested by AMK for HOWTO documents. Probably useful

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import posixpath import posixpath
import re import re
import urllib.parse import urllib.parse
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING
from docutils import nodes from docutils import nodes
from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator
@ -17,8 +17,6 @@ from sphinx.util.docutils import SphinxTranslator
from sphinx.util.images import get_image_size from sphinx.util.images import get_image_size
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from docutils.nodes import Element, Node, Text from docutils.nodes import Element, Node, Text
from sphinx.builders import Builder from sphinx.builders import Builder
@ -695,23 +693,9 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): # type: ignore[misc]
def visit_productionlist(self, node: Element) -> None: def visit_productionlist(self, node: Element) -> None:
self.body.append(self.starttag(node, 'pre')) self.body.append(self.starttag(node, 'pre'))
productionlist = cast('Iterable[addnodes.production]', node)
maxlen = max(len(production['tokenname']) for production in productionlist)
lastname = None
for production in productionlist:
if production['tokenname']:
lastname = production['tokenname'].ljust(maxlen)
self.body.append(self.starttag(production, 'strong', ''))
self.body.append(lastname + '</strong> ::= ')
elif lastname is not None:
self.body.append(' ' * (maxlen + 5))
production.walkabout(self)
self.body.append('\n')
self.body.append('</pre>\n')
raise nodes.SkipNode
def depart_productionlist(self, node: Element) -> None: def depart_productionlist(self, node: Element) -> None:
pass self.body.append('</pre>\n')
def visit_production(self, node: Element) -> None: def visit_production(self, node: Element) -> None:
pass pass

View File

@ -323,7 +323,7 @@ class LaTeXTranslator(SphinxTranslator):
# flags # flags
self.in_title = 0 self.in_title = 0
self.in_production_list = 0 self.in_production_list = False
self.in_footnote = 0 self.in_footnote = 0
self.in_caption = 0 self.in_caption = 0
self.in_term = 0 self.in_term = 0
@ -671,22 +671,20 @@ class LaTeXTranslator(SphinxTranslator):
def visit_productionlist(self, node: Element) -> None: def visit_productionlist(self, node: Element) -> None:
self.body.append(BLANKLINE) self.body.append(BLANKLINE)
self.body.append(r'\begin{productionlist}' + CR) self.body.append(r'\begin{productionlist}' + CR)
self.in_production_list = 1 self.in_production_list = True
def depart_productionlist(self, node: Element) -> None: def depart_productionlist(self, node: Element) -> None:
self.in_production_list = False
self.body.append(r'\end{productionlist}' + BLANKLINE) self.body.append(r'\end{productionlist}' + BLANKLINE)
self.in_production_list = 0
def visit_production(self, node: Element) -> None: def visit_production(self, node: Element) -> None:
if node['tokenname']: # Nothing to do, the productionlist LaTeX environment
tn = node['tokenname'] # is configured to render the nodes line-by-line
self.body.append(self.hypertarget('grammar-token-' + tn)) # But see also visit_literal_strong special clause.
self.body.append(r'\production{%s}{' % self.encode(tn)) pass
else:
self.body.append(r'\productioncont{')
def depart_production(self, node: Element) -> None: def depart_production(self, node: Element) -> None:
self.body.append('}' + CR) pass
def visit_transition(self, node: Element) -> None: def visit_transition(self, node: Element) -> None:
self.body.append(self.elements['transition']) self.body.append(self.elements['transition'])
@ -2070,9 +2068,16 @@ class LaTeXTranslator(SphinxTranslator):
self.body.append('}') self.body.append('}')
def visit_literal_strong(self, node: Element) -> None: def visit_literal_strong(self, node: Element) -> None:
if self.in_production_list:
ctx = [r'\phantomsection']
ctx += [self.hypertarget(id_, anchor=False) for id_ in node['ids']]
self.body.append(''.join(ctx))
return
self.body.append(r'\sphinxstyleliteralstrong{\sphinxupquote{') self.body.append(r'\sphinxstyleliteralstrong{\sphinxupquote{')
def depart_literal_strong(self, node: Element) -> None: def depart_literal_strong(self, node: Element) -> None:
if self.in_production_list:
return
self.body.append('}}') self.body.append('}}')
def visit_abbreviation(self, node: Element) -> None: def visit_abbreviation(self, node: Element) -> None:

View File

@ -79,8 +79,6 @@ class ManualPageTranslator(SphinxTranslator, BaseTranslator): # type: ignore[mi
def __init__(self, document: nodes.document, builder: Builder) -> None: def __init__(self, document: nodes.document, builder: Builder) -> None:
super().__init__(document, builder) super().__init__(document, builder)
self.in_productionlist = 0
# first title is the manpage title # first title is the manpage title
self.section_level = -1 self.section_level = -1
@ -274,25 +272,10 @@ class ManualPageTranslator(SphinxTranslator, BaseTranslator): # type: ignore[mi
def visit_productionlist(self, node: Element) -> None: def visit_productionlist(self, node: Element) -> None:
self.ensure_eol() self.ensure_eol()
self.in_productionlist += 1
self.body.append('.sp\n.nf\n') self.body.append('.sp\n.nf\n')
productionlist = cast('Iterable[addnodes.production]', node)
maxlen = max(len(production['tokenname']) for production in productionlist) def depart_productionlist(self, node: Element) -> None:
lastname = None
for production in productionlist:
if production['tokenname']:
lastname = production['tokenname'].ljust(maxlen)
self.body.append(self.defs['strong'][0])
self.body.append(self.deunicode(lastname))
self.body.append(self.defs['strong'][1])
self.body.append(' ::= ')
elif lastname is not None:
self.body.append(' ' * (maxlen + 5))
production.walkabout(self)
self.body.append('\n')
self.body.append('\n.fi\n') self.body.append('\n.fi\n')
self.in_productionlist -= 1
raise nodes.SkipNode
def visit_production(self, node: Element) -> None: def visit_production(self, node: Element) -> None:
pass pass

View File

@ -189,6 +189,7 @@ class TexinfoTranslator(SphinxTranslator):
self.escape_hyphens = 0 self.escape_hyphens = 0
self.curfilestack: list[str] = [] self.curfilestack: list[str] = []
self.footnotestack: list[dict[str, list[collected_footnote | bool]]] = [] self.footnotestack: list[dict[str, list[collected_footnote | bool]]] = []
self.in_production_list = False
self.in_footnote = 0 self.in_footnote = 0
self.in_samp = 0 self.in_samp = 0
self.handled_abbrs: set[str] = set() self.handled_abbrs: set[str] = set()
@ -1308,20 +1309,11 @@ class TexinfoTranslator(SphinxTranslator):
def visit_productionlist(self, node: Element) -> None: def visit_productionlist(self, node: Element) -> None:
self.visit_literal_block(None) self.visit_literal_block(None)
productionlist = cast('Iterable[addnodes.production]', node) self.in_production_list = True
maxlen = max(len(production['tokenname']) for production in productionlist)
for production in productionlist: def depart_productionlist(self, node: Element) -> None:
if production['tokenname']: self.in_production_list = False
for id in production.get('ids'):
self.add_anchor(id, production)
s = production['tokenname'].ljust(maxlen) + ' ::='
else:
s = ' ' * (maxlen + 4)
self.body.append(self.escape(s))
self.body.append(self.escape(production.astext() + '\n'))
self.depart_literal_block(None) self.depart_literal_block(None)
raise nodes.SkipNode
def visit_production(self, node: Element) -> None: def visit_production(self, node: Element) -> None:
pass pass
@ -1336,9 +1328,15 @@ class TexinfoTranslator(SphinxTranslator):
self.body.append('}') self.body.append('}')
def visit_literal_strong(self, node: Element) -> None: def visit_literal_strong(self, node: Element) -> None:
if self.in_production_list:
for id_ in node['ids']:
self.add_anchor(id_, node)
return
self.body.append('@code{') self.body.append('@code{')
def depart_literal_strong(self, node: Element) -> None: def depart_literal_strong(self, node: Element) -> None:
if self.in_production_list:
return
self.body.append('}') self.body.append('}')
def visit_index(self, node: Element) -> None: def visit_index(self, node: Element) -> None:

View File

@ -408,6 +408,7 @@ class TextTranslator(SphinxTranslator):
self.sectionlevel = 0 self.sectionlevel = 0
self.lineblocklevel = 0 self.lineblocklevel = 0
self.table: Table self.table: Table
self.in_production_list = False
self.context: list[str] = [] self.context: list[str] = []
"""Heterogeneous stack. """Heterogeneous stack.
@ -787,18 +788,17 @@ class TextTranslator(SphinxTranslator):
def visit_productionlist(self, node: Element) -> None: def visit_productionlist(self, node: Element) -> None:
self.new_state() self.new_state()
productionlist = cast('Iterable[addnodes.production]', node) self.in_production_list = True
maxlen = max(len(production['tokenname']) for production in productionlist)
lastname = None def depart_productionlist(self, node: Element) -> None:
for production in productionlist: self.in_production_list = False
if production['tokenname']:
self.add_text(production['tokenname'].ljust(maxlen) + ' ::=')
lastname = production['tokenname']
elif lastname is not None:
self.add_text(' ' * (maxlen + 4))
self.add_text(production.astext() + self.nl)
self.end_state(wrap=False) self.end_state(wrap=False)
raise nodes.SkipNode
def visit_production(self, node: Element) -> None:
pass
def depart_production(self, node: Element) -> None:
pass
def visit_footnote(self, node: Element) -> None: def visit_footnote(self, node: Element) -> None:
label = cast('nodes.label', node[0]) label = cast('nodes.label', node[0])
@ -1224,9 +1224,13 @@ class TextTranslator(SphinxTranslator):
self.add_text('**') self.add_text('**')
def visit_literal_strong(self, node: Element) -> None: def visit_literal_strong(self, node: Element) -> None:
if self.in_production_list:
return
self.add_text('**') self.add_text('**')
def depart_literal_strong(self, node: Element) -> None: def depart_literal_strong(self, node: Element) -> None:
if self.in_production_list:
return
self.add_text('**') self.add_text('**')
def visit_abbreviation(self, node: Element) -> None: def visit_abbreviation(self, node: Element) -> None:
@ -1249,9 +1253,13 @@ class TextTranslator(SphinxTranslator):
self.add_text('*') self.add_text('*')
def visit_literal(self, node: Element) -> None: def visit_literal(self, node: Element) -> None:
if self.in_production_list:
return
self.add_text('"') self.add_text('"')
def depart_literal(self, node: Element) -> None: def depart_literal(self, node: Element) -> None:
if self.in_production_list:
return
self.add_text('"') self.add_text('"')
def visit_subscript(self, node: Element) -> None: def visit_subscript(self, node: Element) -> None:

View File

@ -78,7 +78,7 @@ def test_productionlist(app: SphinxTestApp) -> None:
] ]
text = (app.outdir / 'LineContinuation.html').read_text(encoding='utf8') text = (app.outdir / 'LineContinuation.html').read_text(encoding='utf8')
assert 'A</strong> ::= B C D E F G' in text assert 'A</strong> ::= B C D E F G' in text
@pytest.mark.sphinx('html', testroot='root') @pytest.mark.sphinx('html', testroot='root')
@ -140,14 +140,14 @@ def test_productionlist_continuation_lines(
_, _, content = content.partition('<pre>') _, _, content = content.partition('<pre>')
content, _, _ = content.partition('</pre>') content, _, _ = content.partition('</pre>')
expected = """ expected = """
<strong id="grammar-token-python-grammar-assignment_stmt">assignment_stmt</strong> ::= (<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a> &quot;=&quot;)+ (<code class="xref docutils literal notranslate"><span class="pre">starred_expression</span></code> | <code class="xref docutils literal notranslate"><span class="pre">yield_expression</span></code>) <strong id="grammar-token-python-grammar-assignment_stmt">assignment_stmt</strong> ::= (<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a> &quot;=&quot;)+ (<code class="xref docutils literal notranslate"><span class="pre">starred_expression</span></code> | <code class="xref docutils literal notranslate"><span class="pre">yield_expression</span></code>)
<strong id="grammar-token-python-grammar-target_list">target_list </strong> ::= <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a> (&quot;,&quot; <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a>)* [&quot;,&quot;] <strong id="grammar-token-python-grammar-target_list">target_list</strong> ::= <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a> (&quot;,&quot; <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a>)* [&quot;,&quot;]
<strong id="grammar-token-python-grammar-target">target </strong> ::= <code class="xref docutils literal notranslate"><span class="pre">identifier</span></code> <strong id="grammar-token-python-grammar-target">target</strong> ::= <code class="xref docutils literal notranslate"><span class="pre">identifier</span></code>
| &quot;(&quot; [<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a>] &quot;)&quot; | &quot;(&quot; [<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a>] &quot;)&quot;
| &quot;[&quot; [<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a>] &quot;]&quot; | &quot;[&quot; [<a class="reference internal" href="#grammar-token-python-grammar-target_list"><code class="xref docutils literal notranslate"><span class="pre">target_list</span></code></a>] &quot;]&quot;
| <code class="xref docutils literal notranslate"><span class="pre">attributeref</span></code> | <code class="xref docutils literal notranslate"><span class="pre">attributeref</span></code>
| <code class="xref docutils literal notranslate"><span class="pre">subscription</span></code> | <code class="xref docutils literal notranslate"><span class="pre">subscription</span></code>
| <code class="xref docutils literal notranslate"><span class="pre">slicing</span></code> | <code class="xref docutils literal notranslate"><span class="pre">slicing</span></code>
| &quot;*&quot; <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a> | &quot;*&quot; <a class="reference internal" href="#grammar-token-python-grammar-target"><code class="xref docutils literal notranslate"><span class="pre">target</span></code></a>
""" """
assert content == expected assert content == expected