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 )