polar

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::from copies text together with its location. If the generated constant has a type error, the error points at the user's notes line, not at generated code.
  • out.error reports 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>
  • schema turns each table into a record type, an insert type without the primary key, and an effect with find, all, insert, update and delete.
  • routes becomes an exported router. A :id segment fills the handler parameter called id, and an Int segment that doesn't parse doesn't match.
  • views is JSX-style markup. Each view becomes a function returning Html, and every {…} hole is type-checked.