Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Experimental REPL

x2c repl is a shipped external command that evaluates a supported subset of x2c in a persistent session. The command compiles prompt function definitions to native code. Top-level initializers, statements, and expressions lower to ordinary Lisp and run in the session’s evaluator. Compiler macros use native meta compilation and helper execution.

x2c repl
x2c repl --help
x2c repl --dump --stats

No source file or checkout is needed when using an installed development build. The command reads standard input and accepts no file operands. It also runs directly as x2c-repl from the installation’s libexec/x2c directory.

Try a session

Enter these submissions in order:

x2c> int total = 10;
ok
x2c> int plus(int x) { return total + x; }
defined plus
x2c> plus(3);
=> 13
x2c> total = 20;
ok
x2c> plus(3);
=> 23

The function calls display => 13 and => 23: later calls read the current value of total. Definitions display defined NAME; initializations and statements display ok. A final expression displays its value using Var.repr, so character and unsigned values retain their printed tags.

A function is compiled once, when its complete definition is submitted. Compilation needs the matching x2c compiler and host C toolchain. Later calls use the compiled code. Prompt variables have shared native storage: both compiled functions and interpreted input read and write the same objects. Assignment preserves each variable’s address, including pointers to record fields. &total therefore refers to the same object from either path.

Functions can call earlier functions and recurse directly. A saved Func keeps the same bindings, so changes to shared variables remain visible:

int total = 20;
int plus(int x) { return total + x; }
int main(void) {
Func saved = plus;
total = 30;
saved(3);  // 33
  return 0;
}

Submit one declaration or function at a time. Executable input may contain several statements. Semicolons are required. Incomplete input continues at the ... prompt until it forms a complete submission. Put a multiline if/else in a block when necessary: a complete if can execute before the next line supplies an else.

Interactive terminals support the arrow keys, Home, End, Backspace, Delete, and up/down history recall. History holds the last 100 nonempty commands and completed source submissions for the current process; adjacent duplicates are stored once, and history is not written to disk. A rejected or failed submission remains available for correction.

Tab completes names from the compiler’s live session namespace, offering only names that fit the grammar position: type positions offer types and type-producing macros, expression positions offer values and callable forms, and statement, block, and submission starts offer their legal union plus contextual keywords. Narrow continuation contexts offer else, catch, or finally where each is legal. Fields and evaluator-callable methods remain available on a typed receiver. At a blank primary prompt completion also offers colon commands, grouped with keywords, session names, types, and other callables. Unsupported REPL declarations and imports are not suggested.

A colon-prefixed line completes commands even at a continuation prompt; :ast and :lowered then complete published function names, and :stats completes verbose. A sole match replaces the current name; otherwise Tab inserts the common prefix, and another Tab lists compact, grouped choices. Completion parses only through the cursor and rolls back its semantic work, so it can use locals from pending multiline input without publishing them. APIs become candidates when their ordinary declarations and compile-time bindings become visible.

Long input wraps across terminal rows. Bracketed paste keeps embedded newlines inside one editable entry, so a pasted multiline submission can be accepted with one Enter. Recalled multiline submissions are also one entry. The editor folds these entries for display and moves over the folded range as a unit; it does not provide arbitrary cursor movement among every line of a whole submission. Typing Enter still submits the current edited entry, and an incomplete submission continues at a fresh ... prompt.

Inspect and control the session

CommandMeaning
:helpShow session commands.
:stats [verbose]Show concise or detailed runtime statistics.
:symbols [PREFIX]List successfully defined names and kinds, optionally matching a name prefix.
:load "file.x"Rebuild all loaded files together, expose this file’s public names, and reset loaded native state.
:info NAMEShow a session function’s argument and return types, source, and execution mode.
:ast plusShow plus’s typed x2c AST.
:lowered plusReport that plus is compiled and has no Lisp body.
:cancelDiscard incomplete input.
:quitExit, discarding incomplete input.

For the example above, :symbols prints the list template %((function "plus") (value "total")). Typed AST output begins with typed: %(; :lowered plus prints compiled: native function; no Lisp body. Inspection does not execute the function, and earlier functions remain inspectable after later submissions. Values have symbol entries but no function AST.

:symbols pl lists only names beginning with pl. :info plus prints a declaration, source: prompt, and execution: native. For a loaded function, the source is the loaded file’s resolved absolute path. Parameter names are omitted from the printed declaration; argument and return types remain. Tab completion for :info includes both prompt functions and loaded functions.

Help and inspection commands preserve incomplete input, including when a command reports an error. Colon commands are recognized on separate lines even inside an incomplete string or comment. Missing names, extra arguments, and unknown commands report errors.

--dump prints each available typed AST and lowered Lisp form to standard error. --stats prints the concise runtime report at exit, to standard error. --verbose-stats prints the detailed report and implies exit reporting; when both statistics options are present, one verbose report is printed. The options can be combined with --dump.

The report separates absolute live or retained quantities from activity since the REPL opened. Scope allocation objects and live requested bytes are process-wide runtime values; requested bytes count public managed payload. Pool active and depot bytes partition retained backing capacity. Scope and Pool figures overlap and must not be summed. Verbose reports add Scope and Pool traffic and capacity details.

Load functions from a file

Save this source as numbers.x:

static int twice(int n) { return 2 * n; }
static int calls = 0;
int answer(int n) { calls++; return twice(n + 1); }
int call_count(void) { return calls; }

Load it and call its functions with ordinary prompt expressions:

x2c> :load "numbers.x"
loaded ("answer" "call_count")
reset all loaded native state
x2c> answer(20);
=> 42
x2c> call_count();
=> 1
x2c> int plus_answer(int n) { return answer(n) + 1; }
defined plus_answer
x2c> plus_answer(20);
=> 43

:load builds all loaded files into one native helper program. It uses the ordinary include, module, package, and native dependency handling. Public functions with bodies in the loaded file become prompt functions, including main. No meta annotation is needed. Static helpers such as twice remain private. Functions from included files and declaration-only functions remain available to the compiled code but are not imported into the prompt. Public type declarations accompany the loaded definitions. Enumerator constants are not imported. Source macros remain with the compiled source; loading does not install them in the prompt.

Public scalar globals are also available without source changes. Reads and assignments use the same native storage as the file’s functions:

int counter = 10;
const int limit = 100;
static int private_counter;
int read_counter(void) { return counter; }

After loading this file, counter; returns 10. Entering counter = 20; makes read_counter(); return 20. Compound assignments and numeric prefix or postfix increments also work. Prompt functions can read and assign these globals, including functions saved before a reload. limit is read-only, and private_counter remains private. :symbols lists the public globals.

Supported scalar values are numbers, booleans, enums, Strings, and Symbols, including typedef aliases. Enum values use the compiler’s int representation. Fixed-size C arrays and structs also expose their scalar fields and elements, including nested arrays and structs:

int samples[3] = {10, 20, 30};
struct Point { int x; int y; };
struct Point point = {1, 2};

After loading this file, samples[1]; returns 20. Assignments such as samples[1] = 99; and point.x = 7; change the file’s native storage. Compound assignments and increments work too. Array indices are checked against the native array’s extent and evaluated once per access. These operations also work inside compiled prompt functions.

Enter samples; or point; to inspect the whole object. Arrays display as Arrays and structs as Maps keyed by field name. Inspection copies the data and visits at most 100 fields or elements, including nested containers; "..." marks truncated output. Unsupported fields display "<unavailable>". Inspection is available for the final expression at the prompt. Native arrays and structs are not copied across the helper boundary as ordinary values. They can be passed to loaded functions inside the helper, as shown below. Assign fields and elements individually.

Static globals stay private. Const objects and const fields are read-only. Raw pointers, unions, flexible array members, collections, Var, and callable globals do not gain accessors. Included globals become visible at the prompt when their defining file is explicitly loaded. A loaded object can supply a pointer or reference argument to a loaded function; its address stays inside the helper. Taking that address as a prompt value is unsupported.

Compilation happens on :load and on each prompt function definition, including plus_answer above. Top-level input stays interpreted. Loading needs a matching x2c compiler, the host C compiler, and its runtime. A dependency that the project meta builder cannot compile or link makes the load fail. An explicit meta function remains callable under the same data limits.

The session keeps one helper process and its compiled executable. Native globals and static values persist between calls. Each successful :load replaces the helper and resets all of those values, including when loading an additional file or an unchanged file. Prompt variables and functions survive. Calls exchange data values: numbers, Strings, Symbols, Lists, and independent Arrays and Maps. Arrays and Maps are copied across the boundary. Mutating a collection argument in the helper does not mutate the prompt’s collection; return the changed collection to use it. Native pointers and records cannot be returned to the prompt. Prompt-owned native objects, callbacks, cycles, and shared mutable collection identity cannot cross this boundary. Selecting a loaded object as a function argument keeps that object inside the helper.

A failed load publishes no names and preserves earlier functions. Fix a rejected file and load it again. Loaded names appear as native in :symbols; :info shows their declarations and loaded file. They have no prompt AST or lowered Lisp for inspection.

Use objects and functions from different files

Save this as objects.x:

struct Point { int x; int y; };
int samples[3] = {10, 20, 30};
struct Point point = {1, 2};
int first(void) { return samples[0]; }

Save these declarations and functions as operations.x. The extern declaration and function prototype refer to definitions in objects.x:

struct Point { int x; int y; };
extern int samples[3];
int first(void);

int sum(const int *values, int count) {
  int total = 0;
  for (int i = 0; i < count; i++) total += values[i];
  return total;
}
void shift(struct Point *p, int dx) { p->x += dx; }
int score(struct Point p) { p.x += 100; return p.x + p.y; }
int advance(void) { samples[0]++; return first(); }

Load both files in the same session, then use their objects and functions:

:load "objects.x"
:load "operations.x"
sum(samples, 3);
shift(&point, 5);
point.x;
score(point);
point.x;
advance();
samples[0];

sum returns 60. shift changes point.x to 6. score returns 108 after changing its own by-value copy; point.x remains 6. advance returns 11, and samples[0] also reads 11.

The helper passes its own array and struct to these functions. No native address or struct bytes travel to the REPL process. Field and element selections also work, such as sum(&samples[1], 2). Repeated selections of the same object alias that object. Const restrictions and array bounds remain checked. Scalar arguments and indices are evaluated once in source order. Compiled prompt functions can make these same calls. Call the loaded function by name for object arguments. Saved Func values retain their existing data-value calling limits.

Use normal includes, prototypes, and extern declarations for references between source files. Larger programs usually keep common declarations in a header. A source dependency has one provider and one instance of its public storage. Loading that dependency explicitly exposes its public names without adding another instance. Distinct public definitions with the same name conflict; file-static declarations retain ordinary C privacy.

Load a program with main

:load also accepts a program with main, including a .c file. The source needs no meta annotation or renamed function. Save this as hello.c:

#include <stdio.h>
int main(int argc, char *argv[]) {
  for (int i = 0; i < argc; i++) printf("%d: %s\n", i, argv[i]);
  return 0;
}

Pass an Array of Strings, including the program name as its first element:

x2c> :load "hello.c"
loaded ("main")
reset all loaded native state
x2c> main(["hello", "world"]);
0: hello
1: world
=> 0

The adapter supplies argc, writable copies of the argument strings, and the final null pointer at argv[argc]. main([]) passes zero arguments. Changes to argv do not change the prompt’s Array or Strings. :info main shows the prompt signature int main(Array);.

A program with int main(void) or int main() is called as main(). Both forms return zero if execution reaches the closing brace. Returning from main produces a prompt result and leaves the REPL running. Standard output and standard error appear on their usual streams, including output without a final newline. Loading alone does not execute main.

The original function keeps its native calling convention inside the compiled program. Its ordinary helpers and references to main continue to work. Loading does not modify the source file. Other C dialect and dependency limitations still apply; loading does not provide missing libraries. Calling exit ends the helper, as described below under interrupted calls.

main follows the same replacement rules as other loaded functions. Saved function values and compiled prompt callers use its replacement. Changing between the no-argument and argc/argv forms requires a new session. A second loaded file cannot publish another main in that session.

Edit a loaded file

After editing a file, use the same :load "file.x" command. A successful replacement prints reloaded. Calls use the new code, including calls from earlier prompt functions. Saved Func values use the replacement for calls the REPL already supports. Prompt variables and functions remain. All registered files are rebuilt from their current contents and dependencies, including files other than the one named by the command. Successful loads print reset all loaded native state: variables in every loaded file and dependency start over. Previously returned values remain ordinary prompt data.

Existing functions must keep their argument and return types. Parameter names may change. Existing public types must keep their definitions. New functions, globals, and types are allowed when their names do not conflict. Existing globals must keep their declared types and qualifiers, including const. Removing an existing function, global, or type, or changing its type, requires a new session. A refused replacement leaves the whole previous native program and its variables intact. This includes a compile, link, or compatibility failure in any loaded file, even when the command names a different file. Prompt definitions still cannot be redefined.

Relative paths, absolute paths, and symbolic links to the same file identify one load. If two loaded files include the same dependency, either load rebuilds both files with the dependency’s current contents.

For example, copy the autodiff module from a repository checkout:

cp packages/autodiff/src/autodiff-macros.x /tmp/autodiff-repl.x
x2c repl

At the prompt, load and inspect the copy:

:load "/tmp/autodiff-repl.x"
:symbols ad_do
:info ad_dot

The filtered list contains ad_dot. Its declaration is String ad_dot(String);, followed by the loaded file’s absolute path. Keep some test data and a prompt function:

x2c> String label = "loss";
ok
x2c> String derive(void) { return ad_dot(label); }
defined derive
x2c> derive();
=> "loss_dot"

Edit the copy’s ad_dot definition to append "_derivative" instead of "_dot". Run :load "/tmp/autodiff-repl.x" again, then call derive(). The result is "loss_derivative"; the earlier label and derive still work. This module also exposes syntax helpers such as ad_unbind and ad_mutates, which can be called with List values from the prompt.

A native call that exits, crashes, or exceeds X2C_META_TIMEOUT fails that submission. The next call restarts the shared helper from its retained executable, resetting all loaded native state. Ordinary errors raised by a function leave the helper and its state running.

Supported subset

The current subset includes initialized simple variables, function definitions, integer arithmetic and narrowing, assignment, conditionals, for loops, self recursion, and calls to earlier functions. String values and length, List literals and indexing, and Array construction, mutation, and indexing are exercised by the focused checks. Captured Func values can be called directly or passed to supported native callbacks. A record captured by value retains a copy through later submissions. Reference captures share the original local or parameter, whose storage remains alive until the session ends. Capturing an existing reference parameter keeps its caller’s alias and does not extend the caller object’s lifetime.

An expression lambda can return another captured lambda:

Func factory = %!(int n) => %!() => n;
Func nested = factory(3);
nested();  // 3

A returned noncapturing lambda or named function needs a (Func) cast, as in native code.

The REPL provides the fixed-signature String.format(String fmt, List values) operation. Call it as fmt.format(values):

List values = %("answer" 42);
String text = "%s=%04d".format(values);

It supports %%, flags -+ #0, numeric and * width and precision, integer conversions d i o u x X with hh h l ll, floating conversions f F e E g G a A with default, l, or L, plus %c and %s. It converts numeric Vars and uses Var.str for %s. Pointer and write-count conversions, wide strings and characters, positional formats, and j z t lengths are rejected. The result follows the process locale, and a failure publishes no partial String. Native String.printf retains its ordinary C varargs contract for compiled code.

REPL sessions also provide print(String) and println(String). print writes the String’s bytes; println writes those bytes followed by one newline. Use String interpolation for formatting, such as println(%"count=$count"). Both functions are REPL-only, reserve their names in the session, and report ok. They are not varargs aliases.

A session also declares Json, Diff and Path, so Json.parse, Diff.unified and Path.basename evaluate without an include.

Redefinition is disabled; assign to an existing variable to change its value. Function replacement and mutually recursive forward declarations are not supported. Names beginning __repl_ are reserved.

Arbitrary C libraries, unions, enums, imports, protocols, user macro/meta definitions entered at the prompt, preprocessor directives, and direct Lisp input are outside this subset. A bodyless meta prototype is accepted: it makes a function the compiler links, or one from a native module loaded with --native-module, callable in the session. Variables need initializers; declarator modifiers, const, and volatile are rejected. Function-local static, extern, and threaded storage remain rejected. Top-level statement expressions can contain local declarations followed by one value expression, such as ({ int n=3; n+4; }). Other statement expression bodies are declined. Reference parameters use the ordinary compiler conversion: pass an addressable value, or write *p to pass a pointer’s object. A pointer itself cannot substitute for the referenced object. Records, typedefs, addressed locals and pointer reads/writes use native storage with layouts derived from compiler types. Record assignment copies into existing storage, preserving pointers to its fields. A session global scalar or record keeps its address until the session closes. Other top-level constructs depend on the existing lowering and may be declined. Native function bodies use ordinary compiler semantics; for example, goto is available there. Loaded public types can be used in prompt functions within the same native layout limits as interpreted objects. Interpreted code does not distinguish Var void from an empty List.

Failure and lifetime

Rejected submissions leave earlier definitions usable. A declaration publishes its names only after all initializers succeed. If a later initializer fails, none of that declaration’s names become available, including through inspection. Effects already made on existing values remain, both for failed initialization and failed statements.

Session storage lives until exit. Submission scratch is reclaimed, but canonical syntax, compiler caches, and evaluator allocations can accumulate. Long sessions do not have a bounded-memory guarantee. Compiled modules stay loaded for the process lifetime; captured bindings belong to their session. Each submission may make 40 million interpreted Lisp calls, the evaluator’s default budget. SIGINT (Ctrl-C) during evaluation stops the submission with an (interrupt ...) evaluation error. Once the budget is exhausted or the submission is interrupted, no further interpreted code runs, including pending defer bodies. Ordinary evaluation failures still run those cleanups. The next submission gets a fresh budget. An interrupt cannot stop native callbacks or compiled prompt functions; it takes effect at the next interpreted call. Compiled function loops do not consume the Lisp budget. Their native crashes or nonterminating loops can end or block the REPL process. Loaded files share a helper process separate from the REPL, with the timeout behavior described above.

EOF exits successfully unless input is incomplete, which exits with status

  1. With piped input, any submission or command error makes the final status 1, while later input still runs. In an interactive session, recovered errors do not change a normal exit’s status from 0.

During interactive editing, Ctrl-C clears both the current edit buffer and any pending incomplete submission, then returns to the x2c> prompt without a diagnostic. Ctrl-D on an empty edit buffer exits; pending incomplete source still makes that exit status 1. Terminal canonical mode is restored before a submission runs. Ctrl-C during interpreted evaluation stops only that submission. Terminals without the required escape-sequence support use the basic line reader instead. Piped input retains the same output and exit behavior.