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