Shoddy Documentation

The Language Specification

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.

1. Philosophy

2. Lexical structure

3. Types

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.

4. Definitions

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:

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.

5. Expressions (applicative bodies)

6. Function values — where the brackets went

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:

  1. Bare names auto-quote in argument position. A name that is not a local binding is passed as a function value: Fold(xs, 0, +), Map(xs, Square), Filter(xs, IsEven). To force a bare name to evaluate instead, parenthesize it: f((g)).
  2. Sections pre-load an operator’s trailing arguments: Filter(xs, >=(100)) — “≥ with 100 pre-loaded” — instead of [ 100 >= ].
  3. Fn lambdas for anything genuinely inline: Filter(xs, Fn(x) => x Mod 2 = 0). Sugar for a quotation beginning with an implicit Take.
  4. [ ... ] 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.

7. Lists

{ 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))

8. Records: Type

QuickBASIC’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:

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().

8.1 The language’s own types

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)"

9. Arrays

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.

10. Word reference

Arithmetic
+ - * / Mod ^ Negate Abs Min Max Sqr Floor Ceil Round — owned and documented by math.
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.
Special functions
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.
Random
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
Comparison
= <> < > <= >= (numbers or strings) → Boolean
Logic
And Or Not True False (strictly boolean)
Strings
& 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.
Lists & arrays
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.
Records
Type declarations; per-type constructor and accessor words; With for functional update
Functions
Call Ifte — owned and documented by lineshaft.
I/O
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.
Files (text, whole-file)
No handles — a text file is read or written whole, and paths resolve against the working directory. The words: 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)
        ""
Files (binary, random-access)
Classic BASIC record files. 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.
TCP/IP sockets (gated)
A network capability, off unless the mill runs with --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.
Command line
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.
Time
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.
Graphics (the scribbler)
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.
Three words reach the same windows by SLOT NUMBER, and they exist for one caller. A 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 (the buzzer)
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.
Errors
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.
Stack (concatenative bodies)
Dup Drop Swap Over Rot Nip Tuck Depth

11. Desugaring summary

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

12. Tail calls and errors

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.

13. Include and the standard library

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:

  1. Relative to the including file.
  2. The directory named by the SHODDYLIB environment variable — a named setting the operating system passes to every program.
  3. The machine library shipped with the mill that is running: 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.

13.1 Namespaces

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 NAMESPACEHints 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:

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:

A further file per domain, the reckoner seeds, bridges the rest of the standard library into reckoner via its RckReg registration API. Each has no bearing outside that engine, so none is catalogued here individually. Reckoner’s own page lists every one of them with what each bridges, and does so from the tree rather than from a hand-written list that goes stale the moment a seed is added.

Appendix A. The concatenative core

The compilation target is itself a legal, complete language: Joy with BASIC vocabulary and layout. Every word is a pure Stack → Stack function, and composition is simply writing words next to each other. (This appendix is the law; the tutorial companion — worked traces, the stack drawn after every word — is The Stack.)

Def Fact            Rem ( n -- n! )
    Dup 0 =
    If Then
        Drop 1
    Else
        Dup 1 - Fact *

Appendix B. Design notes