YmerNode.Scripts.Compiler (Ymer Node v0.2.1)

Copy Markdown View Source

Turns a script's code into a loaded module tree, or into a refusal that names what is wrong with it.

The parse comes first, and nothing compiles until it passes

A script's modules are VM-global. defmodule Enum in a script would replace the standard library's Enum for the whole node, and defmodule YmerNode.Repo would replace the node's own — for every process, immediately, with no way back short of a restart. So the code is parsed before it is compiled and refused unless every defmodule the parse can see sits under the script's own Script.<Name> subtree. Code.string_to_quoted/2 runs no user code, so the refusal costs nothing.

What it can see is syntax, and that is the limit to state plainly: an alias that rebinds Script, a defmodule wrapped in another form, a qualified Kernel.defmodule/2 and a Module.create/3 call in a module body are all outside it — and a module body runs while it compiles, so compiling code is running it whatever a parse saw. This guard catches the honest mistake before any compile; the boundary against a hostile author is which code is allowed to compile at all, which is script_author's approval and acceptance (YmerNode's Scripts section).

Three shapes are refused there, and each was measured rather than reasoned about:

  • a top-level defmodule outside Script. — including bare defmodule Script, and a three-segment Script.Jira.Client as the top module;
  • a defmodule Elixir.Anything anywhere, nested included. This is the escape: nesting it inside Script.Hex does not scope it, and compiling that string really does define a top-level module;
  • a defmodule whose name is not a literal alias — unquote(name), @attribute, Module.concat(…) — because a name computed at compile time is a name this check cannot read.

The name is derived, never given: Macro.underscore/1 over the top module's last segment, so the row's name, the VM module tree and the references source token are one identity and a unique name implies a unique tree.

Compiling

The previous tree is purged first. Recompiling a loaded module without purging is not an error — it is two redefining module warnings that would ride into the row's diagnostics and read as the script's fault.

The compile file name is script:<name>, which is what makes a run's stack frames legible: a raise inside a script renders as script:hex:3: Script.Hex.go/0 rather than naming a file nobody can open.

The contract is checked here, not by the compiler

A behaviour's missing callback is a warning. A script that uses YmerNode.Script and never defines run/3 compiles, loads, and fails at the first call. So every callback is checked with function_exported?/3 after the compile, and a module carrying no __script_contract__/0 is refused as not having used the contract at all.

The three metadata callbacks are then called — that is user code running inside a write — so the call is bounded by its own task and timeout. A description/0 that loops would otherwise hang a create, and at boot it would hang the node's start.

The compile itself is bounded too

A defmodule body runs its top-level statements while it compiles, so defmodule Script.Boom do Process.sleep(:infinity) end — or an accidental loop written outside a function — never returns from Code.compile_string/2. try/rescue catches a raise and nothing else, so the compile gets its own task and its own deadline, and a compile that outstays it is killed and refused as :compile_timeout.

The bound is separate from the metadata one and larger: a real module's compile-time work — deriving protocols, building a big literal — can legitimately take longer than three callbacks that must answer immediately. It matters more than a caller's patience: this call happens inside the one process that owns every compile, so a compile that never returned would wedge every later read and write, and at boot it would hang the node's start with no log line and no way in.

Summary

Functions

Parses, purges the previous tree, compiles, and reads the contract.

Reads a script's identity out of its code without compiling any of it.

Unloads a script's whole module tree — the top module and everything under it that is loaded right now.

Renders compile diagnostics as one line each — script:hex:3:5: message — which is what describe shows and what a refusal carries.

Functions

compile(code)

Parses, purges the previous tree, compiles, and reads the contract.

On success answers {:ok, map} carrying name, module, every modules the code defined, the contract version, and the description, actions and declarations the module reports, plus any compile warnings as rendered strings. Every failure is {:error, {reason, detail}}.

parse(code)

Reads a script's identity out of its code without compiling any of it.

Answers {:ok, %{name: name, module: module}}, or {:error, {reason, detail}} where reason names the rule the code broke.

purge(top_module)

Unloads a script's whole module tree — the top module and everything under it that is loaded right now.

Scanning the loaded set rather than trusting a remembered list is deliberate: the list can be stale (a previous compile in this VM that nothing recorded), and a module left loaded is one a later compile would warn about redefining.

render_diagnostics(diagnostics)

Renders compile diagnostics as one line each — script:hex:3:5: message — which is what describe shows and what a refusal carries.