Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

README.md

MAGE

Symbolic MSIL bytecode generation for Clojure on the CLR. Bundled in the flybot-sg/magic monorepo as the emitter MAGIC compiles into.

Quick Example

(require '[mage.core :as il])
(import '[System.Reflection TypeAttributes])

(il/emit!
  (il/assembly "Example"
    [(il/module "Example.dll"
      [(il/type "ExampleType" TypeAttributes/Public [] System.Object nil
        [(il/method
          "AddIntegers"
          Int32 [Int32 Int32]
          [(il/ldarg-1)
           (il/ldarg-2)
           (il/add)
           (il/ret)])]
        [])])]))

(.AddIntegers (ExampleType.) 5 6)
;; 11

il/type is the one constructor that has to be spelled out in full: its shorter arities are currently unreachable, so pass attributes, interfaces, supertype, generic parameters, body and custom attributes every time.

Overview

MAGE wraps the entire CLR System.Reflection.Emit namespace in a gamma-style symbolic compiler. The goal is a functional, composable, data- and REPL-driven bytecode emission framework for the CLR. A tree of symbolic MSIL bytecode is built as Clojure data, and passed to an emit! function that turns it into runnable CLR types in memory. Writing a DLL to disk is the caller's job, on top of that.

Symbolic Bytecode

At the heart of MAGE is the symbolic representation of MSIL. Representing bytecode as persistent data allows it to be manipulated functionally before anything is generated. MAGE provides constructor functions that produce the maps that the emission logic expects.

Opcodes

The most basic forms are MSIL opcodes

(il/ldnull)                ;; {:mage.core/opcode ldnull}
(il/add)                   ;; {:mage.core/opcode add}
(il/ldstr "Hello, World!") ;; {:mage.core/opcode ldstr, :mage.core/argument "Hello, World!"}

Opcodes optionally take a single argument. MSIL is a stack language, so all intermediate data is pushed and popped off of a stack. Adding the integers 13 and 42 would look like

[(il/ldc-i4 13) ;; [{:mage.core/opcode ldc.i4, :mage.core/argument 13}
 (il/ldc-i4 42) ;;  {:mage.core/opcode ldc.i4, :mage.core/argument 42}
 (il/add)]      ;;  {:mage.core/opcode add}]

This will

  1. load constant integer of 4 bytes of value 13 on the stack
  2. load constant integer of 4 bytes of value 42 on the stack
  3. Pop two elements off the stack, add them, and push the result back on the stack

Another property of MSIL being a stack language is that your symbolic bytecode is flat, and not a tree. For convenience, MAGE allows you to generate nested vectors of maps, and will flatten them before final emission.

Locals

Like gamma, MAGE supports local variables but uses Clojure let bindings and map equality semantics instead of introducing new constructs. Every reference to i in the example below will be the same local variable in the final bytecode.

(let [i (il/local Int32)] ;; [{:mage.core/opcode ldc.i4, :mage.core/argument 8}
  [(il/ldc-i4 8)          ;;  {:mage.core/opcode stloc,
   (il/stloc i)           ;;   :mage.core/argument
   (il/ldc-i4 32)         ;;   {:mage.core/local local3112, :mage.core/type System.Int32}}
   (il/ldloc i)           ;;  {:mage.core/opcode ldc.i4, :mage.core/argument 32}
   (il/add)               ;;  {:mage.core/opcode ldloc,
   (il/ldloc i)           ;;   :mage.core/argument
   (il/mul)])             ;;   {:mage.core/local local3112, :mage.core/type System.Int32}}
                          ;;  {:mage.core/opcode add}
                          ;;  {:mage.core/opcode ldloc,
                          ;;   :mage.core/argument
                          ;;   {:mage.core/local local3112, :mage.core/type System.Int32}}
                          ;;  {:mage.core/opcode mul}]

Labels

MSIL uses labels and branching to implement loops and conditionals. This is an infinite loop printing out the numbers from 0 upwards.

(let [start (il/label)         ;; [{:mage.core/opcode ldc.i4, :mage.core/argument 0}
      i (il/local Int32)]      ;;  {:mage.core/opcode stloc,
  [(il/ldc-i4 0)               ;;   :mage.core/argument
   (il/stloc i)                ;;   {:mage.core/local local3130, :mage.core/type System.Int32}}
   start                       ;;  {:mage.core/label label3129}
   (il/ldloc i)                ;;  {:mage.core/opcode ldloc,
   (il/ldc-i4-1)               ;;   :mage.core/argument
   (il/add)                    ;;   {:mage.core/local local3130, :mage.core/type System.Int32}}
   (il/stloc i)                ;;  {:mage.core/opcode ldc.i4.1}
   (il/call                    ;;  {:mage.core/opcode add}
     (il/find-method           ;;  {:mage.core/opcode stloc,
       System.Console          ;;   :mage.core/argument
       "WriteLine"             ;;   {:mage.core/local local3130, :mage.core/type System.Int32}}
       Int32))                 ;;  {:mage.core/opcode call,
   (il/br start)])             ;;   :mage.core/argument #<MonoMethod Void WriteLine(Int32)>}
                               ;;  {:mage.core/opcode br,
                               ;;   :mage.core/argument {:mage.core/label label3129}}]

Methods, Types, Modules, and Assemblies

Opcodes cannot exist on their own. They must be part of the body of a method, which must be part of a type, which must be in a module, which must be in an assembly. Each level is a map that carries the next one under ::body, so the assembly from the Quick Example is this value:

{:mage.core/assembly "Example"
 :mage.core/access   Run
 :mage.core/body
 [{:mage.core/module "Example.dll"
   :mage.core/body
   [{:mage.core/type       "ExampleType"
     :mage.core/attributes AutoLayout, AnsiClass, Class, Public
     :mage.core/interfaces []
     :mage.core/super      System.Object
     :mage.core/body
     [{:mage.core/method      "AddIntegers"
       :mage.core/return-type System.Int32
       :mage.core/parameters  [{:mage.core/parameter System.Int32 ...}
                               {:mage.core/parameter System.Int32 ...}]
       :mage.core/body
       [{:mage.core/opcode ldarg.1}
        {:mage.core/opcode ldarg.2}
        {:mage.core/opcode add}
        {:mage.core/opcode ret}]}]}]}]}

Emission

emit! flattens the tree and builds it through System.Reflection.Emit, so the generated types are usable as soon as the call returns. It builds in memory and writes no file: il/assembly defaults to AssemblyBuilderAccess/Run, and a caller that wants a DLL on disk asks for RunAndSave and saves the builder itself. That is what MAGIC does, in magic.emission.

Rationale and History

A big part of the Arcadia project is hacking the ClojureCLR compiler to improve performance, fix bugs, or support Unity's various restricted export platforms. For much of the first year of the project, this hacking took place at the level of the C# source code of the compiler, which was found to be slow, error prone, and dangerous.

In pursuit of a rapid, iterative, safe, and well reasoned approach to compiler hacking, and in reaction to conversations with fellow Kitchen Table Coder Kovas Boguta, MAGE was designed. In his talks about gamma, Kovas has said that "shader coding should be normal Clojure coding." In that same spirit, MAGE strives to make compiler development more like normal Clojure development. With a REPL, persistent data, and functional programming, building a compiler's emitter is a much more reasonable project.

MAGE was built to support MAGIC, which specifically compiles Clojure forms into MSIL bytecode. MAGE is agnostic to Clojure's semantics, and wraps the CLR's emission machinery in a general way.

Name

MAGE stands for Morgan And Grand Emitter. It is named after the Morgan Avenue and Grand Street intersection in Brooklyn, the location of the Kitchen Table Coders studio where the library was developed.

Legal

Copyright © 2015-2023 Ramsey Nasser and contributors. Copyright © 2026 Flybot Pte. Ltd.

Licensed under the Apache License, Version 2.0.