LilScript v0.1

Language documentation

LilScript is an independent statically typed language. It uses type-first declarations and familiar collection APIs, then compiles the complete program through a typed SSA optimizer to JavaScript or native code. Lilpack is the web bundler for that JavaScript; it is not a second language.

LilScript is not TypeScript and does not accept JavaScript or TypeScript source. The .lil file is the source of truth.

Install and build

Build the compiler and language server from the repository root. A native target additionally requires a C11 compiler such as Clang.

cargo build --release --bins
target/release/lilscript --help
target/release/lilscript-lsp --help

The compiler is a normal command-line executable. No JavaScript runtime is required unless you execute the JavaScript output or run the web playground.

First program

int[] values = [1, 2, 3, 4];
auto doubled = values.map((int value) => value * 2);
int total = doubled.reduce(
  (int sum, int value) => sum + value,
  0
);

print(`total=${total}`);

Save the file as main.lil, compile it, and run the generated module:

lilscript main.lil -o main.js
node main.js

The expected output is total=20.

Compiler targets

--target js

Default. Emits whole-program optimized, minified JavaScript.

--target c

Emits portable C11 for integration into an existing native build.

--target native

Emits C internally and invokes ${CC:-clang} with -std=c11 -O3.

--target all

Optimizes once, then emits JavaScript, C, and a native executable from the same SSA module.

lilscript app.lil -o app.js
lilscript app.lil --target c -o app.c
lilscript app.lil --target native -o app
lilscript app.lil --target all -o build/app

The final command creates build/app.js, build/app.c, and build/app.

Types and declarations

Runtime declarations place the type before the binding name. Every runtime variable requires an initializer.

int count = 5;
float ratio = 3.14;
string title = "LilScript";
string? subtitle = null;
bool enabled = true;
auto inferred = count * 2;
int[] scores = [10, 20, 30];
func(int)->int twice = (int value) => value * 2;

A direct value != null guard narrows a nullable value in its true branch; value == null narrows it in the false branch. Assigning the binding invalidates that narrowing.

TypeSemanticsJavaScript lowering
intSigned 32-bit integer with operator-defined overflowNumber normalized with i32 operations
floatIEEE-754 binary64Number
booltrue or falseBoolean
stringImmutable UTF-8 textString
T?A T value or nullRaw value or null
T[]Mutable homogeneous arraySelected optimized array representation
Task<T>Typed asynchronous value with checked chainingPromise-compatible value

Integer bitwise operators &, |, ^, <<, >>, and >>> return signed 32-bit values and mask shift counts to 0-31. value.toString(radix) formats the signed value; value.toUnsignedString(radix) formats its unsigned bit pattern. Both JavaScript and native targets support radices 2-36.

Modules and tree shaking

// math.lil
export pure int square(int value) {
  return value * value;
}

// main.lil
import { square as sq } from "./math";
import "./startup.lil";
print(sq(5));

Static relative imports remain the default. Private names are isolated per module, dependencies initialize once before importers, and static cycles are rejected. The compiler erases module boundaries before SSA optimization, so calls can inline and aggregates can dissolve across files.

export controls visibility but does not keep unreachable code alive. Bare package imports resolve through a deterministic, content-verified lilscript.lock.

Typed lazy modules

import("./feature")
  .then((auto feature) => print(feature.answer(40)))
  .catch((auto error) => print(error.message));

import() requires a static string and returns Task<module>. The callback namespace exposes exact declared export types. Split builds emit a real lazy ESM chunk, tree-shake unused namespace exports, normalize load failures, and can emit modulepreload links. Lazy-only modules may not contain top-level executable initialization.

Packages and reproducibility

[dependencies]
mathkit = { path = "../mathkit", version = "^1.2", abi = 1 }

# refresh and verify before compilation
lilscript src/main.lil --write-lock -o build/app.js

The lockfile pins transitive dependency edges, semver, compiler ABI, relative roots, entries, and SHA-256 source checksums. Normal builds reject missing, stale, conflicting, or root-escaping dependencies and never rewrite the lockfile.

Arrays and strings

Collection methods are typed intrinsics rather than untyped dynamic dispatch. Their callback signatures are checked at compile time.

int[] values = [1, 2, 3, 4];
values.push(5);
int last = values.pop();
auto evens = values.filter((int value) => value % 2 == 0);
int sum = evens.reduce((int total, int value) => total + value, 0);

string name = "LilScript";
bool valid = name.startsWith("Lil") && name.endsWith("Script");
string normalized = name.toLowerCase();

Arrays provide length, map, filter, reduce, forEach, push, and pop. Callback methods snapshot the receiver length when they start, so callback-appended elements are left for later operations. Strings provide UTF-16 length, charCodeAt, includes, startsWith, endsWith, toUpperCase, and toLowerCase. Integers provide signed and unsigned radix formatting. Floats provide abs, floor, ceil, min, and max.

Collections and binary memory

Map<string, int> counts = new Map();
counts.set("ready", 1);
Set<int> selected = new Set();
selected.add(7);

SharedArrayBuffer storage = new SharedArrayBuffer(4096);
Uint8Array bytes = new Uint8Array(storage);
bytes[0] = 42;
Uint8Array header = bytes.subarray(0, 16);

Map and Set are invariant typed collections. ArrayBuffer, SharedArrayBuffer, and Uint8Array are optimizer-known core types with direct JavaScript built-in lowering and native byte-storage lowering.

The current binary contract is fixed-length and byte-oriented. Atomics, DataView, other typed arrays, growable buffers, and native concurrent shared-memory semantics are not implemented yet.

Structs and classes

Structs are positional value aggregates. Classes are nominal reference values with an optional init constructor and typed methods.

struct Point {
  int x;
  int y;
}

class Counter {
  int value;

  init(int initial) {
    this.value = initial;
  }

  int add(int amount) {
    this.value += amount;
    return this.value;
  }
}

Point origin = Point{0, 0};
Counter counter = new Counter(10);

If an aggregate remains inside the typed program, escape analysis can dissolve it into scalar SSA values. Objects are materialized only when representation or host access requires them.

Functions and closures

pure int add(int left, int right) {
  return left + right;
}

int makeResult(int factor) {
  auto scale = (int value) => value * factor;
  return scale(4);
}

Local values are captured when the closure is created. Captured bindings cannot be rebound inside the closure, while captured arrays and classes can still be mutated through their reference. Top-level bindings are shared globals.

Purity is inferred automatically for effect-aware DCE. The optional pure modifier is a checked contract: printing, mutation, or calls to effectful code cause a compile error. pure extern is a trusted host promise.

Control flow

int total = 0;

for (int index = 0; index < 10; index++) {
  if (index == 2) continue;
  if (index == 8) break;
  total += index;
}

while (total > 20) {
  total -= 1;
}

int before = total++;
int after = ++total;

The language supports blocks, if/else, while, C-style for, break, continue, and return. Prefix updates evaluate to the new value; postfix updates evaluate to the old value. Both forms accept numeric variables, members, and array elements. Logical && and || preserve short-circuit evaluation.

JavaScript applications

Compile a LilScript entry program during your build, then load the resulting JavaScript as a normal module or bundle input. Web apps in this repository go through Lilpack so mixed .lil graphs, HMR, and production delivery stay one pipeline. Lilpack does not replace the compiler and is not a post-minify step.

# package.json build step
lilscript src/main.lil -o dist/lilscript-program.js
<script type="module" src="/lilscript/lilscript-program.js"></script>

Any host function called by LilScript must be declared with extern and available in the output program's JavaScript scope.

Web platform APIs

Browser APIs use typed host declarations. LilScript does not parse JavaScript syntax and does not wrap DOM objects.

extern class Element {
  string textContent;
  void setAttribute(string name, string value);
}

extern class Document {
  Element createElement(string tag);
  Element? querySelector(string selector);
}

extern Document document;

Element button = document.createElement("button");
button.textContent = "Run";

The generated JavaScript calls document.createElement and assigns button.textContent directly. Host global and member names are never mangled, including when LilScript-owned property mangling is enabled. Getters and methods are conservatively effectful unless a method is declared with a trusted pure contract.

Hand-written declarations are supported now. A complete generated Web IDL declaration package still requires inheritance, overload, readonly, dictionary, callback, and exposure modeling. Browser object access is JavaScript-only; native targets report a source diagnostic instead of pretending the DOM has a portable C ABI.

Native applications

Use --target native for a standalone executable, --target c when an existing C build owns linking, or --target all to produce every backend artifact together.

lilscript compute.lil --target native -o compute
./compute

lilscript compute.lil --target c -o generated.c
clang -std=c11 -O3 host.c -o compute

lilscript compute.lil --target all -o build/compute
// host.c
#include "generated.c"

int32_t hostFunction(int32_t value) {
  return value + 1;
}

Native targets consume typed SSA optimized with backend-neutral policy. A configured --target all build shares parsing and semantic analysis, then optimizes separate JavaScript and native IR copies so JavaScript size/performance policy cannot alter native output. Signed integer overflow, closures, arrays, strings, collections, binary buffers, and control flow have native runtime lowering. JavaScript host objects such as document and window are intentionally unavailable to these targets.

Host boundaries

struct Point {
  int x;
  int y;
}

extern int readPoint(Point point);

Point point = Point{10, 20};
int result = readPoint(point);

Arguments and return values are fully type checked. JavaScript function boundaries materialize structs and classes as named objects. Native C function boundaries use generated positional value records for structs and pointer records for classes. Generated C declarations are the authoritative ABI for the host translation unit. Typed browser objects use extern class and direct exact-name member access instead.

extern is the only intentionally untyped JavaScript boundary. Keep it narrow so whole-program scalar replacement and property elimination remain effective.

VS Code and language server

The repository includes lilscript-lsp and a VS Code extension under vscode-extension/. Build the server, package the extension, and install the resulting VSIX:

cargo build --release --bin lilscript-lsp
cd vscode-extension
npm install
npm run package
code --install-extension lilscript-vscode-0.1.0.vsix

The extension recognizes .lil, supplies syntax highlighting, compiler and lint diagnostics, completion, hover documentation, symbols, semantic tokens, scope-aware references and rename, formatting, import organization, and safe quick fixes.

If the server is not on PATH, set lilscript.server.path to the absolute target/release/lilscript-lsp path in VS Code settings.

Lint and format

lilscript-lint src
lilscript-lint src --format json
lilscript-lint src --format sarif --deny-warnings
lilscript-lint src --fix

lilscript-fmt src
lilscript-fmt src --check

The Rust linter resolves the module graph and inspects typed optimized IR, so allocation findings refer to operations that survived DCE and scalar replacement. Loop-cost rules cover arrays, aggregates, maps, sets, buffers, typed-array views, materializing array operations, closures, and unresolved indirect calls. Stable rule IDs, evidence, help, byte spans, JSON, and SARIF make findings usable by both editors and coding agents. minimal, recommended, and strict presets can be overridden per rule.

Projects embedding the compiler can register in-process Rust LintRuleProvider implementations over checked modules and optimized IR. Exact provider namespaces keep project rules independently selectable; duplicate namespaces and undeclared rule IDs fail validation. The built-in web/eager-host-access rule identifies top-level browser work that can run before a progressive-enhancement boundary.

The formatter preserves comments, applies a canonical two-space layout, sorts a safe leading import block, and is idempotent. Both tools honor lilscript.toml; formatting can be disabled without disabling diagnostics.

JavaScript performance and size priority

[optimization]
finite_value_propagation = true
inline_closure_factories = true
identical_function_folding = true
# function_subsumption = true # explicit all-backend enable; false hard-disables

[javascript]
priority = "size-first"
optimization_level = 15
cost_model = "brotli"
candidate_search = "production"
candidate_limit = 1536
# Exact search-feature override; omit to use optimization_level.
# optimizations = ["parsed-peephole", "startup-cost-guard"]
compression = [
  "identifier-mangling",
  "entropy-aware-mangling",
  "quote-style-selection",
  "string-pooling",
  "size-aware-inlining",
  "compact-boolean-literals",
  "structured-closure-inlining",
  "string-array-packing",
  "scalar-phi-copies",
  "phi-affinity-coalescing",
  "ir-inlining-variants",
  "ir-closure-factory-variants",
  "loop-spelling-selection",
  "mutation-spelling-selection",
]
# inline_instruction_limit = 18
# inline_control_flow_limit = 45
# max_inline_growth = 16

[javascript.startup]
parse_weight = 1
compile_weight = 1
memory_weight = 1
parse_overhead_limit_percent = 30
compile_overhead_limit_percent = 30
memory_overhead_limit_percent = 35

[javascript.performance]
deoptimization_weight = 32
allocation_weight = 12
indirect_call_weight = 24
hot_code_weight = 1
max_regression_percent = 25

[profile]
# path = "lilscript.profile.json"
specialization_min_count = 100
max_specializations_per_function = 8
max_clone_instructions = 64

[native]
partial_escape_analysis = true
stack_allocation = true
region_allocation = true
stack_array_element_limit = 64

[lint]
providers = ["correctness", "effects", "performance", "size", "web"]

[mangle]
# identifiers = true
# properties = false
# exports = false
# pool_strings = true

[bundle]
mode = "split"
min_chunk_bytes = 16384
max_chunks = 32
shared_min_imports = 2
preload = "none"

[bundle.cost]
gzip_weight = 1
brotli_weight = 2
request_overhead_bytes = 1000
dependency_depth_penalty_bytes = 160
preload_request_discount_percent = 70
cache_reuse_discount_percent = 20

performance-first uses inlining limits of 24/60 with unbounded growth, no automatic pooling, and signed-i32 |0 normalization. realistic-performance-first uses 18/45 with a +16 instruction growth budget and also keeps |0. balanced uses 12/30 with +4 growth, while the default size-first uses 12/30 with +16 temporary IR instructions so a following fold/DCE fixed point can expose net byte wins. The latter three enable profitable string pooling; size-first also considers delimiter-packed string tables. |0 never helps gzip/Brotli, so size-first and balanced drop proven-redundant coercions. Set integer_coercions = true to keep them.

finite_value_propagation enables bounded boolean, string, nullable-null, and owned-field facts across closed direct calls. Facts widen after four alternatives and are invalidated at exported, extern, indirect-call, closure, and untyped aggregate boundaries.

compression is an exact allowlist. Its decisions are identifier-mangling, entropy-aware-mangling, quote-style-selection, property-mangling, export-mangling, string-pooling, size-aware-inlining, safe-integer-coercion-elision, compact-boolean-literals, structured-closure-inlining, string-array-packing, scalar-phi-copies, phi-affinity-coalescing, ir-inlining-variants, ir-closure-factory-variants, loop-spelling-selection, and mutation-spelling-selection. |0 is not a transfer tactic: size-first and balanced drop proven-redundant coercions even when the name is omitted from an exact list. performance-first keeps |0; integer_coercions = true keeps it on size-first or balanced. Ordinary multiplication uses JavaScript * with i32 normalization and never introduces Math.imul; explicit Math.imul calls remain exact. Structured closures, packed string arrays, phi affinity, scalar versus tuple parallel copies, optimizer IR variants, condition-loop spellings, and range-proven increment forms are compressor-scored against their alternatives. Omit the list to use profile defaults; use compression = [] to disable every optional compression tactic.

optimization_level controls JavaScript compiler effort from 0 to 15. Higher levels progressively add SSA, entropy, structural control-flow, loop, switch, specialization, profile, performance-shape, function folding/layout, and parsed-peephole dimensions while raising the effective candidate cap. Level 14 adds proof-driven private-function subsumption. optimizations replaces that level-derived set with an exact feature allowlist, including ir-function-subsumption-variants, conditional-expression-variants, comma-expression-variants, structural-loop-variants, do-loop-variants, update-loop-variants, switch-lowering-variants, compound-mutation-variants, entropy-cross-scope-reuse, entropy-property-assignment, function-layout-variants, performance-shape-model, profile-guided-optimization, call-site-specialization, capture-signature-cloning, identical-function-folding, parsed-peephole, and startup-cost-guard. This effort control is independent of the semantic IR-pass switches in [optimization].

ir-function-subsumption-variants redirects a private direct-call function to an existing broader implementation only when typed scalar or known-function binding produces an exactly equal normalized SSA/CFG. Every call receives explicit arguments without permuting source argument evaluation; omitted JavaScript arguments are never assumed equivalent. Exports, address-taken functions, methods, constructors, closures, and near-matching bodies bail out. Size-first searches this candidate automatically and retains untouched IR; other priorities require the exact feature name or an explicit optimization.function_subsumption = true.

cost_model selects exact raw, gzip-9, or Brotli-11 measurement for deterministic optimizer-IR and bounded final-emission candidate search. Size-first compares configured, closure-factory-preserving, unspecialized, and fully outlined IRs, then explores conditional/comma expressions, structured and state-machine dispatch, while/for/do loops, update clauses, switch lowering, assignment/prefix/postfix/compound mutation, and similarity-clustered function declarations. Source declaration order remains a complete-artifact candidate. Stratified beams retain prior structural families. Production mode enables candidate search by default; --mode development skips it for faster reloads.

The parsed peephole validates the complete generated artifact and Pratt-parses eligible expressions before applying AST-proven rewrites. Startup policy rejects candidates whose deterministic parse, engine-compile, or memory estimate exceeds the configured baseline limits. A separate typed-IR model scores deoptimization-sensitive shapes, allocations, unresolved indirect calls, and known monomorphic calls according to the selected priority. It is a deterministic proxy, not a browser measurement. --explain human|json reports both metric groups, selected codec bytes, candidate count, peephole rewrites, and measured compiler time.

Run lilscript src/main.lil --profile-template lilscript.profile.json to generate versioned stable function and loop keys. Optional external counters weight hot code without source annotations and drive bounded constant/known-callback specialization plus constant-capture closure cloning. Every clone re-enters folding and DCE and remains codec-scored against the unmodified optimizer IR. Explicit global pass disables in [optimization] remain authoritative over JavaScript effort features. Native output separately places proven local fixed arrays, classes, and closure environments on the frame, uses per-function regions for larger bounded arrays, and keeps uncertain or escaping values on the heap.

The setting changes JavaScript optimization only. It composes with per-pass settings, so optimization.inlining = false still disables inlining. Because raw, gzip, and Brotli sizes can disagree, size priority is a cost-model preference rather than a guarantee for every workload.

Chunk candidates are scored as complete deployments after optimization. The planner combines weighted raw, gzip, and Brotli bytes with requests, dependency depth, preload discounts, shared reachability, and cache reuse. Manifest v2 records the measured inputs, artifact graph, stable source-identity names, content hashes, and selected deploy cost.

Optimization model

  1. Resolve the complete module graph, validate exports, namespace private bindings, and preserve dependency initialization order.
  2. Lower checked LilScript to typed control-flow IR and promote live locals to pruned SSA.
  3. Propagate local/global constants plus bounded interprocedural boolean, string, nullable-null, and owned-field facts; simplify algebra, value-number expressions, fold branches, and prune unreachable blocks to a fixed point.
  4. Devirtualize class methods and known closures, infer effects, validate pure contracts, specialize hot constant and higher-order calls, clone constant capture signatures, and inline eligible expression and multi-block CFG calls across files.
  5. Prove private implementation sharing under typed scalar and known-function bindings, then analyze escapes, dissolve non-escaping structs and classes, remove overwritten field stores, and select conservative frame/region storage for native output.
  6. Remove unused pure calls, allocations, instructions, functions, globals, and bindings.
  7. Coalesce non-interfering SSA values, reuse names across non-overlapping scopes, assign owned properties by weighted frequency, and carry precedence through fused unary, binary, conditional, call, member, and integer-normalization expressions.
  8. Score structural emission candidates with exact compression, validate and Pratt-parse the selected JavaScript for final peepholes, and enforce deterministic parse/compile/memory startup limits.
  9. Apply backend policy and emit minified JavaScript, portable C, or both plus a native executable.

The optimizer is whole-program by design. It covers the optimization responsibilities applicable to a closed, statically typed language; JavaScript-input passes for JSDoc, goog.*, polyfills, and prototype rewriting are intentionally not language features.

Library compatibility claims

An app-level output match does not establish that LilScript implements a JavaScript library. A compatibility claim requires the public exports, documented signatures and defaults, error and timing behavior, side effects, applicable upstream unit tests, and browser integration suites.

LilScript does not currently implement Motion. The benchmark's three-helper animation kernel is a compiler workload. The real Motion package is built separately with Vite and excluded from compiler totals.

The audited Motion v13 scope covers 312 root runtime exports and approximately 64,000 source lines across Motion, Motion Utils, Motion DOM, and Framer Motion. The repository document docs/motion-compatibility.md defines the required language features, staged port, and upstream-test gates.

Verification

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
scripts/verify.sh
benchmarks/run.sh
node benchmarks/finite-values/run.mjs
node benchmarks/ir-variants/run.mjs
node benchmarks/profile-guided/run.mjs
node benchmarks/paired/run.mjs
npm --prefix benchmarks/browser run benchmark

The matrix compiles 72 programs with maximum and disabled optional optimization, then requires Node, the direct native output, independently compiled emitted C, and checked-in expected output to agree across 144 executions. A separate Rust evaluator walks checked AST directly; its fixed 64-case generated scalar, control-flow, array, captured-callback, and binary-memory corpus must match optimized and optimizer-disabled JavaScript with parsed peepholes enabled and disabled, direct native output, and independently compiled C. Ten equivalent workloads are also executed before comparing independently selected raw, gzip-9, and Brotli-11 LilScript artifacts against Closure ADVANCED v20260804 in their matching metrics. A second source-neutral lane mechanically generates both languages and gates only the Brotli-selected artifact's Brotli bytes while publishing raw and gzip diagnostics; its Chromium gate rejects a 95% runtime upper bound above 1.03. The application and complete-library labs remain separate because installed npm code and a LilScript port are not identical implementations. Complete measurements and limits are on the benchmark page.