Skip to content

🐛 A need records the line it is actually written on - #1789

Merged
chrisjsewell merged 4 commits into
masterfrom
need-lineno-source-info
Aug 26, 2026
Merged

🐛 A need records the line it is actually written on#1789
chrisjsewell merged 4 commits into
masterfrom
need-lineno-source-info

Conversation

@chrisjsewell

Copy link
Copy Markdown
Member

The change

NeedDirective stored self.lineno, which counts the lines of what the parser was
handed rather than the lines of the file. Three perfectly ordinary things put text
into a document and shift that count for everything after: Sphinx prepends
rst_prolog to every document it parses, and both docutils' .. include:: and our
own .. list2need:: splice text in mid-parse with StateMachine.insert_input. The
shifts compound, so the recorded line routinely landed past the end of the file.

The line now comes from SphinxDirective.get_source_info(), which is exactly
state_machine.get_source_and_line(self.lineno) — the accessor get_location(), and
so every warning the directive itself emits, is already built on. The stored
lineno now agrees with the line our own warnings have been printing all along.
On
a document with an rst_prolog and a list2need, for a need written on
index.rst:9:

before after
what the warning printed index.rst:9 index.rst:9
what needs.json recorded 19 9

The accessor returns (None, None) when the state machine cannot map the line, in
which case the old value is kept.

No warning location regresses. The only warning locations that move are the ones
this corrects — the sites that report at a need's stored (docname, lineno) now name
the true line. Everything anchored through the directive's own location helper is
byte-identical to before. The one place that had to be taught the
difference is the rendered content of :pre_template:/:post_template:. That content
exists in no file, so it is anchored at the need's own directive, and nested_parse
resolves a nested parse through the enclosing state machine — meaning the anchor has
to be given in the parser's line space, not in the resolved one a need now records.
NeedItemSourceDirective therefore also keeps the unresolved number (parser_lineno,
internal — not in needs.json), and _create_need_node uses it for those two offsets.
tests/test_proper_warning.py pins these locations already; its values are unchanged,
and a second test now builds the same fixture with an rst_prolog in front of it
and asserts the same five locations, which without this would report 7 and 4 where
the file says 9 and 6.

What that looks like across the three causes

A 19-line document with all three, from the new test:

need written on before after
after rst_prolog only index.rst:4 7 4
after an .. include:: index.rst:9 24 9
after a .. list2need:: index.rst:18 47 18

Deliberately not changed

  • docname stays self.env.docname. Better lineno in needs JSON #1692 also derived it from the resolved
    source path; measured, that turns docname into a filesystem path for any need
    inside an .. include::d file and silently breaks the :need: hyperlinks pointing
    at it. Changing docname changes link resolution, which is a separate question
    from getting the line right.
  • lineno_content stays self.content_offset + 1. It is not read as a source
    line: _create_need_node hands it to nested_parse as an offset into the parser's
    own line space, which is what lets a need nested inside another need's content report
    sensible lines. Making the two agree wants a new field rather than a change to this
    one; noted as a follow-up.
  • Needs that list2need generates still record their position inside the
    generated block rather than the line of the list item that produced them — that is
    where their warnings already point, so it is no worse than before, and it is only
    properly fixed by building those needs directly instead of round-tripping them
    through the parser.
  • One honest trade. For a need written inside an .. include::d file, inside a
    list2need block, or in a Python docstring, the number now indexes a different
    file from the one docname names, and it looks like an ordinary line of that
    document — where the old value announced itself as wrong by sitting past the end of
    the file. Both are wrong; the new one is wrong plausibly. It is deliberate: the new
    number is the one that need's own warnings already print, and the real fix is for a
    need to carry a source path of its own rather than for docname to change meaning.
  • Markdown is a no-op. myst-parser's MockStateMachine.get_source_and_line
    returns the line it is given, so .md needs are byte-identical before and after.
    There is a test pinning that.

Tests

  • tests/test_need_lineno.py (new) — one project carrying all three shift sources,
    pinning the true line for every ordinary need directive, plus the Markdown no-op.
  • tests/test_list2need_behaviour.py — the lineno characterization test added in
    🧪 Pin list2need behaviour and correct its documentation #1788 now records the correct line for the need after the list, and keeps the
    generated needs' offsets pinned as the remaining residue.
  • tests/__snapshots__/test_list2need.ambr — 16 generated-need linenos, each checked
    against the generated text.
  • tests/test_proper_warning.py — its existing pins are untouched; a new case rebuilds
    the same fixture behind an rst_prolog and asserts the same warning locations.

Closes #1349.

Credit where it is due: #1692 (sebastiansetzer) identified this fix and its
direction. This PR carries its need.py line-number piece on its own.

``NeedDirective`` stored ``self.lineno``, which counts the lines of what the
parser was handed rather than the lines of the file. Anything that puts text
into a document shifts it for everything after -- Sphinx's ``rst_prolog``, and
the ``insert_input`` of ``.. include::`` and ``.. list2need::`` -- the shifts
compound, and the recorded line routinely landed past the end of the file.

Take the line from ``SphinxDirective.get_source_info()`` instead, which resolves
the counter back through the state machine. It is the accessor ``get_location()``
-- and so every warning this extension emits -- is already built on, so a need's
recorded ``lineno`` and the line its warnings print are now the same number.

``docname`` and ``lineno_content`` are deliberately untouched.

Closes #1349
A rendered ``pre_template``/``post_template`` exists in no file, so a warning
raised inside it is anchored at the need's own directive. ``nested_parse``
resolves a nested parse's locations through the *enclosing* state machine
(``abs_line_number()`` = ``line_offset + input_offset + 1``), so that anchor has
to be given in the parser's line space -- the same space ``lineno_content`` is
kept in, and not the resolved source line a need now records.

Keep the unresolved number on ``NeedItemSourceDirective`` as ``parser_lineno``
(internal: not part of ``dict_repr``, so it never reaches ``needs.json``) and
use it for those two offsets. Every other source kind still holds the parser's
number in ``lineno`` itself, so the previous expression stays right for them and
``NeedItemSourceProtocol`` is unchanged.

``test_proper_warning`` already pins these locations; its values do not move.
A new case builds the same fixture behind an ``rst_prolog`` and asserts the same
five locations -- without this it reports 7 and 4 where the file says 9 and 6.
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.05%. Comparing base (4e10030) to head (b0216eb).
⚠️ Report is 333 commits behind head on master.

Files with missing lines Patch % Lines
sphinx_needs/api/need.py 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1789      +/-   ##
==========================================
+ Coverage   86.87%   91.05%   +4.17%     
==========================================
  Files          56       77      +21     
  Lines        6532    11593    +5061     
==========================================
+ Hits         5675    10556    +4881     
- Misses        857     1037     +180     
Flag Coverage Δ
pytests 91.05% <88.88%> (+4.17%) ⬆️

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.

@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.

Nice catch

@chrisjsewell
chrisjsewell merged commit eebe3b1 into master Aug 26, 2026
25 checks passed
@chrisjsewell
chrisjsewell deleted the need-lineno-source-info branch August 26, 2026 09:31
chrisjsewell added a commit that referenced this pull request Aug 26, 2026
…dText (#1790)

`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 #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.
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.

Lineno gets shifted if rst_prolog is present

2 participants