Lcl · Core · Packages · Libraries
Syntax, data types, scoping, control flow, namespaces, and metaprogramming, with examples the test suite executes.
This is the language manual. Every example written as
>> below is run by the test suite: the line after a
>> command is the repr of its result
(strings print double-quoted, lists as (...), dicts as
#{...}), and !! names an error the command
must raise. Fenced blocks are illustrative only. The overview has the short “coming from Tcl” list;
Embedding covers the C API.
Strings are the fundamental type. Bare numeric literals are typed at compile time; quoting or bracing a number means “this is text”.
Examples:
>> let s "hello world"
>> type $s
"string"
>> type 42
"int"
>> type 3.14
"float"
>> type "42"
"string"
>> type 007
"string"007 is a string because it is a non-canonical spelling
(see Numeric Literals under Syntax Reference). Numeric contexts still
accept numeric text – textual data entering the numeric domain
– without changing the value’s own type:
Examples:
>> + 1 "007"
8
>> type "007"
"string"Lists have a constructor and a literal syntax; () is the
empty list. {a b c} is not a list – it is a
five-character string.
Examples:
>> let lst [list a b c d e]
>> (a b c d e)
("a" "b" "c" "d" "e")
>> ()
()
>> len $lst
5
>> get $lst 2
"c"
>> len {a b c}
5Dictionaries likewise: dict or the #{...}
literal, #{} for the empty dict.
Examples:
>> let d [dict name "Alice" age 30]
>> #{name "Alice" age 30}
#{"name" "Alice" "age" 30}
>> #{}
#{}
>> get $d name
"Alice"
>> has? $d age
1These operations work across multiple types. len is the
length/size of a list, dict, string, or namespace (its number of
bindings); get accesses by index, key, or binding name,
with an optional fallback when the key is absent.
Examples:
>> len (1 2 3)
3
>> len #{a 1 b 2}
2
>> len "hello"
5
>> get (a b c) 0
"a"
>> get #{k v} k
"v"
>> get "hello" 1
"e"
>> get #{k v} missing fallback
"fallback"put and del are functional updates: they
return a new value and leave the original alone. On a dict,
del of an absent key is a no-op; on a list, an out-of-range
index is an error.
Examples:
>> put (a b c) 0 replaced
("replaced" "b" "c")
>> put #{a 1} newkey value
#{"a" 1 "newkey" "value"}
>> del #{a 1 b 2} a
#{"b" 2}
>> del (a b c) 0
("b" "c")
>> del (a b c) 9
!! out of rangeput! and del! make the same updates in
place, on a var. They are special forms: the first word is
the variable’s name, not its value. They are O(1)-ish instead
of copying the container, so use them when accumulating.
Examples:
>> var gd #{}
>> put! gd k v
#{"k" "v"}
>> del! gd k
#{}
>> var gl (1 2 3)
>> put! gl 0 x
("x" 2 3)
>> del! gl 2
("x" 2)has? is a membership test (deep equality for list
elements, key presence for dicts and namespaces, substring for strings)
and empty? checks for emptiness.
Examples:
>> has? (a b c) b
1
>> has? #{a 1} a
1
>> has? "hello" "ell"
1
>> empty? ()
1
>> empty? (1)
0Values are copy-on-write, so set! x [List::push $x v]
copies the list on every iteration – the variable still owns the old
list while List::push
runs, and a value-returning operation cannot mutate what someone else
owns. The ! forms (List::push!, List::pop!,
put!, del!) resolve the name to its
var cell and update the container in place when the cell is
its sole owner (cloning once if it was aliased). The result is exactly
what the copying form would have produced; only the cost differs. Like
set!, they reject a value that would make the cell
reference itself, and they require a var:
Examples:
>> let frozen (1)
>> List::push! frozen 2
!! expected cellExamples:
>> list? (1 2)
1
>> dict? #{}
1
>> string? "hi"
1
>> number? 42
1
>> proc? [lambda {} { 1 }]
1
>> cell? [ref 0]
1number? asks “numerically interpretable?” (a number, or
numeric text); int? and float? inspect the tag
itself.
Examples:
>> number? "007"
1
>> int? 42
1
>> int? "42"
0
>> float? 3.14
1Type-specific operations are organized into namespaces: List, Dict, and String (each has its own
reference page).
Examples:
>> List::push (a b) c
("a" "b" "c")
>> List::pop (a b c)
("a" "b")
>> var acc ()
>> List::push! acc newitem
("newitem")
>> List::pop! acc
"newitem"
>> $acc
()
>> List::reverse (a b c)
("c" "b" "a")
>> List::slice (a b c d e) 1 3
("b" "c")
>> List::concat (a b) (c d)
("a" "b" "c" "d")List::push returns a
new list; List::push!
appends in place to the var and List::pop! removes the last
element in place and returns it.
Examples:
>> Dict::keys #{a 1 b 2}
("b" "a")
>> Dict::values #{a 1 b 2}
(2 1)
>> Dict::merge #{a 1} #{b 2 a 3}
#{"b" 2 "a" 3}Dict order is not insertion order; keys and values are returned in the same (internal) order as each other.
Examples:
>> String::upper "hello"
"HELLO"
>> String::lower "HELLO"
"hello"
>> String::find "hello" "ll"
2
>> String::replace "hello" "l" "L"
"heLLo"
>> String::split "a,b,c" ","
("a" "b" "c")
>> String::join (a b c) "-"
"a-b-c"Lex::commands reads text with the language’s own reader
without evaluating any of it, and returns the lexical structure: a list
of #{line N words (...)} records, one per statement. Each
word record carries text (the decoded literal value:
escapes processed, quotes/braces stripped), dynamic (1 when
evaluation would compute part of the word – a $var or
[subcommand] piece, including () and
#{} literals), the
quoted/braced/expand flags, and
span – the word’s half-open (start end) byte
range in the input.
Examples:
>> let lr [Lex::commands {ls -lsa}]
>> let lhead [get [get [get $lr 0] words] 0]
>> get $lhead text
"ls"
>> get $lhead dynamic
0
>> get $lhead span
(0 2)
>> String::range {ls -lsa} @[get $lhead span]
"ls"Spans let tooling recover any word’s original bytes verbatim
(quoting, escapes, and @ intact) with String::range, instead
of re-quoting decoded text; quoting style is semantic in Lcl
(while dispatches on braced-ness, bare identifiers on
quoting/shape), so slicing is the safe way to re-emit source. Dynamic
words have text "" plus a pieces list
(#{kind lit text ...} / #{kind var name ...} /
#{kind sub}) describing why. Malformed input errors with
the compiler’s message.
Examples:
>> let lw [get [get [get [Lex::commands {puts $x}] 0] words] 1]
>> get $lw dynamic
1
>> get $lw pieces
(#{"kind" "var" "name" "x"})
>> Lex::commands {puts "unterminated}
!! unmatchedThis exists so embedders can classify interactive text – Lcl call, external command, shell handoff – against the real grammar instead of a lookalike parser.
Proc::name, Proc::params, Proc::body and Proc::origin are Lcl’s
version of reflection. Optional parameters are written
(name default) and a *rest parameter collects
the remaining arguments.
Examples:
>> proc padd {a b (c 0) *rest} { + $a $b $c }
>> Proc::params padd
("a" "b" "?c" "*rest")
>> Proc::name padd
"padd"
>> Proc::body padd
" + \$a \$b \$c "
>> Proc::origin {+}
#{}Proc::origin of an Lcl proc is
#{file F line N}; a C proc has none. proc,
lambda and macro return the value they build,
and a proc value prints as <proc add>,
<lambda> or <macro m> – a label,
not re-parseable code. A REPL can therefore hand a fresh definition
straight to the next command:
Examples:
>> proc pfix {x} { * $x $x }
<proc pfix>
>> lambda {x} { + $x 1 }
<lambda>
>> List::map (1 2 3) [proc psq {x} { * $x $x }]
(1 4 9)Three tools for finding out where the time and the copies go.
Interp::stats returns process-wide counters (always on, one
increment each): values_live is the number of Lcl values
currently allocated, list_clones and
dict_clones count copy-on-write copies of shared
containers.
Examples:
>> Dict::keys [Interp::stats]
("list_clones" "values_live" "dict_clones" "values_freed" "values_allocated")A list_clones count that grows with your data is an
accumulation loop that should use
List::push!/put!; values_live
climbing between two identical frames is a leak.
Time::profile (package lcl-time) reports
per-proc calls and inclusive/exclusive time:
let rows [Time::profile { run_frame }]
puts [Time::profile_format $rows]
;; calls incl_us excl_us name
;; 1492 61230 61230 update_enemy
;; 1 66310 2100 run_frame
Time::profile_start! ;; or bracket a region from outside
step
let rows [Time::profile_stop!]Exclusive time is inclusive time minus the time spent in user procs called from the body, so C builtins land in the proc that called them; tail self-calls count as one call.
Every proc call can also be traced from the command line:
LCL_TRACE=1 lcl game.lcl # every user proc, entry with args + exit
LCL_TRACE=Vec::norm,update lcl ... # only these namesBoth are built on lcl_set_call_hook (see Embedding), so
a host can install its own profiler or tracer.
The optional Bench library (lib/bench,
-DLCL_BUILD_BENCH_LIB=ON, needs lcl-time) is
the Test library’s shape applied to timing:
Bench::suite "list" {
Bench::setup { let items [List::range 0 1000] } ;; once per case, untimed
Bench::case "push copying" { var out (); foreach x $items { set! out [List::push $out $x] } }
Bench::case "push!" { var out (); foreach x $items { List::push! out $x } }
Bench::expect_faster "push!" "push copying" 5 ;; checked after measuring
}
exit [Bench::run] ;; or Bench::run #{filter push min_ms 200 batches 5 profile 1 save bench/baseline.lcl}Each body is compiled once and run in batches through
repeat, in a fresh frame that closes over the setup
bindings; batch sizes double until a batch takes min_ms. A
row reports the median and minimum ns/iteration over the batches, the
copy-on-write clones per iteration and the live values a batch leaves
behind (both from Interp::stats), and the change against a
baseline loaded with Bench::baseline:
Bench: list
push copying x1000 1543.2 us min 1520.1 us clones/iter 1000.0 live 0 +2.1% vs baseline
push! x1000 101.7 us min 99.8 us clones/iter 0.0 live 0 -98.1% vs baseline
ok: push! x1000 (101.7 us) vs push copying x1000 (1543.2 us)
The repo’s workloads live in bench/
(lcl bench/run.lcl --baseline bench/baseline.lcl,
--smoke for a quick pass, --profile for a
Time::profile table under each row,
--save file to record a new baseline);
bench/BASELINE.md keeps the history. ctest
runs the workloads once in smoke mode so they cannot bit-rot; only the
copy-vs-in-place ratios are asserted, never absolute times.
The optional Doc library (lib/doc,
-DLCL_BUILD_DOC_LIB=ON) is documentation tooling written in
pure Lcl on top of Lex::commands: it extracts doc comments
with the language’s own reader, so nothing that merely looks
like a doc comment inside braced data ever leaks into the docs. This
manual is built with it.
A doc comment is a run of ;;; lines. A run immediately
above a proc, macro, namespace,
let, or var documents that definition; a run
separated by a blank line is module documentation. Names starting with
_ are private and skipped. There are no
@param-style tags – signatures come from the source’s own
word records. The first line is the summary, the rest is markdown, and
Examples: sections are executable:
;;; Squares a number.
;;;
;;; Examples:
;;; >> M::sq 7
;;; 49
;;; >> M::sq many
;;; !! expected number
proc sq {x} { * $x $x }Each >> line is a command; the line after it is
the expected repr of its result (so string results are
written quoted: "hi", not hi – examples stay
honest about list-vs-string). !! expects an error
containing the given text; no expectation just checks the command runs.
Examples within one doc block share a scope, so a
>> let x ... line can feed later lines.
let m [Doc::extract $src] ;; source text -> doc model
puts [Doc::markdown $m "mymod"] ;; doc model -> markdown
Doc::report [Doc::doctest $m] ;; run all Examples, print failuresDoc::extract_file
/ Doc::markdown_file
/ Doc::doctest_file
are file-reading variants (they need the lcl-io package).
Doc.lcl documents itself with its own format, and its test
suite runs its own doctests.
Docs also attach to live symbols, for help in a REPL or
an editor:
puts [Doc::describe M::sq] ;; signature, origin, doc text
Doc::register_file docs/String.lcl ;; companion docs for C procs...
puts [Doc::describe String::length] ;; ...now describe finds them
Doc::search "square" ;; entries matching name or textDoc::lookup
matches an Lcl proc by its Proc::origin file and line
(reading the file on first use) and anything else by qualified name
against registered sources; Doc::register
indexes source the host owns, such as an editor buffer, and
eval_at gives code evaluated from that buffer the matching
origin.
if takes a value (not a block), Scheme-style:
the condition is [...], not {...} (a braced
condition is a non-empty string, i.e. always true). Unlike Tcl there is
no elseif/elsif; nest if instead.
The branches are bodies, so a bare word inside one is a command, not
data – quote data.
Examples:
>> if [< 1 2] { "yes" } else { "no" }
"yes"
>> let cx -4
>> if [< $cx 0] { "negative" } else { if [== $cx 0] { "zero" } else { "positive" } }
"negative"
>> if 0 { red } else { blue }
!! unknown command: blue (a bare word is a command; quote it to use it as a value)repeat runs a body n times (compiled once;
break/continue/ return work as in
while; the last body value is returned).
Examples:
>> var hits 0
>> repeat 3 { set! hits [+ $hits 1] }
3cond is a multi-branch conditional (like Scheme/Lisp):
it evaluates tests in order and takes the first truthy clause’s branch.
else is the default clause; without one, no match is an
error. A branch follows the same rule as an if body: a
braced branch is code (run in the enclosing scope;
break, continue and return work,
and a self-call in it is a tail call), while any other word is an
expression whose value is the result – "text",
42, $x, [+ $a $b]. Bare words
inside braces are commands, so write a plain value branch as
"negative", not {negative}.
Examples:
>> let cv 5
>> cond [< $cv 0] "negative" [== $cv 0] "zero" else "positive"
"positive"
>> cond [< $cv 0] { "negative" } else { let d [* $cv 2]; "big $d" }
"big 10"
>> cond [< $cv 0] "negative"
!! no matching clause
>> cond [> $cv 0] { positive }
!! unknown command: positivecase is value dispatch (like switch/match): it evaluates
the scrutinee once, compares it against each key with ==,
and takes the matching branch. Keys are evaluated (so
$variables work) and a braced key is a literal string;
branches follow the cond rule above.
Examples:
>> let op "sub"
>> case $op {add} [+ 5 3] {sub} [- 5 3] {mul} [* 5 3] else "unknown op"
2
>> case "pow" {add} [+ 5 3] else "unknown op"
"unknown op"
>> case $op {add} { let k 5; + $k 3 } {sub} { let k 5; - $k 3 }
2while re-evaluates a braced condition each iteration;
for takes init, condition, step and body.
Examples:
>> var wi 5
>> while {$wi} { set! wi [- $wi 1] }
0
>> var fsum 0
>> for {var j 10} {$j} {set! j [- $j 1]} { set! fsum [+ $fsum $j] }
>> $fsum
55foreach iterates values, never text: a list’s elements,
a dict’s (key value) pairs, or a string’s characters.
foreach x {a b c} is an error – write
(a b c).
Examples:
>> var seen ()
>> foreach item (a b c) { List::push! seen $item }
>> $seen
("a" "b" "c")
>> var pairs ()
>> foreach kv #{a 1 b 2} { List::push! pairs "[get $kv 0]=[get $kv 1]" }
>> $pairs
("b=2" "a=1")
>> var chars ()
>> foreach ch "abc" { List::push! chars $ch }
>> $chars
("a" "b" "c")
>> foreach x {a b c} { $x }
!! is text, not a listbreak and continue work as expected:
Examples:
>> var kept ()
>> foreach x (1 2 3 4 5) { if [== $x 3] { continue }; if [== $x 5] { break }; List::push! kept $x }
>> $kept
(1 2 4)Thread a value through a series of operations. ->
inserts the value as the first argument of each step,
->> as the last.
Examples:
>> -> #{key "abc"} {get key} {String::upper}
"ABC"
>> ->> 3 {- 10}
7
>> -> #{a 1 b 2 c 3} {put d 4} {del a}
#{"b" 2 "c" 3 "d" 4}The first is String::upper [get #{key "abc"} key]; the
second is - 10 3. Steps can be lambdas held in variables or
written inline:
Examples:
>> let inc [lambda {x} { + $x 1 }]
>> -> 10 {$inc} {$inc}
12
>> -> 10 {[lambda {x} { + $x 100 }]}
110Lcl namespaces work very differently from Tcl. In
Lcl, namespaces are first-class module values created
with a builder pattern. This is closer to ML modules or Scheme libraries
than Tcl namespaces. All definitions inside the block use
unqualified names; members are accessed with ::.
Qualified variable substitutions are always braced:
${geom::pi}, never $geom::pi (command heads
like geom::double need no braces – they are already whole
words).
Examples:
>> namespace geom {
>> let pi 3.14159
>> proc double {x} { + $x $x }
>> var counter 0
>> proc increment {} { set! counter [+ $counter 1]; $counter }
>> }
<namespace geom>
>> ${geom::pi}
3.14159
>> geom::double 21
42
>> geom::increment
1
>> geom::increment
2
>> len $geom
4
>> get $geom pi
3.14159Key differences from Tcl:
namespace eval does not exist.
Namespaces are values and more like modules, not evaluation blocks that
change scope. The named form desugars to a let of an
anonymous namespace:namespace foo {
proc bar {} { ... }
}
;; This desugars to:
let foo [namespace {
proc bar {} { ... }
}]namespace. You cannot write
proc geom::double {x} {...} at the top level; it is a hard
error. Lcl has lexical scoping, and qualified definitions outside
namespace bring up all kinds of unsound lexical scope
issues.Examples:
>> proc geom::triple {x} { * $x 3 }
!! qualified name not allowed herenamespace on an existing namespace gives access to its
bindings and allows adding new ones.Examples:
>> namespace utils { let x 1 }
>> namespace utils { let y [+ $x 1] }
>> "${utils::x} ${utils::y}"
"1 2"namespace creates a binding in the parent.Examples:
>> namespace outer {
>> let x 1
>> namespace inner {
>> let y 2
>> proc greet {} { return "hello" }
>> }
>> }
>> ${outer::inner::y}
2
>> outer::inner::greet
"hello"Examples:
>> namespace a::b::c { let deep 42 }
>> ${a::b::c::deep}
42set!.Examples:
>> namespace counter {
>> var n 0
>> proc inc {} { set! n [+ $n 1]; $n }
>> proc dec {} { set! n [- $n 1]; $n }
>> }
>> counter::inc
1
>> counter::inc
2
>> counter::dec
1$foo::bar
is a compile error, ${foo::bar} works. For a plain variable
both $baz and ${baz} work.Examples:
>> namespace foo { let bar 42 }
>> $foo::bar
!! qualified substitutions require braces
>> ${foo::bar}
42
>> let baz 777
>> ${baz}
777This was done because it significantly reduces the complexity of the lexer and avoids issues with variable substitution when interoperating with shells.
let foo-bar 42,
proc empty? ..., even operators like + –
binding forms do not own separate name grammars, so
proc f? ... and let f? [lambda ...] are the
same thing. Reference names are what braced substitution
${name} accepts: segments starting with a letter or
_, continuing with letters, digits, _,
-, ?, !, joined by
::. The grammar is lexical, not stylistic –
${foo--bar} and ${a!?b} are valid; typography
is your call. Bare names $name are a
POSIX-shell-shaped shorthand (letters/digits/_ only, one
segment); -, ?, ! end a bare
name, always. Operators are binding names but not reference names: reach
+ with apply or getvar.Examples:
>> let max-len 42
>> ${max-len}
42
>> "$max-len"
!! undefined variable "max"
>> let done? 1
>> ${done?}
1
>> proc add-one {n} { + $n 1 }
>> apply ${add-one} 41
42
>> ${+}
!! invalid character in variable nameThe import command copies bindings from a namespace into
the current scope, allowing you to use them without qualification – all
of them, or a named subset.
Examples:
>> namespace circle { let tau 6.28318; proc square {x} { * $x $x } }
>> import circle
>> $tau
6.28318
>> square 5
25
>> namespace abc { let ia 1; let ib 2; let ic 3 }
>> import abc ia ic
>> $ia
1
>> $ic
3
>> $ib
!! undefined variableKey behaviors:
Examples:
>> namespace tally { var n 0; proc incr {} { set! n [+ $n 1] } }
>> import tally n incr
>> incr
1
>> $n
1
>> ${tally::n}
1Examples:
>> let taken 1
>> namespace ns { let taken 99 }
>> import ns taken
!! already exists in current scopeExamples:
>> namespace outer2 { namespace inner { let deep 42 } }
>> import outer2::inner deep
>> $deep
42Examples:
>> namespace helpers { proc helper {} { 42 } }
>> namespace app { import helpers helper; proc run {} { helper } }
>> app::run
42applyLcl’s dispatch rule is a deliberate departure from Tcl that is worth calling out explicitly. Tcl users in particular should read this section before being surprised by the behavior.
The rule: A one-word command (a subcommand
[word] or a standalone statement)
dispatches only when its sole word is a bare
identifier – a name like foo with no
$, no [...], no braces, no quotes, no special
syntax. Every other form – $var, [expr],
{lit}, (list), #{dict}, literal
numbers, multi-piece concatenation – is a value form,
and yields its value when used as a one-word command. No command lookup
is attempted.
Examples:
>> let val "GET"
>> [$val]
"GET"
>> let clo [lambda {} { 42 }]
>> proc? [$clo]
1
>> [42]
42
>> [{hello world}]
"hello world"Bare identifiers dispatch, and an unknown bare identifier is an error, never a silent fallback to its own text. Bare words meant as data must be value forms – quoted or braced.
Examples:
>> proc GET {} { return "called" }
>> [GET]
"called"
>> [nosuchcmd]
!! unknown command: nosuchcmd
>> if 1 { "red" } else { "blue" }
"red"
>> if 1 { red } else { blue }
!! unknown command: red (a bare word is a command; quote it to use it as a value)To dispatch a value form (a closure held in a variable, or a command
name stored as a string), use apply. The @
operator spreads a list as arguments.
Examples:
>> apply $clo
42
>> apply "GET"
"called"
>> apply {+} 1 2 3
6
>> proc sum3 {a b c} { + $a $b $c }
>> let sargs (10 20 30)
>> apply $sum3 @$sargs
60Why this design?
Tcl’s one-word dispatch rule is runtime-decided: at the head
of a one-word program, Tcl evaluates the word, then looks the result up
as a command. If the value’s string form happens to match a registered
command, Tcl dispatches. This is convenient – eval $cmd
works for stored command names – but it leaks. Anywhere a value’s string
form might coincide with a proc name (cached values, macro template
substitutions, anaphoric conditions), Tcl will silently dispatch instead
of returning the value. The same source line can mean two different
things depending on data flowing through it.
Lcl makes the dispatch decision at parse time. The
shape of the source determines whether a one-word command dispatches;
the data flowing through it doesn’t. A $var is always a
variable lookup, never a call. An [expr] always yields
whatever expr returned. The Tcl idiom of dispatching a
stored name survives via eval $cmd – eval
compiles $cmd’s value as source, and a one-word source like
GET is a bare identifier that dispatches. Use
apply when you want value-dispatch instead.
apply vs eval – these are
complementary, not overlapping:
eval str is source-evaluation: compile
str as Lcl source and run it.apply value args… is value-dispatch: take a
callable value and call it.apply resolves:
apply doesn’t carry);macroexpand
+ eval for programmatic macro use);Examples:
>> eval "GET"
"called"
>> apply {if} 1 {2}
!! cannot apply special form
>> macro twice {x} { quasiquote { * 2 ,$x } }
>> apply $twice 21
!! cannot apply macro
>> macroexpand twice 21
" * 2 21 "
>> eval [macroexpand twice 21]
42
>> apply 42
!! expected callableTcl-to-Lcl migration cheat sheet:
| Tcl | Lcl |
|---|---|
eval $cmd |
apply $cmd (or eval $cmd if
$cmd is source text) |
[$closure] |
[apply $closure] |
[[expr]] |
[apply [expr]] |
$closure $a $b |
apply $closure $a $b |
apply $fn $args (list-splat) |
apply $fn @$args (@ spreads the list) |
eval executes a string as code in the current scope;
subst substitutes variables and commands in a string.
eval_at is eval with a file name (and optional
starting line) for error messages and Proc::origin, for
hosts evaluating editor buffers.
Examples:
>> eval {+ 1 2}
3
>> let ecode "* 6 7"
>> eval $ecode
42
>> let ex 42
>> subst {x is $ex, sum is [+ 1 2]}
"x is 42, sum is 3"eval_at $fragment "*scratch*" 42
eval_at $buffer_text "/home/me/game/main.lcl"Arithmetic is prefix and variadic, and integer-preserving when the result is exact; otherwise the result is a float.
Examples:
>> + 1 2 3
6
>> - 10 3
7
>> * 2 3 4
24
>> / 10 2
5
>> / 10 4
2.5
>> % 7 2
1Comparison: ==/!= are value equality (deep
for lists and dicts, numeric for numeric text); <,
>, <=, >= order numbers.
same?/not-same? test identity – the same
object, not equal values.
Examples:
>> == (1 2) (1 2)
1
>> == "007" 7
1
>> != 1 2
1
>> < 1 2
1
>> > 1 2
0
>> let same_list (1 2)
>> same? $same_list $same_list
1
>> same? (1 2) (1 2)
0
>> not-same? (1 2) (1 2)
1Quasiquote provides Lisp-style template syntax for code generation.
Inside the template, ,$var substitutes a variable’s value,
,[cmd] evaluates a command and inserts the result,
,@$list splices a list’s elements (space-separated),
,{lit} inserts a literal, and \, is a literal
comma. Commas inside "quoted strings" are literal, not
unquotes.
Examples:
>> let qname "Alice"
>> quasiquote { puts ,$qname }
" puts Alice "
>> let qx 10
>> let qy 20
>> eval [quasiquote { + ,$qx ,$qy }]
30
>> quasiquote { + ,[+ 1 2] 3 }
" + 3 3 "
>> let qargs (1 2 3)
>> quasiquote { + ,@$qargs }
" + 1 2 3 "
>> eval [quasiquote { + ,@$qargs }]
6
>> quasiquote { a ,{lit} b \, c }
" a lit b , c "
>> quasiquote { "a, b" ,$qx }
" \"a, b\" 10 "Building a defun this way: wrap params and body in
literal braces so the expansion produces a well-formed proc
call – unquote inlines values directly, it doesn’t re-quote them. Note
that ,@ splices a list, so the parameter list is
(who), not {who}.
Examples:
>> proc defun {name params body} {
>> quasiquote {
>> proc ,$name {,@$params} {,$body}
>> }
>> }
>> eval [defun greet (who) { "Hello, $who!" }]
>> greet "World"
"Hello, World!"A macro is essentially identical, but its result is
evaluated at the call site, so no eval is needed:
Examples:
>> macro defn {name params body} {
>> quasiquote {
>> proc ,$name {,@$params} {,$body}
>> }
>> }
>> defn greet2 (who) { "Hello2, $who!" }
>> greet2 "world"
"Hello2, world!"For writing macros that generate other macros, use ,,
(double comma). At depth 2, ,,$var evaluates now
and produces ,<value> in the output, while a single
,$var is preserved for later evaluation.
Examples:
>> macro make-adder-macro {name amount} {
>> quasiquote {
>> proc ,$name {x} {
>> eval [quasiquote {
>> + ,$x ,,$amount
>> }]
>> }
>> }
>> }
>> make-adder-macro add10 10
>> add10 5
15Braces are literal (no substitution); double quotes substitute;
brackets are command substitution; parens are a list literal
((a b c) is [::list a b c]); hash-braces are a
dict literal (#{a 1 b 2} is [::dict a 1 b 2]).
The literals name the root list/dict
explicitly, so a local binding called list or
dict cannot hijack them.
Examples:
>> let qa 7
>> {$qa [+ 1 2]}
"\$qa \[+ 1 2]"
>> "$qa [+ 1 2]"
"7 3"
>> [+ 1 2]
3
>> (a b c)
("a" "b" "c")
>> #{a 1 b 2}
#{"b" 2 "a" 1}Data belongs in braces. This is the deliberate
division of labor: {...} is a true literal,
"..." is a substitution template, and
[subst {...}] turns a literal into a template explicitly
when that is what you mean. The practical consequence – and a classic
trap for anyone arriving from languages where double quotes are the
default string syntax – is that any string whose content
legitimately contains [, $, or \
must be brace-quoted, or substitution will silently rewrite it:
;; Regex character classes are command substitutions inside quotes:
Regex::find "([a-z]+):([0-9]+)" $text ;; WRONG: [a-z]+ etc. run as
;; commands; pattern mangled,
;; typically matching nothing
Regex::find {([a-z]+):([0-9]+)} $text ;; RIGHT: braced literal
;; Same for shell snippets, code fragments, printf-style templates:
let cmd {awk '{print $1}' data.txt} ;; $1 stays literalRule of thumb: quote ("...") only when you want
interpolation; brace everything else. This also applies inside dict
literals – #{pattern {[0-9]+}} – where a quoted value
substitutes exactly as it would anywhere else.
Examples:
>> {awk '{print $1}' data.txt}
"awk '{print \$1}' data.txt"
>> get #{pattern {[0-9]+}} pattern
"\[0-9]+"A bare, unquoted word matching the numeric-literal grammar is compiled directly to an int or float:
integer := 0 | -?[1-9][0-9]* ("-0" excluded)
float := -?intpart '.' [0-9]+ exp? | -?intpart exp
exp := [eE][+-]?[0-9]+
Examples:
>> type 42
"int"
>> type -3
"int"
>> type 3.14
"float"
>> 1.50
1.5
>> type 1e3
"float"
>> type "42"
"string"
>> type {42}
"string"
>> type 007
"string"
>> type +5
"string"
>> type .5
"string"The grammar is deliberately strict about spelling:
007 might be a ZIP fragment, so Lcl refuses to guess, and
non-canonical spellings are never normalized. Runtime data is
another matter – numeric contexts accept any string that fully parses as
a number, leading zeros and all, without retagging the original value.
Integers are 64-bit on every host (-9223372036854775808 through
9223372036854775807, whatever the C long of the platform);
a literal that matches the grammar but overflows that range is a compile
error, never a silent string:
Examples:
>> + 1 "007"
8
>> 99999999999999999999
!! integer literal out of range
>> type {99999999999999999999}
"string"Comments are Lisp-style ;;, anywhere on a line. (A
# mid-line is literal.)
Examples:
>> + 1 2 ;; this is a comment
3A backslash at the end of a line continues the command:
dict key1 value1 \
key2 value2Inside [...] brackets, newlines and semicolons are
treated as ordinary whitespace. This allows multiline expressions
without explicit line continuation, and it also applies to list literals
inside brackets.
Examples:
>> let mres [list
>> [list a b]
>> [list c d]
>> [list e f]
>> ]
>> $mres
(("a" "b") ("c" "d") ("e" "f"))
>> let nested ((item1 item2)
>> (item3 item4))
>> len $nested
2Important: this only applies to code inside
brackets. Bare commands (not wrapped in [...]) still follow
normal Tcl-like rules and require backslash continuation for multiple
lines:
;; Bare command - backslashes REQUIRED for line continuation
case $cmd \
{add} [+ $a $b] \
{sub} [- $a $b] \
{mul} [* $a $b] \
else "unknown"
;; Same command wrapped in brackets - backslashes not needed inside
let result [case $cmd
{add} [+ $a $b]
{sub} [- $a $b]
{mul} [* $a $b]
else "unknown"]Semicolons and newlines inside {...} braces and
"..." quotes are always preserved:
;; Shell commands with semicolons work correctly
let output [Sh::run {echo "hello"; echo "world"}]
;; Quoted strings preserve newlines
let text "line1
line2"| Function | Description |
|---|---|
List::map l f |
Apply f to each element, return new list |
List::filter l f |
Keep elements where f returns true |
List::reduce l init f |
Fold list with f(acc, elem) |
Dict::map d f |
Apply f(key, value) to each entry, return new dict with mapped values |
Dict::filter d f |
Keep entries where f(key, value) returns true |
Dict::reduce d init f |
Fold dict with f(acc, key, value) |
Examples:
>> List::map (1 2 3) [lambda {x} { * $x 2 }]
(2 4 6)
>> List::filter (1 2 3 4) [lambda {x} { == [% $x 2] 0 }]
(2 4)
>> List::reduce (1 2 3) 0 [lambda {acc x} { + $acc $x }]
6
>> Dict::map #{a 1 b 2} [lambda {k v} { * $v 10 }]
#{"b" 20 "a" 10}
>> Dict::filter #{a 1 b 2} [lambda {k v} { > $v 1 }]
#{"b" 2}
>> Dict::reduce #{a 1 b 2} 0 [lambda {acc k v} { + $acc $v }]
3Lcl uses reference counting for memory management (no garbage collector). This is a deliberate design choice for embeddability – GC adds complexity, unpredictable pauses, and makes integration with host applications harder.
Lcl does not resolve implicit lexical forward references – a bare
name that didn’t exist when a procedure was defined is an error when the
call executes, never a search of the caller’s environment. Mutual
recursion is therefore explicit, through a live path:
at top level, a leading :: resolves live from the
interpreter root, and inside a namespace, members resolve live through
the namespace.
;; Top level: a leading :: resolves live from the interpreter root
proc even? {n} { if [== $n 0] {1} else {::odd? [- $n 1]} }
proc odd? {n} { if [== $n 0] {0} else {::even? [- $n 1]} }
puts [odd? 13] ;; 1Examples:
>> namespace Parity {
>> proc even? {n} { if [== $n 0] {1} else {Parity::odd? [- $n 1]} }
>> proc odd? {n} { if [== $n 0] {0} else {Parity::even? [- $n 1]} }
>> }
>> Parity::odd? 13
1However, mutual recursion is not tail-call optimized. Lcl’s TCO only applies to self-recursive calls (where the callee is the same proc as the caller). Mutually recursive procs consume a stack frame per call and will hit the maximum recursion depth (1024) for large inputs, while a self-recursive proc runs in constant stack:
Examples:
>> Parity::odd? 3000
!! maximum recursion depth exceeded
>> proc count_down {n} { if [== $n 0] { "done" } else { count_down [- $n 1] } }
>> count_down 100000
"done"Additionally, reference counting cannot handle reference cycles. When
two mutable cells each hold a procedure that captures
the other cell, they form a cycle that can never be freed. Lcl detects
this at set! time and raises an error rather than silently
leaking memory:
Examples:
>> var is_even_fn {}
>> var is_odd_fn {}
>> set! is_even_fn [lambda {n} { apply $is_odd_fn [- $n 1] }]
>> set! is_odd_fn [lambda {n} { apply $is_even_fn [- $n 1] }]
!! reference cycle