Ink is a minimal programming language inspired by modern JavaScript and Go, with functional style.
Ink has a few goals. In order, they are
- Ink should have a simple, minimal syntax
- Ink should be easy to learn regardless of skill level
- Ink should feel productive to use
- Ink should be quickly readable and expressive
- Ink should have a great, fully featured, and modular standard library
- Ink should have an ergonomic interpreter and runtime API
Design is always a game of tradeoffs. Ink's goals for minimalism and readability / expressivity means the language deliberately does not aim to be best in other ways:
- Ink doesn't need to be highly efficient or fast, especially compared to compiled languages
- Ink doesn't need to be particularly concise
You can run Ink in three main ways:
- The Ink binary
inkdefaults to executing whatever comes through standard input. So you can pipe any Ink script to the binary to execute it. - Use
ink -input <file>.inkto execute an ink script file. You may pass the flag multiple times to execute multiple scripts, likeink -input a.ink -input b.ink. - Invoke
ink -replto start an interactive repl session, and start typing ink code. You can run files in this context by executing@load <file>.inkin the repl prompt.
Here's an implementation of FizzBuzz in the Ink language.
` ink fizzbuzz implementation `
fb := n => (
[n % 3, n % 5] :: {
[0, 0] -> log('FizzBuzz')
[0, _] -> log('Fizz')
[_, 0] -> log('Buzz')
_ -> log(string(n))
}
)
helper := (n, max) => (
n :: {
max -> fb(n)
_ -> (
fb(n)
helper(n + 1, max)
)
}
)
fizzbuzz := max => helper(1, max)
fizzbuzz(100)You'll notice a few characteristic things about Ink:
- Functions are defined using arrows (
=>) a la JavaScript arrow functions - Ink does not have a looping primitive (no
fororwhile), and instead defaults to tail-optimized recursion. Loops may be possible to have in syntax with macros in the near future. - Rather than using
if/else, Ink uses pattern matching using the match (::) operator. Match expressions in Ink allows for very expressive definition of complex flow control. - Ink does not have explicit return statements. Instead, everything is an expression that evaluates to a value, and function bodies are a list of expressions whose last-evaluated expression value becomes the "return value" of the function.
- As a general convention, Ink tries not to use to many English keywords in favor of a small set of short symbols. In fact, the only keyword using the English alphabet in the language is
is, for reference equality checks.
You can find more sample code in the samples/ directory and run them with ink -input <sample>.ink.
// TODO: thing.
I started this project for a few reasons.
Ink makes a few unconventional choices about how programs should be encoded in writing.
Ink's syntax is inspired by JavaScript and Go, but strives to be minimal. This is not a comprehensive grammar, but expresses the high level structure.
Program: Expression*
Expression: (Atom | BinaryExpr | MatchExpr) ','
UnaryExpr: UnaryOp Atom
BinaryExpr: Expression BinaryOp Expression
MatchExpr: (Atom | BinaryExpr) '::' '{' MatchClause* '}'
MatchClause: Atom '->' Expression
Atom: UnaryExpr | EmptyIdentifier | Identifier | Literal
| FunctionCall | '(' Expression* ')'
EmptyIdentifier: '_'
Identifier: (A-Za-z@!?)[A-Za-z0-9@!?]* | _
FunctionCall: (Identifier
| FunctionLiteral
| FunctionCall
| '(' Expression* ')') '(' Expression* ')'
Literal: NumberLiteral | StringLiteral
| BooleanLiteral | FunctionLiteral
| ObjectLiteral | ListLiteral
NumberLiteral: (0-9)+ ['.' (0-9)*]
StringLiteral: '\'' (.*) '\''
BooleanLiteral: 'true' | 'false'
ObjectLiteral: '{' ObjectEntry* '}'
ObjectEntry: Expression ':' Expression
ListLiteral: '[' Expression* ']'
FunctionLiteral: (Identifier | '(' (Identifier ',')* ')')
'=>' Expression
UnaryOp: (
'~' // negation
)
BinaryOp: (
'+' | '-' | '*' | '/' | '%' // arithmetic
| '>' | '<' // arithmetic comparisons
| '=' // value comparison operator
| 'is' // reference comparison operator
| ':=' // assignment operator
| '.' // property accessor
)
A few quirks of this syntax:
- All variables use lexical binding and scope, and are bound to the most local ExpressionList (execution block)
- Commas (
Separatortokens) are always required where they are marked in the formal grammar, but the tokenizer inserts commas on newlines if it can be inserted, except after unary and binary operators and after opening delimiters, so few are required after expressions, before closing delimiters, and before the ':' in an Object literal. Here, they are auto-inserted during tokenization.- This allows for "minification" of Ink code the same way JavaScript source can be minified. Minified Ink code can be more compact, because in Ink, almost all whitespace is unnecessary (except those wrapping the
isoperator).
- This allows for "minification" of Ink code the same way JavaScript source can be minified. Minified Ink code can be more compact, because in Ink, almost all whitespace is unnecessary (except those wrapping the
- String literals cannot contain comments. Backticks inside string literals are counted as a part of the string literal. String literals are also multiline.
- This also allows the programmer to comment out a block with an explanation, simply like this:
realCode() ` this block is commented out for testing reasons someOtherCode() ` moreRealCode() - List and object property/element access have the same syntax, which is the reference to the list/object followed by the
.(property access) operator. This means we access array indexes witharr.1,arr.(index + 1), etc. and object property withobj.propName,obj.(computed + propName), etc. - Object (dictionary) keys can be arbitrary expressions, including variable names. If the key is a single identifier, the identifier's name will be used as a key in the dict, and if it's not an identifier (a literal, function call, etc.) the value of the expression will be computed and used as the key. This seems like it may cause trouble conceptually, but turns out to be intuitive in practice.
- Assignment is always (re)declaration of a variable in its local scope; this means, for the moment, there is no way to mutate a variable from a parents scope (it'll just shadow the variable in the local scope). I think this is fine, since it forbids a class of potentially confusing state mutations, but I might change my mind in the future and add an assignment-that-isn't-declare. Note that this doesn't affect composite values -- you can mutate objects from a parents scope.
Ink is strongly but dynamically typed, and has seven non-extendable types.
- Number
- String
- Bytes
- Boolean
- Null
- Composite (including both Objects (dictionaries) and Lists, like Lua tables)
- Function
pi: Millisecond timestamp. By convention, global constants begin with@.
in() => string: Read from stdin or until ENTER key (might change later)out(string): Print to stdoutread(string, number, number) => bytes: Read from given file descriptor from some offset for some byteswrite(string, number, bytes): Write to given file descriptor at some offsettime() => number: Current millisecond (since UNIX epoch) timestamp
sin(number) => number: sinecos(number) => number: cosineln(number) => number: natural log
string(any) => stringnumber(any) => numberbytes(any) => bytesboolean(any) => boolean
Ink is currently a single go package. Run go run . to execute the binary.
The ink binary takes in scripts from standard input, unless at least one -input flag is provided, in which case it reads from the filesystem.
Ink also has a vim syntax definition file, under utils/ink.vim.