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"