Skip to content

Latest commit

 

History

History
910 lines (695 loc) · 19 KB

crash-course.md

File metadata and controls

910 lines (695 loc) · 19 KB

Crash Course

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.

Examples

# 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

What Sink Isn't

In many ways, sink is defined by the features it doesn't have:

  1. No classes, inheritence, mixins, interfaces, prototypes, etc
  2. No tables, objects, hashes, dictionaries, etc
  3. No anonymous functions, closures, lambdas, function pointers, etc
  4. No threads, coroutines, generators, iterators, etc
  5. No booleans
  6. No unicode
  7. No exceptions, protected calls, long jumps, etc
  8. No massive standard library
  9. No module system (just simple includes)
  10. No regular expressions
  11. No operator overloading
  12. No unique and clever (and therefore annoying) design decisions
  13. 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.

Whitespace

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

Keywords and Symbols

break       end          nil
continue    enum         return
declare     for          using
def         goto         var
do          if           while
else        include
elseif      namespace
+    +=    <     <=     (    )
-    -=    >     >=     [    ]
%    %=    !     !=     {    }
*    *=    =     ==     ,    :
/    /=    ||    ||=    |    .
^    ^=    &&    &&=    &    ...
~    ~=

Types

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.

Nil

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

Number

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

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.

String Slicing

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

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.

List Slicing

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}

List Identity and User Data

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 and Scope

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

Enumerators

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

Destructuring Assignment

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-Elseif

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.

Do-While

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

For

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.

Range

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.

Goto

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

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

Default Arguments

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

Variable Arguments

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

Piping

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

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'

Using

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

Include and Embed

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.