Repository: magia-hdl/magia (language: python)

Issue:
Importing existing SV Module
Importing existing SV module provides a seamless integration of existing design into Magia base design.

We need to deal with below problems:
1. How to handle modules with `parameter`? (as Magia does not intend to support `parameter` syntax in SV)
2. What is the expected syntax?

## Acceptance Criteria 
1. Having a syntax to import existing SV Code, in string + file path
2. Following existing `Module.instance()` syntax for instantiation.
3. Able to import a SV module with parameters
4. Able to list parameters in a module.
5. Above features tested


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 7cc842f..7fe1863 100644
--- a/.gitignore
+++ b/.gitignore
@@ -164,4 +164,6 @@ sim_build/
 **/sim_build/
 
 poetry.lock
-.ruff_cache/
\ No newline at end of file
+.ruff_cache/
+
+**/parsetab.py
\ No newline at end of file
diff --git a/README.md b/README.md
index 7e4db14..5af3585 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,7 @@ if __name__ == "__main__":
 - [Syntax Documentation](docs/syntax.md)
 - [Elaborate Designs](docs/elaborate.md)
 - [Memory Syntax](docs/memory.md)
+- [External Module](docs/external_module.md)
 
 ## Contributing
 
diff --git a/docs/external_module.md b/docs/external_module.md
new file mode 100644
index 0000000..7945e5c
--- /dev/null
+++ b/docs/external_module.md
@@ -0,0 +1,69 @@
+# External Module
+
+Developers can import existing (System)Verilog designs into Magia.
+They can be instantiated as a normal `Module` does.
+
+Common Use-cases are importing IP core such as:
+
+- SRAM generated by Memory Compilers
+- FPGA features such as PLL, SERDES, DDRx Controller, etc.
+- Processor / Controller Cores.
+- Verification IP such as SVA modules.
+
+## Prerequisite
+
+We need to install [hdlConvertor](https://github.com/Nic30/hdlConvertor) for this feature.
+
+Run the following command to install hdlConvertor, if you are running on Linux X86_64:
+
+```bash
+pip install hdlConvertor-binary
+```
+For other platforms, please refer to the official site of [hdlConvertor](https://github.com/Nic30/hdlConvertor)
+
+## Examples
+
+```python
+from magia import ExternalModule, Module
+
+# Import a module from a file
+NewModule = ExternalModule["path/to/file.sv", "ModuleName"]
+
+# Import a module from a raw string
+code = """module ModuleName #(parameter WIDTH=16, ...)(...); ... endmodule"""
+NewModule = ExternalModule.from_code(code, "ModuleName")
+
+# Specialize a module with parameters
+# Module parameters in SV can be specialized by the constructor
+module = NewModule(WIDTH=32, name="Module_with_32bit_width")
+
+
+class Top(Module):
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        ...
+        submodule = NewModule(WIDTH=32, name="Module_with_32bit_width")
+        submodule.instance("submodule", io={"input_a": self.io.input_a, "out": self.io.out})
+        ...
+```
+
+## Syntax
+
+`ExternalModule` is a metaclass that can be used to import a module from a file or a string,
+which creates a new `Module` class that can be specialized and instantiated.
+
+| Syntax                                            | Description                                                                                 |
+|---------------------------------------------------|---------------------------------------------------------------------------------------------|
+| `ExternalModule["path/to/file.sv", "ModuleName"]` | Import a module from a file and return a class of `ExternalModule`                          |
+| `ExternalModule.from_code(code, "ModuleName")`    | Import a module from a raw string and return a class of `ExternalModule`                    |
+| `ExternalModule[...](param1=...)`                 | Specialize a module with parameters. Override the parameter name if it exists in the module |
+| `ExternalModule[...](param1=...).params`          | Get the current parameters of the specialized module                                        |
+| `ExternalModule[...].default_params()`            | Get the default parameters of the module                                                    |
+| `ExternalModule[...](...).io`                     | Access the I/O of the module                                                                |
+
+## Limitations
+
+- Directive and Macro are not supported.
+- Inout port is not supported.
+- `interface` is not supported.
+- Ports with unpacked array are not supported. e.g. `input [WIDTH-1:0] a [0: PORT_COUNT-1];`
diff --git a/magia/__init__.py b/magia/__init__.py
index 358e216..e44367b 100644
--- a/magia/__init__.py
+++ b/magia/__init__.py
@@ -1,8 +1,31 @@
+from importlib.util import find_spec
+
 from .bundle import IOBundle, SignalBundle
 from .core import Constant, Input, Output, Register, Signal
 from .memory import Memory
 from .module import Blackbox, Elaborator, Instance, Module
 
+if find_spec("hdlConvertor") is not None:
+    from .external import ExternalModule
+else:
+    # HDLConvertor is not installed. Disable ExternalModule.
+    class ExternalModule:
+        ERROR = NotImplementedError("ExternalModule is disabled. Install hdlConvertor to enable it.")
+
+        def __init__(self, **kwargs):
+            raise self.ERROR
+
+        def __class_getitem__(cls, item):
+            raise cls.ERROR
+
+        @classmethod
+        def from_file(cls, sv_file, top_name):
+            raise cls.ERROR
+
+        @classmethod
+        def from_code(cls, sv_code, top_name):
+            raise cls.ERROR
+
 """
 Basic Signal Objects
 """
@@ -30,6 +53,7 @@
     "Instance",
     "Blackbox",
     "Elaborator",
+    "ExternalModule",
 ]
 
 """
diff --git a/magia/external.py b/magia/external.py
new file mode 100644
index 0000000..626a07e
--- /dev/null
+++ b/magia/external.py
@@ -0,0 +1,259 @@
+from os import PathLike
+from pathlib import Path
+from string import Template
+
+import hdlConvertorAst.hdlAst as Ast
+from hdlConvertor import HdlConvertor
+from hdlConvertorAst.language import Language
+
+from .core import Input, Output
+from .module import Module
+
+_ACCEPTABLE_BINARY_OPS = {
+    Ast.HdlOpType.SUB,
+    Ast.HdlOpType.ADD,
+    Ast.HdlOpType.DIV,
+    Ast.HdlOpType.MUL,
+    Ast.HdlOpType.MOD,
+    Ast.HdlOpType.REM,
+    Ast.HdlOpType.POW,
+    Ast.HdlOpType.SLL,
+    Ast.HdlOpType.SRL,
+    Ast.HdlOpType.SLA,
+    Ast.HdlOpType.SRA,
+    Ast.HdlOpType.EQ,
+    Ast.HdlOpType.NE,
+    Ast.HdlOpType.GT,
+    Ast.HdlOpType.GE,
+    Ast.HdlOpType.LT,
+    Ast.HdlOpType.LE,
+    Ast.HdlOpType.DOWNTO,
+}
+
+
+class ExternalModule(Module):
+    """
+    Imported SystemVerilog Module
+    """
+    _INST_TEMPLATE = Template("$module_name #(\n$params\n) inst (\n$io\n);")
+    _IO_TEMPLATE = Template(".$port_name($port_name)")
+    _PARAM_TEMPLATE = Template(".$param_name($param_value)")
+
+    ports_from_code: dict[str, Ast.iHdlObj] = {}
+    params_from_code: dict[str, Ast.iHdlObj] = {}
+    ext_module_name: str = ""
+
+    def __init__(self, **kwargs):
+        if not self.ports_from_code:
+            raise ValueError(
+                "No ports found from the module. "
+                "Did you forget to call ExternalModule[file,top] or from_code?"
+            )
+        sub_kwargs = {
+            key: value
+            for key, value in kwargs.items()
+            if key not in self.params_from_code
+        }
+        super().__init__(**sub_kwargs)
+        self._params_override = {
+            key: Ast.HdlIdDef()
+            for key in kwargs
+            if key in self.params_from_code
+        }
+
+        for key, value in self._params_override.items():
+            value.name = key
+            if isinstance(kwargs[key], (str, float)):
+                value.value = kwargs[key]
+            if isinstance(kwargs[key], (str, int)):
+                value.value = Ast.HdlValueInt(str(kwargs[key]), None, 10)
+
+        self._params = {
+            **self.params_from_code,
+            **self._params_override,
+        }
+        for port in self.ports_from_code:
+            self.io += self._create_port(port)
+
+        self.register_module_doc(locals())
+
+    def elaborate(self) -> tuple[str, set[Module]]:
+        mod_decl = self.mod_declaration()
+        mod_end = "endmodule"
+
+        io_list = []
+        for port in self.io.input_names:
+            io_list.append(self._IO_TEMPLATE.substitute(port_name=port))
+        for port in self.io.output_names:
+            io_list.append(self._IO_TEMPLATE.substitute(port_name=port))
+        io_list = ",\n".join(io_list)
+
+        param_list = []
+        for param in self._params_override:
+            value = self._resolve_param(param)
+            if isinstance(value, str):
+                value = f'"{value}"'
+            param_list.append(self._PARAM_TEMPLATE.substitute(
+                param_name=param,
+                param_value=value,
+            ))
+        param_list = ",\n".join(param_list)
+
+        inst_impl = self._INST_TEMPLATE.substitute(
+            module_name=self.ext_module_name,
+            params=param_list,
+            io=io_list,
+        )
+
+        sv_code = "\n".join((mod_decl, inst_impl, mod_end))
+        return sv_code, set()
+
+    @property
+    def params(self):
+        return {
+            key: self._resolve_param(key)
+            for key in self._params
+        }
+
+    @classmethod
+    def default_params(cls):
+        return cls().params
+
+    def _create_port(self, port_name: str):
+        port = self.ports_from_code[port_name]
+        port_class = {
+            Ast.HdlDirection.IN: Input,
+            Ast.HdlDirection.OUT: Output,
+            Ast.HdlDirection.INOUT: Input,  # Fix this one when we implement inout port support
+        }[port.direction]
+
+        name = port.name
+        signed = False
+        width = 1
+
+        # More details from the port declaration
+        if isinstance(port.type, Ast.HdlOp) and port.type.fn == Ast.HdlOpType.PARAMETRIZATION:
+            # Determine if port is signed
+            _, port_width, port_signed = port.type.ops
+            signed = bool(self._resolve_node(port_signed))
+            width = self._resolve_node(port_width) + 1
+
+        return port_class(name=name, width=width, signed=signed)
+
+    def _resolve_node(self, node: Ast.iHdlObj):
+        # Resolve Constants
+        if node is None:
+            return 0
+        if isinstance(node, (float, str)):
+            return node
+        if isinstance(node, Ast.HdlValueInt):
+            return int(node.val, node.base) if isinstance(node.val, str) else node.val
+
+        # Resolve Parameter Identifier
+        if isinstance(node, Ast.HdlValueId):
+            return self._resolve_param(node.val)
+
+        # Resolve Operators
+        if isinstance(node, Ast.HdlOp):
+            if node.fn == Ast.HdlOpType.INDEX:
+                raise NotImplementedError("Unpacked Port is not supported")
+            if node.fn == Ast.HdlOpType.PARAMETRIZATION:
+                _, width, _ = node.ops
+                return self._resolve_node(width)
+            if node.fn == Ast.HdlOpType.CALL:
+                # Only support $clog2
+                if node.ops[0].val == "$clog2":
+                    op = self._resolve_node(node.ops[1])
+                    return max((op - 1).bit_length(), 0)
+                raise NotImplementedError(f"Function {node.ops[0].val} not supported")
+            if node.fn == Ast.HdlOpType.MINUS_UNARY:
+                return -self._resolve_node(node.ops[0])
+            if node.fn in _ACCEPTABLE_BINARY_OPS:
+                op1, op2 = node.ops
+                op1 = self._resolve_node(op1)
+                op2 = self._resolve_node(op2)
+                return {
+                    Ast.HdlOpType.SUB: lambda x, y: x - y,
+                    Ast.HdlOpType.ADD: lambda x, y: x + y,
+                    Ast.HdlOpType.DIV: lambda x, y: x // y,
+                    Ast.HdlOpType.MUL: lambda x, y: x * y,
+                    Ast.HdlOpType.MOD: lambda x, y: x % y,
+                    Ast.HdlOpType.REM: lambda x, y: x % y,
+                    Ast.HdlOpType.POW: lambda x, y: x ** y,
+                    Ast.HdlOpType.SLL: lambda x, y: x << y,
+                    Ast.HdlOpType.SRL: lambda x, y: x >> y,
+                    Ast.HdlOpType.SLA: lambda x, y: x << y,
+                    Ast.HdlOpType.SRA: lambda x, y: x >> y,
+                    Ast.HdlOpType.EQ: lambda x, y: 1 if x == y else 0,
+                    Ast.HdlOpType.NE: lambda x, y: 1 if x != y else 0,
+                    Ast.HdlOpType.GT: lambda x, y: 1 if x > y else 0,
+                    Ast.HdlOpType.GE: lambda x, y: 1 if x >= y else 0,
+                    Ast.HdlOpType.LT: lambda x, y: 1 if x < y else 0,
+                    Ast.HdlOpType.LE: lambda x, y: 1 if x <= y else 0,
+                    Ast.HdlOpType.DOWNTO: lambda x, y: abs(x - y),
+                }[node.fn](op1, op2)
+
+            raise NotImplementedError(f"Operator {node.fn.name} not supported")
+
+        raise NotImplementedError(f"Node {node.__class__.__name__} not supported")
+
+    def _resolve_param(self, param_name: str):
+        # We need to resolve parameters and operators here
+        return self._resolve_node(self._params[param_name].value)
+
+    def __class_getitem__(cls, item):
+        """
+        Return a new ExternalModule with the given file and Top Level Name
+
+        Syntax:
+            NewExternalModule = ExternalModule[PathLike, str]
+            new_module = NewExternalModule(param1=value1, param2=value2, ...)
+        """
+        sv_file, top_name = item
+        return cls.from_file(sv_file, top_name)
+
+    @classmethod
+    def from_file(cls, sv_file: PathLike, top_name: str):
+        """
+        Create a new ExternalModule from a SystemVerilog file and given Top Level
+        """
+        return cls.from_code(Path(sv_file).read_text(), top_name)
+
+    @classmethod
+    def from_code(cls, sv_code: str, top_name: str):
+        """
+        Create a new ExternalModule from SystemVerilog code and given Top Level
+        """
+        ports, params = cls.parse_sv(sv_code, top_name)
+        return type(f"ExternalModule_{top_name}", (cls,), {
+            "ports_from_code": ports,
+            "params_from_code": params,
+            "ext_module_name": top_name,
+            "__doc__": cls.__doc__,
+        })
+
+    @staticmethod
+    def parse_sv(sv_code: str, top_name: str) -> tuple[dict[str, Ast.iHdlObj], dict[str, Ast.iHdlObj]]:
+        """
+        Parse SV Code and return a dictionary of ports and parameters
+        """
+        parser = HdlConvertor()
+        source = parser.parse_str(sv_code, Language.SYSTEM_VERILOG_2017, [], hierarchyOnly=False, debug=True)
+        modules = [
+            m.dec for m in source.objs
+            if isinstance(m, Ast.HdlModuleDef) and m.dec.name == top_name
+        ]
+        if not modules:
+            raise ValueError(f"Could not find module {top_name} in {sv_code}")
+
+        port_dict = {
+            port.name: port
+            for port in modules[0].ports
+        }
+        params_dict = {
+            p.name: p
+            for p in modules[0].params
+            if not (hasattr(p.type, "val") and p.type.val == "time")
+        }
+
+        return port_dict, params_dict
diff --git a/poetry.lock b/poetry.lock
index 27837e2..7433c7d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -153,6 +153,37 @@ files = [
     {file = "find_libpython-0.3.1-py3-none-any.whl", hash = "sha256:f63c2c05b9c6077bfafa5c7b283547b918a54c17fd930ceead487d1f220ff9cb"},
 ]
 
+[[package]]
+name = "hdlconvertor-binary"
+version = "2.3"
+description = "VHDL and System Verilog parser written in c++"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "hdlConvertor-binary-2.3.tar.gz", hash = "sha256:1f6131f76dbf1bc18d5847fe96b8033a5bb14219ebb2eb50b9bec1f3c62df97b"},
+    {file = "hdlConvertor_binary-2.3-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:d3c66d2f26bde2a6d92bc209c7e42b16168fc25493c86d61d58f050691845def"},
+    {file = "hdlConvertor_binary-2.3-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:e80870720fdc831a44342f8878f0d9d8b991b782ac9ea1020ca074044b0f6a33"},
+    {file = "hdlConvertor_binary-2.3-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:d68d2d2f314c366fe5d47dac55447945a7da3ef9e8873ba1b13dfd107497d9e6"},
+    {file = "hdlConvertor_binary-2.3-cp38-cp38-manylinux_2_31_x86_64.whl", hash = "sha256:3dd437bc872053ea7d704b86a55623ce7a0bad40e123164962a2bc8929e64363"},
+    {file = "hdlConvertor_binary-2.3-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:bb3f66aeefe2735d64806e292129a9376399832b968b2f1186a90da61c08cd71"},
+]
+
+[package.dependencies]
+hdlConvertorAst = ">=1.0"
+
+[[package]]
+name = "hdlconvertorast"
+version = "1.2"
+description = "A library of AST nodes for HDL languages (Verilog, VHDL, ...) and transpiler/compiler utilities"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "hdlConvertorAst-1.2-py3-none-any.whl", hash = "sha256:0becf07c665b399bfdde0cc10559dc061e1278a817ec28262d3512281f58b4b3"},
+    {file = "hdlConvertorAst-1.2.tar.gz", hash = "sha256:7dcf79871f529c891ead74f47a6ad4ddf1d6f058ea1a4faa7644eee2cf4ec0ec"},
+]
+
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -266,14 +297,14 @@ yaml = ["PyYAML (>=3.11,<7)"]
 
 [[package]]
 name = "pytest"
-version = "7.4.3"
+version = "7.4.4"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
-    {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
+    {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
+    {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
 ]
 
 [package.dependencies]
@@ -355,29 +386,29 @@ files = [
 
 [[package]]
 name = "ruff"
-version = "0.1.9"
+version = "0.1.11"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e6a212f436122ac73df851f0cf006e0c6612fe6f9c864ed17ebefce0eff6a5fd"},
-    {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:28d920e319783d5303333630dae46ecc80b7ba294aeffedf946a02ac0b7cc3db"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:104aa9b5e12cb755d9dce698ab1b97726b83012487af415a4512fedd38b1459e"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e63bf5a4a91971082a4768a0aba9383c12392d0d6f1e2be2248c1f9054a20da"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d0738917c203246f3e275b37006faa3aa96c828b284ebfe3e99a8cb413c8c4b"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69dac82d63a50df2ab0906d97a01549f814b16bc806deeac4f064ff95c47ddf5"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2aec598fb65084e41a9c5d4b95726173768a62055aafb07b4eff976bac72a592"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:744dfe4b35470fa3820d5fe45758aace6269c578f7ddc43d447868cfe5078bcb"},
-    {file = "ruff-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:479ca4250cab30f9218b2e563adc362bd6ae6343df7c7b5a7865300a5156d5a6"},
-    {file = "ruff-0.1.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:aa8344310f1ae79af9ccd6e4b32749e93cddc078f9b5ccd0e45bd76a6d2e8bb6"},
-    {file = "ruff-0.1.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:837c739729394df98f342319f5136f33c65286b28b6b70a87c28f59354ec939b"},
-    {file = "ruff-0.1.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e6837202c2859b9f22e43cb01992373c2dbfeae5c0c91ad691a4a2e725392464"},
-    {file = "ruff-0.1.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:331aae2cd4a0554667ac683243b151c74bd60e78fb08c3c2a4ac05ee1e606a39"},
-    {file = "ruff-0.1.9-py3-none-win32.whl", hash = "sha256:8151425a60878e66f23ad47da39265fc2fad42aed06fb0a01130e967a7a064f4"},
-    {file = "ruff-0.1.9-py3-none-win_amd64.whl", hash = "sha256:c497d769164df522fdaf54c6eba93f397342fe4ca2123a2e014a5b8fc7df81c7"},
-    {file = "ruff-0.1.9-py3-none-win_arm64.whl", hash = "sha256:0e17f53bcbb4fff8292dfd84cf72d767b5e146f009cccd40c2fad27641f8a7a9"},
-    {file = "ruff-0.1.9.tar.gz", hash = "sha256:b041dee2734719ddbb4518f762c982f2e912e7f28b8ee4fe1dee0b15d1b6e800"},
+    {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a7f772696b4cdc0a3b2e527fc3c7ccc41cdcb98f5c80fdd4f2b8c50eb1458196"},
+    {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934832f6ed9b34a7d5feea58972635c2039c7a3b434fe5ba2ce015064cb6e955"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea0d3e950e394c4b332bcdd112aa566010a9f9c95814844a7468325290aabfd9"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd4025b9c5b429a48280785a2b71d479798a69f5c2919e7d274c5f4b32c3607"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ad00662305dcb1e987f5ec214d31f7d6a062cae3e74c1cbccef15afd96611d"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4b077ce83f47dd6bea1991af08b140e8b8339f0ba8cb9b7a484c30ebab18a23f"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a88efecec23c37b11076fe676e15c6cdb1271a38f2b415e381e87fe4517f18"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b25093dad3b055667730a9b491129c42d45e11cdb7043b702e97125bcec48a1"},
+    {file = "ruff-0.1.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231d8fb11b2cc7c0366a326a66dafc6ad449d7fcdbc268497ee47e1334f66f77"},
+    {file = "ruff-0.1.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:09c415716884950080921dd6237767e52e227e397e2008e2bed410117679975b"},
+    {file = "ruff-0.1.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f58948c6d212a6b8d41cd59e349751018797ce1727f961c2fa755ad6208ba45"},
+    {file = "ruff-0.1.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:190a566c8f766c37074d99640cd9ca3da11d8deae2deae7c9505e68a4a30f740"},
+    {file = "ruff-0.1.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6464289bd67b2344d2a5d9158d5eb81025258f169e69a46b741b396ffb0cda95"},
+    {file = "ruff-0.1.11-py3-none-win32.whl", hash = "sha256:9b8f397902f92bc2e70fb6bebfa2139008dc72ae5177e66c383fa5426cb0bf2c"},
+    {file = "ruff-0.1.11-py3-none-win_amd64.whl", hash = "sha256:eb85ee287b11f901037a6683b2374bb0ec82928c5cbc984f575d0437979c521a"},
+    {file = "ruff-0.1.11-py3-none-win_arm64.whl", hash = "sha256:97ce4d752f964ba559c7023a86e5f8e97f026d511e48013987623915431c7ea9"},
+    {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"},
 ]
 
 [[package]]
@@ -481,4 +512,4 @@ test = ["packaging", "pytest (>=6.0.1)", "python-dateutil (>=2.8.0,<3.0.0)", "py
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "54f2f6dff6e0199fbe1b1512153e27eadde6ba65216148da24acffe99dc1dab8"
+content-hash = "5c6dbd082bf70a2a556ba6d4c102f39162f9940f4eb456fa0cac2a81603b7295"
diff --git a/pyproject.toml b/pyproject.toml
index 87f715a..83638fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,5 +47,7 @@ pytest = "*"
 pytest-xdist = "*"
 pytest-github-report = "*"
 ruff = "*"
+hdlConvertor-binary = "~2.3"
+
 [project.urls]
 Repository = "https://github.com/khwong-c/syn-magia"


Test patch:
diff --git a/tests/test_external_mod.py b/tests/test_external_mod.py
new file mode 100644
index 0000000..ae06b4c
--- /dev/null
+++ b/tests/test_external_mod.py
@@ -0,0 +1,261 @@
+from pathlib import Path
+
+from magia import Elaborator, ExternalModule, Input, Module, Output
+
+
+class TestExternalModImport:
+    code = """
+    module TopModule #(
+        parameter WIDTH = 8,
+        parameter DEPTH = 8
+    ) (
+        input clk,
+        input wen,
+        input [WIDTH-1:0] addr,
+        input signed [WIDTH-1:0] din,
+        output signed [WIDTH-1:0] dout
+    );
+    endmodule
+    """
+
+    def test_from_file(self, temp_build_dir):
+        sv_path = Path(temp_build_dir, "TopModule.sv")
+        sv_path.write_text(self.code)
+        mod = ExternalModule[sv_path, "TopModule"]()
+
+        input_list = ["clk", "wen", "addr", "din"]
+        assert len(mod.io.input_names) == len(mod.io.input_names)
+        for name in input_list:
+            assert name in mod.io.input_names
+        assert mod.io.output_names == ["dout"]
+
+    def test_io_name(self):
+        mod = ExternalModule.from_code(self.code, "TopModule")()
+        input_list = ["clk", "wen", "addr", "din"]
+        assert len(mod.io.input_names) == len(mod.io.input_names)
+        for name in input_list:
+            assert name in mod.io.input_names
+        assert mod.io.output_names == ["dout"]
+
+    def test_io_width(self):
+        mod = ExternalModule.from_code(self.code, "TopModule")()
+        assert len(mod.io.clk) == 1
+        assert len(mod.io.wen) == 1
+        assert len(mod.io.addr) == 8
+        assert len(mod.io.din) == 8
+        assert len(mod.io.dout) == 8
+
+    def test_io_signed(self):
+        mod = ExternalModule.from_code(self.code, "TopModule")()
+        assert not mod.io.clk.signed
+        assert not mod.io.wen.signed
+        assert not mod.io.addr.signed
+        assert mod.io.din.signed
+        assert mod.io.dout.signed
+
+    def test_default_params(self):
+        mod_class = ExternalModule.from_code(self.code, "TopModule")
+        assert mod_class.default_params() == {"WIDTH": 8, "DEPTH": 8}
+
+    def test_params(self):
+        mod_class = ExternalModule.from_code(self.code, "TopModule")
+        mod = mod_class(WIDTH=16, DEPTH=32)
+        assert mod.params == {"WIDTH": 16, "DEPTH": 32}
+
+    def test_params_override(self):
+        mod_class = ExternalModule.from_code(self.code, "TopModule")
+        mod = mod_class(WIDTH=16)
+        assert len(mod.io.clk) == 1
+        assert len(mod.io.wen) == 1
+        assert len(mod.io.addr) == 16
+        assert len(mod.io.din) == 16
+        assert len(mod.io.dout) == 16
+
+
+class TestExternalModElaborate:
+    top_name = "top"
+    code = f"""
+            module {top_name} #(
+                parameter width = 10,
+                parameter height = 20
+            )(
+              input  logic [width-1:0] a,
+              input  logic [width-1:0] b,
+              output logic [width-1:0] c
+            );
+              assign c = a & b;
+            endmodule
+            """
+
+    def test_elaborate(self):
+        ext_module_class = ExternalModule.from_code(self.code, self.top_name)
+
+        class TopLevel(Module):
+            def __init__(self, width, **kwargs):
+                super().__init__(**kwargs)
+                self.io += Input("a", width)
+                self.io += Input("b", width)
+                self.io += Output("c", width)
+                self.io += Output("d", width)
+                ext_module = ext_module_class(width=width)
+
+                inputs = {"a": self.io.a, "b": self.io.b, }
+                ext_module.instance(io={**inputs, "c": self.io.c, })
+                ext_module.instance(io={**inputs, "c": self.io.d, })
+
+        width = 17
+        result = (Elaborator.to_string(TopLevel(width)))
+        assert f"{self.top_name} #(" in result  # Check if the original module is being wrapped
+        assert f".width({width})" in result  # Check if parameters are being passed
+
+    def test_overriden_params_only(self):
+        ext_module_class = ExternalModule.from_code(self.code, self.top_name)
+
+        class TopLevel(Module):
+            def __init__(self, height, **kwargs):
+                super().__init__(**kwargs)
+                self.io += Input("a", 10)
+                self.io += Input("b", 10)
+                self.io += Output("c", 10)
+                self.io += Output("d", 10)
+                ext_module = ext_module_class(height=height)
+
+                inputs = {"a": self.io.a, "b": self.io.b, }
+                ext_module.instance(io={**inputs, "c": self.io.c, })
+                ext_module.instance(io={**inputs, "c": self.io.d, })
+
+        height = 17
+        result = Elaborator.to_string(TopLevel(height))
+        assert f"{self.top_name} #(" in result  # Check if the original module is being wrapped
+        assert f".height({height})" in result  # Check if parameters are being elaborated
+        assert ".width" not in result  # Check if unused parameters are not being elaborated
+
+
+def test_import_specific_mod():
+    code = """
+        module Mod1 (
+            input clk,
+            input wen,
+            output signed [7:0] dout
+        );
+        endmodule
+        module Mod2 (
+            input clk,
+            input wen,
+            output signed [7:0] dout1,
+            output signed [7:0] dout2
+        );
+        endmodule
+        """
+    mod = ExternalModule.from_code(code, "Mod1")()
+    assert len(mod.io.input_names) == 2
+    assert len(mod.io.output_names) == 1
+    mod = ExternalModule.from_code(code, "Mod2")()
+    assert len(mod.io.input_names) == 2
+    assert len(mod.io.output_names) == 2
+
+
+class TestExternalModSyntax:
+    def test_empty_params(self):
+        code = """
+        module Mod1 (
+            input clk,
+            input wen,
+            output signed [7:0] dout
+        );
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        assert mod.params == {}
+
+    def test_no_local_params(self):
+        code = """
+        module Mod1 (
+            input clk,
+            input wen,
+            output signed [7:0] dout
+        );
+        localparam SHALL_NOT_IMPORT = 1;
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        assert mod.params == {}
+
+    def test_port_def_in_body(self):
+        code = """
+        module Mod1 (
+            clk, wen, dout
+        );
+        input clk;
+        input wen;
+        output signed [7:0] dout;
+
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        input_list = ["clk", "wen"]
+        assert len(mod.io.input_names) == len(mod.io.input_names)
+        for name in input_list:
+            assert name in mod.io.input_names
+        assert mod.io.output_names == ["dout"]
+        assert len(mod.io.dout) == 8
+        assert mod.io.dout.signed
+
+    def test_param_def_in_body(self):
+        code = """
+        module Mod1 (
+            input clk,
+            input wen,
+            output signed [7:0] dout
+        );
+        parameter WIDTH = 8;
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        assert mod.params == {"WIDTH": 8}
+
+    def test_param_propagation(self):
+        code = """
+        module Mod1 #(
+            parameter BASE = 8,
+            parameter WIDTH = 2 * BASE
+        ) (
+            input clk,
+            input wen,
+            output signed [WIDTH-1:0] dout
+        );
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")(BASE=16)
+        assert mod.params == {"WIDTH": 32, "BASE": 16}
+        assert len(mod.io.dout) == 32
+
+    def test_directive_propagation(self):
+        code = """
+        `define ABC 12+3
+        module Mod1 #(
+            parameter WIDTH = `ABC
+        ) (
+            input clk,
+            input logic [WIDTH-1:0] addr,
+            output logic [WIDTH-1:0] dout
+        );
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        assert len(mod.io.addr) == 15
+        assert len(mod.io.dout) == 15
+
+    def test_clog2(self):
+        code = """
+        module Mod1 #(
+            parameter MEM_SIZE = 1024
+        ) (
+            input clk,
+            input logic [$clog2(MEM_SIZE)-1:0] addr,
+            output logic [15:0] dout
+        );
+        endmodule
+        """
+        mod = ExternalModule.from_code(code, "Mod1")()
+        assert len(mod.io.addr) == 10