Releases: bradcypert/plum
Release list
plum v0.0.28
Plum is a small, statically typed, compiled language.
A Plum project can now depend on another one.
Path dependencies
A project says what it depends on in a plum.pkg file at its root:
Package {
name: "myapp",
version: "0.1.0",
deps: [
Dep { name: "semver", path: "../semver" },
],
}
A dependency is an ordinary project: a directory of .plum files that
builds, tests and type-checks on its own. Nothing is published and
nothing is fetched.
There is no new build step and no new command. plum check, run,
build, test and doc all read the dependency's source, and the
language server sees it too, because the change is one function: a
dependency is source, and the compiler already knew how to read source
from a directory. The only new question was which directories.
pub means the same thing across a package boundary as it does across
a module boundary. A dependency's unexported names are unavailable, not
merely undocumented.
Dependencies of dependencies come along, resolved relative to the
manifest that names them. Two packages that depend on each other
terminate rather than recursing.
A project with no dependencies needs no manifest at all. plum new
still writes exactly one file.
A manifest is data, not a program
plum.pkg is read by Plum's own lexer and parser. That is why there is
no second configuration format to learn, and it is also the whole risk:
setup.py, build.rs and build.zig each began as configuration and
became programs, because a config file written in a general-purpose
language invites computing in it, and then the tool has to run the
file in order to read it.
Two things were being conflated there, and only one is dangerous.
Reusing a parser to read data carries no risk: text goes in, a tree
comes out, nothing executes. Making the file look like a program carries
all of it. So plum.pkg has no let, no declaration, no main, and is
deliberately not a .plum file. It holds a bare value, in a file
nothing will ever compile.
And the shape is enforced rather than trusted. Anything that is not a
string, a number, true/false, an array or a struct literal is
rejected:
plum.pkg: a manifest is data, not a program, and a function call is not data.
Only strings, numbers, `true`/`false`, arrays and struct literals are allowed.
String interpolation is caught by the same rule without needing its own
case, because an interpolated string parses to a concatenation. Text
after the value is rejected rather than ignored. An unknown field is an
error, so dependencies: written for deps: says so instead of quietly
building a project with no dependencies.
There is no build file, and that split is the point. plum build
knows how to build a Plum project because there is only one way to build
one, and nothing in a manifest can change that.
Where a library's code goes
A dependency contributes its module subdirectories. Source at a
dependency's root is an error, so a library is laid out one level deeper
than you might first write it:
semver/
plum.pkg declares that it is called `semver`
semver/ module `semver`
compat/ module `compat`
The root module is where main lives and its names are unqualified, so
a package may not put anything there. More usefully: a module is named
by its directory, chosen by the library's author, and it is the same
name whether the library is built alone or used from somewhere else.
That is what lets compat/compat.plum say use semver; and keep
working in both.
This rule replaced a worse one within a day of shipping it, and the
first real example is what found the problem. The details are in
issue #36; the short
version is that the previous rule let a consumer rename a library's
own module, and gave a library two different module layouts depending on
who was building it.
examples/packages/ is the whole thing: two
projects, a library and something that depends on it, about a hundred
lines with a README.
What is not here
Versions are recorded and not used. There is no registry, no fetching,
no lockfile and no resolver, so a dependency is a directory you already
have. Those are separable and tracked on
#36.
The compiler's own bootstrap is unchanged and stays that way: a seed,
and no network.
Also in this release
bootstrap/pkg-check, 27 checks. Half assert that dependencies
resolve; half assert the rejections above, because the risk to a rule
like "a manifest is data" is not a user hitting it once but a
maintainer relaxing it twice over two years.bootstrap/example-sweepnow understands a library. A directory under
examples/with nomain.plumis type-checked and must be named by
some example'splum.pkg. It used to be skipped in silence, which is
the exact failure that harness was written to stop.INSTALL.md's macOS example uses a glob rather than a version number.
The number had said0.0.7for nineteen releases.
plum v0.0.27
Plum is a small, statically typed, compiled language.
Plum has a website, and the compiler wrote most of it.
plumlang.org
Prose, a full API reference, and search: plumlang.org.
The reference on it is not written by hand and not checked in. It is
generated by the compiler at that commit, from the standard library's
own source, so it cannot describe a library that no longer exists. The
prose pages are generated from the Markdown in the repository, so each
document has one copy and reads correctly in both places.
plum doc
The same command that produced the reference works on your code:
plum doc my-project -o docs # Markdown, one page per module
plum doc my-project -o docs --html # a browsable site, with searchThe HTML output is self-contained: no build step, no network, no CDN.
Open it from a folder on disk and search still works, because the index
is a script the page loads rather than a file it fetches.
Documentation comes from /// comments. They are kept as trivia on
the token stream, so they attach to declarations without appearing in
the AST and nothing downstream had to learn about them. plum lsp hover
shows them too.
The standard library documents itself
Every public declaration now carries documentation, written on the
declaration rather than in a list somewhere: 335 declarations, none
undocumented. It goes through the same code path your project uses,
and bootstrap/check-docs fails if docs/stdlib/ falls behind the
compiler.
The README used to list the library by hand. It was accurate, and
nothing checked it, which is a promise that keeps for exactly as long as
somebody keeps remembering.
plum highlight
plum highlight src/main.plum # Plum source as marked-up HTMLEvery static site generator ships a syntax highlighter, and none of them
ships one for a language this young. The usual answer is to write one: a
keyword list and some regexes, maintained separately, wrong about the
corners. Bitwise operators landed in 0.0.26; that highlighter would
already be wrong about ^.
This is the real lexer instead. It works because the token stream is
lossless, so walking it and wrapping each token in a span gives the file
back with markup, and that has a property no regex highlighter can
claim: strip the tags, undo the escaping, and you get the source back
byte for byte. bootstrap/highlight-check asserts it over 294 files,
including the compiler's own 69KB parser. Code that people copy and
paste cannot be silently corrupted by the thing that coloured it.
Source that does not lex renders plain rather than not at all, so an
illustrative fragment still shows up.
The README is a front door again
It was 1,439 lines. It is now 88. The language tour, the module system,
the tooling and the running instructions moved into their own documents,
published as pages on the site and readable on GitHub.
VISION.md was reconciled with reality while this happened. It still
claimed the compiler was written in Rust, which stopped being true on
2026-08-25.
Also in this release
- The compiler runs its own subprocesses through
Process.runrather
than shelling out, which is one fewer place the toolchain depends on a
Unix shell being present. - A quadratic in
lexer.token_text: it split the whole source into
characters to answer a question about one token. Harmless for its
original caller and ruinous for anything walking the stream. New
token_text_at/trivia_before_attake the split source. 65s to
0.69s on the compiler's own parser. - A closure capture could be offered as a memory-reuse candidate and
released twice. Found by writing a JSON decoder library in Plum. bootstrap/doc-checkandbootstrap/check-doc-namesnow derive their
file lists from what the site publishes, so a new document cannot be
added without its snippets being compiled and its names checked. That
took name checking from two files to ten and immediately found six
unchecked names.
plum v0.0.26
Plum is a small, statically typed, compiled language.
A Terminal module, and cleanup that a crash can no longer skip.
Cleanup now runs on panic
A handle releases when its value dies — on a normal return, at the end
of a block, when an Err is returned. Plum has no early return, so
those are every path a function has except one: a panic, which does
not unwind.
That was not untidiness for the operating system to sweep up:
started true
parent dies here
parent exit=1
child survived the parent's panic: YES
A Process.Child outlived its parent and kept working, contradicting
its own documented contract. The OS reclaims file descriptors and
memory. It does not kill your grandchildren, remove your lock file, or
take your terminal out of raw mode.
Live handles are now released on the way out — on a panic, on
Os.exit_with, and on returning from main — in LIFO order, the same
order a scope releases in. Nothing to opt into: if you have a handle,
this already applies to it.
Terminal
use Terminal;
Terminal.is_tty(Stdout) // per stream: Stdin, Stdout, Stderr
Terminal.size() // Result[Size, String] — { cols, rows }
Terminal.write(text) // no newline, no flush
Terminal.flush()
let raw = Terminal.enter_raw()?;
let screen = Terminal.enter_alt_screen()?;
let cursor = Terminal.hide_cursor()?;
The three modes are handles, so the terminal is restored on every exit
path, including a crash. That is the machinery a terminal program
otherwise writes in C, and it is the first thing in the language to
depend on the panic cleanup above.
They release LIFO, so the cursor returns before the screen is given up
and the screen before the mode is restored — the right order, arranged
by nobody. Each works without the others.
Entering the same mode twice is counted, not refused: the first
entry saves the state, the last release restores it, so a library and
its caller can both ask without knowing about each other.
Three things worth knowing before writing a terminal program:
is_ttyis asked per stream, because the answer differs. A
program in a pipeline routinely has a terminal on stderr and a pipe on
stdout, and asking about the wrong one is how progress bars end up in
log files.- Raw mode turns Ctrl+C into a byte (0x03) rather than a signal.
That is what raw mode means everywhere, and here it is also what keeps
cleanup working — a default SIGINT ends a process without running
any cleanup, so signals left enabled would hand back a broken
terminal. Your program is responsible for noticing 0x03 and quitting. - Every mode change refuses when there is no terminal. Escape
sequences written into a pipe are not invisible, they are corruption. sizepolls. POSIX signals a resize withSIGWINCH, Plum has no
signal handling, and comparing the size between iterations of an event
loop costs one syscall against a redraw. It works the same way on
Windows, which has no such signal at all.
Semantic key events — Key.Up, Ctrl+C — are deliberately not here.
That part is pure Plum with no C in it, which makes it the piece most
easily copied into a program and the one where a frozen API would hurt
most; it is tracked separately.
Windows needs Windows 10 or later, so the console can deliver the same
escape sequences a POSIX terminal does.
Also
bootstrap/mem-check's ceilings are per platform now. macOS costs about
1.5x Linux for the same work — likely 16 KB pages against 4 KB, so RSS
is not a comparable quantity — and it varies by 20 MB between runs where
Linux does not vary at all. A single shared ceiling was measuring which
runner a job landed on.
plum v0.0.25
Plum is a small, statically typed, compiled language.
Bitwise operators — the language had none — and control over how numbers
render inside string interpolation.
Bitwise operators
a & b a | b a ^ b a << n a >> n ~a
Until now & did not even lex. That ruled out bit flags, binary
formats, hashing, and every modern random number generator — Rng uses
a 1988 combined generator because PCG and xoshiro were unreachable.
Also Int.shr_logical (an unsigned right shift, since >> is
arithmetic), Int.count_ones, and the formatting below for reading the
results.
Two places this deliberately differs from C.
Bitwise binds tighter than comparison, so a & b == 0 means
(a & b) == 0. In C it means a & (b == 0) — a mistake Ritchie
described as unfixable once code depended on it. Nothing depended on it
here.
Shifts bind like multiplication, as in Go, so 1 << n + 1 is
(1 << n) + 1 rather than 1 << (n + 1).
Shift counts are defined for every value. A count of 64 or more
gives 0 for << and a sign fill for >>, so -1 >> 99 is -1. A
negative count stops the program, alongside division by zero and
integer overflow.
| is both the closure delimiter and bitwise-or, and they never
collide: a closure can only start where an expression is expected, and
| can only be an operator where one has ended. Array.map(xs, |x| x | m)
parses with no ambiguity.
Formatting numbers
String interpolation already existed, so building output was never the
problem:
println("${user} ${n} items")
What was missing was control over how a number renders inside one.
Float.to_fixed(1.0 / 3.0, 2) // "0.33"
Float.to_fixed(19.999, 2) // "20.00"
Int.to_hex(255) // "ff" — signed: to_hex(-255) is "-ff"
Int.to_binary(10) // "1010"
Int.to_octal(64) // "100"
Int.to_radix(1295, 36) // "zz"
Int.to_bits(5) // 64 binary digits, two's complement
Int.to_hex_bits(255) // "00000000000000ff"
String.pad_center("hi", 8, ".") // "...hi..."
to_radix is signed and reversible — to_radix(-255, 16) is
"-ff", which reads back. to_bits is the bit-pattern view instead:
to_binary(-1) is "-1" while to_bits(-1) is sixty-four ones. Both
answer different questions.
Float.to_fixed gives the same answer on every platform, which
took more work than expected. snprintf is correctly rounded
everywhere and does not agree across platforms: glibc rounds exact ties
to even, Microsoft's CRT rounds them away from zero, so to_fixed(2.5, 0)
was "2" on Linux and "3" on Windows. The rounding is now applied to
the digits rather than left to the C library.
Ties round half to even — IEEE 754's default, and unbiased, since
rounding every tie away from zero accumulates. Note that Float.round
rounds half away from zero: that pair is not an inconsistency, it is
what C, Python, Rust and Java all do, because round is arithmetic and
formatting is rendering.
Also
examples/asteroids no longer declares sin and cos in its own
extern "C" block beside a hand-typed 3.14159265358979; it uses the
Float trigonometry added in 0.0.24.
Internally, about 275 chains of .concat(...) in the compiler became
interpolation. Verified by diffing the emitted LLVM IR for every
execution fixture before and after — 93 of 93 byte-identical — so the
change is provably invisible.
plum v0.0.24
Plum is a small, statically typed, compiled language.
Child processes that do not block, stdin reads with a deadline, and the
rest of the numeric surface — trigonometry, logs, constants, and a
seeded generator you can replay.
Child processes, without blocking
Process.run waits for the child to finish, which is right for a build
step and wrong for anything that has to stay responsive meanwhile.
use Process;
use Time;
let child = Process.start(opts)?;
match child.poll() { // never blocks
Ok(None) => draw_spinner(),
Ok(Some(res)) => finish(res),
Err(e) => report(e),
}
child.wait_timeout(Time.seconds(5)) // None = still going, untouched
child.terminate() // SIGTERM
child.kill() // SIGKILL
child.pid()
Child is a handle, so a child whose handle dies is killed and
reaped. That is deliberate and worth knowing before you rely on the
other behaviour: once the value is gone nothing can poll the process,
wait for it, signal it, or read its output, so leaving it running would
leak something the program can no longer name — along with the temp
files it is still writing to.
Output is readable only once the child has exited, and then any number
of times. wait on a finished child returns the same result rather than
failing, so it is safe after a poll that already said Some.
It is start, not spawn, because spawn is a keyword.
On Windows, terminate is kill — there is no SIGTERM, so a child
that would have cleaned up on a polite request does not get the chance.
Reading stdin with a deadline
use Os;
use Time;
match Os.read_stdin_line_timeout(Time.millis(200)) {
Ok(Got(line)) => handle(line),
Ok(Eof) => stop(),
Ok(TimedOut) => redraw(),
Err(e) => report(e),
}
Also Os.read_stdin_timeout(max, duration) for bytes.
Three outcomes, in a type with three names. Option was already spent:
read_stdin_line uses None for end of stream, and telling that from a
blank line is the distinction it exists for.
The timeout bounds the whole call, not just the wait for the first
byte — and a partial line survives it. Bytes already read stay buffered,
so a call that times out mid-line loses nothing and the next one
continues where it stopped. Bounding only the first byte is what most
such APIs do; it passes every test written against a terminal, where a
line arrives at once, and hangs past its deadline on a pipe.
Trigonometry, logs, and constants
Float.sin(x) Float.cos(x) Float.tan(x)
Float.asin(x) Float.acos(x) Float.atan(x)
Float.atan2(y, x)
Float.log(x) Float.log2(x) Float.log10(x) Float.exp(x)
Float.pi() Float.tau() Float.e()
Float.radians(deg) Float.degrees(rad)
Angles are radians. atan2 takes (y, x), the order libm, Go, Python
and Java all use, and knows which quadrant the point is in — which
atan(y / x) cannot.
examples/asteroids now uses these instead of declaring sin and cos
in its own extern "C" block beside a hand-typed 3.14159265358979.
Seeded random numbers
Float.random reads a process-global generator seeded from the clock,
which is right for "different each run" and useless for a test or a
replay.
let r = Rng.from_seed(42);
let (r1, roll) = Rng.int_range(r, 1, 7); // 1..6 — upper bound EXCLUDED
let (r2, f) = Rng.float(r1); // [0.0, 1.0)
let (r3, deck) = Rng.shuffle(r2, cards);
let (r4, pick) = Rng.choice(r3, options); // Option[T]
Every call returns the next generator alongside the value rather than
mutating in place, so a generator is as ordinary a value as an Int —
and the same seed replays exactly, on every platform and every run. A
Ref[Rng] is the opt-in for in-place update.
int_range uses rejection sampling rather than a modulo, so small
ranges are not biased toward their low end. Any Int is a legal seed,
including 0 and negatives.
Not for cryptography. It is a statistical generator — good for
games, replays and tests — and its entire state is recoverable from two
outputs.
A runtime declaration no longer breaks your extern "C"
If your program declared a C function the compiler's runtime also uses,
it did not link:
error: invalid redefinition of function 'sin'
This was always possible — sqrt and pow have been declared by the
runtime for a long time — but the trigonometry above would have made it
common, since sin and cos are exactly what a program declares for
itself. A duplicate declaration is now dropped rather than emitted, so
your extern "C" block can name whatever it needs to.
plum v0.0.23
Plum is a small, statically typed, compiled language.
Duration and a monotonic clock, select arms that stop waiting, and
JSON decoding that says which field disagreed — plus a memory-corruption
fix in the compiler that has no workaround short of upgrading.
A closure inside a match arm could corrupt the heap
Fixed. This is the reason to take this release even if none of the
features below matter to you.
A closure captures its whole enclosing environment, so a closure cell
can hold values its body never mentions. The compiler's last-use pass
did not know that: inside a closure body it still had the enclosing
function's locals in scope, and when one of them was not read again it
offered that value's cell as somewhere to build a new one. But a
captured value belongs to the closure, not to the frame running its
body — so the body released a reference it never held, and the closure's
own cleanup released the same value again later.
The shape that hits it is ordinary:
let found = entries[0].value;
Result.map(inner_run(found, p), |x| Some(x))
|x| Some(x) never names found, and that is exactly why the pass
thought found was free. Symptoms were a malloc(): unaligned tcache chunk abort or a wrong value, on the SECOND call, some distance from
the code at fault. Values kept alive only by a static string literal
never showed it, because releasing an immortal value twice is free.
Programs with no closures compile to identical output.
bootstrap/exec_corpus/closure_capture_reuse pins it. Worth saying what
missed it: 149 corpus fixtures under AddressSanitizer with leak
detection, and the bootstrap fixed point — the compiler simply does not
write that shape anywhere in its own source. It was found by writing the
JSON module below.
Decoding JSON
json_parse gives back a faithful JsonValue. Getting a real type out
of one meant matching JsonObject, scanning an Array[JsonEntry] by
hand, and matching JsonString — once per field, and with the field's
name gone by the time anything could report a failure.
use Json;
struct Repo { name: String, stars: Int, owner: Owner, license: Option[String] }
let repo (): Json.Decoder[Repo] =
Json.map4(
Json.field("name", Json.string()),
Json.field("stars", Json.int()),
Json.field("owner", owner()),
Json.field("license", Json.nullable(Json.string())),
|n, s, o, l| Repo { name: n, stars: s, owner: o, license: l })
Json.decode_string(repo(), text) // Result[Repo, String]
Decoders compose, and the composition is what carries the path:
expected String at data.repos[1].name
no field `admin` at owner
expected a whole Number at stars
Nobody threaded that string. at(["data", "repos"], index(1, field("name", string()))) is nested fields, and each one extends the path the
decoder inside it reports against.
Present, absent and null are three different states and get three
different answers. field requires the key; nullable permits its
value to be null; optional_field accepts either. int() refuses
41.5 rather than truncating it.
The rest: string bool int float value null_as, list, map through
map6, succeed, fail, and_then, one_of, decode.
This is a library, not syntax — a generic struct with a closure field,
compiled by the same compiler as everything else. A decoder that misreads
a document is a COMPILE error at the line that misreads it, which is the
thing a path-string API cannot do.
Duration, sleep, and a monotonic clock
Time had one function, Time.now(), in whole seconds. Anything wanting
to wait, or to measure how long something took, had nothing to use.
use Time;
Time.sleep(Time.millis(250))
let t0 = Time.instant();
run_it();
Time.since(t0).as_millis()
Duration is a type rather than a number, so Time.sleep(500) does not
compile and cannot mean milliseconds on one line and seconds on the
next. Built with nanos micros millis seconds minutes hours zero, read
with as_nanos as_micros as_millis as_seconds, combined with add sub scale negate, compared with lt le gt ge min max compare. Durations
can be negative — between a later and an earlier instant is the
negation of the other order, which is more useful than a saturating zero.
Time.instant() reads a MONOTONIC clock, which never moves backwards
and is unaffected by the system clock being set. It is the one to
measure with. Time.now() and Time.now_millis() remain the wall
clock, which is the one to timestamp with. The origin of an Instant is
deliberately meaningless: two of them are only ever subtracted.
== works on both because it is structural; ordered comparison is
Duration.lt and friends, since < is defined on Int, Float and
String and on nothing else.
select stops waiting forever
select could multiplex channels but could only block, so "take one if
something is ready" and "wait 200ms" both needed a hand-rolled loop.
select {
n = rx => handle(n),
else => "nothing ready",
}
select {
n = rx => handle(n),
Time.millis(200) => "timed out",
}
Both compile to the same call: else is a timeout of zero. No new
keyword — a timeout arm is an expression of type Duration where a
channel would be, and else was already a keyword.
Also
plum test now runs tests in submodules, not only in a project's root
module, and reports them by qualified name — shapes.area_is_positive
rather than area_is_positive. A project whose tests sat beside the
code they cover was silently running none of them.
plum v0.0.22
Plum is a small, statically typed, compiled language.
Process identity — argv, the working directory, stdin and stderr — plus
two language-server fixes for macOS and Windows.
0.0.21 was never published. Its release job failed while uploading
one platform's artifact, and the commit it tagged still had the Windows
bug below. If you are on 0.0.20, this is the upgrade.
The language server, on macOS and Windows
Both bugs arrived in 0.0.20 and both were invisible on Linux.
On macOS, an error in one file of a project was attributed to a
temporary file rather than the file it is in — so the editor underlined
nothing and pointed somewhere you have never opened. The server checks
an unsaved buffer by copying the project to a scratch directory, and it
recognises a diagnostic from that copy by matching the directory as a
prefix. One side of that comparison had been normalized and the other
had not. It only showed where the temporary directory needed
normalizing, which is why macOS saw it and Linux did not: macOS sets
TMPDIR with a trailing slash.
On Windows, hover, completion, go-to-definition and cross-file
diagnostics all failed together. A file: URI always uses /, while
every path the compiler reports uses the platform separator — and the
two met without being reconciled, so the server handed a child process a
project directory spelled one way and a file path spelled the other.
Nothing matched. Both conventions are now converted at the boundary
where they meet.
bootstrap/lsp-smoke sets TMPDIR with a trailing slash itself now, on
every platform, so the shape that hid the first bug is exercised
everywhere rather than only where it happens to occur.
Process identity
use Os;
Os.cwd() // Result[String, String]
Os.chdir(path)
Os.home_dir() // Option[String] — $HOME, or %USERPROFILE%
Os.read_stdin_line() // Result[Option[String], String]
Os.read_stdin(4096) // Result[Bytes, String]
Os.write_stderr("...")
None from read_stdin_line is end of stream and Some("") is a blank
line, and keeping those apart is the point. The shims the language
server has always used answer "" to both, so a filter reading until
end of input stopped at the first blank line — wrong in a way that only
shows up on real data. A final line with no trailing newline is an
ordinary line rather than something to drop.
Os.read_stdin hands back Bytes, because standard input carries
whatever was piped into it. An empty result is end of stream; a short
one is data, since a pipe gives you what it has.
Os.write_stderr is not println, which is stdout. A diagnostic in the
same stream as the program's output cannot be separated from it by
whoever is reading.
Os.home_dir() reads the environment and returns None when it is
unset, rather than guessing a path from a username — a guess that is
usually right is the worst kind of wrong for a directory a program is
about to write to.
args() is documented
It has existed since the beginning, is used by this compiler and by the
test corpus, and appeared in STDLIB.md nowhere — under a heading
promising that every function is listed by construction.
It was not alone: chars_of and panic_raw were missing for the same
reason. The reference is generated by reading declarations, and these
three are implemented by the compiler rather than declared anywhere it
could see. All three are listed now, and a check fails the build if
another one is added without being.
Upgrading
Nothing that compiled under 0.0.20 fails under 0.0.22.
Os.cwd, chdir, home_dir, read_stdin_line, read_stdin and
write_stderr are new names in Os. If you were reaching into
native_stdlib with your own extern "C" block to read standard input,
that still works and is no longer necessary.
plum v0.0.20
Plum is a small, statically typed, compiled language.
Plum can work with bytes.
Bytes, and which type is the special case
Every I/O path was text and whole-value: Os.read_file moved a whole
String, HTTP bodies were String, and Net.read stopped at a NUL.
That ruled out images, gzip, protobuf, and any C ABI that is not text.
Bytes is a byte buffer, and String is now documented and implemented
as bytes carrying a UTF-8 invariant — not the other way round:
let raw = Bytes.from_string("hi") // total: every String is bytes
match raw.as_string() { // fallible: not every Bytes is a String
Ok(text) => println(text),
Err(e) => println(e),
}
"é".as_bytes().len() // 2 -- bytes
"é".char_len() // 1 -- characters
Indices are bytes, so Bytes.slice can split a multi-byte character in
half — precisely what String.slice refuses to do. There is no
.to_string() on Bytes: bytes are not text, so to_hex renders and
as_string decodes, and which you want is your decision.
Binary I/O follows it. Os.read_bytes / write_bytes / append_bytes
round-trip anything, embedded NULs included, and Net.read_bytes /
write_bytes are binary-safe sockets that keep three outcomes apart:
data, a clean peer close, and a real error. The existing String calls
are unchanged and remain the text convenience.
Files that close themselves
match Os.open(path, Mode.Read) {
Err(e) => println(e),
Ok(f) => match f.read(4096) { // Result[Bytes, String]; empty is EOF
Ok(chunk) => println(chunk.len().to_string()),
Err(e) => println(e),
},
}
There is no close in that example and nothing leaks. File is a
handle: a type whose value owns a native resource, and whose death
runs the cleanup. f.close() still exists and still returns a Result,
because fclose is where a buffered write reaches the disk and so where
a full one is discovered — call it when the error matters, rely on the
handle when it does not.
Cleanup runs at the end of the block that introduced the value, in
reverse order of acquisition, and costs nothing at a tail call: a
tail-recursive loop that opens a resource per iteration runs in constant
stack and still closes all of them.
You can declare your own:
extern "C" { fn terminal_restore(h: Int); }
handle RawMode { on_drop: terminal_restore }
handle is a contextual keyword, so existing code using handle as an
identifier is unaffected.
Three new modules
use Encoding; — hex, base64 (standard and URL-safe), and RFC 3986
percent-encoding, all on Bytes. Not a crypto suite.
use Url; — parse, stringify, request_target, and query
access. Parsing is not connecting: https:// parses fine whether or not
the client can speak it yet. Http uses it now instead of its own
hand-rolled parser, which is why http://user@host/ is refused rather
than quietly connecting to a host named user@host.
use Path; — join, dirname, basename, stem, extension,
clean, is_absolute. Lexical only; it never touches the filesystem.
Joins with \ on Windows and accepts both separators as input. The
compiler uses it for its own paths, which is how three hand-rolled
copies of dirname came to be deleted.
Os also grew exists, stat, file_size and mtime. Note the
deliberate split: Os.exists answers Ok(false) for a missing path and
everything else returns Err, because "does this exist" and "can I see
whether this exists" are different questions — a permission error is not
a silent no.
Your debugger knows your source
Debug builds carry DWARF line tables for Plum source, so gdb, perf
and addr2line name the file and line you wrote rather than a mangled
symbol:
plum_codegen_cg_parse_std -> codegen/stdlib.plum:112
Line tables only — a debugger can step, break on a line and attribute a
profile; it cannot print a local. Release builds carry none.
Also
Process.runtakes argv directly, with no shell between you and the
program.plum help, and--release/--tracebuild modes.Float-to-Intconversions behave as documented.- HTTP requests now send
Host: host:portwhen the port is not the
scheme default, per RFC 7230. Previously they sent a bare host, which
a name-based virtual host reads as a different origin. - STDLIB.md is up to 222 entries across 23 sections, still
generated by the compiler and checked against it on every build.
Upgrading
Nothing that compiled under 0.0.19 fails under 0.0.20.
Bytes, File, Mode, Seek and Metadata are new names in Os and
the prelude; handle is a contextual keyword and does not reserve the
word. If you were joining paths with .concat("/"), that still works —
Path.join is an improvement, not a requirement.
plum v0.0.19
Plum is a small, statically typed, compiled language.
plum check and plum build now agree.
Eight programs your editor approved and the build refused
plum check is what runs on every keystroke in an editor. When it
accepts something the compiler then rejects, you find out at the worst
possible moment — after the code is written and you have moved on.
Eight constructs did exactly that:
match n { 1 | 2 => .. } // or-patterns aren't supported yet
(1, 2).to_string() // .to_string() on a tuple isn't supported
().to_string() // Unit has no .to_string()
r.to_string() // on a Ref: use .get().to_string()
f.to_string() // a closure has no .to_string()
c.to_string() // on a CStr: use .as_string()
let r = 1..5; // '..' is only supported as a `for` iterand
show(ref(1)) // where `let show [T] (x: T) = x.to_string()`
Every one of those messages is a good message. They were arriving from
the wrong tool, at the wrong time. They come from plum check now,
worded the same, suggestions included.
This rejects programs 0.0.18 accepted — and every one of them already
failed to build. If your project compiles today it will compile on
0.0.19. What changes is when you hear about the ones that don't.
[T: Show], the third bound
The last of those eight needed more than a message move. Inside a
generic, x.to_string() cannot know what x is:
let show [T] (x: T): String = x.to_string()
show(1) // fine
show(ref(1)) // call to show: T is Ref[Int], but show requires T
// to have a text form, and this one has none
Checked at the call, where the type is concrete, and it follows the
value: a function that renders nothing itself but passes its T to one
that does inherits the requirement.
Show joins Ord and Eq as a bound you can declare — [T: Show] —
and is then required of callers whether or not the body renders
anything.
Type signatures you can read
Hover and completion were showing the compiler's internal parse-tree
notation:
before: let Option.map (o: (gt Option T)) (f: (fn (T) -> U)): (gt Option U)
after: let Option.map (o: Option[T]) (f: (T) -> U): Option[U]
A standard-library reference that cannot go stale
STDLIB.md lists all 161 entries across 18 sections,
generated by the compiler (plum stdlib-reference) and checked against
it on every build. It replaces a hand-written prose list that had
drifted: 32 functions existed and were documented nowhere, including
Ref.get, Ref.set, Sender.send and Receiver.recv.
Tuples also work rather better than the README claimed — nested, inside
arrays, as struct fields, returned from generic functions. Only
.to_string() on one is missing, which is now one of the eight above.
Upgrading
Nothing that compiled under 0.0.18 fails under 0.0.19.
The checker rejects more than it did, and every addition is a program
the compiler was already refusing. If you worked around one of them, the
workaround is still correct and no longer necessary.
plum v0.0.18
Plum is a small, statically typed, compiled language.
Type.func(x) and x.func() are the same call, in both directions.
Method calls work both ways
Plum has one calling rule: Type.func(receiver, args) and
receiver.func(args) are the same call. It is why xs.map(f) works —
there is no separate method system, only Array.map.
The rule did not hold. Nine ordinary things did not compile:
m.len() // Map error: .len(): Map[String, Int] != Array[T0]
s.len() // Set error: .len(): Set[Int] != Array[T0]
a.concat(b) // Array error: .concat() receiver: Array[Int] != String
m.remove(k) // Map error: .remove() requires an Array
Array.len(xs) // error: unbound variant/function: Array
String.concat(a, b) // error: unbound variant/function: String
Int.to_float(n) // error: unbound variant/function: Int
Ref.get(r) // error: unbound variant/function: Ref
Array.set(xs, i, v) // error: unbound variant/function: Array
All of them work now, and each error above named a type the author had
not written — m.len() on a Map complained about Array.
Array.push(xs, x) type-checked and would not compile
Worse than an error message. plum check said ok, and then the build
failed:
self-hosted codegen: prelude function Array.push has no implementation
in this backend's runtime yet
plum check is the one that runs in an editor, so this was a program
your editor called fine and your build refused — reachable by writing
about the most ordinary thing in the language.
Your editor knows about map now
Typing xs. offered twenty-three methods on an array and not map,
filter, fold, len or push — the five you reach for first.
Typing Array. offered one of them.
Separately, use Os; put nothing in the completion list at all. No
standard-library module — Os, Time, Net, Http — was ever
offered, in any project.
Both are fixed, and names are now offered in the form you have to write
them: Os.read_file, not a bare read_file you cannot call.
Upgrading
Nothing that worked before stops working. This release only accepts
more programs than the last one; no spelling that compiled under 0.0.17
is rejected under 0.0.18.
If you worked around any of the above — writing Map.len(m) because
m.len() was refused, or xs.push(x) because Array.push(xs, x) broke
the build — those workarounds are still correct. They are simply no
longer necessary.
Under the hood
The fix is the rule rather than a table of exceptions: a namespaced call
to a builtin method is inferred AS the method call. The alternative —
giving each builtin its own second signature — is exactly what
Array.push already had, and Array.push is the one that compiled to
nothing.
Two new checks keep it that way. bootstrap/check-builtins requires
every method the type checker recognises to be offered by completion and
exercised as a dot call in a fixture that runs; it caught a gap
introduced while writing this release. bootstrap/check-doc-names
verifies every standard-library name the documentation mentions in prose
actually exists.
The README's code examples are compiled and run now, the same as the
tutorial's already were. That found four wrong claims in it, including
one example that could not compile and one describing a feature the
language does not have.