Skip to content

✨ list2need: build the needs directly, without generating reStructuredText - #1790

Merged
chrisjsewell merged 13 commits into
masterfrom
list2need-direct-construction
Aug 26, 2026
Merged

✨ list2need: build the needs directly, without generating reStructuredText#1790
chrisjsewell merged 13 commits into
masterfrom
list2need-direct-construction

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Aug 26, 2026

Copy link
Copy Markdown
Member

list2need renders every item into a need directive through a template and hands the
result back to the parser with state_machine.insert_input. This replaces that step:
the items are built with add_need, and a nested item is placed inside its parent.

The syntax and its parsing are untouched. The same four regular expressions, the
same level→type mapping, the same (ID) capture, the same :delimiter: split, the same
((option="value")) extraction, the same :tags: merge, the same :links-down: walk,
the same validation and the same eleven inputs that end the build. Only the generation
mechanism changes.

This is offered as the answer to the question in #1427"can we save the syntax
(mostly) but have a better implementation (without jinja)?"

What the step cost

Markdown. myst-parser's mock state machine does not implement insert_input, so a
{list2need} fence produced no needs at all — only an error naming a myst-parser
internal. It now works:

before after
needs created none MD-A, MD-B
nesting MD-B.parent_need == "MD-A"
diagnostics MockingError: MockStateMachine has not yet implemented attribute 'insert_input' none

Line numbers (#1349, the list2need half). insert_input advances the parser's flat
line counter by the length of the generated text, for the rest of the file, and the error
compounds with each list. For a document with two items on lines 10 and 11 and an
ordinary .. req:: on line 13:

need written on lineno before after
LN-BEFORE 4 4 4
LN-A 10 14 10
LN-B 11 20 11
LN-AFTER (an ordinary .. req::) 13 27 13

In the repository's own doc_list2need fixture, NEED-Z was recorded at line 45 of a
19-line file. The general case — a lineno shifted by rst_prolog or an
.. include:: — is a separate small PR, #1789.

Template artefacts. An item written () with options lost them: suppressing the
:id: left a blank line where it would have been, which ended the generated need's
option block, so every option after it became body text. And a parent need's stored
content was the generated reStructuredText of its children rather than its own text,
which needs.json and any filter reading content saw.

The one deliberate change to an input that worked

Writing () used to suppress the :id: of the generated need, which sent it down the
need directives' own id generator — a different function, which honours
needs_id_from_title where list2need's does not, hashes the content when the title
is empty, and hashes the title after the ((option="value")) area has been removed
from it. One list could carry two id schemes at once, chosen by two characters of
punctuation. The directive now derives every id itself, from the same input at the same
point, so () means what no brackets at all means.

Three kinds of item therefore get a different generated id:

item before after
* ()Alpha title with needs_id_from_title = True R_ALPHA R_D1EC6, the plain hash
* (). some content here (no title) R_EBC29, the hash of the content R_DA39A, the hash of the empty title
* ()Title with empty parens ((status="open")) R_6AFF7 R_D7997 — the id * Title with empty parens ((status="open")) already had

The third needs no configuration to reach and is the one to watch for; the changelog
says so under Breaking changes, and the documentation carries a versionchanged.
Everything else keeps the id it had, which the untouched id tests prove.

Four inputs that were errors, and are now defined

None of these can appear in a document that builds cleanly today, so nothing that
works can regress; they are listed because each is a decision the template used to
delegate to docutils and the directive now has to make.

  • ((id="MY-ID")) names the need. The template wrote the derived id and the
    inline one as two :id: lines of one generated need, which docutils refused with
    duplicate option "id", dropping the item. The id is applied before :links-down:
    is built, so the other items' links agree with it. An empty ((id="")) is refused
    with a diagnostic rather than quietly replaced by the title hash.
  • An inline option naming the same link field as :links-down:((links="X"))
    under :links-down: links — produced one corrupt link value (the template's
    {%- for %} glued the two field lines into :links: C-2:links: X) and an
    unknown outgoing link warning. The two sets are now merged.
  • ((title_from_content="true")) is read as a need directive reads it.
  • A child of a ((hide="true")) item is rendered at the level above it. A hidden
    need is taken out of the document once read, so nesting a child inside one would put
    its target where no page can reach it.

Evidence

  • The rendered HTML of tests/doc_test/doc_list2need/ is byte-identical before and
    after, all four pages, apart from the per-build random SNCB-… container ids. Its
    needs.json keeps all 16 ids, every title, status, tag, link and parent_need; only
    the linenos and the two parent content strings change.
  • The characterization suite added in 🧪 Pin list2need behaviour and correct its documentation #1788 is the safety net. The eleven build-aborting
    inputs and the links-down level-skip test pass untouched — this PR takes neither
    of those on. Four assertions are edited, each one pinning something the template
    did: the drifted linenos, the MyST failure (which inverts), the parent's
    generated-text content, and the second id function. A fifth prediction went the other
    way (below).
  • Two tests gain an assertion: the Markdown ones now pin the items' line numbers.
    Linenos inside an {eval-rst} fence were wrong too — that block was the only way to
    reach the directive from a Markdown document, and its needs were recorded at 10 and
    16 for items written on 7 and 8.
  • tests/__snapshots__/test_list2need.ambr is rewritten: every lineno in it was wrong.
  • Six tests are added: the ()-plus-options id, a directive written in an item's
    content, and one for each of the four inputs above.

One assertion moved the other way. Removing the three-space indent the parser prepends to
a :-continuation line looked like removing a workaround for the template — it is not:
it is the only thing standing in for the indentation lstrip removes, and without it the
options of a directive written in an item's content (the rst-directives in lists
example in the documentation) land in the directive's own column and are dropped. It is
kept, and now has a test.

Refs #1427, Refs #1349.

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.38095% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.03%. Comparing base (4e10030) to head (580c072).
⚠️ Report is 334 commits behind head on master.

Files with missing lines Patch % Lines
sphinx_needs/directives/list2need.py 92.38% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1790      +/-   ##
==========================================
+ Coverage   86.87%   91.03%   +4.15%     
==========================================
  Files          56       77      +21     
  Lines        6532    11678    +5146     
==========================================
+ Hits         5675    10631    +4956     
- Misses        857     1047     +190     
Flag Coverage Δ
pytests 91.03% <92.38%> (+4.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…dText

The directive rendered every item into a need directive through a template and
handed the result back to the parser with ``state_machine.insert_input``. That
one call is the reason a list in a MyST document produced nothing -- myst-parser's
mock state machine does not implement it -- and the reason every need from the
directive onwards was recorded at the wrong line: ``insert_input`` advances the
parser's flat line counter by the length of the generated text, for the rest of
the file (#1349).

The items are now built with ``add_need``, and a nested item's nodes are placed
inside its parent's. The syntax and its parsing are untouched: the same four
regular expressions, the same level to type mapping, the same delimiter split,
the same ``((option="value"))`` extraction and the same validation.

Beyond losing those artefacts, one behaviour changes deliberately. Writing ``()``
used to suppress the ``:id:`` of the generated need, which sent it down a second
id function -- one that honours ``needs_id_from_title``, where list2need's own
does not -- so a list could carry two id schemes at once, selected by two
characters of punctuation. The directive now derives every id itself, and ``()``
means exactly what no brackets at all means.
A continuation line is stripped of the indentation it was written with, so the
options of a directive written in an item's content would land in the
directive's own column -- which docutils reads as a directive followed by an
unindented field list, reporting "Explicit markup ends without a blank line"
and dropping the options. The three spaces the parser prepends to a line
starting with ":" are what put them back underneath it, which is what makes the
"rst-directives in lists" example in the documentation work.

The indent looked like damage control for the generated text the directive no
longer produces, and had no test. It has one now.
The "Markdown (MyST)" section said the directive works in reStructuredText
documents only; it now shows the fence, says what the host parses and what
list2need parses, and keeps the eval-rst block as the other way to write it.

"Generated IDs" gains a versionchanged for the one behaviour change: a line
whose bracketed group is empty now takes its ID from the formula documented
there, like a line with no brackets at all.

The known-limitations warning names what a content line starting with "*" or
":" actually does, and the "presentation" option no longer describes nesting
as a property of the parent's content.
The changelog listed the three-space indent of a ":" content line among the
things the template step cost, and the directive's known-limitations warning
said such a line is rendered as a field list. Neither is right: the indent is
kept, and what it is for is stated instead.
Building the needs directly means the directive decides four things the
generated need directive used to decide, and each was decided wrongly:

- A ``hide``\den item is taken out of the document once it has been read, so
  a child nested inside one was created, recorded in needs.json, and then
  rendered nowhere -- every ``:need:`` reference to it pointed at an anchor
  that never reached the page. A hidden item is no longer opened as a parent.
- ``add_doc`` registered the document whenever the list parsed, even when
  every one of its items was refused and the document ended up with no needs.
- ``title_from_content`` was not among the options an item may carry, so an
  item with no title of its own did not take the first sentence of its
  content the way a need directive does. The title is now decided by the
  need directives' own ``_get_title``, which also restores the "No title
  given" warning for an item whose title is only whitespace.
- ``((id=""))`` was silently ignored and the title hash used instead. The
  empty value is now handed on, and refused with a diagnostic like any other
  invalid id.

Each is pinned by a test that fails without its fix.
The migration text named two kinds of line that get a different generated ID
and missed the widest of the three: one written "()" that also carries an
((option="value")) area. That one needs no configuration to reach, because the
ID list2need derives is the hash of the title before the option area is
removed from it, where the generator it used to fall through to hashed the
title after. Named in the changelog and in the versionchanged, with the
measured pair.

The changelog also now lists the four inputs that used to be errors and are
now defined -- an inline ((id=...)) among them -- and the source-mapping
docstring says that its question is whether the content is source-mapped, not
whether it is reStructuredText.
Three of the options in CORE_OPTIONS never reach the set it is matched
against, because a need directive reads them as booleans and so does this
directive. The set stays a mirror of what a need accepts; the comment now says
which members are taken out earlier.
test_need_lineno pinned them at their offsets inside the block list2need used
to generate -- 1 and 7 -- with a note saying that only building the needs
directly would fix it. It does: the two items are written on lines 15 and 16
of that fixture, and that is what they now record, with an rst_prolog and an
.. include:: above them.

They are the strongest of the five assertions in that test, because they are
the only ones not resolved back through get_source_info(): the directive gives
each need the line of its own item, from the source mapping of its content.
A need carries two numbers that are not source lines: lineno_content, the
offset its content is parsed at, and parser_lineno, the offset a rendered
pre/post template is parsed at. Both count in the parser's own line space, and
this directive was giving them as resolved source lines, so a document with an
rst_prolog or an .. include:: above the list reported a warning inside an
item's content, or inside its template, several lines away from where it is
written -- by the amount of text inserted above.

Measured on a list item carrying a pre_template and a bad role in its content:
without a prolog both errors point at the right lines; with one they moved to
5 and 6. They now point at the same lines either way.

The lineno a need records is unaffected: that is a source line, and stays the
line of the item.
@chrisjsewell
chrisjsewell force-pushed the list2need-direct-construction branch from 061df40 to e495ac9 Compare August 26, 2026 09:43
A need carries two numbers that are not source lines -- lineno_content, the
offset its content is parsed at, and parser_lineno, the offset a rendered
pre/post template is parsed at. Both are offsets into the parser's own
counter, and nothing pinned that list2need gives them in that space: with the
directive handing over resolved source lines instead, every list2need test
still passed.

The case that catches it needs a document whose counter has been shifted, so
this builds the same list twice, with and without a two-line rst_prolog, and
asserts both warnings land on the same lines either way -- the content role on
line 8, where it is written, and the template role on line 9. Without the
prolog the two spaces coincide and the assertion holds regardless, which is
why one project alone was never going to pin it.

@ubmarco ubmarco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chrisjsewell
chrisjsewell merged commit 6a6ffa5 into master Aug 26, 2026
25 checks passed
@chrisjsewell
chrisjsewell deleted the list2need-direct-construction branch August 26, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants