Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/docc/plugins/html/static/docc.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 53 additions & 7 deletions src/docc/plugins/python/cst.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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__))
Expand All @@ -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
68 changes: 67 additions & 1 deletion src/docc/plugins/verbatim/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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("<snip>")
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")
Expand All @@ -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:
Expand All @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions tests/test_verbatim.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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) == " <snip>"

line_5 = [c for c in root.children if isinstance(c, Line)][1]
assert _line_text(line_5) == " D"
Loading