Sink is a minimal programming language specifically designed to be embedded in larger programs, similar in spirit to Lua (but more simple).
It has TypeScript and C99 implementations, which compile and execute the same source code with exactly the same results. Sink can also be compiled to bytecode.
The API allows the host environment to define native commands, and includes support for a REPL, which is great for embedding debug consoles.
# hashes signify end-of-line comments, like shell languages
/* C-style block comments are supported */
# outputs 'hello, world' to stdout, automatically inserting newline
say 'hello, world'
say 1 + 2 # 3
say 1 ~ 2 # 12 (tilde is string concat)
say 5^2 # 25 (caret is power)
say 25^0.5 # 5
# commands are defined via `def`
def add a, b
# string substitution on double-quoted strings using dollar sign
say "adding $a + $b is ${a + b}"
return a + b
end
say add 1, 2 # 3
say add (add 1, 2), add 4, 5 # 12
# commands can be declared ahead of time
declare factorial
say factorial 10 # 3628800
def factorial a
if a <= 1
return 1
end
return a * factorial a - 1
end
In many ways, sink is defined by the features it doesn't have:
- No classes, inheritence, mixins, interfaces, prototypes, etc
- No tables, objects, hashes, dictionaries, etc
- No anonymous functions, closures, lambdas, function pointers, etc
- No threads, coroutines, generators, iterators, etc
- No booleans
- No unicode
- No exceptions, protected calls, long jumps, etc
- No massive standard library
- No module system (just simple includes)
- No regular expressions
- No operator overloading
- No unique and clever (and therefore annoying) design decisions
- No intention to keep updating the language and make it bigger and bigger
These are intentionally left out in order to drastically simplify the language. You can get by without them, believe it or not. I promise it isn't too painful.
Sink is not a fully-featured scripting language, like Python or Ruby. It is specifically designed for making small scripts that can be easily embedded in a larger host environment.
Sink syntax is a mixture between a shell and a normal language. Newlines matter, and they help
define the end of statements. At any point a backslash \
can be used to ignore a newline, and a
semicolon ;
can be used to separate statements on a single line.
1 # single statement, 1
+ 2 # single statement, + 2
1 \ # awaiting end of statement...
+ 2 # statement processed is 1 + 2
1; + 2 # two statements
Note that +
and -
have special sensitivity to whitespace in order to determine whether you mean
a unary operator or binary operator:
x-1 # subtract
x - 1 # subtract
x- 1 # subtract
x -1 # call `x` with a single argument, -1
break end nil
continue enum return
declare for using
def goto var
do if while
else include
elseif namespace
+ += < <= ( )
- -= > >= [ ]
% %= ! != { }
* *= = == , :
/ /= || ||= | .
^ ^= && &&= & ...
~ ~=
Sink is dynamically typed (which means any variable can contain any type) and has exactly four types:
Type | Description | Example(s) |
---|---|---|
Nil | Nothingness, false | nil |
Number | 64-bit floating point | 5 , 0xFF , 0b1011 , 6.28e+10 |
String | Binary-safe array of bytes | 'hello' , "world" |
List | Variable length list of values | {} , {1, 2, 3} , {nil, 1, {'hi'}} |
All values are considered true except nil
. That means 0
is true, ''
is true, {}
is true,
etc.
The value nil
is a special value that signifies nothingness. It is the only value considered
false.
Missing arguments to commands default to nil
, and accessing a list outside of its range returns
nil
. Many commands in the standard library return nil
to indicate normal failure (for example,
str.find
will return nil
if the substring isn't found).
Since only nil
is false, you can do things like this:
x = x || 5 # set x to 5 only if x is nil
x ||= 5 # or, more compactly
Checking if a value is nil
is done via x == nil
(or simply !x
).
Numbers are 64-bit floating point values. They can be expressed in decimal, binary, octal, and hexadecimal, including fractions:
x = 1
x = 123.456
x = 123.456e19
x = 123.456e-19
x = 0xAB
x = 0xAB.CD
x = 0xAB.CDp19
x = 0xAB.CDp-19
x = 0b1011
x = 0b1011.1101
x = 0b1011.1101p19
x = 0b1011.1101p-19
x = 0c777
x = 0c777.123
x = 0c777.123p19
x = 0c777.123p-19
say num.hex 255 # 0xFF
say num.bin 15 # 0b1111
say num.oct 511 # 0c777
Numbers can also be not-a-number, or infinity:
x = num.nan
x = num.inf
x = -num.inf
if num.isnan x
say 'x is nan'
elseif num.isfinite x
say 'x is finite'
end
Numbers can also be treated as 32-bit signed or unsigned integers in the standard library, depending on the library call. This is no problem because a 64-bit floating point number can store a 52-bit integer losslessly.
Testing for a number is done via the isnum
command:
if isnum x
say 'x is a number'
else
say 'x isn''t a number'
end
Strings are binary-safe arrays of bytes, that can be any length, and include any value from 0 to 255. Strings have no concept of unicode (though there are basic helper commands in the standard library for dealing specifically with UTF-8 strings).
Strings can be specified with single quotes '
or double quotes "
. Single quoted strings do not
perform any substitution and only have one escape sequence ''
(two single quotes) to indicate a
single quote character (i.e., 'it''s like this'
).
Double quoted strings perform substitution via $
, and have the escape sequences:
Escape | Description |
---|---|
"\xFF" |
Any byte specified by two hex numbers |
"\0" |
Byte 0 |
"\b" |
Bell (byte 8) |
"\t" |
Tab (byte 9) |
"\n" |
Newline (byte 10) |
"\v" |
Vertical tab (byte 11) |
"\f" |
Form feed (byte 12) |
"\r" |
Carriage return (byte 13) |
"\e" |
Escape (byte 27) |
"\\" |
Backslash |
"\'" |
Single quote |
"\"" |
Double quote |
"\$" |
Dollar sign |
Subtitution is either a single identifier, or an expression:
say "a is $a" # simple substitution
say "foo.bar is ${foo.bar}" # expression subtitution
say "a + b is ${a + b}" # expression subtitution
say "hi: ${str.lower "HI"}" # nested strings are valid
The unary &
operator returns the string length:
var x = 'hello'
say &x # 5
Concatenation is via ~
(not +
):
say "a" ~ 'b' # ab
say 1 ~ 2 # 12
Converting a string to a number is via unary +
, which returns nil
if the conversion fails:
var x = '5'
say x + 5 # runtime error, cannot add string to number
say +x + 5 # 10
say +'foo' # nil (conversion fails)
Strings are detected via the isstr
command.
Strings support slicing, in the format of s[start:length]
:
var x = 'hello world'
say x[3:5] # lo wo
Slicing can also be used for assignment:
x[2:2] = 'LL'
say x # heLLo world
Lists are the only compound data structure in sink. They are created with curly braces
{ <contents> }
. Elements are accessed using ls[0]
, ls[1]
, etc. Negative indicies will wrap
around the end. Indicies outside the range will return nil
.
Most operations on numbers also work on lists, by performing the operation across all elements
(defaulting values to 0
if outside the range):
say {1, 2, 3} * 2 # {2, 4, 6}
say {1, 2, 3} + {4, 5, 6} # {5, 7, 9}
say {1} + {2, 5} # {3, 5}
say {1} * {2, 5} # {2, 0}
say num.abs {-1, -2} # {1, 2}
The unary &
operator returns the list size:
var x = {1, 2, 3, 4}
say &x # 4
Lists are modified using the commands:
Command | Description |
---|---|
list.push ls, 5 |
Push 5 at end of list |
list.unshift ls, 5 |
Unshift 5 at beginning of list |
list.pop ls |
Pop the last element off the end of the list |
list.shift ls |
Shift the first element off the start of the list |
list.append ls, {1, 2} |
Append the second list on the end of the first list |
list.prepend ls, {1, 2} |
Prepend the second list at the start of the first list |
Concatenation also works, but this creates a new list:
var x = {1}, y = {2}
say x ~ y # {1, 2}
say x # {1}
say y # {2}
Lists are detected via the islist
command.
Lists support slicing, which creates a copy, in the format of ls[start:length]
:
var x = {1, 2, 3, 4}
say x[1:2] # {2, 3}
Slicing can also be used for assignment:
x[1:2] = {5, 6, 7}
say x # {1, 5, 6, 7, 4}
An empty slice is the same as a shallow copy:
var x = {1, 2, 3}
var y = list.push x[:], 4
say x # {1, 2, 3}
say y # {1, 2, 3, 4}
Lists are the only values that have identity. Nil, strings, and numbers do not have an identity.
var x, y
x = 'hello'
y = 'hello'
x == y # true (1)
x = {}
y = {}
x == y # false (nil)
List identity means that lists passed to commands can be mutated.
def test x
x[0] = 5
end
var y = {3}
test y
say y # {5}
List identity is important for binding host objects to sink scripts. The host environment can create lists with hidden data attached to it, that sink scripts cannot access directly.
For example, a host environment might have a way to create shape objects:
var x = circle {0, 0}, 50
say x # {'circle'}
if iscircle x
say radius x # 50
end
Notice that there is no way for sink scripts to access the object properties without going through the host commands. The host has the ability to read the hidden data attached to the list, verify it is a circle object, and return the correct value.
Typically, the sink script can do whatever it wants with the contents of the list, including
emptying the list, pushing different values, etc. That's considered free rein for the sink script.
Having 'circle'
inside the list has no real meaning; it is just convenience.
Modifying the list does not change the object type and data hidden behind it. Host environments
should provide an is<foo>
command for testing the hidden type, like iscircle
in the example
above.
Variables are declared with the var
keyword, and are lexically scoped:
var x = 1, y = 2
def test
var y
x = 10
y = 20
end
test
say x, y # 10 2
Commands have their own scope, with a set of variables created at time of execution:
def test1 base
def test2
test1 base + 10
end
if base < 5
test2
end
say 'base:', base
end
test1 3
# output:
# base: 13
# base: 3
Any statement with a block creates a new scope. The do-end statement doesn't do anything except create a scope:
var x = 1
if x
# new scope
var x = 2
end
say x # 1
do
# new scope
say x # 1
var x = 3
say x # 3
end
say x # 1
Constant numbers can be defined using enum
:
enum x, y, z
say x, y, z # 0 1 2
enum zero, one, three = 3, four, five
say zero, one, three, four, five # 0 1 3 4 5
enum some.constant.number = 100
say some.constant.number # 100
If a value isn't initialized, it is the previous value plus one (starting at 0).
Lists of variables can be used for assignment or variable creation. This allows for parallel assignment and can help with commands returning multiple values:
var {x, y} = {1, 2}
say x, y # 1 2
{x, y} = {y, x}
say x, y # 2 1
def test
return {1, {3, 4}}
end
var {a, {b, c, d}, e} = test
say a, b, c, d, e # 1 3 4 nil nil
Destructuring assignment also allows for variable length assignment using ...
:
var {first, second, ...rest} = {1, 2, 3, 4, 5}
say first # 1
say second # 2
say rest # {3, 4, 5}
if <condition>
do stuff
elseif <condition>
more stuff
elseif <condition>
yet more stuff
else
lastly this
end
Note that only nil
is considered false -- all other values are considered true.
The do-while loop can express three kinds of looping:
# normal while loop:
do while <condition>
stuff
continue # jump to do
break # jump out of loop
end
# normal do loop:
do
stuff
continue # jump to condition
break # jump out of loop
while <condition> end
# combined do-while loop:
do
stuff
continue # jump to condition
break # jump out of loop
while <condition>
stuff
continue # jump to do
break # jump out of loop
end
The combined do-while loop might look strange at first, but it's a natural extension and useful:
do
var x = ask 'What is the password?'
while x != 'hunter2'
say 'Wrong password'
end
The for loop simply iterates over a list:
for var v: {'a', 'b', 'c'}
say v
end
# output:
# a
# b
# c
Another variable can be used for the index:
for var v, index: {'a', 'b', 'c'}
say v, index
end
# output:
# a 0
# b 1
# c 2
The var
is optional -- if left out, the variables must be declared before the loop.
An empty for loop is an infinite loop:
for
say 1
end
# output:
# 1
# 1
# ...forever
Variables are optional and can be omitted if not needed:
for: range 10
# do something 10 times
end
The continue
and break
statements operate as expected inside the for loops.
Use range
to loop over a range of numbers:
for var i: range 3
say i
end
# output:
# 0
# 1
# 2
for var i: range 3, 5
say i
end
# output:
# 3
# 4
for var i: range 0, 9, 3
say i
end
# output:
# 0
# 3
# 6
Special optimizations exist in the compiler so that using range
in a for loop will skip creating
the actual list.
Labels are unique per command, and declared via labelname:
. The goto labelname
statement will
cause execution to jump to the label.
goto skip
say 'won''t see this'
skip:
Commands (aka functions) are created using def
:
def add a, b
say "adding $a + $b: ${a + b}"
return a + b
end
say 'result:', add 1, 2
# output:
# adding 1 + 2: 3
# result: 3
Commands can have default values for arguments, which are expressions that get evaluated if the
passed in argument isn't specified (or nil
):
def test a = 1, b = 2
say a, b
end
test # 1 2
test nil, 5 # 1 5
test 7 # 7 2
var x = 10
def test2 y = x
say y
end
test2 # 10
test2 13 # 13
x = 20
test2 # 20
Commands can accept variable arguments using ...
in the definition:
def printargs prefix, ...rest
for var a: rest
say prefix, a
end
end
printargs 'test:', 5, 6, 7
# output:
# test: 5
# test: 6
# test: 7
Command results can be piped to each other, in order to simplify syntax:
def add a, b
say "adding $a + $b: ${a + b}"
return a + b
end
def mul a, b
say "multiplying $a * $b: ${a * b}"
return a * b
end
var res = add 1, 2 | mul 4
say res
# output:
# adding 1 + 2: 3
# multiplying 3 * 4: 12
# 12
The line:
var res = add 1, 2 | mul 4
is transformed to:
var res = mul (add 1, 2), 4
Piping one command's results into another command always inserts the result as the first parameter. This means it's normally useful for commands to accept the object of interest as the first parameter and return the object so it can be used for chaining.
var ls = {1, 2} | list.push 3 | list.unshift 0 | list.rev
# same as:
# var ls = list.rev (list.unshift (list.push ({1, 2}), 3), 0)
say ls # {3, 2, 1, 0}
Namespaces only exist at compile-time and can be created freely:
namespace foo
def test
say 'inside test'
end
end
foo.test # inside test
Namespaces can be created and added to as needed without the namespace
keyword:
# define command 'test' inside namespace 'foo'
def foo.test
say 'inside test'
end
# declare variable 'c' inside namespace 'a.b'
var a.b.c = 10
# include everything in './file' in the namespace 'foo'
include foo './file'
The using
keyword can be used to expand the search for identifiers across multiple namespaces:
using num
say round 1.3 # 1
However, the local namespace has priority over anything inside using
:
using num
def round a
return a + 10
end
say round 1.3 # 11.3
To include another file, simply use the include
statement:
include './some/file'
This will search the include path (defined by the host) and parse the file as if it had been
pasted directly at that spot. If the file doesn't exist, it will try adding a .sink
extension.
If the file is a directory, it will look for index.sink
inside that directory.
Hosts can also define native libraries, that are typically just one word:
include 'shapes'
This would typically paste a bunch of declarations provided by the host, so that native commands would compile correctly -- but ultimately, this behavior is defined by the host.
To include a file in it's own namespace, you can do:
namespace foo
include './file'
end
Or, more compactly:
include foo './file'
If you include a file more than once, any definitions will fail the second time, because it is seen as trying to define something more than once. Instead, it might be useful to use the syntax:
include + './file'
This will create a unique namespace for the contents of './file'
, accessed directly. It is
effectively short for:
namespace unique_1234
include './file'
end
using unique_1234
You can also include multiple files with a single include
statement:
include
'./first',
'./second',
third './third',
+ './fourth'
The embed
expression can be used to include the contents of a file as a string literal. This can
be useful for embedding data directly in the script at compile-time.
var img = embed './image.png'
This is equivalent to pasting the binary data as a string in the script.