Soma is a strongly, statically typed, expression-oriented language. The
syntax is F#-flavoured ML with some Python habits: significant
indentation, blocks introduced with :, # comments, and
and/or/not spelled as words.
It is young — what runs today is a tree-walking interpreter (compilers come later), and everything here is subject to change. Soma is a working title. You can try everything on this page in the playground.
# Comments start with '#'.
fn fib(n: int) -> int:
if n < 2:
n
else:
fib(n - 1) + fib(n - 2)
for i in 0..11:
print(fib(i))
Values are introduced with let, and types are inferred unless you
annotate. Bindings are immutable by default; let mutable opts in,
and assignment uses <-, which only works on mutable bindings — the
type checker enforces both.
let x = 42 # type inferred: int
let y: float = 1.5 # optional annotation, checked
let mutable count = 0
count <- count + 1
Shadowing is allowed, ML-style: let x = x + 1 creates a new x.
Functions are introduced with fn. Parameters are a parenthesized,
comma-separated list with type annotations; the return type is inferred
unless annotated with ->. The body follows : — inline on the same
line, or an indented block:
fn add(a: int, b: int): a + b # return type inferred: int
fn fib(n: int) -> int: # explicit return type
if n < 2: n
else: fib(n - 1) + fib(n - 2)
One wrinkle: recursive functions need an explicit return type — while the body is still being inferred there is nothing to assume for the recursive call. The checker reminds you if you forget.
Arguments can be passed by name, in any order after the positional ones, and trailing parameters can have defaults (evaluated at call time):
fn area(w: float, h: float = 1.0): w * h
print(area(3.0)) # 3.0
print(area(h = 4.0, w = 3.0)) # 12.0
Anonymous functions are the named form minus the name — same parens, same colon-body rule:
let double = fn(n: int): n * 2
fn twice(f: (:int) -> int, x: int): f(f(x))
print(twice(double, 10)) # 40
Functions are first-class values and close over their environment, including mutable bindings:
fn make_counter():
let mutable count = 0
fn next() -> int:
count <- count + 1
count
next
let tick = make_counter()
print(tick()) # 1
print(tick()) # 2
A block’s value is its last expression:
let x =
let a = 10
let b = 20
a * b # x = 200
if is an expression too. : introduces each body — an expression on
the same line is an inline body; end of line means an indented block
follows. There is no then and no elif; else followed directly by
if chains without stacking indentation:
let n = 7
let parity = if n % 2 == 0: "even" else: "odd"
let sign =
if n < 0: "neg"
else if n == 0: "zero"
else: "pos"
Both branches must have the same type, and an if with no else must
have type unit.
Values are not silently thrown away: discarding a non-unit value is an
error. Write let _ = ... to discard one deliberately. _ works in
any binding position — parameters, loop variables, destructuring,
patterns — and always means the same thing: bind nothing. It can never
be read.
Type names are lowercase: int (64-bit), float (64-bit), bool,
str, and unit (written ()). Identifiers are case-sensitive, but
case carries no meaning — capitalize your own names or don’t, as you
like.
There are no implicit conversions, not even int → float.
Convert explicitly with the builtins:
| builtin | type |
|---|---|
print(x) | any → unit |
str(x) | any non-function → str |
int(x) | float → int (truncates) |
float(x) | int → float |
Integer overflow and division by zero are runtime errors, not wraparound.
Tuples, records, and parameter lists are all one thing in Soma: a
product type, an ordered set of fields. A field is
[name] [: type] [= value] — a bare identifier is always a name, and a
type only ever appears after a :. Fields without a name start with the
colon: (:int, :str) is the type of an anonymous int/str pair.
let pair = (1, "two") # a tuple
let one = pair.0 # positional projection
let (a, b) = pair # destructuring
let p = (x = 1.0, y = 2.0) # named fields (bind with '=')
print(p.x)
(5) is just 5 — grouping. A 1-tuple takes a trailing comma, Python
style: (5,). A lone named field does too: (x = 1,).
Named types are declared with type, which always mints a new,
distinct type:
type meters = float # a newtype: same operations as
let m = meters(5.0) # float, but never mixes with it;
let f = float(m) # convert explicitly
type point = (x: float, y: float) # a record
let p = point(x = 1.0, y = 2.0) # construct by name or position
type alias distance = meters # transparent alias, no new type
meters + meters works; meters + float is an error. Types are
nominal: two distinct declared types never mix, even if they look
identical.
Records can also be declared multiline, and fields can have defaults — supplied when construction doesn’t mention them (defaults must be trailing, and are evaluated per construction):
type widget =
name: str
price: float = 0.0
tags: [str] = []
print(widget("plain")) # ("plain", 0.0, [])
print(widget(name = "t", tags = ["a"]))
Variant payloads take defaults the same way
(type event = click: (x: int = 0, y: int = 0) | quit). Defaults
belong to declarations — a type annotation can’t carry one. A field
with only a default infers its type from it (size = 42), and small
records fit on one line without parens:
type widget = name: str, size = 42.
Contextual construction: where the expected type is already known, the constructor name can be elided — a tuple constructs, going through whatever constructor the type has (memberwise or explicit):
type widget = name: str, size = 42
let w: widget = ("kettle", 3)
let d: widget = ("toaster",) # defaults fill in
fn describe(w: widget): print(w.name)
describe(("socket", 9))
let ws: [widget] = [("a", 1), ("b", 2)]
In the multiline form, an annotated binding takes one constructor
argument per line, and -- lines separate the objects of a typed
array (the annotation selects the container kind — without one, bare
lines are a list, as before):
let inventory: [widget] =
"hammer"
12
--
"wrench"
9
A tuple-typed value still never converts — nominal identity is not structural; only literals construct.
You can ascribe a type to any parenthesized expression: (e : ty).
A type has at most one constructor (@init(.none), below, opts out
entirely). If you don’t write one, you get
the memberwise default seen above — fields by position or name. To write
your own, add a with impl: block directly after the declaration; the
unnamed fn (params) -> Self: is the constructor, and .field = value
initializes a field:
type circle =
radius: float
area: float
with impl:
fn (r: float) -> Self:
.radius = r
.area = 3.14159 * .radius * .radius
let c = circle(2.0)
Once initialized, a field can be read (.radius above). The checker
makes sure every field is initialized exactly once on every path before
the body ends — except fields with declaration defaults, which may be
left alone and take their default when the body finishes.
Writing anything in an impl block — a constructor or methods —
replaces the default constructor. If you want a custom impl and the
default constructor, opt back in with @init(.auto). The other
direction exists too: @init(.none) generates no constructor at all
— nothing constructs the type unless an impl provides an explicit
one. Either mode may be written before the type declaration or
before its impl (they must agree if both). The modes govern what the
compiler generates; constructors you write yourself are always a
door.
Methods live in the same with impl: block: a named fn whose first
parameter is self (written bare — its type is always the type being
extended). Inside a method, .name is shorthand for self.name, and
that includes calling sibling methods:
type circle =
radius: float
@init(.auto)
with impl:
fn area(self) -> float:
3.14159 * .radius * .radius
fn scaled(self, k: float) -> Self:
circle(radius = .radius * k)
fn compare(self) -> str:
"doubling multiplies area by " + str(.scaled(2.0).area() / .area())
let c = circle(radius = 2.0)
print(c.area())
print(c.scaled(3.0).radius)
print(c.compare())
(The @init(.auto) is doing real work there: any impl — even
methods-only — replaces the default constructor.)
Newtypes and sum types take methods the same way:
type shape =
| square: (side: float)
| dot
with impl:
fn area(self) -> float:
match self:
is .square(s): s * s
is .dot: 0.0
print(shape.square(side = 3.0).area())
A few rules to know:
c.area() always means the method. A field
holding a function is still callable — x.f(3) works when no method
is named f, and (x.f)(3) insists on the field.c.area is an error — wrap it in
a lambda to pass it around.self inside a constructor — self isn’t
fully built yet.[t] is the list type, [1, 2, 3] the literal. Elements are separated
by commas or newlines. Indexing is bounds-checked, + concatenates,
== compares by content, and .len is a property (no parens):
let primes = [2, 3, 5, 7]
print(primes[0])
print(primes.len)
print([1, 2] + [3]) # [1, 2, 3]
An empty list needs a type from context: let xs: [int] = [].
In a multiline =-binding, if the first line is a bare value the whole
body is a list literal — one element per line, no brackets needed:
let primes =
2
3
5
(If the first line is a statement, the body is an ordinary computed block instead. Data or code, decided by the first line.)
Dicts use brackets too: [str: int] is a type, ["one": 1, "two": 2] a
literal, and [:] the empty dict. The colon after the first element is
what makes it a dict rather than a list.
Lookup returns an optional — a missing key is ordinary data, not
an error (see the Optionals section; is makes the read pleasant):
let scores = ["anna": 3, "ben": 5]
print(scores["anna"]) # option.some(3)
print(scores["zoe"]) # option.none
if scores["ben"] is .some(n):
print(n * 10) # 50
print(scores.len)
Elements are assigned with d[k] <- v (insert-or-update; the variable
must be mutable), and ?? supplies a default for a missing key —
together they make the counting idiom a one-liner:
let mutable counts: [str: int] = [:]
for w in ["a", "b", "a"]:
counts[w] <- (counts[w] ?? 0) + 1
print(counts) # ["a": 2, "b": 1]
print(counts.keys) # ["a", "b"]
print(counts.values) # [2, 1]
xs[i] <- v), and indexing is
different from dicts, deliberately: xs[i] out of bounds is a
runtime error — a wrong index is a logic bug, a missing key is a
normal condition.?? works on any optional, is right-associative (a ?? b ?? 0
falls through left to right), and only evaluates its right side on
.none.The bare multiline form works here too — a key: value first line
commits to a dict:
let scores =
"anna": 3
"ben": 5
In positions where the type is known (annotated lets, arguments,
assignments…), () can stand in for an empty container:
let xs: [int] = ().
set<t> is the set type: insertion-ordered, unique, equatable
elements. There’s no set literal (braces are reserved) — construct with
set(...):
let s = set(3, 1, 2)
print(s.len) # 3
print(2 in s) # membership: true
print(s + set(2, 9)) # union: set(3, 1, 2, 9)
for x in s: # insertion order
print(x)
x in s is the membership test — and in works wherever for
does: list elements, dict keys (k in d matches for k in d), set
elements, and range bounds (i in 0..n).+ is union; duplicate elements in a set(...) call are an error
(union just keeps one).let s: set<int> = set() (or ()).A sum type is a set of |-separated variants, each with an optional
payload using the same field syntax as records. Variants are scoped
to their type — qualify them with the type name, like shape.circle:
type shape =
| circle: (radius: float)
| rect: (w: float, h: float)
| dot
let c = shape.circle(radius = 1.0) # payload variants are constructors
let d = shape.dot # payload-less variants are values
Wherever the expected type is already known — an annotated binding, an
argument, a list element, a match arm — the qualifier can be elided,
leaving just the dot. (The dot-prefix always means “a name from the
context”, the same way .field works inside methods.)
let e: shape = .dot
let all: [shape] = [.dot, .circle(radius = 1.0), .rect(w = 2.0, h = 1.0)]
type intlist = nil | cons: (head: int, tail: intlist) # recursion works
let l: intlist = .cons(head = 1, tail = .cons(head = 2, tail = .nil))
match is an expression. Arms live in an indented block, each
introduced by is; the scrutinee supplies the type, so arms always use
the dotted form. The default is an else: at the match’s own
indentation — the same rule as if/else:
fn area(s: shape) -> float:
match s:
is .circle(r): 3.14159 * r * r
is .rect(w, h): w * h
is .dot: 0.0
fn describe(s: shape) -> str:
match s:
is .dot: "a dot"
else: "something with area"
print(describe(.circle(radius = 1.0)))
Read an arm with the scrutinee: “s is .circle(r)?”
else:..circle(r)); _ skips a field.name: type — each arm is really a little newtype, and
its constructor follows: a product payload gets the memberwise form
(phone: (area: int, number: int) constructs as
.phone(area = 1, number = 2)), any other type gets a one-argument
form (email: str, constructed as .email("a@b.c")).t? is the optional type: a value that is either .some(...) or
.none. It is a sum type — option with arms none and some: t
— so everything from the previous section applies: the contextual dot,
match, exhaustiveness, structural equality.
fn describe(o: int?) -> str:
match o:
is .some(v): "got " + str(v)
is .none: "nothing"
let a: int? = .some(5)
let b: int? = .none
print(describe(a))
print(describe(b))
print(describe(.some(7)))
option is the qualifier when there’s no context to infer from —
option.some(5) works anywhere (the payload type comes from the
argument). option.none on its own can’t know its type, so it needs
a checking position, where you’d just write .none anyway.
For the common “if present” shape, is works as an expression: it
tests a value against a pattern, and its bindings flow into the branch
it guards — including through and:
let o: int? = .some(5)
if o is .some(v):
print(v)
else:
print("nothing")
if o is .some(v) and v > 3:
print("big: " + str(v))
while works the same way, which makes short work of recursive sums:
type intlist = nil | cons: (head: int, tail: intlist)
let mutable l: intlist = .cons(1, .cons(2, .nil))
while l is .cons(h, t):
print(h)
l <- t
Without bindings, is is an ordinary boolean anywhere
(let present = o is .some(_)); a binding pattern outside an
if/while condition is an error — the binding would have nowhere to
flow. Bindings don’t escape their branch, and don’t survive not or
or.
A few more things worth knowing:
() can stand in for .none in checking positions, like it does
for empty containers: let c: str? = ().let x: int? = 5 is an error —
write .some(5). (The error says so.)t?? doesn’t flatten: an optional optional is just a sum of a
sum, and .some(.none) is different from .none.?? defaults an optional (o ?? 0; see Dicts for the counting
idiom). Chaining (?.) may come later; is and match are the
general ways in.Types can take type parameters, declared with a tick after the name and used anywhere a type can appear:
type result<'t, 'e> =
| ok: 't
| err: 'e
let r: result<int, str> = .ok(5)
match r:
is .ok(v): print(v + 1)
is .err(m): print(m)
Everything from sums carries over: the contextual dot, patterns,
exhaustiveness. Where the arguments determine the parameters, no
annotation is needed (result.ok(5) can infer 't but not 'e, and
says so); recursion works
(type tree<'t> = leaf | node: (left: tree<'t>, value: 't, right: tree<'t>));
products work too (type pair<'a, 'b> = (first: 'a, second: 'b), with
pair(1, "x") inferring both).
Functions are generic too. Any tick in a signature that isn’t
already in scope binds at that function — no separate declaration
needed (though an explicit fn first<'a, 'b>(...) list is also
accepted). The parameters are inferred at each call from the
arguments:
fn map(xs: ['t], f: (:'t) -> 'u) -> ['u]:
let mutable out: ['u] = []
for x in xs:
out <- out + [f(x)]
out
let ns = [1, 2, 3]
print(map(ns, fn(n: int): n * n)) # [1, 4, 9]
print(map(ns, fn(n: int): str(n) + "!")) # ["1!", "2!", "3!"]
Parameters are unconstrained, and that means the permissive thing: a
't supports whatever the body asks of it. A generic body is
checked at each call, against that call’s actual types — so sum
(which needs + on the elements), sort (an ordering), contains
(equality) are all just writable:
fn sum(xs: ['t]) -> 't:
let mutable a = xs[0]
for i in 1..xs.len:
a <- a + xs[i]
a
print(sum([1, 2, 3, 4])) # 10
print(sum(["con", "cat"])) # concat
A type that can’t do what the body asks fails at that call, with
an instantiation trace pointing at the operation and every call that
led there (sum([true]) reports “‘+’ is not defined for bool — in
sum<bool>, for the call at …”). Two honest costs come with the
permissiveness: a generic body that is never called is never checked,
and a signature no longer documents what it needs — the body is the
spec. When trait bounds arrive they’ll be the opt-in strict dial: a
bounded parameter is checked once, at the declaration, against the
bound. (Pheno flips the default: bounds required, duck typing off.)
Generic types take methods too. The type’s ticks are bound by the receiver; a method can add its own ticks, bound afresh at each call from the arguments:
type maybe<'t> = | nothing | just: 't
with impl:
fn or_else(self, d: 't) -> 't:
match self:
is .just(v): v
is .nothing: d
fn convert(self, f: (:'t) -> 'u) -> maybe<'u>:
match self:
is .just(v): .just(f(v))
is .nothing: .nothing
let m: maybe<int> = .just(21)
print(m.or_else(0)) # 't from the receiver: int
print(m.convert(fn(n: int): str(n))) # 'u from this call: str
(On a generic product, add @init(.auto) to keep the memberwise
constructor — explicit constructors on generic types aren’t supported
yet.)
Unused parameters need no names. When a signature never mentions the parameter again, the generic name can stand bare — it means “some instance”:
fn describe(m: maybe) -> str: # a maybe of anything
match m:
is .just(_): "something"
is .nothing: "nothing"
print(describe(m))
Each bare occurrence is a distinct fresh parameter — two bare
maybes are unrelated — so the moment two positions must agree, or
the body needs the payload’s type, name it (maybe<'t>). A bare
generic in a return type is rejected at the declaration (no call
could ever infer it), and in data positions — fields, let
annotations — the arguments are still required. The rule is uniform,
prelude included: a parameter o: option accepts any optional.
In fact option itself is an ordinary declaration in std, the
standard library. The prelude is simply the subset of std that
is in scope everywhere — it declares nothing of its own, it imports
and re-exports:
# std.soma
export type option<'t> = none | some: 't
export type result<'t, 'e> = ok: 't | err: 'e
# prelude.soma — what you get without asking
import from std: option, result
export option, result
t? is sugar for option<t>, and everything optionals do falls out
of the machinery above. result is there for the same reason and
needs no declaring:
fn parse(s: str) -> result<int, str>:
if s == "42": .ok(42)
else: .err("not a number I know")
match parse("42"):
is .ok(n): print(n)
is .err(m): print(m)
Because the prelude is just an outer scope, your own declarations
shadow it: a type result = | yes | no of your own is simply the one
in scope, and t? keeps meaning what it always meant. Nothing is
lost when you shadow — the names live canonically in std, so
import std (or import from std: result as res) gets them back,
and they’re the same types either way.
(A leading | is only ever required for a single-variant sum —
type t = | foo — where type t = foo would mean a newtype over
foo. Multiline sums keep a | per line.)
while cond: and for x in xs: take the usual inline-or-block body and
have type unit. Both accept an else: which runs iff the body never
executed — the “it was empty” case (not Python’s no-break rule):
let primes: [int] = []
for p in primes:
print(p)
else:
print("no primes")
for also iterates ranges: a..b counts from a up to but not
including b (so 0..xs.len is exactly a list’s indices). A range is
an ordinary value with its own type:
for i in 0..3:
print(i) # 0, 1, 2
let xs = ["a", "b", "c"]
for i in 0..xs.len:
print(xs[i])
Endpoints are ints; an empty range (3..3, or a high end below the low
end) runs the else:. That’s all a range does for now — no steps, no
floats, no membership tests yet.
while is there when the trip count isn’t known up front:
let mutable n = 27
let mutable steps = 0
while n != 1:
n <- if n % 2 == 0: n / 2 else: 3 * n + 1
steps <- steps + 1
print(steps)
Precedence, loosest to tightest:
or
and
not
== != < <= > >= (no chaining; combine with 'and')
+ - ('+' also concatenates strings and lists)
* / %
unary -
f(args)
and/or short-circuit. Comparisons don’t chain — write
0 <= x and x < 10.
Spacing around operators is meaningful, and two simple rules cover it:
a + b and a+b are both fine; a +b and a+ b are
errors.1 + -2 and f(-1) are fine; 1+-2 is an error.Together these keep negation unambiguous: x <- 1 (or x<-1) assigns,
x < -1 compares.
A module is — canonically — a source file. A bare identifier imports
the matching sibling file; a string imports an exact path (relative
to the importing file, extension required). Either way as renames,
and without it the name derives from the locator:
import widgets # widgets.soma, as widgets
import widgets as w # ... as w
import "../shared/util.soma" # exact path, as util
import "my-lib.soma" as mylib # unclean stem: 'as' required
Importing executes the file once (at the first import statement,
wherever that is), and every later import — under any name, from any
file — binds the same module. Access is dotted: widgets.make(...).
Import cycles are an error, reported with the chain.
The selective form binds chosen names unqualified — and every
import statement starts with import, so a file’s dependencies are
one search away (a deliberate un-Python):
import from widgets: make, style as s
import from widgets: # the colon takes a block, as ever
make
style
import from math: .cos, .sin, pi # .name picks bind QUALIFIED:
# math.cos, math.sin — and pi bare
A dotted as target introduces a namespace — surgical conflict
resolution, and namespaces you introduce stay open for more imports:
import from circles: draw as gfx.draw
import from squares: fill as gfx.fill # same gfx, composed
Nothing crosses the module boundary unless exported. Within a
module every top-level declaration is visible, unmarked — but the
module’s surface is exactly what it marks with export (the other
half of import):
fn helper(n: int) -> int: n * 37 # module-internal
export fn api(n: int) -> int: helper(n) + 1
export let version = 3
export type widget = name: str, size = 42
export also takes a list of names, mirroring the import list —
so a module’s interface can be stated in one place, at the top, ahead
of the declarations it names:
export:
circle
area
make_widget as widget # publish under a different name
import from geometry: scale # ...
export scale # ...and re-export it as your own
The names may be declared anywhere in the module (the list resolves
once the module is checked), and they may be imported ones — which is
how a module re-exports, or composes a façade from several sources.
An as renames only what clients see; the thing itself is unchanged,
so a type re-exported under a new name is still the same type.
export also takes a block of declarations — the modifier
distributes over every one under it (an impl rides along with its
type):
export:
type circle =
radius: float
with impl:
fn area(self) -> float: 3.14159 * .radius * .radius
fn api(n: int) -> int: helper(n)
One thing to know: that’s grouping, not scoping. Declarations
under export: stay top-level — the rest of the module sees them
exactly as if they weren’t indented. (The rule of thumb: a colon
block under a value construct scopes; under a declaration
construct — with impl:, import from:, export: — it groups.)
Exporting a type carries its member surface — constructors, methods,
properties — along with it. (export is not “public”: within the
module nothing needs marking; export is interface membership.)
Imports are plumbing, never part of your surface.
Members take the finer dials, inline or as blocks: private scopes
a member to its own type; internal keeps it inside the defining
module (its boundary enforcement arrives when types cross modules):
type circle =
radius: float
@init(.auto)
with impl:
private fn base(self) -> float: 3.14159
fn area(self) -> float: .base() * .radius * .radius
print(circle(2.0).area()) # fine — but c.base() is an error
At the top level there’s nothing to mark: unexported declarations are already module-internal, and the checker says so if you try.
Types cross the boundary with their surface. An exported type can be imported by name or reached qualified, and its constructor, methods and properties come along:
import from shapes_lib: circle, shade, describe
let c: circle = circle(2.0)
print(c.area())
import shapes_lib
let d: shapes_lib.circle = c # qualified, wherever a type goes
let s: shade = .dark # sums bring their variants
Identity is by declaration, not by name: your own circle and an
imported circle are different types, and the checker says so if
they meet. private members stay inside their type, internal ones
inside the module that declared them — enforced across the boundary
now that types can travel.
Soma imports Ribo documents. A .ribo file is a document — the
declarative subset — and importing one gives you a module of pure
data, schema included:
import "config.ribo" as cfg
print(cfg.staging.name)
let extra: cfg.server = ("stage-3",) # the document's types, too
Nothing in a document needs export: a document’s boundary is its
data. The subset is enforced when you import one, so a .ribo that
strayed into Soma (a fn, a loop) is an error at the import, not a
surprise later. Documents import by path — the extension is how you
say “this is a document”.
Generics cross as well — a generic type keeps its parameters, and a generic function is instantiated per call in your module while its body is still checked against the module that wrote it:
import from shapes_lib: pair, swap
let p: pair<int> = pair(1, 2)
print(swap(p))
print(swap(pair("a", "b"))) # a fresh instantiation
import coll
let s: coll.stack<str> = coll.stack(["a"])
An attribute passes a value to processors as metadata, written
@ before a declaration. There is no separate annotation language:
@Doc("...") constructs a value of the declared type Doc, and a
unit type is a marker. A typo’d attribute is an unknown type;
wrong arguments are ordinary constructor errors.
type Doc = (text: str)
type Deprecated = ()
type Range = (lo: int, hi: int)
@Doc("The service port")
@Range(1, 65535)
let port = 8080
@Deprecated
type widget =
name: str
@Doc("in millimetres")
size: int
height: int @Range(0, 100)
print(port)
Attributes attach to let bindings, type declarations, fns,
methods, with impl: blocks, and fields — where they may also trail
on the same line (height: int @Range(0, 100)). They stack, and
they’re checked (in the enclosing scope, before the declaration they
describe exists) but never executed by Soma itself — processors
read them. The Ribo processor exposes them as typed values on its
Document API and in --json output under $attributes/$types.
@init(...) is itself an ordinary attribute of prelude-declared
types (type init_mode = auto | none and
type init = (mode: init_mode) — note the contextual dot resolving
the mode inside the argument) that the compiler consults: .auto
keeps the memberwise constructor despite an impl, .none generates
none. It attaches to a type declaration or its impl, whichever reads
better. Because the compiler reads it while checking, the mode must
be written directly (.auto), not computed. Bare value attributes
(@"see the wiki") are reserved for later.
_ separators. Floats need digits on both
sides of the dot: 1.0, not 1..\n \t \" \\ escapes, single-line
only.' — the ML habit of x' works.An honest list, while things are young:
Near-term: spread (point(...p, x = 2.0)), richer patterns,
date/time literals, and modules/imports. On the types side: trait
bounds as the opt-in strict dial for generics, and traits that let
user types join the builtin capabilities (iterable, printable…) —
with attributes set to become executable “macro” values that can
reflect over declarations and generate code. Further out:
user-defined operators, an effects system (fx beside fn), and —
once the language settles — a bytecode VM, then JIT and AOT
compilation. Soma is the middle of a family: Ribo, the purely
declarative subset (its processor ships today and loads .ribo
documents), and Pheno, a full systems language with the strict
defaults.