Shoddy Documentation
The whole language, defined — with the stack core and the design rationale as appendices
The name comes from the shoddy trade of the West Riding of Yorkshire. From 1813, the mills of Ossett, Morley, and Wakefield tore worn-out rags back to fiber, in a machine called the devil. They blended that reclaimed stock with new wool and wove it into cloth again. The trade lasted until synthetic fibers ended it. The language is built the same way: old ideas reclaimed, blended with new wool, woven into something useful. The executable is the mill; the libraries are its machines. The full story is in the heritage of the name.
Shoddy is a purely functional BASIC — a language where values
are made, never changed in place. Its surface syntax is typed and
applicative: parenthesized calls, infix operators, Let,
Fn. Underneath, every program desugars onto a
concatenative (stack-based) core, in the tradition of the languages Joy
and Kitten. Both styles are legal, and they mix freely:
Def Hypot(a As Number, b As Number) As Number
Sqr(Square(a) + Square(b))
Def Square ( Number -- Number )
Dup *
Pedigree: Kitten’s semantics, BASIC’s keywords, Python’s layout.
goto,
no mutation — a value, once made, never changes. Let
binds a name to a value; it never rebinds.Map, Fold, Filter,
Each, Times). There is no mutable loop index.
A for loop is a mutable index in a trenchcoat, so it does
not exist here.endif, no end def, no ;, no line
numbers.Print appears
only in Main.Map, MAP, and
map are the same word. Convention:
PascalCase for keywords, types, functions, and field
accessors (Def, Number, MatMul,
Score); camelCase for variables and
parameters (xs, bestRatio). Records print back
in their declared spelling.( ) { } [ ] ,
— everything else splits on whitespace.42, -3,
1.5. Every number is an IEEE double — the 64-bit
decimal format most languages use, exact for whole numbers up to
253.\" \\ \n \t
\r.Rem or ' to end
of line.(, {, [, or any comma inside
them. Continuation lines carry no indentation meaning, and comments and
blank lines may appear mid-continuation. Any long expression can be made
wrappable by parenthesizing it. A Def at the left margin
always starts a new function, so a missing close bracket is reported at
the line that opened it.! % # $ and the predicate suffix ? may not
appear in words. Types live in declarations, not names. Predicates use
the Is prefix (IsEmpty, IsEven),
after Visual BASIC’s IsEmpty/IsNumeric.
String words carry no sigil (Str, Mid,
Left), matching Val and Asc,
which never had one.count+1 would otherwise read as one
long name rather than an addition. So an operator character —
* + / < > = ^ & — mixed into a word that
also carries a letter or digit is an error. The fix is a space:
count + 1, x > y. The operators themselves
are untouched, since none of them contains a letter or digit. Number
literals are exempt, so 1E+10 is still a number.
; and \ spell nothing in the language and may
not appear in a word at all; \ keeps its meaning inside a
string literal. Not affected: -, which leads unary minus
and stack effects (--), and ', so
y and y' remain two distinct names.| Type | Notes |
|---|---|
Number |
IEEE double |
String |
Immutable |
Boolean |
Distinct type; numbers are never truthy |
List Of t |
Homogeneous, immutable; literal { 1, 2, 3 } |
Array Of t |
Fixed-length, immutable, O(1) index; see §9 |
Option |
Predeclared: Some(Value) | None. Absence as a value you must match; see §8.1 |
Result |
Predeclared: Ok(Value) | Err(Why, At). Failure with a reason; see §8.1 |
Person, … |
User records declared with Type; see §8 |
[ t ... -- u ] |
Quotation (function value); arrow spelled as stack effect |
Type variables — stand-ins for “any type” —
are single lowercase letters (t, u). You never
declare them; using one is enough. Annotations use BASIC’s
As:
Def CountIf(xs As List Of t, p As [ t -- Boolean ]) As Number
Length(Filter(xs, p))
Implementation status: annotations are parsed, and type
checking happens at runtime — a wrong type stops the program with
a line number. The mill’s weave-time linter performs stack-effect
inference: net effects per Def, branch agreement,
call-site depth against declared arities, and unknown words as weave
errors. It warns only on what it can prove and skips what it cannot.
See The linter. Full static
type checking is future work.
Three header forms, dispatched by shape:
Def Name(p As t, ...) As r ' applicative body (expression syntax)
Def Name ( t ... -- u ... ) ' concatenative body, effect signature
Def Name ' concatenative body, undocumented effect
Applicative parameters perform an implicit Take. The
body is a sequence of statements, and its final value is the result.
Def Main() is the entry point.
A fourth top-level form exists: Let NAME = expr at the
left margin binds a program-wide constant. All top-level Lets are
evaluated once, in textual order, before Main. Each sees
the earlier ones and any Def. Together they form the outermost lexical
scope: visible in every body, closed over by quotations, immutable like
every binding. There is no separate Const keyword —
a Let that can never rebind already is one.
In a machine, a top-level Let must bind a
constant. A machine has no Main, so it has no
startup phase in which to evaluate an initializer. A constant needs
none, because it is built once when the machine is loaded. The rule is
a prohibition: the initializer contains no word calls. What it
admits:
{ … } list literals
and type constructors, nested to any depth.+ - * / ^ Mod & over constant operands, folded at
compile time.ToArray and Dim applied to constants,
likewise folded. There is no array literal in the language, so an array
constant is spelled ToArray({ … }).True and False, folded for the same
reason. There is no boolean literal either — both are builtin
words, so a boolean constant is spelled with them.Anything else is refused where it is written, naming the word that
has to go. A quotation is refused by its own message: a quotation
captures the environment it was made in, and a machine constant is one
value shared by every program that loads the machine. A conditional is
refused too, and that covers And and Or,
which compile to one — naming the If the writer never
typed would help nobody.
A machine constant is private to its file. A
Let declares a value, not a word, and an
Include gives you the words a machine declares
(§13.1). So nothing crosses the boundary, and two machines may name
the same constant without colliding. A machine publishes a
constant with a Def that returns it. That Def
then returns the same value every call:
Let unitNames = { "m", "kg", "s" }
Def Units() As List Of String
unitNames ' Units() = Units() is True
That last line is the point, not a nicety. A List
compares by identity — two lists are = only when they
are the same list, not merely lists with the same items. So a zero-arg
Def that builds { "m", "kg", "s" }
hands back a different value every call, and is never equal to itself.
A program’s own top-level Let is unrestricted, as
above: it may call any Def.
Calls: f(a, b) desugars to
a b f — arguments load left to right, then the word
runs.
Infix operators, low to high precedence:
Or; And; comparisons
= <> < > <= >=; + - &;
* / Mod; ^. ^ is
right-associative, so 2 ^ 3 ^ 2 is 512 and
-n ^ 2 is -(n^2), as in classic BASIC.
Not and unary - are prefix. Parentheses
group.
And and Or short-circuit
in expression bodies: the right side evaluates only when it is needed.
So Not IsEmpty(xs) And First(xs) > 0 is safe on an empty
list. (The core’s postfix And/Or words
remain strict — both operands are already on the stack by
then.)
Let Name = expr binds an immutable
local for the rest of the body.
Pipeline chaining: adjacency is stack flow. A call may consume the value of the preceding expression, and deeper-indented lines continue a statement:
Def EvenSquareSum(n As Number) As Number
Range(1, n)
Filter(IsEven)
Map(Square)
Fold(0, +)Conditionals: If cond Then with
indented branches; Else alone on a line at the
If’s indent. If is an expression —
each branch’s final value is its result. Inline alternative:
Ifte(cond, t, f).
Select Case — QBasic’s
value matching, as an expression. The tested value (the scrutinee)
evaluates exactly once, and the first matching clause’s block is
the result. The clause forms:
Case expr — equality.Case e1, e2, ... — any of these.Case a To b — an inclusive range.Case [Is] >= expr — any comparison
operator.Case Type(n1, ..., nk) [Where cond] — record
destructuring: matches records of that Type and binds every field, in
declaration order, to the given names. The names are in scope for the
guard and the body.Case Else — the default, last.With no Case Else, a non-match is a runtime error. The
whole form desugars to nested Ifte over a hidden one-time
binding. Note that Case Type(...) is always a pattern; to
test equality against a constructed record, write
Case = Type(...).
Def Greet(p As Person) As String
Select Case p
Case Person(n, a) Where a < 18
"HEY " & n
Case Person(n, a)
"HELLO, " & Upper(n)
Def Grade(score As Number) As String
Select Case score
Case >= 90
"A"
Case 80 To 89
"B"
Case Else
"F"A quotation is deferred code — code saved to run later, written in brackets in the core. In the applicative surface the brackets are rarely needed:
Fold(xs, 0, +), Map(xs, Square),
Filter(xs, IsEven). To force a bare name to
evaluate instead, parenthesize it: f((g)).Filter(xs, >=(100)) — “≥ with
100 pre-loaded” — instead of [ 100 >= ].Fn lambdas for anything genuinely
inline: Filter(xs, Fn(x) => x Mod 2 = 0). Sugar for a
quotation beginning with an implicit Take.[ ... ] remains legal everywhere as
raw core code. It is required inside concatenative bodies:
without the quote marker, If’s branches would run
before If did.Call applies a function value:
Call(f, acc, x) ≡ acc x f Call.
Quotations are closures. A function value remembers
the variables from the place it was written (lexical scoping):
Filter(xs, >=(p)) still sees p when a
library word executes the quotation later. Since bindings are immutable,
that capture costs one pointer.
{ 1, 2, 3 } builds a List Of Number.
Elements are arbitrary expressions, evaluated at construction. Lists are
immutable.
Primitives: IsEmpty First Rest Prepend. Library words
(expressible in the language itself): Map Filter Fold Each Times
Range Length Reverse Concat Call Ifte — provided as
builtins for speed, and self-hostable as:
Def MySum(xs As List Of Number) As Number
If IsEmpty(xs) Then
0
Else
First(xs) + MySum(Rest(xs))
TypeQuickBASIC’s record syntax, minus the end Type
that layout makes redundant. Declared at column 0, before first
use:
Type Person
Name As String
Age As Number
Declaring a Type generates three things:
Person("ANN", 34) or
Person(Name = "ANN", Age = 34). Positional arity
is checked at parse time.Name(p), Age(p).
Accessors are ordinary words, so they compose with everything:
Map(people, Name),
Filter(people, Fn(p) => Age(p) >= 18), and in the
concatenative core simply p Age. Two TYPEs may share a
field name; the accessor dispatches on the record’s tag at
runtime. Don’t name fields after builtins — the builtin
wins. The SHODDY_LINT switch on the
Toolchain page warns at weave time when
a binding shadows an accessor.With, returning
a new record: With(p, Age = Age(p) + 1) — any number
of field = value pairs.Records are immutable. They compare structurally with =
— equal when their fields are equal. They nest freely
(Customer As Person), and they print as valid input:
Person(Name = "ANN", Age = 34).
Sum types declare tagged alternatives on one line:
Type Shape = Circle(r) | Rect(w, h)
Each variant is a full record type — constructor, accessors,
With, patterns — and Select Case
dispatches on the variant:
Def Area(s As Shape) As Number
Select Case s
Case Circle(r)
Pi() * r ^ 2
Case Rect(w, h)
w * h
A bare variant has zero fields. Construct it as None()
— or bare None outside argument position — and
match it with Case None (structural equality) or
Case None().
Two sum types are predeclared. They need no
Include, and no machine owns them. Absence and failure are
statements about every type, so a version you had to import would not
be a replacement for NULL at all. (NULL is the “no value
here” hole most languages leave open.)
Type Option = Some(Value) | None
Type Result = Ok(Value) | Err(Why, At)
Option is the answer to NULL: absence is a value you
must match, never a hole. Result is for failure that has a
reason. The test that separates them: could a caller do
anything different depending on why? A missing dictionary key has one
possible reason and wants None. A malformed document wants
the reason and the offset. At is 0 when there
is no position, which reads as “absent” the same way a
1-based IndexOf returns 0 for not-found.
They generate no accessor words, and that is deliberate.
A field accessor on a sum type is partial — it works on some
values and dies on the rest. If Value were a word,
Value(None()) would compile and then fail at runtime with
“None has no field Value” — reopening the
exact hole the type exists to close. Pattern binders are positional and
named at the match site, so Case Some(x) already hands you
the value. An accessor could only offer a way to skip the
match. Select Case is the way in, and the only one.
Select Case ToBoolOpt(answer) ' str.shoddy — no Include needed for Option
Case Some(b)
b
Case None
Print("NOT A YES OR A NO")
False
Declaring your own type named Some, None,
Ok or Err is a duplicate-definition error, as
it would be for any name already taken.
Patterns nest. A sub-pattern may be a binder name or
another Type(...) pattern, to any depth. _ is
an ordinary binder used by convention for ignored fields:
Select Case o
Case Order(Person(n, _), items, total) Where total > 100
n & " (BIG SPENDER)"
List is the inductive type — cheap
First/Rest/Prepend, made for
walking front to back by recursion. Array is the indexed
type: fixed length, immutable, Nth in O(1) —
constant time, however long the array — and a functional
SetNth that returns a new array. Use lists to recurse and
arrays to index; matrices are arrays of arrays.
Let a = ToArray(Range(1, 5)) ' Array(1, 2, 3, 4, 5)
Nth(a, 3) ' 3, in o(1)
SetNth(a, 3, 99) ' Array(1, 2, 99, 4, 5) — original untouched
Dim(3, 0) ' Array(0, 0, 0) — basic's keyword, reclaimed
Map Filter Fold Each Length IsEmpty First Nth SetNth Reverse
Concat Sort are polymorphic over both kinds, and they preserve
the input’s kind — Map over an array yields an
array. ToArray/ToList convert.
Rest/Prepend stay list-only by design: on an
array they would hide an O(n) copy, a cost that grows with the whole
array.
The same rule decides which kind a constant should be (§4). A
table consulted by index is an Array; a table consumed by
structural recursion is a List. Both may be held by a
machine’s top-level Let — an array constant is
written ToArray({ … }), which is folded at compile
time.
+ - * / Mod ^ Negate Abs Min Max Sqr Floor Ceil Round — owned and documented by math.Sin Cos Tan Atn Atn2 Asin Acos Tanh Exp Log Log10 Pi Sgn Fix Wrap (radians; Log is natural log; Tanh is the hyperbolic tangent, saturating to ±1; Wrap is floored modulo) — owned and documented by math.Erf GammaP BetaI — the error function and the regularized incomplete gamma and beta functions. These are the native-precision primitives under stats.shoddy’s probability curves — owned and documented by stats.Rnd — uniform in [0, 1); impure, like Print. Seed(n) makes it reproducible. Rnd is owned and documented by random; Seed by math, since random is seedless by construction= <> < > <= >= (numbers or strings) → BooleanAnd Or Not True False (strictly boolean)& Len Str Val Left Right Mid Chr Asc Upper Lower Instr InstrFrom Codes FromCodes CodeAt. Mid is 1-based: Mid("HELLO", 2, 3) → "ELL". Instr(s, sub) is the 1-based position of SUB in S, 0 if absent. InstrFrom(s, sub, from) is the same from a position, answering 0 for a from past the end. Codes(s) is an Array Of Number of every UTF-16 code unit — the 16-bit pieces a string is stored as — and FromCodes is its exact inverse, NUL included, which Chr cannot build. CodeAt(s, k) refuses a position past the end, where Mid trims. It is how a character is read, and Asc(Mid(s, k, 1)) is the trap it replaces. — owned and documented by str.IsEmpty First Nth SetNth Map Filter Fold Each Times Range Length Reverse Concat Sort (polymorphic; Sort ascending over all-numbers or all-strings); Rest Prepend (lists only); Dim ToArray ToList (arrays) — owned and documented by seq.Type declarations; per-type constructor and accessor words; With for functional updateCall Ifte — owned and documented by lineshaft.Print; Input(prompt) prints the prompt and reads a line as a String (numbers via Val). InputLine(prompt) does the same but also answers whether the read hit the end of the input, as a 2-element Array [atEof, text]: element 1 a Boolean, True only at genuine end-of-input; element 2 the line, empty at the end. Input collapses end-of-input and a blank line into "". So a program reading a redirected script can either reprompt on a blank line or stop at the end — never both — and InputLine is how you get both. It is a flat Array rather than a record because a builtin cannot reach a Type; lifting it into a sum type is the Shoddy caller’s job. InKey() reads one pending keystroke without blocking or echoing — "" when none is pending. Arrow keys and PF1–4 arrive as their VT100 application-mode escape sequences. — owned and documented by terminal.ReadFile(path) → String (a missing file stops the program); TryReadFile(path) → Result, the same read with the failure reported rather than fatal; WriteFile(path, s) (overwrite); TryWriteFile(path, s) → Boolean, the same write with the failure reported rather than fatal; AppendFile(path, s); FileExists(path) → Boolean; DeleteFile(path) (a missing file stops the program); TryDeleteFile(path) → Boolean — True if the file went, False if it was not there or could not be removed. Shoddy has no catchable errors, so the Try words are the only way to ask "can this path be read?", "can this path be written?" or "did this file go?". FileExists cannot answer any of them: a directory reports False, an existing file that cannot be opened reports True, and a file that exists can still be locked when a delete lands. — owned and documented by file.TryReadFile answers the language’s own Result (§8.1) rather than a Boolean. Unlike TryWriteFile, it has a payload to carry on success, and reasons worth telling apart on failure: Ok(text), or Err(why, 0) where why is CANNOT READ 'path' (…) ending in one of NO SUCH FILE, IS A DIRECTORY, ACCESS DENIED or UNREADABLE. The phrase is a closed set of Shoddy’s own, never the host platform’s exception text, which varies by OS and language setting. At is 0: a failure to open the file has no position in it.
Select Case TryReadFile(path)
Case Ok(text)
text
Case Err(why, _)
Print(why)
""BOpen(path) → a handle — a Number that stands for the open file — opening read/write and creating if absent, with up to 16 open at once. TryBOpen(path) → Result: Ok(handle), or Err(why, 0) with why = CANNOT OPEN 'path' (…) from the same closed set TryReadFile uses, plus TOO MANY OPEN FILES. It differs from BOpen twice over, and both differences are the point: the failure is reported rather than fatal, and it opens an existing file only. BOpen’s open-or-create means a failed read leaves an empty file behind, so asking whether a binary file is there damages the answer. And since nothing can learn a file’s size or its leading bytes without opening it, TryBOpen is the only way to test a binary read before committing to it. BClose(h); Seek(h, pos) — byte positions are 1-based; BPos(h) / BSize(h). Typed transfer, each advancing the position: PutNum/GetNum (8-byte IEEE double), PutBool/GetBool (1 byte), and PutStr(h, s, Len) / GetStr(h, Len) — fixed-length zero-padded fields. PutStr writes exactly Len bytes; an over-length string stops the program rather than silently truncating. GetStr reads exactly Len and strips the padding. Fixed field sizes make record layouts computable: a Name(12) + Score(8) record is 20 bytes, so record K starts at 1 + (k-1)*20. Reading past the end of the file stops the program. All I/O words are effectful — by convention, Main’s side of the program only. — owned and documented by recio.--allow-net (which sets SHODDY_ALLOW_NET, so a standalone woven exe honors the same switch). The first socket word otherwise stops the program. Two words are the exception, and they exist so guarded code can reach the family at all. NetAllowed() → Boolean answers whether the capability is on, and is not itself gated: the question has to stay askable when the answer is no, or nothing could tell "no network" from "no answer" without ending the run. TryTcpConnect(host, port) → Result is TCPConnect with all three of its failure modes reported instead: Ok(handle), or Err(why, 0) where why is CANNOT REACH 'host:port' (…) ending in one of NETWORK IS DISABLED, TOO MANY OPEN SOCKETS, TIMED OUT or NO SUCH HOST OR REFUSED — a closed set of Shoddy’s own, never the platform’s socket text. TryTcpRequest(host, port, secure, msg) → Result guards the whole conversation rather than a step of it: connect, optionally secure, send, read to the end of the reply, close, answering Ok(reply) or the same shape of Err. It exists because guarding the steps is not enough. TCPSend and TCPRecv fail eight ways between them on ordinary network faults, so a caller composing guarded parts would have to close the socket on each of a dozen error paths — and would leak it on the one missed. Here the socket cannot outlive the word. It is also the only network read that cannot hang: it is bounded by a deadline, where a poll-until-closed loop never returns against a peer that keeps the connection open. Handles are Numbers, like file handles (up to 16 open); payloads are Strings, one byte per character (the Latin-1 convention). The family: TCPConnect(host, port) → handle (bounded 10-second handshake; stops the program if unreachable); TCPListen(host, port) → handle, binding an IP literal ("127.0.0.1" loopback, "0.0.0.0" any); TCPAccept(h) → a connection handle, or 0 when none is waiting; TCPSend(h, s); TCPRecv(h, max) → up to max bytes, "" when nothing is pending or the peer has closed; TCPEof(h) → Boolean, telling those two apart; TCPPoll(h) → Boolean, whether Recv/Accept would find something now; TCPPeer(h) → the remote "ip:port"; TCPClose(h); TCPSecure(h, host) upgrades a connected socket to TLS — encrypted transport — in place, the host name driving both the request and the certificate check (a failed handshake closes the handle). Every readiness word on a plain socket is non-blocking: a program polls, optionally around Sleep, so a socket never freezes a scribbler window or a debug session. A secured handle is the deliberate exception. There, TCPRecv blocks until data, end-of-stream or a 30-second timeout and returns "" only at the end; TCPEof reads a flag rather than polling; and TCPPoll refuses, because once TLS records wrap the stream a TCP-level poll cannot answer honestly about the decrypted data. TLS is for request/response at the edges; the polling contract is for game loops on plain sockets. net.shoddy layers Connect/RecvAll/Request/HttpGet/Listen/Serve1 on top. — owned and documented by net.Args() → List Of String, the program’s arguments. Core, and also documented in terminal — but not terminal-exclusive: a headless service or a GUI-launched program has arguments too.Ticks() → monotonic milliseconds (fractional) for measuring; Sleep(ms) yields the thread; Clock() → a 7-element Array of wall-clock now: year, month, day, hour, minute, second, millisecond. — owned and documented by clock.ScribblerOpen(w, h) → handle. It requires the window backend — mill run/mill dap; woven output run via bare dotnet stops the program. ScribblerClose ScribblerTitle ScribblerPixel ScribblerFill ScribblerText ScribblerGetPixel ScribblerWidth ScribblerHeight ScribblerBlit ScribblerSetInterval ScribblerPoll ScribblerWait. Every word leaves exactly one value — mutators return the scribbler, so Let sc = ... chains read functionally even though the pixel buffer changes in place. Nothing shows until ScribblerBlit. ScribblerPoll/ScribblerWait → a raw 8-element event array (kind 0 = none; Wait blocks at zero CPU, and stops the program headless — nothing would ever wake it); scribbler.shoddy decodes it to a ScribblerEvent. ScribblerSetInterval(sc, ms) asks for tick events (0 = off). ScribblerSave(sc, path) writes the buffer to a PNG — eight-bit truecolour, alpha dropped, no window consulted, so it works headless and under --no-window. ScribblerPlace(sc, x, y) asks for the window’s top-left corner in desktop pixels; a window manager may ignore it, and nothing reads it back.Scribbler is its own kind of value, so unlike a file handle it cannot be carried by a number — and a number is what a reckoner seed needs, since a resource held in reckoner’s named table is found again by an integer the binding carries. TryScribblerOpen(w, h) → Result is ScribblerOpen with both of its failure modes reported rather than fatal — no window backend, and a size below 1×1 — answering Ok(slot) rather than the window itself. ScribblerOf(n) → the window that slot names. ScribblerShut(n) → Boolean closes it and frees the slot, answering False for one already let go, so that closing twice cannot end a run. An ordinary program takes the window from ScribblerOpen and never sees a slot. — owned and documented by scribbler.Sound(freq, ms) NoteOn(ch, freq) NoteOff(ch) SoundQueue(ch, freq, ms) SoundStop(ch) SoundGain(ch, vol) SoundWave(ch, wave) — channels 1–8, and three note-lifetime models: fire-and-forget pool, held until released, and queued back-to-back sample-accurately. For SoundQueue, freq 0 is a rest, and queueing more than five minutes ahead stops the program; SoundQueued(ch) → the milliseconds already queued ahead, which is the only way to see that cap coming. SoundWave picks a channel’s timbre, sticky like gain: 0 square (default), 1 triangle, 2 sine. A held note re-voices in place; the anonymous pool is square only. Sound never blocks and never fails the program: audible under mill run/mill dap, a silent no-op headless. But bad arguments — channel 0, a negative duration, wave 3 — raise even in silence. — owned and documented by buzzer.Error ( s -- ) stops the program with the message and line number; Assert(cond, msg) stops it unless COND is True — owned and documented by lineshaft.Dup Drop Swap Over Rot Nip Tuck Depth| Surface | Core |
|---|---|
f(a, b) |
a b f |
a + b |
a b + |
xs Filter(p) |
xs p Filter |
Let x = e |
e Take x |
Def f(a As t, ...) As r |
Def f + implicit Take |
bare Name as argument |
[ Name ] |
>=(100) |
[ 100 >= ] |
Fn(x) => body |
[ Take x body ] |
{ 1, 2, 3 } |
list construction |
If c Then a Else b |
c [ a ] [ b ] Ifte |
Call(f, acc, x) |
acc x f Call |
Person(Name = n, Age = a) |
n a Person (declaration order) |
Age(p) |
p Age |
Tail recursion is the language’s loop. When a function’s
last act is calling itself, the mill compiles that call into a jump, so
the Fact-with-accumulator shape runs in constant stack
space. Mutual recursion — two functions calling each other —
still consumes stack. Runtime errors — stack underflow, type
mismatch, unknown word, division by zero — stop the program with a
message and a source line number.
Include "FILE.SHODDY" at the left margin
splices another source file in at that point, once per file however
often it is included. Resolution tries three places, in order:
SHODDYLIB environment
variable — a named setting the operating system passes to every
program.machines/ beside the executable, then one level up, beside
its bin/.The fallbacks are what let a program keep a bare
Include "seq.shoddy" wherever it is copied. A
repo’s bin/mill finds machines/ at the
root, and the mill bundled in the VS Code extension finds the one staged
beside it — neither needs anything set. SHODDYLIB
stays the explicit override that pins a checkout to its own machines. A
file found in a machine library is a machine, and is always
used compiled: the mill links Shoddy.Machines.Seq.dll
beside the source, building it first if it is missing or stale. A file
found beside the includer — one of your own program’s files
— is spliced. Declarations must precede use, so libraries go at
the top.
There are two kinds of Include, and the difference is one
sentence: a machine is a boundary, and a program’s own files are
one program. Including one of your own source files splices it:
every Def, Type, variant, and field accessor
lands in one flat table alongside the builtins. That is what lets a mill
split itself across a dozen files without ceremony. Including a
machine gives you the words that machine declares, and
not the words it includes in turn.
Include "stats.shoddy" does not hand you
seq’s ZipWith merely because stats uses
it. Name what you use. The refusal says which Include to add, and the
dependency is compiled and linked either way — only the naming is
scoped.
Within one file, then, every visible declaration is in one flat
table. Include "FILE.SHODDY" As NAME pastes
NAME onto the front of everything that file declares. A
file included As Msg declaring CaveNearby
declares MsgCaveNearby — the same name a hand-written
prefix would have produced, so existing call sites keep working.
Qualification is never required at the use site. A bare word resolves while its meaning is unambiguous, in this order: first the namespace the use site is itself written in (a file’s own names come first, so qualifying two files that share a helper name breaks neither), then an exact declaration, then a unique namespaced name. Only a genuine collision between two namespaces forces you to choose, and the error names both candidates. (This chooses which declaration a name refers to. What a word means when local bindings, Defs, builtins, and accessors all compete is the separate chain described on the Toolchain page.)
To choose, write NAME In NAMESPACE —
Hints In Tbl(), Died In Core (m) in a pattern,
Anchor In Vocab (k, t) for a constructor. It collapses to
the single word TblHints, so both spellings mean exactly
one thing.
Three details are worth knowing:
As namespaces the surface of the file you
named, and nothing further — the words that file
declares, not the words it in turn includes. So
Include "net.shoddy" As Sock spells net’s
own Connect as SockConnect, and leaves
Join — which net gets from str.shoddy
— spelled Join. Namespaces therefore do not compose
through a chain of includes. A name never depends on who included the
includer, and a file’s spelling never depends on its
dependencies’ include lists — adding an include to
str cannot change what As Sock spells.With(g, Dflag = 3) still
names the field bare, and a namespaced accessor is reachable as
Key In Vocab (a) even where a local named key
would shadow the bare spelling.As too, which
is what lets two machines exporting the same word be used in one
program. One pair in the library still does: net.shoddy
and turtle.shoddy both export Close. Include
both and you name one of them —
Include "net.shoddy" As Sock, whose words read
naturally prefixed — and leave turtle’s bare. Include only
one and there is nothing to disambiguate, so you need no
As at all. A machine’s own dependencies are not part
of your program’s names, so they cannot collide with anything
either.Two further guards, independent of namespaces. Declaring a name
twice is an error that names the earlier file and line. And a
Def whose name is a builtin is refused, because a Def
outranks a builtin everywhere in the program. Write Redef
in place of Def when the override is deliberate.
The standard library is written in Shoddy itself:
Any All CountIf Contains IndexOf IndexOpt Taken
DropN Append Last Flatten Zip ZipWith, plus
Type Pair (Fst/Snd).
IndexOpt answers the not-found case with the
language’s Option; IndexOf keeps its 0
sentinel — a special value standing for “not found”
— because the tree already reads it. Aggregation lives in
stats.shoddy; sorting is the Sort builtin.Type Matrix over
a flat row-major array, meaning the rows are stored one after another in
a single array (O(1) MatGet, dimension-checked ops via
Error): Mat MatFill Ident MatFromRows MatGet MatSet
MatRow MatCol Dot VAdd VSub VMul VScale Outer MatMul MatAdd MatSub
MatScale Transp MatVec MatShow. VMul is elementwise;
Outer is the outer product. The algorithms over
the type — determinants, inverses, systems, rank, factorisations,
eigenvalues — are lin.shoddy; this machine owns the
structure and its arithmetic. Includes seq.shoddy.Matrix. Measures
(LinTrace LinNorm LinMaxAbs LinIsSym LinIsSquare), vectors
(LinVNorm LinVUnit LinCross), building
(LinBuild LinSwapRows LinScaleRow LinScaleCol LinAug
LinCols), elimination with partial pivoting
(LinRef LinRref LinRank),
LinDet LinInv LinSolve LinPow LinMinor LinCofactor LinAdj,
the factorisations (LinLuOf → Type LinLu
with LFactor UFactor Perm PSign, LinQrOf
→ Type LinQr, LinChol), and the
eigenproblem (LinCharPoly by Faddeev-LeVerrier,
coefficients highest power first; LinEigVals;
LinEigSym by cyclic Jacobi →
Type LinEigen). LinEigVals finds the
real eigenvalues only, by bracketing sign changes, so
an answer shorter than Rows(m) is itself information;
LinEigSym is complete for a symmetric matrix. Every solver
checks its precondition and stops rather than returning a plausible
wrong answer. A program normally includes matrix alongside
it, since it must name Matrix. Includes
matrix, eng and seq.Type AlgEx = AlgNum(AlgNumer, AlgDenom) | AlgVar | AlgSum |
AlgProd | AlgPow | AlgCall. No subtraction and no
division variant: a - b is a sum with a term times
−1, and a / b a product with a factor to the
−1. So every walker has six cases, and the simplifier never
reasons about a - b + b; AlgShow reconstructs
both on the way out. Numbers are exact rationals
— fractions kept as numerator and denominator — always
normalised by AlgRat: 1/3 + 1/6 is exactly
1/2, which is what lets AlgFactor find
rational roots and AlgMaclaurin return 1/120.
A recursive-descent parser (AlgParse stops the program on
bad input, AlgRead is total and returns
Result) and a minimally-parenthesising printer round-trip
against each other. A capped-fixpoint simplifier works over a documented
total order (AlgCompare, AlgEqual —
never =). Total differentiation including the general power
rule; AlgExpand AlgFactor AlgPolyCoeffs AlgPolyDivide AlgPartFrac
AlgTogether; AlgLimit AlgLimitInf AlgInteg AlgIntegDef
AlgIntegRules AlgSolve AlgDeSolve AlgTaylor AlgMaclaurin; and
AlgToFn, which compiles an expression to a quotation, so
every numeric word in eng accepts a user-typed formula.
Neither machine includes the other, because a quotation
is a builtin type. The partial words return the language’s
Result, and Err there usually means
“outside the implemented rules” rather than a fault:
Err("IRREDUCIBLE OVER THE RATIONALS", 0) is a
fact about the polynomial. Bounded and honest about it: table-driven
integration, univariate rational factorisation,
substitution-plus-L’Hôpital limits, distinct linear factors
in partial fractions, first-order ODEs. Includes seq,
sinq and str.Compare ByKey Descending ThenBy
OrderWith OrderBy OrderByDesc, over a stable
merge sort — equal items keep their order, which is what makes
ThenBy mean anything. The item with the smallest or largest
key, rather than the smallest number: MinBy MaxBy over
MinByOpt MaxByOpt. GroupBy →
Type Group (GKey/GItems), groups
in first-appearance order and items in source order.
JoinOn (inner) and GroupJoinOn (left outer;
the name Join is str’s).
Distinct DistinctBy Union Intersect Except.
SelectMany Thru Descendants Offspring for a record graph
that holds itself — cycles are unconstructible, so none is checked
for. OptMap OptBind OptOr ThruOpt Choose where a hop can
fail, and TakeWhile SkipWhile Chunk FirstOpt LastOpt SingleOpt
FindOpt. Re-declares nothing: aggregation stays in stats, the big
four stay builtins. Written to index arrays rather than walk lists with
Rest, and self-tail-recursively throughout, because
Rest copies and only self-tail-recursion becomes a loop.
Pure. Includes seq.shoddy.Split Join Trim Replace
StartsWith EndsWith StrRep, built on the
Instr/InstrFrom/Left/Right/Mid
builtins; the fixed-width formatters PadLeft PadRight PadZero
ToFixed CommaGroup Commas; and the boolean conversion trio
IsBool ToBool ToBoolOr over the primitive
ToBoolOpt, mirroring the
IsNumeric/Val/ValOr builtins for
numbers. (ToBool and ToBoolOr were
Bool and BoolOr before v1.7.0.)RxRead
parses a pattern to a tree, compiles it to an instruction program, and
answers Ok(prog) or Err(why, at); every match
word takes the program, never a string. The engine is a Pike
VM — a design that runs all possible matches in step
rather than trying one and backing up — so it never
backtracks, and cost is bounded by pattern size times subject
length: (a|a)*b against thirty as returns at
once. Nothing in the file stops the program: a
malformed pattern is an Err, an absent match or an unset
group a None(), and there is not one call to
Error — which is why
the errors page has no row for it.
RxTest RxFind RxFindFrom RxMatchAt RxFindAll RxGroup RxGroupOr
RxCount RxSource RxDisasm RxReplace RxReplaceFirst RxReplaceWith RxSplit
RxQuote, with RxNoOpts/RxFoldOpts for
ASCII folding, dot-all and multiline. The price of the guarantee,
refused by name at its offset: no backreferences, no
lookaround, no atomic or possessive forms, no named groups, no scoped
inline flags. The domain is UTF-16 code units classed as ASCII, so a NUL
is an ordinary literal that FromCodes can build and
\0 matches; semantics are leftmost-first (Perl), not
leftmost-longest (POSIX). A counted repetition compiles by duplication,
so RxMaxCode() caps the program at 2000 instructions and
refuses past it at parse time. Includes nothing: every word it needs is
a builtin.And/Or/Not are Boolean operators
only, so every bit operation here is Mod and
2 ^ n written once and named. Bases and rendering
(BoolInBase BoolFromBase BoolFromBaseOr BoolHex BoolBinS BoolOctS
BoolHexW BoolBinW BoolGrouped BoolCard BoolCardWith); bit
operations (BoolBitAnd BoolBitOr BoolBitXor BoolBitNot BoolBitNand
BoolBitNor BoolBitXnor BoolShl BoolShr BoolShrA BoolRotl
BoolRotr); masks, fields and inspection (BoolMask
BoolMaskFrom BoolTest BoolTestAny BoolSet BoolClear BoolToggle
BoolGetField BoolSetField BoolDecode BoolBitAt BoolSetBit BoolPopCount
BoolLowBit BoolHighBit BoolLog2Floor BoolLeadZeros BoolReverse
BoolBitList BoolFromBitList BoolIsPow2 BoolNextPow2, over
Type BoolField); two’s complement — the
standard binary spelling of negative numbers (BoolSigned
BoolUnsigned BoolSignExtend BoolNeg BoolAddW BoolSubW BoolMulW
BoolCarries BoolOverflows); the codes (BoolGray BoolFromGray
BoolParity BoolParityBit BoolHamming BoolBcd BoolFromBcd
BoolFromBcdOr); the nine gates over Boolean
(BoolAnd BoolOr BoolNot BoolNand BoolNor BoolXor BoolXnor
BoolImplies BoolIff); truth tables over a quotation, never a
parsed string (BoolRowInputs BoolRowIndex BoolTruthTable
BoolMinterms BoolMaxterms BoolFromMinterms BoolIsTautology
BoolIsContradiction BoolEquiv BoolTableText, over
Type BoolRow); and Quine–McCluskey minimisation
(BoolPrimeImplicants BoolMinimise BoolMinimiseWith BoolTermCovers
BoolTermMinterms BoolTermText BoolTermSumText BoolSopText
BoolPosText, over Type BoolTerm). The domain
is checked, never masked: every value is a non-negative whole
number below 253, and every word errors naming itself on
anything else, since a silently masked input is a plausible answer with
the bug upstream. Widths run 1 to 53, because a 54-bit
word cannot represent its own top bit. Exactly three words are
width-free (BoolBitAnd, BoolBitOr,
BoolBitXor), and variable 1 is the most significant
bit of a row index. Reading external text answers the
language’s Result (BoolFromBase,
BoolFromBcd), and an absent bit answers Option
(BoolLowBit, BoolHighBit) — no
−1 sentinel anywhere. BoolMaxVars() = 12 is a hard
refusal, since a 216-row table is not a slow answer but a
hang. Includes seq, sinq and
str.Type Money over whole cents. Whole cents stay exact past
$90 trillion in a double, so no DECIMAL type is needed:
Dollars MoneyVal MoneyAdd MoneySub MoneyMul (explicit
rounding) MoneySum MoneyLt MoneySplit (penny-preserving
allocation) MoneyFmt. Includes str.ReadLines (normalizes
\r\n, drops the trailing newline), WriteLines,
AppendLine. Includes str.shoddy.FinPv FinFv FinPmt FinNper and the annuities), loans and
amortisation (FinBalance FinAmortAt FinSchedule
FinExtraPayoff), appraisal (FinNpv FinNpvOf FinIrr FinMirr
FinPayback), small business, the four depreciation methods, the
day-count bases (FinDays360 FinDateDiff FinYearFrac, over
julian.shoddy’s JulDate — the calendar this
machine once carried as a stated stand-in) and bonds
(FinBondPrice FinYtm FinMdur). Rates are decimal fractions
per period, and everything is a plain Number in whole
currency units. money.shoddy is deliberately not used:
Money has no division, so half of this machine could not be
written in it. The statistics a finance report wants are here too, with
a Fin prefix, so a caller never has to know which machine a
word lives in. Includes seq, stats,
math, str and julian.RecPos RecSeek RecCount GetRec PutRec
AppendRec AllRecs (records numbered from 1).List Of Pair). Keys compare with =, so any
equatable value works. DictPut DictGet DictGetOr DictHas DictDel
DictKeys DictVals. Pure; a hashed implementation could replace it
behind the same words. Includes seq.Erf/GammaP/BetaI and
Sort builtins. Descriptive: Sum Product Maximum
Minimum Mean (Average is an alias)
WeightedMean GeoMean HarmonicMean Median Quantile Percentile Iqr
RangeOf Var VarP StdDev StdDevP StdErr Mad Skewness Kurtosis ZScore
ZScores Outliers OutliersZ Freq RelFreq Modes Ecdf Cov Correl Spearman
LinFit (→ Fit). Distributions:
NormPdf NormCdf Chi2Cdf TCdf FCdf NormInv TInv. Inference
(all → TestResult(Stat, Df, PVal), two-sided):
TTest1 TTest2 TTestPaired ZTest1 Anova ChiSqGof ChiSqTest
TwoPropZTest, plus MeanCI PropCI (→
Pair) and CohenD. Sample forms divide by
n−1; quantiles interpolate (R type 7). Includes
seq.shoddy and dict.shoddy.path & ".idx": a header with the root page,
live count, and free-list head; the free list threads through dead
slots’ payload bytes). Opening reads the header — O(1), no
scan — and falls back to a one-time rebuild scan only when the
index file is missing. Index entries hold slot numbers, and keys are
re-derived through KeyOf, so Number and String keys both work with no
width limit. Mutating words still return the handle (unchanged —
it carries only the two file handles and your functions). Keys come from
a user KeyOf function; deletes free slots for reuse.
IsamOpen IsamClose IsamInsert IsamUpdate IsamDelete IsamGet
IsamGetOr IsamHas IsamCount IsamAll IsamRange IsamFirst IsamLast
IsamNext IsamPrev. Sequential access is cursorless (key-order
navigation). No crash safety or concurrency; one live handle at a
time.Type Json = JNull | JBool | JNum |
JStr | JArr | JObj, where an object’s members are a
List Of Pair — dict.shoddy’s association list
exactly, so JGet/JPut delegate rather than
duplicate. Reading is a guard pair: JsonParse stops the
program on bad input; JsonRead is total and returns
JOk or JErr(why, at) with the 1-based offset,
which is how a program survives input it did not write. The character
scanners are self-tail-recursive, so they compile to loops and a long
document costs no stack. Value/array/object recursion is mutual, and
therefore bounded by a depth limit — 200 by default. Numbers are
validated against the JSON grammar before Val sees them,
since Val is strtod and far laxer.
JsonRead JsonReadDepth JsonParse JsonLoad JsonText JsonPretty
JsonSave JsonEscape JsonCanonical JGet JGetOr JHas JNth JPath JPathOr
JKeys JCount JEqual IsJNull JStrOr JNumOr JBoolOr JPut JDel JNumList
JStrList IsFinite. Known limits: Str gives ten
significant digits, so arbitrary doubles do not round-trip bit-exactly;
a string cannot carry NUL, since Chr(0) is the empty
string, so