Viscosity
Review
Part VII · Building Viscosity · Day 23

90. The plugin API

Viscosity reads JavaScript and Python with one set of analyses. The trick is a small interface: two functions per language, and a core that never sees a line of either language’s syntax.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Read the LanguagePlugin interface and say what lower and emit each receive and return.
  • Explain why parsing, lowering and emitting are per-language while analysis and planning are shared.
  • Describe what a layout strategy is, and how far Viscosity's strategies are pluggable today.
  • Write a tiny plugin, with lower and emit, for a toy language.

The same loop, twice

Here is a loop you have seen many times, written in Python:

ps = []
for i in range(1000):
    ps.append(P(float(i), 1.0))
for p in ps:
    p.x += p.vx

P is a dataclass with fields x and vx. Run viscosity explain on it and the answer is the opposite of the JavaScript version’s:

ps (line 8) {x, vx} → kept as array of
  objects
  · CPython: column access measured
    slower per element than objects
    (the win is about 5x less memory);
    add "# @viscosity soa" above the
    list to opt in

Same program, same analyses, different verdict. To see how one compiler handles two languages, and why it can answer differently for each, you need the interface between the core and a language.

Intuition

A wall socket doesn’t care what you plug into it. A lamp, a kettle and a phone charger all have the same two prongs, and each one handles its own insides. Adding a new appliance never means rewiring the house. A language plugin is the plug: two prongs, lower and emit, and the core is the wiring behind the wall.

Two functions

This is the whole interface, from packages/core/src/plugin.ts:

interface LoweredModule {
  module: Module;
  annotations: Map<BindingId, Annotation>;
}

interface LanguagePlugin {
  id: string;      // 'javascript'
  extensions: string[];
  lower(source: string,
    filename?: string): LoweredModule;
  emit(source: string,
    lowered: LoweredModule,
    plan: Plan): string;
}

lower turns source text into a VIR Module, plus any @viscosity annotations it found in comments (each language writes comments differently, so finding them is the plugin’s job). emit gets the source back, the same lowered object, and the plan, and returns new source.

There is no parse in the interface. Parsing happens inside lower, and the parser is the plugin’s own business: the JavaScript plugin uses acorn or a hand-written parser (chapter 92), the Python plugin its own tokenizer and parser. The core only ever receives VIR.

Definition

A language plugin is the object a language front-end provides to Viscosity: an id, the file extensions it handles, and two functions. lower parses source and lowers it to VIR. emit rewrites that source according to a layout plan. Everything between them (analysis, planning, explain) is shared by all languages.

Look again at emit’s arguments. It gets lowered back, typed as a plain LoweredModule, but a plugin may return a bigger object from lower. The JavaScript plugin adds its ESTree tree, its scope resolution, and the JavaScript text it actually analysed (TypeScript is stripped first). The core passes that object along without looking inside, and emit takes out what it needs. Each plugin keeps private state across the core this way, without the core knowing its type.

Why this split

Step through both programs:

Step throughTwo plugins, one core
JavaScript
const ps = [];
for (let i = 0; i < 1000; i++)
ps.push({ x: i, vx: 1 });
for (const p of ps)
p.x += p.vx;
A JavaScript plugin and a Python plugin feeding one shared coreJavaScript pluginPython pluginVIR · analyze · plan (shared)JS outPython out
Two programs that mean the same thing: a thousand particles, then one loop that moves them. (The Python one uses a dataclass P with fields x and vx.)
1/6

Some work can only be done by someone who knows the language. Parsing, obviously. Lowering too: only the Python plugin knows that P(...) builds a record, because it saw the @dataclass; only the JavaScript plugin knows that { x: i } does. And emit must write correct syntax, with Python’s indentation or JavaScript’s braces.

The rest is about data, not syntax. “Which fields does this loop touch?” has the same answer however the loop was spelled. Writing that analysis once, on VIR, means the escape checks, the numeric inference and the bounds proofs from earlier chapters serve every language, and a bug fixed there is fixed for all of them.

A language is also a fact

So why did the Python program keep its objects? The core never sees Python syntax, but it does read one field of the module, module.language, in two places. The numeric analysis needs it because “a number” differs: any JavaScript number fits a Float64Array, but in Python only a float may go into an array('d') column (an int would come back as 1.0). And the planner needs it here, because the repository’s measurements say so. In this book’s benchmark run on CPython 3.13 with 100,000 elements (packages/bench/FINDINGS.md, section 6), the update loop took 26.7 ns per element over dataclass objects and 72.7 ns over array('d') columns. Memory went from 16.0 MB to 3.3 MB.

CPython is an interpreter. Reading an element of array('d') creates a new float object each time, so the cache-friendlier layout loses on time. V8’s optimizing compiler reads a Float64Array element as a raw double, which is why JavaScript wins. So Python keeps objects by default, explains why, and lets you opt in with # @viscosity soa (or --python-auto) when memory matters more.

Layout strategies

The other axis of extension is how a collection is stored. The same file declares it:

interface LayoutStrategy {
  id: string;
  describe: string;
}

const STRATEGIES: LayoutStrategy[] = [
  { id: 'aos', describe: '…' },
  { id: 'soa', describe: '…' },
  { id: 'aosoa', describe: '…' },
];
Definition

A layout strategy is one way of storing a collection: aos leaves the array of objects, soa uses one column per field, and aosoa uses blocks of N elements, field-major inside each block. The planner chooses a strategy per collection; each plugin’s emit knows how to write it.

Be honest about where this stands. The comment says “strategies are plugins too”, but today the list is a registry of names and descriptions. The planner’s Layout type names the same three, and each emitter implements each one: the JavaScript emitter writes all three, while the Python emitter supports aos and soa and throws on aosoa. Adding a fourth strategy, say hot/cold splitting, means a planner rule plus emitter code in each plugin. The strategies are cleanly separated, but not yet loaded like language plugins.

Under the hood

The Python plugin shows the other duty of a plugin: staying safe at its edges. If its parser meets syntax outside the supported subset, lower catches the error and returns an empty module, and emit returns the source unchanged. Nothing in the core needs to know. The plugin builds its VIR with the same ModuleBuilder as JavaScript, so both get SSA construction from the one shared implementation[1].

Your turn

ExerciseA plugin for a toy language

The toy language has one statement per line. A line NAME = A + B, where A and B are whole numbers, is an addition; every other line is something the plugin doesn’t model. The core is written for you: fold turns every add instruction into a plan entry (line → sum), and compile runs the pipeline. Write the plugin eq, with lower(src) returning { module: { insts } } (one instruction per line: { op: 'add', name, a, b, line } or { op: 'opaque', line }, where line counts from 0), and emit(src, low, plan) replacing each planned line with NAME = SUM and leaving the others exactly as they were.

// The shared core: language-neutral.
function fold(mod) {
  const plan = new Map();
  for (const i of mod.insts) {
    if (i.op !== 'add') continue;
    plan.set(i.line, i.a + i.b);
  }
  return plan;
}
function compile(src, plugin) {
  const low = plugin.lower(src);
  const plan = fold(low.module);
  return plugin.emit(src, low, plan);
}

// Your plugin.
const eq = {
  id: 'eq',
  lower(src) {
    // return { module: { insts } }
  },
  emit(src, low, plan) {
    return src;
  },
};

console.log(compile('x = 1 + 2', eq));
Extra practiceA second plugin, same core

Prove the architecture: write a plugin lisp for lines like (set x (+ 1 2)), using the same fold and compile. An addition line becomes (set x 3); any other line is opaque and stays exactly as it was.

// The shared core: language-neutral.
function fold(mod) {
  const plan = new Map();
  for (const i of mod.insts) {
    if (i.op !== 'add') continue;
    plan.set(i.line, i.a + i.b);
  }
  return plan;
}
function compile(src, plugin) {
  const low = plugin.lower(src);
  const plan = fold(low.module);
  return plugin.emit(src, low, plan);
}

const lisp = {
  id: 'lisp',
  lower(src) {
    // return { module: { insts } }
  },
  emit(src, low, plan) {
    return src;
  },
};

What you learned

  • A language plugin provides id, extensions, lower and emit. Parsing lives inside lower.
  • lower returns VIR and annotations; the plugin can add private data to that object, and emit gets it back.
  • Syntax work (parse, lower, emit) is per-language; work on data (analysis, planning) is shared, on VIR.
  • The core reads module.language as a fact, in numeric inference and in the planner. CPython measured slower with columns, so Python keeps objects unless you opt in.
  • A layout strategy is a way of storing a collection: aos, soa, aosoa. Today they are a fixed list implemented by each emitter.

Check your understanding

0/6
  1. 1.

    Which of these is part of Viscosity’s LanguagePlugin interface?

  2. 2.

    Why must emit be written separately for each language?

  3. 3.

    The JavaScript plugin needs its ESTree tree in emit. How does it get it?

  4. 4.

    A Python list of dataclass objects is updated in a loop. What does Viscosity do by default, and why?

  5. 5.

    The Python parser meets syntax it doesn’t support. What does the plugin do?

  6. 6.

    What is true of Viscosity’s layout strategies today?

References

  1. [1] Matthias Braun, Sebastian Buchwald, Sebastian Hack, Roland Leißa, Christoph Mallon, Andreas Zwinkau (2013). Simple and Efficient Construction of Static Single Assignment Form. Compiler Construction (CC 2013), LNCS 7791, Springer, pp. 102–122. doi:10.1007/978-3-642-37051-9_6 (checked 2026-10-31)