Zone Plugins
A package can teach Polar new zones. A plugin is a small piece of Rust that reads the entries of a zone and turns them into ordinary Polar code, which is then type-checked like everything else.
What we'll build
We'll write a notes zone for named strings:
module Main
notes
greeting = "hello"
farewell = "goodbye"
functions
main() {
Log.info("#{greeting}, then #{farewell}")
}
exports
main
$ polar run
hello, then goodbye
The plugin turns each name = value line into a
constant: greeting: String = "hello". It's a tiny zone,
but it uses every part of the plugin API.
Step 1: Make a package
Plugins live in
packages. Make a
notes package, and list the zones its plugin defines:
notes/
├── polar.toml
├── plugin/
│ └── lib.rs
└── src/
[package]
name = "notes"
module = "Notes"
[plugin]
zones = ["notes"]
If plugin/lib.rs doesn't exist yet, Polar writes a stub
for each listed zone the first time it builds. If the plugin needs
crates from crates.io, add
dependencies = { regex = "1" } under
[plugin].
For a project, the quickest start is
polar init --zone notes: it writes
polar.toml with the [plugin] section, the
plugin/lib.rs stub and the .polar/ folder,
without building anything.
Step 2: Describe the zone
A plugin implements the ZonePlugin trait from the
polar_plugin crate. First, zone says what
the zone is called, and which built-in zone it sits after:
use polar_plugin::{
Diagnostic, Entry, Expansion, Module, Source, Span, Zone, ZonePlugin, export,
};
struct Notes;
impl ZonePlugin for Notes {
fn zone(&self) -> Zone {
Zone {
keyword: "notes".to_string(),
after: "types".to_string(),
blank_between_entries: false,
}
}
Step 3: Expand entries into Polar
expand receives the zone's entries. Each
Entry has its source text and its tokens. The plugin
builds Polar source for each one, and says which zone the generated
code belongs in:
fn expand(&self, _zone: Span, entries: &[Entry], _module: &Module) -> Expansion {
let mut out = Expansion::default();
for entry in entries {
let name = &entry.tokens[0];
let eq = entry.tokens.get(1);
if !name.is("Lower") || !eq.is_some_and(|t| t.is("Eq")) || entry.tokens.len() < 3 {
out.error(
Diagnostic::error("a note is `name = value`", entry.span)
.with_help("write it like `greeting = \"hello\"`"),
);
continue;
}
let value = Span { start: entry.tokens[2].span.start, end: entry.span.end };
let mut source = Source::new();
source
.from(name.span, &name.text)
.push(": String = ")
.from(value, entry.text(value));
out.emit(source.finish("constants", entry.span));
}
out
}
}
export!(Notes);
Two details make plugins pleasant to use:
-
Source::fromcopies text together with its location. If the generated constant has a type error, the error points at the user'snotesline, not at generated code. -
out.errorreports a problem in the same style as the compiler's own errors.
export! registers the plugin. A package with several
zones passes them all: export!(Schema, Routes, Views).
Step 4: Use it from a project
Any project that depends on the package gets its zones:
[project]
name = "app"
[dependencies]
notes = { path = "../notes" }
The first build compiles the plugin with cargo, inside
the package's .polar/ folder, which Polar manages and
git ignores. Later builds reuse it. A bad entry gets a proper error:
error[POLAR0901]: a note is `name = value`
--> src/main.px:6:3
|
6 | oops
| ^^^^
|
= help: write it like `greeting = "hello"`
Formatting
polar fmt asks the plugin how to print its zone,
through the optional print method. The default
re-indents each entry as written. The
polar_plugin crate also has an
align helper for zones laid out in columns.
Bigger examples
The repository's projects/simple_framework package
defines three zones for web apps. Reading its plugins is the best
next step:
schema
posts in Node
id Id<Post> primary
title String
author_id Id<User> references users
routes
GET /posts -> index
GET /posts/:id -> show
views
card(post: Post)
<article class="post">
<h2>{post.title}</h2>
</article>
-
schematurns each table into a record type, an insert type without the primary key, and an effect withfind,all,insert,updateanddelete. -
routesbecomes an exportedrouter. A:idsegment fills the handler parameter calledid, and anIntsegment that doesn't parse doesn't match. -
viewsis JSX-style markup. Each view becomes a function returningHtml, and every{…}hole is type-checked.