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, 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, 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]+([\w-]+):(?:[ \t]+(.*))?$")
 84"""What follows the open word: ``[<markup>]``, whitespace, ``<type>:``, the title."""
 85
 86_OPTION = re.compile(r"^:([\w-]+):(?:[ \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    raw = comment.node.text or b""
166    return raw.decode("utf-8", errors="replace")
167
168
169def _column(comment: SourceComment, text: str, row_text: str | None) -> int | None:
170    """The comment's 0-based column in characters, or ``None`` when not knowable.
171
172    A tree-sitter node gives a byte column; a libclang comment gives none, so its
173    column is read from the row: its first line is the row's suffix, or else its first
174    occurrence in the row (a one-row block comment followed by code).
175    """
176    node = comment.node
177    if getattr(node, "is_libclang", False):
178        if row_text is None:
179            return None
180        first = text.split("\n", 1)[0]
181        if row_text.endswith(first):
182            return len(row_text) - len(first)
183        index = row_text.find(first)
184        return index if index != -1 else None
185    byte_col = node.start_point.column
186    if row_text is None:
187        return byte_col
188    return len(row_text.encode("utf-8")[:byte_col].decode("utf-8", errors="replace"))
189
190
191def _classify(comment: SourceComment, text: str) -> tuple[CommentKind, str]:
192    # a libclang comment has no ``type``; only tree-sitter's Python grammar has docstrings
193    if getattr(comment.node, "type", None) == CommentCategory.docstring:
194        return "docstring", ""
195    stripped = text.lstrip()
196    if stripped.startswith("/*"):
197        return "block", "/*"
198    for delimiter in LINE_DELIMITERS:
199        if stripped.startswith(delimiter):
200            return "line", delimiter
201    return "unknown", ""
202
203
204def _view(comment: SourceComment, source_lines: Sequence[str] | None) -> _Comment:
205    text = _text(comment)
206    row = comment.node.start_point.row
207    row_text = (
208        source_lines[row]
209        if source_lines is not None and row < len(source_lines)
210        else None
211    )
212    col = _column(comment, text, row_text)
213    kind, delimiter = _classify(comment, text)
214    alone = True
215    if row_text is not None and col is not None:
216        alone = not row_text[:col].strip()
217    return _Comment(comment, row, col or 0, text, kind, delimiter, alone)
218
219
220def form_runs(
221    comments: Iterable[SourceComment], source_lines: Sequence[str] | None = None
222) -> list[CommentRun]:
223    """Group one file's comments into runs, in source order.
224
225    :param comments: The file's comment nodes, in any order (tree-sitter's captures are
226        not in source order).
227    :param source_lines: The file's rows, for what precedes a comment on its row; without
228        them every line comment counts as alone on its row.
229    """
230    views = sorted(
231        (_view(comment, source_lines) for comment in comments),
232        key=lambda view: (view.row, view.col),
233    )
234    runs: list[CommentRun] = []
235    current: list[_Comment] = []
236    for view in views:
237        if current and _continues(current[-1], view):
238            current.append(view)
239            continue
240        if current:
241            runs.append(_make_run(current))
242        current = [view]
243    if current:
244        runs.append(_make_run(current))
245    return runs
246
247
248def _continues(previous: _Comment, view: _Comment) -> bool:
249    return (
250        previous.kind == "line"
251        and view.kind == "line"
252        and previous.alone
253        and view.alone
254        and previous.delimiter == view.delimiter
255        and view.row == previous.row + 1
256    )
257
258
259def _make_run(views: list[_Comment]) -> CommentRun:
260    if len(views) == 1 and views[0].kind == "block":
261        lines, leader_stripped, mixed = _block_comment_lines(views[0])
262        return CommentRun([views[0].comment], lines, not leader_stripped, mixed)
263    run_lines: list[LogicalLine] = []
264    for view in views:
265        run_lines.extend(_logical_lines(view))
266    return CommentRun([view.comment for view in views], run_lines)
267
268
269def _logical_lines(view: _Comment) -> list[LogicalLine]:
270    if view.kind == "line":
271        return [_line_comment_line(view)]
272    if view.kind == "block":
273        return _block_comment_lines(view)[0]
274    if view.kind == "docstring":
275        return _docstring_lines(view)
276    logger.debug(
277        f"codelinks: a comment at row {view.row + 1} starts with no comment delimiter; "
278        "its lines are read unstripped"
279    )
280    return [
281        LogicalLine(view.row + offset, view.col if offset == 0 else 0, line)
282        for offset, line in enumerate(view.text.split("\n"))
283    ]
284
285
286def _line_comment_line(view: _Comment) -> LogicalLine:
287    text = view.text[:-1] if view.text.endswith("\n") else view.text
288    stripped = text.lstrip()
289    col = view.col + len(text) - len(stripped) + len(view.delimiter)
290    rest = stripped[len(view.delimiter) :]
291    if rest.startswith(" "):
292        rest = rest[1:]
293        col += 1
294    return LogicalLine(view.row, col, rest)
295
296
297def _block_comment_lines(view: _Comment) -> tuple[list[LogicalLine], bool, bool]:
298    """The block comment's logical lines, whether a ``*`` leader was stripped, and
299    whether, unstripped, some of its lines carry one (a mixed block)."""
300    texts = view.text.split("\n")
301    count = len(texts)
302    cols = [view.col] + [0] * (count - 1)
303    keep = [True] * count
304    if count > 1 and _OPENER_ONLY.match(texts[0].strip()):
305        keep[0] = False
306    else:
307        lead = len(texts[0]) - len(texts[0].lstrip())
308        body = texts[0].lstrip()
309        if body.startswith("/*!"):
310            opener = "/*!"
311        elif body.startswith("/**") and len(view.text.strip()) > 4:
312            opener = "/**"
313        else:
314            opener = "/*"
315        cols[0] += lead + len(opener)
316        texts[0] = body[len(opener) :]
317    if count > 1 and _CLOSER_ONLY.match(texts[-1]):
318        keep[-1] = False
319    else:
320        last = texts[-1].rstrip()
321        if last.endswith("*/"):
322            texts[-1] = last[: -len("*/")]
323
324    # the leader test reads the rows after the opener's, delimiter-only rows dropped
325    later = [i for i in range(1, count) if keep[i]]
326    non_blank = [i for i in later if texts[i].strip()]
327    leader_lines = [
328        i for i in non_blank if _LEADER.match(texts[i]) or _STARS_ONLY.match(texts[i])
329    ]
330    leader_stripped = bool(non_blank) and len(leader_lines) == len(non_blank)
331    mixed = not leader_stripped and bool(leader_lines)
332    if leader_stripped:
333        for i in later:
334            match = _LEADER.match(texts[i])
335            if match:
336                cols[i] += match.end()
337                texts[i] = texts[i][match.end() :]
338            elif _STARS_ONLY.match(texts[i]):
339                texts[i] = ""
340
341    if not texts[0].strip():
342        keep[0] = False
343    if count > 1 and not texts[-1].strip():
344        keep[-1] = False
345    lines = [
346        LogicalLine(view.row + i, cols[i], texts[i]) for i in range(count) if keep[i]
347    ]
348    return lines, leader_stripped, mixed
349
350
351def _docstring_lines(view: _Comment) -> list[LogicalLine]:
352    match = _DOCSTRING_OPEN.match(view.text)
353    if match is None:
354        return [LogicalLine(view.row, view.col, view.text)]
355    quote = match.group(2)
356    inner = view.text[match.end() :]
357    if inner.endswith(quote):
358        inner = inner[: -len(quote)]
359    raw = inner.split("\n")
360    first = raw[0]
361    lines = [
362        LogicalLine(
363            view.row,
364            view.col + match.end() + len(first) - len(first.lstrip()),
365            first.lstrip(),
366        )
367    ]
368    indents = [len(line) - len(line.lstrip()) for line in raw[1:] if line.strip()]
369    margin = min(indents, default=0)
370    for offset, line in enumerate(raw[1:], start=1):
371        text = line[margin:] if line.strip() else ""
372        lines.append(LogicalLine(view.row + offset, margin, text))
373    return lines
374
375
376def _indent(text: str) -> int:
377    return len(text) - len(text.lstrip())
378
379
380def open_rest(text: str, start_sequence: str) -> str | None:
381    """What follows the open word, if ``text`` starts with it as a whole word.
382
383    The word must be followed by ``[``, whitespace or the line end. So the default
384    reference marker ``@need-ids:``, which starts with the default open word, does not
385    open a block, and neither does ``@needle``.
386    """
387    stripped = text.lstrip()
388    if not stripped.startswith(start_sequence):
389        return None
390    rest = stripped[len(start_sequence) :]
391    if rest and rest[0] != "[" and not rest[0].isspace():
392        return None
393    return rest
394
395
[docs]396# @Parse multi-line need blocks in a comment run, IMPL_MLN_2, impl, [FE_MULTILINE_NEEDS]
397def parse_run(
398    lines: Sequence[LogicalLine],
399    config: MultilineNeedsConfig,
400    *,
401    leaderless_block: bool = False,
402    mixed_leaders: bool = False,
403) -> ParseResult:
404    """Find the multi-line needs in one comment run.
405
406    At each line opening a block: the close word ending the same line is the one-line
407    form, refused, and its line is claimed; no close line further down the run is an
408    unterminated block, refused, and the rest of the run is not scanned; an open line
409    that does not match the grammar is refused, and the lines up to its close are still
410    consumed. A block, a refused header and a refused one-line form claim their lines.
411
412    :param leaderless_block: The run is one block comment whose ``*`` leader was not
413        stripped: an open word behind a star is refused as a header, with the cause, and
414        consumed up to the first close line, which may then carry the stars too. The
415        refusal order is the same as for any open line: the one-line form first.
416    :param mixed_leaders: Some lines of that block do carry the leader: an unterminated
417        open then names the missing leader as the likely cause.
418    """
419    result = ParseResult()
420    start, end = config.start_sequence, config.end_sequence
421    unterminated = f"no '{end}' line before the comment ends; the block is skipped"
422    if leaderless_block and mixed_leaders:
423        unterminated += (
424            "; the likely cause: the block comment's lines do not all carry the '*' "
425            "leader"
426        )
427    index = 0
428    while index < len(lines):
429        line = lines[index]
430        rest = open_rest(line.text, start)
431        if rest is None:
432            stars = _STARS.match(line.text) if leaderless_block else None
433            starred_rest = open_rest(line.text[stars.end() :], start) if stars else None
434            if starred_rest is not None and _ends_with_word(starred_rest, end):
435                result.issues.append(_oneline_form(line.row, start, end))
436                result.claimed_rows.add(line.row)
437                index += 1
438                continue
439            if starred_rest is not None:
440                close = next(
441                    (
442                        k
443                        for k in range(index + 1, len(lines))
444                        if _is_close(lines[k].text, end, starred=True)
445                    ),
446                    None,
447                )
448                if close is None:
449                    result.issues.append(
450                        ParseIssue(
451                            WarningSubTypeEnum.multiline_need_unterminated,
452                            line.row,
453                            unterminated,
454                        )
455                    )
456                    break
457                result.issues.append(
458                    ParseIssue(
459                        WarningSubTypeEnum.multiline_need_header,
460                        line.row,
461                        f"'{start}' sits behind a '*' leader in a block comment "
462                        "whose other lines carry none; give every line the leader "
463                        "or none",
464                    )
465                )
466                result.claimed_rows.update(
467                    lines[k].row for k in range(index, close + 1)
468                )
469                index = close + 1
470                continue
471            index += 1
472            continue
473        if _ends_with_word(rest, end):
474            result.issues.append(_oneline_form(line.row, start, end))
475            result.claimed_rows.add(line.row)
476            index += 1
477            continue
478        close = next(
479            (
480                k
481                for k in range(index + 1, len(lines))
482                if _is_close(lines[k].text, end, starred=False)
483            ),
484            None,
485        )
486        if close is None:
487            result.issues.append(
488                ParseIssue(
489                    WarningSubTypeEnum.multiline_need_unterminated,
490                    line.row,
491                    unterminated,
492                )
493            )
494            break
495        result.claimed_rows.update(lines[k].row for k in range(index, close + 1))
496        header = _HEADER.match(rest.rstrip())
497        if header is None:
498            result.issues.append(
499                ParseIssue(
500                    WarningSubTypeEnum.multiline_need_header,
501                    line.row,
502                    f"the open line does not match '{start}[<markup>] <type>: <title>'; "
503                    f"the block up to '{end}' is skipped",
504                )
505            )
506        else:
507            result.blocks.append(
508                _block(lines, index, close, header, config, result.issues)
509            )
510        index = close + 1
511    return result
512
513
514def _oneline_form(row: int, start: str, end: str) -> ParseIssue:
515    return ParseIssue(
516        WarningSubTypeEnum.multiline_need_oneline_form,
517        row,
518        f"'{start}' and '{end}' on one line: a one-line need is written with the "
519        "one-line marker; the block is skipped",
520    )
521
522
523def _is_close(text: str, end: str, *, starred: bool) -> bool:
524    """Whether ``text`` is a close line; ``starred`` also accepts stars before the word."""
525    stripped = text.strip()
526    if stripped == end:
527        return True
528    if starred:
529        stars = _STARS.match(stripped)
530        return stars is not None and stripped[stars.end() :] == end
531    return False
532
533
534def _ends_with_word(text: str, word: str) -> bool:
535    """Whether ``text`` ends with ``word`` preceded by whitespace or nothing."""
536    stripped = text.rstrip()
537    if not stripped.endswith(word):
538        return False
539    before = stripped[: -len(word)]
540    return not before or before[-1].isspace()
541
542
543def _block(
544    lines: Sequence[LogicalLine],
545    open_index: int,
546    close_index: int,
547    header: re.Match[str],
548    config: MultilineNeedsConfig,
549    issues: list[ParseIssue],
550) -> ParsedBlock:
551    open_line, close_line = lines[open_index], lines[close_index]
552    tag, need_type, title = header.group(1), header.group(2), header.group(3) or ""
553    markup = config.default_markup
554    if tag is not None:
555        if tag in config.markups:
556            markup = tag
557        else:
558            issues.append(
559                ParseIssue(
560                    WarningSubTypeEnum.multiline_need_markup,
561                    open_line.row,
562                    f"unknown markup '{tag}' (known: "
563                    f"{', '.join(sorted(config.markups))}); the project default "
564                    f"'{config.default_markup}' is used",
565                )
566            )
567
568    options: dict[str, str] = {}
569    option_rows: dict[str, int] = {}
570    index = open_index + 1
571    # the option a continuation line extends: its key (None when the option was
572    # refused as a duplicate) and its indentation
573    current: tuple[str | None, int] | None = None
574    while index < close_index:
575        text = lines[index].text
576        if current is not None and text.strip() and _indent(text) > current[1]:
577            key = current[0]
578            if key is not None:
579                options[key] = " ".join(filter(None, (options[key], text.strip())))
580            index += 1
581            continue
582        option = _OPTION.match(text.strip())
583        if option is None:
584            break
585        key, value = option.group(1), (option.group(2) or "").strip()
586        if key in options or key in RESERVED_KEYS:
587            what = "the block's own" if key in RESERVED_KEYS else "an earlier"
588            issues.append(
589                ParseIssue(
590                    WarningSubTypeEnum.multiline_need_duplicate_option,
591                    lines[index].row,
592                    f"option '{key}' repeats {what} '{key}'; the first value is kept",
593                )
594            )
595            current = (None, _indent(text))
596        else:
597            options[key] = value
598            option_rows[key] = lines[index].row + 1
599            current = (key, _indent(text))
600        index += 1
601
602    body = list(lines[index:close_index])
603    while body and not body[0].text.strip():
604        body.pop(0)
605    while body and not body[-1].text.strip():
606        body.pop()
607    content_lines: list[str] = []
608    content_start: tuple[int, int] | None = None
609    if body:
610        content_start = (body[0].row, body[0].col + _indent(body[0].text))
611        margin = min(_indent(line.text) for line in body if line.text.strip())
612        for line in body:
613            text = line.text[margin:] if line.text.strip() else ""
614            stripped = text.lstrip()
615            if stripped.startswith("\\" + config.end_sequence):
616                # tested past the indentation, as the close is; the indentation stays
617                text = text[: len(text) - len(stripped)] + stripped[1:]
618            elif open_rest(text, config.start_sequence) is not None:
619                issues.append(
620                    ParseIssue(
621                        WarningSubTypeEnum.multiline_need_nested_open,
622                        line.row,
623                        f"'{config.start_sequence}' inside a multi-line need's body is "
624                        "body text: one need per block",
625                    )
626                )
627            content_lines.append(text)
628
629    need: dict[str, str] = {"type": need_type, "title": title.strip()}
630    need.update(options)
631    need["content"] = "\n".join(content_lines)
632    need["doctype"] = config.markups[markup]
633    return ParsedBlock(
634        open_row=open_line.row,
635        open_col=open_line.col + _indent(open_line.text),
636        close_row=close_line.row,
637        end_col=close_line.col
638        + close_line.text.index(config.end_sequence)
639        + len(config.end_sequence),
640        markup=markup,
641        doctype=need["doctype"],
642        need=need,
643        option_rows=option_rows,
644        content_start=content_start,
645    )
646
647
648def blank_rows(text: str, first_row: int, rows: set[int]) -> str:
649    """``text`` with each of ``rows`` (0-based source rows) replaced by an empty line.
650
651    The text keeps its line count, so the row arithmetic of the other extractors holds.
652    """
653    lines = text.split("\n")
654    return "\n".join(
655        "" if first_row + offset in rows else line for offset, line in enumerate(lines)
656    )