diff --git a/src/docc/plugins/html/static/docc.css b/src/docc/plugins/html/static/docc.css index 62c116e..01a40b7 100644 --- a/src/docc/plugins/html/static/docc.css +++ b/src/docc/plugins/html/static/docc.css @@ -322,6 +322,12 @@ details[open] > summary::after { font-weight: bold; } +.hi-hidden { + color: var(--comment-color); + font-style: oblique; + font-size: 80%; +} + .header-anchor a { color: inherit; text-decoration: none; diff --git a/src/docc/plugins/python/cst.py b/src/docc/plugins/python/cst.py index e70c076..4d1bf81 100644 --- a/src/docc/plugins/python/cst.py +++ b/src/docc/plugins/python/cst.py @@ -50,7 +50,7 @@ from docc.document import BlankNode, Document, ListNode, Node, Visit, Visitor from docc.plugins.listing import Listable, ListingNode from docc.plugins.references import Definition, Reference -from docc.plugins.verbatim import Fragment, Pos, Verbatim +from docc.plugins.verbatim import Fragment, Hidden, Pos, Verbatim from docc.settings import PluginSettings from docc.source import Source, TextSource from docc.transform import Transform @@ -649,7 +649,11 @@ def enter_function_def( source = node.source if isinstance(source, TextSource): body = node.find_child(cst_node.body) - function_def.body = _VerbatimTransform.apply(source, body) + hide: FrozenSet[cst.CSTNode] = frozenset() + docstring_cst = _docstring_statement(cst_node.body) + if docstring_cst is not None: + hide = frozenset([docstring_cst]) + function_def.body = _VerbatimTransform.apply(source, body, hide) assert isinstance(function_def.decorators, ListNode) decorators = function_def.decorators.children @@ -1176,10 +1180,15 @@ def exit(self, node: Node) -> None: class _VerbatimTransform(Visitor): root: Optional[Node] stack: Final[List[Node]] + hide: Final[FrozenSet[cst.CSTNode]] @staticmethod - def apply(source: TextSource, node: Node) -> Node: - transform = _VerbatimTransform() + def apply( + source: TextSource, + node: Node, + hide: FrozenSet[cst.CSTNode] = frozenset(), + ) -> Node: + transform = _VerbatimTransform(hide) node.visit(transform) @@ -1188,9 +1197,10 @@ def apply(source: TextSource, node: Node) -> Node: verbatim.append(transform.root) return verbatim - def __init__(self) -> None: + def __init__(self, hide: FrozenSet[cst.CSTNode] = frozenset()) -> None: self.stack = [] self.root = None + self.hide = hide def enter(self, node: Node) -> Visit: if self.root is None: @@ -1212,7 +1222,9 @@ def exit(self, node: Node) -> None: new: Optional[Node] = None children = [c for c in node.children if not isinstance(c, BlankNode)] - if isinstance(node.cst_node, WHITESPACE): + if node.cst_node in self.hide: + new = Hidden(start=node.start, end=node.end) + elif isinstance(node.cst_node, WHITESPACE): if not children: new = BlankNode() elif len(children) == 1: @@ -1221,7 +1233,10 @@ def exit(self, node: Node) -> None: if new is None: start = node.start for child in children: - if isinstance(child, Fragment) and child.start < start: + if ( + isinstance(child, (Fragment, Hidden)) + and child.start < start + ): start = child.start name = dasherize(underscore(node.cst_node.__class__.__name__)) @@ -1240,3 +1255,34 @@ def exit(self, node: Node) -> None: else: assert self.root == node self.root = new + + +def _docstring_statement( + body: cst.BaseSuite, +) -> Optional[cst.SimpleStatementLine]: + """ + If `body` starts with a docstring, return the outermost + `SimpleStatementLine` that contains it. Otherwise return `None`. + + Mirrors the descent logic in + `libcst._nodes.statement.get_docstring_impl`, but returns the wrapping + statement so its source positions can be elided from a `Verbatim` block. + """ + expr: cst.CSTNode = body + statement: Optional[cst.SimpleStatementLine] = None + while isinstance(expr, (cst.BaseSuite, cst.SimpleStatementLine)): + if statement is None and isinstance(expr, cst.SimpleStatementLine): + statement = expr + if not expr.body: + return None + expr = expr.body[0] + if statement is None: + return None + if not isinstance(expr, cst.Expr): + return None + val = expr.value + if not isinstance(val, (cst.SimpleString, cst.ConcatenatedString)): + return None + if isinstance(val.evaluated_value, bytes): + return None + return statement diff --git a/src/docc/plugins/verbatim/__init__.py b/src/docc/plugins/verbatim/__init__.py index f54d850..1436e2d 100644 --- a/src/docc/plugins/verbatim/__init__.py +++ b/src/docc/plugins/verbatim/__init__.py @@ -248,6 +248,42 @@ def __repr__(self) -> str: return f"Fragment({self.start}, {self.end}, {self.highlights!r})" +class Hidden(Node): + """ + A range of text from a `Source` that should be omitted from the output. + + `Hidden` nodes appear inside `Verbatim` blocks just like `Fragment` nodes, + but instead of contributing text to the output they advance past the + indicated range without emitting anything. + """ + + start: Pos + end: Pos + + def __init__(self, start: Pos, end: Pos) -> None: + self.start = start + self.end = end + + @property + def children(self) -> Tuple[()]: + """ + Child nodes belonging to this node. + """ + return () + + def replace_child(self, old: Node, new: Node) -> None: + """ + Replace the old child with a new one. + """ + raise TypeError() + + def __repr__(self) -> str: + """ + Textual representation of this instance. + """ + return f"Hidden({self.start}, {self.end})" + + @dataclass class _VerbatimContext: node: Verbatim @@ -267,7 +303,7 @@ def __init__(self) -> None: self.end = None def enter(self, node: Node) -> Visit: - if isinstance(node, Fragment): + if isinstance(node, (Fragment, Hidden)): if self.start is None or node.start < self.start: self.start = node.start @@ -411,6 +447,32 @@ def _exit_fragment(self, node: Fragment) -> None: self._depth = depth - 1 + def _enter_hidden(self, node: Hidden) -> Visit: + depth = self._depth + + if depth is None or depth < 1: + raise Exception("Hidden nodes must appear inside Verbatim") + + verbatim = self._verbatim + assert verbatim is not None + + self._copy(node.start.line, node.start) + + self.begin_highlight(["hidden"]) + self.text("") + self.end_highlight() + + if verbatim.written is None: + verbatim.written = _Pos(line=node.end.line, column=node.end.column) + else: + verbatim.written.line = node.end.line + verbatim.written.column = node.end.column + + return Visit.SkipChildren + + def _exit_hidden(self, node: Hidden) -> None: + pass + def _enter_verbatim(self, node: Verbatim) -> Visit: if self._depth is not None: raise Exception("Verbatim nodes cannot be nested") @@ -431,6 +493,8 @@ def enter(self, node: Node) -> Visit: """ if isinstance(node, Fragment): return self._enter_fragment(node) + elif isinstance(node, Hidden): + return self._enter_hidden(node) elif isinstance(node, Verbatim): return self._enter_verbatim(node) else: @@ -447,6 +511,8 @@ def exit(self, node: Node) -> None: """ if isinstance(node, Fragment): return self._exit_fragment(node) + elif isinstance(node, Hidden): + return self._exit_hidden(node) elif isinstance(node, Verbatim): return self._exit_verbatim(node) else: diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py new file mode 100644 index 0000000..6beb0fe --- /dev/null +++ b/tests/test_verbatim.py @@ -0,0 +1,108 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from pathlib import PurePath +from typing import Any, List, Optional, TextIO + +from typing_extensions import override + +from docc.document import Document, Node +from docc.plugins.verbatim import ( + Fragment, + Hidden, + Highlight, + Line, + Pos, + Text, + Transcribe, + Transcribed, + Verbatim, +) +from docc.settings import PluginSettings +from docc.source import TextSource + + +class _Source(TextSource): + _text: str + + def __init__(self, text: str) -> None: + self._text = text + + @override + def open(self) -> TextIO: + return StringIO(self._text) + + @property + @override + def output_path(self) -> PurePath: + return PurePath("source") + + @property + @override + def relative_path(self) -> Optional[PurePath]: + return None + + +def _line_numbers(root: Node) -> List[int]: + return [c.number for c in root.children if isinstance(c, Line)] + + +def _line_text(line: Line) -> str: + out: List[str] = [] + + def walk(node: Node) -> None: + if isinstance(node, Text): + out.append(node.text) + return + if isinstance(node, (Line, Highlight)): + for c in node.children: + walk(c) + + walk(line) + return "".join(out) + + +def test_hidden_skips_lines( + make_context: Any, plugin_settings: PluginSettings +) -> None: + text = "def foo():\n A\n B\n C\n D\n" + source = _Source(text) + + verbatim = Verbatim(source) + indented = Fragment( + start=Pos(2, 4), end=Pos(5, 5), highlights=["indented-block"] + ) + indented.append(Hidden(start=Pos(2, 4), end=Pos(4, 5))) + indented.append( + Fragment(start=Pos(5, 4), end=Pos(5, 5), highlights=["last"]) + ) + verbatim.append(indented) + + ctx = make_context(verbatim) + Transcribe(plugin_settings).transform(ctx) + + root = ctx[Document].root + assert isinstance(root, Transcribed) + + # Lines 3 and 4 should be elided, but line 2 (where the hidden block + # starts) is still opened by the parent fragment's leading copy. + assert _line_numbers(root) == [2, 5] + + line_2 = next(c for c in root.children if isinstance(c, Line)) + assert _line_text(line_2) == " " + + line_5 = [c for c in root.children if isinstance(c, Line)][1] + assert _line_text(line_5) == " D"