There are programs you ship, and there are programs you write to get something done — a rename over a directory, a quick calculation, a check you will run twice and delete. Scripting languages own that second kind, and they own it for one reason: a single command runs the file.
mica --run gives Mica that command, without giving up what a compiler
knows. Every command below is exactly what the released compiler does.
One command
cat > hello.mica << 'EOF'
program Hello;
imp
WriteLn : std;
begin
WriteLn("hello from a script");
end.
EOF
mica --run hello.micahello from a scriptThat is the whole ceremony. The compiler builds the file into a temporary directory at the debug tier, runs it, forwards its exit code, and removes every trace. Nothing appears beside your file, nothing is cached anywhere — a one-file debug compile is milliseconds, so the honest cache is no cache.
Notice what did not appear: no banner, no progress lines. On a clean compile the compiler says nothing at all, so the program owns standard output and pipes stay clean. Warnings still surface — on standard error, where diagnostics belong.
Arguments and exit codes
A script earns its keep through its arguments and its exit code. Everything
after the file on the command line belongs to the program, readable through
the process unit:
cat > args.mica << 'EOF'
program Args;
imp
WriteLn : std;
* : process;
begin
WriteLn("count=%d", ArgCount());
WriteLn("first=%ls", Arg(1));
WriteLn("second=%ls", Arg(2));
end.
EOF
mica --run args.mica alpha betacount=2
first=alpha
second=betaWhen a program’s own arguments begin with a dash, put -- between file and
arguments; everything after it is passed through untouched.
The exit code works the way $? demands: the program’s verdict is the
command’s verdict. Even a runtime failure reports honestly — here a program
that prints one line and then indexes past an array’s bound, which trips
Mica’s always-on bounds guard:
cat > trap.mica << 'EOF'
program Trap;
imp
WriteLn : std;
type
Numbers = array[0..2] of int64;
var
values : Numbers;
index : int64;
begin
WriteLn("before the trap");
index := 5;
values[index] := 1;
end.
EOF
mica --run trap.mica; echo "exit=$?"before the trap
Mica runtime failure: reason=index_out_of_range (12), token_stream_index=40
Mica runtime context: file=.../trap.mica, line=16, column=11
Mica runtime source: values[index] := 1;
exit=1The environment it runs in
A script reads more than its arguments. The process unit carries the
environment the shell handed over, and the program’s own identity beside it —
both are things a program knows about itself that no computation can derive.
cat > editor.mica << 'EOF'
program Editor;
imp
WriteLn, Length : std;
Environment, IsEnvironmentSet, EnvironmentList, VariableList, Halt : process;
const
EditorName = "EDITOR";
Fallback = "vi";
var
editor : string;
list : VariableList;
begin
if IsEnvironmentSet(EditorName) then
editor := Environment(EditorName)
else
editor := Fallback;
list := EnvironmentList();
WriteLn("editor=%ls", editor);
WriteLn("variables=%lld", Length(list));
if IsEnvironmentSet(EditorName) then Halt(0) else Halt(3);
end.
EOF
EDITOR=nano mica --run editor.mica; echo "exit=$?"editor=nano
variables=…
exit=0Reading takes two verbs because there are two questions. Environment
answers the text; IsEnvironmentSet answers whether the name is set at all.
A variable set to nothing and a variable never set are different states, and
a script deciding between them should be able to say which it meant.
| Verb | What it does |
|---|---|
Environment(name) | the text, or the empty string when nothing of that name is set |
IsEnvironmentSet(name) | whether the name is set at all, whatever it holds |
SetEnvironment(name, text) | sets or replaces it, for this program and every child it starts |
RemoveEnvironment(name) | takes it out, and succeeds when there was nothing to take |
EnvironmentList() | every variable, each divided into a name and a text |
ProgramName(), ArgCount(), Arg(i) | the identity the command line gave |
ProcessId(), ParentProcessId() | the numbers the kernel knows the program by |
Halt(code) | ends the program with that status, everything printed flushed first |
The two writers meet the operating system, so they fail on the language’s own
channel: a name that is empty or itself holds = arrives as Einval, the
shapes POSIX refuses. The readers cannot fail at all — an unset variable is an
answer, not an error.
The programs it starts
A script that drives other programs needs more than a system call: it wants to
know what the program said, how it ended, and whether it is still running. The
process unit’s second half is a start record and a child value.
cat > build.mica << 'EOF'
program Build;
imp
WriteLn, ToString, Clear, Append : std;
ProcessStart, Child, Reader, ArgumentList, WiringPipe, Spawn, Wait, ExitCode,
Output, ReadLine, Close, Halt : process;
OsError : posix;
const
Tool = "/bin/echo";
var
start : ProcessStart;
child : Child;
reader : Reader;
line : stringbuffer;
arguments : ArgumentList;
reason : OsError;
more : bool;
begin
Append(address arguments, "compiling unit one");
Append(address arguments, "and unit two");
start.command := Tool;
start.arguments := arguments;
start.output := WiringPipe;
child := Spawn(start) on fail reason do begin
WriteLn("cannot start the tool");
Halt(1);
end;
reader := Output(child);
more := ReadLine(address reader, address line) on fail use False;
while more do begin
WriteLn("tool said: %ls", ToString(line));
Clear(address line);
more := ReadLine(address reader, address line) on fail use False;
end;
Wait(address child) on fail continue;
WriteLn("the tool ended with %d", ExitCode(child));
Close(address child);
end.
EOF
mica --run build.micatool said: compiling unit one and unit two
the tool ended with 0ProcessStart is a plain record, so a start you leave alone behaves the way a
shell would: inherited streams, inherited environment, the parent’s directory.
Fill in what you want changed — WiringPipe on a stream to hold its other end,
an environment list to hand the child its own, a directory to start it
somewhere else.
| Verb | What it does |
|---|---|
Spawn(start) | starts it, searching PATH the way a shell does; fails with the reason it could not start |
Wait(address child) | blocks until it ends and records how; waiting twice is safe |
Poll(address child) | the same question without blocking — the supervisor’s road |
Running, ExitCode, Signal | what the last question learned |
Terminate, Kill | ask it to stop, or end it outright |
WriteLine, CloseInput | speak into its input, then let it see the end |
Output, Errors, ReadLine | read its streams line by line |
Close(address child) | give back the pipe ends the parent still holds |
A child that could not be started at all is different from one that started and
failed: the first is a failure on your channel, the second is a status your
child reports. A program that does not exist fails Spawn with Enoent, and one
that exists but cannot be executed fails it with Eacces, before any child is
there to wait for.
Watching what it started
A script that starts a program often wants to know it is still working — not merely still running. Those are different questions, and only the first one the kernel can answer. The second needs the child to say so, which is what a heartbeat is.
Mica writes that without a select, a timer callback or a signal handler. The child’s output is a stream, because a generator reading its lines is one; the deadline is a second generator joined into the same stream, so a missed beat is an ordinary value; and the policy is yours:
feed := schedule Beats(child) buffer 4; { one value per line the worker sends }
schedule Ticks(budget) into feed; { one value per budget that passes }
watch : for arrival in feed do begin
if arrival = BeatValue then
misses := 0
else begin
misses := misses + 1;
leave watch when misses >= 2; { two ticks with no beat between them }
end;
end;Leaving that loop ends both generators and the child with them, because they belong to the activation that started them. The whole example — one file that starts itself as the worker, so both halves fit on one screen — ships as Supervisor.
One field on ProcessStart is worth knowing before you need it:
ownGroup := True makes the child lead its own process group, so Terminate
and Kill reach everything it started. Without it you end the program at the
top and leave its helpers running — still holding the descriptors they
inherited, which is why a pipe you expect to close stays open.
The flags it accepts
A script that grows past two arguments wants them named. The flags unit
takes the declaration-first shape: the program says what it accepts, one
Parse walks the command line, and the typed readers answer afterwards.
cat > serve.mica << 'EOF'
program Serve;
imp
Write, WriteLn, Length : std;
FlagSet, ArgumentList, FlagError, IntFlag, TextFlag, BoolFlag, Parse,
IntValue, TextValue, BoolValue, Arguments, Help : flags;
Halt : process;
const
PortName = "port";
RootName = "root";
VerboseName = "verbose";
var
cli : FlagSet;
rest : ArgumentList;
reason : FlagError;
begin
IntFlag(address cli, PortName, 8080, "the port to listen on");
TextFlag(address cli, RootName, ".", "the directory to serve");
BoolFlag(address cli, VerboseName, False, "log every request");
Parse(address cli) on fail reason do begin
Write("%ls", Help(cli));
Halt(2);
end;
rest := Arguments(cli);
WriteLn("port=%lld", IntValue(cli, PortName));
WriteLn("root=%ls", TextValue(cli, RootName));
if BoolValue(cli, VerboseName) then WriteLn("verbose=on") else WriteLn("verbose=off");
WriteLn("left over=%lld", Length(rest));
end.
EOF
mica --run serve.mica --port 9000 --verbose site/public notes.txtport=9000
root=.
verbose=on
left over=2The help text is never written — it is generated from the declarations, so a flag that exists is a flag that is documented. A command line the program cannot read fails on the language’s own channel, and the handler above is what a user sees:
mica --run serve.mica --prot 9000; echo "exit=$?" -port int the port to listen on (default 8080)
-root text the directory to serve (default .)
-verbose bool log every request (default false)
exit=2The grammar is the one every Go program already speaks: -name value or
-name=value, one dash or two, a bool set by being named and cleared only by
-name=false, the flag section ending at the first argument that is not a
flag or at a bare --. What is left over is Arguments, in the order it was
typed.
| Verb | What it does |
|---|---|
IntFlag, FloatFlag, TextFlag, BoolFlag | declare a name, its type, its default and the line that explains it |
Parse(address cli) | read the program’s own command line; ParseList reads one you hand it |
IntValue, FloatValue, TextValue, BoolValue | what a flag holds — the command line’s value, or the default |
WasGiven(cli, name) | whether the command line named it, which a default cannot tell you |
Arguments(cli) | everything after the flags |
Help(cli) | the usage text, generated from the declarations |
The set is a value the program declares, so a program can hold two command
lines and a test can parse one it made up. Failures split the way the rest of
Mica does: what the user typed reaches the failure channel (UnknownName,
MissingValue, NotAnInteger, NotAFloat, NotABool), while what the
program spelled — a name declared twice, or a value read at the wrong type —
traps, because no default could hide that.
The file becomes a command
This is the part that surprises people meeting it for the first time: a Mica source file can be a command — indistinguishable from any installed program — and the mechanism behind it is worth two minutes of understanding, because it is the same mechanism behind every script on every Unix system.
What a shebang is. When you execute a file, the kernel reads its first
two bytes. If they are #!, it treats the rest of that first line as the
path of the program that should run this file, and starts that program with
the file’s path appended. That is the whole mechanism. It is a kernel
feature, not a language feature — it is how every bash and python3
script becomes runnable — and the line is called the shebang line.
#!/usr/bin/env mica adds one useful indirection: env looks up mica on
your PATH and runs it. That is the portable idiom scripting ecosystems
settled on, because the script then never hardcodes where the compiler is
installed. Most languages need the GNU-only env -S trick here, to smuggle
a flag like --run into the line; Mica does not, because the bare spelling
mica tool.mica already means “run this” — and the compiler’s source
preparation removes the #! line on every road, line numbers preserved, so
the same file compiles through --compile, runs through --run, and opens
in the editor without a diagnostic on line one.
A tool, step by step. Here is a complete command-line tool — addup, which
adds up whatever numbers follow it. Step one, write the file:
#!/usr/bin/env mica
{
addup — add up whatever numbers follow the command.
}
program AddUp;
imp
WriteLn : std;
* : process;
Val : strings;
var
total, number : int64;
index : int32;
begin
total := 0;
{ every command-line argument is either a number that joins the total or a complaint on the way out }
for index := 1 to ArgCount() do
if Val(Arg(index), address number) then
total := total + number
else
WriteLn("not a number: %ls", Arg(index));
WriteLn("%lld", total);
end.Step two, make it executable — once:
chmod +x addup.micaStep three, it runs:
$ ./addup.mica 12 30
42Step four — the full command experience. Commands do not carry file
extensions, and the bare spelling accepts that too: any existing file counts
as the script. So drop the extension and move it into a folder on your
PATH — ~/bin, which Ubuntu’s login profile puts at the front of PATH from
the next login on, and this shell after the export:
$ mkdir -p ~/bin
$ cp addup.mica ~/bin/addup
$ export PATH="$HOME/bin:$PATH"
$ addup 12 30 -5
37addup is now a command. (A name of its own matters: sum is already a Linux
command, the checksum tool, and the shell finds whichever comes first on the
PATH.) Whoever runs it sees arguments in, answer out, exit
code honest — and a civil reply instead of a stack trace when an argument is
not a number:
$ addup 12 oops 30
not a number: oops
42What they never see is that there is no interpreter behind it. Each invocation compiles the file into a temporary directory and runs the result — compile and run together, beneath the threshold anyone notices — and every compile-time check ran before the first statement did. Your ten-line tool was type-checked, format-string checked, and bounds-guarded on every single invocation. No scripting language gives you that; no compiled language made it this easy before.
And a here-document is a program — --run - reads the source from standard
input:
mica --run - << 'EOF'
program Hi; imp WriteLn : std; begin WriteLn("hi from a here-document"); end.
EOFhi from a here-documentYour libraries come along
If your script lives inside a tree that carries a
mica.project, the run mode
discovers it exactly the way the editor does — the nearest project file up
the directory tree governs — and its encoding and contracts apply. A
script beside your project imports your libraries with no flags at all:
$ cat mica.project
{
"contracts": ["build-lib"]
}
$ mica --run twice.mica # twice.mica says: imp Twice : Numbers;
twice(21) = 42No import paths to configure, no environment to activate. The file your build and your editor already read is the whole configuration — and a here-document typed inside the tree gets the same facts.
The check happens before the run
Here is the part no scripting language gives you. Change args.mica to
print a string with %s — the utf-8 specifier — while the file compiles
under the utf-32 default:
9: WriteLn("first=%s", Arg(1));
^ analyzer error 5184 [9,25]: format string validation error: string format specifier at position 1 must use '%ls' for UTF-32 encoding (found '%s')The script never ran. Elsewhere the matching mistake waits until run time,
for the input that happens to reach the broken line; a Mica script fails on
your terminal, before a single statement executes, with the line and the
fix in the message. Every compile-time check — types, format
strings, unused variables, definite assignment — runs at full strength under
--run, because the default is the debug tier, where checks are maximal and
latency is already unmeasurable.
The mode is deliberately narrow about everything else. It targets the host
architecture, and it combines with eight flags — --optimize, --stdlib,
--memory-class, --tasking, --processes, --runtime-flags,
--stack-size and --remarks. Ask it for anything the build owns and it
refuses in one line:
mica --build somewhere --run hello.micathe flag '--build' cannot be combined with '--run': the run mode owns compilation, linking, and the build directory, and combines only with --optimize, --stdlib, --memory-class, --tasking, --processes, --runtime-flags, --stack-size, and --remarks
run 'mica --help' for the compiler's full flag surfaceAnd when a script outgrows scripting — it happens — nothing needs
rewriting: the same file goes through --compile --link --optimize O2 and
becomes the shipped program it was slowly becoming anyway.
The command-line reference states every rule of the run mode.
Next
- The project file — the few lines that make a script see your libraries.
- The shape of a Mica program — part 2 of the series, if you arrived here directly.
- The starter repository — the container ships everything these pages run.