Largo Language Reference
This document covers the language as currently implemented. Features marked ⚠ TODO
are planned but not yet implemented (attempts to compile them raise a compile-time
error). Features marked ⚠ TBD are planned but syntax is not yet decided.
Features marked ⚠ PARTIAL are partially implemented.
Getting Largo
See pypi/largolang for installation instructions.
Overview
Largo is a scripting language with a python-ish syntax whose runtime
is a persistent, transactional, garbage-collected object database.
Every value, every compiled procedure, every live task, parameter,
stack frame, lives on disk at all times. Pull the plug mid-execution
and the system resumes exactly where it left off.
This means your working memory is as big as your disk, and you don't
have to worry at all about persistence or saving and restoring
anything. It also means you can write a "while true" loop and expect
it to run forever--across reboots, power failures, even moving to a
new machine. No crontabs.
All tasks run non-preemptive async style, uninterrupted until they
wait. Tasks can wait on time, IO, and changes. The following code will
print the sum every time it changes--forever:
for v in <foo.x + foo.y>:
print("The sum of foo.x and foo.y is {v}.")
Or equivalently (and no less efficiently):
for v in <"The sum of foo.x and foo.y is: {foo.x + foo.y}">:
print(v)
In the meantime this task just waits in a queue, on disk, taking
no additional CPU or memory.
You can also define object fields which are computed by a function
that will only be re-invoked if its dependencies have changed since
the last time you accessed it (and those compose efficiently).
Common objects like Maps, Lists, Sets, and weak variations of those,
are implemented in terms of Tables under the hood, but you can also
create your own tables any time. Tables are just Objects like
anything else, and garbage collect away if abandoned. Furthermore,
when you create a table you specify the garbage collection roles of
the columns, which determines what is a weak vs strong reference
within the table. Weak references quietly revert to unknown when
GC'd.
There are lots of other niceties like clean currying with both
positional and keyword args; lambdas (the angle brackets in the
example above); syntax for value, key:value, and zipping iteration;
string dedenting; auto-indenting of interpolated multi-line strings;
in-line default values (x\y means x if known else y); labeled breaks
and continues; constant, variable, monotonic, and weak fields on
objects; clean multiple inheritance of types; multiple dispatch;
nested comments; explicit doc strings; naturally styled shell
commands; and so on.
It is currently in pre-alpha stage, meaning it's usable as far as it
goes but there are gaping holes--some things just haven't been
implemented yet because I haven't needed them yet, including some
very basic things. Some of the things mentioned in this overview are
not yet fully implemented, but solid foundations exist for all of
them. It's not ready to use in production, but it's a great time
to get involved if you want to help shape a new language, either
directly with design suggestions or coding help, or indirectly by
trying to use it and telling me where largo's failing you so I know
what needs fixing or prioritizing. Join the Telegram channel
linked at the top of this document.
It is my intention to add a permissions layer to largo so that
modules can be safely imported and run without worrying about them
mucking up your other tasks, or snooping or deleting your files. And
by this I mean down to the level of who is allowed to read or write
what field on what object, let alone access a particular file or
directory. Between this and the long-running nature of it, largo is
more like an operating system than just a programming language.
Permissions are still on my todo list until it becomes a higher
priority, but the foundations are there (write permissions are
already gated at the property level--e.g., var vs val fields--just
not yet by "user").
Largo runs atop python and for now most of the primitives and
libraries just delegate to python, which has helped to keep the
code base relatively small.
Lexical Conventions
Whitespace and Comments
-- this is a line comment
(| this is a block comment |)
(| block comments (| can be nested |) freely |)
Statements end at a newline. Continuation across lines is
implicit inside any bracketed context ((), [], {}, <>).
Identifiers
Identifiers follow the pattern [a-zA-Z_][a-zA-Z0-9_]*. There are no
reserved keywords. (Though the vim syntax highlighting isn't as smart as
the actual compiler, so some keywords highlight where they shouldn't.)
Literals
Numbers
42 -- integer
3.14 -- float
1.5e10 -- scientific notation
SQLite numeric note: Largo is backed by SQLite, which does not distinguish integers
from whole-number floats. Any float whose value is mathematically whole (e.g.2.0,
1e3) is silently coerced to an integer. For all practical purposes, there is only
one numeric type in Largo.
Strings
Largo has two string delimiters and two quoting modes, giving four combinations.
Plain strings '...'
No interpolation. Standard backslash escapes apply: \n, \t, \\, \', \uXXXX, etc.
'hello world'
'line one\nline two'
Format strings "..."
Expressions are embedded with {}. Use {{ and }} to produce literal braces.
"hello {name}"
"result = {x + y}"
"escaped brace: {{ not interpolated }}"
Guarded strings $'...'$ and $"..."$
Used when the string content itself contains quotes of the same kind. Backslash sequences
are passed through literally (no escape processing). {} interpolation still works
in $"..."$. Any number of $ signs may be used as the guard; opening and closing
counts must match.
$'it's fine to have apostrophes'$
$"embed {expr} and "quotes" freely"$
$$$'contains $$"nested"$$ guard sequences'$$$
Multi-line strings
Any string delimiter may span multiple lines. The content is auto-dedented: the
longest common leading whitespace among all non-empty lines after the first newline
is stripped. If the closing delimiter sits on its own line whose indentation is
less-than-or-equal to the common indent, the string ends with \n.
text = '
line one
line two
'
-- text == "line one\nline two\n"
text2 = '
line one
line two'
-- text2 == "line one\nline two" (no trailing newline)
Indented interpolation in format strings
When a {} expression in a format string is preceded by whitespace on its line, and the
expression's value spans multiple lines, every line of that value is indented to match
the column of the {. This makes embedding indented sub-blocks natural.
result = "Header:
{body}
"
-- If body == "line 1\nline 2", result is:
-- "Header:\n line 1\n line 2\n"
Collections
[] -- empty List
[1, 2, 3] -- List (mutable, ordered)
[:] -- empty Map
[a=1, b=2] -- Map with field-name keys (shorthand for string keys)
["key": val] -- Map with expression key
[a=1, "k": v, x+1: w] -- field-name and expression keys freely mixed in same []
#[] -- empty Tuple
#[1, 2, 3] -- Tuple (immutable, memoized — same contents = same identity)
{} -- empty Set
{1, 2, 3} -- Set (mutable)
Tuples are the primary bridge between content-based and identity-based equality.
#[a, b, c] with the same contents always yields the same object. A plain List can be
memoized with tuple(list).
⚠ TODO: Immutable memoized maps
#[a=1, b=2]and memoized sets#{...}.
Comprehensions
List, map, and set comprehensions use the same bracketing as their literal forms.
[expr for x in seq] -- List comprehension
[key: val for x in seq] -- Map comprehension
{expr for x in seq} -- Set comprehension
[expr for x in seq if cond] -- filtered (works for all three types)
The loop head supports the same iteration forms as for, including index/key capture and zip-style multiple iterators:
[i:x*2 for i:x in seq] -- i = index/key, x = value
[x+y for x in a, y in b] -- zip two iterables (sum two lists)
[x:y for x in a, y in b if y>0] -- zip two iterables into a map, with filter
Nested (cartesian) for clauses in a single comprehension are not currently supported; use nested comprehensions or an explicit loop instead.
Variables and Assignment
x = 42 -- assign to local variable
x = y = 0 -- chained assignment (right-to-left evaluation)
(x = expensive()) -- assignment embedded in expression (must be parenthesized)
Assignment is right-associative. The left-hand side may be:
x = val -- local variable
obj.field = val -- field assignment
obj[key] = val -- index assignment (single key only for now)
⚠ TODO:
:=means in-place mutation.
Operators
Operator Precedence (high to low)
| Level | Operator(s) | Notes |
|---|---|---|
| 1 (tightest) | f() f{} f[] .field x? x! |
call, curry, index, field, known, once known |
| 2 | a \ b |
default: a if known, else b |
| 3 | a ^ b |
exponentiation (right-associative) |
| 4 | -x |
unary negation |
| 5 | * / // % |
multiply, divide, integer-divide, modulo |
| 6 | + - |
add, subtract |
| 7 | a..b a... ...b |
range (exclusive end) |
| 8 | in |
membership / instance test ⚠ TODO |
| 9 | < <= > >= == != |
comparison — chainable ⚠ PARTIAL |
| 10 | not |
logical not |
| 11 | and |
logical and (short-circuits) ⚠ TODO |
| 12 | or xor |
logical or / xor ⚠ TODO |
| 13 | if…then…else |
ternary (right-associative) |
| 14 (loosest) | = := |
assignment (parenthesize when used as sub-expression) |
Operator Notes
Default \ — returns the left operand if it is known (non-None), otherwise
evaluates and returns the right operand:
display_name = user_alias \ user_name \ "Anonymous"
Known test x? — always returns a bool. false? is true — false is a perfectly
known value:
x? -- True if x is not None
false? -- True
config["key"]? -- True only if that key has been set
Once Known: x! — waits until x is known, then returns it. Especially
useful for one-mode fields, but can also pause on intermittently missing
values.
spawn print("foo.x = {foo.x!}") -- background task prints foo.x once set
val = bar(x, y)! -- Returns bar(x, y) once bar(x, y) returns a known value.
⚠ TODO: Need to decide if
bar()!should catch exceptions or pass
them through. Right now they pass through, so you cannot wait for a
a function to "succeed" per se. Possiblybar()!!should also catch
exceptions?
Accessing unknown values — field access and map indexing return unknown (None)
rather than raising an error in most cases, including when the base object is itself
unknown. The exception is accessing a field that does not exist on a known object's
type — that is a semantic error.
obj.unset_field -- unknown (field not yet set)
unknown_var.field -- unknown (base is unknown)
map["absent"] -- unknown
Map indexing by field — For Maps only, field access is a shorthand for indexing
by that string (for destination as well):
m.foo == m['foo']
Chained comparisons — chains must be uniformly ascending (<, <=) or uniformly
descending (>, >=); == and != may appear in either:
x < y -- ok
x < y <= z -- ⚠ PARTIAL: parsed, not yet compiled
Range .. and ... — exclusive end (Python-style: a..b ≡ range(a, b)):
a..b -- [a, b)
a... -- [a, end)
...b -- [0, b)
Unary negation -x — standard arithmetic negation. Delegates to Python's negation, so behaviour on non-numeric types is undefined.
Exponentiation x ^ y — right-associative. Delegates to Python's pow, so 2^10 == 1024. Behaviour on non-numeric types is undefined.
Note on operator dispatch: Currently all built-in operators map directly to their Python equivalents. The intention is to replace this with a Julia-style multiple-dispatch system, where operators like
^de-sugar to generic functions (e.g.pow) that can be overloaded for user-defined types. For now, passing the wrong types will fail at runtime with a Python error.
and, or, xor ⚠ TODO — parsed but not yet compiled.
Ternary if … then … else — right-associative. The if branch is the condition, then is the value when true, else is the value when false:
x = if c then a else b
label = if score > 90 then "A" else if score > 80 then "B" else "C"
Control Flow
If / Else if / Else
if condition:
body
if condition:
body
else if other_condition:
other_body
else:
fallback
The condition must evaluate to a Boolean. A non-Boolean raises a runtime error.
⚠ TODO:
while … then:andfor … then:— athen:block that runs on normal
loop exit (bypassed bybreak), mirroring Python's loopelse:.
While
while condition:
body
while condition as loop_name:
body
For
The for statement calls iter() on its source and advances through the results. Any
iterable works — sequences, maps, sets, reactive functions (see below).
for x in seq:
body
for i:x in seq: -- i receives the index/key, x receives the value
body
for x in seq, y in other: -- multiple iterators advance together (zip-like)
body
for x in seq as loop_name: -- named loop
body
Break and Continue
break and continue always require the loop label, even when using the default name:
break for -- break the innermost for loop
continue while -- continue the innermost while loop
break my_loop -- break a named loop
continue my_loop
Return
return -- return None
return expr -- return a value
Functions
Defining Functions
def add(x, y):
return x + y
def greet(name):
return "Hello, {name}!"
Functions are first-class objects and can be passed, returned, and stored like any value.
Closures and Snapshot Semantics
Both def and lambdas use snapshot semantics: at definition time, any variables from
enclosing function scopes that are referenced by the body are captured in a shallow copy
of those frames. Snapshotting stops at the module boundary — module-level variables are
not captured. Consequences:
- Functions defined directly at module level capture nothing; they reference module
variables live (by name) each time they run. - Functions defined inside other functions capture their parent function's locals at the
moment of definition. - Nested functions cannot modify a caller's local variables.
- Mutable objects (Maps, Lists) are still shared — only the frame bindings are snapshotted.
- Each closure captures its environment independently, so a loop creating functions
produces distinct closures:
funcs = [<x: x + n> for n in 0..10] -- Each lambda captures a different value of n
-- funcs[3](10) == 13
Doc Strings
Doc strings appear before the def line and use a scroll-like
{| … |} format. Each interior line starts with |; this prefix
is stripped. The first non-blank line is the summary; lines from
the first non-blank subsequent line form the details. Both are
available at runtime on the function object.
{|
| The great Foo function.
|
| Does some really neat things.
| Very Fooey things.
|}
def foo(x, y):
return x + y
foo.summary -- "The great Foo function."
foo.details -- "Does some really neat things.\nVery Fooey things."
The full call stack is also walkable at runtime (each frame records its caller).
⚠ TODO: Doc strings on
typedefinitions.
Lambdas
Lambdas are anonymous functions with the same snapshot-capture semantics as def.
<expr> -- zero-argument lambda
<x: expr> -- one-argument lambda
<x, y: expr> -- two-argument lambda
Calling
f(x, y) -- positional call
f(x, kw=val) -- keyword argument
f(x, y, kw=val) -- mixed
Currying
f{…} returns a new function with arguments pre-bound. Any number of positional or
keyword arguments may be curried at once; curries may be cascaded.
f{x} -- curry one positional arg
f{x, y} -- curry two positional args at once
f{kw=val} -- curry a keyword arg
f{x, kw=val} -- positional and keyword together
f{x}{y} -- cascaded curry — equivalent to f{x, y}
f{x}(y) -- same as f(x, y)
Curried positional args are always safe. Curried keyword args are safe for parameters
passed only by keyword. Positional and keyword curries accumulate and are all passed
together when the function is finally called — the semantics are fully predictable, just
occasionally surprising when mixing styles.
The difference between f{x} and <f(x)> is that the former can accept further
arguments; the latter is a complete zero-argument thunk.
Cascaded Index Calls
f[a, b, c] is syntactic sugar for cascaded calls: f(a)(b)(c). Each argument triggers
a separate call on the result of the previous one.
table[row, col] -- idiomatic: table(row)(col)
table[row][col] -- also valid: chained brackets
f[x, y] -- f(x)(y)
Reactive Iteration
Passing any zero-argument function to iter() creates a reactive iterator: it
yields the function's current value immediately, then re-fires whenever any dependency
of that function changes. Multiple dependency changes in the same async cycle produce
only one re-fire.
Because for calls iter() on its source, any zero-argument function works as a for
source directly:
for v in <x + y>: -- re-fires whenever x or y changes
print(v)
for v in my_func: -- same, with a named zero-argument function
print(v)
Note on module-level variables: Reactive tracking of module-level variables is
partially implemented — accesses are recognised and tracking is set up, but assignments
to module-level variables do not currently funnel through the tracker correctly, so
re-fires may not occur. ⚠ PARTIALCalling a blocking primitive inside a reactive iterator is currently an error.
Types
Declaring a Type
Type definitions edit an existing type if one with that name already exists, rather
than creating a new one. This is essential for live module upgrades: existing objects
retain their property storage intact, field defaults and modes can be updated, but fields
are not deleted if omitted (which allows amending types from other modules--see below).
A bare declaration (no body) is valid and useful for establishing a name before its
fields are defined, enabling mutually recursive types. A type body may also reference
its own type (self-recursive).
type Node -- bare declaration (no fields yet)
type Edge in Node -- declare with supertype
type Node: -- now define fields (edits the existing Node type)
val id
var next_node -- may reference Node (self-recursive)
Type with Fields
type Dog in Animal:
val name -- immutable after construction
val breed = "unknown" -- immutable, class default (any expr, evaluated once at defn time)
var age -- mutable
var score = age * 2 -- mutable, with computed default
one owner -- write-once; immutable once set
dyn rank = <score / 10> -- ⚠ TODO: computed on demand, cached, reactive
Field Modes
| Mode | Mutability | Behaviour |
|---|---|---|
val |
Immutable after construction. | May be unknown. Class default evaluated once at type-definition time. |
var |
Fully mutable. | Writing None restores class default rather than forcing unknown (unless no default). |
one |
Unknown until first write; immutable thereafter. | If a class default exists, field is born immutable. |
weak |
Like var but weak ref. | Silently reverts to unknown on GC. ⚠ TODO |
dyn |
Computed on demand; cached until a dependency changes. | Cascades cleanly: a dyn that depends on another dyn triggers at most one recompute per cycle. ⚠ TODO |
Field defaults may be any expression and are evaluated once at type-definition time.
Supertypes and Amending Types
Multiple inheritance is supported:
type Dog in Animal, Domestic
If supertypes are omitted, they default to just Object for a new type, but existing
type's supertypes are untouched in that case. This means you can re-open a type
any time to add new fields:
type Dog in Animal, Domestic:
val name
val breed
-- then, often in another module:
type Dog: -- This does _not_ clear the supertypes
var age
var owner
var health
-- at this point Dog has name, breed, age, owner, and health fields, and is still
-- a subclass of both Animal and Domestic (inheriting their fields as well).
Types as Constructors
Calling a type as a function constructs a new instance. Keyword arguments only — positional arguments are not supported (field order is not guaranteed to be stable, especially as types evolve).
d = Dog(name="Rex", age=3)
All fields are optional at construction. Omitted fields are left unknown (None), falling back to the type's class default if one exists. Construction always allocates a fresh object — there is no memoization or identity-merging as with Tuples.
Equality and Identity
Equality (==) is identity-based for mutable objects and value-based for
immutable scalars:
| Type | == semantics |
|---|---|
| Numbers, Strings, Datetimes | Value equality |
| List, Map, Set, Object | Identity — equal only if the same object |
Tuple #[…] |
Identity, but same-content tuples are always the same object (memoized) |
⚠ TODO:
===content equality for collections.
Modules
Modules are identified by a UUID and a version number,
declared in #-prefixed header lines at the top of the file. The #
lines are parsed as space-separated key=value blobs (no spaces
around =, no quoting — value extends to the next whitespace).
uuid and version are currently required; version must be a
number (floats allowed, compared by magnitude).
# uuid=550e8400-e29b-41d4-a716-446655440000 version=1.3
Multiple # lines are fine; order and grouping do not matter.
Importing Modules
There are three ways to import a module:
import("foo.largo") -- function call; returns the module object
import foo -- statement form; equivalent, binds result to `foo`
import foo, bar -- import multiple modules at once
from foo import x, y -- import specific names from a module into local scope
The import statement forms are syntactic sugar over the import() function. import foo is equivalent to foo = import("foo.largo"); from foo import x, y imports the module and then binds the named fields locally.
All forms wait for the module to finish executing (or reach its first wait point) before returning. Returns the module object, typically immediately unless it's the first import of that module.
When a module is imported, the already-loaded copy is returned by
default. If the stored version is older than the declared version,
the module is re-executed in its existing frame (module scope),
enabling live incremental upgrades without restarting the system or
invalidating existing data. It is up to the module to handle version
upgrading, typically by cascading checks on a module global "version"
as in:
if version\0 < 1:
-- first version of module...
...
version = 1
if version < 2:
-- upgrade module from version 1 to 2
...
version = 2
...
NOTE, however, that any such module global named "version" is
unrelated to the file revision version in the header. It's sensible
to keep them in sync but that is not enforced.
⚠ TODO:
syntax=header field — allows non-DSL source formats (e.g. a visual
flow-chart editor) to be loaded side-by-side with standard Largo source, provided the
source file can begin with#header lines.⚠ TODO: Crypto signatures for modules.
Exception Handling
⚠ TODO: User-level exception handling syntax is not yet defined.
Internally,
breakandcontinuecompile to raises, and iterators use exceptions for
stop-signalling. The full infrastructure (SxTry, jump tables) is in place; the
user-facingtry/catchsurface syntax is TBD.
Async and Persistence
- The runtime is single-threaded at the Largo level. A task runs until it calls a
primitive that waits. No task switching occurs mid-expression. - Everything lives on disk, always. Checkpoints occur between compiled async steps.
The system survives hard shutdown and resumes exactly where it left off. - Largo's async model is unrelated to Python
async/await; the trampoline runs as a
single Python task.
Spawning Tasks
spawn starts a function in a new, independent task. All three forms below are equivalent:
spawn(func) -- call spawn() with a zero-argument function
spawn func() -- statement form; the expression is implicitly lambda'd: spawn(<func()>)
spawn: -- block form; the block body is made into an anonymous function and spawn()'d
func()
The expression in spawn expr and the body in spawn: follow the same snapshot-capture semantics as def and lambdas: locals from the enclosing function scope are captured at the point of the spawn, but module-level variables are not captured (they are accessed live by name).
spawn currently returns nothing. Task handles and spawn foo as handle syntax are planned.
Sleeping
sleep(seconds) -- suspend the current task for the given number of seconds
Waiting on a Condition
wait until expr -- suspend until expr becomes true
wait while expr -- suspend until expr becomes false
The expression is implicitly wrapped in a lambda and iterated reactively: the task wakes whenever any dependency of the expression changes, re-evaluates, and goes back to sleep if the condition is not yet satisfied. This is exactly equivalent to iterating the lambda with iter() and looping until the condition holds.
wait until foo.x > 10 -- wakes whenever foo.x changes; resumes when foo.x > 10
wait while task.running -- wakes whenever task.running changes; resumes when it's false
Built-in Functions
These functions are defined in the system initialisation file and are available in every module without importing anything.
Output and Formatting
| Function | Description |
|---|---|
print(x) |
Print a value to the console. |
pprint(x) |
Developer-oriented deep formatting of a value, printed to console. Follows object references (including upward/parent ones) so output can be verbose; aimed at debugging, not display. |
pformat(x) |
Same as pprint but returns the string rather than printing it. |
str(x) |
Convert a string or number to a string. For other object types, returns a crude system-level handle description. (Proper dispatch to user-defined string conversions is planned.) |
format_time(t) |
Format a time()-style Unix timestamp as a human-readable string. |
Collections
| Function | Description |
|---|---|
append(list, val) |
Append val to list in place. |
truncate(list, len) |
Truncate (or extend) list to len items in place. |
trunc(list, len) |
Return the first len items of list; returns list unchanged if already shorter. |
length(x) |
Return the length of a List (sugar for x.__length__). |
reversed(seq) |
Return a reversed version of seq. |
list(itr) |
Collect an iterable into a new List. |
set(itr) |
Collect an iterable into a new Set. |
tuple(itr) |
Collect an iterable into a new Tuple. |
sjoin(list, sep='') |
Join a list of strings with sep. |
joinlines(list) |
Join a list of strings with newlines (sjoin{sep='\n'}). |
Iteration
| Function | Description |
|---|---|
iter(x) |
Return an iterator for x. Supported for collections (lists, maps, sets), strings (iterates characters), and zero-argument functions (reactive: yields the current value immediately, then re-fires whenever dependencies change). |
kiter(x) |
Like iter, but yields key:value pairs. Supported for maps and lists (index:value). |
span(a, b) |
Return a Range(start=a, stop=b). Sugar for a..b. |
Tasks and Control
| Function | Description |
|---|---|
spawn(func) |
Spawn func() in a new task. See Spawning Tasks. |
sleep(seconds) |
Suspend the current task for the given duration. |
resume(iterator) |
Advance an iterator, suspending until the next value is ready. |
Modules
| Function | Description |
|---|---|
import(path) |
Import a module, launching it in its own task if needed, and suspend until the module itself waits (or finishes). Returns the module object. This handshake prevents deadlock while still ensuring the module is initialised enough to use. Used by the import statement syntax. |
import_fast(path) |
Import a module, launching it if needed, and return immediately without waiting. The module may still be starting up when this returns. |
Misc
| Function | Description |
|---|---|
raise(err) |
Raise an exception. |
quote(s) |
Currently applies to strings only: returns a repr()-style quoted representation of the string. |
ask_root(prompt) |
Post a question to the root console and suspend until an administrator answers it. Multiple tasks may have open questions simultaneously; the console lists all pending questions and lets the administrator select one to answer. Intended for maintenance and administration, not end-user interaction. |
add_property(type, mode, name, default) |
Add a new property to an existing type at runtime. Equivalent to adding a field line to the original type definition. |
Garbage Collection
Largo's garbage collector is currently mark-and-sweep only. It runs as a separate script and must be invoked manually while the application is not running.
Ref-counting GC (with periodic mark-and-sweep for cycles) is planned but is a significant project and has not yet been implemented.
Planned / Future Features
| Feature | Status |
|---|---|
and, or, xor |
⚠ TODO |
in operator (membership / type / subtype test) |
⚠ TODO |
Chained comparisons x < y < z |
⚠ PARTIAL |
weak field mode |
⚠ TODO |
dyn field mode |
⚠ TODO |
while/for … then: (normal-exit handler) |
⚠ TODO |
User exception handling (try/catch) |
⚠ TODO |
| Unpacking / splats | ⚠ TODO |
| Bulk operations on collections (map, filter, etc.) | ⚠ TODO |
=== content equality |
⚠ TODO |
Memoized maps #[a=1], memoized sets #{…} |
⚠ TODO |
type deletes removed fields on re-definition |
⚠ TODO |
syntax= module header field |
⚠ TODO |
| Permissions system for tasks / objects | ⚠ TODO |
| Crypto signatures for modules | ⚠ TODO |
Doc strings on type definitions |
⚠ TODO |
| Shell-like syntax for exec | ⚠ TODO |
Task handles / spawn foo as handle |
⚠ TODO |
| Ref-counting garbage collector | ⚠ TODO |
Nested for clauses in comprehensions |
⚠ TODO |
| Operator overloading / multiple dispatch | ⚠ TODO |
Frame-shift for loop (var1 -> var2) |
⚠ Maybe |
Planned Major Architectural Changes
Frames should be objects, not maps. This would give local
variables all the features of fields such as mode (val/var/one/
weak/etc) and tracking. Furthermore "functions" at this point become
constructors of the function's implied type signature, and the
difference between a function and a type constructor is simply that
the latter returns 'this' (self). Absent an explicit function, the
default constructor is simply to send keyword args to locals and then
return self (current type constructor); but now this becomes truly
just a default function body rather than a hard-coded dispatch, which
facilitates fully custom constructors. Note that 'val' slots would
need to be handled slightly differently: Currently you simply cannot
assign them outside of a constructor; instead we would need to throw
an error if they are accessed before being set (which unfortunately
begs for an assertive None). Likely this shift would make
non-snapshotted spawn blocks more sensible. Particularly if we intro
a "fork" which allows you to write multiple blocks which will eval in
parallel (launched in order) and meet up at the end (like a trio
nursery context) -- with well-defined local variable modes this
becomes more sound. Also this cleans up some current gross hacks
(None guarding in frame parent chain...).
Strings should be interned, to avoid the current rampant
proliferation of huge strings on the db. For '' strings, this would
be transparent at the largo level, but would take some doing under
the hood. A new, sigiled db type would be made for interned strings,
like Map, List and so on. Most of the code actually wouldn't
change that much because interned string Objects should work just
fine most of the places raw strings are currently used (field names,
map indices, etc), and if __str__ returns the string then that
covers many more places. The main change would be everywhere a
string is entered, whether constants or calculated, it would need to
be interned first, which will be a fair number of places... Also,
comparisons of constants will need to either call str() or intern the
contstant (e.g., field mode is currently a string; arguably this
should be made into a type anyway?). The intern table should be
defined with column order (string, oid) with an extra index on oid,
to avoid duplicating the strings (which may be on occasion huge).
But then for "" strings:
Mutable Strings should be returned by "" construction. Under the
hood these should be lists of interned and other mutable strings
(forming a tree), and should track their total length? The main
function of this is to make string arithmetic more efficient.
Interpolated strings end up just returning a list of parts (with no
replication of their interned components in the db); appending things
to a (mutable) string becomes very cheap. String concatenation is
essentially return [a, b] (plus sum of lengths). Then finally an
intern() call can traverse the tree (efficiently in python) to
assemble a single large string entered into the interned table when,
and only when, needed. Immediate interning of "" strings could be
done with #"foo{bar}" syntax, consistent with # as the general
interning/memoizing operator. Semantically, a sum including mutable
strings would mutate with them. So, e.g., s = '(' + x + ')' where
x is a mutable string would return a string, s, that would change
when you change x (here always being a parenthesized version of x).
Caller would have to decide when to treat strings as immutable by
convention vs using intern to snapshot. Alternately we could do
most of the above but disallow in-place mutation, giving immutable
strings that are simply more efficient arithmetically (important in
the context of disk-backed memory) which would be nearly transparent
from the largo perspective except that memoization would not work for
one type of string but would for another, which could be confusing.
Mutability actually makes this cleaner because it perfectly aligns
with the current mutable/immutable identity norm (immutables:
identity = value; mutables: identity = handle). [Side note: We
need to consider whether '#' should just become a normally dispatched
operator. Currently it is handled directly by the compiler for Tuples,
which allows Tuples to compile ahead of time, which is important to
retain. But we could try to handle constant propagation better in
the compiler so that, e.g., #[1, 2, 3] first computes the List and
then notices the List is a constant and so calls # during compilation;
a notion which could be extended to all operators [to be] flagged as
deterministic. Then, e.g., #"foo{10^5+72}" would theoretically be
computed and interned as a constant string at compile time... And
also syntactically, #mylist would work just as well as #[1, 2, 3]
whereas currently we have to call tuple(mylist). Arguably, more
simply for now, if we get the esrapy precedence right we could
make # an operator and just special-case #[] at compile time.]
Return should be re-implemented as an exception, perhaps
hard-coded at the function boundary to avoid populating jump tables
everywhere (if we switch to jump tables tagged on calls; if we stay
with the current jump table approach it doesn't matter much). Mainly
this makes implementation of try/finally much simpler. This would be
transparent within largo except for opening up the possibility of
intentionally catching returns.
Shell syntax could be added for invoking external applications.
But not using an external shell--argument parsing should be done at
compile time, so no worries about spaces in strings and such. E.g.,
something like:
$ tail -n {num} {filename}
should send the last num (a local largo variable) lines of the file
named by filename to stdout. And potentially something like this to
capture the output:
last_lines = $(tail -n {num} {filename})
If captured output (async) iterated lines by default, then this sort
of thing would work out of the box:
for line in $(tail -F {filename}):
print("New line: {line}")
Obviously shell ability would be restricted. Imported modules would
not be allowed to do this by default. But it could make largo into
a pretty handy sysadmin scripting language, given the persistence,
permissions (todo), and change tracking (which can and will be
extended to tracking file changes and such).
Largo Cheat Sheet
⚠ TODO = not yet implemented (compile-time error) · ⚠ PARTIAL = incomplete · ⚠ TBD = syntax undecided
Comments
-- line comment
(| block comment — (| nestable |) |)
Literals
42 3.14 1.5e10 -- numbers (whole floats become ints)
'plain \n no interpolation' -- single-quoted; backslash escapes
"format string {expr}" -- double-quoted; {} interpolates
{{ }} -- literal braces in format strings
$'it's fine'$ -- guarded: no escape, quotes safe
$"embed {expr} and "quotes""$ -- guarded format string
$$$'triple-guarded'$$$ -- any matching number of $
Multi-line: any string auto-dedents to longest common leading whitespace of interior
lines. Closing delimiter on its own indented line → trailing \n.
In format strings, a {} preceded by whitespace indents multi-line values to the
column of {.
Collections
[] -- empty List
[1, 2, 3] -- List (mutable)
[:] -- empty Map
[a=1, b=2] -- Map, field-name keys
["k": v] -- Map, expression key
[a=1, "k": v, x+1: w] -- field-name and expression keys freely mixed
#[] -- empty Tuple
#[1, 2, 3] -- Tuple (immutable, memoized — content = identity)
{} -- empty Set
{1, 2, 3} -- Set (mutable)
For Maps, field access is shorthand for string indexing: m.foo == m['foo'].
⚠ TODO: Object literal
{a: 1, b: 2}. Immutable memoized maps#[a=1]and sets#{…}.
Comprehensions
[expr for x in seq] -- List comprehension
[key: val for x in seq] -- Map comprehension
{expr for x in seq} -- Set comprehension
[expr for x in seq if cond] -- filtered (works for all three types)
[i:x*2 for i:x in seq] -- index/key capture
[x+y for x in a, y in b] -- zip two iterables
Variables and Assignment
x = expr -- local assignment (right-associative)
x = y = 0 -- chained, right-to-left
obj.field = expr -- field assignment
obj[key] = expr -- index assignment
(x = expr) -- assignment as sub-expression (parenthesize)
⚠ TODO:
:=in-place mutation.
Operators — Precedence (high → low)
| # | Operator | Notes |
|---|---|---|
| 1 | f(args) |
call |
| 1 | f{args} |
curry → new function |
| 1 | f[a,b] |
cascaded calls: f(a)(b) — f[a,b] idiomatic, f[a][b] also valid |
| 1 | x.field |
field access (unknown if unset; error if field doesn't exist on known type) |
| 1 | x? |
is x known (non-None)? → bool; false? is true |
| 1 | x! |
wait until x is known, then return it (useful for one fields) |
| 2 | a \ b |
default: a if known, else b |
| 3 | a ^ b |
exponentiation (right-assoc) |
| 4 | -x |
unary negation |
| 5 | * / // % |
mul / div / int-div / mod |
| 6 | + - |
add / subtract |
| 7 | a..b a... ...b |
range [a,b), [a,end), [0,b) |
| 8 | in |
membership / type test ⚠ TODO |
| 9 | < <= > >= == != |
comparison; chainable (ascending or descending) ⚠ PARTIAL |
| 10 | not |
logical not |
| 11 | and |
short-circuit and ⚠ TODO |
| 12 | or xor |
short-circuit or / xor ⚠ TODO |
| 13 | if…then…else |
ternary (right-assoc): if c then a else b |
| 14 | = := |
assignment (parenthesize as sub-expr) |
Map/index access returns unknown (not error) for missing keys or unknown base objects.
Conditionals
if cond:
body
if cond:
body
else if cond2:
body2
else:
fallback
Loops
while cond:
body
while cond as name: -- named loop
for x in seq:
body
for i:x in seq: -- i = index/key, x = value
body
for x in seq1, y in seq2: -- zip-like
body
for x in seq as name:
body
break for -- label always required (default: "for" or "while")
continue while
break name
⚠ TODO:
while cond then:/for x in seq then:— runs on normal exit, skipped bybreak.
Reactive Iteration
for calls iter() on its source. Any zero-argument function used as a source
becomes a reactive iterator: fires immediately, then re-fires whenever a dependency
changes. Multiple changes in one async cycle fire once.
for v in <x + y>: -- lambda source; re-fires when x or y changes
print(v)
for v in my_func: -- named zero-arg function
print(v)
⚠ PARTIAL: Reactive tracking of module-level variables is set up but assignments don't
currently funnel through the tracker — re-fires on module var changes may not occur.
Calling a blocking primitive inside a reactive iterator is currently an error.
Functions
def add(x, y):
return x + y
{|
| One-line summary.
|
| Optional details, available at runtime.
| foo.summary / foo.details
|}
def foo(x):
...
Nested functions and lambdas use snapshot semantics: enclosing function locals are
shallow-copied at definition time. Snapshotting stops at the module boundary — module-level
variables are referenced live, not captured. Caller locals cannot be modified.
⚠ TODO: Doc strings on
typedefinitions.
Lambdas
<expr> -- zero-arg lambda (also a reactive iterator source)
<x: expr> -- one-arg
<x, y: expr> -- two-arg
Calling and Currying
f(x, y) -- positional call
f(x, kw=val) -- keyword arg
f[x, y, z] -- cascaded calls: f(x)(y)(z) — f[x][y][z] also valid
f{x} -- curry: f with x prepended
f{x, y} -- curry two args at once
f{kw=val} -- curry keyword arg
f{x, kw=val} -- mixed
f{x}{y} -- cascaded curry == f{x, y}
f{x}(y) -- == f(x, y)
f{x} vs <f(x)>: the curry accepts more args later; the lambda is a zero-arg thunk.
Types
type Foo -- bare declaration (useful for mutual recursion)
type Foo in Bar -- single inheritance
type Foo in Bar, Baz -- multiple inheritance
type Dog in Animal:
val name -- immutable after construction
val breed = "unknown" -- immutable, class default (any expr; evaluated once)
var age -- mutable
var score = age * 2 -- mutable, computed default
one owner -- write-once
weak ref -- weak reference; reverts to unknown on GC ⚠ TODO
dyn rank = <score / 10> -- computed, cached, reactive ⚠ TODO
d = Dog(name="Rex", age=3) -- construct with keyword args only
Type definitions edit existing types (safe for live upgrades; preserves object data).
| Mode | Mutability |
|---|---|
val |
Set at construction; immutable thereafter |
var |
Fully mutable; writing None restores class default |
one |
Unknown until first write; immutable after |
weak |
Like var but weak reference; silently reverts to unknown on GC ⚠ TODO |
dyn |
On-demand, cached, reactive ⚠ TODO |
Equality
| Type | == semantics |
|---|---|
| Numbers, Strings, Datetimes | Value equality |
| List, Map, Set, Object | Identity (same object only) |
Tuple #[…] |
Identity, but same-content tuples are always the same object |
⚠ TODO:
===content equality for collections.
Async and Tasks
spawn(func) -- spawn func() in a new independent task
spawn func() -- statement form (implicitly lambda'd)
spawn: -- block form
func()
sleep(seconds) -- suspend current task
wait until expr -- suspend until expr is true
wait while expr -- suspend until expr is false
spawn captures enclosing function locals by snapshot; module-level variables are
accessed live. Task handles and spawn foo as handle are planned (⚠ TODO).
Modules
# uuid=550e8400-e29b-41d4-a716-446655440000 version=1.3
Header # lines contain space-separated key=value blobs. uuid and version
(numeric, ordered) are required. Re-importing a higher version re-runs the module
in its existing frame — live upgrade, no data loss.
import("foo.largo") -- function call; returns module object
import foo -- statement form; binds result to `foo`
import foo, bar -- import multiple modules
from foo import x, y -- import specific names into local scope
import_fast(path) -- import without waiting for module init
⚠ TODO:
syntax=header field, crypto signatures.