Everything a script can call. The core language and the system functions are
built in; everything else is a module in lib/, imported by name.
read one line from stdin, and return it without the trailing newline.
let name = input("What is your name? ")
print("hello, " + name)
the argument is a prompt, and it is optional. input() with nothing reads a
line in silence. a prompt is written exactly as given, with no newline added,
so the line the user types starts where the prompt ends.
a prompt that is not a string is an error, and so is calling it with two
arguments. arity zero or one is not expressible in the fixed-arity check the
VM does for every other native, so input() checks inside instead.
the return value is a normal string: empty for an empty line, which is
deliberately a different value from what you get at end of file. pressing
enter on an otherwise empty line gives "", and reaching end of file gives
nil, because a script has to be able to tell "the user typed nothing" from
"there is nothing left to read".
print(input()) # with nothing piped in: nil
an input line can be any length. the buffer starts at 64 bytes and doubles, which is the same growth shape as every other array in the runtime and is not coincidence. a fixed-size buffer would silently truncate a long line, which is the kind of failure a script cannot possibly notice, let alone handle.
windows line endings are tolerated: a \r is skipped, so a file written there
arrives as plain text. a script should not have to know which platform produced
its input.
there is no echo control, no history, no line editing and no signal handling. it is a prompt and a read.
split(s, sep) returns a list of byte strings. An empty separator splits into
one-byte strings. join(xs, sep) joins string elements with the separator.
trim(s) removes surrounding whitespace. contains(s, part),
starts_with(s, prefix), and ends_with(s, suffix) return booleans.
replace(s, old, new) replaces occurrences; lower(s) and upper(s) change
ASCII letters. These operate on bytes; UTF-8 characters are not decoded.
print(split("a,b", ","))
print(join(["a", "b"], ","))
print(trim(" flint "))
print(contains("flint", "lin"))
args() returns arguments after the script path. env(name) returns the
environment value or nil when the variable is unset. An empty environment
value is still a string.
read_file(path) reads a whole file into a string. write_file(path, data)
writes a string and returns true on success. These are not sandboxed. A path
is a path on the host, and a script can overwrite a file it can name.
exec(program, arg...) searches PATH, starts the program directly, waits,
and returns its exit status as a number. It does not invoke a shell and does
not capture output; the child inherits the process streams.
exit() terminates the process with status zero. exit(code) accepts an
integer status from 0 through 255. It does not return to the calling Flint
function.
length of a string in bytes, or of a list in elements.
print(len("hello")) # 5
print(len([1, 2, 3])) # 3
print(len([])) # 0
print(len("")) # 0
anything else is a runtime error, including a table.
print(len({a: 1})) # error: must be a string or list
a string's length is in bytes, not characters, so a utf-8 string is longer than it looks. see data.md.
append to a list. returns the item, so it is usable as an expression.
let xs = []
push(xs, 1)
push(xs, 2)
print(xs) # [1, 2]
print(push(xs, 3)) # 3
the first argument must be a list. the list is mutated in place; nothing is copied.
remove and return the last element.
let xs = [1, 2, 3]
print(pop(xs)) # 3
print(xs) # [1, 2]
popping an empty list is an error, not nil. there is no tryPop.
print(pop([])) # error: cannot pop from an empty list
place a value at a position, shifting everything after it right. returns the
item, like push.
let xs = [1, 2, 3]
print(insert(xs, 1, 9)) # 9
print(xs) # [1, 9, 2, 3]
index rules match subscript exactly: negatives count from the end, so
insert(xs, -1, v) goes where xs[-1] reads. exactly len(xs) appends.
anything else out of range, fractional, or non-numeric fails the same way
subscript does.
take the value out at a position and return it. entries after it shift left.
let xs = [1, 2, 3]
print(remove(xs, 0)) # 1
print(xs) # [2, 3]
print(remove(xs, -1)) # 3
print(xs) # [2]
removing past either end, from an empty list, or through a fractional index
is an error rather than nil: silently returning nothing for a removal that
removed nothing would hide the off-by-one that caused it.
the slot is not cleared, so the popped value stays reachable until the list is collected. that is a deliberate simplification, and it is why a large list that you repeatedly pop does not shrink its memory.
the string form of any value. a string returns itself, so the common case costs nothing.
print(str(42)) # 42
print(str(1.5)) # 1.5
print(str(true)) # true
print(str(nil)) # nil
print(str("s")) # s
str only converts scalars. a list, a table or a function gives <object>,
because str is a native and has no access to the printing the print
statement does.
print(str([1, 2])) # <object>
print([1, 2]) # [1, 2]. the print statement is richer.
that difference is real and it will surprise you the first time. print and
str are two different code paths, and only print knows how to render a
container. there is no way to get a string form of a list, so if you need one,
build it yourself:
fn join(xs, sep) {
let out = ""
let i = 0
while i < len(xs) {
if i > 0 { out = out + sep }
out = out + str(xs[i])
i += 1
}
return out
}
print(join([1, 2, 3], ", ")) # 1, 2, 3
print does quote a string inside a list, so the two cases stay tellable apart:
print(["a", "b"]) # ["a", "b"]
print([1, "two"]) # [1, "two"]
numbers are formatted as flint formats them at the print statement: integral
values have no decimal point, and everything else uses the shortest form that
reads back as the same double. see values.md.
the number form of a string, and the inverse direction of str(). a number
passes through, so num is safe to call on something that might already be
one.
print(num("42")) # 42
print(num("3.14")) # 3.14
print(num("-0.5")) # -0.5
print(num(" 7 ")) # 7. leading and trailing whitespace is fine.
print(num(7)) # 7
the string has to parse whole. num("12abc") fails rather than returning 12,
because returning a prefix would be guessing at what was meant. an empty
string fails too, and so does anything that is neither a number nor a string.
print(num("abc")) # error: cannot convert "abc" to a number.
print(num("")) # error: cannot convert an empty string to a number.
print(num(nil)) # error: must be a number or a string, got a nil.
this is deliberately an error and not nil. a conversion that cannot be done
is a fact about this line, and reporting it here -- naming the value -- beats
returning nil and letting it surface three calls later as an operand error in
code that had nothing to do with it.
note that as number is not this. as is a type assertion: "9" as number
fails, correctly, because a string is not a number. num("9") is 9.
the common shape is reading from the user, since input() returns a string:
import math
const a = num(input("a: "))
print(math.sqrt(a))
the byte value of a one-character string, as a number. the argument has to be exactly one byte long: an empty string and a two-character string are both runtime errors, because either one would be guessing at which byte was meant.
print(ord("A")) # 65
print(ord("a")) # 97
the inverse direction: a number from 0 to 255 becomes the one-character string holding that byte. anything outside the range is a runtime error, as is anything that is not a whole number.
print(chr(65)) # A
print(chr(ord("z"))) # z
ord and chr round-trip: chr(ord(s)) == s for every one-byte string s.
multibyte utf-8 is bytes here, not characters, so ord of a two-byte
character fails rather than returning half of it. see values.md.
the name of a value's type, as a string.
print(type(1)) # number
print(type(1.5)) # number. there is no separate int type.
print(type("s")) # string
print(type(true)) # bool
print(type(nil)) # nil
print(type([1])) # list
print(type({a: 1})) # table
print(type(len)) # function
a closure, a flint function and a native all report function. there is no
way to tell them apart, and no reason to.
these seven words are the only type names in the language, and they are what
x as T takes. a cast and type() cannot disagree about what a value is,
because both ask the same function. see syntax.md.
to check rather than ask, use as:
print(1 as number) # fine
print(1 as string) # error: expected type 'string' but got 'number'
process cpu time in seconds, as a double.
let t = clock()
this is cpu time, not wall clock time, so it does not advance while the process is waiting. that makes it useless for timing anything that blocks, and exactly right for measuring how much work a benchmark did.
for wall clock timing, measure outside the interpreter, with time.
not part of the language. the compiler emits a call to it for every import
statement, and you should not call it yourself. see modules.md.
str([1, 2]) is still <object>. print knows how to render containers;
str() does not. sorting is not a built-in; use a comparison loop or reach
for collections which has min and max.
ten __-prefixed natives exist underneath lib/math.fl. they are
deliberately not part of the language surface: a leading underscore means
"not for you", and programs are expected to use the math module, which
wraps them. nothing in the documentation or the examples reaches for the
bare natives directly.
| native | |
|---|---|
__floor(x) |
largest integer not above x |
__sqrt(x) |
square root |
__fma(a,b,c) |
a*b+c with one rounding |
__ldexp(x,n) |
x times 2 to the n |
__logb(x) |
exponent as a number |
__fabs(x) |
absolute value |
__copysign(x,y) |
x with y's sign |
__hi32(x) / __lo32(x) |
half of a double's bits |
__from_bits(hi,lo) |
a double rebuilt from two halves |
they take and return numbers, and a non-number argument is a runtime error
like any other. the wrappers live in src/util/fl_math.c so the language and
the maths stay separate.
contributed in #1.
import math
print(math.sqrt(2))
the first library anyone imports, and the one the rest of the standard library leans on. every function takes and returns numbers, because that is the only numeric type flint has.
a bare name is a library, not a file. import "foo.fl" looks next to the
importing file, import math looks in the standard library, and the module
loader tells them apart. there is one module system, not two.
math.pi math.e math.tau |
the usual constants |
math.sqrt2 math.ln2 math.ln10 |
the two-letter ones |
math.abs(x) |
|
math.sqrt(x) math.cbrt(x) math.exp(x) math.exp2(x) |
|
math.log(x) math.log2(x) math.log10(x) |
|
math.pow(x, y) |
there is no ^. it is xor elsewhere, and flint does not pretend otherwise |
math.sin cos tan asin acos atan |
radians. always. |
math.atan2(y, x) |
|
math.sinh cosh tanh asinh acosh atanh |
|
math.floor ceil trunc |
|
math.round(x) |
half away from zero |
math.fmod math.remainder math.copysign |
|
math.fma(a, b, c) |
a*b+c with one rounding, not two |
math.ldexp(x, n) |
x times 2 to the n, exactly |
math.hi32(x) math.lo32(x) |
the two halves of a double's bits |
math.isnan math.isinf math.isfinite |
|
math.math_pi math.math_tau math.math_e |
the explicit constants: same values, math_ prefix |
math.math_pi_2 math.math_pi_4 math.math_1_pi math.math_2_pi |
|
math.math_ln2 math.math_ln10 math.math_log2e math.math_log10e |
|
math.math_sqrt2 math.math_sqrt1_2 |
|
math.math_deg2rad math.math_rad2deg |
|
math.math_epsilon math.math_max math.math_min math.math_tiny |
double limits |
math.math_inf math.math_nan |
round is half away from zero, so round(0.5) is 1 and round(-0.5) is -1.
the c library's nearby() rounds half to even and would give 0 and 0. flint
does not use it, because "what everyone means by round" is worth more than
consistency with a function whose name does not mean round.
cbrt(-27) is -3. pow(-27, 1/3) is nan, and that is correct for a
function that has to be right about negative zero and infinities. it is still
the wrong tool for a cube root, which is why both exist.
libm is not bit-identical across platforms, and the last digit of a
transcendental function may differ. what flint promises is the behaviour at
the edges: sqrt(0) is 0, cbrt works on negatives, division by zero gives
infinity rather than an error, and 0/0 is nan, which is not equal to
itself.
import path
print(path.join("a", "b", "c"))
path manipulation, and nothing else. every function here is string
arithmetic: this module never touches the disk. that is fs, and mixing the
two is how a join ends up doing io when somebody expected a string.
path.join(a, b) |
one separator, never two. empty parts are skipped |
path.basename(p) |
after the last separator |
path.dirname(p) |
before the last separator, or "." |
path.ext(p) |
with the dot, or "". a leading dot is not an extension |
path.stem(p) |
the name without the extension |
path.isabs(p) |
starts at the root |
path.has_ext(p, list) |
case-insensitive, list holds bare extensions |
path.sep |
"/" |
has_ext("a.tar.gz", ["gz"]) matches on the last extension, which is what
ext returns. asking for tar.gz is a different question and is not the one
this answers.
the separator is always /, including on windows, so a path that arrived in a
config file behaves the same everywhere. a path written as a\b is treated as
one component. that is a known limitation, not an oversight: a module that
guesses at the host separator is a module that is wrong in one direction and
surprising in the other.
import random
print(random.rand()) # a float in [0, 1)
print(random.rand_int(1, 6)) # an integer in [1, 6]
random.shuffle(my_list) # shuffles in place, returns nil
print(random.choice(my_list)) # picks one element
xorshift64* seeded from the clock and process id at first use. fast and adequate for scripts; not cryptographic. the source says so explicitly.
seed(n) resets the state to a known value, which makes a run reproducible.
useful in tests; not an invitation to assume global state across modules.
import time
let t = time.now() # unix epoch, fractional seconds
print(time.format(t)) # "2006-01-02T15:04:05Z" (always UTC)
time.sleep(500) # milliseconds. blocks.
print(time.clock_ms()) # monotonic wall clock, milliseconds
let ms = time.measure(fn() {
# something you want to time
})
print("took " + str(ms) + "ms")
now() and format() use wall clock time. clock_ms() is monotonic and
suitable for benchmarking. sleep() takes milliseconds and calls nanosleep
internally; a sleep of zero is a yield.
import fs
if fs.exists("config.txt") {
let content = fs.read("config.txt")
print(content)
}
fs.write("out.txt", "hello\n")
fs.append("log.txt", "one more line\n")
fs.mkdir("new_dir")
print(fs.isdir("new_dir")) # true
fs.remove("tmp.txt")
read returns the entire file as a string. write and append return nil.
exists, isdir return booleans. mkdir creates one directory level (not
recursive). remove deletes a file; removing a directory that is not empty is
an error.
these are thin wrappers over fopen/fread/fwrite/stat. no buffering,
no magic. what posix gives you is what you get.
read and write stop the script with a clear error when they cannot do
their job -- a missing source, an unwritable destination. a file that is not
there is not an empty file, and returning nil for one would make every reader
check for a case that is really a failure. check exists() first when a
missing file is an expected outcome rather than an error.
the names inside a directory, or nil when it cannot be read.
import fs
let names = fs.listdir(".")
if names == nil {
print("cannot read directory")
exit(1)
}
for name in names {
print(name)
}
names, not paths: join them with path.join. "." and ".." are included,
exactly as the filesystem reports them. order is whatever the filesystem
returns, which is to say unspecified.
nil covers missing directories, permission failures, and the window
between exists() and listdir() where the directory disappears. check
exists() first when the reason matters; accept nil when it does not.
platform answers, the working directory, and the environment. filesystem
operations are fs, path strings are path, running programs is exec()
or process: this module answers questions about the machine rather than
changing it, with chdir and setenv as the two deliberate exceptions.
import os
print(os.name() + "/" + os.arch()) # linux/x86_64, say
print(os.getcwd()) # where the process is standing
print(os.getenv("HOME", "")) # the value, or "" when unset
print(os.pid()) # this process's id
name() is one of "linux", "darwin", "windows", "freebsd",
"unknown". arch() is one of "x86_64", "aarch64", "x86", "arm",
"unknown". both answer from preprocessor macros, so they cannot be wrong
about the binary they are compiled into -- but "unknown" is a real answer on
a platform nobody taught them about, and a script that branches on it should
have a fallback.
getenv(name, fallback) takes the default explicitly, unlike env(name)
which gives nil for a missing name. the result is always a string and the
caller never branches on nil, which is what a config reader wants.
setenv and unsetenv return booleans. unsetting a name that was never set
succeeds. environment changes affect child processes, which is what makes
them useful before exec() or process.run().
homedir() and tmpdir() give nil when the platform will not answer
($HOME unset, no TEMP on windows). a nil there is information -- there
is no home to report -- not a failure.
run a program and read what it said. the command is a list, never a string: a single string would have to be split somewhere, and splitting on spaces breaks on filenames that contain them.
import process
let r = process.run(["git", "status", "--short"])
print(r.code) # 0
print(r.stdout) # the output, as a string
the result always has the same four fields: stdout, stderr, code, and
timed_out. code follows the shell convention exec() already uses: the
exit status, 128 plus the signal number for a signal death, 127 for "not
found", 126 when exec could not run at all.
let r = process.run_opts(["ls", "/nonexistent"], {})
print(r.code) # 2
print(r.timed_out) # false
options are a table, and every key is optional. cwd runs there instead of
here. stdin is piped to the child's standard input. timeout is
milliseconds before the child is killed with SIGKILL:
let r = process.run_opts(["cat"], {stdin: "hello"})
print(r.stdout) # hello
let slow = process.run_opts(["sleep", "5"], {timeout: 200})
print(slow.timed_out) # true
print(slow.code) # 137, which is 128 + 9
both streams are drained while the child runs, so a child that writes a lot to stderr while the parent reads stdout cannot deadlock -- 64K of unread stderr with nobody reading it is all a naive implementation takes. an unknown option is an error rather than ignored: an option the runtime does not understand is almost certainly a misspelled option it does.
no shell, ever. execvp searches PATH and interprets nothing, so arguments
keep their boundaries whatever they contain. a script that genuinely wants a
shell says so explicitly with ["sh", "-c", ...] and owns the quoting.
import collections as c
let xs = [3, 1, 4, 1, 5, 9]
print(c.min(xs)) # 1
print(c.max(xs)) # 9
print(c.sum(xs)) # 23
print(c.avg(xs)) # 3.8333333333333335
print(c.reverse(xs)) # [9, 5, 1, 4, 1, 3]
print(c.unique(xs)) # [3, 1, 4, 5, 9] (order preserved)
print(c.contains(xs, 4)) # true
print(c.index_of(xs, 4)) # 2
print(c.take(xs, 2)) # [3, 1]
print(c.drop(xs, 2)) # [4, 1, 5, 9]
reverse returns a new list; the original is unchanged. unique preserves
first occurrence. min, max, sum and avg require a non-empty list and
operate on numbers.
the key strings of a table, in insertion order, as a fresh list. mutating the result never touches the table.
let t = {b: 1, a: 2}
print(keys(t)) # ["b", "a"]
whether the table holds the key. compares by content, so a key built at run time finds the entry a literal created.
let t = {ab: 1}
print(has(t, "ab")) # true
print(has(t, "a" + "b")) # true
print(has(t, "zz")) # false
print(has(t, 42)) # false. only strings can be keys.
remove an entry, reporting whether anything was removed. deleting a missing key is false rather than an error.
let t = {a: 1, b: 2}
print(delete(t, "a")) # true
print(has(t, "a")) # false
print(delete(t, "a")) # false
entries after the removed one shift down, preserving insertion order for everything that remains.
import json
let obj = json.parse("{\"x\": 1, \"ys\": [2, 3]}")
print(obj.x) # 1
print(obj.ys[0]) # 2
let s = json.stringify(obj) # {"x":1,"ys":[2,3]}
let p = json.pretty(obj) # indented, 2 spaces
parse returns a table for objects, a list for arrays, a number for numbers,
a string for strings, a bool for booleans, and nil for null. a JSON error
is a runtime error naming the offset.
stringify and pretty accept numbers, strings, booleans, nil, lists, and
tables with string keys. a function in the value tree is a runtime error,
because a function is not JSON and pretending it is makes round-trips wrong.
circular references are not detected. the vm will overflow the call stack first, which is a fine outcome: a circular structure is a bug, not an edge case to handle gracefully.
import http makes outbound request. http.get, http.post, http.put,
http.delete, http.request and http.get_json are the entry points. they
accept options tables: url, method, body, headers (a table),
timeout in milliseconds (default 10000), and follow for redirects
(default true, up to five).
import http
let headers = {}
headers["User-Agent"] = "flint"
let res = http.get("https://api.example.com/data", {headers: headers})
if res.ok {
print(res.body)
} else {
print(res.error)
}
table literals only take identifier keys, so a header name with a dash is set through a computed key first, as above. the options table itself takes identifier keys only.
the result is a table with:
| key | meaning |
|---|---|
ok |
true only for a 2xx response |
status |
the http status number |
status_text |
the reason phrase |
headers |
response headers, a table |
body |
the response body as a string |
url |
the final url after redirects |
redirects |
how many redirects were followed |
error |
an error message, or an empty string |
http:// and https:// are both supported. redirects 301/302/303 collapse
to a GET without a body; 307/308 keep method and body. response bodies are
returned as-is: no automatic gzip decoding.
http.get_json(url, opts) returns json.parse(res.body) when the request
succeeds, and nil on failure or redirect chains the server chose that no
longer carry the body you expect.
max redirects is five. a transport failure -- dns, refused, timeout -- is
reported with ok: false, status: 0, and the error message, not a runtime
error. an unparseable url like ftp://... is a runtime error.
import ansi
print(ansi.red("nope") + ansi.ANSI_RESET)
terminal escape codes, as plain strings. every constant is the bytes for one
code, so they compose with + like anything else. nothing here touches the
terminal: whether the other end interprets the bytes is up to it, and piping
the output to a file writes the codes into the file.
the names follow the code they wrap. ANSI_RED is the 31m foreground,
ANSI_BG_BLUE is the 44m background, ANSI_CURSOR_UP moves one line, and
the builders take numbers: ANSI_fg256(9), ANSI_RGB(255, 0, 128),
ANSI_up(5), ANSI_goto(3, 1). the named wrappers put the code around a
string and reset after it: red, green, yellow, blue, magenta,
cyan, white, gray, bold, dim, italic, underline, strike.
import pretty_print as pretty
pretty.pretty_print([1, [2, 3]])
a value rendered across lines, for debugging. pretty_print(v) prints,
pretty_output(v) returns the string. nested lists and tables indent by two
spaces per level.
this is a debugging aid, not a serializer: strings print bare, and nothing parses the output back.
import args as argv
print(argv.all()) # the whole argv list
print(argv.count()) # how many arguments the script was given
print(argv.get(0)) # the first one, or nil when there is none
print(argv.has("--x")) # true when the flag is present
print(argv.value("--out")) # the argument after the flag, or nil
the alias is required, not style: a bare import args binds args in your
globals and shadows the builtin args() the module itself calls. aliased,
both names work.
the builtin args() returns the script's argument vector without the
interpreter's own flags. this module wraps the questions a script actually
asks so nobody rewrites the loop: how many, which one, is this flag there,
what follows it. get answers nil past the end rather than failing,
because a missing argument is a normal thing for a script to check for.
import encoding as e
print(e.hex_encode("hi")) # 6869
print(e.hex_decode("6869")) # hi
print(e.base64_encode("hi")) # aGk=
print(e.base64_decode("aGk=")) # hi
print(e.url_encode("a b&c")) # a+b%26c
print(e.url_decode("a+b%26c")) # a b&c
the encodings a program meets talking to something that is not flint: a
header, a checksum, a query string. all of them take and return strings,
because that is what the wire carries. url encoding is the query-string
form, where a space is +.
none of these is a cipher. base64 is not encryption, and neither is hex.
a malformed input is a runtime error naming the position, not a best
effort: decoding "zz" as hex fails rather than returning half a byte.