Sushi is a small scripting language with first-class syntax. Source is read into objects. Commands decide which objects to evaluate and which to keep as syntax. That model is the center of the language. It is also why Sushi can express control flow, records, classes, and DSLs without introducing separate subsystems for each.
nimble test
nimble buildbin
nimble buildlibThe CLI supports:
sushi
sushi --run path/to/file.sushi
sushi fmt path/to/file.sushi
sushi fmt --write path/to/file.sushi
sushi pen input.sushi output.html
sushi pen --watch input.sushi output.htmlrecord Counter count
fun bump-twice [counter amount] do
set counter.count (counter.count + [eval amount])
set counter.count (counter.count + [eval amount])
end
var c [new Counter 10]
bump-twice c (1 + 2)
if (c.count > 15) do
write-line "count: \(c.count)"
end
The important part is not the syntax. It is the timing.
record Counter countbuilds a normal construct you can instantiate withnew.bump-twicereceives raw arguments.amountis syntax untilevalasks for its value.- updates still happen through normal command-shaped code.
At the top level, a Sushi file is a stream of commands.
write-line "hello"
write-line "world"
The reader turns source text into objects:
- numbers:
1,42,3.14 - booleans:
T,F - text:
"hello" - symbols:
name,write-line,count+= - commands
- blocks
- sequences and tables
That distinction matters:
"hello"is already a valuehellois a symbol[+ 1 2]is a command objectdo ... endis a block object
Sushi does not apply one universal eager-evaluation rule. Each command chooses what to evaluate. Native commands do this directly. User-defined functions also receive raw argument objects by default.
That is the working model:
- Read source into objects.
- Pass those objects to a command.
- Let the command decide what evaluation means.
Bracket commands delay execution:
var expr [+ 1 2]
eval expr
Grouped expressions are also syntax objects:
set x (x + 1)
Sushi has list and table literals:
#[1 2 3]
{ name "Miso" hp 10 }
Lists are ordered sequences:
var xs #[10 20 30]
xs.(1)
Tables map keys to values:
var hero { name "Miso" hp 10 }
hero.name
hero.(name)
With table literals, bare symbol keys stay literal keys. They are not evaluated as variable lookups unless you use an explicitly evaluated form.
Indexing uses .(...):
xs.(1)
hero.(name)
Blocks are objects too:
do
write-line "hello"
end
A block is not executed just because it exists. Some command has to run it.
\\ is a reader rewrite. It is not a separate runtime feature.
Trailing \\ opens an indented block:
doc Main \\
div { class "prose" } \\
p { } "Hello"
This is rewritten before parsing to the equivalent do ... end form.
Inline \\ rewrites the rest of the line into a block:
fun into-html [xs] \\ into-html-from xs 0
That is equivalent to:
fun into-html [xs] do
into-html-from xs 0
end
This matters because pen.sushi uses \\ heavily, but the semantics are still ordinary Sushi blocks.
else and elif are part of the everyday surface syntax:
if F do
1
else
2
end
They behave exactly like you would expect from ordinary conditional chaining.
Under the hood, the reader rewrites them into explicit end / do structure before parsing. That detail matters only if you are trying to understand how much of Sushi is implemented as reader sugar rather than as special evaluator logic.
Sushi has built-in list and table literal syntax:
#[1 2 3]
{ name "Miso" hp 10 }
At the reader level, these are rewritten to ordinary command forms:
[list 1 2 3]
[table name "Miso" hp 10]
That matters because list and table literals are not special evaluator cases. They are convenient surface syntax for normal Sushi objects.
Sushi allows whitespace before an operator suffix ending in = when the left side is:
- a plain symbol, such as
x += 2 - a dot access ending in a plain symbol, such as
a.b += 3
So these parse the same:
x += 2
x+= 2
a.b += 3
a.b+= 3
Operationally, you can think of:
a.b += 3
as turning into a command whose final callable name is b+= on the dot chain. In other words, it behaves like the parsed form [a.b+= 3]. This keeps update syntax inside the normal command/object model instead of introducing a separate assignment grammar.
Bindings are explicit:
var hp 10
set hp (hp - 1)
let current hp do
write-line current
end
vardefines a binding in the current scopesetupdates an existing binding or assignable targetletcreates a nested scope, binds one name, and evaluates a block in that scope
Control flow is command-based and block-based:
if (hp > 0) do
write-line "alive"
end
cond do
(hp > 10): "healthy"
(hp > 0): "hurt"
T: "down"
end
while (hp > 0) do
set hp (hp - 1)
end
User functions receive raw arguments:
fun twice [x] do
+ [eval x] [eval x]
end
twice (1 + 2)
That is why Sushi functions are naturally macro-like. The function controls evaluation timing.
Lambdas use fn:
var add1 [fn [x] (x + 1)]
Modules are loaded with use:
use io
use syntax global
use module binds the module value. use module global also exposes its exports in the current module scope.
Records are part of the everyday language surface:
record V3 x y z
var v [new V3 1 2 3]
v.x= 10
v.y
At the language level, a record is a convenient way to define a field-based type with an initializer and setters.
Under the hood, record is implemented in the prelude by building class syntax and evaluating it with syntax.eval-node. That matters because it shows the design directly: useful surface features can be built from ordinary syntax objects and ordinary evaluation.
Classes use the same model, not a separate one:
class Counter [] do
field {
count 40
}
fun bump [delta] do
set self.count [+ self.count delta]
end
fun read [] do
self.count
end
end
var counter [new Counter]
counter.bump 2
counter.read
Classes support:
- fields with optional defaults
- instance methods with
fun - inheritance with
class Child [Base] - construction with
new - field access with dot syntax
Methods still receive raw arguments. Object orientation does not change the evaluation model.
Errors are values only after you catch them. Until then, a failing evaluation raises and unwinds normally.
catch evaluates an expression and intercepts any raised error:
catch do
syntax.eval-node [table "kind" "bogus"]
end do
eval error-message
end
With one argument, catch returns nil on failure. With a second block argument, it runs that block and binds the message text to error-message.
Use catch when failure is part of normal control flow and you want to recover explicitly.
?? is a postfix error-coalescing operator. It evaluates the left side and returns the right side only if the left side fails.
[maybe-read-config] ?? "default"
If the right side is a block, that block also receives error-message:
[maybe-read-config] ?? do
"config failed: \(error-message)"
end
Use ?? when you want a local fallback value and want the expression to continue.
!! is the terminating form. It evaluates the left side, and if that fails, it runs a fallback block and then exits with status 1.
[start-server] !! do
write-line "fatal: \(error-message)"
end
Use !! for command-line entrypoints and other cases where failure should report context and then stop the process.
catch, ??, and !! fit the same general rule as the rest of Sushi: they are ordinary commands/operators that decide when to evaluate their operands and what to do with failure.
This is the part to learn precisely.
eval evaluates one object as code.
Use it when a function or method receives delayed syntax and wants its meaning now.
fun show-value [x] do
write-line [eval x]
end
eval-value resolves to the underlying value without the normal command-call step.
Use it when you want the value behind a symbol or delayed object, rather than asking Sushi to treat that object as code to execute.
This is useful in helpers that work with counters, indices, iterators, and stored values.
eval-here evaluates delayed syntax in the current local scope while preserving captured context.
Use it when helper-local bindings should be visible while evaluating caller-originated syntax.
pen uses this for inline HTML insertion through @.
replay executes a captured block in a replay scope.
Use it for DSL blocks and helper bodies that need both:
- names defined inside the helper
- names captured from the caller
If a block DSL works in Sushi, replay is usually the reason.
run starts from source text:
run "write-line \"hello\""
Use run when you have text and want it parsed and executed. Use eval when you already have a Sushi object.
syntax.eval-node evaluates a constructed AST node.
Use it when you are generating syntax programmatically. record uses this path to turn generated class syntax into a real definition.
capture stores caller-authored syntax together with its environment.
raw gives you syntax as syntax, without treating captured syntax as something to replay through its captured environment.
Together with eval, eval-here, and replay, these are the tools for writing syntax-aware abstractions.
When Sushi delays caller code, it keeps more than the syntax object. It also keeps the caller environment associated with that syntax.
That is why a helper can receive a user expression or block, evaluate it later, and still have names resolve the way the caller expected.
Without captured syntax, delayed evaluation would be fragile. With it, delayed syntax is usable as a normal programming technique.
pen.sushi is the clearest example of the language model paying off.
It defines local commands such as:
htmlheadbodydivph1@
Then it replays a caller block inside that local environment:
fun build-html [blk] do
var parts #[]
fun div [kv blk] \\ element "div" kv blk
fun p args \\ text-tag-call "p" args
fun @ [html] do
append parts [eval-here html]
end
replay blk
into-html parts
end
A pen program can then look like this:
doc Main \\
html {} \\
head {} \\
title-tag "Demo"
body {} \\
div { class "prose" } \\
h1 "Sushi"
p "Rendered through a Sushi DSL."
This is not special parser support. It is ordinary Sushi:
- blocks are values
- helper-local commands are ordinary functions
- caller blocks carry captured context
replayruns the block in the right mixed scopeeval-herehandles expression-style insertion- the result is reduced to HTML text
That is the language in one example.
Sushi is built around one idea instead of many separate rules.
- Source becomes objects.
- Commands choose evaluation.
- Delayed syntax is normal.
- Blocks are data.
- Records can be built from syntax tools.
- Classes keep the same evaluation model.
- DSLs like
penare ordinary library code.
The result is a language where advanced features are not exceptions to the core model. They are consequences of it.