diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 9ac5fa313..1c9f13813 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -4,6 +4,51 @@ Changelog ========= +Unreleased +---------- + +Improvements +............ + +- ✨ New :ref:`choose `, ``when`` and ``otherwise`` directives include one of + several branches of content, chosen by variant data (:pr:`2020`) + + A ``choose`` runs its ``when`` tests in order, and the first ``when`` whose condition + is true is included; an ``otherwise``, the optional default, comes last; when no test + holds and there is no ``otherwise``, nothing is rendered. The other branches are never + parsed, so the needs inside them are never created: + + .. code-block:: rst + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. when:: var.arch == "x86" + + x86 content. + + .. otherwise:: + + Content for every other architecture. + + Conditions are exactly those of the :ref:`if ` directive, evaluated by the same + code, and the conditions after the branch that is taken are not evaluated. A + ``choose`` may contain only ``when`` and ``otherwise`` directives and comments, and + nothing outside a branch is ever parsed. Every mistake warns once under the new + ``needs.choose`` type and skips the whole ``choose``: content outside a branch + (refused before the body is parsed, so nothing in it runs), a branch inside another + directive or supplied through an include, a branch written with one colon, no branch + at all, a ``when`` without a condition, an ``otherwise`` with one, a misplaced or + second ``otherwise``, an argument on ``choose``, variant data that is not configured, + and a condition that cannot be evaluated — so a mistake that makes a condition + unevaluable, such as a misspelt key or a syntax error, never renders a later branch + or the ``otherwise`` in its place. Works in reStructuredText and in MyST Markdown. + The undocumented warning ``if`` gives for a condition whose result is not a bool is + now listed in its documentation. + .. _`release:8.5.0`: 8.5.0 diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst new file mode 100644 index 000000000..9fe833875 --- /dev/null +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -0,0 +1,199 @@ +.. _choose: + +choose +====== + +.. versionadded:: 8.6.0 + +The ``choose`` directive includes one of several branches of content, +chosen by :ref:`variant data ` at parse time. +Its branches are ``when`` and ``otherwise`` directives: +``choose`` runs its ``when`` tests in order, and the first true one is included; +``otherwise`` is the optional default, and comes last; +when no test holds and there is no ``otherwise``, nothing is rendered. +The content of every other branch is never parsed, +so the needs inside it are never created. +The names are those of ``choose`` / ``when`` / ``otherwise`` in XSLT, JSTL and MSBuild, which run their tests the same way. + +.. code-block:: rst + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. req:: ARM-specific requirement + :id: REQ_ARM_001 + + .. when:: var.arch == "x86" + + x86 content. + + .. a comment may stand between two branches + + .. otherwise:: + + Content for every other architecture. + +A ``choose`` is the many-branched form of :ref:`if `: +the example includes the ARM content, the x86 content or the content of the ``otherwise``, +and never more than one of them. +Unlike a ``switch`` or a ``match`` statement, a ``choose`` has no subject: +every ``when`` holds a whole condition. + +MyST Markdown +------------- + +In MyST Markdown, ``choose``, ``when`` and ``otherwise`` are fenced directives like any other. +With colon fences (the ``colon_fence`` extension): + +.. code-block:: md + + ::::{choose} + :::{when} var.arch == "arm" + ARM content. + ::: + % a comment may stand between two branches + :::{otherwise} + Content for every other architecture. + ::: + :::: + +and with backtick fences: + +.. code-block:: md + + ````{choose} + ```{when} var.arch == "arm" + ARM content. + ``` + ```{otherwise} + Content for every other architecture. + ``` + ```` + +An outer fence must be longer than the fences inside it, +so a ``choose`` takes one more colon (or backtick) than its branches, +and a ``choose`` nested in a branch takes one fewer than that branch: + +.. code-block:: md + + ::::::{choose} + :::::{when} var.debug + ::::{choose} + :::{when} var.arch == "arm" + ARM debug content. + ::: + :::: + ::::: + :::::: + +``%`` starts a MyST comment, and a ``+++`` block break counts as one too. + +Rules +----- + +- **The first true branch wins.** + The conditions are evaluated in order, and the first ``when`` whose condition is true is included. + The conditions after it are not evaluated at all, so they cannot warn. + When no condition is true, the ``otherwise`` is included; + without an ``otherwise``, the ``choose`` then includes nothing, without a warning, as a false ``if`` does. +- **Every test has a condition, and the default has none.** + A ``when`` without a condition is a mistake rather than a default, + so a condition forgotten on the last ``when`` cannot make it the branch for every other variant. + An ``otherwise`` takes no condition. + A ``choose`` has at most one ``otherwise``, and it must be its last branch. +- **Only branches and comments.** + A ``choose`` may contain only ``when`` and ``otherwise`` directives and comments: + reStructuredText comments (``..``), and in MyST ``%`` comments and ``+++`` block breaks. + In MyST, an HTML comment (````) is raw HTML rather than a comment, so it is a mistake here. + Any other content outside a branch is a mistake, + refused before anything in the body is parsed, so nothing in it ever runs. + A branch belongs directly in a ``choose``: + one anywhere else is a mistake too, + whether it is written loose in the content of another branch + or inside another directive in the ``choose``, + even one that passes its content through, such as a true ``if`` or a ``rst-class``. +- **The branches are written in place.** + Every branch of a ``choose`` is written in the body of that ``choose``, in the same file, + so that one choice is one directive in one place. + An ``.. include::`` (in MyST, an ``{include}``) may not supply the branches; + it may be used inside the content of a branch, + and a whole ``choose`` may stand in an included file. +- **The included branch is ordinary content.** + It may hold headings, which become sections where the ``choose`` stands, + needs, any other directive, and further ``choose`` directives. + An ``.. include::`` may supply part of the content of a branch, + and a ``choose`` may stand in the content of a need. +- **Parse-time evaluation**, as for ``if``: + the content of a branch that is not included is never parsed, + so its needs are never created and its mistakes are never reported. + +Conditions +---------- + +A ``when`` condition is exactly a condition of the :ref:`if ` directive, +evaluated by the same code: +a Python expression over the ``var`` namespace, with no built-in functions +(see the ``if`` directive's :ref:`if_expression_context`). +A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. + +A condition wrapped onto a second line is joined with a line break, +which is a syntax error unless the break falls inside brackets or is escaped with a backslash; +keep conditions on one line. + +Warnings +-------- + +Every mistake warns once, under the ``needs.choose`` type +(suppressible via ``suppress_warnings = ["needs.choose"]``), +at the line of the directive or the content that has it, +and skips the **whole** ``choose``: nothing of it is included, not even its ``otherwise``. +The mistakes are: + +- ``needs_variant_data`` is not configured, even when the ``choose`` holds only an ``otherwise``. +- A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a branch is taken. + So a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, + never renders a later branch or the ``otherwise`` in its place. + (A mistake that leaves a valid condition, such as a misspelt value, cannot be told apart + from a condition that is false.) +- The ``choose`` contains something that is neither a ``when``, an ``otherwise`` nor a comment. + A line of only punctuation, such as ``---`` between two branches, is such content too. + It is refused at its line before anything in the body is parsed, + so a directive there (a need, an ``.. include::``, a false ``if``) never runs. +- A comment that begins with ``when:`` or ``otherwise:``: a branch written with one colon, + or without the space after ``::``, is a comment in reStructuredText; a ``when`` written so would hand the choice + to the ``otherwise``, and an ``otherwise`` written so would make the default vanish. +- A branch is written inside another directive in the ``choose`` rather than directly in it, + or supplied through an include: the directive or the include is such content, + and the warning points at its line (the included file is never read). +- A ``when`` has no condition: write the default as an ``otherwise``. +- An ``otherwise`` is given a condition. The warning names it: + content written on the line right after ``.. otherwise::``, with no blank line between, is read as one. +- The ``choose`` has more than one ``otherwise``, or an ``otherwise`` that is not its last branch. +- The ``choose`` has no ``when`` or ``otherwise`` at all. +- The ``choose`` is given an argument: the conditions go on the ``when`` directives. + +A ``when`` or an ``otherwise`` outside a ``choose`` warns as well, and its content is skipped. +A condition whose result is not a ``bool`` warns, and its truth value is used. +A line that docutils or MyST would report, such as an unknown directive name, +is refused by the ``choose`` before either parses it, +so it is reported once, by the ``choose``, whatever the project's ``report_level``. + +.. note:: + + The body of a ``choose`` is read line by line before it is parsed, + so content outside a branch is refused even where it would leave no trace in the document: + a directive that produces no node, such as ``default-role`` or a **false** ``if``, + and a MyST substitution reference (``{{ sub }}``), which is a line of text to the ``choose``, + whatever branches its definition holds. + +.. note:: + + The content of the branch that is taken is parsed on its own, as the body of a true ``if`` is, + so a directive in it that checks its parent does not find the parent of the ``choose``. + A sphinx-design ``tab-item`` in the taken branch warns + ``The parent of a 'tab-item' should be a 'tab-set'``, exactly as in a true ``if``, + even when the ``choose`` stands in a ``tab-set``. + To vary the content of a tab, put the ``choose`` inside the ``tab-item``. diff --git a/packages/sphinx-needs/docs/directives/if.rst b/packages/sphinx-needs/docs/directives/if.rst index 72f0bb6c3..c61de2acf 100644 --- a/packages/sphinx-needs/docs/directives/if.rst +++ b/packages/sphinx-needs/docs/directives/if.rst @@ -12,6 +12,7 @@ The directive argument is a Python expression evaluated against the ``var`` namespace (populated from :ref:`needs_variant_data`). If the expression evaluates to ``True``, the directive body is parsed and included in the document. Otherwise the entire body is skipped. +To include one of several branches instead, use :ref:`choose `. .. code-block:: rst @@ -77,6 +78,8 @@ The body may contain section headers and any valid reStructuredText: Content under a conditional heading. +.. _if_expression_context: + Expression context ------------------ @@ -104,6 +107,8 @@ Behavior Use :ref:`filter` for need-aware filtering. - **Incremental builds**: If a document is re-read (e.g., because the source changed), all ``if`` directives in it are re-evaluated. +- **Parsed on its own**: the body does not see where the ``if`` stands, so a sphinx-design + ``tab-item`` in a true ``if`` warns that its parent should be a ``tab-set``, even inside one. Warnings -------- @@ -112,3 +117,4 @@ The directive emits warnings (suppressible via ``suppress_warnings = ["needs.if" - ``needs_variant_data`` is not configured but the directive is used. - The expression raises an exception (syntax error, unknown key, etc.). +- The expression does not return a bool (the result is still used, as its truth value). diff --git a/packages/sphinx-needs/docs/directives/index.rst b/packages/sphinx-needs/docs/directives/index.rst index aa9a692b7..31fc2faa0 100644 --- a/packages/sphinx-needs/docs/directives/index.rst +++ b/packages/sphinx-needs/docs/directives/index.rst @@ -19,6 +19,7 @@ Directives for conditional content: :maxdepth: 1 if + choose Directives for visualizing and analyzing needs: diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py new file mode 100644 index 000000000..ee6c5cc04 --- /dev/null +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -0,0 +1,779 @@ +"""Directives for including one of several branches of content based on variant data. + +A ``choose`` holds ``when`` and ``otherwise`` directives (its branches) and comments, +written in its own body, and nothing else. +The first ``when`` whose condition holds is included; +an ``otherwise``, which takes no condition, is the default, +and must be the last branch. + +Before anything in its body is parsed, a ``choose`` reads the body's top-level lines +(:func:`_gate`) and refuses the body at the first one that is neither the start of a +branch, a comment, nor blank. So nothing written outside a branch ever runs: +a need, a label, an ``.. include::`` or another extension's directive there +is refused with a warning, never executed and undone. + +The gate's safety rule: it may refuse a line the parser would have accepted +(a refused line is never parsed, and the author gets a warning), +but it must never pass a line the parser would execute as something other than +a branch or a comment, and it must never believe it is inside a branch where the +parser is outside one (the lines it skips there would escape it). +So what it accepts mirrors the parser's own spelling rules exactly, +and where a MyST branch ends follows the CommonMark closing rule exactly. +Under MyST it reads the document's own parser configuration (front matter included; +the global configuration when the renderer does not expose it), +so that it opens a branch only with a fence kind the document's parser has +(colon, backtick or tilde), accepts ``%`` comments and ``+++`` block breaks indented +up to three spaces as myst-parser does (at any indentation when the document's parser +has its ``code`` rule disabled, as markdown-it then allows every construct), +and refuses an opener that markdown-it would take for the header of a table. +Under any other parser the ``choose`` is refused, with a warning: the gate +could not read the body, and nothing in it may run unread. + +Then the body is parsed into a detached :class:`_ChooseBody` that is never returned. +A branch does not parse its content: in the body it returns a transient +:class:`_BranchPlaceholder` carrying its kind, its condition and its raw content. +Having seen every branch at once, the ``choose`` checks the structure, +evaluates the conditions in order with the evaluator of the ``if`` directive +(:func:`~sphinx_needs.directives.needif.evaluate_variant_condition`), +and parses only the content of the branch it takes, returning those nodes. +So the content of every other branch is never parsed: +the needs in it are never created and its mistakes are never reported, +exactly as for the body of a false ``if``. + +Neither node class reaches a doctree or the need-node cache, +so neither is registered with Sphinx: +one that ever escaped would make a writer fail loudly rather than render silently. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Sequence +from typing import ClassVar, Literal, NamedTuple + +from docutils import nodes +from docutils.parsers.rst.states import RSTState +from docutils.statemachine import StringList +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.directives.needif import evaluate_variant_condition +from sphinx_needs.logging import get_logger, log_warning + +LOGGER = get_logger(__name__) + +_DEPTH_KEY = "sphinx_needs_choose_depth" +"""The ``env.temp_data`` key counting the ``choose`` bodies being parsed. + +A branch is a child of a ``choose`` body exactly when the count is above 0. +A ``choose`` raises it only around the parse of its own body, +and parses the content of the branch it takes at 0, +so a branch written loose in a branch's content is reported as well. +""" + +_BranchKind = Literal["when", "otherwise"] +"""The directive a branch is written with.""" + +_BRANCH_KINDS: frozenset[str] = frozenset(("when", "otherwise")) + +_ONE_COLON = re.compile(r"(when|otherwise)\s*:", re.IGNORECASE) +"""The start of a comment that is a branch directive written with one colon. + +``.. when: `` (one colon) and ``.. when::`` (no space after +``::``) are comments in reStructuredText, and swallow the indented content under them; +both begin with ``when`` and a colon. Matched against the comment's text after its +leading whitespace, so a comment that merely begins with the word is not matched. +""" + +# reStructuredText (docutils `parsers/rst/states.py`, `Body.patterns` and +# `Body.explicit.constructs`, and `directives.directive`, which lower-cases the name) +_RST_EXPLICIT = re.compile(r"\.\.( +|$)") +"""docutils' explicit markup start: ``..`` and then spaces or the end of the line.""" + +_RST_SIMPLENAME = r"(?:(?!_)\w)+(?:[-._+:](?:(?!_)\w)+)*" +_RST_DIRECTIVE = re.compile(rf"\.\.[ ]+({_RST_SIMPLENAME})[ ]?::([ ]+|$)") +"""docutils' directive line: a name, an optional space, ``::``, a space or the end.""" + +# MyST (CommonMark fences, the `colon_fence` extension, and myst-parser's +# `render_fence` / `render_colon_fence`, which take the first word of the stripped +# info string as the directive when it is `{name}`) + + +class _MystPatterns(NamedTuple): + """The line patterns of the MyST gate, for one indentation rule.""" + + opener: re.Pattern[str] + """The first line of a branch: a fence whose info string starts with the name.""" + closer: re.Pattern[str] + """A line that may close a fence: a run of one fence character (its length and + character are compared with the opener's).""" + comment: re.Pattern[str] + """A line comment (myst's ``line_comment``), with its text.""" + block_break: re.Pattern[str] + """The marker run of a block break (myst's ``block_break``): three ``+`` or more, + mixed with spaces and tabs.""" + delimiter: re.Pattern[str] + """A line markdown-it may take for the delimiter row of a table.""" + + +def _myst_patterns(indent: str, /) -> _MystPatterns: + """The MyST gate's patterns, with ``indent`` as their leading whitespace.""" + return _MystPatterns( + opener=re.compile( + indent + r"(:{3,}|`{3,}|~{3,})[ \t]*\{(when|otherwise)\}(?=\s|$)", + re.IGNORECASE, + ), + closer=re.compile(indent + r"(:+|`+|~+)[ \t]*"), + comment=re.compile(indent + r"%(.*)"), + block_break=re.compile(indent + r"\+[+ \t]*"), + delimiter=re.compile(indent + r"[|:-][|:\-\s]*"), + ) + + +# markdown-it-py's `StateBlock.is_code_block(line)` (3.0 and 4) is +# `_code_enabled and sCount - blkIndent >= 4`, and every rule the gate mirrors asks it +# (the fences and their closers, the colon fence through `mdit_py_plugins.utils`, the +# table's two lines, `line_comment`, `block_break`): with the `code` rule enabled a +# line indented four columns or more (a tab counts four) is a code block, and none of +# those constructs; with `code` disabled, indentation bounds none of them. +_MYST_INDENTED = _myst_patterns(r" {0,3}") +"""The MyST gate's patterns while the ``code`` rule is enabled (the default).""" +_MYST_UNBOUNDED = _myst_patterns(r"[ \t]*") +"""The MyST gate's patterns while the ``code`` rule is disabled.""" + +_TABLE_CELL = re.compile(r":?-+:?") + + +class _Stray(NamedTuple): + """The first top-level line of a ``choose`` body that the gate refuses.""" + + index: int + """The line's index in the body.""" + one_colon: _BranchKind | None + """The kind of branch the line is a comment for, written with one colon, if so.""" + + +def _gate( + lines: Sequence[str], + /, + *, + syntax: Literal["rst", "myst"], + colon_fence: bool = True, + fence: bool = True, + comment: bool = True, + block_break: bool = True, + code: bool = True, +) -> _Stray | None: + """The first top-level line of a ``choose`` body that the parser must not see. + + That is the first line that is neither a branch start, a comment, nor blank. + + :param lines: The body, as the directive receives it. + :param syntax: The markup the body is written in. + :param colon_fence: Whether the document's MyST parser has colon fences + (without them, a ``:::`` line opens nothing). + :param fence: Whether it has backtick and tilde fences. + :param comment: Whether it has ``%`` line comments. + :param block_break: Whether it has ``+++`` block breaks. + :param code: Whether it has indented code blocks, which bound the indentation + of every other construct. + :return: That line, or ``None`` if the body holds only branches and comments. + """ + if syntax == "rst": + return _gate_rst(lines) + return _gate_myst( + lines, + colon_fence=colon_fence, + fence=fence, + comment=comment, + block_break=block_break, + code=code, + ) + + +def _gate_rst(lines: Sequence[str], /) -> _Stray | None: + """The gate for a reStructuredText body, dedented and with tabs expanded. + + Only lines at column 0 start a construct; an indented line belongs to the + explicit markup above it (a branch's content, a comment's text), except after + an empty comment followed by a blank line, which docutils ends there, so that + the indented block after it would be a block quote of the body. + A column-0 explicit markup line is classified as docutils classifies it: + a footnote or citation (``[``), a target (``_``) or a substitution definition + (``|``) is refused, which is stricter than docutils when the rest of the line + does not complete the construct; a directive is a branch start if it is + ``when`` or ``otherwise``, and refused otherwise; anything else is a comment. + """ + owned = False + for index, line in enumerate(lines): + if not line.strip(" "): + continue + if line.startswith(" "): + if owned: + continue + return _Stray(index, None) + start = _RST_EXPLICIT.match(line) + if start is None: + return _Stray(index, None) + rest = line[start.end() :] + if rest[:1] in ("[", "_", "|"): + return _Stray(index, None) + directive = _RST_DIRECTIVE.match(line) + if directive is not None: + if directive.group(1).lower() not in _BRANCH_KINDS: + return _Stray(index, None) + owned = True + continue + if rest.startswith('end of inclusion from "'): + # docutils pops its include log for this comment rather than keeping it + return _Stray(index, None) + text = rest + if not text.strip(" "): + following = lines[index + 1] if index + 1 < len(lines) else "" + if not following.strip(" "): + # an empty comment: docutils ends it here + owned = False + continue + # the comment's text is the indented block on the next line, if any + text = following if following.startswith(" ") else "" + one_colon = _ONE_COLON.match(text.lstrip(" ")) + if one_colon is not None: + return _Stray(index, _branch_kind(one_colon.group(1))) + owned = True + return None + + +def _gate_myst( + lines: Sequence[str], + /, + *, + colon_fence: bool, + fence: bool, + comment: bool, + block_break: bool, + code: bool, +) -> _Stray | None: + """The gate for a MyST body, under the document's own parser configuration. + + A branch starts at a fence whose info string's first word is ``{when}`` or + ``{otherwise}``, indented at most three spaces: a colon fence when the document's + parser has ``colon_fence``, a backtick or tilde fence when it has ``fence`` + (a backtick fence may not have a backtick in its info string). It ends at the + first later line of at least as many of the same fence character, indented at + most three spaces, with nothing but spaces or tabs after them (the CommonMark + closing rule); an unclosed branch runs to the end. + markdown-it tries its ``table`` rule before any fence: an opener with a ``|`` + whose next line could be a table's delimiter row would be a table header, so it + is refused (stricter than markdown-it, which also requires as many cells in both + lines). Between branches, a ``%`` comment and a ``+++`` block break (three ``+`` + or more, mixed with spaces and tabs), each indented at most three spaces, are + accepted as myst-parser's ``line_comment`` and ``block_break`` accept them. + "At most three spaces" holds while the parser has its ``code`` rule (a line + indented further is a code block, and none of these constructs); without it, + markdown-it lets every one of them be indented by any spaces and tabs, + and so does the gate. + """ + patterns = _MYST_INDENTED if code else _MYST_UNBOUNDED + marker: str | None = None + for index, line in enumerate(lines): + if marker is not None: + closer = patterns.closer.fullmatch(line) + if closer is not None: + run = closer.group(1) + if run[0] == marker[0] and len(run) >= len(marker): + marker = None + continue + if not line.strip(" \t"): + continue + opener = patterns.opener.match(line) + if opener is not None: + run = opener.group(1) + # a backtick fence may not have a backtick in its info string (CommonMark) + backtick_info = run[0] == "`" and "`" in line[opener.end(1) :] + enabled = colon_fence if run[0] == ":" else fence + following = lines[index + 1] if index + 1 < len(lines) else "" + table = _table_header(line, following, patterns.delimiter) + if not backtick_info and enabled and not table: + marker = run + continue + return _Stray(index, None) + text: str | None = None + if comment and (line_comment := patterns.comment.match(line)) is not None: + text = line_comment.group(1) + elif ( + block_break + and (markers := patterns.block_break.match(line)) is not None + and markers.group(0).count("+") >= 3 + ): + text = line[markers.end() :] + if text is None: + return _Stray(index, None) + one_colon = _ONE_COLON.match(text.strip()) + if one_colon is not None: + return _Stray(index, _branch_kind(one_colon.group(1))) + return None + + +def _table_header(line: str, following: str, delimiter: re.Pattern[str], /) -> bool: + """Whether markdown-it's ``table`` rule may take ``line`` for a table's header. + + That needs a ``|`` in the line and, on the next, a delimiter row (``delimiter``: + indented as the ``code`` rule allows), made of ``|``, ``-``, ``:`` and whitespace + only, with a ``-``, and every cell between the ``|`` that is not empty of the form + ``:?-+:?``. + """ + if "|" not in line or not delimiter.fullmatch(following): + return False + if "-" not in following: + return False + cells = [cell.strip() for cell in following.split("|")] + return all(_TABLE_CELL.fullmatch(cell) for cell in cells if cell) + + +def _branch_kind(name: str, /) -> _BranchKind: + """The branch kind a directive name (in any case) stands for.""" + return "when" if name.lower() == "when" else "otherwise" + + +def _absolute_source(source: str | None, /) -> str | None: + """``source`` made absolute, as Sphinx makes the source of a node's location. + + docutils records an included file relative to the working directory + (``utils.relative_path``) whenever the two share their first two path components: + a build run from the project's own directory, the common case, gives ``docs/inc.txt``, + and a test run from a checkout under ``/tmp`` gives ``../…``. + """ + return os.path.abspath(source) if source else source + + +def _absolute_location(location: str | nodes.Node | None, /) -> str | nodes.Node | None: + """A ``":"`` location with its source made absolute. + + Every location this module reports goes through here. + A node is returned as it is: Sphinx makes the source of a node absolute itself. + """ + if not isinstance(location, str): + return location + source, colon, line = location.rpartition(":") + if not colon or not source or source == "": + return location + return f"{_absolute_source(source)}:{line}" + + +class _BranchPlaceholder(nodes.Element): + """What a branch leaves in the body of its ``choose``; it never reaches a doctree. + + The payload is held in plain Python attributes rather than docutils attributes: + the ``choose`` reads it once and discards it with the body. + """ + + kind: _BranchKind + """The directive the branch is written with.""" + condition: str | None + """The condition, or ``None`` if the directive has none (or only whitespace). + + The ``choose`` refuses a ``when`` without one and an ``otherwise`` with one, + so after its checks ``None`` marks the ``otherwise``. + """ + content: StringList + """The raw content of the branch, parsed only if the branch is taken.""" + content_offset: int + """The ``content_offset`` of the branch directive.""" + lineno: int + """The ``lineno`` of the branch directive.""" + location: str | None + """Where warnings about the branch are reported.""" + + +class _ChooseBody(nodes.Element): + """The detached node a ``choose`` parses its own body into; it is never returned.""" + + +class _BranchDirective(SphinxDirective): + """What ``when`` and ``otherwise`` share: a deferred branch of a ``choose``. + + The content is not parsed here: the ``choose`` parses it if it takes the branch. + Both directives declare one optional argument, and the ``choose`` checks it, + so that a missing condition on a ``when``, or one on an ``otherwise``, + is warned about once, in the words of this extension, at the branch: + a required argument would make docutils (or MyST) reject a ``when`` without one + with an error of its own, and with no argument declared at all, + both would move a condition written on an ``otherwise`` into its content, + where it would be taken, silently, as the default's first paragraph. + """ + + branch_kind: ClassVar[_BranchKind] + """The directive name, which the placeholder and the warnings carry.""" + + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + has_content = True + + def run(self) -> Sequence[nodes.Node]: + kind = self.branch_kind + if self.env.temp_data.get(_DEPTH_KEY, 0) <= 0: + article = "an" if kind == "otherwise" else "a" + log_warning( + LOGGER, + f"'{kind}' directive outside a 'choose' ({article} '{kind}' must be a " + "direct child of a 'choose'); its content is skipped", + "choose", + location=_absolute_location(self.get_location()), + ) + return [] + + placeholder = _BranchPlaceholder() + placeholder.kind = kind + # an argument of only whitespace is no condition: docutils and MyST already drop + # a whitespace-only argument; kept as the contract's guard + has_condition = bool(self.arguments and self.arguments[0].strip()) + placeholder.condition = self.arguments[0] if has_condition else None + placeholder.content = self.content + placeholder.content_offset = self.content_offset + placeholder.lineno = self.lineno + placeholder.location = self.get_location() + return [placeholder] + + +class WhenDirective(_BranchDirective): + """A branch of a ``choose``, included if it is the first whose condition holds. + + The directive argument is a condition, exactly as for the ``if`` directive, + and a ``when`` must have one: its ``choose`` refuses a ``when`` without one, + since ``otherwise`` is the default. + + Example:: + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. otherwise:: + + Content for every other architecture. + """ + + branch_kind = "when" + + +class OtherwiseDirective(_BranchDirective): + """The default branch of a ``choose``, included when no ``when`` before it holds. + + It takes no condition (its ``choose`` refuses one), must be the last branch, + and a ``choose`` has at most one. + """ + + branch_kind = "otherwise" + + +class ChooseDirective(SphinxDirective): + """Include the first ``when`` whose condition holds, or else the ``otherwise``. + + The content may hold only ``when`` and ``otherwise`` directives and comments. + Every mistake is warned about once, and skips the whole ``choose``: + content that is neither a branch nor a comment (refused before anything in the + body is parsed), a branch written with one colon, no branch at all, + a ``when`` without a condition, an ``otherwise`` with one, + an ``otherwise`` that is not the last branch or is not the only one, + variant data that is not configured, + and a condition that cannot be evaluated before a branch is taken. + So a mistake that makes a condition unevaluable, such as a misspelt key + or a syntax error, never renders a later branch or the ``otherwise`` in its place. + + Example:: + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. when:: var.arch == "x86" + + x86 content. + + .. otherwise:: + + Content for every other architecture. + """ + + required_arguments = 0 + # declared only to be refused: with no argument declared, docutils and MyST both move + # the text into the content, which would then be reported only as a stray paragraph + optional_arguments = 1 + final_argument_whitespace = True + has_content = True + + def run(self) -> Sequence[nodes.Node]: + # docutils and MyST already drop a whitespace-only argument; kept as the + # contract's guard + if self.arguments and self.arguments[0].strip(): + self._warn( + f"'choose' directive takes no argument, got {self.arguments[0]!r} " + "(write a condition on each 'when'); the whole choose is skipped" + ) + return [] + + if not self._passes_gate(): + return [] + + branches = self._collect_branches() + if branches is None: + return [] + + if NeedsSphinxConfig(self.env.config).variant_data_proxy is None: + self._warn( + "'choose' directive used but needs_variant_data is not configured; " + "the whole choose is skipped" + ) + return [] + + for branch in branches: + if branch.condition is None: + # the otherwise: the checks leave no other branch without a condition + return self._parse_branch(branch) + taken = evaluate_variant_condition( + self.env, + branch.condition, + directive="when", + subtype="choose", + location=_absolute_location(branch.location), + ) + if taken is None: + # poisoned: no later branch is evaluated or taken, nor the otherwise + return [] + if taken: + # the first branch that holds wins; the later ones are not evaluated + return self._parse_branch(branch) + return [] + + def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> None: + log_warning( + LOGGER, + message, + "choose", + location=_absolute_location( + self.get_location() if location is None else location + ), + ) + + def _syntax(self) -> Literal["rst", "myst"] | None: + """The markup the body is written in (``None``: a parser of another kind).""" + if isinstance(self.state, RSTState): + return "rst" + if type(self.state).__module__.split(".", 1)[0] == "myst_parser": + return "myst" + return None + + def _passes_gate(self) -> bool: + """Read the body's top-level lines before anything in it is parsed. + + The body is refused, with one warning at the line, at the first one that is + neither the start of a branch, a comment, nor blank. + Under a parser other than docutils' and MyST's the body cannot be read, + so the ``choose`` is refused, with one warning, and nothing in it is parsed. + + :return: Whether the body may be parsed. + """ + syntax = self._syntax() + if syntax is None: + self._warn( + "'choose' directive is supported under reStructuredText and MyST " + "only; the whole choose is skipped" + ) + return False + stray = _gate(list(self.content), syntax=syntax, **self._myst_syntax()) + if stray is None: + return True + source, offset = self.content.info(stray.index) + if syntax == "myst": + # MyST numbers the lines of a directive's content from 0 + offset = self.lineno + stray.index + location = ( + f"{source}:{offset + 1}" + if source and offset is not None + else self.get_location() + ) + if stray.one_colon is not None: + kind = stray.one_colon + # the hint follows the syntax the comment is written in + if syntax == "rst": + write = ( + "'.. when:: '" if kind == "when" else "'.. otherwise::'" + ) + else: + write = ( + "a '{when} ' fence" + if kind == "when" + else "an '{otherwise}' fence" + ) + self._warn( + f"'choose' directive has a comment that begins with '{kind}:' " + f"(a branch written with one colon? write {write}); the whole " + "choose is skipped", + location, + ) + return False + # the indentation is kept: an opener indented four spaces is no branch + text = self.content[stray.index].rstrip() + shown = text if len(text) <= 40 else text[:40] + "…" + self._warn( + "'choose' directive may contain only 'when' and 'otherwise' directives " + f"and comments, got {shown!r}; the whole choose is skipped", + location, + ) + return False + + def _myst_syntax(self) -> dict[str, bool]: + """Which constructs the document's MyST parser has, for the gate. + + The document's own configuration (the global one merged with its front matter, + whose ``enable_extensions`` replaces the global list) is held only by the + renderer the directive's state belongs to; without it, the global + ``myst_enable_extensions`` and ``myst_disable_syntax`` are read. + ``disable_syntax`` can switch off a fence kind, line comments, block breaks, + and the ``code`` rule, without which indentation bounds no construct. + """ + config = getattr(getattr(self.state, "_renderer", None), "md_config", None) + if config is not None: + extensions = set(config.enable_extensions) + disabled = set(config.disable_syntax) + else: + extensions = set(getattr(self.env.config, "myst_enable_extensions", ())) + disabled = set(getattr(self.env.config, "myst_disable_syntax", ())) + return { + "colon_fence": "colon_fence" in extensions + and "colon_fence" not in disabled, + "fence": "fence" not in disabled, + "comment": "myst_line_comment" not in disabled, + "block_break": "myst_block_break" not in disabled, + "code": "code" not in disabled, + } + + def _parse_body(self) -> _ChooseBody: + """Parse the content into a detached node, with every branch deferred. + + After the gate, the body holds only branches and comments, + so nothing else runs while it is parsed. + + :return: The parsed body. + """ + body = _ChooseBody() + body.document = self.state.document + temp_data = self.env.temp_data + depth = temp_data.get(_DEPTH_KEY, 0) + temp_data[_DEPTH_KEY] = depth + 1 + try: + self.state.nested_parse(self.content, self.content_offset, body) + finally: + temp_data[_DEPTH_KEY] = depth + return body + + def _collect_branches(self) -> list[_BranchPlaceholder] | None: + """Parse the body and check its structure. + + Every ``when`` must have a condition and the ``otherwise`` none, + and there may be one ``otherwise`` at most, as the last branch. + + :return: The branches, in order, + or ``None`` if the body is not a valid ``choose`` + (a warning has been emitted). + """ + branches: list[_BranchPlaceholder] = [] + for child in self._parse_body().children: + if isinstance(child, _BranchPlaceholder): + branches.append(child) + elif not isinstance(child, nodes.comment): + # past the gate only a message the parser made about a branch + # directive, or a node of an ungated parser, can be here + tagname = child.tagname if isinstance(child, nodes.Element) else "#text" + self._warn( + "'choose' directive may contain only 'when' and 'otherwise' " + f"directives and comments, got <{tagname}>; the whole choose is " + "skipped" + ) + return None + + if not branches: + self._warn("'choose' directive has no 'when' or 'otherwise'") + return None + + for branch in branches: + if branch.kind == "when" and branch.condition is None: + # a forgotten condition must not make a catch-all of this branch + self._warn( + "'when' directive has no condition (use 'otherwise' for the " + "default); the whole choose is skipped", + branch.location, + ) + return None + if branch.kind == "otherwise" and branch.condition is not None: + # the text is named: it may be content that the parser took for the + # argument (written on the line after the directive, with no blank line) + self._warn( + "'otherwise' directive takes no condition, got " + f"{branch.condition!r}; the whole choose is skipped", + branch.location, + ) + return None + + otherwises = [branch for branch in branches if branch.kind == "otherwise"] + if len(otherwises) > 1: + self._warn( + "'choose' directive has more than one 'otherwise'; the whole choose " + "is skipped", + otherwises[1].location, + ) + return None + if otherwises and otherwises[0] is not branches[-1]: + self._warn( + "'choose' directive has an 'otherwise' that is not its last branch; " + "the whole choose is skipped", + otherwises[0].location, + ) + return None + + return branches + + def _parse_branch(self, branch: _BranchPlaceholder) -> list[nodes.Node]: + """Parse the content of the branch that is taken, with section titles allowed. + + It is parsed outside every ``choose`` body (at depth 0), + whatever encloses this ``choose``, + so that a branch written loose in it is reported rather than collected. + + :param branch: The branch that is taken. + :return: The parsed nodes. + """ + node = nodes.container() + node.document = self.state.document + temp_data = self.env.temp_data + depth = temp_data.get(_DEPTH_KEY, 0) + temp_data[_DEPTH_KEY] = 0 + try: + nested_parse_with_titles( + self.state, branch.content, node, self._content_offset_of(branch) + ) + finally: + temp_data[_DEPTH_KEY] = depth + return node.children + + def _content_offset_of(self, branch: _BranchPlaceholder) -> int: + """The offset at which this directive's state parses the content of ``branch``. + + Under docutils a directive's ``content_offset`` is absolute in the input, + so the branch's own offset is valid for any state. + Under MyST it is relative to the directive's own line + (the mock state adds the line it was created at), + so it is re-based from the branch's line onto this directive's line. + + :param branch: The branch that is taken. + """ + if isinstance(self.state, RSTState): + return branch.content_offset + return branch.lineno - self.lineno + branch.content_offset diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py index 522dc50f5..157bf9904 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py @@ -5,15 +5,83 @@ from collections.abc import Sequence from docutils import nodes +from sphinx.environment import BuildEnvironment from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.logging import WarningSubTypes, get_logger, log_warning LOGGER = get_logger(__name__) +def evaluate_variant_condition( + env: BuildEnvironment, + expression: str, + /, + *, + directive: str, + subtype: WarningSubTypes, + location: str | tuple[str | None, int | None] | nodes.Node | None, +) -> bool | None: + """Evaluate a variant condition, as the ``if`` directive and a ``when`` do. + + The expression is Python, evaluated with ``var`` (the proxy over + :confval:`needs_variant_data`) as its only name and no builtins. + This is the one evaluator of both directives, + so that a condition means the same thing whichever of them it is written on. + + Every problem is warned about here, once, naming ``directive``: + variant data that is not configured, and an expression that raises, + make the condition unevaluable; + a result that is not a ``bool`` is warned about and then used as its truth value. + + :param env: The build environment, whose config holds the variant data. + :param expression: The condition, as written. + :param directive: The directive name the warnings give, e.g. ``"if"``. + :param subtype: The warning subtype, ``needs.``. + :param location: Where the warnings are reported. + :return: The truth value of the condition, + or ``None`` when it could not be evaluated (a warning has been emitted). + """ + config = NeedsSphinxConfig(env.config) + var_proxy = config.variant_data_proxy + + if var_proxy is None: + log_warning( + LOGGER, + f"'{directive}' directive used but needs_variant_data is not configured: " + f"{expression!r}", + subtype, + location=location, + ) + return None + + context: dict[str, object] = {"var": var_proxy, "__builtins__": {}} + try: + raw_result = eval(expression, context) + except Exception as e: + log_warning( + LOGGER, + f"'{directive}' directive expression failed: {expression!r} — {e}", + subtype, + location=location, + ) + return None + + if not isinstance(raw_result, bool): + log_warning( + LOGGER, + f"'{directive}' directive expression did not return a bool, " + f"got {type(raw_result).__name__}: {raw_result!r} " + f"(coercing to bool): {expression!r}", + subtype, + location=location, + ) + + return bool(raw_result) + + class IfDirective(SphinxDirective): """Conditionally include content based on a variant data expression. @@ -35,43 +103,13 @@ class IfDirective(SphinxDirective): has_content = True def run(self) -> Sequence[nodes.Node]: - expression = self.arguments[0] - config = NeedsSphinxConfig(self.env.config) - var_proxy = config.variant_data_proxy - - if var_proxy is None: - log_warning( - LOGGER, - f"'if' directive used but needs_variant_data is not configured: " - f"{expression!r}", - "if", - location=self.get_location(), - ) - return [] - - context: dict[str, object] = {"var": var_proxy, "__builtins__": {}} - try: - raw_result = eval(expression, context) - except Exception as e: - log_warning( - LOGGER, - f"'if' directive expression failed: {expression!r} — {e}", - "if", - location=self.get_location(), - ) - return [] - - if not isinstance(raw_result, bool): - log_warning( - LOGGER, - f"'if' directive expression did not return a bool, " - f"got {type(raw_result).__name__}: {raw_result!r} " - f"(coercing to bool): {expression!r}", - "if", - location=self.get_location(), - ) - - if not raw_result: + if not evaluate_variant_condition( + self.env, + self.arguments[0], + directive="if", + subtype="if", + location=self.get_location(), + ): return [] # Parse the content into a container node diff --git a/packages/sphinx-needs/src/sphinx_needs/logging.py b/packages/sphinx-needs/src/sphinx_needs/logging.py index 88fef8cbc..e06c844a9 100644 --- a/packages/sphinx-needs/src/sphinx_needs/logging.py +++ b/packages/sphinx-needs/src/sphinx_needs/logging.py @@ -16,6 +16,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: WarningSubTypes = Literal[ "beta", "card_layout", + "choose", "config", "constraint", "create_need", @@ -68,6 +69,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: WarningSubTypeDescription: dict[WarningSubTypes, str] = { "beta": "Beta feature, subject to change", "card_layout": "Invalid ``needs_card_layouts`` specification", + "choose": "Error in processing choose/when/otherwise directive", "config": "Invalid configuration", "constraint": "Constraint violation", "create_need": "Creation of a need from directive failed", diff --git a/packages/sphinx-needs/src/sphinx_needs/needs.py b/packages/sphinx-needs/src/sphinx_needs/needs.py index 1b6be23fb..b39af5f5e 100644 --- a/packages/sphinx-needs/src/sphinx_needs/needs.py +++ b/packages/sphinx-needs/src/sphinx_needs/needs.py @@ -59,6 +59,11 @@ purge_needs, ) from sphinx_needs.directives.needbar import Needbar, NeedbarDirective, process_needbar +from sphinx_needs.directives.needchoose import ( + ChooseDirective, + OtherwiseDirective, + WhenDirective, +) from sphinx_needs.directives.needextend import Needextend, NeedextendDirective from sphinx_needs.directives.needextract import ( Needextract, @@ -309,6 +314,9 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("needreport", NeedReportDirective) app.add_directive("needuml", NeedumlDirective) app.add_directive("if", IfDirective) + app.add_directive("choose", ChooseDirective) + app.add_directive("when", WhenDirective) + app.add_directive("otherwise", OtherwiseDirective) app.add_directive("needarch", NeedarchDirective) app.add_directive("list2need", List2NeedDirective) diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py new file mode 100644 index 000000000..eb5a545ba --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py @@ -0,0 +1,22 @@ +project = "needs_choose_test" +version = "0.1.0" +extensions = ["sphinx_needs"] + +suppress_warnings = ["epub.unknown_project_files"] +# the files `.. include::` reads are not documents of their own +exclude_patterns = ["_build", "*.txt"] + +needs_types = [ + { + "directive": "req", + "title": "Requirement", + "prefix": "REQ_", + "color": "#BFD8D2", + }, +] + +needs_variant_data = { + "arch": "abc", + "debug": True, + "count": 5, +} diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt new file mode 100644 index 000000000..38d919ae0 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt @@ -0,0 +1,9 @@ +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_X1_IN_INCLUDED_CHOOSE + + .. otherwise:: + + TAKEN_X1_INCLUDED_CHOOSE_DEFAULT diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst new file mode 100644 index 000000000..7484df246 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst @@ -0,0 +1,174 @@ +CHOOSE Test +=========== + +.. toctree:: + + other + +P1 first true branch wins +------------------------- + +The conditions after the taken branch are never evaluated, +so the invalid one and the unknown key cannot warn. + +.. choose:: + + .. when:: var.arch == "abc" + + TAKEN_P1_FIRST + + .. when:: var.debug + + SKIPPED_P1_SECOND_TRUE + + .. when:: this is not python !!! + + SKIPPED_P1_INVALID_SYNTAX + + .. when:: var.no_such_key == 1 + + SKIPPED_P1_UNKNOWN_KEY + + .. otherwise:: + + SKIPPED_P1_DEFAULT + +P2 the otherwise is taken when no condition holds +------------------------------------------------- + +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P2_FALSE + + .. a comment between two branches + + .. otherwise:: + + TAKEN_P2_DEFAULT + +P2b no condition holds and there is no otherwise +------------------------------------------------ + +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P2B_1 + + .. when:: not var.debug + + SKIPPED_P2B_2 + +TAKEN_P2B_AFTER_CHOOSE + +P3 needs in branches +-------------------- + +.. choose:: + + .. when:: var.arch == "xyz" + + .. req:: In a branch that is not taken + :id: REQ_P3_SKIPPED + + .. when:: var.arch == "abc" + + .. req:: In the taken branch + :id: REQ_P3_TAKEN + + .. otherwise:: + + .. req:: In an otherwise that is not taken + :id: REQ_P3_DEFAULT_SKIPPED + +P4 sections in the taken branch +------------------------------- + +.. choose:: + + .. when:: var.debug + + P4 conditional heading + ~~~~~~~~~~~~~~~~~~~~~~ + + TAKEN_P4_SECTION_BODY + + .. otherwise:: + + P4 skipped heading + ~~~~~~~~~~~~~~~~~~ + + SKIPPED_P4_BODY + +P5 nested choose +---------------- + +.. choose:: + + .. when:: var.debug + + TAKEN_P5_OUTER + + .. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P5_INNER + + .. otherwise:: + + TAKEN_P5_INNER_DEFAULT + + .. otherwise:: + + SKIPPED_P5_OUTER + +P5b choose in the content of a need +----------------------------------- + +The need is extracted on the other page, +which renders its content from the need-node cache. + +.. req:: Host with choose content + :id: REQ_HOST + + .. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P5B_IN_NEED + + .. when:: var.arch == "abc" + + TAKEN_P5B_IN_NEED + + .. otherwise:: + + SKIPPED_P5B_IN_NEED_DEFAULT + +X1 a whole choose in an included file +------------------------------------- + +The choose and its branches are written in the same (included) file, +which is fine; branches an include supplies to a choose written elsewhere are refused. + +.. include:: included_choose.txt + +X2 an include inside the taken branch +------------------------------------- + +.. choose:: + + .. when:: var.debug + + .. include:: taken_body.txt + + TAKEN_X2_AFTER_INCLUDE + + .. otherwise:: + + SKIPPED_X2_DEFAULT + +TAKEN_X2_AFTER_CHOOSE diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/other.rst b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/other.rst new file mode 100644 index 000000000..8376855a8 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/other.rst @@ -0,0 +1,5 @@ +Other +===== + +.. needextract:: + :filter: id == 'REQ_HOST' diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt new file mode 100644 index 000000000..218c89d71 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt @@ -0,0 +1,4 @@ +TAKEN_X2_INCLUDED_TEXT + +.. req:: Included into the taken branch + :id: REQ_X2_INCLUDED diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py new file mode 100644 index 000000000..4567000a1 --- /dev/null +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -0,0 +1,1903 @@ +"""Tests for the ``.. choose::``, ``.. when::`` and ``.. otherwise::`` directives.""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +from typing import NamedTuple + +import pytest +from docutils import nodes + +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.directives.needchoose import ( + ChooseDirective, + OtherwiseDirective, + _absolute_location, + _BranchPlaceholder, + _ChooseBody, +) +from sphinx_needs_testkit import assert_no_warnings, build_warnings + +_NEEDS_TYPES = ( + "needs_types = [{'directive': 'req', 'title': 'Requirement'," + " 'prefix': 'REQ_', 'color': '#BFD8D2'}]\n" +) +_VARIANT_DATA = ( + "needs_variant_data = {'arch': 'abc', 'debug': True, 'count': 5," + " 'tags': ['a', 'b'], 'build': {'features': ['f1', 'f2']}}\n" +) +_CONF = "extensions = ['sphinx_needs']\n" + _VARIANT_DATA + _NEEDS_TYPES +_CONF_NO_VARIANT_DATA = "extensions = ['sphinx_needs']\n" + _NEEDS_TYPES +_CONF_MYST = ( + "extensions = ['sphinx_needs', 'myst_parser']\n" + "myst_enable_extensions = ['colon_fence']\n" + _VARIANT_DATA + _NEEDS_TYPES +) + +_HAS_MYST = importlib.util.find_spec("myst_parser") is not None + + +def _project( + body: str, + /, + *, + conf: str = _CONF, + myst: bool = False, + other: str | None = None, + extra: tuple[tuple[str, str], ...] = (), +) -> dict[str, object]: + """An inline project whose root document is a title followed by ``body``. + + :param body: The source after the title. + :param conf: The ``conf.py``. + :param myst: Write the documents as MyST Markdown (``.md``) rather than RST. + :param other: The source of a second document, ``other``, if there is one. + :param extra: Further files, as ``(name, text)``, such as files to include. + """ + suffix, title = (".md", "# Test\n\n") if myst else (".rst", "Test\n====\n\n") + files = [(Path("conf.py"), conf), (Path("index" + suffix), title + body)] + if other is not None: + files.append((Path("other" + suffix), other)) + files.extend((Path(name), text) for name, text in extra) + return {"buildername": "html", "files": files} + + +def _line_of(source: str, text: str) -> int: + """The 1-based number of the one line of ``source`` that is exactly ``text``.""" + lines = [i for i, line in enumerate(source.splitlines(), 1) if line == text] + assert len(lines) == 1, f"{text!r} is on lines {lines}" + return lines[0] + + +def _assert_no_choose_nodes(app) -> None: + """Neither private node class reaches a pickled doctree or the need-node cache.""" + private = (_BranchPlaceholder, _ChooseBody) + for docname in sorted(app.env.found_docs): + doctree = app.env.get_doctree(docname) + assert [ + n for n in doctree.findall(nodes.Element) if isinstance(n, private) + ] == [] + data = SphinxNeedsData(app.env) + for need_id in data.get_needs_view(): + need_node = data.get_need_node(need_id) + assert need_node is not None, need_id + assert [ + n for n in need_node.findall(nodes.Element) if isinstance(n, private) + ] == [], need_id + + +def _section_titles(app, docname: str) -> list[list[str]]: + """The title path of every section of a document, outermost first.""" + paths = [] + for section in app.env.get_doctree(docname).findall(nodes.section): + path = [] + node = section + while isinstance(node, nodes.section): + path.insert(0, node[0].astext()) + node = node.parent + paths.append(path) + return paths + + +# The happy paths, reStructuredText + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/doc_choose_directive"}], + indirect=True, +) +def test_choose_directive(test_app): + """First true branch wins, the otherwise, needs, sections, nesting and includes. + + The project builds without a single warning, + although a branch after a taken one has a condition that is not Python + and another names an unknown key: neither is ever evaluated. + """ + app = test_app + app.build() + assert_no_warnings(app) + + html = Path(app.outdir, "index.html").read_text() + taken = [ + "TAKEN_P1_FIRST", + "TAKEN_P2_DEFAULT", + "TAKEN_P2B_AFTER_CHOOSE", + "TAKEN_P4_SECTION_BODY", + "TAKEN_P5_OUTER", + "TAKEN_P5_INNER_DEFAULT", + "TAKEN_P5B_IN_NEED", + "TAKEN_X1_INCLUDED_CHOOSE_DEFAULT", + "TAKEN_X2_INCLUDED_TEXT", + "TAKEN_X2_AFTER_INCLUDE", + "TAKEN_X2_AFTER_CHOOSE", + ] + assert [word for word in taken if word not in html] == [] + # each taken branch is rendered once + assert [word for word in taken if html.count(f"

{word}

") != 1] == [] + assert "SKIPPED_" not in html + + # the needs of the branches that are not taken are never created + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST", "REQ_P3_TAKEN", "REQ_X2_INCLUDED"] + # the need of the taken branch knows the line it was written on + source = Path(app.srcdir, "index.rst").read_text() + assert needs["REQ_P3_TAKEN"]["lineno"] == _line_of( + source, " .. req:: In the taken branch" + ) + + # a heading in the taken branch is a section of the document, nested where it stands + sections = _section_titles(app, "index") + assert [ + "CHOOSE Test", + "P4 sections in the taken branch", + "P4 conditional heading", + ] in sections + assert not [path for path in sections if "P4 skipped heading" in path] + + # the need with a choose in its content is extracted on the other page + other = Path(app.outdir, "other.html").read_text() + assert "TAKEN_P5B_IN_NEED" in other + assert "SKIPPED_" not in other + + _assert_no_choose_nodes(app) + + +# Each mistake warns once, at the line that has it, and skips the whole choose + + +class _Expected(NamedTuple): + """What a build of ``body`` must report and render.""" + + body: str + #: per warning, in order: a substring, and the source line the warning must name + warnings: tuple[tuple[str, str], ...] + #: words that must be rendered (and no word starting ``SKIPPED_`` may be) + taken: tuple[str, ...] = () + #: the ids the needs view must hold + needs: tuple[str, ...] = () + conf: str = _CONF + #: further files of the project, as ``(name, text)`` + extra: tuple[tuple[str, str], ...] = () + #: the file the warnings are located in + located_in: str = "index.rst" + + +_SKIP = "; the whole choose is skipped" + + +def _stray(text: str) -> str: + """The warning about a line of the body that is neither a branch nor a comment. + + The gate refuses it before anything in the body is parsed, at its line, + and names its text. + """ + return ( + "'choose' directive may contain only 'when' and 'otherwise' directives and " + f"comments, got {text!r}" + _SKIP + ) + + +_NO_BRANCH = "'choose' directive has no 'when' or 'otherwise'" + + +def _branch_like(kind: str, write: str) -> str: + """The warning about a comment that begins with ``kind`` and a colon.""" + return ( + f"'choose' directive has a comment that begins with '{kind}:' " + f"(a branch written with one colon? write {write})" + _SKIP + ) + + +_WHEN_LIKE = _branch_like("when", "'.. when:: '") +_OTHERWISE_LIKE = _branch_like("otherwise", "'.. otherwise::'") +# under MyST the hint names the fence +_WHEN_LIKE_MYST = _branch_like("when", "a '{when} ' fence") +_OTHERWISE_LIKE_MYST = _branch_like("otherwise", "an '{otherwise}' fence") +_BRANCHES_TXT = ( + '.. when:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' + ".. otherwise::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" +) + +_WARNINGS = { + "otherwise not last": _Expected( + ".. choose::\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" + " .. when:: True\n\n SKIPPED_TRUE\n", + ( + ( + "'choose' directive has an 'otherwise' that is not its last branch" + + _SKIP, + " .. otherwise::", + ), + ), + ), + "two otherwise": _Expected( + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. otherwise::\n\n SKIPPED_D1\n\n" + # the second otherwise is refused as a second one: its directive line ends in + # spaces, which is no condition, so it is not refused as having one + " .. otherwise:: \n\n SKIPPED_D2\n", + ( + ( + "'choose' directive has more than one 'otherwise'" + _SKIP, + " .. otherwise:: ", + ), + ), + ), + # the check order: the condition faults, in document order, come before the count + # and the position of the `otherwise`, which come in that order + "two otherwise, then a when without a condition": _Expected( + ".. choose::\n\n" + " .. otherwise::\n\n SKIPPED_D1\n\n" + " .. otherwise::\n\n SKIPPED_D2\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, + " .. when::", + ), + ), + ), + "an otherwise with a condition, a when, then a bare otherwise": _Expected( + ".. choose::\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_FIRST\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_ABC\n\n" + " .. otherwise::\n\n SKIPPED_LAST\n", + ( + ( + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + " .. otherwise:: var.debug", + ), + ), + ), + # both bare: the first line ends in spaces only so that the two lines differ + "two bare otherwise and nothing else": _Expected( + ".. choose::\n\n" + " .. otherwise:: \n\n SKIPPED_D1\n\n" + " .. otherwise::\n\n SKIPPED_D2\n", + ( + ( + "'choose' directive has more than one 'otherwise'" + _SKIP, + " .. otherwise::", + ), + ), + ), + # a forgotten condition on the last `when` would make a catch-all of it: refused, + # since the default is written as an `otherwise` + "when without a condition": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, + " .. when::", + ), + ), + ), + # a true condition: an `otherwise` that took it as a `when` would render it + "otherwise with a condition": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_OTHERWISE\n", + ( + ( + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + " .. otherwise:: var.debug", + ), + ), + ), + # the condition faults are in document order: the otherwise's comes first here + "an otherwise with a condition, then a when without one": _Expected( + ".. choose::\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_FIRST\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + " .. otherwise:: var.debug", + ), + ), + ), + # the children are checked before the condition faults: the stray paragraph after + # a when without a condition is what is reported + "a when without a condition, then a stray paragraph": _Expected( + ".. choose::\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n\n" + " A stray paragraph.\n", + ((_stray("A stray paragraph."), " A stray paragraph."),), + ), + # a branch written with one colon is a comment that swallows the content under it; + # comments are accepted, so for the variant it was written for (`abc`) the choose + # would render its otherwise, silently + "when with one colon": _Expected( + ".. choose::\n\n" + " .. when: var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when: var.arch == 'abc'"),), + ), + "otherwise with one colon": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. otherwise:\n\n SKIPPED_SWALLOWED_OTHERWISE\n", + ((_OTHERWISE_LIKE, " .. otherwise:"),), + ), + # the rule is case-insensitive, as directive names are + "When with one colon, capitalised": _Expected( + ".. choose::\n\n" + " .. When: var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. When: var.arch == 'abc'"),), + ), + # and tolerates whitespace before the colon + "when with a space before one colon": _Expected( + ".. choose::\n\n" + " .. when : var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when : var.arch == 'abc'"),), + ), + # docutils allows one space before `::`: with two the line is a comment that would + # swallow the branch, refused by the one-colon rule + "when with two spaces before the colons": _Expected( + ".. choose::\n\n" + " .. when :: var.debug\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when :: var.debug"),), + ), + # docutils needs a space (or the end of the line) after `::` for a directive + "when without the space after ::": _Expected( + ".. choose::\n\n" + " .. when::var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when::var.arch == 'abc'"),), + ), + # the control: a comment that merely begins with the word is accepted + "a comment that starts with the word when": _Expected( + ".. choose::\n\n" + " .. when we migrate, drop this\n\n" + " .. when:: var.arch == 'abc'\n\n TAKEN_AFTER_WORD_COMMENT\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + (), + taken=("TAKEN_AFTER_WORD_COMMENT",), + ), + # it is a fault of a child, found with the others in document order, before the + # condition faults: after a when without a condition, the comment is reported + "a when without a condition, then a when with one colon": _Expected( + ".. choose::\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n\n" + " .. when: var.debug\n\n SKIPPED_SWALLOWED_BRANCH\n", + ((_WHEN_LIKE, " .. when: var.debug"),), + ), + "paragraph in the body": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n SKIPPED_BRANCH\n\n" + " A stray paragraph.\n", + ((_stray("A stray paragraph."), " A stray paragraph."),), + ), + # a directive in the body is refused at its own line, before it runs: the branch + # in the note is never reached + "note wrapping a branch": _Expected( + ".. choose::\n\n" + " .. note::\n\n .. when:: True\n\n SKIPPED_IN_NOTE\n", + ((_stray(".. note::"), " .. note::"),), + ), + # a directive that would hand the nodes of its content to the choose (a true `if`, + # `rst-class`) is refused the same way: a branch must be written directly in it + "branches inside a true if": _Expected( + ".. choose::\n\n" + " .. if:: var.debug\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", + ((_stray(".. if:: var.debug"), " .. if:: var.debug"),), + ), + "branch inside rst-class": _Expected( + ".. choose::\n\n" + " .. rst-class:: special\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", + ((_stray(".. rst-class:: special"), " .. rst-class:: special"),), + ), + "otherwise inside a true if": _Expected( + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. if:: var.debug\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", + ((_stray(".. if:: var.debug"), " .. if:: var.debug"),), + ), + # a directive that produces no node used to pass unnoticed: a false `if` hid the + # branches in it and the otherwise was taken; now it is refused like any other + "a false if in the body": _Expected( + ".. choose::\n\n" + " .. if:: False\n\n" + " .. when:: True\n\n SKIPPED_IN_FALSE_IF\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. if:: False"), " .. if:: False"),), + ), + "default-role in the body": _Expected( + ".. choose::\n\n" + " .. default-role:: math\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. default-role:: math"), " .. default-role:: math"),), + ), + # a target and a substitution definition are not comments + "a label in the body": _Expected( + ".. choose::\n\n" + " .. _label_in_the_body:\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. _label_in_the_body:"), " .. _label_in_the_body:"),), + ), + "a substitution definition in the body": _Expected( + ".. choose::\n\n" + " .. |sub| replace:: text\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. |sub| replace:: text"), " .. |sub| replace:: text"),), + ), + # an empty comment ends at the blank line after it: the indented block that follows + # would be a block quote of the body, and the need in it would run + "a block quote after an empty comment": _Expected( + ".. choose::\n\n" + " ..\n\n" + " .. req:: In a block quote\n :id: REQ_QUOTED\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + # the stray's text keeps its indentation (the body is dedented by three) + ((_stray(" .. req:: In a block quote"), " .. req:: In a block quote"),), + ), + # a line of one to three punctuation characters would make docutils emit an INFO + # message and a paragraph; it is refused before docutils sees it + "rule line between branches": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " ---\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ((_stray("---"), " ---"),), + ), + "three dots in the body": _Expected( + ".. choose::\n\n ...\n\n .. otherwise::\n\n SKIPPED_DEFAULT\n", + ((_stray("..."), " ..."),), + ), + "when outside a choose": _Expected( + "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", + ( + ( + "'when' directive outside a 'choose' (a 'when' must be a direct child " + "of a 'choose'); its content is skipped", + ".. when:: True", + ), + ), + ), + # the counterpart of an orphan `else`: an otherwise outside every choose + "otherwise outside a choose": _Expected( + "Para.\n\n.. otherwise::\n\n SKIPPED_STRAY_DEFAULT\n", + ( + ( + "'otherwise' directive outside a 'choose' (an 'otherwise' must be a " + "direct child of a 'choose'); its content is skipped", + ".. otherwise::", + ), + ), + ), + "branch loose in the taken branch": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n TAKEN_OUTER\n\n" + " .. when:: True\n\n SKIPPED_LOOSE\n", + (("'when' directive outside a 'choose'", " .. when:: True"),), + taken=("TAKEN_OUTER",), + ), + # a choose written directly in another choose's body is a stray of the outer one, + # refused before it runs: one warning, and the loose branch in its taken branch is + # never reached + "branch loose in the taken branch of a misplaced choose": _Expected( + ".. choose::\n\n" + " .. choose::\n\n" + " .. when:: True\n\n" + " .. when:: True\n\n SKIPPED_LOOSE\n", + ((_stray(".. choose::"), " .. choose::"),), + ), + "unevaluable first branch poisons the otherwise": _Expected( + ".. choose::\n\n" + " .. when:: this is not python !!!\n\n SKIPPED_1\n\n" + " .. when:: True\n\n SKIPPED_2\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'when' directive expression failed: 'this is not python !!!' — ", + " .. when:: this is not python !!!", + ), + ), + ), + "unknown key in a later branch": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_1\n\n" + " .. when:: var.no_such_key == 1\n\n SKIPPED_2\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'when' directive expression failed: 'var.no_such_key == 1' — " + "Unknown variant key: var.no_such_key", + " .. when:: var.no_such_key == 1", + ), + ), + ), + "builtins blocked": _Expected( + ".. choose::\n\n" + " .. when:: __import__('os').system('echo pwned')\n\n SKIPPED\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'when' directive expression failed: " + "\"__import__('os').system('echo pwned')\" — " + "name '__import__' is not defined", + " .. when:: __import__('os').system('echo pwned')", + ), + ), + ), + "non-bool is coerced and taken": _Expected( + ".. choose::\n\n" + " .. when:: var.count\n\n TAKEN_NONBOOL\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'when' directive expression did not return a bool, got int: 5 " + "(coercing to bool): 'var.count'", + " .. when:: var.count", + ), + ), + taken=("TAKEN_NONBOOL",), + ), + "empty string condition": _Expected( + '.. choose::\n\n .. when:: ""\n\n SKIPPED_EMPTY\n\n' + " .. otherwise::\n\n TAKEN_DEFAULT\n", + ( + ( + "'when' directive expression did not return a bool, got str: '' " + "(coercing to bool): '\"\"'", + ' .. when:: ""', + ), + ), + taken=("TAKEN_DEFAULT",), + ), + "choose with an argument": _Expected( + ".. choose:: var.arch\n\n .. when:: True\n\n SKIPPED\n", + ( + ( + "'choose' directive takes no argument, got 'var.arch' " + "(write a condition on each 'when')" + _SKIP, + ".. choose:: var.arch", + ), + ), + ), + "empty choose": _Expected( + ".. choose::\n\nTAKEN_AFTER_EMPTY\n", + ((_NO_BRANCH, ".. choose::"),), + taken=("TAKEN_AFTER_EMPTY",), + ), + "only comments": _Expected( + ".. choose::\n\n .. just a comment\n\n .. and another\n", + ((_NO_BRANCH, ".. choose::"),), + ), + "variant data not configured": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_1\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'choose' directive used but needs_variant_data is not configured" + + _SKIP, + ".. choose::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # nothing is evaluated here, and still the choose warns and renders nothing: + # the "used but not configured" rule holds for every choose + "variant data not configured, only an otherwise": _Expected( + ".. choose::\n\n .. otherwise::\n\n SKIPPED_DEFAULT_ONLY\n", + ( + ( + "'choose' directive used but needs_variant_data is not configured" + + _SKIP, + ".. choose::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # the structure is checked before the configuration: a choose that is wrong in both + # ways gets the one structural warning, at the branch, and its body is still parsed + "variant data not configured, and a misplaced otherwise": _Expected( + ".. choose::\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_ABC\n", + ( + ( + "'choose' directive has an 'otherwise' that is not its last branch" + + _SKIP, + " .. otherwise::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # and a when without a condition is a structural mistake too, warned at the when + "variant data not configured, and a when without a condition": _Expected( + ".. choose::\n\n .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, + " .. when::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # the branches of a choose are written in its body: an include in it is refused at + # its own line, in the host, and the included file is never read + "branches from an include": _Expected( + ".. choose::\n\n .. include:: branches.txt\n", + ((_stray(".. include:: branches.txt"), " .. include:: branches.txt"),), + extra=(("branches.txt", _BRANCHES_TXT),), + ), + # the two other exits that may report a location in an included file: + # an evaluation fault inside a choose the include holds, and a stray branch + "an unevaluable when in an included choose": _Expected( + ".. include:: inc.txt\n", + (("'when' directive expression failed", " .. when:: invalid !!!"),), + extra=( + ( + "inc.txt", + ".. choose::\n\n .. when:: invalid !!!\n\n SKIPPED_INC\n\n" + " .. otherwise::\n\n SKIPPED_INC_DEFAULT\n", + ), + ), + located_in="inc.txt", + ), + "a stray when in an included file": _Expected( + ".. include:: stray.txt\n", + (("'when' directive outside a 'choose'", ".. when:: True"),), + extra=(("stray.txt", ".. when:: True\n\n SKIPPED_STRAY\n"),), + located_in="stray.txt", + ), + # the gate and the structural checks report through the same helper, whose + # location must be absolute as well: a stray, and a branch fault, in a choose an + # include holds (docutils gives the included file a cwd-relative path) + "a stray in an included choose": _Expected( + ".. include:: stray_body.txt\n", + ((_stray("A stray paragraph."), " A stray paragraph."),), + extra=( + ( + "stray_body.txt", + ".. choose::\n\n A stray paragraph.\n\n" + " .. otherwise::\n\n SKIPPED_STRAY_BODY\n", + ), + ), + located_in="stray_body.txt", + ), + "a when without a condition in an included choose": _Expected( + ".. include:: bare_when.txt\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)", + " .. when::", + ), + ), + extra=( + ( + "bare_when.txt", + ".. choose::\n\n .. when::\n\n SKIPPED_BARE_IN_INCLUDE\n", + ), + ), + located_in="bare_when.txt", + ), + # the body is read before any condition: a true branch written in place before + # the include is not taken either + "a branch from an include after a true branch": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n SKIPPED_IN_PLACE\n\n" + " .. include:: branches.txt\n", + ((_stray(".. include:: branches.txt"), " .. include:: branches.txt"),), + extra=(("branches.txt", _BRANCHES_TXT),), + ), + "an otherwise from an include": _Expected( + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. include:: otherwise.txt\n", + ((_stray(".. include:: otherwise.txt"), " .. include:: otherwise.txt"),), + extra=(("otherwise.txt", ".. otherwise::\n\n SKIPPED_FROM_INCLUDE\n"),), + ), + # content outside a branch is refused before it is parsed: the need never exists + "need directly in the body": _Expected( + ".. choose::\n\n" + " .. req:: Directly in the choose body\n :id: REQ_DIRECT\n\n" + " .. when:: True\n\n SKIPPED\n", + ( + ( + _stray(".. req:: Directly in the choose body"), + " .. req:: Directly in the choose body", + ), + ), + ), + # a branch that is not taken is never parsed, exactly as the body of a false `if` + "errors in an untaken branch are never reported": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n TAKEN_E\n\n" + " .. req:: In the taken branch\n :id: REQ_TAKEN\n\n" + " .. when:: False\n\n" + " .. choose::\n\n" + " .. when:: invalid !!!\n\n SKIPPED\n\n" + " .. nosuchdirective::\n\n" + " .. req:: In the branch that is not taken\n :id: REQ_SKIPPED\n", + (), + taken=("TAKEN_E",), + needs=("REQ_TAKEN",), + ), +} + + +@pytest.mark.parametrize( + ("test_app", "expected"), + [ + (_project(row.body, conf=row.conf, extra=row.extra), row) + for row in _WARNINGS.values() + ], + ids=list(_WARNINGS), + indirect=["test_app"], +) +def test_choose_warnings(test_app, expected: _Expected, monkeypatch): + """Each mistake warns exactly once, at the offending line, and fails closed. + + Built from the source directory: docutils then records an included file relative + to the working directory (``branches.txt`` rather than an absolute path), which is + what the warnings must make absolute again, and what a build from a project's + own directory gives in practice. + """ + app = test_app + monkeypatch.chdir(app.srcdir) + app.build() + warnings = build_warnings(app) + assert len(warnings) == len(expected.warnings), warnings + source = Path(app.srcdir, expected.located_in).read_text() + for warning, (text, line) in zip(warnings, expected.warnings, strict=True): + assert warning.startswith( + f"/{expected.located_in}:{_line_of(source, line)}: WARNING: " + ), warning + assert text in warning, warning + assert warning.endswith(" [needs.choose]"), warning + html = Path(app.outdir, "index.html").read_text() + assert [word for word in expected.taken if word not in html] == [] + assert "SKIPPED" not in html + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == list(expected.needs) + _assert_no_choose_nodes(app) + + +@pytest.mark.parametrize( + ("test_app", "line"), + [ + ( + _project( + ".. choose::\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + f" {line}\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + extra=(("docutils.conf", f"[general]\nreport_level: {level}\n"),), + ), + line, + ) + for level, line in ((1, "---"), (4, ".. wehn:: True")) + ], + ids=["a rule line, report level 1", "a misspelt directive, report level 4"], + indirect=["test_app"], +) +def test_choose_refuses_a_stray_whatever_the_report_level(test_app, line, monkeypatch): + """A stray line is refused by the ``choose`` itself, before docutils parses it. + + So the project's ``report_level`` (in its ``docutils.conf``) changes nothing: + at level 1 the INFO docutils would give a ``---`` line never appears, and at + level 4, which hides every docutils error, a misspelt branch is still refused + with a warning rather than letting the ``otherwise`` render with ``-W`` green. + ``sphinx-build`` points ``DOCUTILSCONFIG`` at the project's ``docutils.conf``; + this in-process build does it by hand. + """ + app = test_app + monkeypatch.setenv("DOCUTILSCONFIG", str(Path(app.srcdir, "docutils.conf"))) + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, f' {line}')}: WARNING: " + ), warning + assert _stray(line) in warning, warning + assert warning.endswith(" [needs.choose]"), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + # docutils never saw the line + assert "Unexpected possible title overline" not in app._status.getvalue() + + +@pytest.mark.parametrize( + ("test_app", "line", "error"), + [ + ( + _project( + ".. choose::\n\n" + " .. cas:: True\n\n SKIPPED\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n" + ), + ".. cas:: True", + 'Unknown directive type "cas"', + ), + ( + _project( + ".. choose::\n\n" + " Title\n -----\n\n" + " .. when:: True\n\n SKIPPED\n" + ), + "Title", + "Unexpected section title", + ), + ], + ids=["typo in a directive name", "section title in the body"], + indirect=["test_app"], +) +def test_choose_body_mistake_reported_once(test_app, line: str, error: str): + """A mistake in the body is refused once, by the ``choose``, at its line. + + docutils never parses the line, so its own error for it never appears. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, f' {line}')}: WARNING: " + ), warning + assert _stray(line) in warning, warning + assert error not in warning + html = Path(app.outdir, "index.html").read_text() + assert "SKIPPED" not in html + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", + conf=_CONF + "suppress_warnings = ['needs.choose']\n", + ) + ], + indirect=True, +) +def test_choose_warnings_are_suppressible(test_app): + """Every warning of the three directives is of the ``needs.choose`` type.""" + app = test_app + app.build() + assert_no_warnings(app) + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. req:: Written before the choose\n :id: REQ_BEFORE\n\n" + ".. choose::\n\n" + " .. req:: Directly in the choose body\n :id: REQ_STRAY\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" + ".. req:: Written after the choose\n :id: REQ_AFTER\n", + # read before `index`, so its need is older than every need of `index` + extra=( + ( + "aaa.rst", + ":orphan:\n\nEarlier\n=======\n\n" + ".. req:: In an earlier document\n :id: REQ_EARLIER\n", + ), + ), + ) + ], + indirect=True, +) +def test_choose_body_stray_need_never_runs(test_app): + """A need written directly in the body is refused before it is created. + + Nothing in the body runs, so there is nothing to undo: the needs written before + the ``choose``, in its own document and in an earlier one, and after it are all + there, and the stray one never existed. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + line = _line_of(source, " .. req:: Directly in the choose body") + assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning + assert _stray(".. req:: Directly in the choose body") in warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_AFTER", "REQ_BEFORE", "REQ_EARLIER"] + + +_SWALLOW_CONF = ( + _CONF + + """ +from docutils import nodes +from sphinx.util.docutils import SphinxDirective + + +class Swallow(SphinxDirective): + has_content = True + + def run(self): + node = nodes.container() + try: + self.state.nested_parse(self.content, self.content_offset, node) + except RuntimeError: + return [nodes.paragraph(text="SWALLOWED")] + return [node] + + +def setup(app): + app.add_directive("swallow", Swallow) +""" +) + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. swallow::\n\n" + " .. choose::\n\n" + " .. otherwise::\n\n SKIPPED_X\n\n" + ".. when:: True\n\n SKIPPED_LOOSE_AFTER\n", + conf=_SWALLOW_CONF, + ) + ], + indirect=True, +) +def test_choose_restores_its_depth_when_its_body_raises(test_app, monkeypatch): + """An exception out of a ``choose`` body leaves no ``choose`` open behind it. + + Only the branch directives run while the body is parsed, so the exception is + made to come from one: the ``otherwise`` raises, and a directive of the project + catches it. The ``when`` after it is outside every ``choose`` and must still be + reported, rather than collected as a placeholder that would reach the writer. + """ + + def boom(self): + raise RuntimeError("boom") + + monkeypatch.setattr(OtherwiseDirective, "run", boom) + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + line = _line_of(source, ".. when:: True") + assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning + assert "'when' directive outside a 'choose'" in warning + html = Path(app.outdir, "index.html").read_text() + assert "SWALLOWED" in html + assert "SKIPPED" not in html + + +_TAB_PARENT = "The parent of a 'tab-item' should be a 'tab-set'" + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. tab-set::\n\n" + " .. if:: var.debug\n\n" + " .. tab-item:: IF_TAB\n\n TAKEN_IF_TAB_BODY\n\n" + ".. tab-set::\n\n" + " .. choose::\n\n" + " .. when:: var.debug\n\n" + " .. tab-item:: WHEN_TAB\n\n TAKEN_WHEN_TAB_BODY\n\n" + " .. otherwise::\n\n SKIPPED_TAB\n\n" + ".. tab-set::\n\n" + " .. tab-item:: CHOOSE_INSIDE_TAB\n\n" + " .. choose::\n\n" + " .. when:: var.debug\n\n TAKEN_INSIDE_TAB\n", + conf=_CONF.replace( + "extensions = ['sphinx_needs']", + "extensions = ['sphinx_needs', 'sphinx_design']", + ), + ) + ], + indirect=True, +) +def test_tab_item_in_the_taken_when_warns_as_in_a_true_if(test_app): + """A ``tab-item`` in the taken branch warns exactly as one in a true ``if`` does. + + The content of the taken branch, like the body of a true ``if``, is parsed into + a detached container, so sphinx-design's ``tab-item`` does not see the + ``tab-set`` around the directive and warns about its parent. This pins the + limitation the docs of both directives describe, with the same warning for both, + and the remedy they give: a ``choose`` inside the ``tab-item`` does not warn. + """ + # no skip: sphinx-design is in the shared `test` group, and if it ever leaves it, + # this test must fail rather than stop pinning the documented limitation + import sphinx_design # noqa: F401 + + app = test_app + app.build() + warnings = build_warnings(app) + assert len(warnings) == 2, warnings + source = Path(app.srcdir, "index.rst").read_text() + if_warning, when_warning = warnings + assert if_warning.startswith( + f"/index.rst:{_line_of(source, ' .. tab-item:: IF_TAB')}: WARNING: " + ), if_warning + assert when_warning.startswith( + f"/index.rst:{_line_of(source, ' .. tab-item:: WHEN_TAB')}: " + "WARNING: " + ), when_warning + assert _TAB_PARENT in if_warning, if_warning + assert ( + if_warning.split(": WARNING: ", 1)[1] == when_warning.split(": WARNING: ", 1)[1] + ) + html = Path(app.outdir, "index.html").read_text() + for word in ("TAKEN_IF_TAB_BODY", "TAKEN_WHEN_TAB_BODY", "TAKEN_INSIDE_TAB"): + assert word in html, word + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + + +# One condition language: `when` evaluates exactly what `if` does + +_EXPRESSIONS = { + # expression: whether it is true (None: it cannot be evaluated) + "var.arch == 'abc'": True, + "var.arch == 'xyz'": False, + "var.debug": True, + "not var.debug": False, + "var.count > 10": False, + "'f1' in var.build.features": True, + "var.count": True, + "var.tags": True, + '""': False, + "var.no_such_key == 1": None, + "var.build.no_such_key": None, + "invalid syntax !!!": None, + "__import__('os').system('echo pwned')": None, + "1 / 0": None, +} +#: the expressions whose value is not a bool, which warn and are then used +_NON_BOOL = {"var.count", "var.tags", '""'} + + +def _strip_location_and_type(warning: str) -> str: + """The message of a warning record, without its location and its type.""" + message = warning.split(": WARNING: ", 1)[1] + return message.rsplit(" [needs.", 1)[0] + + +@pytest.mark.parametrize( + ("test_app", "expression", "verdict"), + [ + ( + _project( + f".. if:: {expression}\n\n IF_TAKEN\n\n" + ".. choose::\n\n" + f" .. when:: {expression}\n\n WHEN_TAKEN\n" + ), + expression, + verdict, + ) + for expression, verdict in _EXPRESSIONS.items() + ], + ids=list(_EXPRESSIONS), + indirect=["test_app"], +) +def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): + """``if`` and ``when`` give every condition the same verdict and the same warnings. + + Only the directive name and the warning type differ, + because both directives go through one evaluator. + """ + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text() + assert ("IF_TAKEN" in html) is bool(verdict) + assert ("WHEN_TAKEN" in html) is bool(verdict) + + warnings = build_warnings(app) + if_warnings = [w for w in warnings if w.endswith(" [needs.if]")] + when_warnings = [w for w in warnings if w.endswith(" [needs.choose]")] + assert len(if_warnings) + len(when_warnings) == len(warnings), warnings + assert len(if_warnings) == len(when_warnings), warnings + # an unevaluable condition warns, and so does a result that is not a bool + assert bool(if_warnings) is (verdict is None or expression in _NON_BOOL) + source = Path(app.srcdir, "index.rst").read_text() + for if_warning, when_warning in zip(if_warnings, when_warnings, strict=True): + assert if_warning.startswith( + f"/index.rst:{_line_of(source, f'.. if:: {expression}')}: " + ), if_warning + assert when_warning.startswith( + f"/index.rst:{_line_of(source, f' .. when:: {expression}')}: " + ), when_warning + assert _strip_location_and_type(if_warning).startswith("'if' directive ") + assert _strip_location_and_type(when_warning) == _strip_location_and_type( + if_warning + ).replace("'if' directive ", "'when' directive ", 1) + + +# MyST Markdown + +_MYST_HAPPY = """\ +```{toctree} +other +``` + +## Colon fences + +::::{choose} +:::{when} var.arch == "abc" +TAKEN_M1_FIRST +::: +:::{when} var.debug +SKIPPED_M1_SECOND_TRUE +::: +:::{when} this is not python !!! +SKIPPED_M1_INVALID_SYNTAX +::: +:::{otherwise} +SKIPPED_M1_DEFAULT +::: +:::: + +## Comments between branches + +::::{choose} +:::{when} var.arch == "xyz" +SKIPPED_M2_FALSE +::: + +% a MyST comment between two branches + ++++ + +:::{otherwise} +TAKEN_M2_DEFAULT +::: +:::: + +## Backtick fences, and needs in branches + +`````{choose} +````{when} var.arch == "xyz" +```{req} In a branch that is not taken +:id: REQ_M3_SKIPPED +``` +```` +````{when} var.arch == "abc" +TAKEN_M3 + +```{req} In the taken branch +:id: REQ_M3_TAKEN +``` +```` +````` + +## A section in the taken branch + +::::{choose} +:::{when} var.debug +### M4 conditional heading + +TAKEN_M4_SECTION_BODY +::: +:::: + +## Nested choose, one more fence character per level + +::::::{choose} +:::::{when} var.debug +TAKEN_M5_OUTER + +::::{choose} +:::{when} var.arch == "xyz" +SKIPPED_M5_INNER +::: +:::{otherwise} +TAKEN_M5_INNER_DEFAULT +::: +:::: +::::: +:::::{otherwise} +SKIPPED_M5_OUTER +::::: +:::::: + +## An otherwise whose fence line ends in spaces has no condition + +::::{choose} +:::{when} False +SKIPPED_M6 +::: +:::{otherwise}\x20\x20\x20 +TAKEN_M6_DEFAULT +::: +:::: + +## Choose in the content of a need + +:::::{req} Host with choose content +:id: REQ_M_HOST + +::::{choose} +:::{when} var.arch == "xyz" +SKIPPED_M7_IN_NEED +::: +:::{when} var.arch == "abc" +TAKEN_M7_IN_NEED +::: +:::: +::::: + +## A whole choose in an included file + +```{include} included_choose.txt +``` +""" + +_MYST_INCLUDED_CHOOSE = """\ +::::{choose} +:::{when} var.arch == "xyz" +SKIPPED_M8_IN_INCLUDED_CHOOSE +::: +:::{otherwise} +TAKEN_M8_INCLUDED_CHOOSE_DEFAULT +::: +:::: +""" + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + _MYST_HAPPY, + conf=_CONF_MYST, + myst=True, + other="# Other\n\n```{needextract}\n:filter: id == 'REQ_M_HOST'\n```\n", + extra=(("included_choose.txt", _MYST_INCLUDED_CHOOSE),), + ) + ], + indirect=True, +) +def test_choose_in_myst(test_app): + """Colon and backtick fences, comments, needs, sections, nesting, needextract.""" + app = test_app + app.build() + assert_no_warnings(app) + + html = Path(app.outdir, "index.html").read_text() + taken = [ + "TAKEN_M1_FIRST", + "TAKEN_M2_DEFAULT", + "TAKEN_M3", + "TAKEN_M4_SECTION_BODY", + "TAKEN_M5_OUTER", + "TAKEN_M5_INNER_DEFAULT", + "TAKEN_M6_DEFAULT", + "TAKEN_M7_IN_NEED", + "TAKEN_M8_INCLUDED_CHOOSE_DEFAULT", + ] + assert [word for word in taken if word not in html] == [] + assert "SKIPPED_" not in html + + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_M3_TAKEN", "REQ_M_HOST"] + # MyST gives a directive a content offset relative to its own line, which the + # choose re-bases for the branch it takes: the need knows its true line + source = Path(app.srcdir, "index.md").read_text() + assert needs["REQ_M3_TAKEN"]["lineno"] == _line_of( + source, "```{req} In the taken branch" + ) + + assert [ + "Test", + "A section in the taken branch", + "M4 conditional heading", + ] in _section_titles(app, "index") + + other = Path(app.outdir, "other.html").read_text() + assert "TAKEN_M7_IN_NEED" in other + assert "SKIPPED_" not in other + + _assert_no_choose_nodes(app) + + +# MyST reports a directive nested in a colon fence one line late (its own quirk, the +# same for a `{note}` in a `{note}`), so only the backtick spellings assert a line +_MYST_WARNINGS = { + "when outside a choose, backticks": ( + "Para.\n\n```{when} True\nSKIPPED_STRAY\n```\n", + "'when' directive outside a 'choose'", + "```{when} True", + ), + "otherwise outside a choose, backticks": ( + "Para.\n\n```{otherwise}\nSKIPPED_STRAY_DEFAULT\n```\n", + "'otherwise' directive outside a 'choose' (an 'otherwise' must be a direct " + "child of a 'choose'); its content is skipped", + "```{otherwise}", + ), + "when outside a choose, colons": ( + "Para.\n\n:::{when} True\nSKIPPED_STRAY\n:::\n", + "'when' directive outside a 'choose'", + None, + ), + "branch loose in the taken branch, backticks": ( + "`````{choose}\n````{when} True\nTAKEN_OUTER\n\n" + "```{when} True\nSKIPPED_LOOSE\n```\n````\n`````\n", + "'when' directive outside a 'choose'", + "```{when} True", + ), + "paragraph in the body, backticks": ( + "````{choose}\n```{when} True\nSKIPPED\n```\n\nA stray paragraph.\n````\n", + _stray("A stray paragraph."), + "A stray paragraph.", + ), + "paragraph in the body, colons": ( + "::::{choose}\n:::{when} True\nSKIPPED\n:::\n\nA stray paragraph.\n::::\n", + _stray("A stray paragraph."), + None, + ), + # an HTML comment is raw HTML, not a comment + "html comment between branches, backticks": ( + "````{choose}\n```{when} False\nSKIPPED\n```\n\n\n\n" + "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", + _stray(""), + "", + ), + "html comment between branches, colons": ( + "::::{choose}\n:::{when} False\nSKIPPED\n:::\n\n\n\n" + ":::{otherwise}\nSKIPPED_DEFAULT\n:::\n::::\n", + _stray(""), + None, + ), + "choose with an argument, backticks": ( + "````{choose} var.arch\n```{when} True\nSKIPPED\n```\n````\n", + "'choose' directive takes no argument, got 'var.arch'", + "````{choose} var.arch", + ), + "choose with an argument, colons": ( + "::::{choose} var.arch\n:::{when} True\nSKIPPED\n:::\n::::\n", + "'choose' directive takes no argument, got 'var.arch'", + None, + ), + "when without a condition, backticks": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + "```{when}", + ), + # a fence line that ends in spaces gives a blank condition, which is none + "when with a blank condition, colons": ( + "::::{choose}\n:::{when} var.arch == 'xyz'\nSKIPPED_XYZ\n:::\n" + ":::{when}\x20\x20\x20\nSKIPPED_BLANK_CONDITION\n:::\n::::\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + None, + ), + # MyST would fold the text into the content of a directive without an argument + "otherwise with a condition, backticks": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "```{otherwise} var.debug\nSKIPPED_OTHERWISE\n```\n````\n", + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + "```{otherwise} var.debug", + ), + # the check order, as in reStructuredText + "two otherwise, then a when without a condition, backticks": ( + "````{choose}\n```{otherwise}\nSKIPPED_D1\n```\n```{otherwise}\nSKIPPED_D2\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + "```{when}", + ), + "an otherwise with a condition, a when, then a bare otherwise, backticks": ( + "````{choose}\n```{otherwise} var.debug\nSKIPPED_FIRST\n```\n" + "```{when} var.arch == 'abc'\nSKIPPED_ABC\n```\n" + "```{otherwise}\nSKIPPED_LAST\n```\n````\n", + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + "```{otherwise} var.debug", + ), + "an otherwise with a condition, then a when without one, backticks": ( + "````{choose}\n```{otherwise} var.debug\nSKIPPED_FIRST\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + "```{otherwise} var.debug", + ), + "unevaluable condition, backticks": ( + "````{choose}\n```{when} invalid !!!\nSKIPPED\n```\n" + "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", + "'when' directive expression failed: 'invalid !!!'", + "```{when} invalid !!!", + ), + "unevaluable condition, colons": ( + "::::{choose}\n:::{when} invalid !!!\nSKIPPED\n:::\n" + ":::{otherwise}\nSKIPPED_DEFAULT\n:::\n::::\n", + "'when' directive expression failed: 'invalid !!!'", + None, + ), + # a `%` line is a comment in MyST, under the same rule; the hint names the fence + "when with one colon, % comment": ( + "````{choose}\n% when: var.arch == 'abc'\n" + "```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + _WHEN_LIKE_MYST, + "% when: var.arch == 'abc'", + ), + "otherwise with one colon, % comment": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "% otherwise:\n````\n", + _OTHERWISE_LIKE_MYST, + "% otherwise:", + ), + # the control: accepted, and its branch is taken (no warning) + "a % comment that starts with the word when": ( + "````{choose}\n% when we migrate, drop this\n" + "```{when} var.arch == 'abc'\nTAKEN_AFTER_WORD_COMMENT\n```\n" + "```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + None, + None, + ), + # an `{eval-rst}` block is a fence of another directive: refused at its line + "branch inside eval-rst, backticks": ( + "````{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + "````\n", + _stray("```{eval-rst}"), + "```{eval-rst}", + ), + "branch inside eval-rst, colons": ( + "::::{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + "::::\n", + _stray("```{eval-rst}"), + None, + ), + # an opener indented four spaces is a code block: refused, named with its indentation + "an opener indented four spaces": ( + "````{choose}\n :::{when} var.debug\nSKIPPED\n:::\n````\n", + _stray(" :::{when} var.debug"), + " :::{when} var.debug", + ), + # MyST takes the first word of the info string as the directive: with no space + # after the braces it is no directive, so no branch + "a branch fence without a space after the name": ( + "````{choose}\n```{when}True\nSKIPPED\n```\n````\n", + _stray("```{when}True"), + "```{when}True", + ), +} + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "text", "line"), + [ + (_project(body, conf=_CONF_MYST, myst=True), text, line) + for body, text, line in _MYST_WARNINGS.values() + ], + ids=list(_MYST_WARNINGS), + indirect=["test_app"], +) +def test_choose_warnings_in_myst( + test_app, text: str | None, line: str | None, monkeypatch +): + """The MyST spellings warn once each and fail closed, as in reStructuredText. + + A row without a text is a control: it gives no warning, and its branch is taken. + Built from the source directory, as the reStructuredText rows are. + """ + app = test_app + monkeypatch.chdir(app.srcdir) + app.build() + if text is None: + assert build_warnings(app) == [] + html = Path(app.outdir, "index.html").read_text() + assert "TAKEN_" in html + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + return + (warning,) = build_warnings(app) + assert text in warning, warning + assert warning.endswith(" [needs.choose]"), warning + if line is not None: + source = Path(app.srcdir, "index.md").read_text() + assert warning.startswith( + f"/index.md:{_line_of(source, line)}: WARNING: " + ), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "::::{choose}\n:::{when} True\nSKIPPED_IN_PLACE\n:::\n\n" + "```{include} branches.txt\n```\n::::\n", + conf=_CONF_MYST, + myst=True, + extra=( + ( + "branches.txt", + ':::{when} var.arch == "xyz"\nSKIPPED_X1_FROM_INCLUDE\n:::\n' + ":::{otherwise}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", + ), + ), + ) + ], + indirect=True, +) +def test_choose_refuses_included_branches_in_myst(test_app): + """In MyST too, an ``{include}`` in the body is refused at its line, in the host. + + The included file is never read. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, "```{include} branches.txt") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray("```{include} branches.txt") in warning, warning + assert warning.endswith(" [needs.choose]"), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "````{choose}\n{{ branches }}\n```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + conf=_CONF_MYST.replace( + "['colon_fence']", "['colon_fence', 'substitution']" + ) + + "myst_substitutions = {'branches': " + "':::{when} True\\nSKIPPED_FROM_SUBSTITUTION\\n:::'}\n", + myst=True, + ) + ], + indirect=True, +) +def test_choose_refuses_a_substitution_in_myst(test_app): + """A substitution reference in the body is a line of text to the ``choose``. + + It is refused at its line, so the branches its definition holds are never taken, + and neither is the ``otherwise``. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, "{{ branches }}") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray("{{ branches }}") in warning, warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +_HOST_NEED = "```{req} Host\n:id: REQ_HOST\n:status: open\n```\n\n" + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + _HOST_NEED + "::::{choose}\n:::{when} var.debug | var.debug\n|---|---|\n\n" + "(leak-label-table)=\n" + "```{req} Smuggled by a table\n:id: REQ_SMUGGLED_TABLE\n```\n\n" + "```{needextend} REQ_HOST\n:status: LEAKED_BY_TABLE\n```\n:::\n::::\n", + conf=_CONF_MYST, + myst=True, + ) + ], + indirect=True, +) +def test_choose_refuses_an_opener_a_table_swallows_in_myst(test_app): + """An opener with a ``|`` and a delimiter row under it is a table to markdown-it. + + markdown-it tries its ``table`` rule before any fence, so the lines after the + would-be opener are parsed in the body: the opener is refused instead, and the + need and the ``needextend`` under it never run. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, ":::{when} var.debug | var.debug") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray(":::{when} var.debug | var.debug") in warning, warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST"] + assert needs["REQ_HOST"]["status"] == "open" + _assert_no_choose_nodes(app) + + +def _front_matter_project(front_matter: str, body: str, /) -> dict[str, object]: + """A MyST project whose root document starts with ``front_matter`` (YAML).""" + text = f"---\n{front_matter}---\n# Test\n\n{body}" + return { + "buildername": "html", + "files": [(Path("conf.py"), _CONF_MYST), (Path("index.md"), text)], + } + + +_V6D_BODY = ( + "::::{choose}\n% a comment, a paragraph here\n" + ":::{otherwise}\nSKIPPED_OTHERWISE\n:::\n::::\n\n" + "::::{choose}\n+++\n:::{otherwise}\nTAKEN_BREAK_STILL_A_COMMENT\n:::\n::::\n" +) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "stray", "taken"), + [ + ( + # the front matter replaces the global extensions: no colons + _front_matter_project( + 'myst:\n enable_extensions: ["substitution"]\n', + "`````{choose}\n:::{when} True\n" + "```{req} Smuggled by front matter\n:id: REQ_SMUGGLED\n```\n" + ":::\n`````\n", + ), + ":::{when} True", + (), + ), + ( + _project( + "::::{choose}\n```{when} True\n" + ":::{req} Smuggled by disable_syntax\n:id: REQ_SMUGGLED\n:::\n" + "```\n::::\n", + conf=_CONF_MYST + "myst_disable_syntax = ['fence']\n", + myst=True, + ), + "```{when} True", + (), + ), + ( + # the front matter disables fences, conf.py does not + _front_matter_project( + 'myst:\n disable_syntax: ["fence"]\n', + "::::{choose}\n```{when} True\n" + ":::{req} Smuggled by the front matter\n:id: REQ_SMUGGLED\n:::\n" + "```\n::::\n", + ), + "```{when} True", + (), + ), + ( + # a backtick branch in the same file is still taken + _project( + "````{choose}\n:::{when} True\n" + "```{req} Smuggled by disable_syntax\n:id: REQ_SMUGGLED\n```\n" + ":::\n````\n\n" + "````{choose}\n```{when} var.debug\nTAKEN_BACKTICK\n```\n````\n", + conf=_CONF_MYST + "myst_disable_syntax = ['colon_fence']\n", + myst=True, + ), + ":::{when} True", + ("TAKEN_BACKTICK",), + ), + ( + # the `%` line is a paragraph; the block break is still a comment + _project( + _V6D_BODY, + conf=_CONF_MYST + "myst_disable_syntax = ['myst_line_comment']\n", + myst=True, + ), + "% a comment, a paragraph here", + ("TAKEN_BREAK_STILL_A_COMMENT",), + ), + ( + _front_matter_project( + 'myst:\n disable_syntax: ["myst_line_comment"]\n', _V6D_BODY + ), + "% a comment, a paragraph here", + ("TAKEN_BREAK_STILL_A_COMMENT",), + ), + ( + # the `+++` line is a paragraph; the `%` line is still a comment + _project( + "::::{choose}\n+++\n:::{otherwise}\nSKIPPED_OTHERWISE\n:::\n::::\n\n" + "::::{choose}\n% still a comment\n" + ":::{otherwise}\nTAKEN_COMMENT_STILL_A_COMMENT\n:::\n::::\n", + conf=_CONF_MYST + "myst_disable_syntax = ['myst_block_break']\n", + myst=True, + ), + "+++", + ("TAKEN_COMMENT_STILL_A_COMMENT",), + ), + ], + ids=[ + "colon fences off in the front matter", + "fences disabled", + "fences disabled in the front matter", + "colon fences disabled", + "line comments disabled", + "line comments disabled in the front matter", + "block breaks disabled", + ], + indirect=["test_app"], +) +def test_choose_reads_the_documents_myst_config( + test_app, stray: str, taken: tuple[str, ...] +): + """A branch, a comment or a block break counts only if the document's parser has it. + + The document's configuration is the global one merged with its front matter + (whose ``enable_extensions`` replaces the global list), and ``disable_syntax`` + can switch a fence kind, line comments or block breaks off: such a line is a + stray, so a need under an opener the parser does not have, which the parser + would run, never exists; the rest of the file is read as usual. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + assert warning.startswith( + f"/index.md:{_line_of(source, stray)}: WARNING: " + ), warning + assert _stray(stray) in warning, warning + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == [] + html = Path(app.outdir, "index.html").read_text() + assert [word for word in taken if word not in html] == [] + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + + +_C1_CLOSER = ( + "::::{choose}\n:::{when} False\nSKIPPED_WHEN\n :::\n" + "```{req} Smuggled past a closer\n:id: REQ_SMUGGLED_CLOSER\n```\n" + "```{needextend} REQ_HOST\n:status: LEAKED_BY_CLOSER\n```\n:::\n::::\n\n" +) +_C1_TABLE = ( + "::::{choose}\n:::{when} var.debug | var.debug\n |---|---|\n\n" + "```{req} Smuggled past an indented delimiter row\n:id: REQ_SMUGGLED_TABLE\n```\n" + ":::\n::::\n" +) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "strays"), + [ + ( + _project( + _HOST_NEED + _C1_CLOSER + _C1_TABLE, + conf=_CONF_MYST + "myst_disable_syntax = ['code']\n", + myst=True, + ), + ( + "```{req} Smuggled past a closer", + ":::{when} var.debug | var.debug", + ), + ), + ( + _front_matter_project( + 'myst:\n disable_syntax: ["code"]\n', _HOST_NEED + _C1_CLOSER + ), + ("```{req} Smuggled past a closer",), + ), + ], + ids=["the code rule disabled", "the code rule disabled in the front matter"], + indirect=["test_app"], +) +def test_choose_without_the_code_rule_in_myst(test_app, strays: tuple[str, ...]): + """Without markdown-it's ``code`` rule, indentation bounds no construct. + + A closer indented four spaces then closes the branch, so the fence after it is + a stray of the body; a delimiter row indented four spaces makes the opener above + it a table header. Nothing under either runs: the host need keeps its status. + """ + app = test_app + app.build() + warnings = build_warnings(app) + assert len(warnings) == len(strays), warnings + source = Path(app.srcdir, "index.md").read_text() + for warning, stray in zip(warnings, strays, strict=True): + assert warning.startswith( + f"/index.md:{_line_of(source, stray)}: WARNING: " + ), warning + assert _stray(stray) in warning, warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST"] + assert needs["REQ_HOST"]["status"] == "open" + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "::::{choose}\n~~~{when} var.debug\nTAKEN_TILDE\n~~~\n::::\n\n" + "::::{choose}\n % an indented comment\n" + ":::{otherwise}\nTAKEN_INDENTED_COMMENT\n:::\n::::\n\n" + "::::{choose}\n+ + +\n:::{otherwise}\nTAKEN_SPACED_BREAK\n:::\n::::\n\n" + "::::{choose}\n +++\n:::{otherwise}\nTAKEN_INDENTED_BREAK\n:::\n::::\n", + conf=_CONF_MYST, + myst=True, + ) + ], + indirect=True, +) +def test_choose_accepts_the_myst_spellings_myst_accepts(test_app): + """A tilde branch, an indented ``%`` comment and indented or spaced block breaks.""" + app = test_app + app.build() + assert_no_warnings(app) + html = Path(app.outdir, "index.html").read_text() + taken = ["TAKEN_TILDE", "TAKEN_INDENTED_COMMENT", "TAKEN_SPACED_BREAK"] + taken.append("TAKEN_INDENTED_BREAK") + assert [word for word in taken if word not in html] == [] + _assert_no_choose_nodes(app) + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. choose::\n\n" + " .. req:: Stray need\n :id: REQ_UNGATED\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n" + ) + ], + indirect=True, +) +def test_choose_fails_closed_under_another_parser(test_app, monkeypatch): + """Under a parser the gate cannot read, the ``choose`` is refused unread.""" + monkeypatch.setattr(ChooseDirective, "_syntax", lambda self: None) + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, '.. choose::')}: WARNING: " + ), warning + assert ( + "'choose' directive is supported under reStructuredText and MyST only" + _SKIP + in warning + ), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == [] + + +def test_absolute_location(): + """A ``:`` location is reported with an absolute source. + + docutils gives an included file a path relative to the working directory + whenever the two share their first two path components (a checkout under + ``/tmp`` with its builds under ``/tmp``), which would read ``../…`` in a warning. + A node is left to Sphinx, which makes its source absolute itself. + """ + relative = os.path.join("..", "x", "branches.txt") + assert _absolute_location(f"{relative}:1") == f"{os.path.abspath(relative)}:1" + absolute = os.path.abspath("index.rst") + assert _absolute_location(f"{absolute}:7") == f"{absolute}:7" + node = nodes.paragraph() + assert _absolute_location(node) is node diff --git a/packages/sphinx-needs/tests/test_choose_gate.py b/packages/sphinx-needs/tests/test_choose_gate.py new file mode 100644 index 000000000..b0e388434 --- /dev/null +++ b/packages/sphinx-needs/tests/test_choose_gate.py @@ -0,0 +1,410 @@ +"""Unit tests for the gate a ``choose`` reads its body's top-level lines through. + +No Sphinx application: the gate is a pure function of the body's lines. +It must accept every spelling of a branch and a comment that the parser accepts, +and refuse, at its index, every other top-level line, so that nothing but branches +and comments is ever parsed in a ``choose`` body. +""" + +from __future__ import annotations + +import pytest + +from sphinx_needs.directives.needchoose import _gate, _Stray + +# reStructuredText, as docutils gives a directive its content: dedented, tabs expanded + +_RST_ACCEPTED: dict[str, list[str]] = { + "branches and blank lines": [ + ".. when:: var.arch == 'arm'", + "", + " ARM content.", + "", + ".. otherwise::", + "", + " Other content.", + ], + "an empty body": [], + "comments between branches": [ + ".. a comment", + ".. when:: var.debug", + "", + " Content.", + "", + ".. another comment", + ], + "a comment with an indented continuation": [ + ".. a comment", + " that goes on", + "", + " and on", + ".. otherwise::", + ], + "a condition continued on the next line": [ + ".. when:: var.arch ==", + " 'arm'", + "", + " Content.", + ], + "two spaces after the dots": [".. when:: var.debug", " Content."], + "names in another case": [ + ".. When:: var.debug", + " A.", + ".. OTHERWISE::", + " B.", + ], + "a space before the double colon": [".. when :: var.debug", " Content."], + "an empty comment followed by its indented text": [ + "..", + " the text of the comment", + ".. otherwise::", + ], + "an empty comment then a blank line then a branch": [ + "..", + "", + ".. when:: var.debug", + " Content.", + ], + "a comment that starts with the word when": [ + ".. when we migrate, drop this", + ".. when:: var.debug", + ], + "a branch whose content holds anything": [ + ".. when:: var.debug", + "", + " A paragraph.", + "", + " .. include:: other.rst", + "", + " Heading", + " -------", + ], +} + +_RST_STRAYS: dict[str, tuple[list[str], _Stray]] = { + "a paragraph": ( + [".. when:: var.debug", " Content.", "", "A stray paragraph."], + _Stray(3, None), + ), + "a target": ([".. _label:", ".. otherwise::"], _Stray(0, None)), + "a substitution definition": ( + [".. |sub| image:: picture.png", ".. otherwise::"], + _Stray(0, None), + ), + "a footnote": ([".. [1] A footnote.", ".. otherwise::"], _Stray(0, None)), + "a citation": ([".. [CIT2002] A citation.", ".. otherwise::"], _Stray(0, None)), + "an include": ( + [".. when:: var.debug", " Content.", ".. include:: other.rst"], + _Stray(2, None), + ), + "a false if": ([".. if:: False", "", " .. when:: True"], _Stray(0, None)), + "a note": ([".. note::", "", " .. when:: True"], _Stray(0, None)), + "default-role": ([".. default-role:: math", ".. otherwise::"], _Stray(0, None)), + "a misspelt branch": ([".. wehn:: True", " Content."], _Stray(0, None)), + "a heading": (["Title", "-----", "", ".. when:: True"], _Stray(0, None)), + "a transition": ([".. when:: True", " A.", "", "---"], _Stray(3, None)), + "three dots": (["...", ".. otherwise::"], _Stray(0, None)), + "an anonymous target": ( + ["__ https://example.com", ".. otherwise::"], + _Stray(0, None), + ), + "an indented first line": ( + [" A block quote.", ".. when:: True"], + _Stray(0, None), + ), + "a block quote after an empty comment": ( + ["..", "", " .. req:: A need", ".. otherwise::"], + _Stray(2, None), + ), + "the end-of-inclusion comment": ( + ['.. end of inclusion from "other.rst"', ".. otherwise::"], + _Stray(0, None), + ), + "when with one colon": ([".. when: var.debug", " Content."], _Stray(0, "when")), + "When with one colon": ([".. When: var.debug", " Content."], _Stray(0, "when")), + "when, a space, one colon": ([".. when : var.debug"], _Stray(0, "when")), + # docutils allows one space before `::`; with two the line is a comment + "when with two spaces before the double colon": ( + [".. when :: var.debug", " A."], + _Stray(0, "when"), + ), + "when without the space after the double colon": ( + [".. when::var.debug", " Content."], + _Stray(0, "when"), + ), + "otherwise with one colon": ( + [".. when:: var.debug", " A.", ".. otherwise:", " B."], + _Stray(2, "otherwise"), + ), + "an empty comment whose text is a one-colon branch": ( + ["..", " when: var.debug", ".. otherwise::"], + _Stray(0, "when"), + ), +} + +# MyST, as myst-parser gives a directive its content + +_MYST_ACCEPTED: dict[str, list[str]] = { + "colon branches": [ + ":::{when} var.arch == 'arm'", + "ARM content.", + ":::", + ":::{otherwise}", + "Other content.", + ":::", + ], + "backtick branches": [ + "```{when} var.debug", + "Content.", + "```", + "```{otherwise}", + "```", + ], + "a space before the name": ["::: {when} var.debug", "Content.", ":::"], + "a name in another case": [":::{When} var.debug", "Content.", ":::"], + "an opener indented three spaces": [" :::{when} var.debug", "Content.", ":::"], + "a closer indented three spaces": [ + ":::{when} var.debug", + "Content.", + " :::", + ":::{otherwise}", + "Other.", + ":::", + ], + "a closer followed by spaces and tabs": ["```{when} var.debug", "A.", "``` \t"], + "a longer closer": [":::{when} var.debug", "A.", "::::::"], + "a shorter fence nested in a branch": [ + "::::{when} var.debug", + ":::{note}", + "A note.", + ":::", + "::::", + ], + "a longer fence opened in a branch does not close it": [ + ":::{when} var.debug", + "::::{note} A note.", + "Content.", + ":::", + ], + "comments and block breaks": [ + "% a comment", + "+++", + "+++ a block break with text", + "++++", + ":::{when} var.debug", + ":::", + "% when we migrate, drop this", + ], + "blank lines of spaces and tabs": ["", " ", "\t", ":::{otherwise}", ":::"], + "a tilde branch": ["~~~{when} var.debug", "Content.", "~~~"], + "a backtick in a tilde fence's info": ["~~~{when} `var.debug`", "A.", "~~~"], + "an indented comment": [" % a comment", ":::{otherwise}", ":::"], + "an indented block break": [" +++", ":::{otherwise}", ":::"], + "a spaced block break": ["+ + +", "+ +\t+ text", ":::{otherwise}", ":::"], + "a closer indented four spaces does not close": [ + ":::{when} var.debug", + " :::", + "still the branch's", + ":::", + ], + # markdown-it needs a `|` in the header line for a table + "a delimiter row under an opener without a bar": [ + ":::{when} var.debug", + "|---|---|", + ":::", + ], + "an opener with a bar but no delimiter row after it": [ + ":::{when} var.a | var.b", + "", + "|---|---|", + ":::", + ], + "an unclosed branch runs to the end": [ + ":::{when} var.debug", + "Content.", + "```{include} other.md", + ], +} + +_MYST_STRAYS: dict[str, tuple[list[str], _Stray]] = { + "a paragraph": ([":::{when} var.debug", ":::", "", "A stray."], _Stray(3, None)), + "a heading": (["# Heading", ":::{otherwise}", ":::"], _Stray(0, None)), + "an html comment": ( + ["", ":::{otherwise}", ":::"], + _Stray(0, None), + ), + "eval-rst": (["```{eval-rst}", ".. when:: True", "```"], _Stray(0, None)), + "an include": (["```{include} other.md", "```"], _Stray(0, None)), + # markdown-it tries its `table` rule before any fence: a table header, no branch + "an opener a table swallows": ( + [ + ":::{when} var.debug | var.debug", + "|---|---|", + "", + "```{req} A need", + "```", + ":::", + ], + _Stray(0, None), + ), + "an opener a table with aligned cells swallows": ( + ["```{when} var.a | var.b", " :--- | ---: ", "```"], + _Stray(0, None), + ), + "no space after the name": ([":::{when}var.debug", "A.", ":::"], _Stray(0, None)), + "a backtick in a backtick fence's info": ( + ["```{when} `var.debug`", "A.", "```"], + _Stray(0, None), + ), + "an opener indented four spaces": ( + [" :::{when} var.debug", "A."], + _Stray(0, None), + ), + "an include after a closer indented three spaces": ( + [":::{when} var.debug", "Content.", " :::", "```{include} other.md", "```"], + _Stray(3, None), + ), + "a stray after a nested shorter fence's closer": ( + ["::::{when} var.debug", ":::{note}", "A.", ":::", "::::", "A stray."], + _Stray(5, None), + ), + "a closer with text after it closes nothing": ( + [":::{when} var.debug", "A.", "::: x", "```{include} other.md"], + None, + ), + "two pluses": (["++", ":::{otherwise}", ":::"], _Stray(0, None)), + "a list item": (["+ an item", ":::{otherwise}", ":::"], _Stray(0, None)), + "a comment indented four spaces": ( + [" % code", ":::{otherwise}", ":::"], + _Stray(0, None), + ), + "when with one colon": ( + ["% when: var.debug", ":::{otherwise}", ":::"], + _Stray(0, "when"), + ), + "When, a space, one colon": (["% When : var.debug"], _Stray(0, "when")), + "otherwise with one colon in a block break": ( + [":::{when} var.debug", ":::", "+++ otherwise:"], + _Stray(2, "otherwise"), + ), + "when with one colon in an indented comment": ([" % when: x"], _Stray(0, "when")), + "otherwise with one colon in a spaced block break": ( + ["+ + + otherwise:"], + _Stray(0, "otherwise"), + ), +} + + +@pytest.mark.parametrize("lines", list(_RST_ACCEPTED.values()), ids=list(_RST_ACCEPTED)) +def test_rst_gate_accepts(lines: list[str]): + """Every spelling of a branch and a comment that docutils accepts passes.""" + assert _gate(lines, syntax="rst") is None + + +@pytest.mark.parametrize( + ("lines", "stray"), list(_RST_STRAYS.values()), ids=list(_RST_STRAYS) +) +def test_rst_gate_refuses(lines: list[str], stray: _Stray): + """Every other top-level line is refused, at its index.""" + assert _gate(lines, syntax="rst") == stray + + +@pytest.mark.parametrize( + "lines", list(_MYST_ACCEPTED.values()), ids=list(_MYST_ACCEPTED) +) +def test_myst_gate_accepts(lines: list[str]): + """Every spelling of a branch fence, a comment and a block break MyST accepts passes.""" + assert _gate(lines, syntax="myst") is None + + +@pytest.mark.parametrize( + ("lines", "stray"), list(_MYST_STRAYS.values()), ids=list(_MYST_STRAYS) +) +def test_myst_gate_refuses(lines: list[str], stray: _Stray | None): + """Every other top-level line is refused, at its index. + + The gate never thinks it is inside a branch where MyST is not: a line that only + looks like a closer, with text after it, closes nothing (so what follows is + still the branch's, as it is MyST's). + """ + assert _gate(lines, syntax="myst") == stray + + +def test_myst_gate_without_colon_fence(): + """Without the ``colon_fence`` extension a ``:::`` line opens nothing: a stray.""" + lines = [":::{when} var.debug", "Content.", ":::"] + assert _gate(lines, syntax="myst", colon_fence=False) == _Stray(0, None) + assert ( + _gate(["```{when} var.debug", "```"], syntax="myst", colon_fence=False) is None + ) + + +def test_myst_gate_without_fence(): + """With ``fence`` disabled, a backtick or tilde line opens nothing: a stray.""" + for marker in ("```", "~~~"): + lines = [f"{marker}{{when}} var.debug", "Content.", marker] + assert _gate(lines, syntax="myst", fence=False) == _Stray(0, None) + assert _gate([":::{when} var.debug", ":::"], syntax="myst", fence=False) is None + + +def test_myst_gate_without_comments_or_block_breaks(): + """With ``myst_line_comment`` or ``myst_block_break`` disabled, the line is text.""" + assert _gate(["% a comment"], syntax="myst", comment=False) == _Stray(0, None) + assert _gate(["+++"], syntax="myst", block_break=False) == _Stray(0, None) + assert _gate(["% a comment", "+++"], syntax="myst") is None + + +# With markdown-it's `code` rule disabled no line is an indented code block, so +# indentation bounds none of the constructs the gate mirrors: (lines, the gate's +# answer without the rule, its answer with it, the default) +_WITHOUT_CODE: dict[str, tuple[list[str], _Stray | None, _Stray | None]] = { + "a closer indented four spaces closes": ( + [":::{when} var.debug", "A.", " :::", "A stray."], + _Stray(3, None), + None, + ), + "a closer led by a tab closes": ( + [":::{when} var.debug", "A.", "\t:::", "A stray."], + _Stray(3, None), + None, + ), + "an opener indented four spaces opens": ( + [" :::{when} var.debug", "A.", ":::"], + None, + _Stray(0, None), + ), + "a comment indented four spaces": ( + [" % a comment", ":::{otherwise}", ":::"], + None, + _Stray(0, None), + ), + "a block break indented four spaces": ( + [" +++", ":::{otherwise}", ":::"], + None, + _Stray(0, None), + ), + "an opener over a delimiter row indented four spaces": ( + [":::{when} var.a | var.b", " |---|---|", ":::"], + _Stray(0, None), + None, + ), +} + + +@pytest.mark.parametrize( + ("lines", "without_code", "with_code"), + list(_WITHOUT_CODE.values()), + ids=list(_WITHOUT_CODE), +) +def test_myst_gate_without_code( + lines: list[str], without_code: _Stray | None, with_code: _Stray | None +): + """Without the ``code`` rule, a construct indented four columns or more is live. + + markdown-it-py's ``is_code_block`` is false for every line once the ``code`` rule + is disabled, so a fence, a closer, a delimiter row, a ``%`` comment or a ``+++`` + block break counts at any indentation; with the rule (the default) the same body + gives the gate's usual answer. + """ + assert _gate(lines, syntax="myst", code=False) == without_code + assert _gate(lines, syntax="myst") == with_code