Skip to content

Commit

Permalink
Merge pull request sphinx-doc#7053 from jakobandersen/productionlist
Browse files Browse the repository at this point in the history
Production list scoping and line continuation
  • Loading branch information
jakobandersen authored Jan 25, 2020
2 parents e032043 + 9fa2361 commit 82a465c
Show file tree
Hide file tree
Showing 14 changed files with 187 additions and 15 deletions.
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Incompatible changes
* #6830: py domain: ``meta`` fields in info-field-list becomes reserved. They
are not displayed on output document now
* The structure of ``sphinx.events.EventManager.listeners`` has changed
* Due to the scoping changes for :rst:dir:`productionlist` some uses of
:rst:role:`token` must be modified to include the scope which was previously
ignored.

Deprecated
----------
Expand All @@ -39,6 +42,9 @@ Features added
* #6830: py domain: Add new event: :event:`object-description-transform`
* Support priority of event handlers. For more detail, see
:py:meth:`.Sphinx.connect()`
* #3077: Implement the scoping for :rst:dir:`productionlist` as indicated
in the documentation.
* #1027: Support backslash line continuation in :rst:dir:`productionlist`.

Bugs fixed
----------
Expand Down
16 changes: 12 additions & 4 deletions doc/usage/restructuredtext/directives.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1139,24 +1139,32 @@ derived forms), but provides enough to allow context-free grammars to be
displayed in a way that causes uses of a symbol to be rendered as hyperlinks to
the definition of the symbol. There is this directive:

.. rst:directive:: .. productionlist:: [name]
.. rst:directive:: .. productionlist:: [productionGroup]
This directive is used to enclose a group of productions. Each production
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
continuation line must begin with a colon placed at the same column as in
the first line.

The argument to :rst:dir:`productionlist` serves to distinguish different
sets of production lists that belong to different grammars.
The *productionGroup* argument to :rst:dir:`productionlist` serves to
distinguish different sets of production lists that belong to different
grammars. Multiple production lists with the same *productionGroup* thus
define rules in the same scope.

Blank lines are not allowed within ``productionlist`` directive arguments.

The definition can contain token names which are marked as interpreted text
(e.g. ``sum ::= `integer` "+" `integer```) -- this generates
(e.g. "``sum ::= `integer` "+" `integer```") -- this generates
cross-references to the productions of these tokens. Outside of the
production list, you can reference to token productions using
:rst:role:`token`.
However, 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 reST parsing is done in the production, so that you
don't have to escape ``*`` or ``|`` characters.
Expand Down
48 changes: 39 additions & 9 deletions sphinx/domains/std.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,17 @@ def run(self) -> List[Node]:
return messages + [node]


def token_xrefs(text: str) -> List[Node]:
def token_xrefs(text: str, productionGroup: str = '') -> List[Node]:
if len(productionGroup) != 0:
productionGroup += ':'
retnodes = [] # type: List[Node]
pos = 0
for m in token_re.finditer(text):
if m.start() > pos:
txt = text[pos:m.start()]
retnodes.append(nodes.Text(txt, txt))
refnode = pending_xref(m.group(1), reftype='token', refdomain='std',
reftarget=m.group(1))
reftarget=productionGroup + m.group(1))
refnode += nodes.literal(m.group(1), m.group(1), classes=['xref'])
retnodes.append(refnode)
pos = m.end()
Expand All @@ -437,11 +439,15 @@ class ProductionList(SphinxDirective):
def run(self) -> List[Node]:
domain = cast(StandardDomain, self.env.get_domain('std'))
node = addnodes.productionlist() # type: Element
i = 0
# 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')

for rule in self.arguments[0].split('\n'):
productionGroup = ""
i = 0
for rule in lines:
if i == 0 and ':' not in rule:
# production group
productionGroup = rule.strip()
continue
i += 1
try:
Expand All @@ -451,17 +457,41 @@ def run(self) -> List[Node]:
subnode = addnodes.production(rule)
subnode['tokenname'] = name.strip()
if subnode['tokenname']:
idname = nodes.make_id('grammar-token-%s' % subnode['tokenname'])
# nodes.make_id converts '_' to '-',
# so we can use '_' to delimit group from name,
# and make sure we don't clash with other IDs.
idname = 'grammar-token-%s_%s' \
% (nodes.make_id(productionGroup), nodes.make_id(name))
if idname not in self.state.document.ids:
subnode['ids'].append(idname)

idnameOld = nodes.make_id('grammar-token-' + name)
if idnameOld not in self.state.document.ids:
subnode['ids'].append(idnameOld)
self.state.document.note_implicit_target(subnode, subnode)
domain.note_object('token', subnode['tokenname'], idname,
if len(productionGroup) != 0:
objName = "%s:%s" % (productionGroup, name)
else:
objName = name
domain.note_object(objtype='token', name=objName, labelid=idname,
location=(self.env.docname, self.lineno))
subnode.extend(token_xrefs(tokens))
subnode.extend(token_xrefs(tokens, productionGroup))
node.append(subnode)
return [node]


class TokenXRefRole(XRefRole):
def process_link(self, env: "BuildEnvironment", refnode: Element, has_explicit_title: bool,
title: str, target: str) -> Tuple[str, str]:
target = target.lstrip('~') # a title-specific thing
if not self.has_explicit_title and title[0] == '~':
if ':' in title:
_, title = title.split(':')
else:
title = title[1:]
return title, target


class StandardDomain(Domain):
"""
Domain for all objects that don't fit into another domain or are added
Expand Down Expand Up @@ -493,7 +523,7 @@ class StandardDomain(Domain):
'option': OptionXRefRole(warn_dangling=True),
'envvar': EnvVarXRefRole(),
# links to tokens in grammar productions
'token': XRefRole(),
'token': TokenXRefRole(),
# links to terms in glossary
'term': XRefRole(lowercase=True, innernodeclass=nodes.inline,
warn_dangling=True),
Expand Down
6 changes: 6 additions & 0 deletions tests/roots/test-productionlist/Bare.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Bare
====

.. productionlist::
A: `A` | somethingA
B: `B` | somethingB
5 changes: 5 additions & 0 deletions tests/roots/test-productionlist/Dup1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Dup1
====

.. productionlist::
Dup: `Dup` | somethingDup
5 changes: 5 additions & 0 deletions tests/roots/test-productionlist/Dup2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Dup2
====

.. productionlist::
Dup: `Dup` | somethingDup
6 changes: 6 additions & 0 deletions tests/roots/test-productionlist/LineContinuation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
LineContinuation
================

.. productionlist:: lineContinuation
A: B C D \
E F G
6 changes: 6 additions & 0 deletions tests/roots/test-productionlist/P1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
P1
==

.. productionlist:: P1
A: `A` | somethingA
B: `B` | somethingB
6 changes: 6 additions & 0 deletions tests/roots/test-productionlist/P2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
P2
==

.. productionlist:: P2
A: `A` | somethingA
B: `B` | somethingB
1 change: 1 addition & 0 deletions tests/roots/test-productionlist/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exclude_patterns = ['_build']
5 changes: 5 additions & 0 deletions tests/roots/test-productionlist/firstLineRule.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FirstLineRule
=============

.. productionlist:: FirstLine: something
SecondLine: somethingElse
27 changes: 27 additions & 0 deletions tests/roots/test-productionlist/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.. toctree::

P1
P2
Bare
Dup1
Dup2
firstLineRule
LineContinuation

- A: :token:`A`
- B: :token:`B`
- P1:A: :token:`P1:A`
- P1:B: :token:`P1:B`
- P2:A: :token:`P1:A`
- P2:B: :token:`P2:B`
- Explicit title A, plain: :token:`MyTitle <A>`
- Explicit title A, colon: :token:`My:Title <A>`
- Explicit title P1:A, plain: :token:`MyTitle <P1:A>`
- Explicit title P1:A, colon: :token:`My:Title <P1:A>`
- Tilde A: :token:`~A`.
- Tilde P1:A: :token:`~P1:A`.
- Tilde explicit title P1:A: :token:`~MyTitle <P1:A>`
- Tilde, explicit title P1:A: :token:`MyTitle <~P1:A>`
- Dup: :token:`Dup`
- FirstLine: :token:`FirstLine`
- SecondLine: :token:`SecondLine`
4 changes: 2 additions & 2 deletions tests/test_build_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def test_html4_output(app, status, warning):
"[@class='reference internal']/code/span[@class='pre']", 'HOME'),
(".//a[@href='#with']"
"[@class='reference internal']/code/span[@class='pre']", '^with$'),
(".//a[@href='#grammar-token-try-stmt']"
(".//a[@href='#grammar-token-_try-stmt']"
"[@class='reference internal']/code/span", '^statement$'),
(".//a[@href='#some-label'][@class='reference internal']/span", '^here$'),
(".//a[@href='#some-label'][@class='reference internal']/span", '^there$'),
Expand Down Expand Up @@ -254,7 +254,7 @@ def test_html4_output(app, status, warning):
(".//dl/dt[@id='term-boson']", 'boson'),
# a production list
(".//pre/strong", 'try_stmt'),
(".//pre/a[@href='#grammar-token-try1-stmt']/code/span", 'try1_stmt'),
(".//pre/a[@href='#grammar-token-_try1-stmt']/code/span", 'try1_stmt'),
# tests for ``only`` directive
(".//p", 'A global substitution.'),
(".//p", 'In HTML.'),
Expand Down
61 changes: 61 additions & 0 deletions tests/test_domain_std.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@
:license: BSD, see LICENSE for details.
"""

import pytest

from unittest import mock

from docutils import nodes
from docutils.nodes import definition, definition_list, definition_list_item, term

from html5lib import HTMLParser

from sphinx import addnodes
from sphinx.addnodes import (
desc, desc_addname, desc_content, desc_name, desc_signature, glossary, index
)
from sphinx.domains.std import StandardDomain
from sphinx.testing import restructuredtext
from sphinx.testing.util import assert_node
from sphinx.util import docutils


def test_process_doc_handle_figure_caption():
Expand Down Expand Up @@ -312,3 +317,59 @@ def test_multiple_cmdoptions(app):
assert ('cmd', '--output') in domain.progoptions
assert domain.progoptions[('cmd', '-o')] == ('index', 'cmdoption-cmd-o')
assert domain.progoptions[('cmd', '--output')] == ('index', 'cmdoption-cmd-o')


@pytest.mark.skipif(docutils.__version_info__ < (0, 13),
reason='docutils-0.13 or above is required')
@pytest.mark.sphinx(testroot='productionlist')
def test_productionlist(app, status, warning):
app.builder.build_all()

warnings = warning.getvalue().split("\n");
assert len(warnings) == 2
assert warnings[-1] == ''
assert "Dup2.rst:4: WARNING: duplicate token description of Dup, other instance in Dup1" in warnings[0]

with (app.outdir / 'index.html').open('rb') as f:
etree = HTMLParser(namespaceHTMLElements=False).parse(f)
ul = list(etree.iter('ul'))[1]
cases = []
for li in list(ul):
assert len(list(li)) == 1
p = list(li)[0]
assert p.tag == 'p'
text = str(p.text).strip(' :')
assert len(list(p)) == 1
a = list(p)[0]
assert a.tag == 'a'
link = a.get('href')
assert len(list(a)) == 1
code = list(a)[0]
assert code.tag == 'code'
assert len(list(code)) == 1
span = list(code)[0]
assert span.tag == 'span'
linkText = span.text.strip()
cases.append((text, link, linkText))
assert cases == [
('A', 'Bare.html#grammar-token-_a', 'A'),
('B', 'Bare.html#grammar-token-_b', 'B'),
('P1:A', 'P1.html#grammar-token-p1_a', 'P1:A'),
('P1:B', 'P1.html#grammar-token-p1_b', 'P1:B'),
('P2:A', 'P1.html#grammar-token-p1_a', 'P1:A'),
('P2:B', 'P2.html#grammar-token-p2_b', 'P2:B'),
('Explicit title A, plain', 'Bare.html#grammar-token-_a', 'MyTitle'),
('Explicit title A, colon', 'Bare.html#grammar-token-_a', 'My:Title'),
('Explicit title P1:A, plain', 'P1.html#grammar-token-p1_a', 'MyTitle'),
('Explicit title P1:A, colon', 'P1.html#grammar-token-p1_a', 'My:Title'),
('Tilde A', 'Bare.html#grammar-token-_a', 'A'),
('Tilde P1:A', 'P1.html#grammar-token-p1_a', 'A'),
('Tilde explicit title P1:A', 'P1.html#grammar-token-p1_a', '~MyTitle'),
('Tilde, explicit title P1:A', 'P1.html#grammar-token-p1_a', 'MyTitle'),
('Dup', 'Dup2.html#grammar-token-_dup', 'Dup'),
('FirstLine', 'firstLineRule.html#grammar-token-_firstline', 'FirstLine'),
('SecondLine', 'firstLineRule.html#grammar-token-_secondline', 'SecondLine'),
]

text = (app.outdir / 'LineContinuation.html').text()
assert "A</strong> ::= B C D E F G" in text

0 comments on commit 82a465c

Please sign in to comment.