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.
- 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.
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.
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:
const ps = [];for (let i = 0; i < 1000; i++)ps.push({ x: i, vx: 1 });for (const p of ps)p.x += p.vx;
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: '…' },
];
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.
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
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));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,lowerandemit. Parsing lives insidelower. lowerreturns VIR and annotations; the plugin can add private data to that object, andemitgets it back.- Syntax work (parse, lower, emit) is per-language; work on data (analysis, planning) is shared, on VIR.
- The core reads
module.languageas 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.
Which of these is part of Viscosity’s
LanguagePlugininterface? - 2.
Why must emit be written separately for each language?
- 3.
The JavaScript plugin needs its ESTree tree in
emit. How does it get it? - 4.
A Python list of dataclass objects is updated in a loop. What does Viscosity do by default, and why?
- 5.
The Python parser meets syntax it doesn’t support. What does the plugin do?
- 6.
What is true of Viscosity’s layout strategies today?
References
- [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)