Skip to content

✨ choose / when / otherwise directives: one of several content branches by variant data #2011

Description

@chrisjsewell

What

A choose directive whose content holds only when directives, an optional final otherwise, and comments. The when conditions are evaluated in order against the variant data, the first true one wins, and otherwise is the default. Only the winning branch's content is parsed, so needs inside the other branches are never created, exactly as in a falsy if today.

.. choose::

   .. when:: var.arch == "arm"

      ARM content.

   .. when:: var.arch == "x86"

      x86 content.

   .. otherwise::

      Content for every other architecture.
::::{choose}
:::{when} var.arch == "arm"
ARM content.
:::
:::{otherwise}
The default.
:::
::::

Implemented in #2020.

Renamed on 2026-10-01 from the match / case first proposed here, following the comment below: match and case take a subject in Python and Rust, and this construct has none. Every when holds a condition, and the default is an explicit otherwise, so a forgotten condition is reported instead of silently becoming the catch-all. match / case stay free for a subject-based form later.

Why a container rather than elif / else siblings

For authors, a choose reads the way a choice is thought about: every alternative for one piece of content stands in one block, in order, with the fallback visibly last. What is inside the block is the whole story. A paragraph or a comment between two alternatives, or an include boundary, cannot detach a branch or turn a fallback into an orphan, and a branch written inside a wrapper directive that passes its content through is refused with a warning, because every when belongs to its choose and to nothing else. Mistakes are reported once, where they are made, and a mistake that makes a condition unevaluable never shows the wrong variant: it skips the whole choice rather than falling through to the default, and a when that lost its condition is reported rather than silently becoming the catch-all. A branch can hold anything a document can (sections, needs, includes, another choose), and the branches not taken are never parsed, so no stray need or ID from another variant reaches the build. The same block builds the same way in reStructuredText, in MyST and in ubCode (what still differs is registered in ubCode's divergence register), and in ubCode the editor fades the branches that do not apply to the variant being built, so an author always sees which content is live. if is unchanged, so existing documents keep working as they are.

This is also why a container is sound where sibling elif / else are not. A docutils directive cannot see its source siblings, so sibling branches need the previous branch to leave a message behind. Every mechanism the sibling design in #1999 needs (an invisible marker node returned by every branch including every plain if, a backward scan of the parent's children, a MyST fallback through state.inliner.parent, a stripping transform, a second strip inside add_need because the need-node cache is filled before any transform, a comments-only-between-branches rule, chains crossing .. include::) is a consequence of that one fact. A container owns its branches, sees them all at once, decides once, and nothing escapes: all of it disappears by construction, and if stays untouched.

Decision (2026-10-01): implement the container instead of elif / else. The test cases of #1999 that have a counterpart in a container design are salvaged, as is its fix for the undocumented non-bool warning in docs/directives/if.rst.

Design: the deferred-content branch

  • when and otherwise do not parse their content. Each returns a transient placeholder carrying its StringList, content_offset, the condition (None for otherwise), its kind and its location. Both declare an optional argument: when so that a missing condition is reported by the choose with one warning (a required argument would make docutils refuse the directive with an error of its own), otherwise so that a condition on it can be refused.
  • choose (an optional argument declared only so that it can be refused: without a declared argument, docutils and MyST both move first-line text into the content, which would then be reported only as a stray paragraph) first reads its body's top-level lines and refuses the body, with one warning at the line, at the first that is neither a branch start, a comment nor blank (nothing is parsed then; in reStructuredText docutils' own explicit-markup constructs decide, at column 0; in MyST a branch is a {when} / {otherwise} fence closed by CommonMark's rule, and only % comments, +++ and blank lines stand between branches), then nested-parses its own body into a detached container that is never returned, validates the children (placeholders and nodes.comment only), checks that every when has a condition, that otherwise has none, is last and occurs at most once, evaluates the conditions in order with exactly if's evaluator extracted into one shared function, and parses only the winner with nested_parse_with_titles at the winner's offset (re-based under MyST, whose content_offset is relative to the directive line).
  • A depth counter in env.temp_data, raised around the body parse only, detects a when or otherwise outside any choose, including one written loose inside a branch body.
  • Nothing outside a branch is ever parsed, so there is nothing to roll back: a need, a label, a needextend, an included file or another extension's directive written in the body is refused before it runs. The gate may refuse more than the parser would accept (a refused line is never parsed); it never passes a line the parser would execute as anything else.
  • No transform, no add_node, no change to api/need.py.

A prototype of the container design, under its first names (284 lines), was built against master and passes 8 happy-path probes and 31 error-path probes in RST and MyST, -W clean, on Sphinx 9.1 / myst-parser 5.1 and on the sphinx-7 cell (Sphinx 7.4.7 / myst-parser 4.0.1), including a choose inside a need that needextract renders on another page.

Semantics (the contract ubCode implements too)

situation behaviour
several when true the first wins; later conditions are not evaluated, so they cannot warn
none true, otherwise present otherwise is taken
none true, no otherwise nothing rendered, no warning
when without a condition warn once at the when; the whole choose renders nothing
otherwise given a condition warn once at the otherwise; the whole choose renders nothing
otherwise not last, or two of them warn once at the offending otherwise; the whole choose renders nothing
content in the body that is neither a branch nor a comment (a line of punctuation such as --- included) warn once at its line, before the body is parsed; whole choose skipped; nothing in it runs (no need, label or included file is made and undone)
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 MyST % when: comment or +++ when: block break likewise) warn once at the comment; whole choose skipped (a comment that merely starts with the word is still a comment)
a branch inside another directive in the body (even a true if), or supplied through an include the wrapping directive or the .. include:: is itself content that is neither a branch nor a comment: warn once at its line, in the host; whole choose skipped; the included file is never read (branches are written in place; a whole choose inside an included file is fine). A directive that produces no node (a FALSE if, default-role) and a MyST {{ sub }} are refused the same way, as ubCode refuses any non-branch child
when or otherwise outside any choose warn once; its content skipped
a condition that cannot be evaluated (before the winner) warn once; the whole choose is skipped, otherwise included (a mistake that makes a condition unevaluable never renders the fallback in its place)
a non-bool result warn once, coerce and use (as if)
choose given an argument warn once; whole choose skipped
empty choose, or comments only warn once
variant data not configured warn once at the choose; whole choose skipped, even when only an otherwise exists; the structure is checked first
headings inside the winner real sections
needs and errors inside a losing branch never created, never reported
comments accepted RST .. comments; MyST % comments and +++ block breaks; an HTML <!-- --> is a raw node and is rejected
a sphinx-design tab-item directly inside the winner warns "parent should be a tab-set", exactly as inside a true if (the winner is parsed into a detached container); documented

Warnings go under a new subtype, needs.choose. Conditions are those of if, through the same function, so choose adds no new expression language.

Deliverables

  • src/sphinx_needs/directives/needchoose.py; the evaluator moved out of IfDirective.run into a shared function (messages byte-identical); registration next to if; "choose" in WarningSubTypes and its description.
  • docs/directives/choose.rst (+ toctree, a pointer from if.rst), including the MyST rule that an outer fence must be longer than its inner fences.
  • tests/test_choose_directive.py with a doc project and inline projects covering every row above, the MyST twin, needextract, and one expression table run through both if and when asserting the same verdicts; tests/test_choose_gate.py, a pure table of the gate in both syntaxes.
  • A changelog entry.

ubCode implements the same contract (useblocks/ubcode#3763, PR useblocks/ubcode#3783), with the editor fading each losing branch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementpkg: sphinx-needsThe sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions