Docs: Cell reference as a custom documenter

Use autodocs to perform cell reference docs generation instead of generating rst files directly.
e.g.
```
.. autocell:: simlib.v:$alu
   :source:
   :linenos:
```
This commit is contained in:
Krystine Sherwin 2024-05-17 17:54:08 +12:00
parent 06e5e18371
commit e5f54dd7cd
No known key found for this signature in database
3 changed files with 526 additions and 9 deletions

View File

@ -100,9 +100,14 @@ latex_elements = {
sys.path += [os.path.dirname(__file__) + "/../"]
extensions.append('util.cmdref')
def setup(sphinx):
from util.RtlilLexer import RtlilLexer
sphinx.add_lexer("RTLIL", RtlilLexer)
# use autodocs
extensions.append('sphinx.ext.autodoc')
extensions.append('util.cellref')
from util.YoscryptLexer import YoscryptLexer
sphinx.add_lexer("yoscrypt", YoscryptLexer)
from sphinx.application import Sphinx
def setup(app: Sphinx) -> None:
from util.RtlilLexer import RtlilLexer
app.add_lexer("RTLIL", RtlilLexer)
from util.YoscryptLexer import YoscryptLexer
app.add_lexer("yoscrypt", YoscryptLexer)

367
docs/util/cellref.py Normal file
View File

@ -0,0 +1,367 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import re
from typing import Any
from sphinx.application import Sphinx
from sphinx.ext import autodoc
from sphinx.ext.autodoc import Documenter
from sphinx.util import logging
logger = logging.getLogger(__name__)
# cell signature
cell_ext_sig_re = re.compile(
r'''^ (?:([\w._/]+):)? # explicit file name
([\w$._]+?)? # module and/or class name(s)
(?:\.([\w_]+))? # optional: thing name
(::[\w_]+)? # attribute
\s* $ # and nothing more
''', re.VERBOSE)
class SimHelper:
name: str = ""
title: str = ""
ports: str = ""
source: str = ""
desc: list[str]
code: list[str]
group: str = ""
ver: str = "1"
def __init__(self) -> None:
self.desc = []
def simcells_reparse(cell: SimHelper):
# cut manual signature
cell.desc = cell.desc[3:]
# code-block truth table
new_desc = []
indent = ""
for line in cell.desc:
if line.startswith("Truth table:"):
indent = " "
new_desc.pop()
new_desc.extend(["::", ""])
new_desc.append(indent + line)
cell.desc = new_desc
# set version
cell.ver = "2a"
def load_cell_lib(file: Path):
simHelpers: dict[str, SimHelper] = {}
simHelper = SimHelper()
with open(file, "r") as f:
lines = f.readlines()
for lineno, line in enumerate(lines, 1):
line = line.rstrip()
# special comments
if line.startswith("//-"):
simHelper.desc.append(line[4:] if len(line) > 4 else "")
elif line.startswith("//* "):
_, key, val = line.split(maxsplit=2)
setattr(simHelper, key, val)
# code parsing
if line.startswith("module "):
clean_line = line[7:].replace("\\", "").replace(";", "")
simHelper.name, simHelper.ports = clean_line.split(maxsplit=1)
simHelper.code = []
simHelper.source = f'{file.name}:{lineno}'
elif not line.startswith("endmodule"):
line = " " + line
try:
simHelper.code.append(line.replace("\t", " "))
except AttributeError:
# no module definition, ignore line
pass
if line.startswith("endmodule"):
if simHelper.ver == "1" and file.name == "simcells.v":
# default simcells parsing
simcells_reparse(simHelper)
if not simHelper.desc:
# no help
simHelper.desc.append("No help message for this cell type found.\n")
elif simHelper.ver == "1" and file.name == "simlib.v" and simHelper.desc[1].startswith(' '):
simHelper.desc.pop(1)
simHelpers[simHelper.name] = simHelper
simHelper = SimHelper()
return simHelpers
class YosysCellDocumenter(Documenter):
objtype = 'cell'
parsed_libs: dict[Path, dict[str, SimHelper]] = {}
object: SimHelper
option_spec = {
'source': autodoc.bool_option,
'linenos': autodoc.bool_option,
}
@classmethod
def can_document_member(
cls,
member: Any,
membername: str,
isattr: bool,
parent: Any
) -> bool:
sourcename = str(member).split(":")[0]
if not sourcename.endswith(".v"):
return False
if membername == "__source":
return False
def parse_name(self) -> bool:
try:
matched = cell_ext_sig_re.match(self.name)
path, modname, thing, attribute = matched.groups()
except AttributeError:
logger.warning(('invalid signature for auto%s (%r)') % (self.objtype, self.name),
type='cellref')
return False
if not path:
return False
self.modname = modname
self.objpath = [path]
self.attribute = attribute
self.fullname = ((self.modname) + (thing or ''))
return True
def import_object(self, raiseerror: bool = False) -> bool:
# find cell lib file
objpath = Path('/'.join(self.objpath))
if not objpath.exists():
objpath = Path('source') / 'generated' / objpath
# load cell lib
try:
parsed_lib = self.parsed_libs[objpath]
except KeyError:
parsed_lib = load_cell_lib(objpath)
self.parsed_libs[objpath] = parsed_lib
# get cell
try:
self.object = parsed_lib[self.modname]
except KeyError:
return False
self.real_modname = f'{objpath}:{self.modname}'
return True
def get_sourcename(self) -> str:
return self.objpath
def format_name(self) -> str:
return self.object.name
def format_signature(self, **kwargs: Any) -> str:
return f"{self.object.name} {self.object.ports}"
def add_directive_header(self, sig: str) -> None:
domain = getattr(self, 'domain', self.objtype)
directive = getattr(self, 'directivetype', 'def')
name = self.format_name()
sourcename = self.get_sourcename()
cell = self.object
# cell definition
self.add_line(f'.. {domain}:{directive}:: {name}', sourcename)
# options
opt_attrs = ["title", ]
for attr in opt_attrs:
val = getattr(cell, attr, None)
if val:
self.add_line(f' :{attr}: {val}', sourcename)
if self.options.noindex:
self.add_line(' :noindex:', sourcename)
def add_content(self, more_content: Any | None) -> None:
# set sourcename and add content from attribute documentation
sourcename = self.get_sourcename()
startline = int(self.object.source.split(":")[1])
for i, line in enumerate(self.object.desc, startline):
self.add_line(line, sourcename, i)
# add additional content (e.g. from document), if present
if more_content:
for line, src in zip(more_content.data, more_content.items):
self.add_line(line, src[0], src[1])
def filter_members(
self,
members: list[tuple[str, Any]],
want_all: bool
) -> list[tuple[str, Any, bool]]:
return [(x[0], x[1], False) for x in members]
def get_object_members(
self,
want_all: bool
) -> tuple[bool, list[tuple[str, Any]]]:
ret: list[tuple[str, str]] = []
if self.options.source:
ret.append(('__source', self.real_modname))
return False, ret
def document_members(self, all_members: bool = False) -> None:
want_all = (all_members or
self.options.inherited_members)
# find out which members are documentable
members_check_module, members = self.get_object_members(want_all)
# document non-skipped members
memberdocumenters: list[tuple[Documenter, bool]] = []
for (mname, member, isattr) in self.filter_members(members, want_all):
classes = [cls for cls in self.documenters.values()
if cls.can_document_member(member, mname, isattr, self)]
if not classes:
# don't know how to document this member
continue
# prefer the documenter with the highest priority
classes.sort(key=lambda cls: cls.priority)
# give explicitly separated module name, so that members
# of inner classes can be documented
full_mname = self.real_modname + '::' + mname
documenter = classes[-1](self.directive, full_mname, self.indent)
memberdocumenters.append((documenter, isattr))
member_order = self.options.member_order or self.config.autodoc_member_order
memberdocumenters = self.sort_members(memberdocumenters, member_order)
for documenter, isattr in memberdocumenters:
documenter.generate(
all_members=True, real_modname=self.real_modname,
check_module=members_check_module and not isattr)
def generate(
self,
more_content: Any | None = None,
real_modname: str | None = None,
check_module: bool = False,
all_members: bool = False
) -> None:
if not self.parse_name():
# need a cell lib to import from
logger.warning(
f"don't know which cell lib to import for autodocumenting {self.name}",
type = 'cellref'
)
return
self.import_object()
# check __module__ of object (for members not given explicitly)
# if check_module:
# if not self.check_module():
# return
sourcename = self.get_sourcename()
self.add_line('', sourcename)
# format the object's signature, if any
try:
sig = self.format_signature()
except Exception as exc:
logger.warning(('error while formatting signature for %s: %s'),
self.fullname, exc, type='cellref')
return
# generate the directive header and options, if applicable
self.add_directive_header(sig)
self.add_line('', sourcename)
# e.g. the module directive doesn't have content
self.indent += self.content_indent
# add all content (from docstrings, attribute docs etc.)
self.add_content(more_content)
# document members, if possible
self.document_members(all_members)
class YosysCellSourceDocumenter(YosysCellDocumenter):
objtype = 'cellsource'
priority = 20
@classmethod
def can_document_member(
cls,
member: Any,
membername: str,
isattr: bool,
parent: Any
) -> bool:
sourcename = str(member).split(":")[0]
if not sourcename.endswith(".v"):
return False
if membername != "__source":
return False
if isinstance(parent, YosysCellDocumenter):
return True
return False
def add_directive_header(self, sig: str) -> None:
domain = getattr(self, 'domain', 'cell')
directive = getattr(self, 'directivetype', 'source')
name = self.format_name()
sourcename = self.get_sourcename()
cell = self.object
# cell definition
self.add_line(f'.. {domain}:{directive}:: {name}', sourcename)
if self.options.linenos:
self.add_line(f' :source: {cell.source.split(":")[0]}', sourcename)
else:
self.add_line(f' :source: {cell.source}', sourcename)
self.add_line(f' :language: verilog', sourcename)
if self.options.linenos:
startline = int(self.object.source.split(":")[1])
self.add_line(f' :lineno-start: {startline}', sourcename)
if self.options.noindex:
self.add_line(' :noindex:', sourcename)
def add_content(self, more_content: Any | None) -> None:
# set sourcename and add content from attribute documentation
sourcename = self.get_sourcename()
startline = int(self.object.source.split(":")[1])
for i, line in enumerate(self.object.code, startline-1):
self.add_line(line, sourcename, i)
# add additional content (e.g. from document), if present
if more_content:
for line, src in zip(more_content.data, more_content.items):
self.add_line(line, src[0], src[1])
def get_object_members(
self,
want_all: bool
) -> tuple[bool, list[tuple[str, Any]]]:
return False, []
def setup(app: Sphinx) -> dict[str, Any]:
app.setup_extension('sphinx.ext.autodoc')
app.add_autodocumenter(YosysCellDocumenter)
app.add_autodocumenter(YosysCellSourceDocumenter)
return {
'version': '1',
'parallel_read_safe': True,
}

View File

@ -1,5 +1,9 @@
# based on https://github.com/ofosos/sphinxrecipes/blob/master/sphinxrecipes/sphinxrecipes.py
from __future__ import annotations
from docutils import nodes
from docutils.nodes import Node, Element
from docutils.parsers.rst import directives
from docutils.parsers.rst.states import Inliner
from sphinx.application import Sphinx
@ -7,24 +11,59 @@ from sphinx.domains import Domain, Index
from sphinx.domains.std import StandardDomain
from sphinx.roles import XRefRole
from sphinx.directives import ObjectDescription
from sphinx.directives.code import container_wrapper
from sphinx.util.nodes import make_refnode
from sphinx import addnodes
class CommandNode(ObjectDescription):
class TocNode(ObjectDescription):
def _object_hierarchy_parts(self, sig_node: addnodes.desc_signature) -> tuple[str, ...]:
if 'fullname' not in sig_node:
return ()
modname = sig_node.get('module')
fullname = sig_node['fullname']
if modname:
return (modname, *fullname.split('::'))
else:
return tuple(fullname.split('::'))
def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str:
if not sig_node.get('_toc_parts'):
return ''
config = self.env.app.config
objtype = sig_node.parent.get('objtype')
if config.add_function_parentheses and objtype in {'function', 'method'}:
parens = '()'
else:
parens = ''
*parents, name = sig_node['_toc_parts']
if config.toc_object_entries_show_parents == 'domain':
return sig_node.get('fullname', name) + parens
if config.toc_object_entries_show_parents == 'hide':
return name + parens
if config.toc_object_entries_show_parents == 'all':
return '.'.join(parents + [name + parens])
return ''
class CommandNode(TocNode):
"""A custom node that describes a command."""
name = 'cmd'
required_arguments = 1
option_spec = {
'title': directives.unchanged_required,
'title': directives.unchanged,
'tags': directives.unchanged
}
def handle_signature(self, sig, signode: addnodes.desc_signature):
fullname = sig
signode['fullname'] = fullname
signode += addnodes.desc_addname(text="yosys> help ")
signode += addnodes.desc_name(text=sig)
return sig
return fullname
def add_target_and_index(self, name_cls, sig, signode):
signode['ids'].append(type(self).name + '-' + sig)
@ -32,7 +71,7 @@ class CommandNode(ObjectDescription):
name = "{}.{}.{}".format(self.name, type(self).__name__, sig)
tagmap = self.env.domaindata[type(self).name]['obj2tag']
tagmap[name] = list(self.options.get('tags', '').split(' '))
title = self.options.get('title')
title = self.options.get('title', sig)
titlemap = self.env.domaindata[type(self).name]['obj2title']
titlemap[name] = title
objs = self.env.domaindata[type(self).name]['objects']
@ -48,6 +87,111 @@ class CellNode(CommandNode):
name = 'cell'
class CellSourceNode(TocNode):
"""A custom code block for including cell source."""
name = 'cellsource'
option_spec = {
"source": directives.unchanged_required,
"language": directives.unchanged_required,
'lineno-start': int,
}
def handle_signature(
self,
sig,
signode: addnodes.desc_signature
) -> str:
language = self.options.get('language')
fullname = sig + "::" + language
signode['fullname'] = fullname
signode += addnodes.desc_name(text="Simulation model")
signode += addnodes.desc_sig_space()
signode += addnodes.desc_addname(text=f'({language})')
return fullname
def add_target_and_index(
self,
name: str,
sig: str,
signode: addnodes.desc_signature
) -> None:
idx = f'{".".join(self.name.split(":"))}.{sig}'
signode['ids'].append(idx)
def run(self) -> list[Node]:
"""Override run to parse content as a code block"""
if ':' in self.name:
self.domain, self.objtype = self.name.split(':', 1)
else:
self.domain, self.objtype = '', self.name
self.indexnode = addnodes.index(entries=[])
node = addnodes.desc()
node.document = self.state.document
source, line = self.get_source_info()
if line is not None:
line -= 1
self.state.document.note_source(source, line)
node['domain'] = self.domain
# 'desctype' is a backwards compatible attribute
node['objtype'] = node['desctype'] = self.objtype
node['noindex'] = noindex = ('noindex' in self.options)
node['noindexentry'] = ('noindexentry' in self.options)
node['nocontentsentry'] = ('nocontentsentry' in self.options)
if self.domain:
node['classes'].append(self.domain)
node['classes'].append(node['objtype'])
self.names = []
signatures = self.get_signatures()
for sig in signatures:
# add a signature node for each signature in the current unit
# and add a reference target for it
signode = addnodes.desc_signature(sig, '')
self.set_source_info(signode)
node.append(signode)
try:
# name can also be a tuple, e.g. (classname, objname);
# this is strictly domain-specific (i.e. no assumptions may
# be made in this base class)
name = self.handle_signature(sig, signode)
except ValueError:
# signature parsing failed
signode.clear()
signode += addnodes.desc_name(sig, sig)
continue # we don't want an index entry here
finally:
# Private attributes for ToC generation. Will be modified or removed
# without notice.
if self.env.app.config.toc_object_entries:
signode['_toc_parts'] = self._object_hierarchy_parts(signode)
signode['_toc_name'] = self._toc_entry_name(signode)
else:
signode['_toc_parts'] = ()
signode['_toc_name'] = ''
if name not in self.names:
self.names.append(name)
if not noindex:
# only add target and index entry if this is the first
# description of the object with this name in this desc block
self.add_target_and_index(name, sig, signode)
# handle code
code = '\n'.join(self.content)
literal: Element = nodes.literal_block(code, code)
if 'lineno-start' in self.options:
literal['linenos'] = True
literal['highlight_args'] = {
'linenostart': self.options['lineno-start']
}
literal['classes'] += self.options.get('class', [])
literal['language'] = self.options.get('language')
literal = container_wrapper(self, literal, self.options.get('source'))
return [self.indexnode, node, literal]
class TagIndex(Index):
"""A custom directive that creates a tag matrix."""
@ -223,6 +367,7 @@ class CellDomain(CommandDomain):
directives = {
'def': CellNode,
'source': CellSourceNode,
}
indices = {