slashbinbash.de / Sikkel

Sikkel (Extension: .sik) is a Lisp language.

Download Sikkel Interpreter (sikkel-20230906.zip)

To run the interpreter, you will need a Java 17 Runtime Environment.

java -jar sikkel.jar script.sik

Language

General

Comments are prefaced by a semicolon ';' and can appear anywhere in the code:

; Comment

Data-Types

The built-in data-types are:

NameExample
Booleantrue or false
Integer42
String"Hello World!"
Symbol+, even?, bob
List(24 true ("Hello World!" bob) cat (42))

Arithmetic

(+ 2 3)
    => 5
(+ 10 20 30 40 50)
    => 150

(- 8)
    => -8
(- 10 3)
    => 7

(* 3 9)
    => 27
(* 1 2 3 4)
    => 24

(/ 10 2)
    => 5

(mod 6 2)
    => 0

Comparisons

(= 3 3)
    => true
(< 9 3)
    => false
(> 9 3)
    => true
(<= 8 8)
    => true
(>= 9 4)
    => true

(boolean? 5)
    => false
(integer? "test")
    => false
(list? 18)
    => false
(string? 18)
    => false
(symbol? "test")
    => false

You can also test if values are in a certain range:

(define x 50)
(<= 0 x 100)
    => true

Logic

Boolean operations can only be used with boolean values.

(not true)
    => false
(not false)
    => true

(and true true)
    => true
(and true true false true)
    => false

(or false false)
    => false
(or false false true false)
    => true

(xor true false)
    => true
(xor true true)
    => false

and, or, and xor are implemented as short-circuit operators.

(and (integer? true) foobar)
    => false

(or (integer? 18) foobar)
    => true

The second expression is never evaluated because the first expression evaluates to a boolean value that determines the result for the entire expression.

Conversions

(string->symbol expr)  
(symbol->string expr)  
(integer->string expr)
(string->chars expr)
(chars->string expr)
(string->list expr)
(list->string expr)

Variables

Variables are defined using the define function:

(define x 24)
(define y (* 10 2))
(define z (+ x y))

(define x 0)
    => error: 'x' already defined

Trying to redefine a variable in the same namespace will result in an error. If the variable is already defined, you can change its value by using set.

(set! x 24)

Variables are defined in either the global scope of the module, the function scope, or scoped forms (see scope, let, match).

Evaluation

List evaluation can be manipulated with quote and eval.

(quote ())
    => ()
(' (1 2 3 4 5 6))
    => (1 2 3 4 5 6)
(' (+ 3 4))
    => (+ 3 4)
(eval (' (+ 3 4)))
    => 7

Function

Functions are defined in the Common Lisp style:

(defun add1 (x) (+ x 1))

(add1 18)
    => 19

(defun test (x y)
    (print "Test")
    (* x y))

The function definition can have multiple expressions. The return value of a function is the result of the last expression.

You can define variables inside the function body. The variables are only visible in the body.

(defun test (x y)
    (define z 10)
    (* x y z))

(test 2 3)
    => 60

z
    => error: undefined symbol 'z'

Lambda

You can create an unnamed function by using lambda:

(lambda (x) (+ x 1))

((lambda (x y) (* x y)) 10 5)
    => 50

Lambda functions are the most restrictive function type. Any variables that you want to use, you have to pass as a parameter.

You can give the lambda function a name:

(define add1 (lambda (x) (+ x 1)))
(add1 3)
    => 4

Closure

A closure is a lambda but with state. When you use closure, all variables that are defined in its surrounding scope are captured:

(defun create-closure (a)
    (closure (x) (* a x)))  ; captures a

(define closure1 (create-closure 5))

(closure1 10)
    => 50

Note that captured objects cannot be garbage collected until the reference to the function object is removed.

Function Application Forms

Partial function application using partial:

(defun sum (x y) (+ x y))

(define sum3 (partial sum 3))

(sum3 7)
    => 10

Function composition using compose:

(defun inc (n) (+ n 1))
(defun dec (n) (- n 1))
(defun dup (n) (* n 2))

(dec (dup (inc 10)))
    => 21

((compose inc dup dec) 10)
    => 21

Applying functions in left-to-right order using pipe:

(pipe 10 inc dup dec)
    => 21

Calling function with argument list using apply:

(apply + (list 1 2 3 4))
    => 10

Do

Some forms only allow one expression. You can use do to bypass this restriction.

(do
    (print "First")
    (print "Second")
    (+ 3 18))

The return value of do is the result of the last expression.

Conditions

If

(if (< 3 8) 10 16)

(if (= 3 8)
    (print "A")
    (print "B"))

The if form requires you to specify a true and false expression.

Cond

The cond form is a list of condition and expression pairs.

(cond
    ((< 9 3)  (print "A"))
    ((= 5 18) (+ 3 3))
    (true     (* 10 2)))

A condition that evaluates to true is required.

Case

The case form is very similar to switch-statements in other languages. The values in each case are compared to the result of the value expression. If any of them matches, the case expression is evaluated.

(case (+ 1 2)
    (1       (print "A"))
    ((2 3 4) (print "B"))
    (_       (print "C")))

The separate cases are compared by equality. You can test the equality of either one or more values. The character '_' denotes the else case and is required.

Match

The match form is a pattern matching function that compares values for equality and also assign variables if needed.

(require list)

(defun foo (x y)
    (if (> x 5)
        (list (' ok) x y)
        (list (' err) "error")))

(match (foo 8 2)
    ((ok 8 5)   (print "eight five"))
    ((ok 8 _)   (print "eight any"))
    ((ok %x %y) (print (+ x y)))
    ((err %m)   (print m))
    (_          (print "any")))

If we call (foo 8 5), the function returns (ok 8 5). This matches with the first statement (ok 8 5) perfectly, and "eight five" is printed.

If we call (foo 8 2), the function returns (ok 8 2). This does not match the first statement. The second statement contains the wildcard character '_', which matches anything. "eight any" is printed.

If we call (foo 10 20), the function returns (ok 10 20). This does not match neither the first nor the second statement. The third one however matches. The character '%' tells the interpreter to do an assignment of any value that is found, to the given variable name. This means that 10 is assigned to x and 20 is assigned to y. The number 30 is printed.

If we call (foo 2 10), the function returns (err "error"). Similar to the previous match, the error message is assigned to the variable m, and then printed.

The last case will match any other case that did not previously match.

Notes

Scope

There are three namespaces where variable and function definitions live:

  1. built-in namespace
  2. global namespace
  3. function namespace

The built-in namespace has definitions for all the standard language functions that can be used in any module. This includes functions for arithmetic, logic, conditions, loops, and others.

The global namespace contains the definitions of a module. These are the variables and functions that you define, the names from the modules that you import, etc.

The function namespace is a temporary namespace that is created when a function is called. It contains the values that you pass to the function, the variables that you define inside the function, etc.

In addition, there are functions that create temporary namespaces when variables are defined that would otherwise pollute the current namespace. For instance, match can create a temporary namespace if you decide to assign values to variables.

Scope

scope creates a temporary scope. The variables that are defined in the scope are invalidated once the the program leaves the scope. This allows you to define variables without polluting your namespace.

(define x 1)

(scope
    (print x)
    (define x 2)
    (print x))

(print x)

Output:

1
2
1

The return value of scope is the result of the last expression.

Let

let is similar to scope. You can create a temporary scope, define variables, and use them in an expression, all in one concise form.

(let ((a 5)
      (b 10)
      (c (+ a b)))
    (* (+ b a) (+ a c)))

This is equivalent to writing:

(scope
    (define a 5)
    (define b 10)
    (define c (+ a b))
    (* (+ b a) (+ a c)))

Modules

The language comes with different modules. To import the names of a module, use the require function.

(require list)

If you use it on the top level, it adds all definitions of list to the global namespace of the current module.

You can restrict which names are imported into the namespace by using require-from:

(require-from list (list append))

(append (list 1 2 3) (list 4 5 6))
    => (1 2 3 4 5 6)

(length (list 1 2 3 4))
    => error: undefined symbol 'length'

This is particularly useful if you only want to import a few names of a module that would otherwise import dozens of names.

To avoid name clashes between modules, you can import the names of a module with a given prefix using require-as:

(require-as list lst/)
(require-as system sys-)

(lst/add (list 1 2 3 4) 5)
(sys-read-file "foo.txt")

The prefix is arbitrary but it must be a valid symbol.

You can use require inside of a scope or a function to import the names without polluting the global namespace:

(scope
    (require list)
    (list 1 2 3 4))

    => (1 2 3 4)

(defun test ()
    (require list)
    (list 1 2 3 4))

(test)
    => (1 2 3 4)

(length (test))
    => error: undefined symbol 'length'

(list 1 2 3 4)
    => error: undefined symbol 'list'

Built-in Modules

There are five built-in modules:

The functions of each system are outlined in the reference document that is included in the downloaded archive.

Sikkel Module Files

Each Sikkel file is in itself a module and can be imported like a module. All names of a module are private by default. You have to specify which names should be exported when the module is loaded with provide:

Foo.sik

(provide A B)

(define A 42)
(define B 10)
(define C 7)

To import the module defined by the Foo.sik file, use the require function:

Bar.sik

(require Foo)

A
    => 42
B
    => 10
C
    => error: undefined symbol 'C'

If the module is located in a sub-directory, the path becomes part of the module name. For instance, the module address for the file "std/foo/bar.sik" is std.foo.bar:

(require std.foo.bar)

Java Integration

There is limited support for Java integration:

The interpreter has to know where to find the classes before it can load them. You can add include directories and JAR files by using the -I option:

java -jar sikkel.jar script.sik -I bin
java -jar sikkel.jar script.sik -I library.jar

Create a Java class

package de.slashbinbash.sikkel;

public class TestClass {
    public static int testA(int a, int b) {
        return a + b;
    }

    public static String testB() {
        return "test";
    }
}

Import all methods with class namespace import

(require system)

(import de.slashbinbash.sikkel.TestClass)

(TestClass.testA 10 15)
    => 25
(TestClass.testB)
    => "test"

Import all methods with import-static

(require system)

(import-static de.slashbinbash.sikkel.TestClass.*)

(testA 42 8)
    => 50
(testB)
    => "test"

Import single method with import-static

(require system)

(import-static de.slashbinbash.sikkel.TestClass.testA)

(testA 8 12)
    => 20
(testB)
    => error: undefined