Phosphor
Phosphor BASIC — an embeddable BASIC interpreter in Free Pascal (FPC/Lazarus). Desktop, MIT. Spiritual successor to Plan9Basic.
- Active · last change 10-12-2026
- ★ 1 on GitHub
README
Phosphor BASIC
An embeddable BASIC interpreter written in Free Pascal (FPC 3.2.2 / Lazarus). Desktop only — Windows and Linux. MIT licensed.
The spiritual successor to Plan9Basic (Delphi/FireMonkey, now frozen). Not a port: several language decisions change on purpose — see docs/decisions.md.
To use it rather than build it: download a release from the repository's Releases page and start with docs/getting-started.md. What changed in each version, and the limits to know before running a script you did not write, are in CHANGELOG.md.
Status — the whole oracle corpus, and where this language stops
Phosphor runs Plan9Basic's test oracle, byte-exact green on Windows and
Linux: 43 of its 45 tests/suite files have a counterpart here — 40 under the same
names, three (23_archive, 32_http_offline, 34_sqlite_full) under
tests/packages — alongside 27 suite files of Phosphor's own, the negative suite,
and six opt-in host packages (crt, base64, zip, gzip, http, sqlite).
18_examples_catalog is Plan9Basic's own website plumbing and out of scope;
11_encoding is answered for base64 and gzip but not for its regex third.
It is not the whole of Plan9Basic, and this heading used to say it was. The
languages are close — mechanically rewriting the # handle suffix to @ makes 95
of Plan9Basic's 98 example programs compile here, so the distance is mostly one
recorded decision — but close is not the same. next takes no control variable,
do while is the only do form, an unknown escape is an error rather than literal
text, and indexing is base 1 throughout, so a Plan9Basic program that does
compile can still answer differently: instr returns 0 where Plan9Basic returns
-1, and mid$ counts from 1 where s_mid counts from 0. Plan9Basic's cls,
dump, assert, watch/unwatch, traceon/traceoff and refreshrate
statements are not in this language at all.
The library surface is narrower in three places, and none of them was written down
before 2026-09-18: regex is find and split only (8 of Plan9Basic's 16 names —
no regex_replace$, regex_escape$, regex_isvalid), HTTP speaks GET and
POST, and Plan9Basic's AI library has no counterpart here. See
docs/decisions.md for what changed on purpose.
731 built-in functions are registered across those libraries and
packages together -- 546 names from engine/libs and 181 from host/packages -- and
the phosphor binary registers all of them, which is why the resolution section below
points back at this number instead of stating a second one. Every one is exercised by
a test and listed in the reference -- both held by a gate in the acceptance suite
rather than by a promise. Errors are values, not
crashes: a library records its error state and the program keeps running. It is a
real, working interpreter — not a skeleton.
Binary work is first-class. len counts characters and bytelen counts bytes,
with byteat/bytestr$/bytemid$ to read a string as bytes; # channels stream
through a sliding window and seek/loc are positionable and 1-based; and a
buffer (buffer_new@) is a mutable block of bytes behind a handle -- the same
handle file_readallbytes@ returns, so reader, edit and writer compose without a
conversion step.
The standard-BASIC command set from the founding brief is built and covered by
tests/classic (byte-exact, both OSes): the INPUT / LINE INPUT statements and the
INPUT$ function; classic #-number file I/O (OPEN … FOR input|output|append AS #n,
PRINT #, INPUT #, LINE INPUT #, CLOSE, and EOF/LOF/LOC); PRINT USING;
SWAP; and while … wend. These live alongside the IOUtils-style Io functions
(file_writealltext, file_readalltext$, savetext$, dir_*/path_*), so a file can
be worked either way. The phosphor host links every function package, so a program
it runs, compiles or packs reaches the whole library surface.
docs/language-reference.md documents it all.
What it is, precisely: an engine-as-library (engine/) that does no I/O of its
own and never names a host or GUI unit — the build fails if it does. Everything a
program can touch (the console, files, the GUI, the network) arrives through a small
host around it. Three hosts ship in this repo, plus the opt-in packages.
Prerequisites
Lazarus, with the FPC 3.2.2 it bundles. Not FPC alone: phosphor is one
binary that is both the console interpreter and the GUI host, so it links the
LCL and the build needs Lazarus present even when you never open a window. This
section says so because the README told Linux readers for months that a native
fpc was enough, and it is not.
| what to install | where the build looks | |
|---|---|---|
| Linux | Lazarus 4.8 from the official .deb packages (fpc-laz, fpc-src, lazarus-project) plus libgtk2.0-dev — the exact files and their SHA-256 are in docs/building.md. The distribution's own lazarus lcl-gtk2 packages are found too, but no build has been measured against them |
$LAZARUSDIR first, then /usr/share/lazarus/*, /usr/lib/lazarus/*, /opt/lazarus, ~/lazarus — it needs the gtk2 units under one of them |
| Windows | the Lazarus 4.8 installer, lazarus-4.8-fpc-3.2.2-win64.exe |
C:\lazarus; installed elsewhere, pass -Lazarus <dir> to scripts\build.ps1 |
Both build scripts name the missing piece if it is not there, so a wrong guess costs one run and not an afternoon.
A step-by-step tutorial -- from a bare Windows or Ubuntu machine to both
phosphor and the PhosphorIDE editor built and tested, with the exact packages
and checksums the measured builds used -- is docs/building.md.
Python 3, for the ten source gates. Building needs none. But
scripts/test-suite fails without an interpreter rather than skipping the
gates — deliberately, because a skipped check reads as a pass — so a machine with
no Python cannot run the acceptance suite.
Quickstart
Two routes. They are not the same build, and the difference is written down here rather than discovered.
# 1. The scripts -- AUTHORITATIVE. This is the build every suite is run against,
# and the only one that also runs the engine boundary check (no unit under
# engine/ may reach a host or the LCL), which no project file can express.
powershell -NoProfile -File scripts\build.ps1 # Windows
# bash scripts/build.sh # Linux
# Write a program:
# println "Hello, " + "world"
bin\phosphor.exe run hello.bas# 2. Lazarus. Open host\console\phosphor.lpi and press Ctrl+F9, or:
lazbuild --build-mode=Release host\console\phosphor.lpi
# TWO BUILD MODES, ON PURPOSE:
# Default -- debug info, for stepping through the host in the IDE.
# Release -- the same flags scripts\build.* passes. Measured on both:
# identical in size on Windows (4,939,264 bytes), 72 bytes apart
# on Linux -- an embedded path, not different code. Default is
# 34.5 MB of debug info. Build Release when the binary is what
# you want.
# Both pass -vewn, so the warnings and notes this project holds itself to are
# VISIBLE in the Messages window. Making one of them FAIL the build is still
# the scripts' job -- a .lpi cannot do it.To build the reusable engine package or the demo instead, see
lazarus/README.md; the demo needs
lazbuild --add-package-link lazarus/phosphor_engine.lpk once.
phosphor run <file.bas> run a program
phosphor run <file.bas> --out <path> send the program's output to a file
phosphor --no-console <file.bas> run with no console window (see below)
phosphor compile [--check] <in.bas> <out.pbc> compile to portable .pbc bytecode
phosphor pack [--no-console] <in.pbc> <out> standalone executable (from bytecode)
phosphor --sandbox <dir> <file.bas> confine every path the script names to <dir>
phosphor an interactive REPL (state persists)
phosphor --version | --help | --diag--sandbox is the only bound this host puts on a script, and it fails closed.
A root that will not bind is never an unconfined run: phosphor prints cannot establish the sandbox root <dir> -- refusing to run unconfined and exits 2. That
covers the empty string as well as whitespace, so phosphor --sandbox "$RUNDIR" untrusted.bas with RUNDIR unset is refused rather than run wide open.
What it confines is every path the script names. The operator's own --out
path is not confined: it is opened before the root is bound, so --sandbox cage --out ../outside.txt writes outside the cage — the operator named that path, as
they named the root. An empty --out is refused rather than quietly dropped,
phosphor: --out needs a path and exit 2, because --out "$LOG" with LOG
unset used to print to the terminal and exit 0. Each flag takes one value:
a second --sandbox or --out is refused (exit 2, naming the flag) rather than
quietly replacing the first, which let --sandbox cage --sandbox . run in the
wider root. And no output may name an input: --out naming the program,
compile's .pbc naming the source or the --names file, pack's executable
naming the .pbc or the phosphor binary it copies — each is refused before
anything is opened, by any spelling that reaches the same file (a .. detour,
case on Windows, a hard or symbolic link). --out job.bas job.bas used to
truncate the program before reading it. The three ceilings that bound how
long a script runs are the embedder's to set, and this host sets none of them:
docs/embedding.md has those, and says what all five ceilings
together still do not bound.
One binary, and it decides at startup. phosphor links the LCL and asks a
single question when it starts: is a graphical session reachable? If it is, it brings
the widgetset up and registers the 439 LCL GUI functions alongside everything else; if
it is not, it registers none of them and is a plain console interpreter. A GUI program
therefore needs no flag and no second file, and a .bas that never opens a window runs
identically on a desktop, over a pipe, in CI and on a headless server.
This works because linking the LCL is not what connects to the display. The unit
everyone reaches for, Interfaces, contains one thing: a CreateWidgetset call in its
initialization section — and on gtk2 that call opens the X display, before main,
which is why a binary that merely listed it died wherever no session existed. Name the
widgetset unit directly (Gtk2Int / Win32Int with InterfaceBase) and the same code
links while the call stays ours to make, when we have already checked. Measured both
ways on both platforms before this was built.
Where no session is reachable, a program that calls a GUI function is told exactly
that — no function form@ — rather than the process dying inside gtk before main.
Windows always has the GUI: the win32 widgetset draws through USER32 and needs no
display server.
Compiling needs no session either: the compiler is host-agnostic, so phosphor compile <gui-app.bas> <out.pbc> works on a headless machine, and phosphor pack makes
a standalone GUI application — the stub is this same complete binary.
A function name is resolved when the program runs, not when it compiles — and
that is what makes a .pbc portable. Which functions exist is a host's decision:
phosphor registers all 731 names counted above — it links every package — the
package test runner adds the assertion library, and the GUI runner adds 439 more, as
does phosphor itself wherever a graphical session is reachable. No GUI name
collides with a library name, so that is 1141 in one process. The compiler has no
registry at all and cannot know which names will exist; the VM asks whichever host
loaded the program.
The cost is that a typo survives until the line runs, so the two moments where the host IS known now check:
phosphor packrefuses a.pbcthat calls a name this binary cannot provide, and names it with the line that first calls it. It can be certain: the executable it writes carries this very binary as its stub, so no other host will ever load that payload. Portability is not lost here — packing is where you give it up on purpose.phosphor compile --checkwarns and carries on, exit 0,.pbcwritten. A name this host lacks is not necessarily a mistake; the file may be meant for a host that has it. It also lists a function written without its parentheses where a value is wanted —p$ = date$reads a variable nobody assigned and leavesp$empty — when the program never assigns that name itself.--names <file>judges against the host the file is meant for: one signature per line, as that host registers it (name:codes), added to this binary's.
The console is kept by default, and can be let go. A windowed program still has a
console, which is where PRINT goes — useful while developing, unwanted in something
you hand to someone. phosphor --no-console <file.bas> releases it at startup;
phosphor pack --no-console <in.pbc> <out> bakes the choice into the executable,
which is what a packed application needs because its whole command line belongs to
its program, which reads no phosphor options; and crt_hideconsole() does the same from inside a program that decides for
itself. Both refuse to touch a console shared with a terminal:
run from a shell, they answer 0 and change nothing, because that window is the user's.
Printing after releasing is safe — output that was a console goes to the null device,
output redirected to a file or pipe keeps going there.
phosphor (the console host) is the develop-compile-run tool, and a .bas file
written in any editor runs as it is. There is also a dedicated editor:
PhosphorIDE — a Lazarus application
with syntax highlighting for this language and a real debugger, breakpoints and
stepping and a variables pane, driven over a small line-oriented protocol against
phosphor debug. It never links the engine: a script that loops forever or faults
the interpreter takes its own process down and leaves the editor holding your
unsaved work. Its getting-started guide expects a checkout of this repository beside it
(the two side by side, as siblings). (This paragraph said "there is no dedicated IDE" until 2026-09-18,
which had stopped being true some time before.)
A GUI program is run the same way, phosphor run <file.bas>, and builds an LCL
window; to embed the engine in your own Pascal program, see
docs/embedding.md.
Learn the language
- docs/language-reference.md — the didactic guide: syntax, types, operators, control flow, functions, strings and arrays, with short runnable examples. Start here.
- docs/function-reference.md — the complete catalog of every built-in, by library.
- docs/debugging.md —
phosphor debug: stepping, the call stack and variables in the terminal, the socket protocol an editor attaches to, and five things to try with the output each should produce. - examples/ — runnable programs (
crt_demo.bas,crt_dashboard.bas,gui_demo.bas, …). The largest iscontact_manager.bas: a desktop application for suppliers, customers and their contacts, kept in SQLite behind a login with hashed passwords, in one file of BASIC — and it tests itself. Its tour, with screenshots, is docs/contact-manager.md.
Documentation for contributors
- docs/building.md — building and testing Phosphor and PhosphorIDE from source, on Windows and Linux.
- docs/architecture.md — the library/host seam, the boundary check, the UTF-8 policy, the Linux build.
- docs/decisions.md — the frozen language decisions (five value
types,
@handles, base-1 indexing, strict boolean, real vs integer division, the opt-in execution limits) and the on-disk-bytecode format decisions. - lazarus/README.md — the Lazarus package and a demo application with the engine embedded in it: five examples, including what your application sees when a bug in its own code faults the interpreter.
- CLAUDE.md — the short form of the above, loaded automatically by Claude Code every session: what "done" means here, the invariants, and the traps that have already cost time.
- docs/dev-agent-playbook.md — the hardened rules and round-by-round retrospective from the autonomous build loop that completed the oracle.
- docs/attack-plan.md — the plan of record for everything still open: seven waves with a gate to leave each, the decisions that need taking before code is written, and §5's sixteen defects that were not on any ledger.
- docs/gui-components.md · docs/embedding.md · docs/roadmap.md.
The ten source gates
After the acceptance corpus, test-suite runs ten Python checks over the source —
invariants no compiler can check and no golden happens to cover. They run in the
suite rather than in the build, because building should not need Python but passing
should mean the invariants hold; and a missing interpreter fails the run instead
of skipping them, since a gate that quietly does not run reads as a pass. This paragraph used to end by
saying that nothing checks this list, and that this was how check-suffix.py and
check-examples.py joined the suite on 2026-09-06 and were named nowhere here. It
predicted itself: check-budget.py joined on 2026-09-07 and the heading still said
six. coverage.py now counts the gates scripts/test-suite.ps1 actually runs and
fails if this number disagrees, so the next one to join cannot be missed the same
way. And it did: check-crossrefs.py joined on 2026-09-11 and this heading
still said eight, which coverage.py refused within minutes of the wiring — the
mechanism catching the person who had just built a mechanism for the same class one
level over. check-boundary.py joined on 2026-09-15 and made it ten.
scripts/test-suite.sh runs the same ten on Linux.
| gate | what it refuses to let through |
|---|---|
coverage.py |
a registered built-in -- GUI included -- that no executed test calls (a mention in a comment or a string is not a call) and that is not on the dated GUI worklist the gate prints, that the function reference omits, or that its own library page does not describe — and, the other way round, a function name or a count a document states that the registry does not back. |
check-crossrefs.py |
a repo path named in a comment or a document that the tree does not have — renumbering one test file broke four citations in an afternoon and no other gate reads a sentence — a probe registered in one suite runner but not the other, which runs on one operating system while both print OK, and a runner that accepts an argument it does not understand, which is how -ProveFailure was a silent full run on five of six bash runners. |
check-boundary.py |
a comment-stripping bug in the engine boundary check — the thing that decides whether engine/ has reached a host or GUI unit. The two halves are RUN over tests/boundary, whose eleven expected answers are derived from Pascal rather than from a run. The first time anyone asked, the bash halves scored 8/11 and the PowerShell halves 10/11: the bash ones stripped no (* *) comments at all, so one legal file failed on Linux and passed on Windows, and all four stripped // first, which lets a brace comment lose its terminator to a // on the same line and then swallow a real uses clause — hiding a violation, which is the one thing this check must never do. |
check-codepage.py |
a Char concatenated into a code-page string, where every byte >= 128 is silently destroyed. The class has been swept three times. |
check-sandbox.py |
a routine a script can reach that touches the filesystem without asking the sandbox gate first — or without being exempt by name, with a reason. |
check-seams.py |
a host that neither fills an engine seam (OnOutput, OnInput, OnBreakpoint, HostServices) nor records why leaving it nil is right. A nil seam answers silently. |
check-suffix.py |
a registered name whose type suffix is not the kind its body returns. The suffix is the return-type system for built-ins, and fifteen registrations lied. |
check-examples.py |
a basic code block in the docs that does not compile. coverage.py already refuses a block calling a function that does not exist; it cannot refuse one whose names are all real and whose syntax is wrong. |
check-budget.py |
a library loop or allocation over a script-supplied count that never consults PhosphorBudget — or is exempt without a stated reason. A library call is one instruction, so the three time ceilings cannot see inside it. |
check-manifests.py |
a .bas in a manifest-driven corpus that the manifest does not name, and a name in a manifest with no file behind it. The runners read the manifest and nothing read the directory, so an unlisted test simply never ran and the runner printed OK. |
Layout
| path | what |
|---|---|
engine/ |
the interpreter library. Host-agnostic, GUI-free. |
engine/libs/ |
the standard built-in libraries (Str, Num, Array, Dict, Json, …). |
host/console/ |
the phosphor CLI: run / compile / pack / REPL. |
host/gui/ |
the 17 LCL GUI packages under libs/, and phosphorguitest, the headless runner for the GUI suite. The GUI host is phosphor itself. |
host/embed/ |
an example of embedding the engine. |
host/packages/ |
opt-in packages: base64, zip, gzip, http, sqlite, crt. |
tests/ |
six corpora: suite (the oracle), negative, classic, packages, gui, skeleton, plus the assert library and the Pascal probes. |
examples/ |
runnable example programs — and they are RUN: test-examples byte-compares each to a golden (a windowed one is compiled, since the compiler needs no display). |
scripts/ |
build, test, test-suite, test-classic, test-packages, test-gui, test-examples (.ps1/.sh), and the ten source gates described above. |
docs/ |
the documentation above. |
Requirements are at the top, under Prerequisites: Lazarus with
the FPC 3.2.2 it bundles, on both platforms, because the one binary links the LCL.
This line used to say Linux needed "a native fpc", which did not build.

