The shape of a file#
A file is a module. Its top-level statements are its program, so the
smallest whole Science program is one line and there is no
main to write. Blocks are indentation, opened by a
:. Comments are # to end of line;
## is a doc comment and attaches to the declaration below it.
The lexical rules are in Tokens, identifiers,
whitespace, the three kinds of comment in
Comments, and the block and continuation rules in
Indentation, newlines, continuation.
print("lift-off")
# lift-off
$ sciencec check hello.science
$ echo $?
0
$ sciencec build hello.science
$ ./hello
lift-off
check printing nothing is the verdict. Diagnostics go to
stderr and dumps to stdout, and the exit code is 0 when nothing was
reported as an error — warnings alone do not fail.
The seven statement forms
| Form | Written | Notes |
|---|---|---|
| binding | let x be v | let mutable to allow reassignment. |
| assignment | x be v | The same word. Assignment is a statement, never an operator. |
| expression | f(x) | A bare expression on its own line. |
| return | return v | Or bare return from a function with no value. |
| break | break | Ends the innermost loop or for. |
| continue | continue | Next iteration of the innermost loop. |
| declaration | def … | Every form in the four sections below. |
A function body's last expression is its value; there is no implicit
return anywhere else. A file may have top-level statements
or a main, and not both — two answers to "what
runs first" is SC0117, and what a top-level statement binds is
in Scope and shadowing.
Seven, and const is not one of them.
const NAME be v is a declaration, so it lives at the
top level of a module and not in a body: writing one inside a
def is SC0105, "expected an expression, found
const". That is the rule that makes a constant visible to every
item in the file, which a statement could never be.
Tokens, identifiers, whitespace#
A source file is UTF-8 text and nothing else — no byte-order-mark
rule, no encoding pragma, no include. The lexer turns it into a stream that
always ends in Eof, and it recovers from every error
rather than stopping, so a stray character costs one diagnostic and
the rest of the file is still read. Four of the token kinds are synthetic:
they stand for line structure, they have no characters of their own, and
they are what makes indentation a grammar rather than a convention.
Every token kind
| Kind | Written | Notes |
|---|---|---|
| Newline | the end of a logical line | Not emitted inside an unclosed bracket, and not emitted for a line that produced no token of its own. |
| Indent | a line indented past the level above it | One, whatever the width of the step. |
| Dedent | a line indented back | One per level closed. A line that closes four blocks emits four. |
| Eof | the end of the file | Preceded by every outstanding Dedent, so a file that ends mid-block still hands the parser a balanced stream. |
| Int | 42 0xFF 0o755 0b1010 | Carries value, base and suffix. The _ separators and the base prefix are already gone. |
| Float | 3.14 6.02e23 | Carries value and suffix. |
| Str | "hola" r"C:\runs" | Escapes already resolved. A raw literal carries no marker into the stream: nothing after the lexer needs to know how a String was spelled. |
| Char | 'a' | Exactly one Unicode scalar value. |
| FStrStart, FStrText, InterpStart, InterpEnd, FStrEnd | f"a{x}b" | An f"…" is five kinds of token, not one, and a hole's interior is ordinary tokens in the same stream. See Literals. |
| Ident | samples dia n2 | Anything word-shaped that is not one of the 68 words. |
| keyword | 68 distinct words | 40 live, 28 held. The list is in Every reserved word. |
| punctuation | ( ) [ ] { } , : ; . .. ..= -> => _ @ ? | Longest match wins: ..= before .. before ., and -> before -. |
| operator | + - * ** / % & | ^ << >> = == != < > <= >= | Rungs and associativity are in Operators and precedence. |
| Unknown | anything else | Emitted rather than aborting, which is what lets the parser report more than one error per pass. |
Four of those are lexed and the grammar consumes none of them.
; and => have token kinds and no production:
let a be 1; let b be 2 is SC0100, "expected end of
line, found ;". { and } are lexed and
reach only the parser's bracket-depth counters, which is error recovery and
not syntax. A fifth kind, Hash, is never emitted at all
— it exists against the day # stops meaning a comment. A
page that listed them among the syntax would be describing the token table
instead of the language, so they are named here and nowhere else.
What may be an identifier
An identifier starts with _ or any character Unicode calls
alphabetic, and continues with _ or any alphanumeric one.
That is the whole rule, and it is why a Greek letter is an
ordinary name rather than a concession — which matters for a language
whose programs are meant to be read beside the paper they came from. Case is
significant and carries no meaning of its own: Doc and
doc are two names, and nothing in the grammar requires a type to
be capitalised.
let snake_case be 1
let CamelCase be 2
let n2 be 3
let _unused be 4
let día be 5
let Δt be 0.5
print(f"{snake_case} {CamelCase} {n2} {día} {Δt}")
# 1 2 3 5 0.5
| Not an identifier | What happens |
|---|---|
| 2n | A digit starts a number, so this is the literal 2 carrying the suffix n: SC0009, and then SC0102 for the name that never arrived. |
| kebab-case | Three tokens. - is subtraction and begins no identifier. |
| total$ | SC0001 — $ is not a character Science recognises. There are no sigils of any kind. |
| _ | Its own token, the wildcard. let _ be 1 is SC0102: _ names nothing, so there is nothing to bind. _x, with anything after the underscore, is an ordinary name. |
What is whitespace
Between tokens a space, a tab and a carriage return separate and mean
nothing else, so a file indented with spaces and aligned with tabs
inside a line is one program however it is displayed, and a CRLF
file is the same program as an LF one. In the indentation the rule is
different and stricter: only spaces count, a tab there is
SC0003, and a lone carriage return before the break carries no
width. The reason is in the diagnostic — a tab's width depends on how
the file is displayed, so a block indented with one would mean different
things to two readers of the same file. The cost is that the same character
is legal in one column and an error three columns to its left.
The language sets no line length. sciencec fmt targets
90 columns and indents by four spaces, and neither number is
a rule the compiler enforces.
Every lexical diagnostic
SC0001–SC0099 is the lexer's band and it
spends twelve of them. SC0170–SC0177 is the
interpolating literal's band, emitted here as well, because the shape of an
f"…" is only visible to the pass that reads the
characters: by the time the parser has a token stream, an unterminated hole
has already become a stream that stops.
| Code | Fires on | Example |
|---|---|---|
| SC0001 | A character that is not part of the language. | 1 $ 2 |
| SC0003 | A tab in a line's indentation. | a tab, then code |
| SC0004 | An indentation width matching no open level. | 4, then 2 |
| SC0005 | An integer literal too large to hold. | 999…999 |
| SC0006 | An escape the lexer does not know. | "\q" |
| SC0007 | A string with no closing quote on its line. | "open |
| SC0008 | A character literal unterminated, empty, or holding more than one. | 'ab' |
| SC0009 | A suffix that is not one of the ten, or an integer suffix on a float. | 42foo 1.5i32 |
| SC0010 | A digit outside the literal's base. | 0b1012 |
| SC0011 | A float literal that overflows F64. | 1e400 |
| SC0016 | ==. Fixed to is. | a == b |
| SC0017 | !=. Fixed to is not. | a != b |
| SC0170 | An interpolation with no closing }. | f"{n" |
| SC0171 | A } inside an f"…" that closes nothing. | f"a } b" |
| SC0173 | A format specification, which is specified and unbuilt. | f"{pi:.2f}" |
| SC0174 | An interpolation with nothing in it. | f"{}" |
| SC0175 | A # inside an interpolation — it would comment out the rest of the literal. | f"{n # why}" |
| SC0177 | Literal prefixes combined. | rf"x" |
SC0002 and SC0012–SC0015 are
unallocated. SC0172 — a plain "…" every
one of whose names resolves, which is a forgotten f — is
specified and not implemented, and cannot be: deciding it needs a scope, and
the lexer is the only pass that still has the literal.
error[SC0016]: equality is written `is`
--> stale.science:1:12
|
1 | let a be 1 == 2
| ^^ `==` is not an operator in Science
|
= note: there is one spelling for each comparison: `is` and `is not` for identity, and `>`, `<`, `>=`, `<=` for order
= help: write the word instead: `is`
1 error
== is a lexical error and not a parse error, and the
difference is the point: the lexer reports it and then emits the equality
token anyway, so a stale symbol costs one diagnostic carrying an applicable
fix, and the rest of the expression parses as though the fix had already been
taken. ! on its own is SC0001 — negation is
not, and ! begins no operator at all.
Indentation, newlines, continuation#
Indentation is how every block in this language is delimited.
There is no {, no end, no begin. A
: at the end of a line opens a block; indenting past the level
above emits Indent; indenting back emits one
Dedent per level closed. Because the lexer does this, a block is
a grammatical object the parser matches on, and a misplaced line is a
diagnostic with a caret rather than a program that quietly means something
else.
The rules, in full
| Rule | What follows from it |
|---|---|
| Spaces only. | A tab in the indentation is SC0003. Recovery gives the line the level currently on top of the stack rather than guessing a width, so one tab costs one diagnostic and not a cascade. |
| Any width opens a level. | The language names no number. Four spaces is what sciencec fmt writes and not what the compiler requires. |
| Dedenting must land on an open level. | Otherwise SC0004, which lists the levels that are open. Recovery pushes the width as a new level so the stream stays balanced. |
One Dedent per level closed. | A statement at level 6 followed by one at level 1 emits five. Closing many levels at once is the case that breaks a naive indentation stack, so the corpus goes eight deep on purpose. |
| Blank and comment-only lines take no part. | They are skipped before the width is measured, so neither opens or closes anything, whatever column it is written at. |
| The file ends the last line and closes every block. | A file with no trailing newline still gets its Newline, and Eof is preceded by every outstanding Dedent. The parser sees one shape and not two. |
The synthetic tokens are visible. sciencec tokens prints the
stream, which is the shortest way to settle any argument about what a layout
means:
$ cat sign.science
def sign(n: Int) -> Int:
if n < 0:
-1
else:
1
$ sciencec tokens sign.science
0..3 Function
4..8 Ident("sign")
...
23..24 Colon
24..25 Newline
25..29 Indent
29..31 If
...
37..38 Colon
38..39 Newline
39..47 Indent
47..48 Minus
48..49 Int { value: 1, base: Dec, suffix: None }
49..50 Newline
54..54 Dedent
54..58 Else
58..59 Colon
59..60 Newline
60..68 Indent
68..69 Int { value: 1, base: Dec, suffix: None }
69..70 Newline
70..70 Dedent
70..70 Dedent
70..70 Eof
Note the Dedent before Else, and the two at the
end. Indent carries a span covering the whitespace it measured;
Dedent and the final Newline are zero-width, which
is why a diagnostic about a missing block points at a column rather than
underlining one.
error[SC0004]: this indentation matches no open level
--> ragged.science:3:1
|
3 | let b be 2
| ^^ indented 2 columns
|
= note: the open levels are 0
error[SC0100]: unexpected indentation at the top level of a module
--> ragged.science:3:3
|
3 | let b be 2
| ^ unexpected indentation at the top level of a module
2 errors
Line continuation — the two things that do it
Every line break ends a statement, except two, and both are written at the start of the continuing line. A reference that omits this is the reason a reader believes a five-line chain is five statements.
| Continues | Rule |
|---|---|
| ( [ { | While any bracket is open the line structure is suppressed entirely: no Newline, no Indent, no Dedent, and the indentation inside is decorative. This is what makes one-argument-per-line formatting work. |
| . | A line whose first character is . and whose next character can start an identifier continues the line above. That is how a method chain breaks. |
| where | A line beginning with the word where continues the line above, so a long signature breaks before its bounds. No statement may begin with where, so recognising it here costs nothing. |
.. is excluded from the . rule deliberately: a
range is not a chain link, and no line may begin with one. The rule is
written as "a . followed by an identifier start", which also
means a leading .0 continues nothing — consistent with
there being no t.0 to reach for. Blank and
comment-only lines take no part here either, so a comment in the middle of a
chain does not break it.
type Config:
host: String
port: U16
retries: U8
def connect(
host: borrowed String,
port: U16,
retries: U8,
) -> Bool:
retries > 0
def hosts(configs: borrowed Array of Config) -> Array of String:
configs
.iterate()
# a comment-only line takes no part, and the chain survives it
.map(each.host)
.collect()
def described of T(
value: borrowed T,
) -> String
where T: Display:
f"{value}"
let config be Config(
host: "localhost",
port: 8080u16,
retries: 3u8,
)
print(f"{connect(config.host, config.port, config.retries)}")
Trailing commas are allowed in every parenthesised and bracketed
list — arguments, parameters, generic arguments, record
fields, tuples, array literals and pattern lists. They are not allowed in a
where clause, which has no brackets to hold it open: where
T: A,: is SC0104 and then a small cascade, because the
parser goes looking for the type that the comma promised.
The two continuations are visible in the token stream as an absence.
A chain broken over four lines produces one Newline, at the end
of the last:
$ sciencec tokens chain.science
...
59..60 Newline
60..64 Indent
64..66 Ident("xs")
75..76 Dot
76..83 Ident("iterate")
83..84 LParen
84..85 RParen
94..95 Dot
95..102 Ident("collect")
102..103 LParen
103..104 RParen
104..105 Newline
105..105 Dedent
105..105 Eof
Between Ident("xs") at 64 and Dot at 75 there are
two line breaks and eight columns of indentation, and not one token stands
for them. That is the rule doing its job: an indented chain continuation is
expression indentation, not a body, and an indentation-delimited
language has to be able to tell them apart or it cannot have chains at all.
def and tool#
def declares every function, method, associated function and
interface member the language has. There is one word for all four, and the
thing that tells them apart is where the declaration sits and whether its
first parameter is self.
The shape is def name(param: Type, …)
-> ReturnType: and an indented body. The return arrow is
omitted when there is nothing to return; the function's type is then
(). Parameters are always annotated — inference is local
to a body and stops at a signature, so a signature is the one place a type
is never guessed. The body's last expression is its value.
def area(width: Int, height: Int) -> Int:
width * height
def greet(name: borrowed String):
print(f"hola, {name}")
def clamp(value: Int, low: Int, high: Int) -> Int:
if value < low: return low
if value > high: return high
value
print(area(6, 7))
greet("Kepler")
print(clamp(120, 0, 100))
That listing checks and does not build: print of a non-String
needs a vtable the backend does not emit. The same three constructs, with
the values sent through an f"…" instead, do build:
def area(width: Int, height: Int) -> Int:
width * height
let w be 6
let h be 7
print(f"{w} by {h} is {area(w, h)}")
# 6 by 7 is 42
tool — a function a model may call
tool is a second declaration form, not a synonym for
def, and it exists because it carries five obligations a
def does not and a compiler can check: every parameter type has
to survive a round trip through JSON Schema, the declaration may not be
generic, no parameter may be borrowed, it must carry a
## description, and its return type is constrained. A marker
that changes nothing would be an attribute; five checkable obligations are a
declaration form.
## Count the peaks above a threshold in one run.
##
## Returns the number of peaks, or zero when the run is empty.
tool count_peaks(run: String, threshold: Int) -> Int:
if run.is_empty(): return 0
run.length() + threshold
print(count_peaks("abc", 2))
The description is not documentation the compiler tolerates — it is an
argument, and the schema below is derived from the signature rather than
written beside it. An Int becomes an integer with its
width, which is a claim no other tool SDK's int can make.
$ sciencec tools --json peaks.science
[{"name":"count_peaks","inputSchema":{"type":"object","properties":{"run":
{"type":"string"},"threshold":{"type":"integer","minimum":-9223372036854775808,
"maximum":9223372036854775807}},"required":["run","threshold"]},"description":
"Count the peaks above a threshold in one run.\n\nReturns the number of peaks,
or zero when the run is empty."}]
The JSON above is one line in the real output; it is wrapped here to fit the
column. Nothing in a tool declaration names a protocol: it
declares a name, a typed parameter list, a required description and a
result, and MCP is one backend over that which lives in a package.
type and choice#
type Name: declares a record — named fields, all of them
present. choice Name: declares a sum — named variants,
exactly one of them present. type Name is T, with the
is, declares an alias rather than a new type. The three forms
share one keyword and are told apart by what follows the name.
type Reading:
channel: Int
counts: Int
choice Format:
Plain
Markdown
Json
choice LoadError:
NotFound(String)
Malformed(String, Int)
type Embedding is Array of F32
type Counter is I64
let first be Reading(channel: 1, counts: 4_900)
print(first.counts)
A record is constructed with named arguments and nothing else:
Reading(channel: 1, counts: 4_900). There is no positional form
and no ordering rule to remember, which is also what lets a record pattern
in a match read exactly like the construction it mirrors. A
variant's payload is positional, because a variant is a tag over a tuple
rather than a record.
type Reading:
channel: Int
counts: Int
def total(first: Reading, second: Reading) -> Int:
first.counts + second.counts
let a be Reading(channel: 1, counts: 4_900)
let b be Reading(channel: 2, counts: 1_180)
print(f"channels {a.channel} and {b.channel} hold {total(a, b)} counts")
# channels 1 and 2 hold 6080 counts
A variant is reachable unqualified — Json — and also
through its type — Format.Json. The qualified form is what
disambiguates when two choices in scope share a variant name;
both spellings are the same value and neither is preferred.
Interfaces and methods#
Three declaration forms share the job a class does elsewhere.
interface I: declares required methods, default methods and
associated types. T implements I: supplies them.
T has: declares methods that come from no interface at all.
Splitting them is what lets an interface be implemented for a type its
author never saw.
interface Summarize:
def summarize(self) -> String
def preview(self) -> String:
self.summarize().truncate(80)
type Doc:
title: String
body: String
Doc implements Summarize:
def summarize(self) -> String:
self.body.truncate(200)
Doc has:
def new(title: String) -> Doc:
Doc(title: title, body: String.new())
def is_empty(self) -> Bool:
self.body.is_empty()
choice Format:
Plain
Json
Format implements Copy
let doc be Doc(title: "Regions", body: "a region is a set of program points")
print(doc.preview())
print(Doc.new("scratch").is_empty())
| Form | Means | Reached by |
|---|---|---|
| def m(self) -> T | A method. | value.m() |
| def m(mutable self) -> T | A method that may write its receiver. | value.m(), receiver borrowed exclusively |
| def m(x: T) -> U | An associated function: no receiver. | Type.m(x) |
| type Item | An associated type, named by the interface. | Self.Item, supplied with type Item is Int |
| T implements I | A marker interface — nothing to supply. | One line, no body |
Eighteen interfaces are in the prelude and eight of them are operators:
Add, Sub, Mul, Div,
Rem, Pow, MatMul and
Neg, plus Index, IndexMutably,
Eq, Ord, Copy, Clone,
Drop, Iterate, From,
Display and Error. A scientific language in which
+ does not work on your own type is not a scientific language,
so + is a method call and a user type takes part in arithmetic.
Modules and visibility#
A file is a module and a directory with a mod.science is a
module containing its siblings, so there is no declaration form for one and
nothing to keep in sync with the filesystem. use imports;
public exports; const declares a compile-time
constant. Everything is private until it says otherwise.
use text.parser
use text.parser (Token, lex)
const WIDTH be 768
const GREETING be "hola"
public type Document:
title: String
body: String
public def parse(source: borrowed String) -> Array of Token:
lex(source)
def helper(document: borrowed Document) -> Int:
document.body.length()
public def main():
print(WIDTH)
print(GREETING)
print(helper(Document(title: "t", body: "body")))
This listing needs a file beside it, and that is the one
listing on this page that is not self-contained. The
text/parser.science it was checked against is four lines:
public type Token: with a text: String field, and
public def lex(source: borrowed String) -> Array of Token
returning (Array of Token).new(). check treats the
file's own directory as the crate root and compiles every module the
use declarations name.
| Form | Means |
|---|---|
| use a.b | Import the module; its names are reached through the path, a.b.f(). |
| use a.b (X, y) | Import selected names; they are used unqualified. |
| use a.b ( X, y, ) | A selection over several lines, trailing comma permitted. |
| public … | A prefix on a type, choice, interface, def or a member of a has block. |
| const NAME be v | A compile-time constant. Uses be, like every other binding; has no storage of its own. |
public is refused where it would mean nothing.
On a use, on an extern block, on an implementation
and on a top-level statement it is SC0107 with a deletion
offered — an import exports nothing, an implementation declares no name
to export, and a statement in a script has no name at all. There is one
visibility word and no private: everything is private until it
says otherwise, so the absent word is the default and the present one is the
exception.
extern and unsafe#
An extern block is a list of symbols and the claims a
programmer makes about them, in a small grammar of its own. It is always
unsafe, because every line in it is something the compiler
will never check. unsafe: is also a block expression, and it is
the only place a foreign function may be called.
unsafe extern "C" library "m":
def fabs(value: CDouble) -> CDouble
def pow(base: CDouble, exponent: CDouble) -> CDouble
unsafe extern "C" library "openblas" via pkg-config "openblas":
type BlasInt is I32
const CBLAS_ROW_MAJOR be 101 as CInt
def cblas_ddot(
n: BlasInt,
x: ffi.Span of F64, incx: BlasInt,
y: ffi.Span of F64, incy: BlasInt,
) -> F64 symbol "cblas_ddot"
def magnitude(value: F64) -> F64:
unsafe:
fabs(value as CDouble) as F64
print(magnitude(-2.5))
| Header clause | Means |
|---|---|
| library "name" | The library to link. Required — a block without one is SC0412. |
| via pkg-config "name" | Discover the flags; the plain name stays the fallback, because pkg-config is not on every platform. |
| kind static | Link the archive rather than the shared object. |
| when available | Lower to a table of function pointers filled in on first use, and give the block an is_available(). One indirect call per foreign call. |
| Item | Means |
|---|---|
| def f(…) -> T | A foreign function. No body. symbol "x" after it decouples the Science name from the linker name. |
| type X is Y | A spelling for a C typedef. |
| const K be v as T | A #define or an enumerator, transcribed. |
| static G: T | An exported global. Reading one is a power unsafe grants, because the memory belongs to C. |
| union U: size n align m | An opaque blob of the right size and alignment, with no way to read the arms. |
union and static are live here and held
everywhere else. They are keywords of the extern
sublanguage and reserved words at the top level, which is the contextual
treatment the FFI design settled on and the reason the same two words appear
in both tables on this page. The C scalar vocabulary —
CChar, CInt, CUInt,
CLong, CULong, CLongLong,
CULongLong, CFloat, CDouble,
CSizeT, CPtrDiff, CVoid — is
deliberately not aliased to the Science primitives, because a width
the author did not think about is how a numerical program gets silently
wrong answers.
Bindings#
One word binds and the same word reassigns: be. A binding is
immutable unless it says mutable. The annotation is optional
inside a body and mandatory in a signature.
let greeting be "hola" let answer be 42 let annotated: Int be 42 let widened: I64 be 42i64 let mutable count be 0 count be count + 1 count be count * 2 let view: borrowed String be borrowed greeting let mutable owner be "mutable text" let exclusive: mutable borrowed String be mutable borrowed owner print(count) print(annotated)
Destructuring exists in exactly one place: a tuple on the left of a
be. It is not a general pattern position — there
is no let Point(x: a) be p — and the reason it exists at
all is that the error model returns a pair, so unpacking one is the shape
every fallible call takes.
choice ParseError:
Empty
Unsupported(String)
def parse_port(text: borrowed String) -> (Int, ParseError?):
if text.is_empty():
return (0, ParseError.Empty)
(8080, null)
let port, err be parse_port("port=8080")
if err?:
print("bad")
else:
print(port)
| Form | Means |
|---|---|
| let x be v | Bind, immutably. |
| let mutable x be v | Bind, and allow reassignment. |
| let x: T be v | Bind with an annotation. mutable goes before the name, the type after it. |
| let a, b be f() | Unpack a tuple return. |
| x be v | Reassign. A statement — it has no value and cannot appear in an expression. |
| let _x be v | A leading underscore says a value is deliberately unused. It is what silences SC0140 on an ignored error. |
Scope and shadowing#
A name is visible from where it is bound to the end of the block that binds
it, and nowhere else. Items are the exception: a
def, a type, a choice, an
interface and a const live in a table of their own
and are visible throughout the file regardless of order, which is what lets
two functions call each other without a forward declaration.
What introduces a scope
| Scope | Holds |
|---|---|
| the module | Every item in the file, and whatever a use brings in. Order-free, and consulted only after every block has missed. |
| a declaration's generics | The type parameters, in scope for the parameters, the return type, the where clause and the body — and out of scope the moment the declaration closes. |
| a parameter list | The parameters, including self. |
| every block | Every function body, every if branch, every else, every loop, for, match arm and unsafe body — and every inline block. One rule, no exceptions for the short forms. |
| a pattern | A for loop's bindings and a match arm's bindings, for the body that follows. |
| a closure | Exactly one name: the giving parameter, or the invented each. |
A let in a branch is visible to the rest of that branch and to
nothing after it. The condition of an if and the iterable of a
for are resolved outside the block they head, so a loop
cannot see its own bindings in the expression it walks.
error[SC0200]: cannot find `b` in this scope
--> leaked.science:7:5
|
7 | b
| ^ not found
1 error
A name may be rebound, and it shadows
A second let on the same name in the same block is
legal, silent, and shadows the first. The new binding may have a
different type, and its initialiser is resolved before any of its names are
defined — so let n be n + 1 reads the old
n and then hides it. There is no warning, because the feature
exists in order to be used: narrowing a value through a series of steps
without inventing n2 and n3 is the whole point.
def shadowed(n: Int) -> Int:
let n be n + 1
let n be n * 10
n
let answer be 4
let answer be shadowed(answer)
print(f"{answer}")
# 50
Shadowing is a property of blocks and of nothing else. A
repeated parameter, a repeated generic parameter, and a
name bound twice inside one pattern are each
SC0201, "the ... is defined more than once", and the first
declaration keeps the name. That includes an or-pattern: A(x) |
B(x) is documented in the resolver as binding one x and
in fact reports SC0201, so an alternative that binds a payload
has to be written as two arms today.
error[SC0201]: the binding `x` is defined more than once
--> alts.science:6:18
|
6 | A(x) | B(x): x
| - first defined here
| ^ defined again here
1 error
What a top-level statement binds
A file whose top level holds statements is a script: the compiler
wraps those statements in the main it writes for you.
So a top-level let binds a local, not a module-level
name, and the consequence surprises people — it is invisible
inside a def in the same file, because a function body is a
different scope and the module table holds items only.
error[SC0200]: cannot find `top` in this scope
--> script.science:3:5
|
3 | top
| ^^^ not found
1 error
Written out: let top be 7 at the top of a file, then def
uses_top() -> Int: with top as its body, does not
check. Pass the value as a parameter, or make it a const —
a const is an item and is in scope everywhere in the file.
Two consequences of the same rule are their own diagnostics.
SC0117: a file may have top-level statements
or a hand-written main and not both, because two
answers to "what runs first" is not a question the compiler should pick for
you. SC0213: top-level statements in a module
that is not the entry point are an error, because Science does not run a
module's statements when it is imported, so they would never run at all.
error[SC0117]: this file has top-level statements and also declares `main`
--> both.science:2:5
|
1 | let a be 1
| ---------- the top-level statements start here, and are already a `main`
2 | def main():
| ^^^^ `main` is declared here
|
= note: a file whose top level holds statements is a script: the statements are the program, and the compiler writes the `main` that runs them
= note: move the statements into `main`, or delete `main` and let the statements be the script
1 error
A local shadows a module too. text.parser folds into a
module path only when no enclosing block holds a binding called
text; bind that name and the path becomes a field access on
your value, which is the reading you asked for. Reserved words cannot be
shadowed because they cannot be bound at all: let model be 1 is
SC0102, "expected an identifier, found model, which
is reserved for a later phase". each is a keyword for that exact
reason — no user binding can collide with the one a closure invents.
The type vocabulary#
Eighteen names are primitive, every width is written into the name, and
there is no implicit conversion between any two of them —
as is always spelled out, including where it loses nothing.
let a: I8 be 1i8 let b: I16 be 2i16 let c: I32 be 3i32 let d: I64 be 4i64 let e: U8 be 5u8 let f: U16 be 6u16 let g: U32 be 7u32 let h: U64 be 8u64 let i: F16 be 1.5 let j: BF16 be 1.5 let k: F32 be 1.5f32 let l: F64 be 2.5 let m: Bool be true let n: Char be 'x' let o: String be "owned" let p: () be () let alias_int: Int be 9 let alias_float: Float be 9.5 let maybe: String? be null let pair: (Int, String) be (1, "one") let items: Array of Int be (Array of Int).new() let table: Map of (String, Int) be (Map of (String, Int)).new() print(alias_int) print(maybe?)
| Type | Width | Notes |
|---|---|---|
| I8 I16 I32 I64 | 8, 16, 32, 64 bits | Signed. Literal suffixes i8…i64. |
| U8 U16 U32 U64 | 8, 16, 32, 64 bits | Unsigned. Suffixes u8…u64. |
| F32 F64 | 32, 64 bits | IEEE binary32 and binary64. Suffixes f32, f64. |
| F16 BF16 | 16 bits | Half and bfloat16. No literal suffix — a literal reaches them through an annotation. |
| Bool | — | true and false, and nothing else is a condition. |
| Char | One scalar value | Single quotes. Not a byte. |
| String | Owned, UTF-8 | A string literal has this type. |
| () | Zero | Unit — the type and the value share the spelling. |
| Int Float | 64 bits | Aliases of I64 and F64. Not separate types. |
| Never | Uninhabited | The type of panic. Coerces to anything, which is what lets one match arm bail out. |
F32 and F64 implement Eq and
not Ord, because NaN has no total order. That
is a deliberate hole in the prelude and not an oversight: a sort over
floats has to say what it does with NaN.
Compound types
| Written | Means | Notes |
|---|---|---|
| T? | A T, or null. | Not a wrapper. There is nothing to unwrap and no Option to import. |
| (A, B) | A tuple. | Read by destructuring or by a tuple pattern. There is no t.0. |
| () | The empty tuple. | The return type of a function with no ->. |
| Array of T | A growable sequence. | Built with (Array of T).new(). |
| Map of (K, V) | A hash map. | Read with .get(k), which returns (borrowed V)?. |
| Box of T | An owning indirection. | Built with Box.new(v), never with named arguments. |
| borrowed T | A shared borrow. | Many at once, none of them writing. |
| mutable borrowed T | An exclusive borrow. | One at a time. Both words, in that order. |
| any I | Dynamic dispatch over an interface. | Only behind an indirection: borrowed any I or Box of any I. |
| T of A | A generic instantiation. | One argument bare, several in parentheses: Pair of (A, B). |
There is no lifetime syntax, and none is elided. The region of a borrow is inferred and propagated, including through a record field that holds one. That is the language's largest single bet: a borrow checker with nothing to annotate is either sound or it is a bug, and there is no third state where the programmer is asked to help.
Generics#
of is the one word for a type argument, in a declaration and at
a use site. A parameter may be a value as well as a type —
const WIDTH: Int — which is what the tensor shapes of a
later phase are built out of, and which makes a generic parameter an
ordinary value inside the body.
interface Summarize:
def summarize(self) -> String
type Doc:
title: String
Doc implements Summarize:
def summarize(self) -> String:
self.title.truncate(80)
Doc implements Clone:
def clone(self) -> Doc:
Doc(title: self.title.truncate(80))
type Pair of (A, B):
first: A
second: B
type Window of (T, const WIDTH: Int):
items: Array of T
Window of (T, const WIDTH: Int) has:
def empty() -> Window of (T, WIDTH):
Window(items: (Array of T).new())
def capacity(self) -> Int:
WIDTH
def describe of T: Summarize(value: borrowed T) -> String:
value.summarize()
def best_of of T(left: borrowed T, right: borrowed T) -> String
where T: Summarize + Clone:
if left.summarize().length() >= right.summarize().length():
left.summarize()
else:
right.summarize()
def paired of (A, B)(first: A, second: B) -> Pair of (A, B):
Pair(first: first, second: second)
def describe_any(value: borrowed any Summarize) -> String:
value.summarize()
def describe_boxed(value: Box of any Summarize) -> String:
value.summarize()
let doc be Doc(title: "Regions")
print(describe(doc))
print(describe_any(doc))
print(describe_boxed(Box.new(Doc(title: "boxed"))))
print((Window of (Int, 4)).empty().capacity())
print(paired(1, "one").second)
| Written | Means |
|---|---|
| def f of T(…) | One type parameter. |
| def f of (A, B)(…) | Several. |
| def f of T: I(…) | A bound, written inline. |
| … where T: I + J | Several bounds, written after the return type. |
| type W of (T, const N: Int): | A const generic. N is an ordinary value in the body. |
| (Window of (Int, 4)).empty() | An associated function on a generic type. The parentheses are required — the bare form cannot say whether .empty() belongs to 4 or to the whole type, and Science reports the ambiguity (SC0116) rather than making the space load-bearing. |
Static dispatch is monomorphised: one specialised copy per instantiation.
any I is the dynamic form and it costs an indirection, which is
why the language makes you write the word. Both are in the listing above, on
the same interface.
The where clause
A bound may be written in two places and they mean the same thing. Inline
— def f of T: Summarize(…) — puts the
constraint where the parameter is introduced, which reads well when there is
one of each. A where clause puts every constraint after the
signature instead, which is the only form that stays readable when a
parameter carries three bounds or a declaration carries three parameters. The
cost of having both is that a reader has to look in two places; the reason
for paying it is that a signature is the thing most often read and least
often edited, and a line of bounds in the middle of it hides the parameters.
interface Summarize:
def summarize(self) -> String
interface Clone:
def clone(self) -> Self
def inline of T: Summarize(value: borrowed T) -> String:
value.summarize()
def clause of T(value: borrowed T) -> String where T: Summarize:
value.summarize()
def several of (A, B)(left: borrowed A, right: borrowed B) -> String
where A: Summarize, B: Summarize + Clone:
right.clone().summarize()
def both of T: Summarize(value: borrowed T) -> String where T: Clone:
value.clone().summarize()
Both forms on one declaration is legal and is not a diagnostic.
both above declares T: Summarize inline and
T: Clone in the clause, and the checker reads the two lists and
concatenates them, so T is Summarize + Clone. The
syntax tree keeps them apart deliberately — a diagnostic has to be able
to point at where the programmer wrote a bound, not at where the compiler
filed it.
Where a where may appear. Six declaration forms
carry generic parameters, and the clause goes on all six.
| Declaration | Written |
|---|---|
| def | def f of T(…) -> R where T: I: |
| type | type Sorted of T where T: Ord: |
| choice | choice Tagged of T where T: Ord: |
| interface | interface Keyed of T where T: Ord: |
| implements | Sorted of T implements Keyed of T where T: Ord: |
| has | Sorted of T has where T: Ord: |
interface Ord:
def cmp(self, other: borrowed Self) -> Int
type Sorted of T where T: Ord:
items: Array of T
choice Tagged of T where T: Ord:
Present(T)
Absent
interface Keyed of T where T: Ord:
def key(self) -> Int
Sorted of T implements Keyed of T where T: Ord:
def key(self) -> Int:
self.items.length()
Sorted of T has where T: Ord:
def count(self) -> Int:
self.items.length()
It goes on the six forms above and nowhere else. A where after a
type alias (type E is Array of F32 where …),
after a const, or after a use is SC0100,
"expected end of line, found where" — those declarations
have no generic parameters for a predicate to constrain. It also parses on a
tool, because tool and def share the
same reader, but a generic tool is SC0191 in the
next breath, so the clause has nothing it could bind.
What may be in one. A clause is one or more predicates, and a
predicate is a subject, a : and a list of bounds joined by
+.
| Form | Means |
|---|---|
| where T: I | One predicate: a subject, a :, a bound. |
| where T: I + J + K | Several bounds on one subject. The list ends at the first token that is not +, which is the only thing telling the bounds' : from the block's. |
| where A: I, B: J | Several predicates, comma-separated. No trailing comma — there are no brackets here to hold the list open, so where T: I,: is SC0104 and a short cascade after it. |
| where T: (Int) -> Bool | A bound may be a closure type as well as an interface. A bound that opens with ( and has no -> after it is SC0119: the parenthesis has no other reading, so the arrow is not optional there. |
| where … | Written on its own line, it continues the line above — see continuation. It is one of exactly two words-or-symbols that do. |
The subject parses as any type and only one shape is honoured.
where Array of T: Ord, where borrowed T: Ord and
where Int: Ord all parse, and the checker keeps a predicate only
when its subject is a bare, argument-free name that resolves to a generic
parameter the declaration itself introduced. Everything else is
silently discarded, and that is one of the six holes this page
records: it is item 3 of Parses, and is not built,
with the program that demonstrates it.
Literals#
Four integer bases, a digit separator that works in all of them, ten numeric
suffixes, three string forms, a character, two booleans, null
and unit. Nothing here is context-dependent: a literal means the same thing
wherever it is written.
let decimal be 1_000_000
let hexadecimal be 0xDeadBeef
let binary be 0b1111_0000
let octal be 0o755
let sized be 42i32
let unsigned be 7u8
let pi be 3.14
let exponent be 6.02E23
let single be 2.5f32
let text be "hola"
let escaped be "she said \"hi\"\n"
let codepoint be "\u{1F600}"
let raw be r"C:\runs\2026\peaks.csv"
let letter be 'a'
let newline be '\n'
let yes be true
let absent: String? be null
let nothing be ()
let name be "Kepler"
let count be 3
print(f"{name} has {count} runs, {count * 2} files")
print(raw)
| Form | Example | Notes |
|---|---|---|
| decimal | 42, 1_000_000 | _ anywhere between digits, in any base. |
| hexadecimal | 0xFF, 0xDeadBeef | Case-insensitive digits. |
| binary | 0b1111_0000 | — |
| octal | 0o755 | — |
| suffix | 42i32, 7u8, 2.5f32 | Ten: i8 i16 i32 i64 u8 u16 u32 u64 f32 f64. Combines with any base and with separators. |
| float | 3.14, 1e-9, 6.02E23 | Exponent in either case, sign optional. |
| string | "hola" | Type String, owned. |
| raw string | r"C:\runs\peaks.csv" | No escapes at all. Cannot contain a " and is never continued onto the next line. r#"…"# is the known extension and is deferred. |
| interpolating string | f"{name} has {n}" | Each hole holds one expression, rendered by its Display. A literal brace is {{. |
| character | 'a', '\n', 'ñ' | Exactly one scalar value. |
| boolean | true, false | Keywords, so neither can be bound. |
| null | null | A literal, not a prelude value: T? is a type the compiler knows, so what inhabits it is a token the lexer knows. |
| unit | () | The value and the type. |
The escapes are \n, \t, \r,
\\, \", \', \0 and
\u{…}. Eight, in a string and in a character alike, and
anything else is SC0007.
An f"…" is not one token. It opens, alternates literal
runs with {-delimited holes whose interiors are
ordinary tokens, and closes — which is what lets
f"{m["a"]}" contain a " that does not end the
literal, and lets a hole hold a nested f"…". The cost is
that a : at the hole's own depth ends the expression, so
f"{if flag: "yes" else: "no"}" needs parentheses.
let name be "Kepler"
let count be 3
print(f"{name} has {count} runs, {count * 2} files")
print(r"C:\runs\2026\peaks.csv")
print("a tab\there, a quote \"there\"")
# Kepler has 3 runs, 6 files
# C:\runs\2026\peaks.csv
# a tab here, a quote "there"
The format specification after a : is specified and
unbuilt. Fill, alignment, sign, width, grouping, precision, the
type codes and the !i conversion are all designed and none of
it exists; a : in a hole is SC0173, which says so.
See Parses, and is not built.
What an unsuffixed number is
A numeric literal is inferred, not defaulted — and inferred
among the numeric types only. 42 does not arrive as an
Int and then get converted; it arrives as an integer
literal, joins whatever type the expression around it pins it to, and
falls back to I64 — or F64 for a float
— only if nothing constrains it at all. That is why let n be
42 followed by takes(n) at an I8 parameter
checks, and why 1 + 1u8 makes the 1 a
U8 rather than a width error.
The inference is among numbers and not a wildcard, which is the
whole difference from a hole. Handing that same n to a
String parameter is SC0525, and the diagnostic
names the pending type by what it is:
error[SC0525]: expected `String`, found `an integer literal`
--> pending.science:5:13
|
5 | print(wants(n))
| ^ this is `an integer literal`
1 error
An integer literal is accepted at a floating position —
let x: F64 be 1 — because that is what a scientific
program writes and 1 is exact there. A float literal is
not accepted at an integer one: let n: I32 be 1.5 is a
value the target cannot hold, and admitting it is how a language becomes
wrong on somebody's index arithmetic.
The edges of a numeric literal
| Written | Read as | Because |
|---|---|---|
| 1.5 | a float | A . makes a float only when a digit follows it. |
| 1.round() | an integer, then a method call | Same rule, read the other way: no digit after the dot, so the literal ends at the 1. This is what keeps method calls on integer literals possible. |
| 1. | an integer, then a dot | And then SC0102, because a . wants a name after it. |
| 6.02e23 | a float | An exponent, in either case, with an optional sign. |
| 1e | an integer with the suffix e | An exponent needs digits. Without them the e is word-shaped, so it is read as a suffix — and then SC0009. |
| 1f32 | a float | A float suffix makes a float with neither dot nor exponent. The reverse does not hold: 1.5i32 is SC0009. |
| 42foo | one broken literal | Everything word-shaped after the digits is consumed as the suffix, so this is SC0009 and not an integer standing beside an identifier. |
| 0x1.8 | not a hexadecimal float | A prefixed literal is never a float. 0x1, then a dot, then 8 — and SC0102. |
| 1_0i64 0b1111_0000 | 10 and 240 | _ separates digits in every base, anywhere among them, and combines with a suffix. |
The ten suffixes are i8 i16 i32 i64 u8 u16 u32 u64 f32 f64, and
they are the spelling of the literal: the type names are
I8…U64, F32,
F64. A literal's value is accumulated into a 128-bit
accumulator and SC0005 fires on overflow, but the number the
diagnostic quotes is U64's, because that is the widest integer
type the program actually has.
The array literal
[a, b, c] is an Array of T where T is
the unification of its elements. A [ in prefix position
is what tells the literal from an index, which is a postfix [,
and a trailing comma is allowed like everywhere else. An empty literal takes
its element type from the position it sits in and has no other source for
one.
let counts be [1, 2, 3]
let empty: Array of Int be []
let widths: Array of U8 be [1u8, 2u8,]
print(f"{counts.length()} {empty.length()} {widths.length()}")
| Written | Means |
|---|---|
| [1, 2, 3] | An Array of I64 — the elements are unconstrained integer literals, so the fallback applies. |
| [1, "two"] | SC0281, once per element that disagrees, with the element that set the type underlined as well. There is no implicit numeric conversion, so [1, 3.5] is the same error. |
| [] | SC0282 unless the position says what it holds. Annotate the binding. |
| [x for x in xs] | SC0153. Science has no comprehensions; the chain does the same thing, lazily and in reading order. |
error[SC0281]: the elements of this array literal do not have one type
--> mixed.science:1:18
|
1 | let mixed be [1, "two", 3.5, true]
| - this is an integer literal
| ^^^^^ this is `String`
|
= note: an array literal is an `Array of T` where `T` is the unification of its elements (§3.1), and there is no implicit numeric conversion (§5.1): write the suffix, the `as`, or the literal you meant
1 error
Indexing is the other half of the bracket and has four diagnostics of its
own: SC0151 for a[], SC0152 for
a[-1] — a negative index does not count from the end, and
the note says why Python's reading is the trap it is —
SC0154 for a range that starts past where it ends, and
SC0538 for a range index at all, since there is no
Slice for it to produce.
Operators and precedence#
Eleven rungs. The table below is read off binary_op and the
expression descent in crates/science-parser/src/parser.rs, not
off a note — the parser has a unit test that asserts the ten infix
rungs against the same numbers, so a row that lost an operator to a typo
would fail a test rather than show up as a wrong parse somewhere far away.
| Binds | Operators | Associates | Where it lives |
|---|---|---|---|
| tightest | f(x) a[i] a.b a.b(x) e? | left to right | the descent — postfix |
| ↑ | - not borrowed mutable borrowed | prefix | the descent — unary |
| ↑ | as | left | the descent — cast |
| 10 | ** | right | infix table |
| 9 | * / % @ | left | infix table |
| 8 | + - | left | infix table |
| 7 | << >> | left | infix table |
| 6 | & | left | infix table |
| 5 | ^ | left | infix table |
| 4 | | | left | infix table |
| 3 | is is not < > <= >= | left | infix table |
| 2 | and | left | infix table |
| 1 | or | left | infix table |
| loosest | .. ..= | neither — both ends required, no chaining | above the climb |
| outside | x giving e | takes the whole right-hand side | read before the climb starts |
| not an operator | be | — | a statement; the parser asserts it has no rung |
Three rows are worth a sentence each. ** is the only
right-associative operator, so 2 ** 3 ** 2 is
2 ** (3 ** 2); every other row absorbs to the left.
.. is looser than everything, which is what
makes 0..n - 1 count to n - 1 rather than
subtracting from a range. giving is read above the
whole table, because there is no operator its right-hand side could
be an operand of: doc giving doc.title.length() gives the
closure everything after the word.
let a be 10 let b be 3 let power be 2 ** 3 ** 2 let mixed be 1 + 2 * 3 let bits be 1 + 1 << 2 | 0b1 ^ 0b10 & 0b11 let ordered be a + b < a * b let identical be a + b is a * b let logical be a < b or a > b and true let negated be not (a < b) let widened be a as F64 * 2.0 print(power) print(mixed) print(bits) print(ordered) print(identical) print(logical) print(negated) print(widened)
The parts of that listing the backend can emit — everything but
**, the shift and the cast — run and print this:
let a be 10
let b be 3
print(f"{a + b} {a - b} {a * b}")
print(f"{1 + 2 * 3} {(1 + 2) * 3}")
print(f"{a < b or a > b and true} {not (a < b)}")
# 13 7 30
# 7 9
# true true
Identity in words, order in symbols
is is equality and is not is inequality, and they
are the only spelling identity has. == and
!= are not Science and the lexer reports them as
SC0016 and SC0017 with the word as a
machine-applicable fix. Ordering has no word form and needs none:
>= is four hundred years old.
error[SC0016]: equality is written `is`
--> comparison.science:2:9
|
2 | print(a == 1)
| ^^ `==` is not an operator in Science
|
= note: there is one spelling for each comparison: `is` and `is not` for identity, and `>`, `<`, `>=`, `<=` for order
= help: write the word instead: `is`
1 error
@ is matrix multiply and sits with * / %. There is
no tensor in the library today, so it reaches a user type through the
MatMul interface exactly as + reaches one through
Add. The operator and its precedence are settled now so that
the phase that brings tensors does not get to move them.
Calls, chains, closures#
A call is f(x); a method call is a.m(x); a
named-argument call on a path is a record construction and nothing
else. Chains are the shape most Science code takes, and there are two
spellings for the function a link needs.
type Doc:
title: String
body: String
Doc has:
def is_empty(self) -> Bool:
self.body.is_empty()
def headlines(docs: borrowed Array of Doc) -> Array of String:
docs
.iterate()
.discard(each.is_empty())
.map(each.title)
.take(5)
.collect()
def by_length(docs: borrowed Array of Doc) -> Array of String:
docs
.iterate()
.map(doc giving doc.title)
.sort(by: line giving line.length())
.collect()
let docs be (Array of Doc).new()
print(headlines(docs).length())
print(by_length(docs).length())
| Form | Means |
|---|---|
| each.field | A closure over the enclosing call's element, with no parameter declared. Cannot nest — a nested each is SC0115 rather than a rule nobody would remember. |
| x giving e | A closure that names its parameter. Takes the whole expression to its right. |
| f(label: v) | A labelled argument. On a path this is a record construction instead; resolution tells them apart, because only it knows whether the path is a module. |
| a.m(x) | A method call. A borrowed parameter is borrowed at the call site with nothing written for it. |
| a[i] | An index — [ in postfix position, binding with call and field access. |
| [a, b] | An array literal — [ in prefix position. The rule is parser position and nothing else. See below: this parses and is not type-checked. |
| unsafe: | A block expression. Its value is its body's value. |
A chain breaks across lines inside an unclosed bracket, where the line structure is suspended, and that is the only line-continuation rule in the language — there is no backslash.
Branches and loops#
if is an expression, so it is also the conditional. Two loops:
for x in xs walks anything implementing Iterate,
and loop takes no condition at all. There is no
while, and the reason is that a loop's condition is usually the
first statement of its body once it is written honestly.
def longest(a: borrowed String, b: borrowed String) -> borrowed String:
if a.length() > b.length(): a else: b
def bucket(n: Int) -> String:
if n < 10: "small" else: if n < 100: "medium" else: "large"
def sign(n: Int) -> Int:
if n < 0: return -1
if n > 0: return 1
0
def dangling(a: Bool, b: Bool):
if a: if b: print("both") else: print("only a")
def not_dangling(a: Bool, b: Bool):
if a:
if b: print("both")
else:
print("neither")
let flag be true
let chosen be if flag: 1 else: 0
print(longest("abc", "de"))
print(bucket(42))
print(sign(-3))
print(chosen)
dangling(true, false)
not_dangling(false, false)
An inline block ends where its expression ends, not at the end of
the line. In if c: a else: b the then-branch ends
mid-line, at else, because else cannot continue an
expression. "Fits on one line" is a consequence of that rule and not the
rule. An inline body may be an expression, an assignment, or
return, break or continue; it may
not be a let, which would bind a name nothing could
then use (SC0109). A dangling else binds to the
innermost if, and that is a language rule rather than an
artefact of recursive descent.
def sign(n: Int) -> Int:
if n < 0: return -1
if n > 0: return 1
0
print(f"{sign(-3)} {sign(0)} {sign(9)}")
# -1 0 1
def ruler(width: Int) -> Int:
let mutable marks be 0
for i in 0..width:
if i % 5 is 0:
marks be marks + 1
marks
def triangular(n: Int) -> Int:
let mutable sum be 0
for i in 0..=n:
sum be sum + i
sum
def countdown(start: Int):
let mutable n be start
loop:
if n <= 0:
break
print(n)
n be n - 1
def first_multiple(limit: Int, factor: Int) -> Int?:
for i in 1..=limit:
if i % factor is not 0:
continue
return i
null
def count_spaces(text: borrowed String) -> Int:
let mutable spaces be 0
for c in text.chars():
if c is ' ':
spaces be spaces + 1
spaces
print(ruler(20))
print(triangular(4))
countdown(3)
let found be first_multiple(20, 7)
if found?:
print(found)
print(count_spaces("one two three"))
| Form | Means |
|---|---|
| for x in xs: | Walks anything implementing Iterate. |
| for i in 0..n: | Half-open — stops one short of n. This is the counting loop. |
| for i in 0..=n: | Inclusive — visits n itself. |
| loop: | Unbounded. Ended by a break or a return in its body. |
| break | Ends the innermost loop only. |
| continue | Next iteration of the innermost loop. |
| for _ in 0..n: | A loop whose element is deliberately unused. |
A range's ends are written with .. and never with
:, which already opens blocks and separates match arms, fields,
named arguments and bounds. Both ends are required — see
below for what that costs.
else if, and the two block shapes
Every block in the language is one of two shapes and they mix freely: a
: then a newline and an indented body, or a : then
a single statement on the same line. else if is not a
third thing and not a keyword — it is an else whose body
is an if, which is why it takes no : of its own and
why there is no elif to remember.
def bucket(n: Int) -> String:
if n < 10:
"small"
else if n < 100:
"medium"
else:
"large"
def bucket_inline(n: Int) -> String:
if n < 10: "small" else: if n < 100: "medium" else: "large"
def mixed(n: Int) -> Int:
if n < 0:
let magnitude be 0 - n
magnitude
else if n is 0:
0
else:
n
def short(n: Int) -> Int:
if n > 0:
1
else: 0
print(f"{bucket(42)} {bucket_inline(4)} {mixed(-3)} {short(9)}")
| Shape | Holds |
|---|---|
| : ↵ indented | One or more statements. The last expression is the block's value. |
| : one statement | An expression, an assignment, or return / break / continue. Not a let, which would bind a name nothing could then use — SC0109. |
| else if | An else whose body is an if. No : after else, and the chain nests rather than flattening. |
| else: | A block like any other. A dangling else binds to the innermost if, by rule rather than by accident of the descent. |
A : with neither an indented block nor something that can start
a statement after it is SC0103, "expected an indented block or a
single expression after :". match is the
one construct with no inline form: its arms are always
:, newline, indented — match c: A: 1 on one
line is SC0100. Individual arms take either shape.
What the loop words do and do not take
| Written | Rule |
|---|---|
| for p in xs: | The subject is a pattern, not just a name, so for (a, b) in pairs:, for _ in 0..n: and for mutable x in xs: all parse. The parentheses on a tuple are required — for a, b in xs is SC0100, "expected in, found ,". |
| for each x in xs: | SC0138. each names the subject of a call, as in docs.map(each.title), and nothing else. |
| while c: | SC0142, with the whole head underlined and loop offered as the fix — the diagnostic is reproduced under No while. The condition is parsed and then discarded and the body is kept, so the rest of the function still checks against something. |
| break | Ends the innermost loop. No label. A value after it parses, and there is no loop-as-expression for that value to become, so it is read as a separate statement rather than as the loop's result. |
| continue | Next iteration of the innermost loop. No value, no label. |
| return | With a value or without. It is the only way out of a loop that also leaves the function, and it works from inside any depth of block. |
All three are statements and not expressions, so
let x be break does not parse. The one place they behave like
expressions is an inline block, where each is allowed as the single statement
— a concession made because each has type Never in every
respect except which list the syntax tree files it under.
match and patterns#
A match answers for every value or the program has no answer
for the rest, so exhaustiveness is checked and a missing variant is named in
the diagnostic rather than left to a wildcard. Every pattern form the
language has is in the listing below.
choice Format:
Plain
Markdown
Json
choice Token:
Ident(String)
Number(Int)
Symbol(Char)
Eof
type Point:
x: Int
y: Int
def copy_of(text: borrowed String) -> String:
let mutable out be String.new()
out.push_str(text)
out
def name_of(format: borrowed Format) -> String:
match format:
Plain: "plain"
Markdown: "markdown"
Json: "json"
def qualified(format: borrowed Format) -> String:
match format:
Format.Plain: "plain"
Format.Markdown: "markdown"
Format.Json: "json"
def digit_name(digit: Int) -> String:
match digit:
0: "zero"
1: "one"
_: "many"
def size_class(n: Int) -> String:
match n:
0 | 1 | 2: "small"
3 | 4 | 5 | 6: "medium"
_: "large"
def quadrant(point: (Int, Int)) -> String:
match point:
(0, 0): "origin"
(x, 0): "on the X axis"
(0, y): "on the Y axis"
(_, _): "somewhere else"
def classify(point: borrowed Point) -> String:
match point:
Point(x: 0, y: 0): "origin"
Point(x: 0, y: y): "on the Y axis"
Point(x: x, y: _): "general"
def payload(token: borrowed Token) -> String:
match token:
Ident(name): copy_of(name)
Number(value): digit_name(value)
Symbol(','): "comma"
Symbol(c): "symbol"
Eof: panic("expected a token, found the end of input")
def passthrough(n: Int) -> Int:
match n:
0: 1
other: other
let label be match Markdown:
Plain: "plain"
Markdown: "markdown"
Json: "json"
print(label)
print(name_of(Json))
print(qualified(Format.Json))
print(size_class(4))
print(quadrant((0, 3)))
print(classify(Point(x: 1, y: 0)))
print(payload(Ident("width")))
print(passthrough(9))
| Pattern | Matches |
|---|---|
| Plain | A variant with no payload. Unqualified. |
| Format.Plain | The same variant, qualified by its type. |
| Ident(name) | A variant, binding its payload. |
| Symbol(',') | A variant whose payload is itself matched. |
| 0 'a' "hi" true | A literal, of any literal type. |
| a | b | c | Alternatives. Composes with the forms above. |
| (x, 0) | A tuple, positionally. |
| Point(x: 0, y: y) | A record, by field name — mirroring construction exactly. |
| Point(x: x, y: _) | A record ignoring a field. |
| other | A binding that catches everything and names it. |
| _ | The wildcard: catches everything and names nothing. |
An arm's body is inline after the :, or an indented block, and
the two mix freely inside one match. There is no guard
clause — no if inside a pattern — because a
condition on a bound payload is an if in the arm body, one line
down, and a guard is the feature that makes exhaustiveness checking a
guess.
error[SC0250]: `borrowed Format` has a value no arm of this `match` covers: `Json`
--> exhaustive.science:7:11
|
7 | match format:
| ^^^^^^ this is `borrowed Format`
|
= note: a `match` answers for every value or the program has no answer for the rest (§4.5); write an arm for `Json` — or a `_` arm, which covers everything the arms above leave
1 error
panic has type Never, which coerces to any type.
That is what lets one arm bail out while the others produce a
String, without the match losing its type.
choice Format:
Plain
Markdown
Json
def name_of(format: Format) -> String:
match format:
Plain: "plain"
Markdown: "markdown"
Json: "json"
print(name_of(Plain))
print(name_of(Json))
# plain
# json
Two more pattern forms, and every form there is not
null is a literal, so it is a literal pattern like any
other, and a binding may be mutable — which is the only
way to change a value a match just bound without copying it into
a fresh let first.
def present(x: Int?) -> Int:
match x:
null: 0
_: 1
def bump(n: Int) -> Int:
match n:
0: 0
mutable other:
other be other + 1
other
print(f"{present(null)} {present(3)} {bump(7)}")
Matching null does not narrow the other arm: in
match x: with a null arm and a binding arm, the
binding still has type Int? and using it as an Int
is SC0525. Narrowing is what if x?: is for, and
match over a nullable is for the shape rather than for the
unwrap.
A pattern is a closed grammar and everything below is refused. Each is a feature some other language has, and the entry says what to write instead.
| Not a pattern | What happens | Write |
|---|---|---|
| 1..5: | SC0100, "expected :, found ..". There is no range pattern. | An if in the arm body, or alternatives. |
| x if x > 0: | SC0100, "expected :, found if". There are no guards, and that is the decision, not an omission: a guard is the feature that turns exhaustiveness checking into a guess. | An if in the arm body, one line down. |
| whole @ A(v): | SC0100, "expected :, found @". @ is matrix multiply and binds nothing. | A second arm, or bind the whole value and destructure inside. |
| -1: | SC0106, "expected a pattern, found -". A negative literal is a unary operator applied to a literal, and a pattern holds no operators. | A binding arm, and compare in the body. |
| [a, b]: | SC0106, "expected a pattern, found [". There are no array or slice patterns, for the same reason there is no Slice. | Index, or iterate. |
| Doc(title): | SC0206 — "Doc is a record type, so its pattern names its fields". There is no field shorthand: a record pattern mirrors construction exactly, and construction names every field. | Doc(title: title) |
| A(x) | B(x): | SC0201. An alternative that binds is one binding too many for the shared scope, so alternatives work for unit variants and literals and not for payloads. See Scope and shadowing. | Two arms. |
| (a, ..): | SC0106. No rest pattern, in a tuple or a record. | (a, _) |
Absence and failure#
Two shapes, and they are deliberately the same shape.
Absence is T?: a value of that type is a
T, or it is null.
Failure is a second return value: -> (T, E?),
so a function that can fail hands back what it produced and what
went wrong. ? is the postfix presence test —
err? is a Bool — and after
if err?: return … the compiler knows the value is good.
type Config:
host: String
port: U16
Config has:
def empty() -> Config:
Config(host: String.new(), port: 0u16)
choice ConfigError:
NotFound(String)
Malformed(String, Int)
ConfigError implements Error:
def message(self) -> String:
match self:
NotFound(key): copy_of(key)
Malformed(detail, line): copy_of(detail)
def copy_of(text: borrowed String) -> String:
let mutable out be String.new()
out.push_str(text)
out
def heading(text: borrowed String) -> String?:
if text.starts_with("# "):
text.truncate(60)
else:
null
def parse_port(text: borrowed String) -> (U16, ConfigError?):
if text.is_empty():
return (0u16, ConfigError.Malformed("empty input", 1))
if not text.starts_with("port="):
return (0u16, ConfigError.NotFound("port"))
(8080u16, null)
def read_config(path: borrowed String) -> (Config, Error?):
let port, err be parse_port(path)
if err?:
return (Config.empty(), err)
(Config(host: copy_of(path), port: port), null)
def save(path: borrowed String) -> Error?:
let config, err be read_config(path)
if err?:
return err
null
let title be heading("# Regions")
if title?:
print(title)
else:
print("no heading")
let config, err be read_config("port=8080")
if err?:
print(err.message())
else:
print(config.port)
| Form | Means |
|---|---|
| T? | A T or null. A field, a parameter and a return type like any other. |
| e? | Presence. A Bool, and a total operator — it can only produce a value and never returns from the function around it. |
| -> (T, E?) | Fallible, with a concrete error. match over it stays exhaustive and it does not box. |
| -> (T, Error?) | Fallible, with the interface error. Shorthand for (T, (any Error)?). Composes across a library boundary, and boxes. |
| -> Error? | Fallible with nothing to return. The pair would have () in its first slot. |
| if err?: return … | The check. The narrowing that follows is what keeps the model from being cast-ridden. |
? binds on the tightest rung with call, index and field access
and is postfix, so registry.points.get(0)? asks about the
result of the call, not about registry. Covering less takes
parentheses. Because it produces a Bool it composes with
and, or and not like any other
boolean.
SC0140 is what recovers most of what a
Result guaranteed: an error bound and never tested is
a compile error.
error[SC0140]: `err` is never checked for an error
--> unchecked.science:5:15
|
5 | let text, err be load("notes.txt")
| ^^^ bound here and never tested
|
= note: test it with `if err?:`, pass it on, or return it — or rename it to `_err` to say the failure is deliberately ignored
1 error
Four things excuse an untested error and nothing else does: naming it
_err, returning it, passing it on, and a body that cannot
return at all. The check is on the binding, not on the call, which
is why it survives the value being used — print(value)
does not count as having looked at err.
The hole in that check, and why this entry is the softest on the page
The condition the design states is "E? where E
implements Error", and read literally that is silent on the
program the language itself recommends: a function naming a concrete
choice as its error so the caller can match on it
exhaustively, with no implements Error block. The corpus
writes exactly that. So the one construct that recovers what a
Result guaranteed had a shape it did not cover, and the shape
was the recommended one.
That coverage is being changed as this page is written, so
it is the one claim here not worth pinning to a number: the rule is moving
from the error type to the error position — the
last binding of a several-binding let, of nullable type, whose
initialiser is a call. Check it against your own compiler before relying on
it either way. The advice that survives the change is unchanged: if you
take the concrete error form for the match it buys, test the
error by hand and do not wait to be told.
There is no Option, no Result, no
Some, no None, no Ok, no
Err, no try, no unwrap and no
is_some — because there is no wrapper type left for those
methods to hang off. ? is the only operation a nullable has.
Every reserved word#
The lexer's from_word in
crates/science-lexer/src/token.rs maps 68 words
to something other than an identifier. Forty are live —
they mean something today. Twenty-eight are held —
reserved for a later phase, an error to use, and never an identifier. Both
tables below are that function, read out.
Live — 40 words
| Word | Means |
|---|---|
| def | Declares every function, method, associated function and interface member. |
| tool | A function a model may call, with five extra checkable obligations. |
| type | A record (type X:) or an alias (type X is Y). |
| choice | A sum type. |
| interface | Required methods, defaults and associated types. |
| implements | T implements I: — supplies an interface. |
| has | T has: — methods from no interface. |
| of | A generic argument, in a declaration and at a use site. |
| where | A bounds clause after a return type. |
| const | A compile-time constant, and a const generic parameter. |
| public | Exports an item from its module. |
| use | Imports a module or selected names from one. |
| extern | Opens a foreign declaration block. Always preceded by unsafe. |
| unsafe | On an extern block, and as a block expression. |
| let | Introduces a binding. |
| be | Binds, and reassigns. One word for both. |
| mutable | On a binding, on self, and before borrowed. |
| borrowed | A shared borrow, in a type and as a unary operator. |
| any | Dynamic dispatch: any I. |
| as | The cast. Always written; there is no implicit conversion. |
| if | A branch, and an expression. |
| else | The other branch. Binds to the innermost if. |
| match | An exhaustive dispatch over patterns. |
| for | for x in xs:. |
| in | The second half of for. |
| loop | The unbounded loop. Takes no condition. |
| break | Ends the innermost loop. |
| continue | Next iteration. |
| return | Returns from the enclosing function. |
| each | Names a chain link's element without declaring it. Reserved for that alone. |
| giving | A named closure: x giving e. |
| and | Logical conjunction, rung 2. |
| or | Logical disjunction, rung 1. |
| not | Logical negation, unary. |
| is | Equality; is not is inequality. Followed by at most one more word. |
| self | The receiver of a method. |
| Self | The implementing type, inside an interface or implementation. |
| true | A Bool literal. |
| false | A Bool literal. |
| null | The other value of a T?. A literal, not a prelude value. |
_ is a token of its own and not a word in that list: it is the
wildcard pattern and the "deliberately unused" prefix. library,
via, pkg-config, kind,
when, available, symbol,
size and align are not keywords:
they are recognised positionally inside an extern header or
item and remain ordinary identifiers everywhere else.
Held — 28 words
Each of these lexes to a reserved token. Using one as a name is an error today and will not be a breaking change when the phase that wants it lands.
| Word | Held for |
|---|---|
| agent | F4 — agent as a declaration construct. |
| prompt | F4 — a prompt as a declaration construct. |
| model | F4, hypothetically. The audit asks to free it; it is the most common variable name in machine-learning code. |
| spawn | F3 — actors. |
| send | F3 — actors. |
| receive | F3 — actors. |
| supervise | F3 — supervision trees. |
| async | Concurrency. The concurrency note declines the model and keeps the word. |
| await | The same decision, the same note. |
| durable | F5 — durability. |
| checkpoint | F5. Collides with a training checkpoint in binding position. |
| resume | F5 — resuming a lowered state machine. |
| tensor | F1, hypothetically. The audit asks to free it; the type Tensor is capitalised and was never at risk. |
| shape | F1, hypothetically. The audit asks to free it, and the broadcasting note has since confirmed F1 wants no shape declaration at all. |
| parallel | F2 — a parallel chain link, where the word on the screen is the point. |
| kernel | F2 — a GPU kernel. The audit asks to free it: it is also a KDE kernel, an NMR window and a nullspace. |
| equation | An equation as a construct: dimensionally checked at compile time and rendered to the paper from the same definition that ran. formula was rejected because chem already spells a chemical formula that way. |
| pure | A purity marker on a declaration, with its own diagnostic in the effects design. |
| assert | A verification construct, not a library function — a place where the compiler can sometimes discharge the condition statically. Kept deliberately, and unbuilt. |
| with | A block binding const dimension parameters, in the broadcasting design. |
| on | A parameter label — train(net, on: data). |
| move | An explicit move, at a C boundary. |
| yield | A generator form no phase asks for. The audit asks to free it: it is the result in chemistry, agronomy and finance. |
| mod | A module declaration form that does not exist — mod.science is a filename, and filenames are not identifiers. The audit asks to free it for arithmetic modulo. |
| import | Nothing: Science spells it use. The audit asks to free it. |
| union | Live inside an extern block; held at the top level. The audit asks to free it there, for set algebra. |
| static | Live inside an extern block; held at the top level, where there are no mutable globals to declare. |
| macro | A macro form, checked against the reserved list by the self-hosting design. |
Eight names are deliberately not reserved, because each is a common variable name in the code of the people this language is for:
- grad
- dim
- dims
- axis
- device
- dtype
- unit
- alias
function is free too, and that is a decision rather than an
omission: it was the declaration keyword until syntax revision 3 and is now
an ordinary word a program may bind. The stale declaration is recognised
positionally — the word function followed by a name, one
token of lookahead — which is SC0156 with a
machine-applicable fix. A reservation would have bought the same diagnostic
and cost the identifier, which the FFI design already binds as a C struct
field.
What using a held word looks like
error[SC0102]: expected an identifier, found `model`, which is reserved for a later phase
--> naming.science:1:5
|
1 | let model be 3
| ^^^^^ expected an identifier, found `model`, which is reserved for a later phase
error[SC0105]: expected an expression, found `model`, which is reserved for a later phase
--> naming.science:2:7
|
2 | print(model)
| ^^^^^ expected an expression, found `model`, which is reserved for a later phase
2 errors
Three places the design notes and the lexer disagree
1. The audit asks to free eight words, and all eight are still
held. shape, model,
tensor, mod, yield,
kernel, union and import are each
marked Free in reserved-words.md §5, and
from_word maps every one of them to
Reserved(…) today. The recommendation was strengthened
rather than withdrawn — §6 records that the F1 design has since
confirmed it wants no shape declaration, so the reservation
protects a construct nothing asked for — and it has not been acted
on.
2. The dot rule is not implemented. §0.1 of that note
asks for one function: after a ., accept any word as a member
name, because no keyword can begin anything in that position. It calls the
change "required regardless of every other decision here". The parser
still calls expect_ident after a Dot, so
x.shape, a.union(b), values.any(),
re.match(s), n.mod(m) and
frame.import() all fail — and
g.shape produces three errors rather than one,
because the parser cannot resynchronise on a word it refused.
3. There is no SC0002. §5.1 of the note
describes SC0002 as "a word reserved for a later phase used as
a name" and asks for its message to gain a fix. No such code exists in any
crate; a held word surfaces as whatever the parser wanted there instead
— SC0102 in binding position, SC0105 in
expression position — which is true and useless, exactly as the note
predicted for the member case. The core spec's §13 also still lists
tool, extern and unsafe as
"reserved, not yet used", and the lexer makes all three live keywords.
Parses, and is not built#
Six constructs get through the parser and stop somewhere after it. A page that listed them beside the working forms would be worse than one that omitted them, so they are here, each with the diagnostic it produces today — or, in two cases, with the fact that it produces none, which is the more expensive kind.
1. a[i] be v reaches an interface you cannot supply
Indexing is two interfaces: Index reads and yields
borrowed Output; IndexMutably writes and yields
mutable borrowed Output. a[i] be v parses,
dispatches to IndexMutably, and works for the built-in
Array. Writing your own is where it stops: the obvious
implementation reads through the backing Array, an index read
yields borrowed T, and nothing in the language turns
that into a mutable borrowed T.
type Grid:
cells: Array of Int
Grid implements IndexMutably of Int:
type Output is Int
def index_mutably(mutable self, at: Int) -> mutable borrowed Self.Output:
self.cells[at]
let mutable grid be Grid(cells: (Array of Int).new())
grid[0] be 9
error[SC0525]: expected `mutable borrowed Int`, found `borrowed Int`
--> writing.science:8:9
|
8 | self.cells[at]
| ^^^^^^^^^^^^^^ this is `borrowed Int`
1 error
Writing mutable borrowed self.cells[at] does not rescue it
— that is mutable borrowed borrowed Int. What
does check is an IndexMutably whose output is reached
through a field rather than through an index, so the
interface is implementable and the container it is wanted for is not.
2. Slicing does not exist, in two different ways
a[1..5], a[2..], a[..5] and
a[..] are all specified as index-bracket forms, and a range
index is specified to produce a Slice of T — a borrow and
a length, which does not copy. None of the four works, and the two
halves fail at different stages.
The open-ended forms do not parse: parse_range
requires both ends, because an open end would have to be told from a
.. that ends a line.
error[SC0105]: expected an expression, found `]`
--> slice.science:2:19
|
2 | let tail be xs[2..]
| ^ expected an expression, found `]`
1 error
The closed form parses and is refused by the type checker,
which is the better of the two failures and was not always the case: this
used to check clean and hand back borrowed Int — one
element rather than a slice — because the untyped range and the
Int the Index implementation wanted agreed by
cancelling instead of by matching. It now says what it means.
error[SC0538]: a range index would produce a slice, and there is no `Slice` type
--> slice.science:2:5
|
2 | xs[1..3]
| ^^^^^^^^ slicing a `borrowed Array of Int` is not implemented
|
= note: `a[1..5]` is specified as a `Slice of T` — a borrow and a length, which does not copy (§2.3) — and this compiler declares no such type. Index one element at a time, or iterate: `xs.iterate().skip(1).take(2)`
1 error
There is no Slice in the prelude to name either:
Slice of Int in a signature is SC0200, "cannot find
Slice". Index one element at a time, or iterate.
3. A where predicate on anything but a bare parameter is discarded
A clause's subject is read by the full type grammar, so
where Array of T: Ord, where borrowed T: Ord and
where Int: Ord all parse. The checker then keeps a predicate
only when its subject is a bare, argument-free name
resolving to a generic parameter the declaration itself introduced, and drops
every other predicate — no diagnostic, no note, no warning. The listing
below checks clean, and D implements Ord nowhere.
interface Ord:
def cmp(self, other: borrowed Self) -> Int
type D:
v: Int
def held of T(x: borrowed T) -> Int where Array of T: Ord, borrowed T: Ord, Int: Ord:
0
def calls(d: borrowed D) -> Int:
held(d)
print(f"{calls(borrowed D(v: 1))}")
The bare form is checked properly, and the difference is the measurement:
change that clause to where T: Ord and the same call is
SC0534, "D does not implement Ord",
with the declaration site pointed at as well. So the failure is not that
bounds go unchecked — it is that a bound the checker does not recognise
looks exactly like one it satisfied. Write the subject as a bare parameter
name, and put anything else in the body, where it will be checked.
What used to be here, and is not any more
This slot held the array literal, which parsed and was not type
checked at all: let counts: Array of String be [1, 2,
3] checked clean, and let counts be [1, 2, 3] left its
binding at the error type with no diagnostic, so every check downstream of
it was vacuous. SC0281 — the code the design allocated
for exactly this — was defined in no crate. It is defined now, and a
second code, SC0282, covers the empty literal that has nothing
to take an element type from. Both forms are in
Literals as working syntax. This paragraph is here
rather than deleted because a reference that quietly drops a hole teaches
the reader nothing about how far to trust the rest of the list.
4. The format mini-language is specified and unbuilt
Fill, alignment, sign, width, grouping, precision, the type codes and the
!i conversion are all designed, and checkable at compile time
against the argument's static type. None of it exists. The compiler
refuses a specification rather than parsing and ignoring
one, which would look supported.
error[SC0173]: a format specification, which this compiler does not implement
--> format.science:2:12
|
2 | print(f"{pi:.2f}")
| ^^^^ no part of the format mini-language is built
|
= note: `f"{value}"` is the whole of what this compiler reads: an expression, rendered by its `Display`. The fill, alignment, sign, width, grouping, precision and type codes, and the `!i` conversion, are specified and unbuilt
1 error
5. SC0286 — bounds checking — does not exist
The design gives every a[i] an index obligation —
0 <= i and i < extent — discharged
statically where it can be, and warned about under SC0286 where
it cannot. No pass discharges one and no crate defines the code; the whole
SC0280–SC0289 band is unallocated. An index
is unchecked at compile time and the runtime fallback is a panic that has
never run.
6. Interpolating a narrowed T? emits an invalid module
Four lines, checking clean, that the backend miscompiles. The nullable's two-word representation is handed to a runtime entry point that takes one word, and LLVM's own verifier catches it before the linker is reached.
let mutable maybe: Int? be null
maybe be 7
if maybe?:
print(f"{maybe}")
error[SC0402]: the linker failed
= note: the command was: (the backend, before the linker)
= note: what follows is the linker's own output, verbatim:
= note: the module failed verification in `<module>`: Call parameter type does not match function signature!
%v10 = load [2 x i64], ptr %l1, align 8
i64 call void @science_string_push_i64(ptr %v9, [2 x i64] %v10)
That is the good failure: the backend verifies what it emits and stops
rather than linking a binary that would read the wrong word. Narrowing works
everywhere check can see it; it is the lowering that has not
caught up.
What there is instead#
Twelve things a reader arriving from Python, Rust or Go will look for and not find. Each one is a decision with a replacement, and for eight of them the compiler says so itself rather than failing on the grammar — a migration diagnostic that names the word and, where there is one spelling to swap in, carries a machine-applicable fix.
| Not in the language | Write instead | Diagnostic |
|---|---|---|
| while c: | loop: if not c: break | SC0142 |
| class C: | type C: … C has: | a grammar error |
| a if c else b | if c: a else: b | a grammar error |
| try / catch | let v, err be f() if err?: | a grammar error |
| try f() | the same three lines | SC0155 |
| Result of (T, E) | -> (T, E?) | SC0200 |
| Option of T, Some, None | T? and null | SC0200 |
| <'a> lifetimes | nothing — regions are inferred | a grammar error |
| [x * 2 for x in xs] | xs.iterate().map(each * 2).collect() | SC0153 |
| t.0 | let a, b be t | SC0102 |
| f"{x:.2f}" | nothing yet — see above | SC0173 |
| a[-1] | a.last() | SC0152 |
| ==, != | is, is not | SC0016, SC0017 |
| function f(): | def f(): | SC0156 |
| pub | public | a grammar error |
No while
A while loop's condition is nearly always a restatement of the
break that ends it, and a counted loop is a range. What is left
is the loop whose end is a condition on the first line of its body, so that
is what loop is — and the diagnostic teaches both
replacements rather than one.
error[SC0142]: the unbounded loop is written `loop`
--> loop.science:2:1
|
2 | while n > 0:
| ^^^^^^^^^^^ `while` is not a keyword in Science, and `loop` takes no condition
|
= note: end it with the condition negated: make `if not …: break` the first statement of the body, or write a count as a range, `for i in 0..n:`
= help: open the loop with: `loop`
1 error
No comprehensions
One form per operation. A comprehension and a chain do the same thing, and keeping both means every reader learns both and every author picks. The chain is the one kept, because it reads in the order it runs and it composes past three links where a comprehension does not.
error[SC0153]: Science has no comprehensions; write the chain
--> chain.science:2:16
|
2 | let doubled be [x * 2 for x in xs]
| ^^^^^^^^^^^^^^^^^^^ this bracket holds a comprehension, not a list
|
= note: the chain does the same thing, lazily and in reading order: `source.iterate().keep(<condition>).map(<expression>).collect()`
= note: `keep` comes before `map`, and `each` names the element, so the chain declares no loop variable
1 error
A comprehension is caught on the syntax tree, before types, and that is deliberate: a comprehension type-checks as nothing at all, so a phase that let it through would report the absence of a chain three times and the comprehension never.
No try, and no Result for it to unwrap
try f() was one line and the check is three. The trade was
taken knowingly: the error path is then written where it happens, in the
order it happens. try also widened one error type into another
by calling From invisibly, which no section of the spec ever
said it did — so the conversion moved to the caller, at the
return, where it is written down.
error[SC0155]: `try` was removed with the `Result` type
--> failure.science:4:10
|
4 | let n be try load("run.csv")
| ^^^ there is no `try` in Science
|
= note: a function that can fail returns its value and an error: write `let value, err be f()`, then `if err?:` and return
1 error
Removed from the language — kept only for comparison
let doc be try load("notes.txt") def find(k: String) -> Option of Doc: Some(doc) def load(p: String) -> Result of (Doc, Error): Ok(doc)
No t.0
A tuple is read by destructuring it or by a tuple pattern, and never by a
numeric field. The . is a postfix operator whose right operand
is a single name, forever — which is also the property that makes the
dot rule of the reserved-word audit correct, and it is the same rule here
working against a number.
error[SC0102]: expected an identifier, found a number literal
--> tuple.science:2:13
|
2 | print(point.0)
| ^ expected an identifier, found a number literal
error[SC0100]: expected `)`, found a number literal
error[SC0100]: expected end of line, found a number literal
3 errors
No negative indexing
This is the one absence whose diagnostic argues rather than instructs, and
the argument is worth keeping: reading a[-1] as the last element
is what makes a[i - 1] inside a loop return the last element at
an i of zero, silently, instead of failing.
error[SC0152]: a negative index does not count from the end in Science
--> index.science:2:10
|
2 | print(xs[-1])
| ^^ an index runs from `0` up to the extent
|
= note: Python reads `a[-1]` as the last element, which is what makes `a[i - 1]` in a loop read the last element at an `i` of zero instead of failing
= help: read the last element with: `.last()`
1 error
No lifetime syntax
There is nothing to write and nothing elided. <'a> is not
a form the grammar knows, and the parser meets it as a <
where it wanted a (, then as a character literal that never
closes — four errors for a construct that does not exist, which is the
honest failure of a grammar with no slot for it. The bet is stated in
the type vocabulary: a borrow checker with nothing to
annotate is either sound or it is a bug.
Comments, documentation, pragmas#
One character opens all three kinds and what follows it decides which:
#is an ordinary comment,##is documentation that the compiler reads and ships, and# fmt: offis an instruction to the formatter. All three run to the end of the line. There are no block comments, and there is no nesting to get wrong, because a comment ends where the line does and nothing reopens it.# An ordinary comment runs to the end of the line. There are no block # comments, so a paragraph is a run of these. #No space after the hash is required. # A comment may hold a "string", a 'c', an 0xFF, and words that would be # keywords anywhere else: def return let be mutable type choice giving. # It may hold anything Unicode can spell. def tally(n: Int) -> Int: # A comment as the first thing in a block. let mutable total be n # a trailing comment after a statement # A comment-only line inside a block, after a blank one. total be total + 1 total print(f"{tally(1)}")## Counts the samples above a floor. ## ## The first line is the summary. The whole run is the description, summary ## included, and `sciencec tools --json` emits it verbatim. tool count_above(samples: Array of F64, floor: U16) -> Int: samples.length() ## A run attaches to the next token that has text of its own, so the newline ## and the indent between this run and `def` pass straight over it. def unused() -> Int: 0 print(f"{unused()}") # 0#— the ordinary commentEverything from the
#to the line break, and nothing else. A comment-only line and a blank line take no part in the indentation calculation, which is what lets a comment sit at any column without opening or closing a block — a comment written at column 0 in the middle of a function does not end the function, and one indented past every open level does not start one. That rule is the reason the language can afford to be indentation-delimited and still let you write a note wherever the note belongs.##— the documentation runA doc comment is trivia, not a token. Making it a token would put it in every
matchover token kinds in the parser, for a thing the grammar never mentions; instead the lexer accumulates a run of consecutive##lines and hands it to the next token that has text of its own.Newline,IndentandDedentpass over it without consuming it, and that is precisely what lets a run survive the line break and the indent that always sit between it and the declaration it documents.###, and every deeper run —###heads a Markdown section, and refusing it would make the marker fight the language written inside it. The cost is that a####rule drawn as decoration is a doc line, and joins the run below it.##lines are one thing with one span, from the first#to the last non-blank character. Trailing whitespace is dropped so a caret under the run is as wide as the run reads.tool's parameters. Nowhere else — a run before adef's parameter isSC0194.On a
toolthe run is not documentation the compiler tolerates; it is program data, and the compiler enforces it. The whole run — every line, joined by line feeds, summary included — becomes thedescriptionthat a model reads in order to decide whether to call the thing.That is one line in the real output, wrapped here to fit the column. Note what is not in it: there is no
titlefield. The first line is the summary by rule —SC0195enforces that it is not blank — and the split is never made in the JSON, because the caller reads the description whole.toolwith no##run above it. An undescribed tool does not fail: it is simply never chosen, and no test catches that.##run before the parameter of anything that is not atool. Only atooldocuments its parameters one by one, because only there does each become a described field of a schema.tool's run that opens with a blank##. The caret covers the whole run, not the declaration under it, because a run of four lines that opens blank is wrong across all four.Two gaps, stated rather than implied.
SC0176— a##run attached to no declaration, at the end of a file or separated from the next declaration by a blank line — is specified as a warning and is defined in no crate: today such a run is silently dropped, and a run separated from its declaration by a blank line still attaches to it. And a##run on atool's parameter is parsed and kept in the syntax tree, but the schema emitter works over a later representation that has no field to carry it, so no per-propertydescriptionreaches the JSON.# fmt:— the formatter's three pragmassciencec fmtis idempotent and has one canonical layout, so the only thing it can offer is an exception. There are three, and they are ordinary comments to every other pass — the compiler does not know they exist.# fmt: on. Both marker lines are inside the region and are reproduced.offabove it, it is an ordinary comment and is never warned about.The spelling is exact and the compiler will not tell you when you miss it. Exactly one
#; then, after any amount of whitespace, the literal textfmt: off,fmt: onorfmt: skip, and nothing else on the line. So#fmt: offworks and# fmt:offdoes not;## fmt: offis a doc run and never a directive;# FMT: OFFis a comment;# fmt: off for nowis a comment. There is deliberately no diagnostic for a near miss, which is the cost: a mistyped marker is silently inert.# fmt: off
)
onit finds, so the inneroffopens nothing.An
offwith noonis the one case that is reported. The region runs to the end of the file and the formatter warns rather than refusing, because ignoring the marker would reformat code that asked to be left alone — the one thing the marker exists to prevent.A suppressed region is reproduced as written, but the whole of it is shifted by one common offset so that its first line lands on the column the rest of the file uses. Byte-for-byte reproduction was rejected for a reason worth knowing: a region left at a column the file no longer uses would change the block structure, and a formatter that can change what a program means is worse than one that reformats something you wanted held.