[fpvgen] FPV boilerplate generator script

This adds a boilerplate generator script for FPV testbenches. The
generator can be used for both comportable or noncomportable IPs.

Signed-off-by: Michael Schaffner <msf@opentitan.org>
diff --git a/util/_index.md b/util/_index.md
index a42b1f5..ebace13 100644
--- a/util/_index.md
+++ b/util/_index.md
@@ -15,4 +15,5 @@
 * `testplanner.py` utility for generating verification testplans from Hjson descriptors [README]({{< relref "util/testplanner/README.md" >}})
 * `tlgen.py` utility for TileLink bus matrix [README]({{< relref "util/tlgen/README.md" >}})
 * `uvmdvgen.py` utility for generating testbenches for comportable IP [README]({{< relref "util/uvmdvgen/README.md" >}})
+* `fpvgen.py` utility for generating FPV testbenches for comportable IP [README]({{< relref "util/fpvgen/README.md" >}})
 * `wavegen.py` utility for creating technical waveforms in documentation [README]({{< relref "util/wavegen/README.md" >}})
diff --git a/util/fpvgen.py b/util/fpvgen.py
new file mode 100755
index 0000000..91eb061
--- /dev/null
+++ b/util/fpvgen.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""Generates boilerplate code for the FPV testbench setup.
+"""
+import argparse
+import sys
+from io import StringIO
+from pathlib import Path
+
+from mako.template import Template
+
+import fpvgen.sv_parse as sv_parse
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        prog="fpvgen",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        description="""\
+        Boilerplate code generator for FPV testbenches. Can be used for
+        comportable or non-comportable IPs.
+
+        The generator creates the FuseSoC core file and two additional
+        subfolders 'tb' and 'vip' in the output directory. It will place stubs
+        for the testbench and bind files into the 'tb' subfolder, and a stub for
+        the FPV assertions into the 'vip' (verification IP) subfolder.
+
+        The generator needs the path to the top-level module of the IP to be
+        tested. E.g., suppose we would like to generate an FPV testbench for a
+        FIFO primitive located at 'hw/ip/prim/rtl/prim_fifo_sync.sv' we can
+        invoke the generator as follows:
+
+        util/fpvgen.py hw/ip/prim/rtl/prim_fifo_sync.sv
+
+        By default, the output directory is assumed to be '../fpv' with respect
+        to the toplevel module, but this can be overriden using the -eo switch.
+
+        Further if the IP is comportable, this can be indicated using the -c
+        switch, which causes the generator to add a bind statement for the CSR
+        FPV assertions in the testbench.""",
+        add_help=True)
+    parser.add_argument(
+        'file',
+        type=str,
+        help="""Relative path to the SystemVerilog file of the module for which
+        the code shall be generated. This can be a primitive or a comportable IP
+        (for which the -c switch should be set)."""
+    )
+
+    parser.add_argument(
+        '-o',
+        '--outdir',
+        type=str,
+        default="",
+        help="""Path where to place the testbench code. This is defaults to
+        '../fpv' w.r.t. to the module path. For instance, if the module path is
+        'hw/ip/mymod/rtl/mymod.sv', the FPV testbench would be generated under
+        hw/ip/mymod/fpv. """
+    )
+    parser.add_argument(
+        '-c',
+        '--is_cip',
+        action="store_true",
+        default=False,
+        help="""Indicates whether this is a comportable IP. If yes, FPV
+        assertions for the TL-UL interface and CSRs are automatically bound in
+        the testbench. Note however that these CSR assertions need to be
+        generated separately using the regtool automation."""
+        )
+
+    args = parser.parse_args()
+
+    mod_path = Path(args.file)
+    if not mod_path.is_file() or mod_path.suffix != ".sv":
+        print("Error: %s is not a module or does not exist" % str(mod_path))
+        return 1
+
+    if not args.outdir:
+        # the default output path is ../fpv with
+        # respect to the module location
+        parentpath = mod_path.absolute().parent.parent
+        outpath = parentpath.joinpath("fpv")
+    else:
+        outpath = args.outdir
+
+    print("Output path is: %s" % outpath)
+
+    dut = sv_parse.parse_file(mod_path)
+    dut.is_cip = args.is_cip
+
+    # always include the prims
+    dut.deps += ["lowrisc:prim:all"]
+
+    if args.is_cip:
+        # for TL-UL assertions
+        dut.deps += ["lowrisc:ip:tlul"]
+        # in this case the parent directory is
+        # likely the correct basename of the IP
+        dut.deps += ["lowrisc:ip:" + parentpath.stem]
+
+    # define template files to iterate over
+    template_files = [(Path(__file__).parent.joinpath("fpvgen/fpv.sv.tpl"),                  \
+                       outpath.joinpath("tb").joinpath(mod_path.stem + "_fpv.sv")),          \
+                      (Path(__file__).parent.joinpath("fpvgen/bind_fpv.sv.tpl"),             \
+                        outpath.joinpath("tb").joinpath(mod_path.stem + "_bind_fpv.sv")),    \
+                      (Path(__file__).parent.joinpath("fpvgen/assert_fpv.sv.tpl"),           \
+                        outpath.joinpath("vip").joinpath(mod_path.stem + "_assert_fpv.sv")), \
+                      (Path(__file__).parent.joinpath("fpvgen/fusesoc.core.tpl"),            \
+                        outpath.joinpath(mod_path.stem + "_fpv.core"))]
+
+    for (tpl_file, out_file) in template_files:
+        print("Generating %s" % str(out_file))
+        out_file.parent.mkdir(parents=True, exist_ok=True)
+        tpl = Template(tpl_file.read_text())
+        out_file.write_text(tpl.render(dut=dut))
+
+    return 0
+
+
+if __name__ == "__main__":
+    main()
diff --git a/util/fpvgen/README.md b/util/fpvgen/README.md
new file mode 100644
index 0000000..550f9fc
--- /dev/null
+++ b/util/fpvgen/README.md
@@ -0,0 +1,86 @@
+---
+title: "Fpvgen: Initial FPV testbench generation tool"
+---
+
+# Overview
+
+`fpvgen` is a Python tool that can be used to generate the initial boilerplate code for an FPV testbench.
+It takes as input a SystemVerilog module file representing the top-level of an IP to be tested with FPV, and generates the following folders and files in the output directory (which defaults to `../fpv` with respect to the module file provided):
+```console
+.
+├── fpv // default output folder
+│   ├── <ip-name>_fpv.core
+│   ├── tb
+│   │   ├── <ip-name>_bind_fpv.sv
+│   │   └── <ip-name>_fpv.sv
+│   └── vip
+│       └── <ip-name>_assert_fpv.sv
+└── rtl // folder containing the SV module file
+    ├── <ip-name>.sv
+    ...
+```
+The `<ip-name>_fpv.sv` is the FPV testbench that can be used to instantiate different parameterizations of the DUT to be tested.
+`<ip-name>_bind_fpv.sv` contains the bind statement which binds the verification IP `<ip-name>_assert_fpv.sv` to all DUT instances.
+If the IP is flagged as being comportable using the `-c` switch, the CSR FPV assertions are bound to the module as well.
+
+# Examples
+Generating a non-comportable IP can be done as follows (using the LFSR as an example):
+```console
+util/fpvgen.py hw/ip/prim/rtl/prim_lfsr.sv
+```
+
+If the IP is comportable, only the `-c` switch has to be added.
+E.g., using the `pinmux` comportable IP as an example:
+```console
+util/fpvgen.py -c hw/ip/pinmux/rtl/pinmux.sv
+```
+
+If needed, the default output directory can be overridden using the `-o` switch.
+
+# Help Output
+This is the help output from the tool (switch `-h`).
+```console
+usage: fpvgen [-h] [-o OUTDIR] [-c] file
+
+        Boilerplate code generator for FPV testbenches. Can be used for
+        comportable or non-comportable IPs.
+
+        The generator creates the FuseSoC core file and two additional
+        subfolders 'tb' and 'vip' in the output directory. It will place stubs
+        for the testbench and bind files into the 'tb' subfolder, and a stub for
+        the FPV assertions into the 'vip' (verification IP) subfolder.
+
+        The generator needs the path to the top-level module of the IP to be
+        tested. E.g., suppose we would like to generate an FPV testbench for a
+        FIFO primitive located at 'hw/ip/prim/rtl/prim_fifo_sync.sv' we can
+        invoke the generator as follows:
+
+        util/fpvgen.py hw/ip/prim/rtl/prim_fifo_sync.sv
+
+        By default, the output directory is assumed to be '../fpv' with respect
+        to the toplevel module, but this can be overriden using the -eo switch.
+
+        Further if the IP is comportable, this can be indicated using the -c
+        switch, which causes the generator to add a bind statement for the CSR
+        FPV assertions in the testbench.
+
+positional arguments:
+  file                  Relative path to the SystemVerilog file of the module
+                        for which the code shall be generated. This can be a
+                        primitive or a comportable IP (for which the -c switch
+                        should be set).
+
+optional arguments:
+  -h, --help            show this help message and exit
+  -o OUTDIR, --outdir OUTDIR
+                        Path where to place the testbench code. This is
+                        defaults to '../fpv' w.r.t. to the module path. For
+                        instance, if the module path is
+                        'hw/ip/mymod/rtl/mymod.sv', the FPV testbench would be
+                        generated under hw/ip/mymod/fpv.
+  -c, --is_cip          Indicates whether this is a comportable IP. If yes,
+                        FPV assertions for the TL-UL interface and CSRs are
+                        automatically bound in the testbench. Note however
+                        that these CSR assertions need to be generated
+                        separately using the regtool automation.
+```
diff --git a/util/fpvgen/__init__.py b/util/fpvgen/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/util/fpvgen/__init__.py
diff --git a/util/fpvgen/assert_fpv.sv.tpl b/util/fpvgen/assert_fpv.sv.tpl
new file mode 100644
index 0000000..108e8f5
--- /dev/null
+++ b/util/fpvgen/assert_fpv.sv.tpl
@@ -0,0 +1,52 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Assertions for ${dut.name}.
+// Intended to be used with a formal tool.
+
+% if len(dut.pkgs) > 0:
+module ${dut.name}_fpv
+% for pkg in dut.pkgs:
+  import ${pkg};
+% endfor
+% if dut.params:
+#(
+% else:
+(
+% endif
+% else:
+% if dut.params:
+module ${dut.name}_assert_fpv #(
+% else:
+module ${dut.name}_assert_fpv (
+% endif
+% endif
+% if dut.params:
+% for k, param in enumerate(dut.params):
+<% comma = "" if (k == len(dut.params)-1) else "," %>  ${param.style} ${param.datatype} ${param.name} =${param.value}${comma}
+% endfor
+) (
+% endif
+% for k, port in enumerate(dut.ports):
+<% comma = "" if (k == len(dut.ports)-1) else "," %>  input ${port.datatype} ${port.name}${comma}
+% endfor
+);
+
+  ///////////////////////////////
+  // Declarations & Parameters //
+  ///////////////////////////////
+
+  /////////////////
+  // Assumptions //
+  /////////////////
+
+  // `ASSUME(MyAssumption_M, ..., clk_i, !rst_ni)
+
+  ////////////////
+  // Assertions //
+  ////////////////
+
+  // `ASSUME(MyAssertions_A, ..., clk_i, !rst_ni)
+
+endmodule : ${dut.name}_assert_fpv
diff --git a/util/fpvgen/bind_fpv.sv.tpl b/util/fpvgen/bind_fpv.sv.tpl
new file mode 100644
index 0000000..e2a5af5
--- /dev/null
+++ b/util/fpvgen/bind_fpv.sv.tpl
@@ -0,0 +1,27 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+
+module ${dut.name}_bind_fpv;
+
+  bind ${dut.name} ${dut.name}_assert_fpv ${dut.name}_assert_fpv (
+    .*
+  );
+% if dut.is_cip:
+
+  bind ${dut.name} tlul_assert #(
+    .EndpointType("Device")
+  ) tlul_assert_device (
+    .clk_i,
+    .rst_ni,
+    .h2d  (tl_i),
+    .d2h  (tl_o)
+  );
+
+  bind ${dut.name} ${dut.name}_csr_assert_fpv (
+  	.*
+  );
+% endif
+
+endmodule : ${dut.name}_bind_fpv
diff --git a/util/fpvgen/fpv.sv.tpl b/util/fpvgen/fpv.sv.tpl
new file mode 100644
index 0000000..c3d6d27
--- /dev/null
+++ b/util/fpvgen/fpv.sv.tpl
@@ -0,0 +1,51 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Testbench module for ${dut.name}.
+// Intended to be used with a formal tool.
+
+% if len(dut.pkgs) > 0:
+module ${dut.name}_fpv
+% for pkg in dut.pkgs:
+  import ${pkg};
+% endfor
+% if dut.params:
+#(
+% else:
+(
+% endif
+% else:
+% if dut.params:
+module ${dut.name}_fpv #(
+% else:
+module ${dut.name}_fpv (
+% endif
+% endif
+% if dut.params:
+% for k, param in enumerate(dut.params):
+<% comma = "" if (k == len(dut.params)-1) else "," %>  ${param.style} ${param.datatype} ${param.name} =${param.value}${comma}
+% endfor
+) (
+% endif
+% for k, port in enumerate(dut.ports):
+<% comma = "" if (k == len(dut.ports)-1) else "," %>  ${port.direction} ${port.datatype} ${port.name}${comma}
+% endfor
+);
+
+% if dut.params:
+  ${dut.name} #(
+% for k, param in enumerate(dut.params):
+  <% comma = "" if (k == len(dut.params)-1) else "," %>  .${param.name}(${param.name})${comma}
+% endfor
+  ) i_${dut.name} (
+% else:
+  ${dut.name} i_${dut.name} (
+%endif
+  % for k, port in enumerate(dut.ports):
+<% comma = "" if (k == len(dut.ports)-1) else "," %>    .${port.name}${comma}
+  % endfor
+  );
+
+
+endmodule : ${dut.name}_fpv
diff --git a/util/fpvgen/fusesoc.core.tpl b/util/fpvgen/fusesoc.core.tpl
new file mode 100644
index 0000000..8db8a75
--- /dev/null
+++ b/util/fpvgen/fusesoc.core.tpl
@@ -0,0 +1,29 @@
+CAPI=2:
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+name: "lowrisc:fpv:${dut.name}_fpv:0.1"
+description: "${dut.name} FPV target"
+filesets:
+  files_fpv:
+    depend:
+% for dep in dut.deps:
+      - ${dep}
+% endfor
+    files:
+      - vip/${dut.name}_assert_fpv.sv
+      - tb/${dut.name}_bind_fpv.sv
+      - tb/${dut.name}_fpv.sv
+% if dut.is_cip:
+      - vip/${dut.name}_csr_assert_fpv.sv
+% endif
+    file_type: systemVerilogSource
+
+targets:
+  default:
+    # note, this setting is just used
+    # to generate a file list for jg
+    default_tool: icarus
+    filesets:
+      - files_fpv
+    toplevel: ${dut.name}_fpv
diff --git a/util/fpvgen/sv_parse.py b/util/fpvgen/sv_parse.py
new file mode 100644
index 0000000..325e798
--- /dev/null
+++ b/util/fpvgen/sv_parse.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""Helper functions for parsing a systemverilog module header.
+"""
+
+from io import StringIO
+from pathlib import Path
+
+
+class Param():
+    name = ""
+    datatype = ""
+    style = ""
+    value = ""
+
+    def __init__(self, name_="", datatype_="", style_="", value_=""):
+        self.name = name_
+        self.datatype = datatype_
+        self.style = style_
+        self.value = value_
+
+
+class Port():
+    name = ""
+    datatype = ""
+    direction = ""
+
+    def __init__(self, name_="", datatype_="", direction_=""):
+        self.name = name_
+        self.datatype = datatype_
+        self.direction = direction_
+
+
+class Dut():
+    name = ""
+    pkgs = []
+    params = []
+    ports = []
+    deps = []
+    is_cip = False
+
+    def __init__(self, name_="", pkgs_=[], ports_=[], \
+        params_=[], deps_=[], is_cip_=False):
+        self.name = name_
+        self.pkgs = pkgs_
+        self.ports = ports_
+        self.params = params_
+        self.deps = deps_
+        self.is_cip = is_cip_
+
+
+# strip // comments
+def strip_comments(buf):
+    outbuf = ""
+    for line in buf.split('\n'):
+        for k in range(len(line) - 1):
+            if line[k:k + 2] == "//":
+                break
+            else:
+                outbuf += line[k]
+        else:
+            if line:
+                outbuf += line[-1]
+        outbuf += " "
+
+    return outbuf
+
+
+PARENTH_STYLES = {'(': ')', '[': ']', '{': '}'}
+
+
+# parse parenthesis and optionally handle the body using the handler function
+# if no handler is specified, it just calls itself recursively
+def parse_parenthesis(hdl_raw, dut, style='(', handler=None):
+    if style not in PARENTH_STYLES:
+        print("Unknown parenthesis style %s, aborting." % style)
+    else:
+        par_opened = False
+        while hdl_raw:
+            c = hdl_raw.pop(0)
+            if c == style:
+                par_opened = True
+                if handler:
+                    handler(hdl_raw, dut)
+                else:
+                    parse_parenthesis(hdl_raw, dut, style)
+            if c == PARENTH_STYLES[style]:
+                if not par_opened:
+                    hdl_raw.insert(0, c)
+                break
+    return
+
+
+# parse individual port declarations
+# may not be fully correct with unpacked dimensions,
+# but works for the common case
+def parse_port(buf, dut):
+    words = buf.split()
+    if words:
+        if words[0] not in ["input", "inout", "output"]:
+            print("Warning, expected input, inout or output keyword")
+        else:
+            if len(words) > 2:
+                dut.ports += [Port(words[-1], "".join(words[1:-1]), words[0])]
+            elif len(words) == 2:
+                dut.ports += [Port(words[-1], "", words[0])]
+            else:
+                print("Warning, port declaration incomplete")
+    else:
+        print("Warning, port declaration empty")
+    return
+
+
+# parse individual parameter declaration
+def parse_param(buf, dut):
+    words = buf.split('=')
+    value = '='.join(words[1:])
+    words = words[0].split()
+
+    if words:
+        if words[0] not in ["parameter", "localparam"]:
+            print("Warning, expected parameter or localparam keyword")
+        else:
+            if len(words) > 2:
+                dut.params += [
+                    Param(words[-1], " ".join(words[1:-1]), words[0], value)
+                ]
+            elif len(words) == 2:
+                dut.params += [Param(words[-1], "", words[0], value)]
+            else:
+                print("Warning, parameter declaration incomplete")
+    else:
+        print("Warning, port declaration empty")
+    return
+
+
+# extract individual declarations
+def parse_declaration(hdl_raw, dut, handler):
+    buf = ""
+    par_opened = 0
+    while hdl_raw:
+        c = hdl_raw.pop(0)
+        # end of this port
+        if c == ',':
+            handler(buf, dut)
+            buf = ""
+        elif c == '(':
+            par_opened = par_opened + 1
+            buf += c
+        elif c == ')':
+            if par_opened:
+                # part of the declaration
+                par_opened = par_opened - 1
+                buf += c
+            else:
+                # end of the declaration list
+                handler(buf, dut)
+                hdl_raw.insert(0, ')')
+                break
+        else:
+            buf += c
+    return
+
+
+def parse_ports(hdl_raw, dut):
+    parse_declaration(hdl_raw, dut, parse_port)
+
+
+def parse_params(hdl_raw, dut):
+    parse_declaration(hdl_raw, dut, parse_param)
+
+
+def parse_module(words, dut):
+    # check for imports first
+    while words:
+        w = words.pop(0)
+        if w == "import":
+            if words:
+                # get package names to import
+                pkg = words.pop(0).split(";")
+                dut.pkgs += [pkg[0]]
+            else:
+                print("Unexpected end")
+        # stop package scan and move on to body
+        elif '#' in w or '(' in w:
+            words.insert(0, w)
+            break
+
+    hdl_raw = list(' '.join(words))
+    while hdl_raw:
+        c = hdl_raw.pop(0)
+        if c == '#':
+            parse_parenthesis(hdl_raw, dut, '(', parse_params)
+        elif c == '(':
+            hdl_raw.insert(0, '(')
+            parse_parenthesis(hdl_raw, dut, '(', parse_ports)
+        elif c == ';':
+            break
+    return
+
+
+# simplistic module declaration parser.
+# this works in most cases, but there are exceptions.
+def parse_file(file):
+    dut = Dut()
+    hdl_raw = ""
+    with open(file, 'r') as fp:
+        hdl_raw = strip_comments(fp.read())
+    # extract first module declaration in file and extract port list
+    # also look for imported packages (either in the module declaration
+    # or before it)
+    words = hdl_raw.split()
+    while words:
+        w = words.pop(0)
+        if w == "import":
+            if words:
+                # get package names to import
+                pkg = words.pop(0).split(";")
+                dut.pkgs += [pkg[0]]
+            else:
+                print("Unexpected end")
+        elif w == "module":
+            if words:
+                # get module name
+                dut.name = words[0]
+                # parse the module params and port list and exit
+                parse_module(words, dut)
+            break
+    return dut