a small bytecode interpreter and scripting language, in portable c11. no dependencies beyond libc and libm.
make releasethat's the whole build. no llvm, no cmake, no configure step, no code generator. the compiler runs once, most of it in parallel, and you get a binary. if that takes more than a second, check your machine.
./flint -e 'print(1 + 2)'from source, which is two commands and works anywhere with a c11 compiler:
git clone https://github.com/programmersd21/flint
cd flint && make releaseon arch, there is an aur package. it is community-maintained, not owned by this repo, so it may trail the releases here by a version:
yay -S flint-binif yay reports a version older than the latest tag above, build from source.
a package manager that installs last month's interpreter is a package manager
doing its best, and the build takes less than a second anyway.
- a stack vm over 64-bit nan-boxed values: a number, or a tagged pointer, in eight bytes
- a single-pass pratt parser. no ast. tokens go in one at a time, bytecode comes out the other end
- mark-and-sweep gc with an explicit gray stack, so a deeply nested list is heap work rather than a segfault in the collector's third frame
- interned strings, which is what makes
==on two strings cost one pointer compare instead of a memcmp on every loop iteration - closures that capture by reference, lists, tables, modules, and a scripting language small enough that the whole thing reads in an afternoon. if there is a comment above a function, it is there because the reason is not in the code.
make release # -O2, no asserts. the one you ship.
make debug # -O0 -g3, dumps the bytecode and traces every instruction
make stress # gc on every allocation, under asan and ubsan
make test # builds release, runs the language suite
make diagnostic-test # checks human, short, JSON, and fix output
make unit # the value and chunk unit tests
make lint # clang-tidy, policy in .clang-tidy
make fmt # clang-format, policy in .clang-format
make fmt-check # same, but reports instead of rewriting. this is the ci one.make stress is the one that finds things. it runs the whole suite with the
collector firing on every single allocation, so a missing root turns into a
use-after-free immediately rather than on a thursday.
./flint # repl, one line at a time
./flint path/to/script.fl # run a file
./flint -e 'print(1 + 2)' # run one expression
./flint - # read a script from stdinexit codes follow sysexits, so a shell can tell the failures apart: 64 for usage, 65 for a file that did not compile, 70 for a program that blew up, 74 for a file that could not be read.
three lines to start:
let xs = [1, 2, 3]
for x in xs { print(x * 2) }
let a = 10
a += 5 # 15
const b = "immutable"
b = "nope" # error: cannot assign to constant
const is read-only wherever it lives. a local one is caught when the file
compiles; a global one is caught when the assignment runs, because it may have
come from a module that hadn't been read yet. same rule, two timings.
if a > 10 {
print("greater")
} else {
print("not greater")
}
let i = 0
while i < 3 { i += 1 }
for n in 1..5 { print(n) } # 1 2 3 4. the end is excluded.
for item in ["apple", "banana"] { print(item) }
a closure grabs the enclosing locals by reference. when the frame exits, any upvalue that outlives it moves to the heap, so the closure keeps working.
fn make_counter() {
let n = 0
fn bump() {
n += 1
return n
}
return bump
}
let c = make_counter()
print(c()) # 1
print(c()) # 2
this is the main substitute for classes. two counters from the same factory
don't interfere, because each call gets its own n. if you need a small
object with a few methods, return a table of closures.
let list = [1, 2, 3]
push(list, 4)
print(list[0]) # 1
print(list[-1]) # 4. negative counts from the end.
print(pop(list)) # 4
let table = { host: "localhost", port: 8080 }
print(table.host)
table.port = 9000
print(table.missing) # nil. a miss is not an error, which is a tradeoff.
print(1 as number) # 1
print(1 as string) # error: expected type 'string' but got 'number'
an assertion, not a conversion. there is nothing to convert between: one
numeric type, everything else already distinct in the value. str() is the
conversion; as is the check.
what it buys is an error on the line you wrote, instead of a nil three
functions later. the seven type names are the seven type() returns: number,
string, bool, nil, list, table, function.
one file is a module. import runs it, and everything it defines becomes
global.
# math.fl
export fn square(x) {
return x * x
}
# main.fl
import "math.fl"
print(square(6))
imports resolve against the importing file, so a script runs from any directory:
$ flint myproject/main.fl # works
$ cd myproject && flint main.fl # also worksa relative path is joined to the directory of the file doing the import, not to wherever you happen to be standing. an absolute path is used as given. that is the whole change, and it is the difference between a script that works and a script that works only from one place.
modules run once per VM. importing one again is a no-op, so a library's top-level code happens once however many files pull it in, and a repeated import is not a redeclaration of its constants. a file that imports itself is an error naming the file, not a stack overflow.
there is still no private and no namespace: all files share one global
table, so two modules defining the same name is a collision that import order
decides. see docs/modules.md.
The built-ins cover core values, byte-string operations, and basic system access. There is no package manager and no external runtime dependency.
let name = input("What is your name? ") # a prompt, a line, no trailing \n
let path = args() # arguments after the script name
let home = env("HOME") # nil if it is not set
let text = read_file("in.txt") # the whole file, as a string
print(write_file("out.txt", text))
exit(exec("grep", "-c", "error", "app.log")) # no shell, ever
input() writes its prompt with no newline and reads one line, so the typing
starts where the prompt ends. an empty line gives "" and end of file gives
nil, which are different values on purpose: the script has to be able to
tell "the user typed nothing" from "there is nothing left to read".
exec() calls execvp and never a shell. there is no path from this API to
/bin/sh, so a filename with a space or a semicolon in it is an argument
rather than an injection.
the string primitives a text script spends its time in:
print(split("a,b,c", ",")) # ["a", "b", "c"]
print(join(["a", "b"], "-")) # "a-b"
print(trim(" hi ")) # "hi"
print(contains("hello", "ell")) # true
print(replace("a-b", "-", "+")) # "a+b"
still absent on purpose: no sort, no random, no environment listing, no
process spawning beyond exec, and no regex. each is a portability question
or a design argument, and the answer so far has been no. see
docs/library.md for the full list and the reasoning.
there are also ten __-prefixed maths natives for a library that is not in
this repository yet. the underscore means "not for you", and nothing in the
documentation or the examples uses them.
flintflint v0.3.0
a small scripting language. type an expression and press enter.
:help for what works here, ctrl-d to leave.
> 1 + 2
3
> let x = 5
> x * 2
10
> [1, 2, 3]
[1, 2, 3]
an expression prints its value, a statement does not, and an error does not
end the session. one line at a time, so an unclosed block is a syntax error.
--quiet drops the banner and the prompt for piping.
the default output is one line per error, and it is what scripts that compare stderr already expect. when you want more:
$ flint --error-format=human bad.fl
error[E0100]: Expect expression.
--> <command line>:1:8
|
1 | let x =
| ^ expected here
|short prints one location line; json emits one object per diagnostic.
--fix applies the machine-applicable closing-delimiter edits currently
supported. --explain E0102 prints a short explanation. Runtime spans still
underline the executing source line, and the compiler does not yet collect
multiple independent errors. See docs/diagnostics.md.
both of these have their own section in the docs, and both have cost this project a bug:
- 0 is truthy. only
nilandfalseare falsy, soif ndoes not mean "if n is non-zero" - ranges are half-open.
1..5is four values:1 2 3 4
docs/, in the order you need it:
| values | the types, and what makes them equal |
| syntax | expressions, operators, statements, as |
| control | if, while, for, break, continue |
| functions | functions, recursion, closures |
| data | strings, lists, tables |
| modules | import and export |
| library | core, string, and system functions |
| diagnostics | error formats, current coverage, and --fix |
| language reference | the language in one document |
| VM internals | value representation, GC, closures, modules |
| errors | what goes wrong, and the exit codes |
| limits | what it doesn't do, and what that costs |
examples/ has runnable programs. hello.fl first, then work up.
The module example uses paths relative to its own file.
- zero warnings under
-Wall -Wextra -Wpedantic -Werror. one warning is a rejected patch. - run
make stress. if asan reports a leak, a use-after-free or a misaligned access, fix it before asking for a review. - a bug gets a test: a
.flfile undertests/language/and a.expectedfile next to it.make testruns every pair it finds. - don't mix reformatting into a functional patch.
make fmtis not an excuse. - no emoji in commit messages.
CONTRIBUTING.md has the rest, including the list of things that are known to be wrong and where each fix belongs.
mit. see LICENSE.