[ipgen] Initial commit of IP generation tool

Signed-off-by: Philipp Wagner <phw@lowrisc.org>
diff --git a/util/ipgen/renderer.py b/util/ipgen/renderer.py
new file mode 100644
index 0000000..92d7c0c
--- /dev/null
+++ b/util/ipgen/renderer.py
@@ -0,0 +1,286 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+import shutil
+import time
+from pathlib import Path
+from typing import Dict, Optional, Union
+
+import reggen.gen_rtl
+from mako import exceptions as mako_exceptions  # type: ignore
+from mako.lookup import TemplateLookup as MakoTemplateLookup  # type: ignore
+from reggen.ip_block import IpBlock
+
+from .lib import IpConfig, IpTemplate
+
+_HJSON_LICENSE_HEADER = ("""// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+""")
+
+
+class TemplateRenderError(Exception):
+    pass
+
+
+class IpTemplateRendererBase:
+    """ Render an IpTemplate into an IP block. """
+
+    ip_template: IpTemplate
+    ip_config: IpConfig
+
+    _lookup: Optional[MakoTemplateLookup] = None
+
+    def __init__(self, ip_template: IpTemplate, ip_config: IpConfig) -> None:
+        self.ip_template = ip_template
+        self.ip_config = ip_config
+        self._check_param_values()
+
+    def _check_param_values(self) -> None:
+        """ Check if all parameters in IpConfig are defined in the template. """
+
+        for name in self.ip_config.param_values:
+            if name not in self.ip_template.params:
+                raise KeyError("No parameter named {!r} exists.".format(name))
+
+    def get_template_parameter_values(self) -> Dict[str, Union[str, int]]:
+        """ Get a typed mapping of all template parameters and their values.
+        """
+        ret = {}
+
+        for name, template_param in self.ip_template.params.items():
+            if name in self.ip_config.param_values:
+                val = self.ip_config.param_values[name]
+            else:
+                val = template_param.default
+
+            assert template_param.param_type in ('string', 'int')
+            try:
+                if template_param.param_type == 'string':
+                    val_typed = str(val)  # type: Union[int, str]
+                elif template_param.param_type == 'int':
+                    if not isinstance(val, int):
+                        val_typed = int(val, 0)
+                    else:
+                        val_typed = val
+            except (ValueError, TypeError):
+                raise TemplateRenderError(
+                    "For parameter {} cannot convert value {!r} "
+                    "to {}.".format(name, val,
+                                    template_param.param_type)) from None
+
+            ret[name] = val_typed
+
+        return ret
+
+    def _get_mako_template_lookup(self) -> MakoTemplateLookup:
+        """ Get a Mako TemplateLookup object """
+
+        if self._lookup is None:
+            # Define the directory containing the IP template as "base"
+            # directory, allowing templates to include other templates within
+            # this directory using relative paths.
+            # Use strict_undefined to throw a NameError if undefined variables
+            # are used within a template.
+            self._lookup = MakoTemplateLookup(
+                directories=[str(self.ip_template.template_path)],
+                strict_undefined=True)
+        return self._lookup
+
+    def _tplfunc_instance_vlnv(self, template_vlnv_str: str):
+        template_vlnv = template_vlnv_str.split(':', 3)
+        if len(template_vlnv) < 3:
+            raise TemplateRenderError(
+                f"{template_vlnv_str} isn't a valid FuseSoC VLNV. "
+                "Required format: 'vendor:library:name:version'")
+        template_core_name = template_vlnv[2]
+
+        # Remove the template name from the start of the core name.
+        # For example, a core name `rv_plic_component` will result in an
+        # instance name 'my_instance_component' (instead of
+        # 'my_instance_rv_plic_component').
+        if template_core_name.startswith(self.ip_template.name):
+            template_core_name = template_core_name[len(self.ip_template.name):]
+
+        instance_core_name = self.ip_config.instance_name + template_core_name
+        instance_vlnv = 'lowrisc:opentitan:' + instance_core_name
+        return instance_vlnv
+
+    def _render_mako_template_to_str(self, template_filepath: Path) -> str:
+        """ Render a template and return the rendered text. """
+
+        lookup = self._get_mako_template_lookup()
+        template_filepath_rel = template_filepath.relative_to(
+            self.ip_template.template_path)
+        template = lookup.get_template(str(template_filepath_rel))
+
+        helper_funcs = {}
+        helper_funcs['instance_vlnv'] = self._tplfunc_instance_vlnv
+
+        # TODO: Introduce namespacing for the template parameters and other
+        # parameters, and/or pass the IpConfig instance to the template after
+        # we have converted more IP blocks to ipgen.
+        tpl_args = {
+            "instance_name": self.ip_config.instance_name,  # type: ignore
+            **helper_funcs,  # type: ignore
+            **self.get_template_parameter_values()  # type: ignore
+        }
+        try:
+            return template.render(**tpl_args)
+        except:
+            raise TemplateRenderError(
+                "Unable to render template: " +
+                mako_exceptions.text_error_template().render()) from None
+
+    def _render_mako_template_to_file(self, template_filepath: Path,
+                                      outdir_path: Path) -> None:
+        """ Render a file template into a file in outdir_path.
+
+        The name of the output file matches the file name of the template file,
+        without the '.tpl' suffix.
+        """
+
+        assert outdir_path.is_dir()
+
+        outfile_name = self._filename_without_tpl_suffix(template_filepath)
+
+        with open(outdir_path / outfile_name, 'w') as f:
+            f.write(self._render_mako_template_to_str(template_filepath))
+
+    def _filename_without_tpl_suffix(self, filepath: Path) -> str:
+        """ Get the name of the file without a '.tpl' suffix. """
+        assert filepath.suffix == '.tpl'
+        return filepath.stem
+
+
+class IpDescriptionOnlyRenderer(IpTemplateRendererBase):
+    """ Generate the IP description only.
+
+    The IP description is the content of what is typically stored
+    data/ip_name.hjson.
+    """
+    def render(self) -> str:
+        template_path = self.ip_template.template_path
+
+        # Look for a data/ip_name.hjson.tpl template file and render it.
+        hjson_tpl_path = template_path / 'data' / (self.ip_template.name +
+                                                   '.hjson.tpl')
+        if hjson_tpl_path.is_file():
+            return self._render_mako_template_to_str(hjson_tpl_path)
+
+        # Otherwise, if a data/ip_name.hjson file exists, use that.
+        hjson_path = template_path / 'data' / (self.ip_template.name +
+                                               '.hjson')
+        try:
+            with open(hjson_path, 'r') as f:
+                return f.read()
+        except FileNotFoundError:
+            raise FileNotFoundError(
+                "Neither a IP description template at {}, "
+                "nor an IP description at {} exist!".format(
+                    hjson_tpl_path, hjson_path))
+
+
+class IpBlockRenderer(IpTemplateRendererBase):
+    """ Produce a full IP block directory from a template.
+
+    - Copy everything but Mako templates (files ending with '.tpl') into the
+      destination directory.
+    - Process all Mako templates and write the results to the output directory.
+      A template at <PATH>.tpl will write results to <PATH> in the output
+      directory.
+    - Run reggen to generate the register interface.
+    """
+    def render(self, output_dir: Path, overwrite_output_dir: bool) -> None:
+        """ Render the IP template into output_dir. """
+
+        # Ensure that we operate on an absolute path for output_dir.
+        output_dir = output_dir.resolve()
+
+        if not overwrite_output_dir and output_dir.exists():
+            raise TemplateRenderError(
+                "Output directory '{}' exists and should not be overwritten.".
+                format(output_dir))
+
+        # Prepare the IP directory in a staging area to later atomically move it
+        # to the final destination.
+        output_dir_staging = output_dir.parent / f".~{output_dir.stem}.staging"
+        if output_dir_staging.is_dir():
+            raise TemplateRenderError(
+                "Output staging directory '{}' already exists. Remove it and "
+                "try again.".format(output_dir_staging))
+
+        template_path = self.ip_template.template_path
+
+        try:
+            # Copy everything but the templates.
+            shutil.copytree(template_path,
+                            output_dir_staging,
+                            ignore=shutil.ignore_patterns('*.tpl'))
+
+            # Render templates.
+            for template_filepath in template_path.glob('**/*.tpl'):
+                template_filepath_rel = template_filepath.relative_to(
+                    template_path)
+
+                # Put the output file into the same relative directory as the
+                # template. The output file will also have the same name as the
+                # template, just without the '.tpl' suffix.
+                outdir_path = output_dir_staging / template_filepath_rel.parent
+
+                self._render_mako_template_to_file(template_filepath,
+                                                   outdir_path)
+
+            # Generate register interface through reggen.
+            hjson_path = (output_dir_staging / 'data' /
+                          (self.ip_template.name + '.hjson'))
+            rtl_path = output_dir_staging / 'rtl'
+            # TODO: Pass on template parameters to reggen? Or enable the user
+            # to set a different set of parameters in the renderer?
+            reggen.gen_rtl.gen_rtl(IpBlock.from_path(str(hjson_path), []),
+                                   str(rtl_path))
+
+            # Write IP configuration (to reproduce the generation process).
+            # TODO: Should the ipconfig file be written to the instance name,
+            # or the template name?
+            self.ip_config.to_file(
+                output_dir_staging /
+                'data/{}.ipconfig.hjson'.format(self.ip_config.instance_name),
+                header=_HJSON_LICENSE_HEADER)
+
+            # Safely overwrite the existing directory if necessary:
+            #
+            # - First move the existing directory out of the way.
+            # - Then move the staging directory with the new content in place.
+            # - Finally remove the old directory.
+            #
+            # If anything goes wrong in the meantime we are left with either
+            # the old or the new directory, and potentially some backups of
+            # outdated files.
+            do_overwrite = overwrite_output_dir and output_dir.exists()
+            output_dir_existing_bak = output_dir.with_suffix(
+                '.bak~' + str(int(time.time())))
+            if do_overwrite:
+                os.rename(output_dir, output_dir_existing_bak)
+
+            # Move the staging directory to the final destination.
+            os.rename(output_dir_staging, output_dir)
+
+            # Remove the old/"overwritten" data.
+            if do_overwrite:
+                try:
+                    shutil.rmtree(output_dir_existing_bak)
+                except Exception as e:
+                    msg = (
+                        f'Unable to delete the backup directory '
+                        '{output_dir_existing_bak} of the overwritten data. '
+                        'Please remove it manually.')
+                    raise TemplateRenderError(msg).with_traceback(
+                        e.__traceback__)
+
+        finally:
+            # Ensure that the staging directory is removed at the end. Ignore
+            # errors as the directory should not exist at this point actually.
+            shutil.rmtree(output_dir_staging, ignore_errors=True)