"
+
+ def test_repr_with_attributes(self) -> None:
+ tag = HTMLTag("a", {"href": "/test"})
+ result = repr(tag)
+ assert "
None:
+ tag = HTMLTag("div", {"data-value": 'test"quote'})
+ result = repr(tag)
+ assert """ in result
+
+ def test_repr_attribute_without_value(self) -> None:
+ tag = HTMLTag("input", {"disabled": None})
+ result = repr(tag)
+ assert "disabled" in result
+
+
+class TestHTMLRoot:
+ def test_init(self) -> None:
+ context = Context({})
+ root = HTMLRoot(context)
+ assert list(root.children) == []
+
+ def test_append(self) -> None:
+ context = Context({})
+ root = HTMLRoot(context)
+ tag = HTMLTag("div")
+ root.append(tag)
+ assert tag in root.children
+
+ def test_replace_child(self) -> None:
+ context = Context({})
+ root = HTMLRoot(context)
+ old = HTMLTag("div")
+ new = HTMLTag("span")
+ root.append(old)
+
+ root.replace_child(old, new)
+ assert old not in root.children
+ assert new in root.children
+
+ def test_extension(self) -> None:
+ context = Context({})
+ root = HTMLRoot(context)
+ assert root.extension == ".html"
+
+
+class TestHTMLContext:
+ def test_provides_html_type(self) -> None:
+ assert HTMLContext.provides() == HTML
+
+ def test_init_default_values(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ ctx = HTMLContext(plugin_settings)
+ html = ctx.provide()
+ assert html.extra_css == []
+ assert html.breadcrumbs is True
+
+ def test_init_with_extra_css(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.html.context": {
+ "extra_css": ["custom.css", "theme.css"]
+ }
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.html.context")
+ ctx = HTMLContext(plugin_settings)
+ html = ctx.provide()
+ assert html.extra_css == ["custom.css", "theme.css"]
+
+ def test_init_invalid_extra_css_raises(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {"docc.html.context": {"extra_css": [123]}}
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.html.context")
+ with pytest.raises(SettingsError, match="extra_css"):
+ HTMLContext(plugin_settings)
+
+ def test_init_breadcrumbs_false(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.html.context": {"breadcrumbs": False}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.html.context")
+ ctx = HTMLContext(plugin_settings)
+ html = ctx.provide()
+ assert html.breadcrumbs is False
+
+ def test_init_invalid_breadcrumbs_raises(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.html.context": {"breadcrumbs": "yes"}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.html.context")
+ with pytest.raises(SettingsError, match="breadcrumbs"):
+ HTMLContext(plugin_settings)
+
+
+class TestHTMLDiscover:
+ def test_discover_yields_static_resources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ discover = HTMLDiscover(plugin_settings)
+ sources = list(discover.discover(frozenset()))
+
+ assert len(sources) == 4
+
+ output_paths = [str(s.output_path) for s in sources]
+ assert any("chota" in p for p in output_paths)
+ assert any("docc" in p for p in output_paths)
+ assert any("fuse" in p for p in output_paths)
+ assert any("search" in p for p in output_paths)
+
+
+class TestHTMLParser:
+ def test_parse_simple_tag(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed("hello
")
+
+ children = list(parser.root.children)
+ assert len(children) == 1
+ child = children[0]
+ assert isinstance(child, HTMLTag)
+ assert child.tag_name == "div"
+
+ def test_parse_nested_tags(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed("text
")
+
+ children = list(parser.root.children)
+ assert len(children) == 1
+ div = children[0]
+ assert isinstance(div, HTMLTag)
+
+ span = list(div.children)[0]
+ assert isinstance(span, HTMLTag)
+ assert span.tag_name == "span"
+
+ def test_parse_with_attributes(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed('click')
+
+ children = list(parser.root.children)
+ anchor = children[0]
+ assert isinstance(anchor, HTMLTag)
+ assert anchor.attributes["href"] == "/test"
+ assert anchor.attributes["class"] == "link"
+
+ def test_parse_text_content(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed("
hello world
")
+
+ children = list(parser.root.children)
+ p = children[0]
+ assert isinstance(p, HTMLTag)
+ text_children = list(p.children)
+ assert len(text_children) == 1
+ text_child = text_children[0]
+ assert isinstance(text_child, TextNode)
+ assert text_child._value == "hello world"
+
+ def test_parse_multiple_elements(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed("
one
two
")
+
+ children = list(parser.root.children)
+ assert len(children) == 2
+
+
+class TestElementTreeVisitor:
+ def test_basic_tag(self) -> None:
+ tag = HTMLTag("div")
+ visitor = _ElementTreeVisitor()
+ tag.visit(visitor)
+ element = visitor.builder.close()
+
+ assert element.tag == "div"
+
+ def test_tag_with_attributes(self) -> None:
+ tag = HTMLTag("a", {"href": "/test"})
+ visitor = _ElementTreeVisitor()
+ tag.visit(visitor)
+ element = visitor.builder.close()
+
+ assert element.attrib["href"] == "/test"
+
+ def test_nested_tags(self) -> None:
+ parent = HTMLTag("div")
+ child = HTMLTag("span")
+ parent.append(child)
+
+ visitor = _ElementTreeVisitor()
+ parent.visit(visitor)
+ element = visitor.builder.close()
+
+ assert element.tag == "div"
+ assert len(list(element)) == 1
+ assert list(element)[0].tag == "span"
+
+ def test_text_node(self) -> None:
+ tag = HTMLTag("p")
+ tag.append(TextNode("hello"))
+
+ visitor = _ElementTreeVisitor()
+ tag.visit(visitor)
+ element = visitor.builder.close()
+
+ assert element.text == "hello"
+
+
+class TestMakeRelative:
+ def test_same_path_returns_none(self) -> None:
+ path = PurePath("a/b/c")
+ result = _make_relative(path, path)
+ assert result is None
+
+ def test_sibling_file(self) -> None:
+ from_path = PurePath("a/b/c.html")
+ to_path = PurePath("a/b/d.html")
+ result = _make_relative(from_path, to_path)
+ assert result == PurePath("d.html")
+
+ def test_parent_directory(self) -> None:
+ from_path = PurePath("a/b/c.html")
+ to_path = PurePath("a/d.html")
+ result = _make_relative(from_path, to_path)
+ assert result == PurePath("../d.html")
+
+ def test_deeper_directory(self) -> None:
+ from_path = PurePath("a/b.html")
+ to_path = PurePath("a/c/d.html")
+ result = _make_relative(from_path, to_path)
+ assert result == PurePath("c/d.html")
+
+
+class TestRenderFunctions:
+ def test_blank_node_returns_none(self) -> None:
+ context = Context({})
+ parent = HTMLRoot(context)
+ blank = BlankNode()
+
+ result = blank_node(context, parent, blank)
+ assert result is None
+
+ def test_list_node_returns_parent(self) -> None:
+ context = Context({})
+ parent = HTMLRoot(context)
+ node = ListNode()
+
+ result = list_node(context, parent, node)
+ assert result is parent
+
+ def test_html_tag_appends_to_parent(self) -> None:
+ context = Context({})
+ parent = HTMLRoot(context)
+ tag = HTMLTag("div")
+
+ result = html_tag(context, parent, tag)
+ assert result is None
+ assert tag in parent.children
+
+ def test_text_node_appends_to_parent(self) -> None:
+ context = Context({})
+ parent = HTMLRoot(context)
+ text = TextNode("hello")
+
+ result = text_node(context, parent, text)
+ assert result is None
+ assert text in parent.children
+
+
+class TestHTMLTransform:
+ def test_transform_skips_output_nodes(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ context_obj = Context({})
+ root = HTMLRoot(context_obj)
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = HTMLTransform(plugin_settings)
+ transform.transform(context)
+
+ assert context[Document].root is root
+
+
+class TestHTMLEdgeCases:
+ def test_html_tag_none_attribute_value(self) -> None:
+ tag = HTMLTag("input", {"disabled": None, "type": "text"})
+ result = repr(tag)
+ assert "disabled" in result
+ assert 'type="text"' in result
+
+ def test_deeply_nested_html(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ html = "
"
+ parser.feed(html)
+
+ children = list(parser.root.children)
+ assert len(children) == 1
+
+ def test_html_with_special_characters(self) -> None:
+ context = Context({})
+ parser = HTMLParser(context)
+ parser.feed("
<script>
")
+
+ children = list(parser.root.children)
+ p = children[0]
+ assert isinstance(p, HTMLTag)
+ text_children = list(p.children)
+ text_child = text_children[0]
+ assert isinstance(text_child, TextNode)
+ assert "")
+ assert text._value == ""
+
+
+class TestHTMLVisitorTraversal:
+ def test_visitor_traversal_blank_node(self) -> None:
+ context = Context({})
+ visitor = HTMLVisitor(context)
+
+ blank = BlankNode()
+ blank.visit(visitor)
+
+ assert len(visitor.stack) == 1
+ assert visitor.stack[0] is visitor.root
diff --git a/tests/test_html_renderer_cache.py b/tests/test_html_renderer_cache.py
new file mode 100644
index 0000000..dfc4fcd
--- /dev/null
+++ b/tests/test_html_renderer_cache.py
@@ -0,0 +1,129 @@
+# Copyright (C) 2025 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
.
+
+"""Tests for the shared renderer cache in the HTML plugin."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Node, Visit
+from docc.plugins.html import (
+ _LOADED_RENDERERS,
+ HTMLVisitor,
+)
+
+
+@pytest.fixture(autouse=True) # noqa: SC200
+def _clear_renderer_cache() -> None:
+ _LOADED_RENDERERS.clear()
+
+
+class TestRendererCacheBehavioral:
+ """Verify that HTMLVisitor resolves renderers and produces output."""
+
+ def test_visitor_resolves_blank_node_renderer(self) -> None:
+ context = Context({})
+ visitor = HTMLVisitor(context)
+ blank = BlankNode()
+
+ result = visitor.enter(blank)
+
+ assert result == Visit.SkipChildren
+
+ def test_visitor_produces_expected_output_for_blank_node(self) -> None:
+ context = Context({})
+ visitor = HTMLVisitor(context)
+ blank = BlankNode()
+
+ initial_stack_len = len(visitor.stack)
+ visitor.enter(blank)
+
+ assert len(visitor.stack) == initial_stack_len + 1
+ assert isinstance(visitor.stack[-1], BlankNode)
+
+
+class TestRendererCacheCallCount:
+ """Verify that EntryPoint.load() is called at most once per node type."""
+
+ def test_load_called_once_for_two_visitors(self) -> None:
+ mock_renderer = MagicMock(return_value=None)
+ mock_entry_point = MagicMock()
+ mock_entry_point.load.return_value = mock_renderer
+
+ context = Context({})
+ first_visitor = HTMLVisitor(context)
+ second_visitor = HTMLVisitor(context)
+
+ key = "docc.document:BlankNode"
+
+ # Inject the mock entry point into both visitors.
+ first_visitor.entry_points[key] = mock_entry_point
+ second_visitor.entry_points[key] = mock_entry_point
+
+ # First visitor triggers load.
+ first_visitor.enter(BlankNode())
+ assert mock_entry_point.load.call_count == 1
+
+ # Second visitor reuses the cache; load is not called again.
+ second_visitor.enter(BlankNode())
+ assert mock_entry_point.load.call_count == 1
+
+ def test_load_called_once_for_same_visitor_twice(self) -> None:
+ mock_renderer = MagicMock(return_value=None)
+ mock_entry_point = MagicMock()
+ mock_entry_point.load.return_value = mock_renderer
+
+ context = Context({})
+ visitor = HTMLVisitor(context)
+
+ key = "docc.document:BlankNode"
+ visitor.entry_points[key] = mock_entry_point
+
+ visitor.enter(BlankNode())
+ # Pop the stack entry added by enter() so we can call enter() again.
+ visitor.stack.pop()
+ visitor.enter(BlankNode())
+
+ assert mock_entry_point.load.call_count == 1
+
+
+class TestRendererCacheKeying:
+ """Verify that the shared cache is keyed by Type[Node] subclasses."""
+
+ def test_cache_contains_blank_node_after_visit(self) -> None:
+ context = Context({})
+ visitor = HTMLVisitor(context)
+ visitor.enter(BlankNode())
+
+ assert BlankNode in _LOADED_RENDERERS
+
+ def test_cache_values_are_callable(self) -> None:
+ context = Context({})
+ visitor = HTMLVisitor(context)
+ visitor.enter(BlankNode())
+
+ for key, value in _LOADED_RENDERERS.items():
+ assert issubclass(key, Node), f"Key {key} is not a Node subclass."
+ assert callable(value), f"Value for {key} is not callable."
+
+ def test_visitors_share_same_renderers_dict(self) -> None:
+ context = Context({})
+ first_visitor = HTMLVisitor(context)
+ second_visitor = HTMLVisitor(context)
+
+ assert first_visitor.renderers is second_visitor.renderers
+ assert first_visitor.renderers is _LOADED_RENDERERS
diff --git a/tests/test_integration.py b/tests/test_integration.py
new file mode 100644
index 0000000..dc0e4f4
--- /dev/null
+++ b/tests/test_integration.py
@@ -0,0 +1,1001 @@
+# Copyright (C) 2025 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
.
+
+import tempfile
+from pathlib import Path, PurePath
+from typing import Dict, Iterator, List, Optional, Set, Tuple, Type
+
+import libcst
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document, ListNode, Node, Visit, Visitor
+from docc.plugins.mistletoe import (
+ DocstringTransform,
+ MarkdownNode,
+ ReferenceTransform,
+ _SearchVisitor,
+)
+from docc.plugins.python import nodes
+from docc.plugins.python.cst import (
+ PythonBuilder,
+ PythonDiscover,
+ PythonTransform,
+)
+from docc.plugins.references import (
+ Definition,
+ Index,
+ IndexTransform,
+ Reference,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+class MockSource(Source):
+ _output_path: PurePath
+
+ def __init__(self, output_path: Optional[PurePath] = None) -> None:
+ self._output_path = (
+ output_path if output_path is not None else PurePath("test.py")
+ )
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._output_path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._output_path
+
+
+class NodeCollector(Visitor):
+ """Visitor that collects all nodes in the tree for easy assertion."""
+
+ def __init__(self) -> None:
+ self.all_nodes: List[Node] = []
+ self.by_type: Dict[Type[Node], List[Node]] = {}
+
+ def enter(self, node: Node) -> Visit:
+ self.all_nodes.append(node)
+ node_type = type(node)
+ if node_type not in self.by_type:
+ self.by_type[node_type] = []
+ self.by_type[node_type].append(node)
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ def get(self, node_type: Type[Node]) -> List[Node]:
+ return self.by_type.get(node_type, [])
+
+ def get_names(self, node_type: Type[Node]) -> List[str]:
+ """Extract name strings from nodes that have a name attribute."""
+ result = []
+ for node in self.get(node_type):
+ name_node = getattr(node, "name", None)
+ if isinstance(name_node, nodes.Name):
+ result.append(name_node.name)
+ return result
+
+
+def _run_pipeline(
+ temp_dir: Path,
+ py_content: str,
+ filename: str = "example.py",
+) -> Tuple[Document, Source, Context]:
+ """Helper: write source, discover, build, and transform."""
+ (temp_dir / filename).write_text(py_content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ "docc.python.transform": {},
+ }
+ }
+ }
+ },
+ )
+
+ discover_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(discover_settings)
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(discover_settings)
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ document = list(documents.values())[0]
+ source = list(documents.keys())[0]
+ index = Index()
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform_settings = settings.for_plugin("docc.python.transform")
+ transform = PythonTransform(transform_settings)
+ transform.transform(context)
+
+ return document, source, context
+
+
+class TestPythonPipeline:
+ def test_discover_build_transform_pipeline(self, temp_dir: Path) -> None:
+ py_content = '''"""Module docstring."""
+
+class MyClass:
+ """A test class."""
+
+ def method(self, x: int) -> str:
+ """A method."""
+ return str(x)
+
+
+def standalone_func(arg: str) -> None:
+ """Standalone function."""
+ pass
+'''
+ (temp_dir / "example.py").write_text(py_content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ "docc.python.transform": {},
+ }
+ }
+ }
+ },
+ )
+
+ discover_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(discover_settings)
+ sources: Set[Source] = set(discover.discover(frozenset()))
+ assert len(sources) == 1
+
+ builder = PythonBuilder(discover_settings)
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+ assert len(documents) == 1
+
+ document = list(documents.values())[0]
+ source = list(documents.keys())[0]
+ index = Index()
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform_settings = settings.for_plugin("docc.python.transform")
+ transform = PythonTransform(transform_settings)
+ transform.transform(context)
+
+ # Verify the tree contains Module, Class, Function nodes
+ class NodeTypeChecker(Visitor):
+ found_module = False
+ found_class = False
+ found_function = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, nodes.Module):
+ self.found_module = True
+ elif isinstance(node, nodes.Class):
+ self.found_class = True
+ elif isinstance(node, nodes.Function):
+ self.found_function = True
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = NodeTypeChecker()
+ document.root.visit(checker)
+ assert checker.found_module, "Should contain a Module node"
+ assert checker.found_class, "Should contain a Class node"
+ assert checker.found_function, "Should contain a Function"
+
+ # Strengthened assertions: verify names and structure
+ collector = NodeCollector()
+ document.root.visit(collector)
+
+ # Module should have a name derived from the filename
+ modules = collector.get(nodes.Module)
+ assert len(modules) == 1
+ module = modules[0]
+ assert isinstance(module, nodes.Module)
+ assert isinstance(module.name, nodes.Name)
+
+ # Class should be named "MyClass"
+ class_names = collector.get_names(nodes.Class)
+ assert (
+ "MyClass" in class_names
+ ), f"Expected 'MyClass' in class names, got {class_names}"
+
+ # Functions should include "method" and "standalone_func"
+ func_names = collector.get_names(nodes.Function)
+ assert (
+ "method" in func_names
+ ), f"Expected 'method' in function names, got {func_names}"
+ assert (
+ "standalone_func" in func_names
+ ), f"Expected 'standalone_func' in function names, got {func_names}"
+
+ # The "method" function should have parameters
+ for func_node in collector.get(nodes.Function):
+ assert isinstance(func_node, nodes.Function)
+ if (
+ isinstance(func_node.name, nodes.Name)
+ and func_node.name.name == "method"
+ ):
+ assert isinstance(func_node.parameters, ListNode)
+ params = list(func_node.parameters.children)
+ assert len(params) > 0, "method should have parameters"
+ break
+ else:
+ raise AssertionError("Function 'method' not found")
+
+ def test_python_with_docstring_transform(self, temp_dir: Path) -> None:
+ py_content = '''"""
+Module with **markdown** docstring.
+"""
+
+def func():
+ """Function with *emphasis*."""
+ pass
+'''
+ (temp_dir / "markdown_example.py").write_text(py_content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ },
+ )
+
+ discover_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(discover_settings)
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(discover_settings)
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ document = list(documents.values())[0]
+ source = list(documents.keys())[0]
+ index = Index()
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = PythonTransform(
+ settings.for_plugin("docc.python.transform")
+ )
+ transform.transform(context)
+
+ docstring_transform = DocstringTransform(
+ settings.for_plugin("docc.mistletoe.transform")
+ )
+ docstring_transform.transform(context)
+
+ # Verify docstrings were converted to MarkdownNode
+ class MarkdownNodeChecker(Visitor):
+ found_markdown = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, MarkdownNode):
+ self.found_markdown = True
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = MarkdownNodeChecker()
+ document.root.visit(checker)
+ assert (
+ checker.found_markdown
+ ), "Docstrings should be converted to MarkdownNode"
+
+ # Strengthened assertions: verify markdown content
+ collector = NodeCollector()
+ document.root.visit(collector)
+ markdown_nodes = collector.get(MarkdownNode)
+ assert (
+ len(markdown_nodes) >= 1
+ ), "Should have at least one MarkdownNode"
+
+ # At least one markdown node should contain searchable text
+ # from the module or function docstring
+ all_search_text = []
+ for md_node in markdown_nodes:
+ assert isinstance(md_node, MarkdownNode)
+ search_content = md_node.to_search()
+ if isinstance(search_content, str):
+ all_search_text.append(search_content)
+ combined_text = " ".join(all_search_text)
+ assert (
+ "markdown" in combined_text.lower()
+ ), f"Expected 'markdown' in search text, got: {combined_text}"
+ assert (
+ "emphasis" in combined_text.lower()
+ ), f"Expected 'emphasis' in search text, got: {combined_text}"
+
+
+class TestMistletoeTransforms:
+ def test_docstring_to_markdown(self) -> None:
+ docstring = nodes.Docstring("This is **bold** and *italic*.")
+ root = ListNode([docstring])
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = DocstringTransform(
+ PluginSettings(Settings(Path("."), {}), {})
+ )
+ transform.transform(context)
+
+ assert not isinstance(list(document.root.children)[0], nodes.Docstring)
+
+ def test_reference_transform_converts_ref_links(self) -> None:
+ import mistletoe as md
+
+ markdown = "[link text](ref:some.identifier)"
+ root = MarkdownNode(md.Document(markdown))
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = ReferenceTransform(
+ PluginSettings(Settings(Path("."), {}), {})
+ )
+ transform.transform(context)
+
+ class ReferenceChecker(Visitor):
+ found_reference = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, Reference):
+ self.found_reference = True
+ assert node.identifier == "some.identifier"
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = ReferenceChecker()
+ document.root.visit(checker)
+ assert checker.found_reference
+
+ def test_reference_transform_ignores_http_links(self) -> None:
+ import mistletoe as md
+
+ markdown = "[external](https://example.com)"
+ root = MarkdownNode(md.Document(markdown))
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = ReferenceTransform(
+ PluginSettings(Settings(Path("."), {}), {})
+ )
+ transform.transform(context)
+
+ class ReferenceChecker(Visitor):
+ found_reference = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, Reference):
+ self.found_reference = True
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = ReferenceChecker()
+ document.root.visit(checker)
+ assert not checker.found_reference
+
+
+class TestIndexTransformIntegration:
+ def test_indexes_definitions_in_tree(self) -> None:
+ first_definition = Definition(identifier="module.ClassA")
+ second_definition = Definition(identifier="module.func_b")
+ root = ListNode([first_definition, second_definition])
+ document = Document(root)
+
+ source = MockSource()
+ index = Index()
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = IndexTransform(PluginSettings(Settings(Path("."), {}), {}))
+ transform.transform(context)
+
+ assert first_definition.specifier == 0
+ assert second_definition.specifier == 0
+
+ class_locations = list(index.lookup("module.ClassA"))
+ func_locations = list(index.lookup("module.func_b"))
+ assert len(class_locations) == 1
+ assert len(func_locations) == 1
+
+
+class TestSearchVisitor:
+ def test_collect_text_from_markdown(self) -> None:
+ import mistletoe as md
+
+ markdown = "Hello **world** and *everyone*"
+ root = MarkdownNode(md.Document(markdown))
+
+ texts = _SearchVisitor.collect(root)
+ combined = " ".join(texts)
+
+ assert "Hello" in combined
+ assert "world" in combined
+ assert "everyone" in combined
+
+
+class TestMarkdownNode:
+ def test_children_lazy_loaded(self) -> None:
+ import mistletoe as md
+
+ markdown = "Test **bold**"
+ node = MarkdownNode(md.Document(markdown))
+
+ children = list(node.children)
+ assert len(children) > 0
+
+ def test_replace_child(self) -> None:
+ import mistletoe as md
+
+ markdown = "Test **bold**"
+ node = MarkdownNode(md.Document(markdown))
+
+ children = list(node.children)
+ old_child = children[0]
+ new_child = BlankNode()
+
+ node.replace_child(old_child, new_child)
+
+ new_children = list(node.children)
+ assert new_child in new_children
+
+ def test_to_search(self) -> None:
+ import mistletoe as md
+
+ markdown = "Searchable text here"
+ node = MarkdownNode(md.Document(markdown))
+
+ result = node.to_search()
+ assert "Searchable" in result
+
+ def test_search_children_returns_false(self) -> None:
+ import mistletoe as md
+
+ markdown = "Test"
+ node = MarkdownNode(md.Document(markdown))
+
+ assert node.search_children() is False
+
+ def test_repr(self) -> None:
+ import mistletoe as md
+
+ markdown = "Test"
+ node = MarkdownNode(md.Document(markdown))
+
+ assert "MarkdownNode" in repr(node)
+ assert "Document" in repr(node)
+
+
+class TestFullTransformPipeline:
+ def test_python_to_html_references(self, temp_dir: Path) -> None:
+ py_content = '''"""
+Module with references.
+
+See [other](ref:module.other) for more.
+"""
+
+def other():
+ """Another function."""
+ pass
+'''
+ (temp_dir / "references.py").write_text(py_content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ },
+ )
+
+ discover = PythonDiscover(settings.for_plugin("docc.python.discover"))
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(settings.for_plugin("docc.python.discover"))
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ document = list(documents.values())[0]
+ source = list(documents.keys())[0]
+ index = Index()
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ PythonTransform(
+ settings.for_plugin("docc.python.transform")
+ ).transform(context)
+
+ DocstringTransform(
+ settings.for_plugin("docc.mistletoe.transform")
+ ).transform(context)
+
+ ReferenceTransform(
+ settings.for_plugin("docc.mistletoe.reference")
+ ).transform(context)
+
+ IndexTransform(settings.for_plugin("docc.references.index")).transform(
+ context
+ )
+
+ # Verify the Reference was found and Index has the definition
+ class ReferenceChecker(Visitor):
+ found_reference = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, Reference):
+ self.found_reference = True
+ assert node.identifier == "module.other"
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = ReferenceChecker()
+ document.root.visit(checker)
+ assert (
+ checker.found_reference
+ ), "Reference to module.other should be found"
+
+ # Verify function was indexed
+ locations = list(index.lookup("references.other"))
+ assert (
+ len(locations) == 1
+ ), "Function 'other' should be indexed exactly once"
+
+
+class TestEdgeCases:
+ def test_empty_python_file(self, temp_dir: Path) -> None:
+ (temp_dir / "empty.py").write_text("")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ },
+ )
+
+ discover = PythonDiscover(settings.for_plugin("docc.python.discover"))
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(settings.for_plugin("docc.python.discover"))
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ assert len(documents) == 1
+ # Verify the document was created and can be traversed
+ document = list(documents.values())[0]
+ source = list(documents.keys())[0]
+ assert (
+ len(list(document.root.children)) >= 0
+ ), "Root should be traversable"
+
+ # Transform the empty file to get the output node types
+ index = Index()
+ context = Context({Document: document, Source: source, Index: index})
+ transform = PythonTransform(
+ settings.for_plugin("docc.python.transform")
+ )
+ transform.transform(context)
+
+ # After transform, should have a Module node
+ collector = NodeCollector()
+ document.root.visit(collector)
+ modules = collector.get(nodes.Module)
+ assert (
+ len(modules) == 1
+ ), "Empty file should produce exactly one Module"
+
+ module = modules[0]
+ assert isinstance(module, nodes.Module)
+
+ # Empty module should have no class/function/attribute members
+ assert (
+ len(collector.get(nodes.Class)) == 0
+ ), "Empty module should have no classes"
+ assert (
+ len(collector.get(nodes.Function)) == 0
+ ), "Empty module should have no functions"
+ assert (
+ len(collector.get(nodes.Attribute)) == 0
+ ), "Empty module should have no attributes"
+ assert (
+ len(collector.get(nodes.Docstring)) == 0
+ ), "Empty module should have no docstrings"
+
+ def test_python_with_syntax_error_handled(self, temp_dir: Path) -> None:
+ (temp_dir / "syntax_error.py").write_text("def broken(\n")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ },
+ )
+
+ discover = PythonDiscover(settings.for_plugin("docc.python.discover"))
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(settings.for_plugin("docc.python.discover"))
+ documents: Dict[Source, Document] = {}
+
+ with pytest.raises(libcst.ParserSyntaxError):
+ builder.build(sources, documents)
+
+ def test_nested_classes(self, temp_dir: Path) -> None:
+ py_content = '''"""Module."""
+
+class Outer:
+ """Outer class."""
+
+ class Inner:
+ """Inner class."""
+
+ def method(self) -> None:
+ """Inner method."""
+ pass
+'''
+ (temp_dir / "nested.py").write_text(py_content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ },
+ )
+
+ discover = PythonDiscover(settings.for_plugin("docc.python.discover"))
+ sources: Set[Source] = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(settings.for_plugin("docc.python.discover"))
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ assert len(documents) == 1
+
+ # Verify nested class structure was captured
+ document = list(documents.values())[0]
+ import libcst
+
+ from docc.plugins.python.cst import CstNode
+
+ class NestedClassChecker(Visitor):
+ found_outer = False
+ found_inner = False
+ found_method = False
+
+ def enter(self, node: Node) -> Visit:
+ if isinstance(node, CstNode):
+ cst = node.cst_node
+ if isinstance(cst, libcst.ClassDef):
+ if cst.name.value == "Outer":
+ self.found_outer = True
+ elif cst.name.value == "Inner":
+ self.found_inner = True
+ elif isinstance(cst, libcst.FunctionDef):
+ if cst.name.value == "method":
+ self.found_method = True
+ return Visit.TraverseChildren
+
+ def exit(self, node: Node) -> None:
+ pass
+
+ checker = NestedClassChecker()
+ document.root.visit(checker)
+
+ assert checker.found_outer, "Outer class should be found"
+ assert checker.found_inner, "Inner class should be found"
+ assert checker.found_method, "Inner method should be found"
+
+
+class TestPythonTransformContract:
+ """
+ Behavioral-level tests for the Python source -> document tree pipeline.
+
+ These tests assert on the OUTPUT contract (document tree structure using
+ node types from docc.plugins.python.nodes), NOT on CST internals. They
+ should survive a CST -> AST migration unchanged.
+ """
+
+ def test_module_with_class_and_function(self, temp_dir: Path) -> None:
+ """Verify structural output for a module with class and function."""
+ py_content = '''"""Module docstring."""
+
+class MyClass:
+ """A test class."""
+
+ def method(self, x: int) -> str:
+ """A method."""
+ return str(x)
+
+def standalone_func(arg: str) -> None:
+ """Standalone function."""
+ pass
+'''
+ document, source, context = _run_pipeline(temp_dir, py_content)
+
+ collector = NodeCollector()
+ document.root.visit(collector)
+
+ # The document tree root should contain a Definition wrapping a Module
+ definitions = collector.get(Definition)
+ module_definitions = [
+ d
+ for d in definitions
+ if isinstance(d, Definition) and isinstance(d.child, nodes.Module)
+ ]
+ assert (
+ len(module_definitions) >= 1
+ ), "Should have a Definition wrapping a nodes.Module"
+
+ # There should be exactly one Module
+ modules = collector.get(nodes.Module)
+ assert len(modules) == 1, f"Expected 1 Module, got {len(modules)}"
+ module = modules[0]
+ assert isinstance(module, nodes.Module)
+
+ # The Module should have the module docstring
+ docstrings = collector.get(nodes.Docstring)
+ docstring_texts = [
+ d.text for d in docstrings if isinstance(d, nodes.Docstring)
+ ]
+ assert any(
+ "Module docstring" in t for t in docstring_texts
+ ), f"Expected module docstring, got: {docstring_texts}"
+
+ # The Module members should include a Definition wrapping a Class
+ # named "MyClass"
+ classes = collector.get(nodes.Class)
+ assert len(classes) >= 1, "Should have at least one Class node"
+ class_names = [
+ c.name.name
+ for c in classes
+ if isinstance(c, nodes.Class) and isinstance(c.name, nodes.Name)
+ ]
+ assert (
+ "MyClass" in class_names
+ ), f"Expected class 'MyClass', got: {class_names}"
+
+ # MyClass should be wrapped in a Definition
+ class_definitions = [
+ d
+ for d in definitions
+ if isinstance(d, Definition) and isinstance(d.child, nodes.Class)
+ ]
+ assert (
+ len(class_definitions) >= 1
+ ), "Class should be wrapped in a Definition"
+
+ # The Class should have members including a Function named "method"
+ functions = collector.get(nodes.Function)
+ func_names = [
+ f.name.name
+ for f in functions
+ if isinstance(f, nodes.Function) and isinstance(f.name, nodes.Name)
+ ]
+ assert (
+ "method" in func_names
+ ), f"Expected function 'method', got: {func_names}"
+ assert (
+ "standalone_func" in func_names
+ ), f"Expected function 'standalone_func', got: {func_names}"
+
+ # The method Function should be wrapped in a Definition
+ func_definitions = [
+ d
+ for d in definitions
+ if isinstance(d, Definition)
+ and isinstance(d.child, nodes.Function)
+ ]
+ assert (
+ len(func_definitions) >= 1
+ ), "Functions should be wrapped in Definitions"
+
+ # Verify docstrings exist for class and functions
+ assert any(
+ "A test class" in t for t in docstring_texts
+ ), f"Expected class docstring, got: {docstring_texts}"
+ assert any(
+ "A method" in t for t in docstring_texts
+ ), f"Expected method docstring, got: {docstring_texts}"
+ assert any(
+ "Standalone function" in t for t in docstring_texts
+ ), f"Expected standalone_func docstring, got: {docstring_texts}"
+
+ def test_class_with_attributes(self, temp_dir: Path) -> None:
+ """Verify that annotated class attributes produce Attribute nodes."""
+ py_content = '''"""Module."""
+
+class Config:
+ """Configuration class."""
+
+ timeout: int
+ """Timeout in seconds."""
+
+ name: str
+'''
+ document, source, context = _run_pipeline(
+ temp_dir, py_content, filename="config.py"
+ )
+
+ collector = NodeCollector()
+ document.root.visit(collector)
+
+ # Class "Config" should exist
+ classes = collector.get(nodes.Class)
+ config_classes = [
+ c
+ for c in classes
+ if isinstance(c, nodes.Class)
+ and isinstance(c.name, nodes.Name)
+ and c.name.name == "Config"
+ ]
+ assert (
+ len(config_classes) == 1
+ ), "Should have exactly one class named 'Config'"
+ config_class = config_classes[0]
+ assert isinstance(config_class, nodes.Class)
+
+ # Config should have members
+ assert isinstance(config_class.members, ListNode)
+ member_list = list(config_class.members.children)
+ assert len(member_list) >= 1, "Config should have members"
+
+ # At least one member should be (or wrap) a nodes.Attribute
+ attributes = collector.get(nodes.Attribute)
+ assert len(attributes) >= 1, "Should have at least one Attribute node"
+
+ # The attribute with docstring ("timeout") should have a Docstring
+ attr_with_docstring = None
+ for attr in attributes:
+ assert isinstance(attr, nodes.Attribute)
+ if isinstance(attr.docstring, nodes.Docstring):
+ attr_with_docstring = attr
+ break
+
+ assert (
+ attr_with_docstring is not None
+ ), "At least one attribute should have a Docstring child"
+ assert isinstance(attr_with_docstring.docstring, nodes.Docstring)
+ assert "Timeout in seconds" in attr_with_docstring.docstring.text, (
+ f"Expected 'Timeout in seconds' in docstring, "
+ f"got: {attr_with_docstring.docstring.text}"
+ )
+
+ def test_determinism(self, temp_dir: Path) -> None:
+ """Run the same source twice to verify identical output."""
+ py_content = '''"""Module docstring."""
+
+class MyClass:
+ """A test class."""
+
+ x: int
+ """An attribute."""
+
+ def method(self, x: int) -> str:
+ """A method."""
+ return str(x)
+
+def standalone_func(arg: str) -> None:
+ """Standalone function."""
+ pass
+'''
+ # First run
+ with tempfile.TemporaryDirectory() as td1:
+ doc1, _, _ = _run_pipeline(Path(td1), py_content)
+ collector1 = NodeCollector()
+ doc1.root.visit(collector1)
+
+ # Second run (fresh temp dir, fresh pipeline state)
+ with tempfile.TemporaryDirectory() as td2:
+ doc2, _, _ = _run_pipeline(Path(td2), py_content)
+ collector2 = NodeCollector()
+ doc2.root.visit(collector2)
+
+ # Both runs should produce the same sequence of node types
+ types1 = [type(n).__name__ for n in collector1.all_nodes]
+ types2 = [type(n).__name__ for n in collector2.all_nodes]
+ assert types1 == types2, (
+ f"Tree structure differs between runs:\n"
+ f" Run 1: {types1}\n"
+ f" Run 2: {types2}"
+ )
+
+ # Both runs should produce the same node names in the same order
+ def extract_names(collector: NodeCollector) -> List[str]:
+ result = []
+ for node in collector.all_nodes:
+ if isinstance(node, nodes.Name):
+ result.append(node.name)
+ return result
+
+ names1 = extract_names(collector1)
+ names2 = extract_names(collector2)
+ assert names1 == names2, (
+ f"Name nodes differ between runs:\n"
+ f" Run 1: {names1}\n"
+ f" Run 2: {names2}"
+ )
+
+ # Both runs should produce the same docstrings in the same order
+ docstrings1 = [
+ d.text
+ for d in collector1.get(nodes.Docstring)
+ if isinstance(d, nodes.Docstring)
+ ]
+ docstrings2 = [
+ d.text
+ for d in collector2.get(nodes.Docstring)
+ if isinstance(d, nodes.Docstring)
+ ]
+ assert docstrings1 == docstrings2, (
+ f"Docstrings differ between runs:\n"
+ f" Run 1: {docstrings1}\n"
+ f" Run 2: {docstrings2}"
+ )
diff --git a/tests/test_listing.py b/tests/test_listing.py
new file mode 100644
index 0000000..209d478
--- /dev/null
+++ b/tests/test_listing.py
@@ -0,0 +1,369 @@
+# Copyright (C) 2025 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
.
+
+import tempfile
+from pathlib import Path, PurePath
+from typing import Dict, Iterator, List, Optional, Set
+
+import pytest
+from jinja2 import Environment
+
+import docc.plugins.listing as listing_module
+from docc.context import Context
+from docc.document import BlankNode, Document
+from docc.plugins.html import HTMLTag
+from docc.plugins.listing import (
+ Listable,
+ ListingBuilder,
+ ListingDiscover,
+ ListingNode,
+ ListingSource,
+ _get_listing_env,
+ render_html,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+@pytest.fixture
+def plugin_settings(temp_dir: Path) -> PluginSettings:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.listing.discover")
+
+
+class MockSource(Source):
+ _output_path: PurePath
+
+ def __init__(
+ self,
+ relative_path: Optional[PurePath] = None,
+ output_path: Optional[PurePath] = None,
+ ) -> None:
+ self._relative_path = relative_path
+ self._output_path = output_path or relative_path or PurePath("output")
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._relative_path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._output_path
+
+
+class ListableSource(Source, Listable):
+ def __init__(
+ self,
+ relative_path: Optional[PurePath] = None,
+ show: bool = True,
+ ) -> None:
+ self._relative_path = relative_path
+ self._show = show
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._relative_path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._relative_path or PurePath("output")
+
+ @property
+ def show_in_listing(self) -> bool:
+ return self._show
+
+
+class TestListingSource:
+ def test_init(self) -> None:
+ relative = PurePath("src")
+ output = PurePath("src/index")
+ sources: Set[Source] = set()
+
+ source = ListingSource(relative, output, sources)
+
+ assert source.relative_path == relative
+ assert source.output_path == output
+ assert source.sources is sources
+
+ def test_output_path_property(self) -> None:
+ source = ListingSource(
+ PurePath("docs"),
+ PurePath("docs/index"),
+ set(),
+ )
+ assert source.output_path == PurePath("docs/index")
+
+ def test_relative_path_property(self) -> None:
+ source = ListingSource(
+ PurePath("api"),
+ PurePath("api/index"),
+ set(),
+ )
+ assert source.relative_path == PurePath("api")
+
+
+class TestListingNode:
+ def test_init(self) -> None:
+ sources: Set[Source] = {MockSource(PurePath("test.py"))}
+ node = ListingNode(sources)
+ assert node.sources is sources
+
+ def test_children_empty(self) -> None:
+ node = ListingNode(set())
+ assert node.children == ()
+
+ def test_replace_child_raises(self) -> None:
+ node = ListingNode(set())
+ with pytest.raises(TypeError):
+ node.replace_child(BlankNode(), BlankNode())
+
+
+class TestListingBuilder:
+ def test_build_processes_listing_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = ListingSource(PurePath("docs"), PurePath("docs/index"), set())
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = ListingBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert len(unprocessed) == 0
+ assert source in processed
+ assert isinstance(processed[source].root, ListingNode)
+
+ def test_build_ignores_non_listing_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource(PurePath("test.py"))
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = ListingBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert source in unprocessed
+ assert len(processed) == 0
+
+
+class TestListingDiscover:
+ def test_discover_empty_known(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(frozenset()))
+ assert sources == []
+
+ def test_discover_creates_listing_for_directory(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ known_source = MockSource(PurePath("src/module.py"))
+ known: frozenset[Source] = frozenset([known_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ # "src/module.py" has parents "src" and ".", so 2 listings are created
+ assert len(sources) == 2, "Should create listings for 'src' and root"
+ assert all(isinstance(s, ListingSource) for s in sources)
+ # Verify a listing was created for the 'src' directory
+ src_listing = next(
+ (s for s in sources if s.relative_path == PurePath("src")), None
+ )
+ assert (
+ src_listing is not None
+ ), "Should create listing for 'src' directory"
+
+ def test_discover_creates_nested_listings(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ known_source = MockSource(PurePath("a/b/c/module.py"))
+ known: frozenset[Source] = frozenset([known_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ # Should create listings for a, a/b, a/b/c (at least 3 levels)
+ assert (
+ len(sources) >= 3
+ ), "Should create listings for each directory level"
+ # Verify specific directory listings were created
+ paths = {s.relative_path for s in sources}
+ assert PurePath("a") in paths, "Should create listing for 'a'"
+ assert PurePath("a/b") in paths, "Should create listing for 'a/b'"
+ assert PurePath("a/b/c") in paths, "Should create listing for 'a/b/c'"
+
+ def test_discover_skips_listable_hidden(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ hidden_source = ListableSource(PurePath("hidden/file.py"), show=False)
+ visible_source = MockSource(PurePath("visible/file.py"))
+ known: frozenset[Source] = frozenset([hidden_source, visible_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ listing_paths = [str(s.relative_path) for s in sources]
+ assert "hidden" not in listing_paths
+ assert any("visible" in p for p in listing_paths)
+
+ def test_discover_includes_listable_shown(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ shown_source = ListableSource(PurePath("shown/file.py"), show=True)
+ known: frozenset[Source] = frozenset([shown_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ # "shown/file.py" has parents "shown" and ".", so 2 listings
+ assert len(sources) == 2, "Should create listings for 'shown' and root"
+ # Verify a listing was created for the 'shown' directory
+ shown_listing = next(
+ (s for s in sources if s.relative_path == PurePath("shown")), None
+ )
+ assert (
+ shown_listing is not None
+ ), "Should create listing for 'shown' directory"
+ # Verify the shown source is included in the listing
+ assert (
+ shown_source in shown_listing.sources
+ ), "Shown source should be in listing"
+
+ def test_discover_skips_source_without_path(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ no_path_source = MockSource(
+ relative_path=None, output_path=PurePath("out")
+ )
+ known: frozenset[Source] = frozenset([no_path_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ assert sources == []
+
+ def test_discover_adds_sources_to_listing(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ first_source = MockSource(PurePath("dir/file1.py"))
+ second_source = MockSource(PurePath("dir/file2.py"))
+ known: frozenset[Source] = frozenset([first_source, second_source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ dir_listing = next(
+ (s for s in sources if s.relative_path == PurePath("dir")), None
+ )
+ assert dir_listing is not None
+ assert first_source in dir_listing.sources
+ assert second_source in dir_listing.sources
+
+ def test_discover_listable_with_no_relative_path_falls_back_to_output_path(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ """
+ Test that Listable sources with relative_path=None fall
+ back to output_path for directory listing discovery.
+ """
+ source = ListableSource(relative_path=None, show=True)
+ known: frozenset[Source] = frozenset([source])
+
+ discover = ListingDiscover(plugin_settings)
+ sources = list(discover.discover(known))
+
+ # The source has output_path="output", so a listing
+ # should be created for its parent directory (".")
+ # output_path="output" has one parent ".", so 1 listing is created
+ assert (
+ len(sources) == 1
+ ), "Should create listing when Listable falls back to output_path"
+
+
+class TestRenderHtml:
+ def test_render_html_produces_links(self) -> None:
+ """Test that render_html produces correct HTML with relative links."""
+ entry_source = MockSource(
+ relative_path=PurePath("docs/api/module.py"),
+ output_path=PurePath("docs/api/module"),
+ )
+ listing_sources: Set[Source] = {entry_source}
+ listing_source = ListingSource(
+ PurePath("docs"),
+ PurePath("docs/index"),
+ listing_sources,
+ )
+ node = ListingNode(listing_sources)
+
+ context = Context({Source: listing_source})
+ parent = HTMLTag("div")
+
+ render_html(context, parent, node)
+
+ # Parent should now contain children from the template
+ children = list(parent.children)
+ assert (
+ len(children) > 0
+ ), "render_html should append children to parent"
+
+ # Walk the HTML tree to find
tags with href attributes
+ def find_tags(node: object, tag_name: str) -> List[HTMLTag]:
+ results: List[HTMLTag] = []
+ if isinstance(node, HTMLTag):
+ if node.tag_name == tag_name:
+ results.append(node)
+ for child in node.children:
+ results.extend(find_tags(child, tag_name))
+ return results
+
+ links = find_tags(parent, "a")
+ assert len(links) >= 1, "Should produce at least one link"
+ # The link should have an href attribute ending with .html
+ href = links[0].attributes.get("href") or ""
+ assert href.endswith(
+ ".html"
+ ), f"Link href should end with .html, got: {href}"
+
+
+class TestListingEnvCache:
+ def setup_method(self) -> None:
+ """Reset the module-level cache before each test."""
+ listing_module._LISTING_ENV = None
+
+ def test_returns_environment_with_correct_loader(self) -> None:
+ """Verify _get_listing_env returns an Environment with a loader."""
+ env = _get_listing_env()
+ assert isinstance(env, Environment)
+ assert env.loader is not None
+
+ def test_two_calls_return_same_object(self) -> None:
+ """Two calls to _get_listing_env return the exact same object."""
+ env1 = _get_listing_env()
+ env2 = _get_listing_env()
+ assert env1 is env2
+
+ def test_cache_is_populated_after_call(self) -> None:
+ """Verify the module-level cache is populated after a call."""
+ assert listing_module._LISTING_ENV is None
+ _get_listing_env()
+ assert listing_module._LISTING_ENV is not None
diff --git a/tests/test_loader.py b/tests/test_loader.py
new file mode 100644
index 0000000..df61f87
--- /dev/null
+++ b/tests/test_loader.py
@@ -0,0 +1,210 @@
+# Copyright (C) 2025 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 .
+
+import sys
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+if sys.version_info < (3, 10):
+ from importlib_metadata import EntryPoint
+else:
+ from importlib.metadata import EntryPoint
+
+import docc.plugins.loader as loader_module
+from docc.build import Builder
+from docc.discover import Discover
+from docc.plugins.loader import Loader, PluginError, _get_plugin_entry_points
+from docc.transform import Transform
+
+
+class TestLoader:
+ def test_init(self) -> None:
+ loader = Loader()
+ assert isinstance(loader.entry_points, dict)
+ assert len(loader.entry_points) > 0, "Entry points should be populated"
+ assert "docc.python.discover" in loader.entry_points
+
+ def test_entry_points_populated(self) -> None:
+ loader = Loader()
+ assert len(loader.entry_points) > 0
+ assert "docc.python.discover" in loader.entry_points
+
+ def test_load_discover_plugin(self) -> None:
+ loader = Loader()
+ cls = loader.load(Discover, "docc.python.discover")
+ assert isinstance(cls, type)
+ assert issubclass(cls, Discover)
+
+ def test_load_builder_plugin(self) -> None:
+ loader = Loader()
+ cls = loader.load(Builder, "docc.python.build")
+ assert isinstance(cls, type)
+ assert issubclass(cls, Builder)
+
+ def test_load_transform_plugin(self) -> None:
+ loader = Loader()
+ cls = loader.load(Transform, "docc.python.transform")
+ assert isinstance(cls, type)
+ assert issubclass(cls, Transform)
+
+ def test_load_nonexistent_plugin_raises(self) -> None:
+ loader = Loader()
+ with pytest.raises(KeyError):
+ loader.load(Discover, "nonexistent.plugin")
+
+ def test_load_abstract_class_raises_plugin_error(self) -> None:
+ loader = Loader()
+ # Inject a fake entry point that loads an abstract class
+ mock_ep = MagicMock()
+ mock_ep.load.return_value = Discover
+ loader.entry_points["fake.abstract"] = mock_ep
+
+ with pytest.raises(PluginError, match="is abstract"):
+ loader.load(Discover, "fake.abstract")
+
+ def test_load_wrong_subclass_raises_plugin_error(self) -> None:
+ loader = Loader()
+ # Inject a fake entry point that loads a class not subclassing the base
+
+ class NotADiscover:
+ pass
+
+ mock_ep = MagicMock()
+ mock_ep.load.return_value = NotADiscover
+ loader.entry_points["fake.wrong_type"] = mock_ep
+
+ with pytest.raises(PluginError, match="is not a subclass of"):
+ loader.load(Discover, "fake.wrong_type")
+
+
+class TestPluginError:
+ def test_create_error(self) -> None:
+ error = PluginError("test error message")
+ assert "test error message" in str(error)
+
+ def test_error_inheritance(self) -> None:
+ error = PluginError("test")
+ assert isinstance(error, Exception)
+
+
+class TestLoaderMultiplePlugins:
+ def test_load_multiple_transforms(self) -> None:
+ loader = Loader()
+
+ transforms = [
+ "docc.python.transform",
+ "docc.mistletoe.transform",
+ "docc.mistletoe.reference",
+ "docc.html.transform",
+ ]
+
+ for name in transforms:
+ cls = loader.load(Transform, name)
+ assert isinstance(cls, type)
+ assert issubclass(cls, Transform)
+
+ def test_load_multiple_discovers(self) -> None:
+ loader = Loader()
+
+ discovers = [
+ "docc.python.discover",
+ "docc.html.discover",
+ ]
+
+ for name in discovers:
+ cls = loader.load(Discover, name)
+ assert isinstance(cls, type)
+ assert issubclass(cls, Discover)
+
+
+class TestLoaderCacheBehavioral:
+ """Behavioral tests: two Loader instances share cached entry points."""
+
+ def test_two_loaders_share_identical_entry_points(self) -> None:
+ """Two Loader instances must have identical entry_points dicts."""
+ loader_a = Loader()
+ loader_b = Loader()
+ assert loader_a.entry_points is loader_b.entry_points
+
+ def test_both_loaders_can_load_known_plugin(self) -> None:
+ """Both Loader instances can load a known plugin after caching."""
+ loader_a = Loader()
+ loader_b = Loader()
+ cls_a = loader_a.load(Discover, "docc.python.discover")
+ cls_b = loader_b.load(Discover, "docc.python.discover")
+ assert cls_a is cls_b
+ assert callable(cls_a)
+
+
+class TestLoaderCacheCallCount:
+ """Spy tests: entry_points() called once across instances."""
+
+ def setup_method(self) -> None: # noqa: SC200
+ """Reset the module-level cache before each test."""
+ loader_module._PLUGIN_ENTRY_POINTS = None
+
+ def teardown_method(self) -> None: # noqa: SC200
+ """Reset the module-level cache after each test."""
+ loader_module._PLUGIN_ENTRY_POINTS = None
+
+ def test_entry_points_called_once_for_multiple_loaders(
+ self,
+ ) -> None:
+ """Multiple Loader instances call entry_points() once."""
+ with patch(
+ "docc.plugins.loader.entry_points",
+ wraps=loader_module.entry_points,
+ ) as mock_ep:
+ Loader()
+ Loader()
+ Loader()
+ mock_ep.assert_called_once_with(group="docc.plugins")
+
+ def test_get_plugin_entry_points_calls_once(self) -> None:
+ """Repeated _get_plugin_entry_points() calls entry_points() once."""
+ with patch(
+ "docc.plugins.loader.entry_points",
+ wraps=loader_module.entry_points,
+ ) as mock_ep:
+ _get_plugin_entry_points()
+ _get_plugin_entry_points()
+ mock_ep.assert_called_once_with(group="docc.plugins")
+
+
+class TestLoaderCacheKeying:
+ """Cache-keying tests: the cached dict has correct structure."""
+
+ def test_cache_maps_names_to_entry_point_objects(self) -> None:
+ """The cached dict maps string names to EntryPoint instances."""
+ loader = Loader()
+ for name, ep in loader.entry_points.items():
+ assert isinstance(name, str), "Keys must be strings."
+ assert isinstance(
+ ep, EntryPoint
+ ), "Values must be EntryPoint instances."
+
+ def test_cache_contains_known_entry_points(self) -> None:
+ """The cached dict includes known plugin entry point names."""
+ loader = Loader()
+ expected_names = [
+ "docc.python.discover",
+ "docc.python.build",
+ "docc.python.transform",
+ ]
+ for name in expected_names:
+ assert (
+ name in loader.entry_points
+ ), f"Expected entry point '{name}' not found in cache."
diff --git a/tests/test_mistletoe_comprehensive.py b/tests/test_mistletoe_comprehensive.py
new file mode 100644
index 0000000..c6ca1c2
--- /dev/null
+++ b/tests/test_mistletoe_comprehensive.py
@@ -0,0 +1,831 @@
+# Copyright (C) 2025 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 unittest.mock import MagicMock
+
+import mistletoe as md
+import pytest
+from conftest import ReferenceChecker
+from mistletoe import block_token as blocks
+from mistletoe import span_token as spans
+
+from docc.context import Context
+from docc.document import BlankNode, ListNode, Visit
+from docc.plugins.html import HTMLRoot, HTMLTag, TextNode
+from docc.plugins.mistletoe import (
+ MarkdownNode,
+ _DocstringVisitor,
+ _ReferenceVisitor,
+ _render_auto_link,
+ _render_block_code,
+ _render_document,
+ _render_emphasis,
+ _render_escape_sequence,
+ _render_heading,
+ _render_html_block,
+ _render_html_span,
+ _render_image,
+ _render_inline_code,
+ _render_line_break,
+ _render_link,
+ _render_list,
+ _render_list_item,
+ _render_paragraph,
+ _render_quote,
+ _render_raw_text,
+ _render_strikethrough,
+ _render_strong,
+ _render_table,
+ _render_table_cell,
+ _render_table_row,
+ _render_thematic_break,
+ _SearchVisitor,
+ render_html,
+)
+from docc.plugins.python import nodes
+
+
+@pytest.fixture
+def context() -> Context:
+ return Context({})
+
+
+@pytest.fixture
+def html_root(context: Context) -> HTMLRoot:
+ return HTMLRoot(context)
+
+
+class TestMarkdownNodeComprehensive:
+ def test_repr(self) -> None:
+ markdown = "test"
+ node = MarkdownNode(md.Document(markdown))
+ result = repr(node)
+ assert "MarkdownNode" in result
+ assert "Document" in result
+
+ def test_replace_child(self) -> None:
+ markdown = "**bold**"
+ node = MarkdownNode(md.Document(markdown))
+
+ children = list(node.children)
+ # "**bold**" produces a Document with one Paragraph child
+ assert len(children) == 1
+ old = children[0]
+ new = BlankNode()
+ node.replace_child(old, new)
+
+ new_children = list(node.children)
+ assert new in new_children
+
+ def test_search_children_returns_false(self) -> None:
+ node = MarkdownNode(md.Document("test"))
+ assert node.search_children() is False
+
+ def test_to_search_returns_text(self) -> None:
+ node = MarkdownNode(md.Document("hello world"))
+ result = node.to_search()
+ assert "hello world" in result
+
+
+class TestRenderStrong:
+ def test_creates_strong_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "**bold**"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ strong_token = para.children[0]
+ node = MarkdownNode(strong_token)
+
+ result = _render_strong(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "strong"
+ children = list(html_root.children)
+ assert result in children
+
+
+class TestRenderEmphasis:
+ def test_creates_em_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "*italic*"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ em_token = para.children[0]
+ node = MarkdownNode(em_token)
+
+ result = _render_emphasis(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "em"
+
+
+class TestRenderInlineCode:
+ def test_creates_code_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "`code`"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ code_token = para.children[0]
+ node = MarkdownNode(code_token)
+
+ result = _render_inline_code(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "code"
+
+
+class TestRenderRawText:
+ def test_creates_text_node(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "plain text"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ text_token = para.children[0]
+ node = MarkdownNode(text_token)
+
+ result = _render_raw_text(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ text_nodes = [c for c in children if isinstance(c, TextNode)]
+ assert len(text_nodes) == 1
+ assert text_nodes[0]._value == "plain text"
+
+
+class TestRenderStrikethrough:
+ def test_creates_del_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "~~strikethrough~~"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ strike_token = para.children[0]
+ node = MarkdownNode(strike_token)
+
+ result = _render_strikethrough(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "del"
+
+
+class TestRenderImage:
+ def test_creates_img_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=spans.Image)
+ mock_token.src = "image.png"
+ mock_token.content = "alt text"
+ mock_token.title = ""
+ node = MarkdownNode(mock_token)
+
+ result = _render_image(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ img = next(
+ c
+ for c in children
+ if isinstance(c, HTMLTag) and c.tag_name == "img"
+ )
+ assert img.attributes["src"] == "image.png"
+ assert img.attributes["alt"] == "alt text"
+
+ def test_img_with_title(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=spans.Image)
+ mock_token.src = "image.png"
+ mock_token.content = "alt"
+ mock_token.title = "title"
+ node = MarkdownNode(mock_token)
+
+ _render_image(context, html_root, node)
+
+ children = list(html_root.children)
+ img = next(
+ c
+ for c in children
+ if isinstance(c, HTMLTag) and c.tag_name == "img"
+ )
+ assert img.attributes.get("title") == "title"
+
+
+class TestRenderLink:
+ def test_creates_anchor_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "[link](http://example.com)"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ link_token = para.children[0]
+ node = MarkdownNode(link_token)
+
+ result = _render_link(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "a"
+ assert result.attributes.get("href") == "http://example.com"
+
+ def test_link_with_title(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = '[link](http://example.com "title")'
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ link_token = para.children[0]
+ node = MarkdownNode(link_token)
+
+ result = _render_link(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.attributes.get("title") == "title"
+
+
+class TestRenderAutoLink:
+ def test_creates_anchor_for_url(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = ""
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ auto_token = para.children[0]
+ node = MarkdownNode(auto_token)
+
+ result = _render_auto_link(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "a"
+
+ def test_mailto_prefix_for_email(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = ""
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ auto_token = para.children[0]
+ node = MarkdownNode(auto_token)
+
+ result = _render_auto_link(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert "mailto:" in (result.attributes.get("href") or "")
+
+
+class TestRenderEscapeSequence:
+ def test_raises_not_implemented(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ node = MarkdownNode(md.Document("test"))
+
+ with pytest.raises(NotImplementedError):
+ _render_escape_sequence(context, html_root, node)
+
+
+class TestRenderHeading:
+ def test_creates_h1_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "# Heading 1"
+ doc = md.Document(markdown)
+ heading_token = doc.children[0]
+ node = MarkdownNode(heading_token)
+
+ result = _render_heading(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "h1"
+
+ def test_creates_h2_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "## Heading 2"
+ doc = md.Document(markdown)
+ heading_token = doc.children[0]
+ node = MarkdownNode(heading_token)
+
+ result = _render_heading(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "h2"
+
+ def test_creates_h3_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "### Heading 3"
+ doc = md.Document(markdown)
+ heading_token = doc.children[0]
+ node = MarkdownNode(heading_token)
+
+ result = _render_heading(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "h3"
+
+
+class TestRenderQuote:
+ def test_creates_blockquote_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "> quoted text"
+ doc = md.Document(markdown)
+ quote_token = doc.children[0]
+ node = MarkdownNode(quote_token)
+
+ result = _render_quote(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "blockquote"
+
+
+class TestRenderParagraph:
+ def test_creates_p_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "paragraph text"
+ doc = md.Document(markdown)
+ para_token = doc.children[0]
+ node = MarkdownNode(para_token)
+
+ result = _render_paragraph(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "p"
+
+
+class TestRenderBlockCode:
+ def test_creates_pre_code_tags(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "```\ncode block\n```"
+ doc = md.Document(markdown)
+ code_token = doc.children[0]
+ node = MarkdownNode(code_token)
+
+ result = _render_block_code(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "code"
+ children = list(html_root.children)
+ pre = children[0]
+ assert isinstance(pre, HTMLTag)
+ assert pre.tag_name == "pre"
+
+
+class TestRenderList:
+ def test_creates_ul_for_unordered(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "- item 1\n- item 2"
+ doc = md.Document(markdown)
+ list_token = doc.children[0]
+ node = MarkdownNode(list_token)
+
+ result = _render_list(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "ul"
+
+ def test_creates_ol_for_ordered(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "1. item 1\n2. item 2"
+ doc = md.Document(markdown)
+ list_token = doc.children[0]
+ node = MarkdownNode(list_token)
+
+ result = _render_list(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "ol"
+
+ def test_ol_with_custom_start(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "5. item 1\n6. item 2"
+ doc = md.Document(markdown)
+ list_token = doc.children[0]
+ node = MarkdownNode(list_token)
+
+ result = _render_list(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.attributes.get("start") == 5
+
+
+class TestRenderListItem:
+ def test_creates_li_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "- item"
+ doc = md.Document(markdown)
+ list_token = doc.children[0]
+ item_token = list_token.children[0]
+ node = MarkdownNode(item_token)
+
+ result = _render_list_item(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "li"
+
+
+class TestRenderTable:
+ def test_creates_table_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ node = MarkdownNode(table_token)
+
+ result = _render_table(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "table"
+
+ def test_table_with_header_creates_thead(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ node = MarkdownNode(table_token)
+
+ result = _render_table(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "table"
+ # Table with header row should have a child
+ thead_children = [
+ c
+ for c in result.children
+ if isinstance(c, HTMLTag) and c.tag_name == "thead"
+ ]
+ assert (
+ len(thead_children) == 1
+ ), "Table with header row should have a child element"
+
+
+class TestRenderTableRow:
+ def test_creates_tr_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ row_token = table_token.children[0]
+ node = MarkdownNode(row_token)
+
+ result = _render_table_row(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "tr"
+
+
+class TestRenderTableCell:
+ def test_creates_td_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ row_token = table_token.children[0]
+ cell_token = row_token.children[0]
+ node = MarkdownNode(cell_token)
+
+ result = _render_table_cell(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "td"
+
+ def test_default_alignment_is_left(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A |\n|---|\n| 1 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ row_token = table_token.children[0]
+ cell_token = row_token.children[0]
+ node = MarkdownNode(cell_token)
+
+ result = _render_table_cell(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.attributes.get("align") == "left"
+
+ def test_center_alignment(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "| A |\n|:---:|\n| 1 |"
+ doc = md.Document(markdown)
+ table_token = doc.children[0]
+ row_token = table_token.children[0]
+ cell_token = row_token.children[0]
+ node = MarkdownNode(cell_token)
+
+ result = _render_table_cell(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.attributes.get("align") == "center"
+
+ def test_right_alignment(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=blocks.TableCell)
+ mock_token.align = 2
+ node = MarkdownNode(mock_token)
+
+ result = _render_table_cell(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.attributes.get("align") == "right"
+
+ def test_unknown_alignment_raises_not_implemented(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=blocks.TableCell)
+ mock_token.align = 99
+ node = MarkdownNode(mock_token)
+
+ with pytest.raises(NotImplementedError, match="table alignment 99"):
+ _render_table_cell(context, html_root, node)
+
+
+class TestRenderThematicBreak:
+ def test_creates_hr_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "---"
+ doc = md.Document(markdown)
+ hr_token = doc.children[0]
+ node = MarkdownNode(hr_token)
+
+ result = _render_thematic_break(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ assert any(
+ isinstance(c, HTMLTag) and c.tag_name == "hr" for c in children
+ )
+
+
+class TestRenderLineBreak:
+ def test_hard_break_creates_br_tag(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ # Two trailing spaces before newline creates a hard break
+ markdown = "line1 \nline2"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+
+ break_token = None
+ for child in para.children:
+ if isinstance(child, spans.LineBreak):
+ break_token = child
+ break
+
+ assert break_token is not None, "Expected a LineBreak token"
+ assert (
+ not break_token.soft
+ ), "Two trailing spaces should produce a hard break"
+
+ node = MarkdownNode(break_token)
+ result = _render_line_break(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ br_tags = [
+ c
+ for c in children
+ if isinstance(c, HTMLTag) and c.tag_name == "br"
+ ]
+ assert len(br_tags) == 1, "Hard break should append an HTMLTag('br')"
+
+ def test_soft_break_creates_text_newline(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ # No trailing spaces before newline creates a soft break
+ markdown = "line1\nline2"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+
+ break_token = None
+ for child in para.children:
+ if isinstance(child, spans.LineBreak):
+ break_token = child
+ break
+
+ assert break_token is not None, "Expected a LineBreak token"
+ assert (
+ break_token.soft
+ ), "No trailing spaces should produce a soft break"
+
+ node = MarkdownNode(break_token)
+ result = _render_line_break(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ text_nodes = [c for c in children if isinstance(c, TextNode)]
+ assert (
+ len(text_nodes) == 1
+ ), "Soft break should append a TextNode('\\n')"
+ assert text_nodes[0]._value == "\n"
+
+
+class TestRenderHtmlSpan:
+ def test_parses_inline_html(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=spans.HTMLSpan)
+ mock_token.content = "hello"
+ node = MarkdownNode(mock_token)
+
+ result = _render_html_span(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ # "hello" produces exactly one tag
+ assert len(children) == 1
+ em_tag = children[0]
+ assert isinstance(em_tag, HTMLTag)
+ assert em_tag.tag_name == "em"
+
+
+class TestRenderHtmlBlock:
+ def test_parses_block_html(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ mock_token = MagicMock(spec=blocks.HTMLBlock)
+ mock_token.content = "block content
"
+ node = MarkdownNode(mock_token)
+
+ result = _render_html_block(context, html_root, node)
+
+ assert result is None
+ children = list(html_root.children)
+ # "block content
" produces exactly one tag
+ assert len(children) == 1
+ div_tag = children[0]
+ assert isinstance(div_tag, HTMLTag)
+ assert div_tag.tag_name == "div"
+
+
+class TestRenderDocument:
+ def test_creates_div_with_class(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "document content"
+ doc = md.Document(markdown)
+ node = MarkdownNode(doc)
+
+ result = _render_document(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "div"
+ assert result.attributes.get("class") == "markdown"
+
+
+class TestRenderHtml:
+ def test_dispatches_to_correct_renderer(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "plain text"
+ doc = md.Document(markdown)
+ node = MarkdownNode(doc)
+
+ result = render_html(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "div"
+
+ def test_renders_paragraph(
+ self, context: Context, html_root: HTMLRoot
+ ) -> None:
+ markdown = "paragraph"
+ doc = md.Document(markdown)
+ para = doc.children[0]
+ node = MarkdownNode(para)
+
+ result = render_html(context, html_root, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "p"
+
+
+class TestDocstringVisitorEdgeCases:
+ def test_docstring_at_root_becomes_markdown(self) -> None:
+ visitor = _DocstringVisitor()
+ docstring = nodes.Docstring("**bold** text")
+
+ docstring.visit(visitor)
+
+ assert visitor.root is not None
+ assert isinstance(visitor.root, MarkdownNode)
+
+ def test_nested_docstrings(self) -> None:
+ visitor = _DocstringVisitor()
+ inner_doc = nodes.Docstring("inner")
+ outer = ListNode([inner_doc])
+
+ outer.visit(visitor)
+
+ children = list(outer.children)
+ assert len(children) == 1
+ assert isinstance(children[0], MarkdownNode)
+
+
+class TestReferenceVisitorEdgeCases:
+ def test_autolink_with_ref_becomes_reference(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "
"
+ root = MarkdownNode(md.Document(markdown))
+
+ root.visit(visitor)
+
+ checker = ReferenceChecker()
+ assert visitor.root is not None
+ visitor.root.visit(checker)
+ assert (
+ checker.found
+ ), "Autolink with ref: prefix should become Reference"
+
+ def test_link_with_multiple_children(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "[**bold** text](ref:test)"
+ root = MarkdownNode(md.Document(markdown))
+
+ root.visit(visitor)
+
+ checker = ReferenceChecker()
+ assert visitor.root is not None
+ visitor.root.visit(checker)
+ assert (
+ checker.found
+ ), "Link with multiple children should become Reference"
+
+ def test_link_without_ref_prefix_unchanged(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "[link](https://example.com)"
+ root = MarkdownNode(md.Document(markdown))
+
+ root.visit(visitor)
+
+ assert isinstance(
+ visitor.root, MarkdownNode
+ ), "Root should remain MarkdownNode"
+ checker = ReferenceChecker()
+ assert visitor.root is not None
+ visitor.root.visit(checker)
+ assert (
+ not checker.found
+ ), "Regular links should not become Reference nodes"
+
+
+class TestSearchVisitorEdgeCases:
+ def test_enter_returns_traverse_children_for_non_markdown(self) -> None:
+ visitor = _SearchVisitor()
+ blank = BlankNode()
+
+ result = visitor.enter(blank)
+
+ assert result == Visit.TraverseChildren
+
+ def test_exit_does_nothing(self) -> None:
+ visitor = _SearchVisitor()
+ blank = BlankNode()
+
+ visitor.exit(blank)
+
+ def test_raw_text_extraction(self) -> None:
+ visitor = _SearchVisitor()
+ markdown = "plain text content"
+ node = MarkdownNode(md.Document(markdown))
+
+ node.visit(visitor)
+
+ assert "plain text content" in visitor.texts
+
+ def test_to_search_joins_multiple_fragments_with_spaces(self) -> None:
+ # Two paragraphs produce separate RawText tokens: "bold" and
+ # "new paragraph". With " ".join() these become
+ # "bold new paragraph"; with "".join() they would become
+ # "boldnew paragraph".
+ node = MarkdownNode(md.Document("**bold**\n\nnew paragraph"))
+ result = node.to_search()
+
+ assert "bold new" in result
diff --git a/tests/test_mistletoe_extended.py b/tests/test_mistletoe_extended.py
new file mode 100644
index 0000000..362c9d0
--- /dev/null
+++ b/tests/test_mistletoe_extended.py
@@ -0,0 +1,269 @@
+# Copyright (C) 2025 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 .
+
+import typing
+from pathlib import Path
+
+import mistletoe as md
+import pytest
+from conftest import ReferenceChecker
+from mistletoe.token import Token as MarkdownToken
+
+from docc.context import Context
+from docc.document import BlankNode, Document, ListNode
+from docc.plugins.mistletoe import (
+ DocstringTransform,
+ MarkdownNode,
+ ReferenceTransform,
+ _DocstringVisitor,
+ _ReferenceVisitor,
+ _SearchVisitor,
+)
+from docc.plugins.python import nodes
+from docc.plugins.references import Reference
+from docc.settings import PluginSettings, Settings
+
+
+@pytest.fixture
+def plugin_settings() -> PluginSettings:
+ settings = Settings(Path("."), {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.mistletoe.transform")
+
+
+class TestDocstringVisitor:
+ def test_enter_non_docstring_node(self) -> None:
+ visitor = _DocstringVisitor()
+ blank = BlankNode()
+
+ visitor.enter(blank)
+ assert visitor.root is blank
+
+ def test_exit_non_docstring_node(self) -> None:
+ visitor = _DocstringVisitor()
+ blank = BlankNode()
+
+ visitor.enter(blank)
+ visitor.exit(blank)
+
+ assert len(visitor.stack) == 0
+
+ def test_transforms_docstring_to_markdown(self) -> None:
+ visitor = _DocstringVisitor()
+ docstring = nodes.Docstring("Test **bold**")
+ parent = ListNode([docstring])
+
+ parent.visit(visitor)
+
+ children = list(parent.children)
+ assert len(children) == 1
+ assert not isinstance(children[0], nodes.Docstring)
+
+
+class TestReferenceVisitor:
+ def test_enter_non_markdown_node(self) -> None:
+ visitor = _ReferenceVisitor()
+ blank = BlankNode()
+
+ visitor.enter(blank)
+ assert visitor.root is blank
+
+ def test_exit_non_markdown_node(self) -> None:
+ visitor = _ReferenceVisitor()
+ blank = BlankNode()
+
+ visitor.enter(blank)
+ visitor.exit(blank)
+
+ assert len(visitor.stack) == 0
+
+ def test_transforms_ref_link_to_reference(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "[text](ref:identifier)"
+ root = MarkdownNode(md.Document(markdown))
+
+ root.visit(visitor)
+
+ checker = ReferenceChecker()
+ assert visitor.root is not None
+ visitor.root.visit(checker)
+ assert (
+ checker.found
+ ), "ref: link should be transformed to Reference node"
+
+ def test_ignores_non_ref_links(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "[text](https://example.com)"
+ root = MarkdownNode(md.Document(markdown))
+
+ root.visit(visitor)
+
+ checker = ReferenceChecker()
+ assert visitor.root is not None
+ visitor.root.visit(checker)
+ assert (
+ not checker.found
+ ), "Regular links should not become Reference nodes"
+
+ def test_ref_link_becomes_root_when_only_element(self) -> None:
+ visitor = _ReferenceVisitor()
+ markdown = "[ref](ref:test)"
+ token = md.Document(markdown)
+ paragraph = token.children[0]
+ link_token = paragraph.children[0]
+ link_node = MarkdownNode(link_token)
+ link_node.visit(visitor)
+
+ assert isinstance(
+ visitor.root, Reference
+ ), "Single ref link should become Reference root"
+
+
+class TestSearchVisitor:
+ def test_collect_empty(self) -> None:
+ result = _SearchVisitor.collect([])
+ assert result == []
+
+ def test_collect_blank_node(self) -> None:
+ blank = BlankNode()
+ result = _SearchVisitor.collect(blank)
+ assert result == []
+
+ def test_collect_single_node(self) -> None:
+ markdown = "Hello world"
+ node = MarkdownNode(md.Document(markdown))
+ result = _SearchVisitor.collect(node)
+ assert "Hello world" in " ".join(result)
+
+ def test_collect_multiple_nodes(self) -> None:
+ nodes_list = [
+ MarkdownNode(md.Document("First")),
+ MarkdownNode(md.Document("Second")),
+ ]
+ result = _SearchVisitor.collect(nodes_list)
+ combined = " ".join(result)
+ assert "First" in combined
+ assert "Second" in combined
+
+
+class TestDocstringTransform:
+ def test_transform_simple_docstring(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ docstring = nodes.Docstring("A simple docstring")
+ root = ListNode([docstring])
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = DocstringTransform(plugin_settings)
+ transform.transform(context)
+
+ children = list(context[Document].root.children)
+ assert not isinstance(children[0], nodes.Docstring)
+
+
+class TestReferenceTransform:
+ def test_transform_creates_references(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ markdown = "[link](ref:test.module)"
+ root = MarkdownNode(md.Document(markdown))
+ document = Document(root)
+ context = Context({Document: document})
+
+ transform = ReferenceTransform(plugin_settings)
+ transform.transform(context)
+
+ checker = ReferenceChecker()
+ context[Document].root.visit(checker)
+ assert (
+ checker.found
+ ), "Transform should create Reference nodes from ref: links"
+
+
+class TestMarkdownNodeChildren:
+ def test_children_with_token_children_none(self) -> None:
+ class MockToken:
+ children = None
+
+ node = MarkdownNode(typing.cast(MarkdownToken, MockToken()))
+ children = list(node.children)
+ assert children == []
+
+ def test_children_lazy_evaluation(self) -> None:
+ markdown = "Test **bold**"
+ node = MarkdownNode(md.Document(markdown))
+
+ assert node._children is None
+
+ children = list(node.children)
+
+ assert node._children is not None
+ assert len(children) > 0
+
+
+class TestMarkdownFormats:
+ def test_strong_text(self) -> None:
+ markdown = "**strong**"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "strong" in result
+
+ def test_emphasis_text(self) -> None:
+ markdown = "*emphasis*"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "emphasis" in result
+
+ def test_code_block(self) -> None:
+ markdown = "```python\ncode\n```"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "code" in result, "Code block content should be searchable"
+
+ def test_list_items(self) -> None:
+ markdown = "- item 1\n- item 2"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "item 1" in result
+ assert "item 2" in result
+
+ def test_heading(self) -> None:
+ markdown = "# Heading"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "Heading" in result
+
+ def test_link_with_text(self) -> None:
+ markdown = "[link text](http://example.com)"
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "link text" in result
+
+ def test_mixed_content(self) -> None:
+ markdown = """
+# Title
+
+Paragraph with **bold** and *italic*.
+
+- List item
+- Another item
+
+[Link](http://example.com)
+"""
+ node = MarkdownNode(md.Document(markdown))
+ result = node.to_search()
+ assert "Title" in result
+ assert "bold" in result
+ assert "List item" in result
diff --git a/tests/test_python_cst.py b/tests/test_python_cst.py
new file mode 100644
index 0000000..93afa22
--- /dev/null
+++ b/tests/test_python_cst.py
@@ -0,0 +1,627 @@
+# Copyright (C) 2025 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 .
+
+import dataclasses
+import tempfile
+from collections.abc import Iterator
+from pathlib import Path, PurePath
+from typing import Dict, Mapping, Set
+from unittest.mock import patch
+
+import pytest
+
+from docc.document import BlankNode, Document, ListNode
+from docc.plugins.python import nodes
+from docc.plugins.python.cst import (
+ PythonBuilder,
+ PythonDiscover,
+ PythonSource,
+)
+from docc.settings import Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+@pytest.fixture
+def settings_with_paths(temp_dir: Path) -> Settings:
+ settings_dict: Dict[str, object] = {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]},
+ }
+ }
+ }
+ }
+ return Settings(temp_dir, settings_dict)
+
+
+class TestPythonDiscover:
+ def test_init_raises_on_non_sequence_paths(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {"docc.python.discover": {"paths": 123}}
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+
+ with pytest.raises(TypeError, match="paths must be a list"):
+ PythonDiscover(plugin_settings)
+
+ def test_init_raises_on_non_string_path(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {"docc.python.discover": {"paths": [123]}}
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+
+ with pytest.raises(
+ TypeError, match="every python path must be a string"
+ ):
+ PythonDiscover(plugin_settings)
+
+ def test_init_raises_on_empty_paths(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {"docc.python.discover": {"paths": []}}
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+
+ with pytest.raises(ValueError, match="python needs at least one path"):
+ PythonDiscover(plugin_settings)
+
+ def test_discover_finds_python_files(self, temp_dir: Path) -> None:
+ (temp_dir / "test.py").write_text("# test")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(plugin_settings)
+
+ sources = list(discover.discover(frozenset()))
+ assert len(sources) == 1
+ assert isinstance(sources[0], PythonSource)
+
+ def test_discover_finds_nested_python_files(self, temp_dir: Path) -> None:
+ subdir = temp_dir / "subdir"
+ subdir.mkdir()
+ (subdir / "nested.py").write_text("# nested")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(plugin_settings)
+
+ sources = list(discover.discover(frozenset()))
+ assert len(sources) == 1
+ assert "nested.py" in str(sources[0].relative_path)
+
+ def test_excluded_paths(self, temp_dir: Path) -> None:
+ subdir = temp_dir / "exclude_me"
+ subdir.mkdir()
+ (subdir / "test.py").write_text("# excluded")
+ (temp_dir / "keep.py").write_text("# keep")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {
+ "paths": [str(temp_dir)],
+ "excluded_paths": ["exclude_me"],
+ }
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(plugin_settings)
+
+ sources = list(discover.discover(frozenset()))
+ assert len(sources) == 1
+ assert "keep.py" in str(sources[0].relative_path)
+
+ def test_excluded_paths_non_sequence_raises(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {
+ "paths": [str(temp_dir)],
+ "excluded_paths": 123,
+ }
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+
+ with pytest.raises(TypeError, match="excluded paths must be a list"):
+ PythonDiscover(plugin_settings)
+
+
+class TestPythonSource:
+ def test_relative_path_property(self, temp_dir: Path) -> None:
+ relative = PurePath("test.py")
+ absolute = temp_dir / "test.py"
+ absolute.write_text("# test")
+
+ source = PythonSource(temp_dir, relative, absolute)
+ assert source.relative_path == relative
+
+ def test_output_path_property(self, temp_dir: Path) -> None:
+ relative = PurePath("subdir") / "test.py"
+ absolute = temp_dir / "subdir" / "test.py"
+ absolute.parent.mkdir(exist_ok=True)
+ absolute.write_text("# test")
+
+ source = PythonSource(temp_dir, relative, absolute)
+ assert source.output_path == relative
+
+ def test_open_returns_file_handle(self, temp_dir: Path) -> None:
+ content = "# test content\nx = 1"
+ relative = PurePath("test.py")
+ absolute = temp_dir / "test.py"
+ absolute.write_text(content)
+
+ source = PythonSource(temp_dir, relative, absolute)
+ with source.open() as f:
+ assert f.read() == content
+
+
+class TestPythonBuilder:
+ def test_build_simple_module(self, temp_dir: Path) -> None:
+ content = '''"""Module docstring."""
+x = 1
+'''
+ (temp_dir / "test.py").write_text(content)
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(plugin_settings)
+ sources = set(discover.discover(frozenset()))
+
+ builder = PythonBuilder(plugin_settings)
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ assert len(documents) == 1
+
+ def test_build_removes_sources_from_unprocessed(
+ self, temp_dir: Path
+ ) -> None:
+ (temp_dir / "test.py").write_text("x = 1")
+
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.python.discover": {"paths": [str(temp_dir)]}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.python.discover")
+ discover = PythonDiscover(plugin_settings)
+ sources: Set[Source] = set(discover.discover(frozenset()))
+ original_count = len(sources)
+
+ builder = PythonBuilder(plugin_settings)
+ documents: Dict[Source, Document] = {}
+ builder.build(sources, documents)
+
+ assert len(sources) == 0
+ assert len(documents) == original_count
+
+
+class TestPythonNodes:
+ def test_module_default_fields(self) -> None:
+ module = nodes.Module()
+ assert isinstance(module.name, BlankNode)
+ assert isinstance(module.docstring, BlankNode)
+ assert isinstance(module.members, ListNode)
+
+ def test_module_children(self) -> None:
+ module = nodes.Module()
+ children = list(module.children)
+ assert len(children) == 3
+
+ def test_module_to_search(self) -> None:
+ module = nodes.Module()
+ module.name = nodes.Name("test_module")
+ result = module.to_search()
+ assert isinstance(result, Mapping)
+ assert result["type"] == "module"
+ assert "test_module" in result["name"]
+
+ def test_class_default_fields(self) -> None:
+ cls = nodes.Class()
+ assert isinstance(cls.decorators, ListNode)
+ assert isinstance(cls.name, BlankNode)
+ assert isinstance(cls.bases, ListNode)
+ assert isinstance(cls.metaclass, BlankNode)
+ assert isinstance(cls.docstring, BlankNode)
+ assert isinstance(cls.members, ListNode)
+
+ def test_class_to_search(self) -> None:
+ cls = nodes.Class()
+ cls.name = nodes.Name("TestClass")
+ result = cls.to_search()
+ assert isinstance(result, Mapping)
+ assert result["type"] == "class"
+ assert "TestClass" in result["name"]
+
+ def test_function_default_fields(self) -> None:
+ func = nodes.Function(asynchronous=False)
+ assert func.asynchronous is False
+ assert isinstance(func.decorators, ListNode)
+ assert isinstance(func.name, BlankNode)
+ assert isinstance(func.parameters, ListNode)
+ assert isinstance(func.return_type, BlankNode)
+ assert isinstance(func.docstring, BlankNode)
+ assert isinstance(func.body, BlankNode)
+
+ def test_function_async(self) -> None:
+ func = nodes.Function(asynchronous=True)
+ assert func.asynchronous is True
+
+ def test_function_to_search(self) -> None:
+ func = nodes.Function(asynchronous=False)
+ func.name = nodes.Name("test_func")
+ result = func.to_search()
+ assert isinstance(result, Mapping)
+ assert result["type"] == "function"
+ assert "test_func" in result["name"]
+
+ def test_parameter(self) -> None:
+ param = nodes.Parameter()
+ assert param.star is None
+ assert isinstance(param.name, BlankNode)
+ assert isinstance(param.type_annotation, BlankNode)
+
+ def test_parameter_with_star(self) -> None:
+ param = nodes.Parameter(star="*")
+ assert param.star == "*"
+
+ double_star_param = nodes.Parameter(star="**")
+ assert double_star_param.star == "**"
+
+ def test_attribute_to_search(self) -> None:
+ attr = nodes.Attribute()
+ attr.names = ListNode([nodes.Name("test_attr")])
+ result = attr.to_search()
+ assert isinstance(result, Mapping)
+ assert result["type"] == "attribute"
+ assert "test_attr" in result["name"]
+
+ def test_name_children_empty(self) -> None:
+ name = nodes.Name("test")
+ assert tuple(name.children) == ()
+
+ def test_name_replace_child_raises(self) -> None:
+ name = nodes.Name("test")
+ with pytest.raises(TypeError):
+ name.replace_child(BlankNode(), BlankNode())
+
+ def test_name_with_full_name(self) -> None:
+ name = nodes.Name("test", "module.test")
+ assert name.name == "test"
+ assert name.full_name == "module.test"
+
+ def test_docstring_children_empty(self) -> None:
+ doc = nodes.Docstring("test docstring")
+ assert tuple(doc.children) == ()
+
+ def test_docstring_replace_child_raises(self) -> None:
+ doc = nodes.Docstring("test")
+ with pytest.raises(TypeError):
+ doc.replace_child(BlankNode(), BlankNode())
+
+ def test_docstring_to_search(self) -> None:
+ doc = nodes.Docstring("This is documentation")
+ assert doc.to_search() == "This is documentation"
+
+ def test_type_node(self) -> None:
+ type_node = nodes.Type()
+ assert isinstance(type_node.child, BlankNode)
+
+ def test_subscript_node(self) -> None:
+ sub = nodes.Subscript()
+ assert isinstance(sub.name, BlankNode)
+ assert isinstance(sub.generics, BlankNode)
+
+ def test_binary_operation(self) -> None:
+ binop = nodes.BinaryOperation()
+ assert isinstance(binop.left, BlankNode)
+ assert isinstance(binop.operator, BlankNode)
+ assert isinstance(binop.right, BlankNode)
+
+ def test_bit_or(self) -> None:
+ bit_or = nodes.BitOr()
+ children = list(bit_or.children)
+ assert len(children) == 0
+
+ def test_list_node(self) -> None:
+ list_node = nodes.List()
+ assert isinstance(list_node.elements, ListNode)
+
+ def test_tuple_node(self) -> None:
+ tuple_node = nodes.Tuple()
+ assert isinstance(tuple_node.elements, ListNode)
+
+ def test_access_node(self) -> None:
+ access = nodes.Access()
+ assert isinstance(access.value, BlankNode)
+ assert isinstance(access.attribute, BlankNode)
+
+
+class TestPythonNodeRepr:
+ def test_module_repr(self) -> None:
+ module = nodes.Module()
+ assert repr(module) == "Module(...)"
+
+ def test_class_repr(self) -> None:
+ cls = nodes.Class()
+ assert repr(cls) == "Class(...)"
+
+ def test_function_repr(self) -> None:
+ func = nodes.Function(asynchronous=False)
+ assert repr(func) == "Function(...)"
+
+
+class TestPythonNodeReplaceChild:
+ def test_replace_child_in_module(self) -> None:
+ old_name = nodes.Name("old")
+ new_name = nodes.Name("new")
+ module = nodes.Module()
+ module.name = old_name
+
+ module.replace_child(old_name, new_name)
+ assert module.name == new_name
+
+ def test_replace_child_not_found(self) -> None:
+ module = nodes.Module()
+ original_name = module.name
+ original_docstring = module.docstring
+ original_members = module.members
+
+ old = nodes.Name("old")
+ new = nodes.Name("new")
+
+ module.replace_child(old, new)
+
+ # Verify original field values are unchanged after no-op replace
+ assert module.name is original_name
+ assert module.docstring is original_docstring
+ assert module.members is original_members
+
+
+class TestNameVisitor:
+ def test_collect_single_name(self) -> None:
+ name = nodes.Name("test")
+ result = nodes._NameVisitor.collect(name)
+ assert result == ["test"]
+
+ def test_collect_multiple_names(self) -> None:
+ names = [nodes.Name("a"), nodes.Name("b"), nodes.Name("c")]
+ result = nodes._NameVisitor.collect(names)
+ assert result == ["a", "b", "c"]
+
+ def test_collect_from_list_node(self) -> None:
+ list_node = ListNode([nodes.Name("x"), nodes.Name("y")])
+ result = nodes._NameVisitor.collect(list_node)
+ assert result == ["x", "y"]
+
+ def test_collect_empty(self) -> None:
+ blank = BlankNode()
+ result = nodes._NameVisitor.collect(blank)
+ assert result == []
+
+
+class TestPythonNodeChildrenTypeError:
+ def test_children_raises_type_error_for_non_node_field(self) -> None:
+ """
+ PythonNode.children raises TypeError when a field annotated
+ as Node contains a non-Node value. This documents the
+ defensive contract in nodes.py:44.
+ """
+ module = nodes.Module()
+ # Forcefully set a Node-typed field to a non-Node value
+ object.__setattr__(module, "name", "not a node")
+
+ with pytest.raises(TypeError, match="child not Node"):
+ list(module.children)
+
+
+class TestFieldsCacheBehavioral:
+ """Behavioral tests for children and replace_child with caching."""
+
+ def setup_method(self) -> None:
+ nodes.PythonNode._fields_cache.clear()
+
+ def test_module_children_yields_expected_nodes(self) -> None:
+ """Module.children should yield all three default child nodes."""
+ module = nodes.Module()
+ children = list(module.children)
+ assert len(children) == 3
+ assert children[0] is module.name
+ assert children[1] is module.docstring
+ assert children[2] is module.members
+
+ def test_function_children_yields_expected_nodes(self) -> None:
+ """Function.children should yield all Node-typed fields."""
+ func = nodes.Function(asynchronous=False)
+ children = list(func.children)
+ # Function has 6 Node fields: decorators, name, parameters,
+ # return_type, docstring, body.
+ assert len(children) == 6
+
+ def test_class_children_yields_expected_nodes(self) -> None:
+ """Class.children should yield all Node-typed fields."""
+ cls = nodes.Class()
+ children = list(cls.children)
+ # Class has 6 Node fields: decorators, name, bases, metaclass,
+ # docstring, members.
+ assert len(children) == 6
+
+ def test_replace_child_works_with_cache(self) -> None:
+ """replace_child should correctly swap a child node."""
+ module = nodes.Module()
+ old_name = module.name
+ new_name = nodes.Name("replaced")
+ module.replace_child(old_name, new_name)
+ assert module.name is new_name
+
+
+class TestFieldsCacheCallCount:
+ """Spy tests verifying fields() is called once per subclass."""
+
+ def setup_method(self) -> None:
+ nodes.PythonNode._fields_cache.clear()
+
+ def test_fields_called_once_for_multiple_module_instances(self) -> None:
+ """Multiple Module instances should trigger only one fields() call."""
+ with patch(
+ "docc.plugins.python.nodes.fields", wraps=dataclasses.fields
+ ) as mock_fields:
+ first = nodes.Module()
+ second = nodes.Module()
+ list(first.children)
+ list(second.children)
+ list(first.children)
+ mock_fields.assert_called_once()
+
+ def test_fields_called_once_per_distinct_subclass(self) -> None:
+ """Each distinct subclass should trigger exactly one fields() call."""
+ with patch(
+ "docc.plugins.python.nodes.fields", wraps=dataclasses.fields
+ ) as mock_fields:
+ module = nodes.Module()
+ func = nodes.Function(asynchronous=False)
+ list(module.children)
+ list(func.children)
+ assert mock_fields.call_count == 2
+
+ def test_replace_child_does_not_trigger_extra_fields_call(self) -> None:
+ """replace_child should reuse cached fields without extra calls."""
+ with patch(
+ "docc.plugins.python.nodes.fields", wraps=dataclasses.fields
+ ) as mock_fields:
+ module = nodes.Module()
+ list(module.children)
+ assert mock_fields.call_count == 1
+
+ old_name = module.name
+ module.replace_child(old_name, nodes.Name("new"))
+ # Still only one call; replace_child used the cache.
+ assert mock_fields.call_count == 1
+
+
+class TestFieldsCacheKeying:
+ """Tests verifying cache keys and stored value types."""
+
+ def setup_method(self) -> None:
+ nodes.PythonNode._fields_cache.clear()
+
+ def test_cache_populated_with_correct_keys(self) -> None:
+ """Cache should contain Module and Function type keys."""
+ module = nodes.Module()
+ func = nodes.Function(asynchronous=False)
+ list(module.children)
+ list(func.children)
+
+ assert nodes.Module in nodes.PythonNode._fields_cache
+ assert nodes.Function in nodes.PythonNode._fields_cache
+
+ def test_cached_values_are_tuples_of_field(self) -> None:
+ """Cached values should be tuples of dataclasses.Field objects."""
+ module = nodes.Module()
+ list(module.children)
+
+ cached = nodes.PythonNode._fields_cache[nodes.Module]
+ assert isinstance(cached, tuple)
+ for item in cached:
+ assert isinstance(item, dataclasses.Field)
+
+ def test_cache_empty_after_clear(self) -> None:
+ """Clearing the cache should remove all entries."""
+ module = nodes.Module()
+ list(module.children)
+ assert len(nodes.PythonNode._fields_cache) > 0
+
+ nodes.PythonNode._fields_cache.clear()
+ assert len(nodes.PythonNode._fields_cache) == 0
diff --git a/tests/test_references.py b/tests/test_references.py
new file mode 100644
index 0000000..604ecd3
--- /dev/null
+++ b/tests/test_references.py
@@ -0,0 +1,386 @@
+# Copyright (C) 2025 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 .
+
+import tempfile
+from pathlib import Path, PurePath
+from typing import Iterator, Optional
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document, ListNode
+from docc.plugins.references import (
+ Definition,
+ Index,
+ IndexContext,
+ IndexTransform,
+ Location,
+ Reference,
+ ReferenceError,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+@pytest.fixture
+def basic_settings(temp_dir: Path) -> Settings:
+ return Settings(temp_dir, {"tool": {"docc": {}}})
+
+
+@pytest.fixture
+def plugin_settings(basic_settings: Settings) -> PluginSettings:
+ return basic_settings.for_plugin("docc.references")
+
+
+_UNSET = object()
+
+
+class MockSource(Source):
+ _output_path: PurePath
+
+ def __init__(
+ self,
+ relative_path: object = _UNSET,
+ output_path: Optional[PurePath] = None,
+ ) -> None:
+ if relative_path is _UNSET:
+ self._relative_path: Optional[PurePath] = PurePath("test.py")
+ elif relative_path is None or isinstance(relative_path, PurePath):
+ self._relative_path = relative_path
+ else:
+ raise TypeError(f"unexpected type: {type(relative_path)}")
+ self._output_path = (
+ output_path or self._relative_path or PurePath("test.py")
+ )
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._relative_path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._output_path
+
+
+class TestLocation:
+ def test_create_location(self) -> None:
+ source = MockSource()
+ location = Location(source=source, identifier="test.func", specifier=0)
+
+ assert location.source is source
+ assert location.identifier == "test.func"
+ assert location.specifier == 0
+
+ def test_location_is_frozen(self) -> None:
+ source = MockSource()
+ location = Location(source=source, identifier="test", specifier=0)
+
+ with pytest.raises(AttributeError):
+ location.identifier = "changed" # pyre-ignore[41]
+
+ def test_location_equality(self) -> None:
+ source = MockSource()
+ first_location = Location(
+ source=source, identifier="test", specifier=0
+ )
+ second_location = Location(
+ source=source, identifier="test", specifier=0
+ )
+ third_location = Location(
+ source=source, identifier="test", specifier=1
+ )
+
+ assert first_location == second_location
+ assert first_location != third_location
+
+ def test_location_is_hashable(self) -> None:
+ source = MockSource()
+ location = Location(source=source, identifier="test", specifier=0)
+ location_set = {location}
+ assert location in location_set
+
+
+class TestIndex:
+ def test_create_index(self) -> None:
+ index = Index()
+ assert isinstance(index._index, dict)
+ assert len(index._index) == 0
+
+ def test_define_creates_location(self) -> None:
+ index = Index()
+ source = MockSource()
+
+ location = index.define(source, "test.module.func")
+
+ assert location.source is source
+ assert location.identifier == "test.module.func"
+ assert location.specifier == 0
+
+ def test_define_increments_specifier(self) -> None:
+ index = Index()
+ source = MockSource()
+
+ first_location = index.define(source, "test.func")
+ second_location = index.define(source, "test.func")
+ third_location = index.define(source, "test.func")
+
+ assert first_location.specifier == 0
+ assert second_location.specifier == 1
+ assert third_location.specifier == 2
+
+ def test_define_different_identifiers(self) -> None:
+ index = Index()
+ source = MockSource()
+
+ first_location = index.define(source, "func_a")
+ second_location = index.define(source, "func_b")
+ third_location = index.define(source, "func_a")
+
+ assert first_location.specifier == 0
+ assert second_location.specifier == 0
+ assert third_location.specifier == 1
+
+ def test_lookup_existing(self) -> None:
+ index = Index()
+ source = MockSource()
+ expected = index.define(source, "test.func")
+
+ result = list(index.lookup("test.func"))
+
+ assert len(result) == 1
+ assert result[0] == expected
+
+ def test_lookup_multiple(self) -> None:
+ index = Index()
+ source = MockSource()
+ first_location = index.define(source, "test.func")
+ second_location = index.define(source, "test.func")
+
+ result = list(index.lookup("test.func"))
+
+ assert len(result) == 2
+ assert first_location in result
+ assert second_location in result
+
+ def test_lookup_nonexistent_raises(self) -> None:
+ index = Index()
+
+ with pytest.raises(ReferenceError):
+ index.lookup("nonexistent")
+
+
+class TestReferenceError:
+ def test_basic_error(self) -> None:
+ error = ReferenceError("undefined_func")
+ assert "undefined_func" in str(error)
+ assert error.identifier == "undefined_func"
+ assert error.context is None
+
+ def test_error_with_context_source(self) -> None:
+ source = MockSource(relative_path=PurePath("src/module.py"))
+ context = Context({Source: source})
+ error = ReferenceError("missing_ref", context=context)
+
+ assert "missing_ref" in str(error)
+ assert "src/module.py" in str(error)
+ assert error.context is context
+
+ def test_error_with_context_no_relative_path(self) -> None:
+ source = MockSource(
+ relative_path=None, output_path=PurePath("output.html")
+ )
+ context = Context({Source: source})
+ error = ReferenceError("missing_ref", context=context)
+
+ assert "missing_ref" in str(error)
+ assert "output.html" in str(error)
+
+
+class TestBase:
+ def test_children_returns_tuple(self) -> None:
+ child = BlankNode()
+ base = Definition(identifier="test", child=child)
+
+ assert base.children == (child,)
+
+ def test_default_child_is_blank(self) -> None:
+ base = Definition(identifier="test")
+
+ assert isinstance(base.child, BlankNode)
+
+ def test_replace_child(self) -> None:
+ old_child = BlankNode()
+ new_child = BlankNode()
+ base = Definition(identifier="test", child=old_child)
+
+ base.replace_child(old_child, new_child)
+
+ assert base.child is new_child
+
+ def test_replace_child_no_match(self) -> None:
+ child = BlankNode()
+ other = BlankNode()
+ new_child = BlankNode()
+ base = Definition(identifier="test", child=child)
+
+ base.replace_child(other, new_child)
+
+ assert base.child is child
+
+
+class TestDefinition:
+ def test_create_definition(self) -> None:
+ child = BlankNode()
+ definition = Definition(identifier="test.func", child=child)
+
+ assert definition.identifier == "test.func"
+ assert definition.child is child
+ assert definition.specifier is None
+
+ def test_specifier_can_be_set(self) -> None:
+ definition = Definition(identifier="test", specifier=5)
+ assert definition.specifier == 5
+
+
+class TestReference:
+ def test_create_reference(self) -> None:
+ child = BlankNode()
+ reference = Reference(identifier="test.func", child=child)
+
+ assert reference.identifier == "test.func"
+ assert reference.child is child
+
+
+class TestIndexContext:
+ def test_provides_index(self) -> None:
+ assert IndexContext.provides() == Index
+
+ def test_init_creates_index(self, plugin_settings: PluginSettings) -> None:
+ ctx = IndexContext(plugin_settings)
+ assert isinstance(ctx.index, Index)
+
+ def test_provide_returns_index(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ ctx = IndexContext(plugin_settings)
+ provided = ctx.provide()
+
+ assert provided is ctx.index
+
+
+class TestIndexTransform:
+ def test_transform_indexes_definitions(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ index = Index()
+
+ definition = Definition(identifier="test.func")
+ root = ListNode([definition])
+ document = Document(root)
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = IndexTransform(plugin_settings)
+ transform.transform(context)
+
+ assert definition.specifier == 0
+
+ locations = list(index.lookup("test.func"))
+ assert len(locations) == 1
+ assert locations[0].identifier == "test.func"
+
+ def test_transform_nested_definitions(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ index = Index()
+
+ inner_def = Definition(identifier="inner")
+ outer_def = Definition(identifier="outer", child=inner_def)
+ root = ListNode([outer_def])
+ document = Document(root)
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = IndexTransform(plugin_settings)
+ transform.transform(context)
+
+ assert outer_def.specifier == 0
+ assert inner_def.specifier == 0
+
+ outer_locations = list(index.lookup("outer"))
+ inner_locations = list(index.lookup("inner"))
+ assert len(outer_locations) == 1
+ assert len(inner_locations) == 1
+
+ def test_transform_multiple_definitions_same_id(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ index = Index()
+
+ first_definition = Definition(identifier="same_id")
+ second_definition = Definition(identifier="same_id")
+ root = ListNode([first_definition, second_definition])
+ document = Document(root)
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = IndexTransform(plugin_settings)
+ transform.transform(context)
+
+ assert first_definition.specifier == 0
+ assert second_definition.specifier == 1
+
+ def test_transform_ignores_references(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ index = Index()
+
+ reference = Reference(identifier="some_ref")
+ root = ListNode([reference])
+ document = Document(root)
+
+ context = Context({Document: document, Source: source, Index: index})
+
+ transform = IndexTransform(plugin_settings)
+ transform.transform(context)
+
+ with pytest.raises(ReferenceError):
+ index.lookup("some_ref")
+
+
+class TestDefinitionReferenceInteraction:
+ def test_definition_child_is_reference(self) -> None:
+ ref = Reference(identifier="other")
+ definition = Definition(identifier="test", child=ref)
+
+ assert definition.child is ref
+ assert definition.children == (ref,)
+
+ def test_reference_child_is_definition(self) -> None:
+ definition = Definition(identifier="inner")
+ reference = Reference(identifier="test", child=definition)
+
+ assert reference.child is definition
+ assert reference.children == (definition,)
diff --git a/tests/test_resources.py b/tests/test_resources.py
new file mode 100644
index 0000000..b17c2e5
--- /dev/null
+++ b/tests/test_resources.py
@@ -0,0 +1,155 @@
+# Copyright (C) 2025 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 Path, PurePath
+from typing import Dict, Set
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document
+from docc.plugins.resources import (
+ ResourceBuilder,
+ ResourceNode,
+ ResourceSource,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def plugin_settings() -> PluginSettings:
+ settings = Settings(Path("."), {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.resources.build")
+
+
+class TestResourceSource:
+ def test_with_path_creates_source(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ assert source is not None
+ assert source.output_path == PurePath("static") / "docc"
+ assert source.extension == ".css"
+
+ def test_relative_path_is_none(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ assert source.relative_path is None
+
+ def test_output_path(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("output") / "style",
+ )
+ assert source.output_path == PurePath("output") / "style"
+
+
+class TestResourceNode:
+ def test_children_empty(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ node = ResourceNode(source.resource, source.extension)
+ assert node.children == ()
+
+ def test_extension(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ node = ResourceNode(source.resource, source.extension)
+ assert node.extension == ".css"
+
+ def test_replace_child_raises(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ node = ResourceNode(source.resource, source.extension)
+
+ with pytest.raises(TypeError):
+ node.replace_child(BlankNode(), BlankNode())
+
+ def test_output(self) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+ node = ResourceNode(source.resource, source.extension)
+ context = Context({})
+ destination = StringIO()
+
+ node.output(context, destination)
+
+ result = destination.getvalue()
+ assert len(result) > 0, "Output should not be empty"
+ assert "{" in result, "CSS output should contain style blocks"
+
+
+class TestResourceBuilder:
+ def test_build_processes_resource_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = ResourceSource.with_path(
+ "docc.plugins.html",
+ PurePath("static") / "docc.css",
+ PurePath("static") / "docc",
+ )
+
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = ResourceBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert len(unprocessed) == 0
+ assert len(processed) == 1
+ assert source in processed
+ assert isinstance(processed[source].root, ResourceNode)
+
+ def test_build_ignores_non_resource_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ class OtherSource(Source):
+ @property
+ def relative_path(self):
+ return PurePath("other.py")
+
+ @property
+ def output_path(self):
+ return PurePath("other.py")
+
+ source = OtherSource()
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = ResourceBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert source in unprocessed
+ assert len(processed) == 0
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000..dd6462c
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,455 @@
+# Copyright (C) 2025 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 .
+
+import json
+from io import StringIO
+from pathlib import Path, PurePath
+from typing import Dict, Optional, Set
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document
+from docc.plugins.references import Definition, Index, ReferenceError
+from docc.plugins.search import (
+ ByReference,
+ BySource,
+ Item,
+ Search,
+ Searchable,
+ SearchBuilder,
+ SearchContext,
+ SearchDiscover,
+ SearchNode,
+ SearchSource,
+ SearchTransform,
+ _SearchVisitor,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def plugin_settings() -> PluginSettings:
+ settings = Settings(Path("."), {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.search")
+
+
+class MockSource(Source):
+ _path: PurePath
+
+ def __init__(self, path: Optional[PurePath] = None) -> None:
+ self._path = path if path is not None else PurePath("mock.py")
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._path
+
+
+class TestBySource:
+ def test_create(self) -> None:
+ source = MockSource()
+ location = BySource(source=source)
+ assert location.source is source
+
+ def test_frozen(self) -> None:
+ source = MockSource()
+ location = BySource(source=source)
+ with pytest.raises(AttributeError):
+ location.source = MockSource() # pyre-ignore[41]
+
+ def test_equality(self) -> None:
+ source = MockSource()
+ first_location = BySource(source=source)
+ second_location = BySource(source=source)
+ assert first_location == second_location
+
+
+class TestByReference:
+ def test_create(self) -> None:
+ location = ByReference(identifier="test.func", specifier=0)
+ assert location.identifier == "test.func"
+ assert location.specifier == 0
+
+ def test_create_without_specifier(self) -> None:
+ location = ByReference(identifier="test.func", specifier=None)
+ assert location.specifier is None
+
+ def test_frozen(self) -> None:
+ location = ByReference(identifier="test", specifier=0)
+ with pytest.raises(AttributeError):
+ location.identifier = "changed" # pyre-ignore[41]
+
+
+class TestItem:
+ def test_create_with_string_content(self) -> None:
+ source = MockSource()
+ location = BySource(source=source)
+ item = Item(location=location, content="test content")
+
+ assert item.location is location
+ assert item.content == "test content"
+
+ def test_create_with_dict_content(self) -> None:
+ source = MockSource()
+ location = BySource(source=source)
+ item = Item(
+ location=location, content={"type": "module", "name": ["test"]}
+ )
+
+ assert isinstance(item.content, dict)
+ assert item.content["type"] == "module"
+
+
+class TestSearch:
+ def test_init(self) -> None:
+ search = Search()
+ assert len(search._items) == 0
+
+ def test_add_string_content(self) -> None:
+ search = Search()
+ source = MockSource()
+ location = BySource(source=source)
+ item = Item(location=location, content="test content")
+
+ search.add(item)
+
+ # Note: Accessing _items directly as Search has no public query API
+ assert location in search._items
+ assert "text" in search._items[location]
+ assert "test content" in search._items[location]["text"]
+
+ def test_add_dict_content(self) -> None:
+ search = Search()
+ source = MockSource()
+ location = BySource(source=source)
+ item = Item(
+ location=location, content={"type": "module", "name": ["test"]}
+ )
+
+ search.add(item)
+
+ assert location in search._items
+ assert "type" in search._items[location]
+ assert "name" in search._items[location]
+
+ def test_add_multiple_items_same_location(self) -> None:
+ search = Search()
+ source = MockSource()
+ location = BySource(source=source)
+
+ search.add(Item(location=location, content="first"))
+ search.add(Item(location=location, content="second"))
+
+ assert "first" in search._items[location]["text"]
+ assert "second" in search._items[location]["text"]
+
+
+class TestSearchSource:
+ def test_relative_path_is_none(self) -> None:
+ source = SearchSource()
+ assert source.relative_path is None
+
+ def test_output_path(self) -> None:
+ source = SearchSource()
+ assert source.output_path == PurePath("search")
+
+
+class TestSearchNode:
+ def test_extension(self) -> None:
+ node = SearchNode()
+ assert node.extension == ".js"
+
+
+class TestSearchBuilder:
+ def test_build_processes_search_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = SearchSource()
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = SearchBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert len(unprocessed) == 0
+ assert source in processed
+ assert isinstance(processed[source].root, SearchNode)
+
+ def test_build_ignores_non_search_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = SearchBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert source in unprocessed
+ assert len(processed) == 0
+
+
+class TestSearchDiscover:
+ def test_discover_yields_search_source(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ discover = SearchDiscover(plugin_settings)
+ sources = list(discover.discover(frozenset()))
+
+ assert len(sources) == 1
+ assert isinstance(sources[0], SearchSource)
+
+
+class TestSearchContext:
+ def test_provides(self) -> None:
+ assert SearchContext.provides() == Search
+
+ def test_init(self, plugin_settings: PluginSettings) -> None:
+ ctx = SearchContext(plugin_settings)
+ assert isinstance(ctx.search, Search)
+
+ def test_provide(self, plugin_settings: PluginSettings) -> None:
+ ctx = SearchContext(plugin_settings)
+ provided = ctx.provide()
+ assert provided is ctx.search
+
+
+class TestSearchNodeOutput:
+ def test_output_by_source(self) -> None:
+ """
+ Test SearchNode.output() serializes search index to
+ JavaScript JSON with items indexed by BySource.
+ """
+ source = MockSource(PurePath("module.py"))
+ search = Search()
+ search.add(
+ Item(location=BySource(source=source), content="hello world")
+ )
+ index = Index()
+ context = Context({Search: search, Index: index})
+
+ node = SearchNode()
+ dest = StringIO()
+ node.output(context, dest)
+
+ output = dest.getvalue()
+ assert output.startswith("this.SEARCH_INDEX = ")
+ assert output.endswith("; Object.freeze(this.SEARCH_INDEX);")
+ # Parse the JSON portion
+ json_str = output[
+ len("this.SEARCH_INDEX = ") : -len(
+ "; Object.freeze(this.SEARCH_INDEX);"
+ )
+ ]
+ data = json.loads(json_str)
+ assert len(data) == 1
+ assert data[0]["source"]["path"] == "module.py"
+ assert "hello world" in data[0]["content"]["text"]
+
+ def test_output_by_reference_without_specifier(self) -> None:
+ """Test SearchNode.output() resolves ByReference location via Index."""
+ source = MockSource(PurePath("ref_module.py"))
+ search = Search()
+ location = ByReference(identifier="my.module.func", specifier=None)
+ search.add(Item(location=location, content="func docs"))
+
+ index = Index()
+ index.define(source, "my.module.func")
+
+ context = Context({Search: search, Index: index})
+ node = SearchNode()
+ dest = StringIO()
+ node.output(context, dest)
+
+ output = dest.getvalue()
+ json_str = output[
+ len("this.SEARCH_INDEX = ") : -len(
+ "; Object.freeze(this.SEARCH_INDEX);"
+ )
+ ]
+ data = json.loads(json_str)
+ assert len(data) == 1
+ assert data[0]["source"]["identifier"] == "my.module.func"
+ assert data[0]["source"]["path"] == "ref_module.py"
+
+ def test_output_by_reference_with_specifier(self) -> None:
+ """Test SearchNode.output() resolves ByReference with specifier."""
+ source = MockSource(PurePath("spec_module.py"))
+ search = Search()
+ location = ByReference(identifier="my.ident", specifier=0)
+ search.add(Item(location=location, content="spec docs"))
+
+ index = Index()
+ index.define(source, "my.ident") # specifier=0
+
+ context = Context({Search: search, Index: index})
+ node = SearchNode()
+ dest = StringIO()
+ node.output(context, dest)
+
+ output = dest.getvalue()
+ json_str = output[
+ len("this.SEARCH_INDEX = ") : -len(
+ "; Object.freeze(this.SEARCH_INDEX);"
+ )
+ ]
+ data = json.loads(json_str)
+ assert len(data) == 1
+ assert data[0]["source"]["specifier"] == 0
+ assert data[0]["source"]["path"] == "spec_module.py"
+
+ def test_output_by_reference_specifier_not_found_raises(self) -> None:
+ """
+ Test ByReference with specifier not found raises
+ ReferenceError.
+ """
+ source = MockSource(PurePath("err_module.py"))
+ search = Search()
+ # Use specifier=99 which won't match any definition
+ location = ByReference(identifier="my.missing", specifier=99)
+ search.add(Item(location=location, content="should fail"))
+
+ index = Index()
+ index.define(source, "my.missing") # specifier=0
+
+ context = Context({Search: search, Index: index})
+ node = SearchNode()
+ dest = StringIO()
+ with pytest.raises(ReferenceError):
+ node.output(context, dest)
+
+ def test_output_mixed_sources_and_references(self) -> None:
+ """
+ Test SearchNode.output() with both BySource and
+ ByReference items.
+ """
+ source = MockSource(PurePath("mixed.py"))
+ search = Search()
+ search.add(
+ Item(location=BySource(source=source), content="source item")
+ )
+ ref_location = ByReference(identifier="mixed.func", specifier=None)
+ search.add(Item(location=ref_location, content="ref item"))
+
+ index = Index()
+ index.define(source, "mixed.func")
+
+ context = Context({Search: search, Index: index})
+ node = SearchNode()
+ dest = StringIO()
+ node.output(context, dest)
+
+ output = dest.getvalue()
+ json_str = output[
+ len("this.SEARCH_INDEX = ") : -len(
+ "; Object.freeze(this.SEARCH_INDEX);"
+ )
+ ]
+ data = json.loads(json_str)
+ assert len(data) == 2
+
+
+class TestSearchTransform:
+ def test_transform_indexes_searchable_under_definition(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ """
+ Test that transform() indexes Searchable nodes wrapped in
+ Definitions as ByReference with the correct identifier.
+ """
+ source = MockSource(PurePath("test_transform.py"))
+ search = Search()
+
+ searchable = MockSearchable("indexed content")
+ definition = Definition(identifier="my.module.MyClass")
+ definition.specifier = 0
+ definition.child = searchable
+
+ document = Document(definition)
+ context = Context({Source: source, Search: search, Document: document})
+
+ transform = SearchTransform(plugin_settings)
+ transform.transform(context)
+
+ # The searchable should be indexed as ByReference
+ by_ref = ByReference(identifier="my.module.MyClass", specifier=0)
+ assert by_ref in search._items
+ assert "indexed content" in search._items[by_ref]["text"]
+
+
+class MockSearchable(BlankNode, Searchable):
+ def __init__(
+ self, content: str = "test content", search_children_val: bool = True
+ ) -> None:
+ self._content = content
+ self._search_children = search_children_val
+
+ def to_search(self) -> str:
+ return self._content
+
+ def search_children(self) -> bool:
+ return self._search_children
+
+
+class TestSearchVisitor:
+ def test_adds_searchable_content(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ search = Search()
+ document = Document(MockSearchable("searchable content"))
+ context = Context({Source: source, Search: search, Document: document})
+
+ visitor = _SearchVisitor(context)
+ document.root.visit(visitor)
+
+ by_source = BySource(source=source)
+ assert by_source in search._items
+ assert "searchable content" in search._items[by_source]["text"]
+
+ def test_respects_search_children(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockSource()
+ search = Search()
+ inner = MockSearchable("inner")
+
+ class ParentNode(MockSearchable):
+ def __init__(self):
+ super().__init__("parent", search_children_val=False)
+ self._children = [inner]
+
+ @property
+ def children(self):
+ return self._children
+
+ document = Document(ParentNode())
+ context = Context({Source: source, Search: search, Document: document})
+
+ visitor = _SearchVisitor(context)
+ document.root.visit(visitor)
+
+ # Note: Accessing _items is necessary as Search has no public query API
+ by_source = BySource(source=source)
+ assert "parent" in search._items[by_source]["text"]
+ # Verify children were NOT indexed when search_children=False
+ assert (
+ "inner" not in search._items[by_source]["text"]
+ ), "Children should be skipped when search_children() returns False"
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..3c6e903
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,307 @@
+# Copyright (C) 2025 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 .
+
+import tempfile
+from pathlib import Path, PurePath
+from typing import Iterator
+
+import pytest
+
+from docc.settings import (
+ FILE_NAME,
+ MAX_DEPTH,
+ Output,
+ PluginSettings,
+ Settings,
+ SettingsError,
+)
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+class TestOutput:
+ def test_create_with_path(self) -> None:
+ output = Output(path=Path("/output/docs"))
+ assert output.path == Path("/output/docs")
+
+ def test_create_with_none(self) -> None:
+ output = Output(path=None)
+ assert output.path is None
+
+
+class TestPluginSettings:
+ def test_init(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {"key": "value"})
+
+ assert plugin_settings["key"] == "value"
+
+ def test_len(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {"a": 1, "b": 2, "c": 3})
+
+ assert len(plugin_settings) == 3
+
+ def test_iter(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {"a": 1, "b": 2})
+
+ keys = list(plugin_settings)
+ assert "a" in keys
+ assert "b" in keys
+
+ def test_getitem(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {"test_key": "test_value"})
+
+ assert plugin_settings["test_key"] == "test_value"
+
+ def test_getitem_missing_raises(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {})
+
+ with pytest.raises(KeyError):
+ plugin_settings["missing"]
+
+ def test_get_with_default(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {})
+
+ assert plugin_settings.get("missing", "default") == "default"
+
+ def test_resolve_path(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {})
+
+ resolved = plugin_settings.resolve_path(PurePath("subdir"))
+ assert resolved.is_absolute()
+ assert str(temp_dir) in str(resolved)
+
+ def test_unresolve_path(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = PluginSettings(settings, {})
+
+ absolute = temp_dir / "subdir" / "file.py"
+ relative = plugin_settings.unresolve_path(absolute)
+
+ assert not relative.is_absolute()
+
+
+class TestSettings:
+ def test_init_with_empty_tool_docc(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ assert isinstance(settings.context, list)
+ assert settings.output.path is None
+
+ def test_init_without_tool_key(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {})
+ assert isinstance(settings.context, list)
+ assert settings.output.path is None
+
+ def test_init_with_invalid_tool_type(self, temp_dir: Path) -> None:
+ with pytest.raises(TypeError, match="must be a dict"):
+ Settings(temp_dir, {"tool": "not_a_dict"})
+
+ def test_output_path_from_settings(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"output": {"path": "docs"}}}},
+ )
+ assert settings.output.path == Path("docs")
+
+ def test_output_path_none_when_not_specified(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ assert settings.output.path is None
+
+ def test_for_plugin(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {"plugins": {"test.plugin": {"option": "value"}}}
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("test.plugin")
+
+ assert plugin_settings["option"] == "value"
+
+ def test_for_plugin_missing_returns_empty(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = settings.for_plugin("nonexistent.plugin")
+
+ assert len(plugin_settings) == 0
+
+ def test_context_default(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ context = settings.context
+
+ assert isinstance(context, list)
+ assert "docc.references.context" in context
+ assert "docc.search.context" in context
+ assert "docc.html.context" in context
+
+ def test_context_custom(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"context": ["custom.context"]}}},
+ )
+ context = settings.context
+
+ assert context == ["custom.context"]
+
+ def test_discovery_default(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ discovery = settings.discovery
+
+ assert isinstance(discovery, list)
+ assert "docc.python.discover" in discovery
+ assert "docc.html.discover" in discovery
+
+ def test_discovery_custom(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"discovery": ["custom.discover"]}}},
+ )
+ discovery = settings.discovery
+
+ assert discovery == ["custom.discover"]
+
+ def test_build_default(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ build = settings.build
+
+ assert isinstance(build, list)
+ assert "docc.python.build" in build
+
+ def test_build_custom(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"build": ["custom.build"]}}},
+ )
+ build = settings.build
+
+ assert build == ["custom.build"]
+
+ def test_transform_default(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ transform = settings.transform
+
+ assert isinstance(transform, list)
+ assert "docc.python.transform" in transform
+ assert "docc.html.transform" in transform
+
+ def test_transform_custom(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"transform": ["custom.transform"]}}},
+ )
+ transform = settings.transform
+
+ assert transform == ["custom.transform"]
+
+ def test_resolve_path(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ resolved = settings.resolve_path(PurePath("subdir"))
+
+ assert resolved.is_absolute()
+ assert str(temp_dir) in str(resolved)
+
+ def test_resolve_path_escapes_root_raises(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+
+ with pytest.raises(ValueError):
+ settings.resolve_path(PurePath("../escape"))
+
+ def test_unresolve_path(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ absolute = temp_dir / "subdir" / "file.py"
+ relative = settings.unresolve_path(absolute)
+
+ assert not relative.is_absolute()
+ assert relative == PurePath("subdir") / "file.py"
+
+
+class TestSettingsFromFile:
+ def test_from_file_finds_pyproject_toml(self, temp_dir: Path) -> None:
+ pyproject = temp_dir / "pyproject.toml"
+ pyproject.write_text('[tool.docc]\noutput = { path = "docs" }\n')
+
+ settings = Settings.from_file(temp_dir)
+
+ assert settings.output.path == Path("docs")
+
+ def test_from_file_searches_parent_directories(
+ self, temp_dir: Path
+ ) -> None:
+ pyproject = temp_dir / "pyproject.toml"
+ pyproject.write_text('[tool.docc]\noutput = { path = "docs" }\n')
+
+ subdir = temp_dir / "src" / "submodule"
+ subdir.mkdir(parents=True)
+
+ settings = Settings.from_file(subdir)
+
+ assert settings.output.path == Path("docs")
+
+ def test_from_file_respects_max_depth(self, temp_dir: Path) -> None:
+ deep_path = temp_dir
+ for i in range(MAX_DEPTH + 5):
+ deep_path = deep_path / f"level{i}"
+ deep_path.mkdir(parents=True)
+
+ with pytest.raises(SettingsError, match="could not find"):
+ Settings.from_file(deep_path)
+
+ def test_from_file_not_found_raises(self, temp_dir: Path) -> None:
+ with pytest.raises(SettingsError, match="could not find"):
+ Settings.from_file(temp_dir)
+
+ def test_from_file_with_complete_config(self, temp_dir: Path) -> None:
+ config = """
+[tool.docc]
+context = ["custom.context"]
+discovery = ["custom.discover"]
+build = ["custom.build"]
+transform = ["custom.transform"]
+
+[tool.docc.output]
+path = "output"
+
+[tool.docc.plugins."custom.plugin"]
+option = "value"
+"""
+ pyproject = temp_dir / "pyproject.toml"
+ pyproject.write_text(config)
+
+ settings = Settings.from_file(temp_dir)
+
+ assert settings.context == ["custom.context"]
+ assert settings.discovery == ["custom.discover"]
+ assert settings.build == ["custom.build"]
+ assert settings.transform == ["custom.transform"]
+ assert settings.output.path == Path("output")
+
+
+class TestSettingsConstants:
+ def test_max_depth(self) -> None:
+ assert MAX_DEPTH == 10
+
+ def test_file_name(self) -> None:
+ assert FILE_NAME == "pyproject.toml"
diff --git a/tests/test_source.py b/tests/test_source.py
new file mode 100644
index 0000000..4579ed0
--- /dev/null
+++ b/tests/test_source.py
@@ -0,0 +1,222 @@
+# Copyright (C) 2025 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 .
+
+import tempfile
+from io import StringIO
+from pathlib import Path, PurePath
+from typing import Optional, TextIO
+from unittest.mock import patch
+
+import pytest
+
+from docc.source import Source, TextSource
+
+
+class ConcreteSource(Source):
+ _output: PurePath
+
+ def __init__(
+ self,
+ relative: Optional[PurePath] = None,
+ output: Optional[PurePath] = None,
+ ) -> None:
+ self._relative = relative
+ self._output = output or PurePath("output.html")
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._relative
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._output
+
+
+class ConcreteTextSource(TextSource):
+ _relative: PurePath
+ _output: PurePath
+
+ def __init__(
+ self,
+ content: str,
+ relative: Optional[PurePath] = None,
+ output: Optional[PurePath] = None,
+ ) -> None:
+ self._content = content
+ self._relative = relative or PurePath("test.py")
+ self._output = output or self._relative
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._relative
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._output
+
+ def open(self) -> TextIO:
+ return StringIO(self._content)
+
+
+class TestSource:
+ def test_repr_with_relative_path(self) -> None:
+ source = ConcreteSource(relative=PurePath("src/module.py"))
+ result = repr(source)
+
+ assert "src/module.py" in result
+ assert "ConcreteSource" in result
+
+ def test_repr_without_relative_path(self) -> None:
+ source = ConcreteSource(relative=None)
+ result = repr(source)
+
+ assert "ConcreteSource" in result
+
+ def test_output_path(self) -> None:
+ source = ConcreteSource(output=PurePath("docs/output.html"))
+ assert source.output_path == PurePath("docs/output.html")
+
+
+class TestTextSource:
+ def test_open_returns_text_io(self) -> None:
+ source = ConcreteTextSource("content")
+ with source.open() as f:
+ assert f.read() == "content"
+
+ def test_line_returns_correct_line(self) -> None:
+ content = "line1\nline2\nline3"
+ source = ConcreteTextSource(content)
+
+ assert source.line(1) == "line1"
+ assert source.line(2) == "line2"
+ assert source.line(3) == "line3"
+
+ def test_line_out_of_range_raises(self) -> None:
+ content = "line1\nline2"
+ source = ConcreteTextSource(content)
+
+ with pytest.raises(IndexError, match="line 10 out of range"):
+ source.line(10)
+
+ def test_line_single_line(self) -> None:
+ content = "single line"
+ source = ConcreteTextSource(content)
+
+ assert source.line(1) == "single line"
+
+ def test_line_empty_content(self) -> None:
+ content = ""
+ source = ConcreteTextSource(content)
+
+ assert source.line(1) == ""
+
+ def test_line_with_empty_lines(self) -> None:
+ content = "first\n\nthird"
+ source = ConcreteTextSource(content)
+
+ assert source.line(1) == "first"
+ assert source.line(2) == ""
+ assert source.line(3) == "third"
+
+
+class TestTextSourceBoundary:
+ def test_line_zero_returns_last_line(self) -> None:
+ """
+ line(0) computes lines[0 - 1] = lines[-1], which silently
+ returns the last line due to Python negative indexing.
+ """
+ content = "first\nsecond\nthird"
+ source = ConcreteTextSource(content)
+ # line(0) accesses lines[-1] which is "third"
+ assert source.line(0) == "third"
+
+ def test_line_negative_one_returns_second_to_last(self) -> None:
+ """
+ line(-1) computes lines[-1 - 1] = lines[-2], which silently
+ returns the second-to-last line due to Python negative indexing.
+ """
+ content = "first\nsecond\nthird"
+ source = ConcreteTextSource(content)
+ # line(-1) accesses lines[-2] which is "second"
+ assert source.line(-1) == "second"
+
+
+class TestTextSourceWithFiles:
+ def test_line_from_real_file(self) -> None:
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".py", delete=False
+ ) as f:
+ f.write("# Line 1\n# Line 2\n# Line 3\n")
+ f.flush()
+ path = Path(f.name)
+
+ class FileTextSource(TextSource):
+ def __init__(self, file_path: Path):
+ self._path = file_path
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return PurePath(self._path.name)
+
+ @property
+ def output_path(self) -> PurePath:
+ return PurePath(self._path.name)
+
+ def open(self) -> TextIO:
+ return open(self._path, "r")
+
+ try:
+ source = FileTextSource(path)
+ assert source.line(1) == "# Line 1"
+ assert source.line(2) == "# Line 2"
+ finally:
+ path.unlink()
+
+
+class TestTextSourceCache:
+ def test_open_called_once_for_multiple_line_calls(self) -> None:
+ """Calling line() multiple times should only open the file once."""
+ content = "line1\nline2\nline3"
+ source = ConcreteTextSource(content)
+
+ with patch.object(source, "open", wraps=source.open) as mock_open:
+ source.line(1)
+ source.line(2)
+ source.line(3)
+
+ mock_open.assert_called_once()
+
+ def test_lines_cache_matches_content_split(self) -> None:
+ """After calling line(), _lines_cache matches content split."""
+ content = "alpha\nbeta\ngamma"
+ source = ConcreteTextSource(content)
+
+ source.line(1)
+
+ assert source._lines_cache == ["alpha", "beta", "gamma"]
+
+ def test_lines_cache_empty_content(self) -> None:
+ """Cache for empty content should be a list with one empty string."""
+ source = ConcreteTextSource("")
+
+ source.line(1)
+
+ assert source._lines_cache == [""]
+
+ def test_lines_cache_is_none_before_first_call(self) -> None:
+ """Before any line() call, the cache should be None."""
+ source = ConcreteTextSource("some content")
+
+ assert source._lines_cache is None
diff --git a/tests/test_transform.py b/tests/test_transform.py
new file mode 100644
index 0000000..12d0c2d
--- /dev/null
+++ b/tests/test_transform.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2025 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 .
+
+import tempfile
+from pathlib import Path
+from typing import Iterator
+
+import pytest
+
+from docc.context import Context
+from docc.settings import PluginSettings, Settings
+from docc.transform import Transform, load
+
+
+@pytest.fixture
+def temp_dir() -> Iterator[Path]:
+ with tempfile.TemporaryDirectory() as td:
+ yield Path(td)
+
+
+class ConcreteTransform(Transform):
+ def __init__(self, config: PluginSettings) -> None:
+ self.config = config
+
+ def transform(self, context: Context) -> None:
+ pass
+
+
+class TestTransform:
+ def test_concrete_transform_init(self, temp_dir: Path) -> None:
+ settings = Settings(temp_dir, {"tool": {"docc": {}}})
+ plugin_settings = settings.for_plugin("test")
+
+ transform = ConcreteTransform(plugin_settings)
+ assert transform.config is plugin_settings
+
+
+class TestTransformLoad:
+ def test_load_empty_transform_list(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"transform": []}}},
+ )
+
+ result = load(settings)
+ assert result == []
+
+ def test_load_single_transform(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"transform": ["docc.python.transform"]}}},
+ )
+
+ result = load(settings)
+ assert len(result) == 1
+ assert result[0][0] == "docc.python.transform"
+
+ def test_load_multiple_transforms(self, temp_dir: Path) -> None:
+ settings = Settings(
+ temp_dir,
+ {
+ "tool": {
+ "docc": {
+ "transform": [
+ "docc.python.transform",
+ "docc.mistletoe.transform",
+ ]
+ }
+ }
+ },
+ )
+
+ result = load(settings)
+ assert len(result) == 2
+
+ def test_load_preserves_order(self, temp_dir: Path) -> None:
+ transforms = [
+ "docc.python.transform",
+ "docc.mistletoe.transform",
+ "docc.html.transform",
+ ]
+ settings = Settings(
+ temp_dir,
+ {"tool": {"docc": {"transform": transforms}}},
+ )
+
+ result = load(settings)
+ for i, (name, _) in enumerate(result):
+ assert name == transforms[i]
diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py
new file mode 100644
index 0000000..11bd038
--- /dev/null
+++ b/tests/test_verbatim.py
@@ -0,0 +1,700 @@
+# Copyright (C) 2025 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 Path, PurePath
+from typing import Any, List, Optional, Sequence, TextIO
+from unittest.mock import patch
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document, ListNode
+from docc.plugins.html import HTMLTag, TextNode
+from docc.plugins.verbatim import (
+ Fragment,
+ Highlight,
+ Line,
+ Pos,
+ Text,
+ Transcribe,
+ Transcribed,
+ Verbatim,
+ VerbatimVisitor,
+ _BoundsVisitor,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import TextSource
+
+
+class MockTextSource(TextSource):
+ _path: PurePath
+
+ def __init__(self, content: str, path: Optional[PurePath] = None) -> None:
+ self._content = content
+ self._path = path if path is not None else PurePath("test.py")
+
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return self._path
+
+ @property
+ def output_path(self) -> PurePath:
+ return self._path
+
+ def open(self) -> TextIO:
+ return StringIO(self._content)
+
+
+@pytest.fixture
+def plugin_settings() -> PluginSettings:
+ settings = Settings(Path("."), {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.verbatim.transform")
+
+
+class TestPos:
+ def test_create(self) -> None:
+ pos = Pos(line=1, column=5)
+ assert pos.line == 1
+ assert pos.column == 5
+
+ def test_frozen(self) -> None:
+ pos = Pos(line=1, column=5)
+ with pytest.raises(AttributeError):
+ pos.line = 2 # pyre-ignore[41]
+
+ def test_repr(self) -> None:
+ pos = Pos(line=10, column=20)
+ assert repr(pos) == "10:20"
+
+ def test_ordering(self) -> None:
+ first_pos = Pos(line=1, column=0)
+ second_pos = Pos(line=1, column=5)
+ third_pos = Pos(line=2, column=0)
+
+ assert first_pos < second_pos < third_pos
+
+ def test_equality(self) -> None:
+ first_pos = Pos(line=1, column=5)
+ second_pos = Pos(line=1, column=5)
+ assert first_pos == second_pos
+
+
+class TestText:
+ def test_create(self) -> None:
+ text = Text(text="hello")
+ assert text.text == "hello"
+
+ def test_children_empty(self) -> None:
+ text = Text(text="hello")
+ assert text.children == ()
+
+ def test_replace_child_raises(self) -> None:
+ text = Text(text="hello")
+ with pytest.raises(TypeError):
+ text.replace_child(BlankNode(), BlankNode())
+
+
+class TestLine:
+ def test_create(self) -> None:
+ line = Line(number=1)
+ assert line.number == 1
+ assert list(line.children) == []
+
+ def test_children_with_content(self) -> None:
+ line = Line(number=1, _children=[Text("hello")])
+ children = list(line.children)
+ assert len(children) == 1
+ assert isinstance(children[0], Text)
+
+ def test_replace_child(self) -> None:
+ old = Text("old")
+ new = Text("new")
+ line = Line(number=1, _children=[old])
+
+ line.replace_child(old, new)
+
+ children = list(line.children)
+ assert new in children
+ assert old not in children
+
+ def test_repr(self) -> None:
+ line = Line(number=5)
+ assert "Line" in repr(line)
+ assert "5" in repr(line)
+
+
+class TestHighlight:
+ def test_create(self) -> None:
+ highlight = Highlight(highlights=["keyword"])
+ assert highlight.highlights == ["keyword"]
+
+ def test_children_empty(self) -> None:
+ highlight = Highlight()
+ assert list(highlight.children) == []
+
+ def test_children_with_content(self) -> None:
+ text = Text("highlighted")
+ highlight = Highlight(_children=[text])
+ assert text in highlight.children
+
+ def test_replace_child(self) -> None:
+ old = Text("old")
+ new = Text("new")
+ highlight = Highlight(_children=[old])
+
+ highlight.replace_child(old, new)
+
+ assert new in highlight.children
+ assert old not in highlight.children
+
+ def test_repr(self) -> None:
+ highlight = Highlight(highlights=["keyword", "function"])
+ result = repr(highlight)
+ assert "Highlight" in result
+ assert "keyword" in result
+
+
+class TestTranscribed:
+ def test_create(self) -> None:
+ transcribed = Transcribed()
+ assert list(transcribed.children) == []
+
+ def test_children(self) -> None:
+ line = Line(number=1)
+ transcribed = Transcribed(_children=[line])
+ assert line in transcribed.children
+
+ def test_replace_child(self) -> None:
+ old = Line(number=1)
+ new = Line(number=2)
+ transcribed = Transcribed(_children=[old])
+
+ transcribed.replace_child(old, new)
+
+ assert new in transcribed.children
+ assert old not in transcribed.children
+
+ def test_repr(self) -> None:
+ transcribed = Transcribed()
+ assert repr(transcribed) == "Transcribed(...)"
+
+
+class TestFragment:
+ def test_create(self) -> None:
+ start = Pos(line=1, column=0)
+ end = Pos(line=1, column=10)
+ fragment = Fragment(start, end)
+
+ assert fragment.start == start
+ assert fragment.end == end
+ assert fragment.highlights == []
+
+ def test_create_with_highlights(self) -> None:
+ start = Pos(line=1, column=0)
+ end = Pos(line=1, column=10)
+ fragment = Fragment(start, end, highlights=["keyword"])
+
+ assert fragment.highlights == ["keyword"]
+
+ def test_repr(self) -> None:
+ start = Pos(line=1, column=0)
+ end = Pos(line=1, column=10)
+ fragment = Fragment(start, end, highlights=["test"])
+
+ result = repr(fragment)
+ assert "Fragment" in result
+ assert "1:0" in result
+ assert "1:10" in result
+
+
+class TestVerbatim:
+ def test_create(self) -> None:
+ source = MockTextSource("content")
+ verbatim = Verbatim(source)
+
+ assert verbatim.source is source
+ assert list(verbatim.children) == []
+
+ def test_repr(self) -> None:
+ source = MockTextSource("content")
+ verbatim = Verbatim(source)
+
+ result = repr(verbatim)
+ assert "Verbatim" in result
+
+
+class TestVerbatimNode:
+ def test_append(self) -> None:
+ source = MockTextSource("content")
+ verbatim = Verbatim(source)
+ fragment = Fragment(Pos(1, 0), Pos(1, 5))
+
+ verbatim.append(fragment)
+
+ assert fragment in verbatim.children
+
+ def test_append_nested_verbatim_raises(self) -> None:
+ source = MockTextSource("content")
+ outer = Verbatim(source)
+ inner = Verbatim(source)
+
+ with pytest.raises(ValueError, match="cannot nest"):
+ outer.append(inner)
+
+ def test_replace_child(self) -> None:
+ source = MockTextSource("content")
+ verbatim = Verbatim(source)
+ old = Fragment(Pos(1, 0), Pos(1, 5))
+ new = Fragment(Pos(1, 0), Pos(1, 10))
+ verbatim.append(old)
+
+ verbatim.replace_child(old, new)
+
+ assert new in verbatim.children
+ assert old not in verbatim.children
+
+
+class TestBoundsVisitor:
+ def test_finds_start_end(self) -> None:
+ first_fragment = Fragment(Pos(1, 5), Pos(1, 10))
+ second_fragment = Fragment(Pos(2, 0), Pos(2, 15))
+
+ source = MockTextSource("line1\nline2")
+ verbatim = Verbatim(source)
+ verbatim.append(first_fragment)
+ verbatim.append(second_fragment)
+
+ visitor = _BoundsVisitor()
+ verbatim.visit(visitor)
+
+ assert visitor.start == Pos(1, 5)
+ assert visitor.end == Pos(2, 15)
+
+ def test_no_fragments(self) -> None:
+ visitor = _BoundsVisitor()
+ blank = BlankNode()
+ blank.visit(visitor)
+
+ assert visitor.start is None
+ assert visitor.end is None
+
+
+class ConcreteVerbatimVisitor(VerbatimVisitor):
+ lines: List[int]
+ texts: List[str]
+ highlights: List[Any]
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.lines = []
+ self.texts = []
+ self.highlights = []
+
+ def line(self, source: TextSource, line: int) -> None:
+ self.lines.append(line)
+
+ def text(self, text: str) -> None:
+ self.texts.append(text)
+
+ def begin_highlight(self, highlights: Sequence[str]) -> None:
+ self.highlights.append(("begin", list(highlights)))
+
+ def end_highlight(self) -> None:
+ self.highlights.append(("end", None))
+
+
+class TestVerbatimVisitor:
+ def test_visit_verbatim_with_fragment(self) -> None:
+ source = MockTextSource("hello world")
+ verbatim = Verbatim(source)
+ fragment = Fragment(Pos(1, 0), Pos(1, 5), highlights=["keyword"])
+ verbatim.append(fragment)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ assert 1 in visitor.lines
+ assert ("begin", ["keyword"]) in visitor.highlights
+ assert ("end", None) in visitor.highlights
+
+ def test_visit_verbatim_multi_line_fragment(self) -> None:
+ source = MockTextSource("line one\nline two\nline three")
+ verbatim = Verbatim(source)
+ fragment = Fragment(Pos(1, 0), Pos(3, 10))
+ verbatim.append(fragment)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ assert 1 in visitor.lines
+ assert 2 in visitor.lines
+ assert 3 in visitor.lines
+
+ joined = "".join(visitor.texts)
+ assert "line one" in joined
+ assert "line two" in joined
+ assert "line three" in joined
+
+ def test_visit_verbatim_multiple_fragments(self) -> None:
+ source = MockTextSource("alpha\nbeta\ngamma\ndelta")
+ verbatim = Verbatim(source)
+ first_fragment = Fragment(Pos(1, 0), Pos(2, 4))
+ second_fragment = Fragment(Pos(3, 0), Pos(4, 5))
+ verbatim.append(first_fragment)
+ verbatim.append(second_fragment)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ joined = "".join(visitor.texts)
+ assert "alpha" in joined
+ assert "beta" in joined
+ assert "gamma" in joined
+ assert "delta" in joined
+
+ def test_nested_verbatim_raises(self) -> None:
+ # This test verifies the visitor's safety check against nested
+ # Verbatim. While Verbatim.append() prevents nesting at construction
+ # time, the visitor has an additional runtime check as defense-in-
+ # depth. We simulate already being inside a Verbatim by setting _depth.
+ source = MockTextSource("content")
+ outer = Verbatim(source)
+
+ visitor = ConcreteVerbatimVisitor()
+ visitor._depth = 1 # Simulate already inside a Verbatim
+
+ with pytest.raises(Exception, match="cannot be nested"):
+ visitor._enter_verbatim(outer)
+
+ def test_fragment_outside_verbatim_raises(self) -> None:
+ fragment = Fragment(Pos(1, 0), Pos(1, 5))
+ visitor = ConcreteVerbatimVisitor()
+
+ with pytest.raises(Exception, match="must appear inside"):
+ visitor._enter_fragment(fragment)
+
+
+class TestTranscribe:
+ def test_transform_simple(self, plugin_settings: PluginSettings) -> None:
+ source = MockTextSource("hello world")
+ verbatim = Verbatim(source)
+ fragment = Fragment(Pos(1, 0), Pos(1, 5))
+ verbatim.append(fragment)
+
+ document = Document(verbatim)
+ context = Context({Document: document})
+
+ transform = Transcribe(plugin_settings)
+ transform.transform(context)
+
+ assert isinstance(document.root, Transcribed)
+
+ def test_transcribe_multi_line(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ source = MockTextSource("line one\nline two\nline three")
+ verbatim = Verbatim(source)
+ fragment = Fragment(Pos(1, 0), Pos(3, 10))
+ verbatim.append(fragment)
+
+ document = Document(verbatim)
+ context = Context({Document: document})
+
+ transform = Transcribe(plugin_settings)
+ transform.transform(context)
+
+ assert isinstance(document.root, Transcribed)
+ lines = [
+ child
+ for child in document.root.children
+ if isinstance(child, Line)
+ ]
+ assert len(lines) == 3
+ assert lines[0].number == 1
+ assert lines[1].number == 2
+ assert lines[2].number == 3
+
+ def _find_text(node: object) -> List[Text]:
+ found: List[Text] = []
+ if isinstance(node, Text):
+ found.append(node)
+ if hasattr(node, "children"):
+ for child in node.children: # type: ignore[union-attr]
+ found.extend(_find_text(child))
+ return found
+
+ for line_node in lines:
+ text_nodes = _find_text(line_node)
+ assert len(text_nodes) > 0
+
+ def test_transform_no_verbatim(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ blank = BlankNode()
+ document = Document(blank)
+ context = Context({Document: document})
+
+ transform = Transcribe(plugin_settings)
+ transform.transform(context)
+
+ assert document.root is blank
+
+
+class TestVerbatimHtmlRendering:
+ """Tests for src/docc/plugins/verbatim/html.py render functions."""
+
+ def test_render_transcribed(self) -> None:
+ """render_transcribed produces an HTML table with class 'verbatim'."""
+ from docc.plugins.verbatim.html import render_transcribed
+
+ context = Context({})
+ parent = HTMLTag("div")
+ node = Transcribed()
+
+ result = render_transcribed(context, parent, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "table"
+ assert result.attributes.get("class") == "verbatim"
+ # The table should be appended to parent
+ assert result in parent.children
+
+ def test_render_line_inside_table(self) -> None:
+ """
+ render_line produces a tr with th (line number) and td>pre
+ (code) when parent is a table.
+ """
+ from docc.plugins.verbatim.html import render_line
+
+ context = Context({})
+ parent = HTMLTag("table")
+ node = Line(number=42)
+
+ result = render_line(context, parent, node)
+
+ # result should be the inside the |
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "pre"
+
+ # Parent (table) should have a | child
+ assert len(list(parent.children)) == 1
+ tbody = list(parent.children)[0]
+ assert isinstance(tbody, HTMLTag)
+ assert tbody.tag_name == "tbody"
+
+ # tbody should contain a
+ tr = list(tbody.children)[0]
+ assert isinstance(tr, HTMLTag)
+ assert tr.tag_name == "tr"
+
+ #
should contain | and |
+ tr_children = list(tr.children)
+ assert len(tr_children) == 2
+ th, td = tr_children
+ assert isinstance(th, HTMLTag)
+ assert th.tag_name == "th"
+ assert isinstance(td, HTMLTag)
+ assert td.tag_name == "td"
+
+ # | should contain TextNode with line number
+ th_children = list(th.children)
+ assert len(th_children) == 1
+ th_child = th_children[0]
+ assert isinstance(th_child, TextNode)
+ assert th_child._value == "42"
+
+ def test_render_line_outside_table(self) -> None:
+ """render_line appends |
directly to non-table parents."""
+ from docc.plugins.verbatim.html import render_line
+
+ context = Context({})
+ parent = HTMLTag("div")
+ node = Line(number=1)
+
+ result = render_line(context, parent, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "pre"
+ # Parent (div) should have
directly (no
)
+ assert len(list(parent.children)) == 1
+ tr = list(parent.children)[0]
+ assert isinstance(tr, HTMLTag)
+ assert tr.tag_name == "tr"
+
+ def test_render_text(self) -> None:
+ """render_text appends a TextNode with correct content."""
+ from docc.plugins.verbatim.html import render_text
+
+ context = Context({})
+ parent = HTMLTag("pre")
+ node = Text(text="hello world")
+
+ result = render_text(context, parent, node)
+
+ assert result is None
+ children = list(parent.children)
+ assert len(children) == 1
+ child = children[0]
+ assert isinstance(child, TextNode)
+ assert child._value == "hello world"
+
+ def test_render_highlight(self) -> None:
+ """render_highlight produces with hi-{name} hi classes."""
+ from docc.plugins.verbatim.html import render_highlight
+
+ context = Context({})
+ parent = HTMLTag("pre")
+ node = Highlight(highlights=["keyword", "function"])
+
+ result = render_highlight(context, parent, node)
+
+ assert isinstance(result, HTMLTag)
+ assert result.tag_name == "span"
+ classes = result.attributes.get("class") or ""
+ assert "hi-keyword" in classes
+ assert "hi-function" in classes
+ assert "hi" in classes.split()
+ # The span should be appended to parent
+ assert result in parent.children
+
+
+class TestBoundsCacheBehavior:
+ """Caching _BoundsVisitor results must not change output."""
+
+ def test_cached_bounds_produce_same_text_output(self) -> None:
+ """Caching does not change text output for wrapped Fragments."""
+ source = MockTextSource("alpha\nbeta\ngamma")
+ verbatim = Verbatim(source)
+
+ wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 4))])
+ verbatim.append(wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ joined = "".join(visitor.texts)
+ assert "alpha" in joined
+ assert "beta" in joined
+
+ def test_multiple_wrappers_produce_correct_output(self) -> None:
+ """Multiple wrappers with Fragments produce correct text."""
+ source = MockTextSource("aaa\nbbb\nccc\nddd")
+ verbatim = Verbatim(source)
+
+ first_wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 3))])
+ second_wrapper = ListNode([Fragment(Pos(3, 0), Pos(4, 3))])
+ verbatim.append(first_wrapper)
+ verbatim.append(second_wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ joined = "".join(visitor.texts)
+ assert "aaa" in joined
+ assert "bbb" in joined
+ assert "ccc" in joined
+ assert "ddd" in joined
+
+
+class TestBoundsCacheCallCount:
+ """The cache must eliminate redundant _BoundsVisitor creation."""
+
+ def test_single_wrapper_visited_once(self) -> None:
+ """Enter and exit of one node creates only one _BoundsVisitor."""
+ source = MockTextSource("hello\nworld")
+ verbatim = Verbatim(source)
+
+ wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 5))])
+ verbatim.append(wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+
+ with patch(
+ "docc.plugins.verbatim._BoundsVisitor",
+ wraps=_BoundsVisitor,
+ ) as mock_cls:
+ verbatim.visit(visitor)
+ # One wrapper node -> one _BoundsVisitor creation (not two).
+ assert mock_cls.call_count == 1
+
+ def test_two_wrappers_visited_once_each(self) -> None:
+ """Two distinct nodes each create exactly one _BoundsVisitor."""
+ source = MockTextSource("aaa\nbbb\nccc\nddd")
+ verbatim = Verbatim(source)
+
+ first_wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 3))])
+ second_wrapper = ListNode([Fragment(Pos(3, 0), Pos(4, 3))])
+ verbatim.append(first_wrapper)
+ verbatim.append(second_wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+
+ with patch(
+ "docc.plugins.verbatim._BoundsVisitor",
+ wraps=_BoundsVisitor,
+ ) as mock_cls:
+ verbatim.visit(visitor)
+ # Two unique wrappers -> exactly two creations.
+ assert mock_cls.call_count == 2
+
+
+class TestBoundsCacheKeying:
+ """The _bounds_cache dictionary must be keyed by id(node)."""
+
+ def test_cache_contains_wrapper_ids(self) -> None:
+ """Cache has entries keyed by id(node) for each wrapper node."""
+ source = MockTextSource("hello\nworld")
+ verbatim = Verbatim(source)
+
+ wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 5))])
+ verbatim.append(wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ assert id(wrapper) in visitor._bounds_cache
+
+ def test_cache_values_are_start_end_tuples(self) -> None:
+ """Cached values must be (start, end) tuples of Optional[Pos]."""
+ source = MockTextSource("hello\nworld")
+ verbatim = Verbatim(source)
+
+ wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 5))])
+ verbatim.append(wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ start, end = visitor._bounds_cache[id(wrapper)]
+ assert start == Pos(1, 0)
+ assert end == Pos(2, 5)
+
+ def test_cache_has_entry_per_wrapper(self) -> None:
+ """Each non-Fragment/non-Verbatim node gets its own cache entry."""
+ source = MockTextSource("aaa\nbbb\nccc\nddd")
+ verbatim = Verbatim(source)
+
+ first_wrapper = ListNode([Fragment(Pos(1, 0), Pos(2, 3))])
+ second_wrapper = ListNode([Fragment(Pos(3, 0), Pos(4, 3))])
+ verbatim.append(first_wrapper)
+ verbatim.append(second_wrapper)
+
+ visitor = ConcreteVerbatimVisitor()
+ verbatim.visit(visitor)
+
+ assert id(first_wrapper) in visitor._bounds_cache
+ assert id(second_wrapper) in visitor._bounds_cache
+ assert len(visitor._bounds_cache) == 2
diff --git a/tox.ini b/tox.ini
index 612d4d0..3fd5318 100644
--- a/tox.ini
+++ b/tox.ini
@@ -12,6 +12,7 @@ python =
description = run tests
extras =
lint
+ test
commands =
python --version
isort src tests setup.py --check --diff
@@ -25,6 +26,7 @@ description = check type annotations
platform = (linux|darwin)
extras =
lint
+ test
commands =
python --version
pyre --noninteractive check
diff --git a/whitelist.txt b/whitelist.txt
index 96390b2..a56fe2a 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -1,19 +1,38 @@
asts
autoescape
+autolink
+binop
+blockquote
+boldnew
+caplog
casefold
charref
charrefs
+chdir
checkable
+collector1
+collector2
+commonpath
+conftest
copyfileobj
ctx
+dasherize
decl
defs
delitem
+dest
+doc1
+doc2
docc
-commonpath
-dasherize
+docstrings
+docstrings1
+docstrings2
endtag
entityref
+env1
+env2
+envs
+ep
eq
ethereum
etree
@@ -22,9 +41,14 @@ exc
expr
fullname
func
-href
getitem
globbed
+h1
+h2
+h3
+hashable
+href
+img
isabstract
islice
j2
@@ -39,12 +63,16 @@ matcher
merchantability
metaclass
modulefinder
+names1
+names2
+ol
param
params
pathname2url
perf
Pos
ptag
+pyproject
qualname
removeprefix
renderer
@@ -54,20 +82,34 @@ ret
rvalue
setext
setitem
+source1
+source2
src
starttag
stmt
+stringio
strikethrough
subclasses
+subclassing
+subdir
+subdirectory
+subtrees
superclass
tbody
+td1
+td2
thead
this'll
toml
tomli
+tooltip
tostring
-traverser
traversable
+traverser
typeddict
+types1
+types2
+unittest
+unlink
unresolve
urlunsplit