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__) + "/../"] sys.path += [os.path.dirname(__file__) + "/../"]
extensions.append('util.cmdref') extensions.append('util.cmdref')
def setup(sphinx): # use autodocs
from util.RtlilLexer import RtlilLexer extensions.append('sphinx.ext.autodoc')
sphinx.add_lexer("RTLIL", RtlilLexer) extensions.append('util.cellref')
from util.YoscryptLexer import YoscryptLexer from sphinx.application import Sphinx
sphinx.add_lexer("yoscrypt", YoscryptLexer) 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 # 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 import directives
from docutils.parsers.rst.states import Inliner from docutils.parsers.rst.states import Inliner
from sphinx.application import Sphinx from sphinx.application import Sphinx
@ -7,24 +11,59 @@ from sphinx.domains import Domain, Index
from sphinx.domains.std import StandardDomain from sphinx.domains.std import StandardDomain
from sphinx.roles import XRefRole from sphinx.roles import XRefRole
from sphinx.directives import ObjectDescription from sphinx.directives import ObjectDescription
from sphinx.directives.code import container_wrapper
from sphinx.util.nodes import make_refnode from sphinx.util.nodes import make_refnode
from sphinx import addnodes 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.""" """A custom node that describes a command."""
name = 'cmd' name = 'cmd'
required_arguments = 1 required_arguments = 1
option_spec = { option_spec = {
'title': directives.unchanged_required, 'title': directives.unchanged,
'tags': directives.unchanged 'tags': directives.unchanged
} }
def handle_signature(self, sig, signode: addnodes.desc_signature): def handle_signature(self, sig, signode: addnodes.desc_signature):
fullname = sig
signode['fullname'] = fullname
signode += addnodes.desc_addname(text="yosys> help ") signode += addnodes.desc_addname(text="yosys> help ")
signode += addnodes.desc_name(text=sig) signode += addnodes.desc_name(text=sig)
return sig return fullname
def add_target_and_index(self, name_cls, sig, signode): def add_target_and_index(self, name_cls, sig, signode):
signode['ids'].append(type(self).name + '-' + sig) signode['ids'].append(type(self).name + '-' + sig)
@ -32,7 +71,7 @@ class CommandNode(ObjectDescription):
name = "{}.{}.{}".format(self.name, type(self).__name__, sig) name = "{}.{}.{}".format(self.name, type(self).__name__, sig)
tagmap = self.env.domaindata[type(self).name]['obj2tag'] tagmap = self.env.domaindata[type(self).name]['obj2tag']
tagmap[name] = list(self.options.get('tags', '').split(' ')) 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 = self.env.domaindata[type(self).name]['obj2title']
titlemap[name] = title titlemap[name] = title
objs = self.env.domaindata[type(self).name]['objects'] objs = self.env.domaindata[type(self).name]['objects']
@ -48,6 +87,111 @@ class CellNode(CommandNode):
name = 'cell' 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): class TagIndex(Index):
"""A custom directive that creates a tag matrix.""" """A custom directive that creates a tag matrix."""
@ -223,6 +367,7 @@ class CellDomain(CommandDomain):
directives = { directives = {
'def': CellNode, 'def': CellNode,
'source': CellSourceNode,
} }
indices = { indices = {