Viscosity
Review
Part XI · Viscosity in Rust · Day 43

171. The plugin trait

In TypeScript, a plugin’s emit receives a plain LoweredModule and casts it back to the kind its own lower made. Nothing stops you handing it another plugin’s. In Rust an associated type ties the two halves together, and the compiler rejects the mix-up.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Read engine/rust’s LanguagePlugin trait, and say what its associated type Extra replaces in the TypeScript interface.
  • Explain how compile<P: LanguagePlugin> and the two implementors, JsPlugin and PyPlugin, fit together.
  • Explain why a Vec<&dyn LanguagePlugin> fails with E0191, and why naming Extra doesn’t fix it.
  • Pick a plugin at run time without trait objects, and erase Extra behind a second trait when you need one.

A cast nobody checks

Chapter 90’s interface gives emit the same LoweredModule that lower returned. But the JavaScript plugin needs more than the base type holds: the ESTree program and the scope resolution. So its lower returns a bigger object, and its emit casts it back:

emit(_source, lowered: LoweredModule,
     plan: Plan): string {
  const l = lowered as JsLowered;
  return emitJs(l.js, l.program,
                l.resolution, plan);
}

as is a promise, not a check. I lowered xs = [] with the Python plugin and handed the result to the JavaScript plugin’s emit:

const lowered = pythonPlugin().lower(py);
jsPlugin().emit(py, lowered, p);

tsc --strict accepted it. Run, it returned undefined: not an error, not a string. The two halves of a plugin belong together, and the type system can’t see it.

Intuition

A coat check gives you a ticket. In TypeScript every ticket looks the same, so you can hand in someone else’s and get handed whatever hangs on that hook. In Rust each cloakroom prints tickets of its own colour, and the other cloakroom’s attendant won’t take yours.

The trait

Here is the Rust version, from crates/core/src/plugin.rs (doc comments shortened):

pub trait LanguagePlugin {
    /// carried from lower to emit
    type Extra;

    fn id(&self) -> &'static str;
    fn extensions(&self)
        -> &'static [&'static str];
    fn lower(&self, source: &str) -> Result<
        LoweredModule<Self::Extra>,
        CompileError,
    >;
    fn emit(
        &self,
        source: &str,
        lowered:
            &LoweredModule<Self::Extra>,
        plan: &Plan,
    ) -> Result<String, CompileError>;
}

type Extra; is an associated type: a type that each implementation chooses, the way Iterator lets each iterator choose its Item (chapter 134). LoweredModule is generic over it:

pub struct LoweredModule<X> {
    pub module: Module,
    pub annotations:
        HashMap<BindingId, Annotation>,
    pub extra: X,
}

The module and the annotations are what the core reads. extra is the plugin’s private data, the part TypeScript smuggled through the cast.

Two types implement the trait. JsPlugin sets type Extra = JsExtra: facts about the syntax nodes behind instructions and the VIR binding of each JavaScript binding (chapter 172 says why the AST itself can’t be kept). PyPlugin sets type Extra = PyExtra: the lowered Python, or None plus the reason when the source is outside the subset, so that emit returns the file unchanged (chapter 187). Both are empty structs: neither has options.

Now the mix-up from above, in Rust, against the real crates (rustc 1.97, trimmed):

error[E0308]: mismatched types
  JsPlugin.emit(py, &l, &p);
                    ^^ expected
  `&LoweredModule<JsExtra>`, found
  `&LoweredModule<PyExtra>`
Definition

LanguagePlugin is the Rust trait a Viscosity front-end implements: id, extensions, lower and emit, plus an associated type Extra for the data that lower passes to emit. Because emit takes &LoweredModule<Self::Extra>, it only accepts what its own plugin’s lower produced. Errors are Results with a CompileError instead of exceptions.

One copy per plugin

The pipeline is generic, as chapter 132 said:

pub fn compile<P: LanguagePlugin>(
    source: &str,
    plugin: &P,
    opts: CompileOptions,
) -> Result<CompileResult, CompileError> {
    let lowered = plugin.lower(source)?;
    let analysis = analyze(&lowered.module);
    // options: opts + the annotations
    let p = plan(&analysis, options);
    let code =
        plugin.emit(source, &lowered, &p)?;
    let explain = explain(&p);
    Ok(CompileResult { code, plan: p,
        analysis, explain })
}

(Adapted from crates/core/src/lib.rs.) Each plugin gets its own monomorphized copy, compile::<JsPlugin> and compile::<PyPlugin>, and inside each copy Extra is one known type. Look at what comes out: CompileResult has the code, the plan, the analysis and the explain text. None of them mentions Extra. It is born in lower, used in emit, and dropped before compile returns.

Try itBranch first, then call the generic
trait Plugin {
    type Extra;
    fn lower(&self, s: &str)
        -> Self::Extra;
    fn emit(&self, s: &str,
        x: &Self::Extra) -> String;
}

struct Js;
struct Py;

impl Plugin for Js {
    // the number of lines
    type Extra = usize;
    fn lower(&self, s: &str) -> usize {
        s.lines().count()
    }
    fn emit(&self, s: &str, n: &usize)
        -> String {
        format!("// {n} lines\n{s}")
    }
}

impl Plugin for Py {
    // the first line
    type Extra = String;
    fn lower(&self, s: &str) -> String {
        let f = s.lines().next();
        f.unwrap_or("").to_string()
    }
    fn emit(&self, s: &str, f: &String)
        -> String {
        format!("# starts {f:?}\n{s}")
    }
}

// one copy per P: compile::<Js>,
// compile::<Py>
fn compile<P: Plugin>(s: &str, p: &P)
    -> String {
    let x = p.lower(s);
    p.emit(s, &x)
}

fn main() {
    let src = "xs = []\nys = []";
    for f in ["a.js", "b.py"] {
        let py = f.ends_with(".py");
        // both arms return a String
        let out = if py {
            compile(src, &Py)
        } else {
            compile(src, &Js)
        };
        println!("{f}:\n{out}\n");
    }
}
Open in Rust Playground ↗

Why not dyn?

Chapter 131 kept a Js and a Py in one Vec<&dyn Plugin>. Try that with the real trait, and step through what rustc says:

Step throughHolding any plugin
Two plugins with different Extra types, and three attempts to treat them as oneJsPluginExtra = JsExtraPyPluginExtra = PyExtratrait LanguagePlugin { type Extra; fn lower(..) -> LoweredModule<Extra> fn emit(.., &LoweredModule<Extra>)}errors: rustc 1.97 on the real crates (trimmed)
The trait has an associated type, Extra: whatever the plugin carries from lower to emit. JsPlugin says type Extra = JsExtra; PyPlugin says type Extra = PyExtra. Two plugins, two different Extras.
1/5

The trait itself is fine. The Rust Reference’s rules for a dyn-compatible trait forbid generic methods, associated constants and associated types with generics, and ask for a receiver like &self[1]. LanguagePlugin breaks none of them. But a trait object has to name every associated type, which is what E0191 says[2]. And once you name it, dyn LanguagePlugin<Extra = JsExtra> and dyn LanguagePlugin<Extra = PyExtra> are different types. That is not fussiness. Code that calls lower through a vtable[3] gets the result back and has to know its type, so the type can’t be left open.

The port’s answer is to branch before the generic call. From run_language in crates/wasm/src/lib.rs, where compile is imported under another name:

let r = match language {
    "javascript" | "js" =>
        compile(source, &JsPlugin, opts),
    "python" | "py" =>
        compile(source, &PyPlugin, opts),
    _ => return Err(/* ... */),
};

Both arms have the type Result<CompileResult, CompileError>, so the match has one type. The CLI does the same with the file’s extension. Chapter 132 left this as an open question; the answer needed no change to the trait.

Under the hood

A few smaller changes from the TypeScript interface. jsPlugin(opts) was a factory taking a parser choice and a TypeScript flag; JsPlugin has no options, because the port has one JavaScript parser (oxc) and rejects .ts files. lower takes no file name. id and extensions return &'static data: string constants that live for the whole program (chapter 133). A Python file outside the subset is not an error: PyPlugin::lower returns Ok with an empty module, as the TypeScript plugin does, so the core sees nothing to transform. And STRATEGIES, the layout list from chapter 90, is a &[LayoutStrategy] constant.

Your turn

If the port ever needs a registry of plugins chosen at run time, the usual move is a second trait without the associated type, implemented for every plugin at once. That is a design, not code in engine/rust, and it is the exercise.

ExerciseErase Extra behind a second trait

Plugin has an associated type, so &dyn Plugin is E0191. Hide it: implement AnyPlugin for every P: Plugin at once. handles is true when the file name ends with one of the plugin’s extensions; compile runs lower, then emit with what lower returned. Then write pick, which returns the plugin that handles the file, or None if no plugin does.

trait Plugin {
    type Extra;
    fn exts(&self)
        -> &'static [&'static str];
    fn lower(&self, s: &str)
        -> Self::Extra;
    fn emit(&self, s: &str,
        x: &Self::Extra) -> String;
}

struct Js;
struct Py;

impl Plugin for Js {
    type Extra = usize;
    fn exts(&self)
        -> &'static [&'static str] {
        &[".js", ".mjs"]
    }
    fn lower(&self, s: &str) -> usize {
        s.lines().count()
    }
    fn emit(&self, _: &str, n: &usize)
        -> String {
        format!("js {n}")
    }
}

impl Plugin for Py {
    type Extra = String;
    fn exts(&self)
        -> &'static [&'static str] {
        &[".py"]
    }
    fn lower(&self, s: &str) -> String {
        s.to_uppercase()
    }
    fn emit(&self, _: &str, u: &String)
        -> String {
        format!("py {u}")
    }
}

// Plugin with Extra hidden. No
// associated type, so &dyn works.
trait AnyPlugin {
    fn handles(&self, file: &str)
        -> bool;
    fn compile(&self, src: &str)
        -> String;
}

impl<P: Plugin> AnyPlugin for P {
    fn handles(&self, file: &str)
        -> bool {
        todo!()
    }
    fn compile(&self, src: &str)
        -> String {
        todo!()
    }
}

fn pick(file: &str)
    -> Option<&'static dyn AnyPlugin> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • LanguagePlugin has id, extensions, lower and emit, and an associated type Extra that carries each plugin’s own data from lower to emit. Passing one plugin’s output to another is E0308.
  • compile<P: LanguagePlugin> is monomorphized per plugin, and its result never mentions Extra.
  • &dyn LanguagePlugin is E0191: a trait object must name Extra, and each choice is a different type (E0271 for the other plugin).
  • To pick a plugin at run time, branch first and call compile in each arm, or erase Extra behind a second, dyn-friendly trait.

Check your understanding

0/6
  1. 1.

    What does the associated type Extra replace in the TypeScript plugin design?

  2. 2.

    Why does compile return the same type, Result<CompileResult, CompileError>, for every plugin?

  3. 3.

    What does rustc say about let ps: Vec<&dyn LanguagePlugin> = vec![&JsPlugin, &PyPlugin];?

  4. 4.

    You write Vec<&dyn LanguagePlugin<Extra = JsExtra>> and put &JsPlugin and &PyPlugin in it. Which part fails?

  5. 5.

    How does the wasm crate run Python or JavaScript by name?

  6. 6.

    In the exercise, why is AnyPlugin usable as &dyn AnyPlugin when Plugin isn’t?

References

  1. [1] The Rust Project Developers. Traits: Dyn compatibility (The Rust Reference). The Rust Reference (rust-lang/reference on GitHub, rendered at doc.rust-lang.org/reference). doc.rust-lang.org/reference/items/traits.html#dyn-compatibility (checked 2026-10-31)
  2. [2] The Rust Project Developers. Error code E0191 (Rust compiler error index). Rust compiler error index; source in compiler/rustc_error_codes/src/error_codes/E0191.md of github.com/rust-lang/rust. doc.rust-lang.org/error_codes/E0191.html (checked 2026-10-31)
  3. [3] The Rust Project Developers. Trait objects (The Rust Reference). The Rust Reference; source in src/types/trait-object.md of github.com/rust-lang/reference. doc.rust-lang.org/reference/types/trait-object.html (checked 2026-10-31)