terp icon indicating copy to clipboard operation
terp copied to clipboard

A functional programming language with lisp syntax and ML semantics that runs on the BEAM

  • Terp

    ~terp~ is a toy language that falls somewhere between an ML and a lisp.

    Currently implemented:

    • Using terp
      • [[#installation-prerequisites][Installation]]
      • [[#repl][REPL]]
      • [[#file-evaluation][File evaluation]]
    • Core language features
      • Integers
      • Strings
      • Atoms
      • Booleans (~#t~, ~#f~)
      • [[#comments][Comments]]
      • [[#conditionals][Conditionals]]
        • ~if then else~
        • ~cond~
      • [[#function-definition][Function definition]] (~lambda~, ~defn~)
      • [[#recursive-functions][Recursive functions]] (~letrec~, ~defrec~)
      • [[#variable-binding][Variable binding]] (~let~)
        • locally scoped variable binding (~let-values~)
    • [[#module-system][Module system]]
      • Module imports
      • Function exports
    • [[#type-system][Type system]]
      • HM type inference
      • [[#higher-kinded-types][Higher-kinded types]]
      • Type definition/annotation
      • [[#pattern-matching][Pattern matching]] (currently limited to type constructors)
    • [[#elixirerlang-interop][Elixir/Erlang interop]]
    • Tooling
      • [[#test-utilities][Test utilities]]
    • [[#error-messages][Error messages]]

    Additional examples can be found in the [[https://github.com/tpoulsen/terp/tree/master/examples][examples]] directory.

  • Usage ** Installation/Prerequisites To try terp, you must have Elixir and Erlang installed. If you don't already have them installed, [[https://github.com/asdf-vm/asdf][the asdf version manager]] is a good way to do so (or see the [[https://elixir-lang.org/install.html][installation guide]] at elixir-lang.org).
    If you're using =asdf=, there is a =.tool-versions= file in the root of the project; running ~asdf install~ will install the proper versions of Elixir and Erlang.

    With those installed, run ~mix deps.get~ in the root directory to install additional dependencies. ** REPL The quickest way to try terp is via the Read-Eval-Print-Loop (REPL). The repl is started by running* ~iex -S mix terp.repl~ at the command line from the project root.

    In the REPL you can evaluate expressions, import modules, and type check expressions.

    [[file:media/repl_demo.gif]]

    *The REPL can also be started with just ~mix terp.repl~. If stared like this, there is not any scrollback or history. By using ~iex -S mix terp.repl~, the IEx shell provides those features while still running the terp REPL. ** File evaluation There's a mix task (~mix terp.run $FILENAME~) to evaluate a file:

    For example, with a file named ~test.tp~ (~terp~ files must end in ~.tp~) that contains the following code: #+BEGIN_SRC racket (defn identity (x) x)

    (defn square (x) (* x x))

    (square (identity 2)) #+END_SRC

    Running ~mix terp.run test.tp~ evaluates the file: #+BEGIN_SRC sh $ mix terp.run test.tp 4 #+END_SRC

  • Language Features ** Comments Comments are single-line and begin with ~;;~: #+BEGIN_SRC racket ;; A comment (+ 5 1) ;; 6 #+END_SRC

** Variable binding Variables are bound using ~let~: #+BEGIN_SRC racket (let x 5)

  (let identity
      (lambda (x) x))

  (identity x)
  ;; 5
#+END_SRC

Local variables can be bound with ~let-values~:
#+BEGIN_SRC racket
  (let-values
      ([x 5]
       [y 3])
    (+ x y))
  ;; 8

  (defn plusOne (x)
    (let-values
        ([y 1])
      (+ x y)))
  (plusOne 5)
  ;; 6
#+END_SRC

** Conditionals ~if~ expressions must include a value for both the true and false case (an ~if~ and an ~else~). #+BEGIN_SRC racket (if #t 5 10) ;; 5

 (let x 5)
 (if (equal? x 5)
     (* x x)
     0)
 ;; 25

#+END_SRC

~cond~ can be used to test multiple possible conditions without chaining if/elses: ~cond~ takes conditions and their outcomes should their case be true; the last condition should be a default. #+BEGIN_SRC racket (let sound (lambda (animal) (cond [(equal? animal "cow") "moo"] [(equal? animal "cat") "meow"] [(equal? animal "dog") "bark"] [#t "zzz"] )))

 (sound "dog")
 ;; "bark"

#+END_SRC ** Function definition Functions are defined using ~lambda~; they can be bound to a name with ~let~.

The arguments must be wrapped in parens. The body of the function can be bare if it does not have to be evaluated (e.g. returns a single value). Otherwise, the body must be parenthesized as well. #+BEGIN_SRC racket ;; An anonymous identity function. ;; It returns the value it receives. (lambda (x) x)

 ;; Defining a named function:
 (let double
     (lambda (x)
       (* 2 x)))
 (double 5)
 ;; 10

 (let square
     (lambda (x)
       (* x x)))
 (square 5)
 ;; 25

#+END_SRC

Multi-argument functions: #+BEGIN_SRC racket (((lambda (x) (lambda (y) (+ x y))) 5 ) 3) ;; 8

 ((lambda (x y)
    (+ x y)) 5 3)
 ;; 8

#+END_SRC

Functions are automatically [[https://en.wikipedia.org/wiki/Currying][curried]] when defined. This allows for easy partial application of multi-argument functions: #+BEGIN_SRC racket ;; add is a function that takes two arguments. ;; Currying turns it into a series of functions ;; that each takes a single argument. (let add (lambda (x y) (+ x y)))

 ;; We can define a new function, add_five, that partially
 ;; applies add to the value 5:
 (let add_five
     (add 5))

 ;; evaluating add_five with 3 binds the last argument in
 ;; add, and the function is fully evaluated:
 (add_five 3)
 ;; 8

#+END_SRC

Functions can also be defined using ~defn~; this is syntactic sugar for ~let/lambda~ definition: #+BEGIN_SRC racket (defn add (x y) (+ x y)) #+END_SRC ** Recursive functions Recursive functions are defined with ~letrec~. The base case(s) and recursive case(s) must be provided or the function will not terminate. #+BEGIN_SRC racket (letrec factorial (lambda (n) (if (equal? n 0) 1 (* n (factorial (- n 1))))))

  (factorial 5)
  ;; 120
#+END_SRC

Recursive functions can also be defined using ~defrec~; this is syntactic sugar for ~letrec/lambda~:
#+BEGIN_SRC racket
  (defrec factorial (n)
      (if (equal? n 0)
          1
          (* n (factorial (- n 1)))))

  (factorial 5)
  ;; 120
#+END_SRC

** Module system Modules can be imported in to other modules to make their functions/defined expressions available. Modules must specify the functions that they export (via ~provide~) or they cannot be used in other modules.

To import a module use ~(require ...)~, where ~...~ is a sequence of module names, at the top of the file. Module names are derived from their file-path relative to the project root directory (e.g. a file at ".examples/factorial.tp" has the module name ~examples/factorial~).

#+BEGIN_SRC racket (require examples/factorial examples/identity)

 (factorial (identity 10))

#+END_SRC

With [[./examples/factorial.tp][examples/factorial]] and [[./examples/identity.tp][examples/identity]] defined as in the examples directory.

To use functions from an imported module, the module that is imported must explicitly export functions it wants to make available externally. The syntax is ~(provide ...)~ where ~...~ is a sequence of function names. #+BEGIN_SRC racket ;; Module only exports factorial; identity is private.

 (provide factorial)

 (letrec factorial
   (lambda (n)
     (if (equal? n 0)
         1
         (* n (factorial (- n 1))))))

 (let identity
     (lambda (x) x))

#+END_SRC ** Type system Terp implements Hindley-Milner type inference.

Expressions are type checked prior to evaluation. If an expression fails the type check, it won't be evaluated. To see the inferred type for an expression in the REPL, prefix it with ~:t~ or ~:type~.

A type environment is maintained during evaluation and REPL sessions; this environment remembers the types for functions and variables.

/Binding a simple variable:/

[[file:media/repl_simple_env.gif]]

/Binding and using a recursive, higher-order function:/ [[file:media/repl_type_env.png]] *** Higher kinded types Higher kinded types (types parameterized by another type) are defined using ~data~: #+BEGIN_SRC racket (data (Maybe a) [Just a] [Nothing]) #+END_SRC This defines a type, ~Maybe~, that is parameterized by another type (represented by the type variable ~a~). Concrete examples could be ~Maybe Int~ or ~Maybe String~. Using ~Maybe Int~ as an example, values of the ~Maybe Int~ type can be either ~Just Int~ or ~Nothing~. This can be used to work with values that can potentially be non-existent.

Defining a type with ~data~ also defines constructor functions for the value constructors of the type (~Just~ and ~Nothing~ in this example).

** Pattern matching ~match~ allows you to pattern match against the value constructors for a type. In this example, ~Maybe~ is a type with the value constructors ~Just~ and ~Nothing~. With ~match~, you can write a function that takes a value of type ~Maybe~ and nicely handles values that are either ~Just~ or ~Nothing~: #+BEGIN_SRC racket (data (Maybe a) [Just a] [Nothing])

 (type maybePlusFive (-> [Maybe Int] [Maybe Int]))
 (defn maybePlusFive (x)
   (match (x)
     [(Just y) (Just (+ 5 y))]
     [(Nothing) (Nothing)]))

 (maybePlusFive (Just 5))
 ;; Just 10
 (maybePlusFive (Nothing))
 ;; Nothing

#+END_SRC ** Elixir/Erlang interop Elixir and Erlang functions can be used by prefixing them with a ~:~, e.g: #+BEGIN_SRC racket ;; Using Elixir functions directly: (:Enum.map '(1 2 3 4 5) (lambda (x) (* x x))) ;; '(1 4 9 16 25)

 ;; Calling Elixir's uppercase function:
 (:String.upcase "asdf")
 ;; "ASDF"

 ;; Calling Erlang's uppercase function:
 (:string.uppercase "asdf")
 ;; "ASDF"

 ;; Writing and using a function that uses an Elixir function:
 (defn square (xs)
   (:Enum.map xs (lambda (x) (* x x))))
 (square '(1 2 3 4 5))
 ;; '(1 4 9 16 25)

#+END_SRC Caveats

There are currently a few important things to keep in mind:

  1. This is not yet thoroughly tested. There's a large surface area to test to make sure everything works as expected.
  2. Type inference does not work for Elixir/Erlang functions. When writing functions that use Elixir/Erlang functions, type annotations should be provided for used functions. See [[./examples/elixir_interop.tp][./examples/elixir_interop.tp]] for examples/details.
  3. The full module and function names must be provided.
  4. Elixir and Erlang functions aren't curried. ** Test utilities There's a mix task (~mix terp.test [$FILENAME | $DIRECTORY]~) to find and run tests in the given file(s)/directories.

Test files must end in ~_test.tp~ or they will not be run.

If a directory is provided to ~mix terp.test~, its subdirectories are recursively checked for files to test.

~prelude/test.tp~ exports the functions ~test~, ~assert~, and ~refute~. See the documentation in [[https://github.com/tpoulsen/terp/blob/add-testing-features/prelude/test/runner.tp][prelude/test/runner.tp]] for more information. #+BEGIN_SRC racket (type test (-> String (-> Bool Bool)))

 (type assert (-> Bool Bool))

 (type refute (-> Bool Bool))

#+END_SRC

**** Running tests A symbol [✓ | x] and the name provided to ~test~ are printed to the console; they are color coded green/red based on pass/fail respectively.

 The time spent running tests and a count of total tests and total failures are also printed.

 [[file:media/test_run.png]]

** Error messages To help with debugging, error messages try to be as informative as possible: [[file:media/error_messages.png]]