1"""Multi-line needs in source comments: comment runs, prefix stripping, the block grammar.
  2
  3A multi-line need is one need written across the lines of a comment. This module turns
  4the comment nodes of one file into comment RUNS, strips each line's comment prefix by the
  5comment's kind, and parses a run's lines with the block grammar. It knows nothing of
  6Sphinx or of a need's fields: the record is built by ``analyse.py``, and the need is
  7validated and its content parsed by whoever consumes the record.
  8
  9**Runs.** One block comment (``/* … */``, ``/** … */``, ``/*! … */``) or one Python string
 10statement (a docstring or any bare string statement) is a run on its own. Consecutive line comments form one run when they sit on consecutive rows,
 11use the same delimiter, and have only whitespace before them on their rows; a line
 12comment after code on its row neither starts nor continues a run.
 13
 14**Prefix stripping** follows the comment's kind and never searches the text, so each
 15logical line maps to exactly one source row:
 16
 17========================================  ==================================================  ===========================================
 18comment kind                              stripped from each line                             languages
 19========================================  ==================================================  ===========================================
 20line comment ``//``, ``///``, ``//!``     the delimiter, then ONE space if present            C/C++, C#, Rust, Go, TS/JS, JSONC
 21line comment ``#``                        the delimiter, then ONE space if present            Python, YAML, Bash, PowerShell
 22line comment ``--``, ``---``, ``--!``     the delimiter, then ONE space if present            VHDL
 23block comment ``<# … #>``                 a first row holding only ``<#`` and a last row      PowerShell
 24                                          holding only ``#>`` are dropped; the delimiters
 25                                          are removed (no leader rule)
 26block comment ``/* … */``, ``/** … */``,  a first row holding only the opener (``/*`` with  C/C++, C#, Rust, Go, TS/JS, JSONC
 27``/*! … */``                              any stars, or ``/*!``) and a last row holding only
 28                                          the closer (stars then ``*/``) are dropped; the
 29                                          delimiters are removed; then a ``*`` leader (``*``
 30                                          and one space, or a lone ``*``) only if EVERY
 31                                          remaining non-blank line after the opener's row
 32                                          carries one (the doxygen rule)
 33Python docstring                          the quotes and any string prefix; the interior      Python
 34                                          dedented as ``inspect.cleandoc`` does (no line is
 35                                          removed)
 36block comment ``<!-- … -->``              one row to add when the language lands              Markdown (sphinx-needs #1895)
 37========================================  ==================================================  ===========================================
 38
 39A comment whose text does not start with a delimiter its kind allows -- impossible for a
 40tree-sitter node, possible for a libclang one -- keeps its lines unstripped, and a debug
 41message says so.
 42
 43**The grammar** (after stripping) is the one in ``docs/components/analyse.rst``: the open
 44line ``<open>[<markup>] <type>: <title>``, option lines ``:key: value`` (a line indented
 45deeper than its option line continues the value), the body in the declared markup, and the
 46close word alone on its line. :func:`parse_run` returns the blocks, the refusals and the
 47rows a block claims; the claimed rows are hidden from the one-line and ``@need-ids:``
 48extractors.
 49"""
 50
 51from __future__ import annotations
 52
 53import re
 54from collections.abc import Iterable, Sequence
 55from dataclasses import dataclass, field
 56from typing import Literal
 57
 58from sphinx_codelinks.analyse.models import SourceComment, WarningSubTypeEnum
 59from sphinx_codelinks.config import CommentCategory, MultilineNeedsConfig
 60from sphinx_codelinks.logger import get_logger
 61
 62logger = get_logger(__name__)
 63
 64CommentKind = Literal["line", "block", "docstring", "unknown"]
 65
 66#: line-comment delimiters, longest first so that ``///`` is not read as ``//``, and
 67#: ``---`` (a common VHDL style) not as ``--``
 68LINE_DELIMITERS: tuple[str, ...] = ("///", "//!", "//", "---", "--!", "--", "#")
 69
 70#: PowerShell's block comment delimiters
 71POWERSHELL_BLOCK_OPENER = "<#"
 72POWERSHELL_BLOCK_CLOSER = "#>"
 73
 74_LEADER = re.compile(r"^[ \t]*\*(?: |$)")
 75"""A doxygen leader: optional whitespace, ``*``, then one space or the line end."""
 76
 77_OPENER_ONLY = re.compile(r"^/\*+!?\s*$")
 78"""A block comment's first row holding nothing but its opener (``/*``, ``/*****``, ``/*!``)."""
 79
 80_CLOSER_ONLY = re.compile(r"^\s*\*+/\s*$")
 81"""A block comment's last row holding nothing but its closer (``*/``, `` ***/``)."""
 82
 83_STARS = re.compile(r"^\s*\*+\s*")
 84"""Stars at the start of a line, with the whitespace around them."""
 85
 86_STARS_ONLY = re.compile(r"^\s*\*+\s*$")
 87"""A line of stars only: a separator, counted as a leader line and read as blank."""
 88
 89_DOCSTRING_OPEN = re.compile(r"""^([rRuUbBfF]{0,2})(\"\"\"|'''|"|')""")
 90
 91_HEADER = re.compile(r"^(?:\[([^\]]*)\])?[ \t]+([A-Za-z0-9_-]+):(?:[ \t]+(.*))?$")
 92"""What follows the open word: ``[<markup>]``, whitespace, ``<type>:``, the title."""
 93
 94_OPTION = re.compile(r"^:([A-Za-z0-9_-]+):(?:[ \t]+(.*))?$")
 95"""An option line, leading whitespace removed: ``:<key>:`` then the value."""
 96
 97RESERVED_KEYS: frozenset[str] = frozenset({"type", "title", "content", "doctype"})
 98"""Keys the record sets itself: an option naming one duplicates it."""
 99
100
101@dataclass(frozen=True)
102class LogicalLine:
103    """One line of a comment run with its comment prefix stripped."""
104
105    row: int
106    """The 0-based source row."""
107    col: int
108    """The 0-based source column (in characters) of ``text[0]``."""
109    text: str
110    """The line after prefix stripping."""
111
112
113@dataclass
114class CommentRun:
115    """The comments one block grammar pass reads, and their logical lines."""
116
117    comments: list[SourceComment]
118    lines: list[LogicalLine]
119    leaderless_block: bool = False
120    """A block comment whose lines did not all carry a ``*`` leader, so none was
121    stripped: an open word behind a star is reported, not silently lost."""
122    mixed_leaders: bool = False
123    """A leaderless block in which some lines DO carry the leader: the missing leader is
124    then the likely cause of an unterminated block (a plain block has none to miss)."""
125
126
127@dataclass
128class ParsedBlock:
129    """One multi-line need as the grammar found it, in source coordinates (0-based)."""
130
131    open_row: int
132    open_col: int
133    close_row: int
134    end_col: int
135    """One past the close word's last character."""
136    markup: str
137    doctype: str
138    need: dict[str, str]
139    option_rows: dict[str, int]
140    content_start: tuple[int, int] | None
141
142
143@dataclass(frozen=True)
144class ParseIssue:
145    """A refusal or a note of the grammar, at a 0-based source row."""
146
147    kind: WarningSubTypeEnum
148    row: int
149    msg: str
150
151
152@dataclass
153class ParseResult:
154    blocks: list[ParsedBlock] = field(default_factory=list)
155    issues: list[ParseIssue] = field(default_factory=list)
156    claimed_rows: set[int] = field(default_factory=set)
157
158
159@dataclass
160class _Comment:
161    comment: SourceComment
162    row: int
163    col: int
164    text: str
165    kind: CommentKind
166    delimiter: str
167    alone: bool
168    """Only whitespace before the comment on its row (or not knowable: libclang
169    without the row's text)."""
170
171
172def _text(comment: SourceComment) -> str:
173    return comment.text.decode("utf-8", errors="replace")
174
175
176def _column(comment: SourceComment, text: str, row_text: str | None) -> int | None:
177    """The comment's 0-based column in characters, or ``None`` when not knowable.
178
179    A tree-sitter node gives a byte column; a libclang comment gives none, so its
180    column is read from the row: its first line is the row's suffix, or else its first
181    occurrence in the row (a one-row block comment followed by code).
182    """
183    node = comment.node
184    if getattr(node, "is_libclang", False):
185        if row_text is None:
186            return None
187        first = text.split("\n", 1)[0]
188        if row_text.endswith(first):
189            return len(row_text) - len(first)
190        index = row_text.find(first)
191        return index if index != -1 else None
192    byte_col = node.start_point.column
193    if row_text is None:
194        return byte_col
195    return len(row_text.encode("utf-8")[:byte_col].decode("utf-8", errors="replace"))
196
197
198def _classify(comment: SourceComment, text: str) -> tuple[CommentKind, str]:
199    # a libclang comment has no ``type``; only tree-sitter's Python grammar has docstrings
200    if getattr(comment.node, "type", None) == CommentCategory.docstring:
201        return "docstring", ""
202    stripped = text.lstrip()
203    if stripped.startswith("/*"):
204        return "block", "/*"
205    if stripped.startswith(POWERSHELL_BLOCK_OPENER):
206        return "block", POWERSHELL_BLOCK_OPENER
207    for delimiter in LINE_DELIMITERS:
208        if stripped.startswith(delimiter):
209            return "line", delimiter
210    return "unknown", ""
211
212
213def _view(comment: SourceComment, source_lines: Sequence[str] | None) -> _Comment:
214    text = _text(comment)
215    row = comment.node.start_point.row
216    row_text = (
217        source_lines[row]
218        if source_lines is not None and row < len(source_lines)
219        else None
220    )
221    col = _column(comment, text, row_text)
222    kind, delimiter = _classify(comment, text)
223    alone = True
224    if row_text is not None and col is not None:
225        alone = not row_text[:col].strip()
226    return _Comment(comment, row, col or 0, text, kind, delimiter, alone)
227
228
229def form_runs(
230    comments: Iterable[SourceComment], source_lines: Sequence[str] | None = None
231) -> list[CommentRun]:
232    """Group one file's comments into runs, in source order.
233
234    :param comments: The file's comment nodes, in any order (tree-sitter's captures are
235        not in source order).
236    :param source_lines: The file's rows, for what precedes a comment on its row; without
237        them every line comment counts as alone on its row.
238    """
239    views = sorted(
240        (_view(comment, source_lines) for comment in comments),
241        key=lambda view: (view.row, view.col),
242    )
243    runs: list[CommentRun] = []
244    current: list[_Comment] = []
245    for view in views:
246        if current and _continues(current[-1], view):
247            current.append(view)
248            continue
249        if current:
250            runs.append(_make_run(current))
251        current = [view]
252    if current:
253        runs.append(_make_run(current))
254    return runs
255
256
257def _continues(previous: _Comment, view: _Comment) -> bool:
258    return (
259        previous.kind == "line"
260        and view.kind == "line"
261        and previous.alone
262        and view.alone
263        and previous.delimiter == view.delimiter
264        and view.row == previous.row + 1
265    )
266
267
268def _make_run(views: list[_Comment]) -> CommentRun:
269    if len(views) == 1 and views[0].kind == "block":
270        if views[0].delimiter == POWERSHELL_BLOCK_OPENER:
271            return CommentRun([views[0].comment], _powershell_block_lines(views[0]))
272        lines, leader_stripped, mixed = _block_comment_lines(views[0])
273        return CommentRun([views[0].comment], lines, not leader_stripped, mixed)
274    run_lines: list[LogicalLine] = []
275    for view in views:
276        run_lines.extend(_logical_lines(view))
277    return CommentRun([view.comment for view in views], run_lines)
278
279
280def _logical_lines(view: _Comment) -> list[LogicalLine]:
281    if view.kind == "line":
282        return [_line_comment_line(view)]
283    if view.kind == "block":
284        return _block_comment_lines(view)[0]
285    if view.kind == "docstring":
286        return _docstring_lines(view)
287    logger.debug(
288        f"codelinks: a comment at row {view.row + 1} starts with no comment delimiter; "
289        "its lines are read unstripped"
290    )
291    return [
292        LogicalLine(view.row + offset, view.col if offset == 0 else 0, line)
293        for offset, line in enumerate(view.text.split("\n"))
294    ]
295
296
297def _line_comment_line(view: _Comment) -> LogicalLine:
298    text = view.text[:-1] if view.text.endswith("\n") else view.text
299    stripped = text.lstrip()
300    col = view.col + len(text) - len(stripped) + len(view.delimiter)
301    rest = stripped[len(view.delimiter) :]
302    if rest.startswith(" "):
303        rest = rest[1:]
304        col += 1
305    return LogicalLine(view.row, col, rest)
306
307
[docs]308# @PowerShell block comment stripping, IMPL_PS_4, impl, [FE_POWERSHELL]
309def _powershell_block_lines(view: _Comment) -> list[LogicalLine]:
310    """A ``<# … #>`` comment's logical lines: the delimiters removed, and a first row
311    left holding nothing but ``<#``, or a last row nothing but ``#>``, dropped.
312    PowerShell has no leader convention, so nothing else is stripped."""
313    texts = view.text.split("\n")
314    count = len(texts)
315    cols = [view.col] + [0] * (count - 1)
316    body = texts[0].lstrip()
317    cols[0] += len(texts[0]) - len(body) + len(POWERSHELL_BLOCK_OPENER)
318    texts[0] = body[len(POWERSHELL_BLOCK_OPENER) :]
319    last = texts[-1].rstrip()
320    if last.endswith(POWERSHELL_BLOCK_CLOSER):
321        texts[-1] = last[: -len(POWERSHELL_BLOCK_CLOSER)]
322    keep = [True] * count
323    if count > 1 and not texts[0].strip():
324        keep[0] = False
325    if count > 1 and not texts[-1].strip():
326        keep[-1] = False
327    return [
328        LogicalLine(view.row + i, cols[i], texts[i]) for i in range(count) if keep[i]
329    ]
330
331
332def _block_comment_lines(view: _Comment) -> tuple[list[LogicalLine], bool, bool]:
333    """The block comment's logical lines, whether a ``*`` leader was stripped, and
334    whether, unstripped, some of its lines carry one (a mixed block)."""
335    texts = view.text.split("\n")
336    count = len(texts)
337    cols = [view.col] + [0] * (count - 1)
338    keep = [True] * count
339    if count > 1 and _OPENER_ONLY.match(texts[0].strip()):
340        keep[0] = False
341    else:
342        lead = len(texts[0]) - len(texts[0].lstrip())
343        body = texts[0].lstrip()
344        if body.startswith("/*!"):
345            opener = "/*!"
346        elif body.startswith("/**") and len(view.text.strip()) > 4:
347            opener = "/**"
348        else:
349            opener = "/*"
350        cols[0] += lead + len(opener)
351        texts[0] = body[len(opener) :]
352    if count > 1 and _CLOSER_ONLY.match(texts[-1]):
353        keep[-1] = False
354    else:
355        last = texts[-1].rstrip()
356        if last.endswith("*/"):
357            texts[-1] = last[: -len("*/")]
358
359    # the leader test reads the rows after the opener's, delimiter-only rows dropped
360    later = [i for i in range(1, count) if keep[i]]
361    non_blank = [i for i in later if texts[i].strip()]
362    leader_lines = [
363        i for i in non_blank if _LEADER.match(texts[i]) or _STARS_ONLY.match(texts[i])
364    ]
365    leader_stripped = bool(non_blank) and len(leader_lines) == len(non_blank)
366    mixed = not leader_stripped and bool(leader_lines)
367    if leader_stripped:
368        for i in later:
369            match = _LEADER.match(texts[i])
370            if match:
371                cols[i] += match.end()
372                texts[i] = texts[i][match.end() :]
373            elif _STARS_ONLY.match(texts[i]):
374                texts[i] = ""
375
376    if not texts[0].strip():
377        keep[0] = False
378    if count > 1 and not texts[-1].strip():
379        keep[-1] = False
380    lines = [
381        LogicalLine(view.row + i, cols[i], texts[i]) for i in range(count) if keep[i]
382    ]
383    return lines, leader_stripped, mixed
384
385
386def _docstring_lines(view: _Comment) -> list[LogicalLine]:
387    match = _DOCSTRING_OPEN.match(view.text)
388    if match is None:
389        return [LogicalLine(view.row, view.col, view.text)]
390    quote = match.group(2)
391    inner = view.text[match.end() :]
392    if inner.endswith(quote):
393        inner = inner[: -len(quote)]
394    raw = inner.split("\n")
395    first = raw[0]
396    lines = [
397        LogicalLine(
398            view.row,
399            view.col + match.end() + len(first) - len(first.lstrip()),
400            first.lstrip(),
401        )
402    ]
403    indents = [len(line) - len(line.lstrip()) for line in raw[1:] if line.strip()]
404    margin = min(indents, default=0)
405    for offset, line in enumerate(raw[1:], start=1):
406        text = line[margin:] if line.strip() else ""
407        lines.append(LogicalLine(view.row + offset, margin, text))
408    return lines
409
410
411def _indent(text: str) -> int:
412    return len(text) - len(text.lstrip())
413
414
415def open_rest(text: str, start_sequence: str) -> str | None:
416    """What follows the open word, if ``text`` starts with it as a whole word.
417
418    The word must be followed by ``[``, whitespace or the line end. So the default
419    reference marker ``@need-ids:``, which starts with the default open word, does not
420    open a block, and neither does ``@needle``.
421    """
422    stripped = text.lstrip()
423    if not stripped.startswith(start_sequence):
424        return None
425    rest = stripped[len(start_sequence) :]
426    if rest and rest[0] != "[" and not rest[0].isspace():
427        return None
428    return rest
429
430
[docs]431# @Parse multi-line need blocks in a comment run, IMPL_MLN_2, impl, [FE_MULTILINE_NEEDS]
432def parse_run(
433    lines: Sequence[LogicalLine],
434    config: MultilineNeedsConfig,
435    *,
436    leaderless_block: bool = False,
437    mixed_leaders: bool = False,
438) -> ParseResult:
439    """Find the multi-line needs in one comment run.
440
441    At each line opening a block: the close word ending the same line is the one-line
442    form, refused, and its line is claimed; no close line further down the run is an
443    unterminated block, refused, and the rest of the run is not scanned; an open line
444    that does not match the grammar is refused, and the lines up to its close are still
445    consumed. A block, a refused header and a refused one-line form claim their lines.
446
447    :param leaderless_block: The run is one block comment whose ``*`` leader was not
448        stripped: an open word behind a star is refused as a header, with the cause, and
449        consumed up to the first close line, which may then carry the stars too. The
450        refusal order is the same as for any open line: the one-line form first.
451    :param mixed_leaders: Some lines of that block do carry the leader: an unterminated
452        open then names the missing leader as the likely cause.
453    """
454    result = ParseResult()
455    start, end = config.start_sequence, config.end_sequence
456    unterminated = f"no '{end}' line before the comment ends; the block is skipped"
457    if leaderless_block and mixed_leaders:
458        unterminated += (
459            "; the likely cause: the block comment's lines do not all carry the '*' "
460            "leader"
461        )
462    index = 0
463    while index < len(lines):
464        line = lines[index]
465        rest = open_rest(line.text, start)
466        if rest is None:
467            stars = _STARS.match(line.text) if leaderless_block else None
468            starred_rest = open_rest(line.text[stars.end() :], start) if stars else None
469            if starred_rest is not None and _ends_with_word(starred_rest, end):
470                result.issues.append(_oneline_form(line.row, start, end))
471                result.claimed_rows.add(line.row)
472                index += 1
473                continue
474            if starred_rest is not None:
475                close = next(
476                    (
477                        k
478                        for k in range(index + 1, len(lines))
479                        if _is_close(lines[k].text, end, starred=True)
480                    ),
481                    None,
482                )
483                if close is None:
484                    result.issues.append(
485                        ParseIssue(
486                            WarningSubTypeEnum.multiline_need_unterminated,
487                            line.row,
488                            unterminated,
489                        )
490                    )
491                    break
492                result.issues.append(
493                    ParseIssue(
494                        WarningSubTypeEnum.multiline_need_header,
495                        line.row,
496                        f"'{start}' sits behind a '*' leader in a block comment "
497                        "whose other lines carry none; give every line the leader "
498                        "or none",
499                    )
500                )
501                result.claimed_rows.update(
502                    lines[k].row for k in range(index, close + 1)
503                )
504                index = close + 1
505                continue
506            index += 1
507            continue
508        if _ends_with_word(rest, end):
509            result.issues.append(_oneline_form(line.row, start, end))
510            result.claimed_rows.add(line.row)
511            index += 1
512            continue
513        close = next(
514            (
515                k
516                for k in range(index + 1, len(lines))
517                if _is_close(lines[k].text, end, starred=False)
518            ),
519            None,
520        )
521        if close is None:
522            result.issues.append(
523                ParseIssue(
524                    WarningSubTypeEnum.multiline_need_unterminated,
525                    line.row,
526                    unterminated,
527                )
528            )
529            break
530        result.claimed_rows.update(lines[k].row for k in range(index, close + 1))
531        header = _HEADER.match(rest.rstrip())
532        if header is None:
533            result.issues.append(
534                ParseIssue(
535                    WarningSubTypeEnum.multiline_need_header,
536                    line.row,
537                    f"the open line does not match '{start}[<markup>] <type>: <title>'; "
538                    f"the block up to '{end}' is skipped",
539                )
540            )
541        else:
542            result.blocks.append(
543                _block(lines, index, close, header, config, result.issues)
544            )
545        index = close + 1
546    return result
547
548
549def _oneline_form(row: int, start: str, end: str) -> ParseIssue:
550    return ParseIssue(
551        WarningSubTypeEnum.multiline_need_oneline_form,
552        row,
553        f"'{start}' and '{end}' on one line: a one-line need is written with the "
554        "one-line marker; the block is skipped",
555    )
556
557
558def _is_close(text: str, end: str, *, starred: bool) -> bool:
559    """Whether ``text`` is a close line; ``starred`` also accepts stars before the word."""
560    stripped = text.strip()
561    if stripped == end:
562        return True
563    if starred:
564        stars = _STARS.match(stripped)
565        return stars is not None and stripped[stars.end() :] == end
566    return False
567
568
569def _ends_with_word(text: str, word: str) -> bool:
570    """Whether ``text`` ends with ``word`` preceded by whitespace or nothing."""
571    stripped = text.rstrip()
572    if not stripped.endswith(word):
573        return False
574    before = stripped[: -len(word)]
575    return not before or before[-1].isspace()
576
577
578def _block(
579    lines: Sequence[LogicalLine],
580    open_index: int,
581    close_index: int,
582    header: re.Match[str],
583    config: MultilineNeedsConfig,
584    issues: list[ParseIssue],
585) -> ParsedBlock:
586    open_line, close_line = lines[open_index], lines[close_index]
587    tag, need_type, title = header.group(1), header.group(2), header.group(3) or ""
588    markup = config.default_markup
589    if tag is not None:
590        if tag in config.markups:
591            markup = tag
592        else:
593            issues.append(
594                ParseIssue(
595                    WarningSubTypeEnum.multiline_need_markup,
596                    open_line.row,
597                    f"unknown markup '{tag}' (known: "
598                    f"{', '.join(sorted(config.markups))}); the project default "
599                    f"'{config.default_markup}' is used",
600                )
601            )
602
603    options: dict[str, str] = {}
604    option_rows: dict[str, int] = {}
605    index = open_index + 1
606    # the option a continuation line extends: its key (None when the option was
607    # refused as a duplicate) and its indentation
608    current: tuple[str | None, int] | None = None
609    while index < close_index:
610        text = lines[index].text
611        if current is not None and text.strip() and _indent(text) > current[1]:
612            key = current[0]
613            if key is not None:
614                options[key] = " ".join(filter(None, (options[key], text.strip())))
615            index += 1
616            continue
617        option = _OPTION.match(text.strip())
618        if option is None:
619            break
620        key, value = option.group(1), (option.group(2) or "").strip()
621        if key in options or key in RESERVED_KEYS:
622            what = "the block's own" if key in RESERVED_KEYS else "an earlier"
623            issues.append(
624                ParseIssue(
625                    WarningSubTypeEnum.multiline_need_duplicate_option,
626                    lines[index].row,
627                    f"option '{key}' repeats {what} '{key}'; the first value is kept",
628                )
629            )
630            current = (None, _indent(text))
631        else:
632            options[key] = value
633            option_rows[key] = lines[index].row + 1
634            current = (key, _indent(text))
635        index += 1
636
637    body = list(lines[index:close_index])
638    while body and not body[0].text.strip():
639        body.pop(0)
640    while body and not body[-1].text.strip():
641        body.pop()
642    content_lines: list[str] = []
643    content_start: tuple[int, int] | None = None
644    if body:
645        content_start = (body[0].row, body[0].col + _indent(body[0].text))
646        margin = min(_indent(line.text) for line in body if line.text.strip())
647        for line in body:
648            text = line.text[margin:] if line.text.strip() else ""
649            stripped = text.lstrip()
650            if stripped.startswith("\\" + config.end_sequence):
651                # tested past the indentation, as the close is; the indentation stays
652                text = text[: len(text) - len(stripped)] + stripped[1:]
653            elif open_rest(text, config.start_sequence) is not None:
654                issues.append(
655                    ParseIssue(
656                        WarningSubTypeEnum.multiline_need_nested_open,
657                        line.row,
658                        f"'{config.start_sequence}' inside a multi-line need's body is "
659                        "body text: one need per block",
660                    )
661                )
662            content_lines.append(text)
663
664    need: dict[str, str] = {"type": need_type, "title": title.strip()}
665    need.update(options)
666    need["content"] = "\n".join(content_lines)
667    need["doctype"] = config.markups[markup]
668    return ParsedBlock(
669        open_row=open_line.row,
670        open_col=open_line.col + _indent(open_line.text),
671        close_row=close_line.row,
672        end_col=close_line.col
673        + close_line.text.index(config.end_sequence)
674        + len(config.end_sequence),
675        markup=markup,
676        doctype=need["doctype"],
677        need=need,
678        option_rows=option_rows,
679        content_start=content_start,
680    )
681
682
683def blank_rows(text: str, first_row: int, rows: set[int]) -> str:
684    """``text`` with each of ``rows`` (0-based source rows) replaced by an empty line.
685
686    The text keeps its line count, so the row arithmetic of the other extractors holds.
687    """
688    lines = text.split("\n")
689    return "\n".join(
690        "" if first_row + offset in rows else line for offset, line in enumerate(lines)
691    )