Source code for svdoc.parser

"""AST walker: pyslang SyntaxTree -> Doc IR. See PLAN.md Phase 1 (modules) and
Phase 2 (interfaces/modports).
"""

import re
from typing import Optional

import pyslang

from .ir import (
    EnumValue,
    Instance,
    InterfaceDoc,
    Modport,
    ModportPortGroup,
    ModuleDoc,
    PackageDoc,
    Param,
    ParamValue,
    Port,
    PortConnection,
    Signal,
    StructField,
    Subroutine,
    Typedef,
)

_DOC_KINDS = (
    pyslang.parsing.TriviaKind.BlockComment,
    pyslang.parsing.TriviaKind.LineComment,
)


def _source_manager(include_dirs: Optional[list] = None) -> pyslang.SourceManager:
    """Build a fresh SourceManager, optionally registered with directories to
    search for ```include`` targets that don't live next to the including
    file (e.g. a shared ``include/`` dir separate from per-module ``source/``
    dirs -- the layout PLAN.md's real-RTL testing found). A fresh instance is
    always used (never a shared/cached one) per the existing fromFile-caching
    gotcha noted elsewhere in this module."""
    sm = pyslang.SourceManager()
    for d in include_dirs or []:
        sm.addUserDirectories(d)
    return sm


def _clean(text: str) -> str:
    """Strip comment delimiters (/** */, ///<, //) and leading '*'/whitespace."""
    text = text.strip()
    text = re.sub(r"^/\*\*?|\*/$", "", text)
    text = re.sub(r"^///<?|^//", "", text)
    lines = [re.sub(r"^\s*\*\s?", "", ln) for ln in text.splitlines()]
    return "\n".join(ln.strip() for ln in lines).strip()


def _leading_doc(node) -> Optional[str]:
    """A node's own doc comment is the LAST block/line comment in its leading
    trivia -- an earlier one may belong to a preceding sibling's trailing
    ///< comment (see _trailing_doc), so take the one closest to the node."""
    doc = None
    for t in node.getFirstToken().trivia:
        if t.kind in _DOC_KINDS:
            doc = t.getRawText()
    return _clean(doc) if doc else None


def _type_str(node) -> str:
    """str(node) includes leading trivia (whitespace, and any comments that
    belong to a preceding sibling's trailing ///< doc) -- strip comment lines
    so only the actual type text remains."""
    text = re.sub(r"//.*", "", str(node))
    return " ".join(text.split())


def _trailing_doc(next_node) -> Optional[str]:
    """Doxygen ///< trailing comments attach as leading trivia on the *next*
    item in the list (see Phase 0 spike findings in PLAN.md)."""
    tok = next_node.getFirstToken() if hasattr(next_node, "getFirstToken") else next_node
    for t in tok.trivia:
        if t.kind == pyslang.parsing.TriviaKind.LineComment:
            return _clean(t.getRawText())
    return None


def _parse_params(header) -> list:
    if not header.parameters:
        return []
    params = []
    decls = [d for d in header.parameters.declarations if d.kind == pyslang.syntax.SyntaxKind.ParameterDeclaration]
    for i, d in enumerate(decls):
        next_node = decls[i + 1] if i + 1 < len(decls) else header.parameters.closeParen
        declarator = d.declarators[0]
        params.append(
            Param(
                name=declarator.name.valueText,
                type=_type_str(d.type),
                default=_type_str(declarator.initializer.expr) if declarator.initializer else None,
                doc=_trailing_doc(next_node),
            )
        )
    return params


def _port_direction_and_type(port_header) -> tuple:
    """Port headers come in different syntax shapes depending on whether the
    port is a plain data type (VariablePortHeader, has .direction/.dataType)
    or an interface reference (InterfacePortHeader, e.g. `some_if.modport
    name` -- no direction, "type" is the interface[.modport] name instead)."""
    if port_header.kind == pyslang.syntax.SyntaxKind.InterfacePortHeader:
        iface_name = _type_str(port_header.nameOrKeyword)
        modport = _type_str(port_header.modport) if port_header.modport else ""
        return "interface", f"{iface_name}{modport}"
    return port_header.direction.valueText, _type_str(port_header.dataType)


def _parse_ports(header) -> list:
    if not header.ports:
        return []
    ports = []
    port_decls = [p for p in header.ports.ports if hasattr(p, "declarator")]
    for i, p in enumerate(port_decls):
        next_node = port_decls[i + 1] if i + 1 < len(port_decls) else header.ports.closeParen
        direction, type_str = _port_direction_and_type(p.header)
        ports.append(
            Port(
                name=p.declarator.name.valueText,
                direction=direction,
                type=type_str,
                doc=_trailing_doc(next_node),
            )
        )
    return ports


def _find_declaration(tree, kind):
    return next(m for m in tree.root.members if m.kind == kind)


[docs] def parse_module(path: str, include_dirs: Optional[list] = None) -> ModuleDoc: """Parse a ``.sv`` file containing a single module declaration. :param path: Path to the ``.sv`` file. Only the first module found in the file is parsed. :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside ``path`` (e.g. a shared ``include/`` directory). ```include``s resolve automatically without this when the included file is in the same directory as ``path``. :returns: The module's :class:`~svdoc.ir.ModuleDoc`. :raises ValueError: If the file fails to parse cleanly. """ # fromFile's default SourceManager caches file contents by path across # calls in the same process, so a fresh SourceManager avoids seeing a # stale read of a file that was modified earlier in this process # (e.g. by --fix, or in tests that fix then re-parse the same path). tree = pyslang.syntax.SyntaxTree.fromFile(path, _source_manager(include_dirs)) if tree.diagnostics: raise ValueError(f"parse errors in {path}: {list(tree.diagnostics)}") mod = _find_declaration(tree, pyslang.syntax.SyntaxKind.ModuleDeclaration) header = mod.header return ModuleDoc( name=header.name.valueText, doc=_leading_doc(mod), params=_parse_params(header), ports=_parse_ports(header), )
[docs] def parse_file(path: str, include_dirs: Optional[list] = None): """Parse a ``.sv`` file containing a single module, interface, or package. Dispatches to :func:`parse_module`, :func:`parse_interface`, or :func:`parse_package` based on the syntax kind of the first top-level declaration found. :param path: Path to the ``.sv`` file. :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside ``path``. :returns: A :class:`~svdoc.ir.ModuleDoc`, :class:`~svdoc.ir.InterfaceDoc`, or :class:`~svdoc.ir.PackageDoc`, matching whichever construct was found. :raises ValueError: If the file fails to parse cleanly. """ tree = pyslang.syntax.SyntaxTree.fromFile(path, _source_manager(include_dirs)) if tree.diagnostics: raise ValueError(f"parse errors in {path}: {list(tree.diagnostics)}") kind = next(m.kind for m in tree.root.members if hasattr(m, "header") or hasattr(m, "members")) if kind == pyslang.syntax.SyntaxKind.InterfaceDeclaration: return parse_interface(path, include_dirs) if kind == pyslang.syntax.SyntaxKind.PackageDeclaration: return parse_package(path, include_dirs) return parse_module(path, include_dirs)
[docs] def resolve_types(doc, paths: list, include_dirs: Optional[list] = None) -> None: """Resolve cross-file port types by elaborating a full ``Compilation``. Given a :class:`~svdoc.ir.ModuleDoc` (or :class:`~svdoc.ir.InterfaceDoc`) already parsed from a single file via :func:`parse_module` / :func:`parse_file`, and the full list of files it should be elaborated alongside (e.g. packages it imports types from), patches each :class:`~svdoc.ir.Port`'s ``type_ref`` with the fully-qualified ``"package::type"`` name for any port whose type resolves to a type defined in another file. Mutates ``doc`` in place. Only meaningful for modules today: a bare interface never becomes an elaborated top-level instance, so this is a no-op when ``doc.name`` can't be found among the compilation's top instances. :param doc: An already-parsed module (or interface) doc to patch in place. :param paths: All ``.sv`` files needed to elaborate ``doc``, including the file it was originally parsed from. :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside any file in ``paths`` (e.g. a shared ``include/`` directory separate from per-module source dirs). :raises ValueError: If any of the given files fails to parse cleanly. """ # Without an explicit topModules list, slang only elaborates modules that # nothing else instantiates -- e.g. in a full multi-module design, most # modules are instantiated by something else and would silently vanish # from topInstances, making resolve_types() a no-op for them. Forcing # doc.name as an explicit top module means it's elaborated on its own # regardless of whether other files in `paths` also instantiate it. opts = pyslang.ast.CompilationOptions() opts.topModules = {doc.name} bag = pyslang.Bag() bag.compilationOptions = opts sm = _source_manager(include_dirs) comp = pyslang.ast.Compilation(bag) for p in paths: tree = pyslang.syntax.SyntaxTree.fromFile(p, sm) if tree.diagnostics: raise ValueError(f"parse errors in {p}: {list(tree.diagnostics)}") comp.addSyntaxTree(tree) inst = next((i for i in comp.getRoot().topInstances if i.name == doc.name), None) if inst is None: return # Two port symbol shapes: plain data-type ports (SymbolKind.Port, has # .type) resolve to a package-qualified type string like "pkg::type". # Interface-typed ports (SymbolKind.InterfacePort, e.g. `some_if.modport # name`) have no .type but do have .interfaceDef/.modport -- resolved the # same way into "ifname::modport" so the HTML renderer's existing # pkg::type cross-linking machinery (_type_cell) handles both uniformly. resolved = {} interface_ports = {} # port name -> (interface name, modport name) for p in inst.body.portList: if hasattr(p, "type"): resolved[p.name] = str(p.type) elif p.kind == pyslang.ast.SymbolKind.InterfacePort and p.interfaceDef: resolved[p.name] = f"{p.interfaceDef.name}::{p.modport}" interface_ports[p.name] = (p.interfaceDef.name, str(p.modport)) # For an inline HTML preview of the modport's ins/outs (not just a link # to it), parse whichever of `paths` is that interface and grab the # matching Modport object directly -- skip files that don't parse as an # interface (modules/packages among `paths`), and only do this for # interfaces actually referenced by a port, not every file. needed_interfaces = {iface for iface, _ in interface_ports.values()} interface_docs = {} for p in paths: if not needed_interfaces: break parsed = parse_file(p, include_dirs) if isinstance(parsed, InterfaceDoc) and parsed.name in needed_interfaces: interface_docs[parsed.name] = parsed needed_interfaces.discard(parsed.name) for port in doc.ports: type_str = resolved.get(port.name) if type_str and "::" in type_str: port.type_ref = type_str if port.name in interface_ports: iface_name, modport_name = interface_ports[port.name] iface_doc = interface_docs.get(iface_name) if iface_doc: port.modport_preview = next( (mp for mp in iface_doc.modports if mp.name == modport_name), None, )
def _port_connection_text(inst_sym, port) -> Optional[str]: conn = inst_sym.getPortConnection(port) if conn is None or conn.expression is None: return None expr = conn.expression # An output/inout port's connection is wrapped in an internal Assignment # expression (lvalue = port) rather than being the connected expression # itself -- unwrap to .left to get the actual connected signal text. if expr.kind == pyslang.ast.ExpressionKind.Assignment: expr = expr.left return str(expr.syntax) if expr.syntax else None def _build_port_connection(inst_sym, p) -> PortConnection: # Interface-typed ports bind to a (interface instance, modport) pair # rather than a data expression -- getPortConnection().expression is # always None for these, so read the binding straight off the port # symbol's own .connection instead. if p.kind == pyslang.ast.SymbolKind.InterfacePort: conn = p.connection if conn is not None: iface_inst, modport = conn return PortConnection( name=p.name, expr=None, interface_instance=iface_inst.name, modport=modport.name, ) return PortConnection(name=p.name, expr=None) return PortConnection(name=p.name, expr=_port_connection_text(inst_sym, p)) def _build_instance(inst_sym) -> Instance: params = [ ParamValue(name=p.name, value=str(p.value)) for p in inst_sym.body.parameters if p.kind == pyslang.ast.SymbolKind.Parameter ] connections = [_build_port_connection(inst_sym, p) for p in inst_sym.body.portList] instance = Instance( path=inst_sym.body.hierarchicalPath, name=inst_sym.name, module=inst_sym.body.definition.name, params=params, connections=connections, is_interface=inst_sym.body.definition.definitionKind == pyslang.ast.DefinitionKind.Interface, ) def visit_child(node): if node is inst_sym: return None if node.kind == pyslang.ast.SymbolKind.Instance: instance.children.append(_build_instance(node)) return pyslang.ast.VisitAction.Skip return None inst_sym.visit(visit_child) return instance def build_hierarchy(module_name: str, paths: list, include_dirs: Optional[list] = None) -> Instance: """Elaborate ``module_name`` as top and build its instance hierarchy. Walks the elaborated instance tree (via ``Symbol.visit``), including instances expanded from ``generate`` blocks, and records each instance's resolved (post-override) parameter values and port connection expressions. :param module_name: Name of the module to elaborate as the hierarchy root. :param paths: All ``.sv`` files needed to elaborate it (the module itself plus anything it instantiates, directly or transitively). :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside any file in ``paths``. :returns: The root :class:`~svdoc.ir.Instance`, with ``children`` nested to match the real instantiation tree. :raises ValueError: If any of the given files fails to parse cleanly. :raises LookupError: If ``module_name`` isn't found as a top instance after elaboration. """ opts = pyslang.ast.CompilationOptions() opts.topModules = {module_name} bag = pyslang.Bag() bag.compilationOptions = opts sm = _source_manager(include_dirs) comp = pyslang.ast.Compilation(bag) for p in paths: tree = pyslang.syntax.SyntaxTree.fromFile(p, sm) if tree.diagnostics: raise ValueError(f"parse errors in {p}: {list(tree.diagnostics)}") comp.addSyntaxTree(tree) top = next((i for i in comp.getRoot().topInstances if i.name == module_name), None) if top is None: raise LookupError(f"{module_name!r} not found as a top instance") return _build_instance(top)
[docs] def parse_interface(path: str, include_dirs: Optional[list] = None) -> InterfaceDoc: """Parse a ``.sv`` file containing a single interface declaration. :param path: Path to the ``.sv`` file. Only the first interface found in the file is parsed. :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside ``path``. :returns: The interface's :class:`~svdoc.ir.InterfaceDoc`, including its signals and modports. :raises ValueError: If the file fails to parse cleanly. """ tree = pyslang.syntax.SyntaxTree.fromFile(path, _source_manager(include_dirs)) if tree.diagnostics: raise ValueError(f"parse errors in {path}: {list(tree.diagnostics)}") iface = _find_declaration(tree, pyslang.syntax.SyntaxKind.InterfaceDeclaration) header = iface.header doc = InterfaceDoc( name=header.name.valueText, doc=_leading_doc(iface), params=_parse_params(header), ports=_parse_ports(header), ) members = list(iface.members) for i, m in enumerate(members): next_node = members[i + 1] if i + 1 < len(members) else iface.endmodule if m.kind == pyslang.syntax.SyntaxKind.DataDeclaration: declarator = m.declarators[0] doc.signals.append( Signal( name=declarator.name.valueText, type=_type_str(m.type), doc=_trailing_doc(next_node), ) ) elif m.kind == pyslang.syntax.SyntaxKind.ModportDeclaration: item = m.items[0] modport = Modport(name=item.name.valueText, doc=_leading_doc(m)) raw_groups = [g for g in item.ports.ports if hasattr(g, "direction")] for j, g in enumerate(raw_groups): group_next = raw_groups[j + 1] if j + 1 < len(raw_groups) else item.ports.closeParen # g.ports is comma-interleaved (ModportNamedPort nodes plus # comma tokens) when a direction covers multiple signals # (e.g. "input a, b, c") -- filter to named-port nodes only. signal_names = [p.name.valueText for p in g.ports if hasattr(p, "name")] modport.port_groups.append( ModportPortGroup( direction=g.direction.valueText, signals=signal_names, doc=_trailing_doc(group_next), ) ) doc.modports.append(modport) return doc
def _parse_enum_values(enum_type) -> list: # members is comma-interleaved (like params/ports) -- filter to just the # declarators so "next item" lookups skip over comma tokens, same fix as # the param/port comma-interleaving bug found in Phase 1's --fix. decls = [n for n in enum_type.members if hasattr(n, "name")] values = [] for i, n in enumerate(decls): next_node = decls[i + 1] if i + 1 < len(decls) else enum_type.closeBrace values.append( EnumValue( name=n.name.valueText, value=_type_str(n.initializer.expr) if n.initializer else None, doc=_trailing_doc(next_node), ) ) return values def _parse_struct_fields(struct_type) -> list: raw = list(struct_type.members) fields = [] for i, f in enumerate(raw): next_node = raw[i + 1] if i + 1 < len(raw) else struct_type.closeBrace fields.append( StructField( name=f.declarators[0].name.valueText, type=_type_str(f.type), doc=_trailing_doc(next_node), ) ) return fields def _parse_subroutine_args(port_list) -> list: if port_list is None: return [] # comma-interleaved like params/ports/enum members -- filter to # declarators first so next-item lookups skip over comma tokens. decls = [p for p in port_list.ports if hasattr(p, "declarator")] args = [] for i, p in enumerate(decls): next_node = decls[i + 1] if i + 1 < len(decls) else port_list.closeParen args.append( Port( name=p.declarator.name.valueText, direction=p.direction.valueText if p.direction else "input", type=_type_str(p.dataType), doc=_trailing_doc(next_node), ) ) return args def _parse_subroutine(m, kind: str) -> Subroutine: proto = m.prototype return_type = _type_str(proto.returnType) if kind == "function" else None return Subroutine( name=str(proto.name).strip(), doc=_leading_doc(m), kind=kind, return_type=return_type, args=_parse_subroutine_args(proto.portList), )
[docs] def parse_package(path: str, include_dirs: Optional[list] = None) -> PackageDoc: """Parse a ``.sv`` file containing a single package declaration. :param path: Path to the ``.sv`` file. Only the first package found in the file is parsed. :param include_dirs: Optional directories to search for ```include`` targets that don't live alongside ``path``. :returns: The package's :class:`~svdoc.ir.PackageDoc`, including its typedefs (enums, structs, aliases) and subroutines (functions, tasks). :raises ValueError: If the file fails to parse cleanly. """ tree = pyslang.syntax.SyntaxTree.fromFile(path, _source_manager(include_dirs)) if tree.diagnostics: raise ValueError(f"parse errors in {path}: {list(tree.diagnostics)}") pkg = _find_declaration(tree, pyslang.syntax.SyntaxKind.PackageDeclaration) doc = PackageDoc(name=pkg.header.name.valueText, doc=_leading_doc(pkg)) members = list(pkg.members) for i, m in enumerate(members): next_node = members[i + 1] if i + 1 < len(members) else pkg.endmodule if m.kind == pyslang.syntax.SyntaxKind.FunctionDeclaration: doc.subroutines.append(_parse_subroutine(m, "function")) elif m.kind == pyslang.syntax.SyntaxKind.TaskDeclaration: doc.subroutines.append(_parse_subroutine(m, "task")) elif m.kind == pyslang.syntax.SyntaxKind.TypedefDeclaration: if m.type.kind == pyslang.syntax.SyntaxKind.EnumType: doc.typedefs.append( Typedef( name=m.name.valueText, doc=_leading_doc(m), kind="enum", base_type=_type_str(m.type.baseType) if m.type.baseType else None, values=_parse_enum_values(m.type), ) ) elif m.type.kind == pyslang.syntax.SyntaxKind.StructType: doc.typedefs.append( Typedef( name=m.name.valueText, doc=_leading_doc(m), kind="struct", fields=_parse_struct_fields(m.type), ) ) else: doc.typedefs.append( Typedef( name=m.name.valueText, doc=_trailing_doc(next_node) or _leading_doc(m), kind="alias", alias_type=_type_str(m.type), ) ) return doc