Parse transforms
Using parse transforms
- Requirement: none. Every transform runs in Clause's own
transform host: those Clause's library provides
(
ms_transform,qlc_pt) compiled, project transform sources and.beamfiles withdebug_infointerpreted. Erlang/OTP 29 installed on the host (host OTP) runs only what the transform host cannot (a.beamwithoutdebug_info, an OTP module Clause's library lacks), or every non-library chain when--erl PATHnames it.--erl nonenever runs host OTP code. - Invoke a transform in the source with
-compile({parse_transform, Module}).(or inside a-compile([...])list), or for every module of the command with--parse-transform Module(repeatable; these run first, likeerlc +'{parse_transform,M}'). - Transform modules are found as
.beamfiles on the transform paths, then in Clause's library, then as project sources Clause reads on every build, then among OTP's modules (transform modules). - Precompile a transform that comes from another library (always), or a
project transform the host compiler cannot build with the target's include
directories and defines:
erlc +debug_info -o deps/lib/ebin deps/lib/src/*.erlwith the host OTP (debug_infolets Clause's transform host run it without OTP), then namedeps/lib/ebinwith--transform-pathor intransform_paths. The.beammust come from the host's OTP release; a.beamthere wins over a source of the same module. - What runs it: compiling,
--print-ast,--print-sourceand--print-abstr(which shows the transformed forms);--parse-check,--preprocess-checkand--print-ppdo not.
clau --transform-path ebin -o app app.erl
Clause applies {parse_transform, Module} the way erlc does. It exports the
parsed module as OTP
abstract format forms, runs
the transform module in Clause's transform host (or on an Erlang/OTP 29 installed
on the host), imports the returned forms as the module's new syntax and compiles
that (compiler/src/transforms/). Generated programs never need OTP.
OTP behavior
From pinned lib/compiler/src/compile.erl (transform_module,
foldl_transform, maybe_strip_columns) and observed under OTP 29.1.1:
- Transforms run as the first standard pass: after
eppand parsing, beforeerl_lint, beforeerl_internaladdsmodule_info/0,1andbehaviour_info/1. - The transform list is every
{parse_transform, M}of the compile options (erlc +'{parse_transform,m}'), then of the-compileattributes in form order. A transform named twice runs twice. Before the first runs, those options are removed from the forms: a-compileattribute holding only the option disappears, a list loses the element. Mmust load (code:ensure_loaded/1) and exportparse_transform/2, else the errorundefined parse transform 'M'(no location).M:parse_transform(Forms, Options)gets the forms and the compile options (not the source's-compileoptions):{features, Enabled},report_warnings,report_errors,{cwd, Dir},{outdir, Dir},{i, Dir}per include directory,{d, Name[, Value]}per define, plus any{parse_transform, M}given as an option.- It returns
Forms,{warning, Forms, Warnings}or{error, Errors, Warnings}, where each list is[{File, [{Location, Module, Descriptor}]}]formatted byModule:format_error(Descriptor). An exception is the errorerror in parse transform 'M': ...(compile:format_error/1of{parse_transform, M, {Class, Reason, Stack}}). The next transform gets the previous one's forms; the first error stops the chain. M:parse_transform_info/0returning#{error_location => line}, or the compile option{error_location, line}, strips columns from the forms beforeMruns and from the final forms.- Forms carry
{Line, Column}annotations.eppadds{attribute, {1,1}, file, {File, 1}}first, the same for each included file when it starts, and{attribute, {L,1}, file, {File, L}}when the includer continues at lineL, the line after its-include. Macro definitions and other directives leave no form. The list ends with{eof, Location}, the end of the main file. Preprocessor and parse errors stay in the list as{error, E}forms anderl_lintreports them after the transforms. - OTP 29 forms that
absform.mddoes not list:{attribute, A, native_record, {Name, Fields}}; record names{Module, Name}(qualified) and[](#_) inrecord/record_fieldexpressions;{type, A, record, [{tuple, A, [Module, Name]} | Fields]}for qualified record types;{zip, A, Qualifiers}comprehension qualifiers;{mc, ...},m_generateand the_strictgenerators. erlc +to_abstrwrites the forms after transforms (anderl_lint) toFile.abstr, one~p.term per form;erlc File.abstr(compile:file(F, [from_abstr])) compiles such a file without a preprocessor or parser.
Clause pipeline
- Preprocess and parse the module. Its preprocessor and parse errors and
warnings go to the transforms as
{error, {Location, clause_source, Text}}(warning) forms beforeeof, asepp's do; the forms that failed are left out. They are reported after the transforms' own messages, unless a transform failed, aserl_lintreportsepp's; errors then fail the module. - Collect the transforms in OTP's order:
--parse-transformoptions and the project'sparse_transforms, then-compileattributes. A module without any skips the rest and never starts OTP. - Remove those options from the forms and export them:
{Line, Column}annotations aserl_parseassigns them,epp'sfileattributes, andeof. The predefined functions are not part of the forms yet. - Run the loader once for the module in the transform host, or on the host OTP for the chains it takes.
- Import the returned forms by turning them into tokens located at their
annotations and parsing them with Clause's parser; a
fileattribute switches the file of the following forms as inerl_lint. - Add the predefined functions, then lint, analyze and compile as usual.
The actions that run transforms are listed under using parse transforms.
Interchange
clauand the loader exchange External Term Format files (term_to_binary, version 131, uncompressed): exact floats, UTF-8 atoms, big integers and no text parsing in Erlang. The format itself (tags, layouts, bounds checks) lives once inabi/include/clause/abi/external_term.hpp, independent of any term representation; the runtime'sterm_to_binaryandbinary_to_termshare it (external term format). Clause's compiler term model holds atoms, integers, floats, proper and improper lists, tuples, maps and bitstrings; pids, ports, references and funs are rejected with a diagnostic..abstrtext is thefile:consult/1syntax: one term per form, each ending in..--print-abstrprints each module's forms after transforms in that syntax to stdout, one form per line; a.abstrinput file compiles likeerlc File.abstr. Clause writes canonical text, not OTP's~playout; equal terms are what matter. The forms equalepp:parse_file(F, [{location, {1, 1}}])for every construct oftests/fixtures/transforms/abstract/, includingepp'sfileattributes (an explicit-fileis annotated[{generated, true}, {location, L}]) and aneofcounted from the last-fileof the main file.
Importing forms
Returned forms, and .abstr inputs (positional or project sources), become
tokens located at their annotations and go through Clause's parser, so they get
the same syntax checks as source; operator precedence decides where parentheses
are added. {Line, Column}, a bare Line (shown as file:line:) and property
lists with location and file are accepted; generated is ignored. Line 0,
which OTP's qlc transform gives generated code, takes the place of the node
imported before it. A file attribute switches the file of the following forms;
when that file exists, imported code keeps its debug lines and source comments.
Malformed forms are errors at their annotation (unknown expression bogus,
expected {Name, Arity}, invalid annotation) and the module fails. OTP's
{error, _}/{warning, _} forms that transforms return are formatted by the
loader with Module:format_error/1 (as {Location, clause_message, Text})
and reported with that text; in .abstr inputs they are reported with their
descriptor, which needs OTP to format. clause_source forms are reported by
clau itself. Importing an exported module and exporting it again gives
the same forms.
Host OTP
- When it runs: a chain the
transform host cannot run, or with
--erl PATHevery chain that is not all library transforms. erlis found in order:--erl PATH,erlonPATH, then on Windows%ProgramFiles%\Erlang OTP\bin\erl.exe. Not finding one when a module needs it is an error naming the transform and the module; there is no fallback that skips transforms.--erl PATHnames the hosterlexecutable (erl.exeon Windows) and skips the search. It works in positional and--projectbuilds alike; there is no project manifest key. Giving it twice is the usage error--erl specified more than once. The path is checked only when a module needs a transform: a path that is not an executable is then the errorerl not found: PATH, and a build without transforms never looks at it.--erl noneforbids running host OTP code. A module that names a transform (-compile,--parse-transformorparse_transforms) the transform host cannot run fails with the missing-OTP error, even when the build cache holds its reply. Missing transform paths are still reported first. Use it to prove that a build needs no host OTP. Project transform code still runs, interpreted by the transform host.- The host must run OTP 29 (
erlang:system_info(otp_release)); another release is an error. - The loader runs as
erl -noshell -noinput -pa Dir ... -eval ...(code path directories made absolute) with a time limit of 600 s. Its stdout and stderr (including the transform'sio:formatoutput) are copied toclau's stderr after it ends. A loader that exits without writing a reply, times out or cannot start is an error. - A transform executes arbitrary code with the user's rights, exactly as it
would under
erlc.
Transform host
Clause's transform host runs the transforms Clause's library
provides (ms_transform and qlc_pt among them) without a host OTP. It is the
loader module linked with the whole library and every export kept,
as --shell links it, with clause_transform_loader:main/1
as its entry (compiler/src/transforms/host.cpp,
compiler/src/driver/transform_host.cpp).
- When it runs: by default, for every chain whose
transforms are modules of Clause's library (none of them replaced by a
.beamon the transform paths), project sources or.beamfiles on the transform paths; with--erl PATHonly for chains of library transforms. Other chains go to the host OTP. A library module withoutparse_transform/2is OTP'sundefined parse transform 'M'. - Interpreted transforms: the host runs project sources and
.beamfiles withclause_interp. A source comes as formsclauread; a.beam, and any module a transform calls that the host neither links nor was given, is interpreted from itsdebug_info(abstract_codechunk) when first called, found on the transform paths. A library module replaced by a.beamcannot run there, since the host links the library's own. Interpreted code runs much slower than compiled code. - Built once: the first module that needs it builds it with
clauitself (clau --shell --erl none --no-cache --entry clause_transform_loader:main/1), which takes about a minute on a Debug build. It is kept in<user cache>/clause/transform-host/<clau>-<identity>/(%LOCALAPPDATA%on Windows,$XDG_CACHE_HOMEor~/.cacheon Linux,~/Library/Cacheson macOS).<clau>hashes the path ofclau,<identity>its version, size and modification time and those of its runtime archive and every library file, so a rebuiltclauor an edited library builds a new host; the new build removes the hosts older builds of the sameclauleft. Concurrentclaus wait for the one building it (a lock file beside the directory). - Protocol: the loader's own request and reply files; the host gets the
workspace as its program argument (
clause_transform_host -- DIR). Its replies equal those of OTP's transforms, and the build cache keys them by the host's path. - Not available: a host that cannot be built is the error
File: cannot build the parse transform host (...)with the build's output.
Loader
compiler/src/transforms/loader/clause_transform_loader.erl is project-owned
and embedded into clau; the transform host is built from it
too. Each run writes it and the request into a fresh
temporary directory, clause-transform-* below <build_dir>/tmp
(build directory) or, for positional inputs
without --build-dir, the system's temporary directory, removed after the run;
erl compiles and loads the loader in memory. The request holds the forms, the
options, the transform list, the module's file name, the code path and the
project transform sources as {File, Forms}. With a build directory, the reply and the loader's output are
kept in the build cache and reused while the request
and everything it depends on are unchanged; the host's sticky module list is
kept the same way. The loader:
- checks the OTP release;
- loads the project transform sources: the host OTP compiles their forms in
memory (
compile:forms/2) and loads them, the transform host interprets them; - for each transform: loads it, strips columns as
parse_transform_info/0asks, callsparse_transform/2and formats warnings and errors to{File, Location, Text}withModule:format_error/1; - writes
{ok, Forms, Warnings}or{error, Errors, Warnings}and halts.
Transform modules
A transform module is looked up in this order:
- Precompiled
.beamfiles in the directories of--transform-path DIR(repeatable) and the project optiontransform_paths, which go on the loader's code path before OTP's. This is the only way to use a transform that comes from a dependency library: build that library with the host OTP first (for exampleerlc +debug_info -o deps/lib/ebin deps/lib/src/*.erl); Clause never builds dependencies. The.beammust come from the host's OTP major release. - Clause's library (
ms_transform,qlc_pt, ...), run in the transform host when every transform of the module is there. - The current project's sources: a
Module.erlin the batch or itssource_search_paths, together with the project modules it calls by name (found the same way).claupreprocesses and parses each with the target's include directories and defines, runs its own transforms and sends its forms to the loader on every run. The search passes over sources thatignore_dirsorignore_modulesleave out, as the module search does. Clause looks for these sources once per batch and keeps them in memory; the loader still compiles them for every module the transform runs on, so precompile a large transform to save that time (withdebug_infofor the transform host). Modules the host loads from its sticky directories (kernel,stdlib,compiler, such asms_transformorqlc_ptwhen OTP's sources are on a search path) cannot be reloaded, so the host's own modules are used: their sources, and the modules only they call, are not looked for or compiled. Clause asks the hosterlfor these modules once per invocation. - OTP's own modules from the host's code path, for those Clause's library
does not provide. OTP ships
erl_id_transonly as an example source, not as a module.
Reading a project transform can fail where its real build succeeds: headers of
dependencies, a transform of the transform that cannot run, or macros that only
another build tool defines. Clause then reports its own errors against the
transform's source with the hint to precompile it into a .beam with the host
OTP and pass its directory with --transform-path; the host OTP's compiler
errors for its forms get the same hint. A project transform is also
compiled by Clause as an ordinary module when it is part of the batch; its own
calls (erl_syntax, erl_parse, ...) may then need library modules Clause does
not ship, so keep transforms out of a target's sources when only the transform
needs them.
Diagnostics
| Situation | Message |
|---|---|
| No host OTP | File: parse transform 'M' needs Erlang/OTP 29 on the host; pass --erl |
--erl names no executable | File: erl not found: PATH |
| Wrong OTP release | parse transforms need Erlang/OTP 29 on the host, found N |
| Transform not found | File: undefined parse transform 'M' (OTP's text) |
| Transform raised | File: error in parse transform 'M': ... (OTP's text) |
| Transform warning or error | File:Line:Column: Text as returned |
| Transform source does not compile | its messages, then the precompile hint |
No host OTP, .beam without debug_info | File: parse transform 'M' in PATH has no debug_info; pass --erl to run it on the host Erlang/OTP |
| A transform path does not exist | File: parse transform path is not a directory: DIR |
A .beam on a transform path does not load | File: cannot load parse transform 'M' from PATH: Reason |
| Loader failed, timed out or wrote no reply | parse transform loader failed: ... |
| The transform host cannot be built | File: cannot build the parse transform host (...) and the build's output |
| Invalid returned forms | the parser's diagnostic at the form's location |
Options
| CLI | Project ([targets.options]) | Meaning |
|---|---|---|
--parse-transform M | parse_transforms = ["m"] | Apply M to every module, before its own -compile transforms (CLI ones first) |
--transform-path DIR | transform_paths = ["deps/*/ebin"] | Code path for precompiled transform .beam files; patterns as in source_search_paths |
--erl PATH or none | — | Host erl executable, which then runs every chain that is not all library transforms; none never runs host code (host OTP) |
--print-abstr | — | Print the forms after transforms instead of compiling |
A project that uses its own transform and a precompiled one from a dependency:
[[targets]]
name = "app"
sources = ["src/*.erl"]
[targets.options]
source_search_paths = ["transforms"] # transforms/pt_rewrite.erl, compiled for each build
parse_transforms = ["pt_dep"] # applied to every module of the target
transform_paths = ["deps/*/ebin"] # deps/ptdep/ebin/pt_dep.beam, built with erlc first
Clause