Module System

MathViz provides a module system for organizing code into reusable, maintainable components. Modules compile to Python files with functions becoming top-level definitions or @staticmethod methods in classes. This guide covers everything from basic module creation to advanced dependency patterns, with real compiled output from the MathViz compiler.

1. Module Basics

A module in MathViz is simply a .mviz file containing functions, constants, and types. Any .mviz file can serve as a module that other files import from.

// math_utils.mviz - A reusable module

pub fn factorial(n) {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

pub fn fibonacci(n) {
    if n <= 1 {
        return n
    }
    return fibonacci(n - 1) + fibonacci(n - 2)
}

pub fn gcd(a, b) {
    if b == 0 {
        return a
    }
    return gcd(b, a % b)
}

fn private_helper() {
    return 42
}
import numpy as np
from numba import jit, njit, vectorize, prange
import numba

@njit(cache=True)
def factorial(n):
    if (n <= 1):
        return 1
    return (n * factorial((n - 1)))

@njit(cache=True)
def fibonacci(n):
    if (n <= 1):
        return n
    return (fibonacci((n - 1)) + fibonacci((n - 2)))

@njit(cache=True)
def gcd(a, b):
    if (b == 0):
        return a
    return gcd(b, (a % b))

@njit(cache=True)
def private_helper():
    return 42
Module = File

Each .mviz file is a module. The module name is derived from the filename (without extension). A file named math_utils.mviz creates a module named math_utils.

2. Public / Private Visibility

By default, all items in a module are private. Use the pub keyword to make functions, constants, structs, and traits available for import.

DeclarationVisibilityImportable?
pub fn name()PublicYes - other modules can import it
fn name()PrivateNo - only accessible within the file
pub const X = 42PublicYes
const X = 42PrivateNo
pub struct PointPublicYes
struct PointPrivateNo
pub trait ShapePublicYes

Visibility Example

// Public - can be imported by other modules
pub fn factorial(n) {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

// Private - internal implementation detail
fn private_helper() {
    return 42
}
@njit(cache=True)
def factorial(n):
    if (n <= 1):
        return 1
    return (n * factorial((n - 1)))

@njit(cache=True)
def private_helper():
    return 42
Compiled Output Note

In the compiled Python, both pub fn and fn produce identical function definitions. The visibility distinction is enforced at compile time by the MathViz type checker, not at runtime.

3. Importing Modules

Use the use keyword to import a module. Functions are then accessed via qualified names (module_name.function_name).

use math_utils

fn main() {
    let f = math_utils.factorial(10)
    let fib = math_utils.fibonacci(20)
    let g = math_utils.gcd(48, 18)
    print(f)
    print(fib)
    print(g)
}
import numpy as np
from numba import jit, njit, vectorize, prange
import numba

import math_utils

@njit(cache=True)
def main():
    f = math_utils.factorial(10)
    fib = math_utils.fibonacci(20)
    g = math_utils.gcd(48, 18)
    print(f, end='')
    print(fib, end='')
    print(g, end='')


if __name__ == "__main__":
    main()
CLI vs Pipeline Compilation

The CLI command uv run mathviz compile main.mviz generates import math_utils (Python-style import). For full module inlining (where the module code is embedded in the output), use the CompilationPipeline API programmatically.

Import Patterns

MathViz
// Basic module import
use math_utils

// Import specific items
use math::{sin, cos, PI}

// Import everything (glob)
use geometry::*

// Import with alias
use statistics::mean as average

// Declare a submodule
mod helpers

4. Module Resolution

When the compiler encounters a use statement, it searches for the module file in a specific order.

Resolution Order

  1. Same directory - Look for module_name.mviz in the same directory as the importing file
  2. Project root - Look in the project's root directory
  3. Standard library - Built-in modules (math, collections)
  4. Installed packages - External packages

File to Module Mapping

File PathModule NameUsage
math_utils.mvizmath_utilsuse math_utils
graph/bfs.mvizgraph::bfsuse graph::bfs
graph/mod.mvizgraphuse graph
sorting/tri_bulles.mvizsorting::tri_bullesuse sorting::tri_bulles

The mod.mviz Convention

For directories with multiple modules, create a mod.mviz file that serves as the entry point and re-exports items:

MathViz geometry/mod.mviz
// Declare submodules
mod point
mod vector
mod shapes

// Re-export commonly used items
pub use point::Point
pub use vector::Vector
pub use shapes::{Circle, Rectangle, Triangle}

5. Multi-Module Projects

Project Structure

Directory Structure
my_project/
├── main.mviz              # Entry point
├── math_utils.mviz        # Math utility module
├── graph/
│   ├── mod.mviz           # Graph module entry
│   ├── bfs.mviz           # BFS algorithm
│   ├── dfs.mviz           # DFS algorithm
│   └── dijkstra.mviz      # Dijkstra's algorithm
├── sorting/
│   ├── tri_bulles.mviz    # Bubble sort
│   └── tri_rapide.mviz    # Quick sort
└── scenes/
    ├── mod.mviz           # Scene module entry
    ├── intro.mviz         # Intro animation
    └── demo.mviz          # Demo animation

Multi-File Example

// math_utils.mviz
pub fn factorial(n) {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

pub fn fibonacci(n) {
    if n <= 1 {
        return n
    }
    return fibonacci(n - 1) + fibonacci(n - 2)
}

pub fn gcd(a, b) {
    if b == 0 {
        return a
    }
    return gcd(b, a % b)
}
// main.mviz
use math_utils

fn main() {
    let f = math_utils.factorial(10)
    let fib = math_utils.fibonacci(20)
    let g = math_utils.gcd(48, 18)
    print(f)
    print(fib)
    print(g)
}
import numpy as np
from numba import jit, njit, vectorize, prange
import numba

import math_utils

@njit(cache=True)
def main():
    f = math_utils.factorial(10)
    fib = math_utils.fibonacci(20)
    g = math_utils.gcd(48, 18)
    print(f, end='')
    print(fib, end='')
    print(g, end='')


if __name__ == "__main__":
    main()

6. Compiled Output

Understanding how modules compile to Python is essential for debugging and interoperability. The compiler uses two strategies depending on the compilation mode.

CLI Compilation (import statement)

When compiling with uv run mathviz compile main.mviz, the compiler generates a standard Python import statement:

use math_utils

fn main() {
    let f = math_utils.factorial(10)
}
import math_utils

@njit(cache=True)
def main():
    f = math_utils.factorial(10)

Pipeline Compilation (inlined module)

When using the CompilationPipeline API, the module code is inlined directly into the output file as a Python class with @staticmethod methods:

Compiled Python (Pipeline mode)
import numpy as np
from numba import jit, njit, vectorize, prange
import numba

# Inlined module: math_utils
class math_utils:
    @staticmethod
    @njit(cache=True)
    def factorial(n):
        if (n <= 1):
            return 1
        return (n * math_utils.factorial((n - 1)))

    @staticmethod
    @njit(cache=True)
    def fibonacci(n):
        if (n <= 1):
            return n
        return (math_utils.fibonacci((n - 1))
            + math_utils.fibonacci((n - 2)))

    @staticmethod
    @njit(cache=True)
    def gcd(a, b):
        if (b == 0):
            return a
        return math_utils.gcd(b, (a % b))


def main():
    f = math_utils.factorial(10)
    fib = math_utils.fibonacci(20)
    g = math_utils.gcd(48, 18)
    print(f)
    print(fib)
    print(g)

if __name__ == "__main__":
    main()
Recursive Calls in Inlined Modules

When modules are inlined as classes, recursive function calls must use the fully qualified class name (e.g., math_utils.factorial(n-1)). The compiler handles this automatically when inlining. If you edit the generated Python, ensure recursive calls use the class prefix.

7. Transitive Dependencies

When module A imports module B, and module B imports module C, this creates a transitive dependency chain. The compiler resolves these by processing modules in dependency order.

Dependency Chain
main.mviz  -->  graph.mviz  -->  queue.mviz
    |                                |
    +-------->  sorting.mviz         |
                    |                |
                    +-- uses queue --+
// queue.mviz - Base module
pub fn create_queue() {
    return []
}

pub fn enqueue(queue, item) {
    queue.append(item)
    return queue
}

pub fn dequeue(queue) {
    return queue.pop(0)
}
// graph.mviz - Uses queue
use queue

pub fn bfs(graph, start) {
    let visited = {}
    let q = queue.create_queue()
    queue.enqueue(q, start)
    // ... BFS algorithm
    return visited
}
// main.mviz - Uses graph (and transitively, queue)
use graph

fn main() {
    let g = [[1, 2], [0, 3], [0], [1]]
    let result = graph.bfs(g, 0)
    print(result)
}
Current Limitation

The CompilationPipeline currently handles one level of module inlining. For transitive dependencies (A uses B uses C), B is inlined but C may need to be manually resolved. The CLI command always generates Python import statements, which Python resolves at runtime.

8. Diamond Dependencies

A diamond dependency occurs when two modules both depend on a common third module:

Diamond Pattern
        main.mviz
       /          \
  graph.mviz    sorting.mviz
       \          /
       queue.mviz

The compiler handles diamond dependencies correctly by ensuring each module is only compiled and inlined once. In CLI mode, Python's import system naturally deduplicates imports (each module is loaded once into sys.modules).

In pipeline mode, the compiler tracks which modules have been inlined and skips duplicates, ensuring the generated output contains only one definition of each class.

9. Circular Dependency Detection

Circular dependencies (A uses B, B uses A) are detected at compile time and produce a clear error message:

MathViz
// module_a.mviz
use module_b

pub fn func_a() {
    return module_b.func_b()
}

// module_b.mviz
use module_a     // ERROR: circular dependency!

pub fn func_b() {
    return module_a.func_a()
}
Compiler Error
error[E0200]: circular dependency detected
  --> module_b.mviz:1:1
   |
 1 | use module_a
   | ^^^^^^^^^^^^
   |
   = help: module_a -> module_b -> module_a
   = help: break the cycle by extracting shared code into a third module

How to Fix Circular Dependencies

Extract the shared functionality into a new module that both can import:

Solution
// Before (circular):
// module_a -> module_b -> module_a

// After (fixed):
// module_a -> shared
// module_b -> shared

shared/
├── shared.mviz      # Common functionality
├── module_a.mviz    # Uses shared
└── module_b.mviz    # Uses shared

10. Mixing Python and MathViz Imports

MathViz modules can coexist with Python packages. When the compiler encounters a use statement it cannot resolve as a .mviz file, it falls back to generating a Python import statement.

// MathViz module (resolved as .mviz)
use math_utils

// Python packages (passed through as imports)
use numpy
use scipy

fn main() {
    let f = math_utils.factorial(5)
    let arr = numpy.array([1, 2, 3])
}
import math_utils
import numpy
import scipy

def main():
    f = math_utils.factorial(5)
    arr = numpy.array([1, 2, 3])

if __name__ == "__main__":
    main()
NumPy is Special

You rarely need use numpy because the compiler automatically converts math functions (sin, cos, sqrt, etc.) to np.xxx and adds the import numpy as np import. Direct use numpy is only needed for functions the compiler does not auto-convert.

11. Complete Multi-File Example

Here is a complete multi-file project demonstrating the module system end-to-end: a graph theory module with a BFS algorithm, used by a main file.

Project Structure

Directory Layout
graph_project/
├── graphe.mviz         # Graph data structure module
├── graphe_bfs.mviz     # BFS algorithm (uses graphe)
└── main.mviz           # Entry point (uses both)

Module: graphe.mviz

MathViz graphe.mviz
// Graph utilities module
pub fn create_adj_list(num_vertices) {
    let adj = []
    for i in 0..num_vertices {
        adj.append([])
    }
    return adj
}

pub fn add_edge(adj, u, v) {
    adj[u].append(v)
    adj[v].append(u)
    return adj
}

pub fn vertex_count(adj) {
    return len(adj)
}

pub fn edge_list(adj) {
    let edges = []
    for u in 0..len(adj) {
        for v in adj[u] {
            if u < v {
                edges.append([u, v])
            }
        }
    }
    return edges
}

Module: graphe_bfs.mviz

MathViz graphe_bfs.mviz
use graphe

pub fn bfs(adj, start) {
    let n = graphe.vertex_count(adj)
    let visited = [false for _ in 0..n]
    let order = []
    let queue = [start]
    visited[start] = true

    while len(queue) > 0 {
        let current = queue.pop(0)
        order.append(current)
        for neighbor in adj[current] {
            if not visited[neighbor] {
                visited[neighbor] = true
                queue.append(neighbor)
            }
        }
    }
    return order
}

Entry Point: main.mviz

MathViz main.mviz
use graphe
use graphe_bfs

fn main() {
    let adj = graphe.create_adj_list(6)
    graphe.add_edge(adj, 0, 1)
    graphe.add_edge(adj, 0, 2)
    graphe.add_edge(adj, 1, 3)
    graphe.add_edge(adj, 2, 4)
    graphe.add_edge(adj, 3, 5)

    let order = graphe_bfs.bfs(adj, 0)
    print(order)
}

Compiled Output (Pipeline mode, abbreviated)

Compiled Python
import numpy as np
from numba import jit, njit, vectorize, prange
import numba

class graphe:
    @staticmethod
    def create_adj_list(num_vertices):
        adj = []
        for i in range(0, num_vertices):
            adj.append([])
        return adj

    @staticmethod
    def add_edge(adj, u, v):
        adj[u].append(v)
        adj[v].append(u)
        return adj

    @staticmethod
    def vertex_count(adj):
        return len(adj)

class graphe_bfs:
    @staticmethod
    def bfs(adj, start):
        n = graphe.vertex_count(adj)
        visited = [False for _ in range(0, n)]
        order = []
        queue = [start]
        visited[start] = True
        while (len(queue) > 0):
            current = queue.pop(0)
            order.append(current)
            for neighbor in adj[current]:
                if (not visited[neighbor]):
                    visited[neighbor] = True
                    queue.append(neighbor)
        return order


def main():
    adj = graphe.create_adj_list(6)
    graphe.add_edge(adj, 0, 1)
    graphe.add_edge(adj, 0, 2)
    graphe.add_edge(adj, 1, 3)
    graphe.add_edge(adj, 2, 4)
    graphe.add_edge(adj, 3, 5)
    order = graphe_bfs.bfs(adj, 0)
    print(order)

if __name__ == "__main__":
    main()

12. Best Practices

One Concept Per File

Keep each module focused on a single domain: one struct, trait, or set of related functions. This makes code easier to find and maintain.

Explicit Exports

Only mark items as pub that are intended for external use. Keep implementation details private to maintain a clean API surface.

Use mod.mviz for Directories

Create a mod.mviz in module directories to serve as the public interface. Re-export commonly used items for convenience.

Avoid Circular Dependencies

Structure modules in a hierarchy where dependencies flow one direction. Extract shared code into a common module.

Group Imports by Source

Organize import statements: standard library first, then project modules, then local modules. Add blank lines between groups.

Test Modules Independently

Each module should be compilable and testable on its own. Avoid tight coupling between modules.

Import Style Guide

MathViz
// Good: Group imports by source
// Standard library
use math::{sin, cos, PI}
use collections::HashMap

// Project modules
use models::User
use services::{AuthService, ApiClient}

// Local modules
use self::helpers::validate

// Bad: Mixed, unorganized imports
use services::AuthService
use math::sin
use self::helpers::validate
use models::User
use math::cos

Navigation

Previous

Language Reference - Complete syntax documentation

Next

CLI Reference - Command-line interface

Related

Advanced Examples - Multi-module projects in action

Getting Started

Getting Started - Installation and first program