Refactoring with Virtual Modules

The problem

You have a 500-line file. It works. You want to split it into modules. The traditional workflow:

  1. Create a new file
  2. Move functions there
  3. Add pub to every moved declaration
  4. Add import in the original file
  5. Prefix every call site with module. (or add selective imports)
  6. Hope you didn’t miss a dependency

If you got step 6 wrong, you find out after you’ve already made 200 edits. Tape solves this with compiler-validated refactoring.

Developer flow

Step 1: Wrap code you want to extract

tape
// app.tape — your working file
import io from "io";
import math from "math";

var items: [64]i64;
var count: i64 = 0;

refactor("./store.tape") {
    var items: [64]i64;
    var count: i64 = 0;

    // User chosen whitespace
    fn add_item(val: i64) {items[count] = val;count = count + 1;}

    // Sum comment
    fn sum() -> i64 {
        var total: i64 = 0;
        for (i in 0..count) {
            // Also comment here
            total = total + items[i];
        }
        return total;
    }
}

fn main() {
    add_item(42);
    add_item(7);
    io.println("total: {sum()}");
}

Step 2: Build normally

bash
tape build app.tape -o app.exe

The compiler validates the refactor boundary:

  • add_item and sum are called from host — they’ll need to be pub
  • Neither function references anything from the host that isn’t an import
  • The block doesn’t use io or math — no imports needed in the target module

If you accidentally left a reference to a host-local variable inside the block, you’d see an error now — not after 200 manual edits.

Step 3: Iterate

Move more code in or out of the block. Every build validates the boundary. Keep going until you’re happy with the split.

tape
// Oops, let's also extract the formatting:
refactor("./store.tape") {
    var items: [64]i64;
    var count: i64 = 0;

    fn add_item(val: i64) { ... }
    fn sum() -> i64 { ... }
    fn format_items() -> string { ... }  // new
}

Step 4: Materialize

bash
tape refactor app.tape

This produces two files:

store.tape (generated):

tape
pub {
    var items: [64]i64;
    var count: i64 = 0;

    // User chosen whitespace
    fn add_item(val: i64) {items[count] = val;count = count + 1;}

    // Sum comment
    fn sum() -> i64 {
        var total: i64 = 0;
        for (i in 0..count) {
            // Also comment here
            total = total + items[i];
        }
        return total;
    }

    fn format_items() -> string { ... }  // new
}

app.tape (rewritten):

tape
import io from "io";
import math from "math";

import { items, count, add_item, sum } from "./store.tape";

fn main() {
    add_item(42);
    add_item(7);
    io.println("total: {sum()}");
}

Call sites are unchanged. The selective import brings the symbols into scope without a prefix.

CLI reference

bash
tape refactor <file>           # materialize all refactor blocks
tape refactor <file> --dry-run # preview output without writing files
tape refactor <file> --force   # overwrite if target files exist

—dry-run

Shows what would be generated:

plaintext
Virtual module "./store.tape":
  Imports:     (none)
  Exports:     items, count, add_item, sum

Host "app.tape" after extraction:
  New import: import { items, count, add_item, sum } from "./store.tape";
  Removed:    refactor block (lines 12-31)

Would write: ./store.tape (new file)
Would rewrite: app.tape

—force

By default, tape refactor errors if the target file already exists. Use --force to overwrite.

Multiple targets

Extract to several modules at once:

tape
import io from "io";
import math from "math";

fn main() {
    let v = make_vec(1.0, 2.0, 3.0);
    io.println(format_vec(v));
}

refactor("./veclib.tape") {
    struct Vec3 { x: f64; y: f64; z: f64; }
    fn make_vec(x: f64, y: f64, z: f64) -> Vec3 {
        return Vec3 { x: x; y: y; z: z; };
    }
    fn vec_len(v: Vec3) -> f64 {
        return math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
    }
}

refactor("./display.tape") {
    fn format_vec(v: Vec3) -> string {
        return "{v.x}, {v.y}, {v.z}";
    }
}

The compiler resolves that display needs Vec3 from veclib. After materialization:

  • veclib.tape gets import math from "math" (used by vec_len)
  • display.tape gets import { Vec3 } from "./veclib.tape" (used by format_vec)
  • app.tape gets selective imports from both

Why this works

The refactor block is not a preprocessor trick or a post-hoc tool. It’s a compiler-enforced module boundary that happens to live inside a single file. This means:

  • Errors show up during development, not when you run a refactoring tool
  • You can iterate — move code in and out of blocks, build each time
  • The tool is trivial — by the time tape refactor runs, everything is already validated
  • No broken intermediate states — the program works the entire time

Constraints

  • Refactor blocks can only appear in #code region
  • Code inside cannot reference host-local symbols (only imports and other refactor block contents)
  • Circular dependencies between blocks are forbidden
  • The host file’s @profile is inherited by generated modules

Last modified: