Rust's speed, Python's ease, zero dependencies
A small language with a huge standard library. No null, no mutation, no package manager, no unsafe. Errors you can't ignore. Iteration that runs in parallel when it is safe to.
One command turns your file into a Rust program, so it is as fast and as memory safe as Rust whether or not you know Rust. Every file pins the compiler that wrote it, so what compiled once compiles forever.
// Nothing here is mutable, so nothing here can race. // map and filter are language operations, so they run on every core. // y yields one value per element, a:i is an array of integers. evens:a:i = filter num in array_range(1, 1000000) { y num % 2 == 0; }; squares:a:i = map num in evens { y num * num; }; total:i = array_sum(squares);
This page is a Nail program: server, highlighting, compiler output, all of it. Read its source β
If it is not on this screen, Nail does not have it
nail latest // Every construct Nail has, on one screen. If it is not here, the language // does not have it. // // The short tokens, once: f declares a function, r returns, y yields one // element to a collection operation, p opens a parallel block (real threads), // c opens a concurrent block (overlapped waiting, one thread). The type // letters: i integer, f float, s string, b boolean, a array, h hashmap, // e error, v void. struct Point { x_pos:i, y_pos:i } enum Direction { North, South } f add_coordinates(point:Point):i { r point.x_pos + point.y_pos; } f half_of(num:i):i!e { if { num % 2 == 1 -> { r e(`odd numbers do not halve cleanly`); }, else -> { r num / 2; } } } f return_zero_if_error(err:e):i { r 0; } f announce(message:s):v { print(message); } f first_square_past(limit:i, candidate:i):i { if { candidate * candidate > limit -> { r candidate; }, else -> { r first_square_past(limit, candidate + 1); } } } // Everything is immutable, and every declaration carries its type coordinate_total:i = add_coordinates(Point { x_pos = 3, y_pos = 4 }); ratio:f = 2.5; markup:s = html`<b>a tagged string tells highlighters its language</b>`; is_ready:b = true && !false; heading:Direction = Direction::North; // The three ways out of an error type: handle it, crash on it, or insist half:i = safe(half_of(coordinate_total), return_zero_if_error); crash_if_odd:i = danger(half_of(2)); promised_even:i = expect(half_of(4)); // if is an expression too heading_name:s = if { heading == Direction::North -> { r `north`; }, else -> { r `south`; } }; // Arrays and hashmaps are the collections numbers:a:i = [1, 2, 3, 4, 5]; ages:h<s,i> = hashmap_new(); hashmap_set(ages, `grug`, 30); grug_age:i = danger(hashmap_get(ages, `grug`)); // map, filter and reduce are the loops, and they run on every core squares:a:i = map num in numbers { y num * num; }; evens:a:i = filter num in squares { y num % 2 == 0; }; total:i = reduce acc num in evens from 0 { y acc + num; }; // scan keeps every step, find, all and any answer questions, each is for // side effects, and any of them can also take an index iterator running:a:i = scan acc num in numbers from 0 { y acc + num; }; first_even:i = danger(find num index in numbers { y num % 2 == 0; }); all_positive:b = all num in numbers { y num > 0; }; any_negative:b = any num in numbers { y num < 0; }; each num in numbers { print(num); } // a search that stops is a function that calls itself until it has the answer print(first_square_past(50, 0)); // forever runs until the program ends: servers, watchers, heartbeats. // Nothing runs behind the program's back, so a forever function runs in a // c block beside whatever else lives as long as the program. This one is // declared and not called, so the tour itself can end f heartbeat():v { forever { announce(`still here`); time_sleep(60.0); } } // p runs statements on real threads at once, c overlaps their waiting p left:i = array_sum(array_range_inclusive(1, 1000)); right:i = array_sum(array_range_inclusive(1, 2000)); /p c doubled:i = half * 2; tripled:i = half * 3; /c // import splices in another file, sandboxed so it can only compute, and // import_dangerous splices one in with the sandbox off. Each needs a second // file, so they are the one pair not shown running on this screen. print(total + left + right + grug_age + first_even + doubled + tripled);
It compiles. This server checked it with its own compiler at startup, where it became 175 lines of Rust.
Three everyday mistakes, refused by this server's own compiler at startup. The panes are editable: fix one and the same compiler, running in your browser, changes its verdict
nail latest // Reading a file can fail, and this code pretends it cannot. // Nail should FAIL type checking here, which is the point of the page. content:s = fs_read(`config.txt`); print(content);
error: 'content' is declared as a string (s) but fs_read returns a result (s!e) that may contain an error
nail latest // A Point needs both coordinates, and this literal hopes nobody notices. // Nail should FAIL type checking here, which is the point of the page. struct Point { x_pos:i, y_pos:i } somewhere:Point = Point { x_pos = 3 }; print(somewhere.x_pos);
error: Missing field 'y_pos' in instantiation of struct 'Point'
nail latest // announce returns nothing, and this code tries to keep the nothing. // Nail should FAIL type checking here, which is the point of the page. f announce():v { print(`hello`); } kept:s = announce(); print(kept);
error: 'kept' is declared as a string (s) but announce returns void (v)
Every feature here exists to make a whole family of bugs impossible to write
A name keeps its value. Nothing elsewhere can overwrite it, so there is never a hunt for what did.
No null, no undefined, no declared-but-unset. Every name holds a real value from the moment it exists.
map changes every item, filter keeps some, reduce folds them into one. No counters, so no off-by-one.
A c block waits on files and networks together. A p block spreads CPU work over every core. You never touch a thread.
Anything that can fail returns something you must handle: safe() for a fallback, danger() to accept the crash.
No unsafe to switch the rules off, no package manager pulling in dependency trees. Your program is what you wrote.
import sandboxes downloaded code at compile time: no network, no disk, no way to phone home.
A thousand functions built in: servers, databases, stats, money, geo. Nothing to install.
Every message says what broke, where, why, and how to fix it. The wording is pinned by tests.
Your program becomes Rust, then one static executable. Deploying is copying it.
Nothing here is a mockup. βΆ Run serves output this server really computed, the highlighting is Nail's own lexer, and every Rust panel is what the transpiler emitted.
Two blocks run statements at the same time, against different bottlenecks:
Overlapping waits is nearly free. Multiplying work takes more hardware.
nail latest // c.../c starts every statement at the same time and waits for all of them. // The compiler turns this block into Rust's tokio::join!, async I/O on one // thread. Concurrency overlaps waiting: three reads cost one wait. It cannot // speed up computation, that is what p.../p and its extra cores are for. // If a read fails, safe() hands the error here instead of crashing. // There is deliberately no safe(expr, `default`) shortcut: a bare default // would hide the error, and the moment you need to see what went wrong you // would be rewriting it as a function anyway. So it starts as one - and it // shows what went wrong before it substitutes. f read_fallback(err:e):s { print(`read failed:`, err); r `(file unavailable)`; } // Three real file reads happen concurrently, one per way of handling failure: c // danger(): crash on failure. For when the program is pointless without the value. spec:s = danger(fs_read(`nail_language_spec.md`)); // expect(): also crashes, but tells the reader "I checked, this can't fail". readme:s = expect(fs_read(`README.md`)); // safe(): pass the error to a fallback function and carry on. website_source:s = safe(fs_read(`examples/website/main.nail`), read_fallback); /c // Past /c, every value is guaranteed loaded. Those three are the only ways to // use a value that might fail: there is no ignoring the error and hoping. print(`Language spec chars:`, string_length(spec)); print(`README chars:`, string_length(readme)); print(`Website source chars:`, string_length(website_source)); print(`All three files loaded concurrently!`);
use tokio;
use nail::std_lib;
use nail::print_macro;
use std::boxed::Box;
use rayon::prelude::*;
use rayon::iter::IntoParallelIterator;
use futures::future;
fn main() {
let __threads = nail::threads::configure();
tokio::runtime::Builder::new_multi_thread()
.worker_threads(__threads)
.enable_all()
.build()
.expect("the async runtime could not start")
.block_on(nail_main());
}
async fn nail_main() {
fn read_fallback(err: String) -> String {
print_macro!("read failed:".to_string(), err);
return "(file unavailable)".to_string();
}
let (spec, readme, website_source) = tokio::join!(
async { (std_lib::fs::read_file("nail_language_spec.md".to_string()).await).unwrap_or_else(|nail_error| panic!("π¨ Nail Error: {}", nail_error)) },
async { (std_lib::fs::read_file("README.md".to_string()).await).unwrap_or_else(|nail_error| panic!("π¨ Nail Error: {}", nail_error)) },
async { (match (std_lib::fs::read_file("examples/website/main.nail".to_string()).await) { Ok(v) => v, Err(e) => (read_fallback.clone())(e) }) }
);
print_macro!("Language spec chars:".to_string(), std_lib::string::len(&spec));
print_macro!("README chars:".to_string(), std_lib::string::len(&readme));
print_macro!("Website source chars:".to_string(), std_lib::string::len(&website_source));
print_macro!("All three files loaded concurrently!".to_string());
}
Everything inside c.../c starts at once and the block ends when all of it finishes, so three waits cost one. The compiler emits tokio::join!, the same idea as Promise.all in JavaScript or goroutines plus a WaitGroup in Go. It cannot make computation faster: one thread is still doing the computing. Why not p here? An OS thread costs megabytes of stack to sit asleep on a disk read, while a c task waits for close to free, so c scales to thousands of simultaneous waits. Past /c the values are ordinary and immutable, with no await left to forget.
nail latest // p.../p runs each statement on its own OS thread (std::thread::spawn). // All threads are joined at /p, so every value below the block is ready. // Parallelism multiplies working: three cores compute in the same instant. // c.../c cannot do that, it overlaps waiting on a single thread. f factorial(num:i):i { if { num <= 1 -> { r 1; }, else -> { r num * factorial(num - 1); } } } f is_prime(num:i):b { if { num < 2 -> { r false; }, else -> { has_divisor:b = any div in array_range(2, num) { y num % div == 0; }; r !has_divisor; } } } f count_primes_below(limit:i):i { primes:a:i = filter num in array_range(2, limit) { y is_prime(num); }; r array_length(primes); } // Three CPU-heavy jobs run simultaneously on separate cores. // No locks, no mutexes: values are immutable, so threads cannot collide. p fact_12:i = factorial(12); sum_to_million:i = array_sum(array_range_inclusive(1, 1000000)); prime_count:i = count_primes_below(10000); /p print(`12! =`, fact_12); print(`Sum of 1 to 1,000,000 =`, sum_to_million); print(`Primes below 10,000 =`, prime_count);
use tokio;
use nail::std_lib;
use nail::print_macro;
use std::boxed::Box;
use rayon::prelude::*;
use rayon::iter::IntoParallelIterator;
use futures::future;
fn main() {
let __threads = nail::threads::configure();
tokio::runtime::Builder::new_multi_thread()
.worker_threads(__threads)
.enable_all()
.build()
.expect("the async runtime could not start")
.block_on(nail_main());
}
async fn nail_main() {
fn factorial(num: i64) -> i64 {
if num <= 1i64 {
return 1i64;
} else {
return num * factorial(num - 1i64);
}
}
fn is_prime(num: i64) -> bool {
if num < 2i64 {
return false;
} else {
let has_divisor: bool = {
let __iter = (2i64..num);
let num = num.clone();
let __search_result = __iter.into_par_iter().any(|div| {
let num = num.clone();
let condition_result = {
(num % div) == 0i64
};
condition_result
});
__search_result
};
return !has_divisor;
}
}
fn count_primes_below(limit: i64) -> i64 {
let primes: Vec<i64> = {
let __iter = (2i64..limit);
let __result: Vec<_> = __iter.into_par_iter().filter_map(|num| {
let condition_result = {
is_prime(num)
};
if condition_result {
Some(num.clone())
} else {
None
}
}).collect();
__result
};
return std_lib::array::len(&primes);
}
let (fact_12, sum_to_million, prime_count) = {
let handle0 = std::thread::spawn({ let __rt_handle = tokio::runtime::Handle::current(); move || { __rt_handle.block_on(async move { factorial(12i64) }) } });
let handle1 = std::thread::spawn({ let __rt_handle = tokio::runtime::Handle::current(); move || { __rt_handle.block_on(async move { std_lib::array::sum(&std_lib::array::array_range_inclusive(1i64, 1000000i64)) }) } });
let handle2 = std::thread::spawn({ let __rt_handle = tokio::runtime::Handle::current(); move || { __rt_handle.block_on(async move { count_primes_below(10000i64) }) } });
(handle0.join().unwrap(), handle1.join().unwrap(), handle2.join().unwrap())
};
print_macro!("12! =".to_string(), fact_12);
print_macro!("Sum of 1 to 1,000,000 =".to_string(), sum_to_million);
print_macro!("Primes below 10,000 =".to_string(), prime_count);
}
Each statement in p.../p gets its own thread on its own core, joined at /p, so every value below the block is ready. Three computations happen in the same instant, so the block costs its slowest statement instead of the sum. Threads only pay off because these jobs burn CPU instead of waiting. No locks, because there is nothing mutable to lock. Rule of thumb: c when the bottleneck is waiting, p when it is computing.
nail latest // Nail forces you to handle errors - no silent failures! f divide(numerator:i, denominator:i):i!e { if { denominator == 0 -> { r e(`Cannot divide by zero!`); }, else -> { r numerator / denominator; } } } // Must explicitly handle the error case result:i = danger(divide(10, 2)); print(`10 / 2 = `); print(result); // Safe handling with fallback function. The handler always receives the // error, so logging it is one line β a bare default value never could. f handle_div_error(err:e):i { print(`Error occurred: `); print(err); r 0; // Return default value } safe_result:i = safe(divide(10, 0), handle_div_error); print(`Result with error handling: `); print(safe_result);
use tokio;
use nail::std_lib;
use nail::print_macro;
use std::boxed::Box;
use rayon::prelude::*;
use rayon::iter::IntoParallelIterator;
use futures::future;
fn main() {
let __threads = nail::threads::configure();
tokio::runtime::Builder::new_multi_thread()
.worker_threads(__threads)
.enable_all()
.build()
.expect("the async runtime could not start")
.block_on(nail_main());
}
async fn nail_main() {
async fn divide(numerator: i64, denominator: i64) -> Result<i64, String> {
if denominator == 0i64 {
return Err(format!("divide: {}", "Cannot divide by zero!".to_string()));
} else {
return Ok(numerator / denominator);
}
}
let result: i64 = (Box::pin(divide(10i64, 2i64)).await).unwrap_or_else(|nail_error| panic!("π¨ Nail Error: {}", nail_error));
print_macro!("10 / 2 = ".to_string());
print_macro!(result);
fn handle_div_error(err: String) -> i64 {
print_macro!("Error occurred: ".to_string());
print_macro!(err);
return 0i64;
}
let safe_result: i64 = (match (Box::pin(divide(10i64, 0i64)).await) { Ok(v) => v, Err(e) => (handle_div_error.clone())(e) });
print_macro!("Result with error handling: ".to_string());
print_macro!(safe_result);
}
divide returns i!e, an integer or an error, and Nail will not compile code that ignores the error half. Handle it with safe() and a fallback, or accept the crash with danger(). Every error path is written down, and the compiler checks it.
Every frame below is a Nail program, built with nailc --target=wasm. The
same source files open native windows on a desktop. Click a game to give it your
keyboard.
Click it, then arrows or A and D move, Space jumps, R restarts the level. On a phone, the corner pads. Five levels of pits, coins, spikes and things that walk and fly, all pure functions over one struct, with sound. Every rectangle, circle and glyph is an instanced quad your graphics card draws through WebGL2, with the CPU rasterizer as the fallback. Read its source β
Drag to orbit, scroll to zoom, or drag and pinch on a phone. The monument is a glTF file the program fetches, projected and lit by the standard library on the CPU: no JavaScript renderer, every triangle placed by your program. Read its source β
Drag to orbit, scroll to zoom, click a tower to claim it. One scene shape holding an island of towers in a rolling ocean: instanced draws, a real depth buffer, fog, mouse picking, and real-time lighting - the glowing orb is the sun, and every face relights as it circles - rendered by your graphics card through WebGL2. The sun's fire and the rolling water are WGSL shaders the program itself carries as strings and loads through game3d_shader, compiled and checked the moment they load. The same program opens a native window on Vulkan. When no card answers, the same scene falls back to the CPU. Read its source β
import pulls a file into your program and sandboxes it: it can compute anything and touch nothing
nail latest // A handy helper you found on the internet. It says it formats greetings. // It also, quietly, tries to read your machine. f malicious_greeting(name:s):s { stolen:s = danger(fs_read(`/etc/passwd`)); r string_concat([`Hello `, name, `! `, stolen]); }
nail latest // Your program. The downloaded file runs inside the compile-time sandbox. // Nail should FAIL parsing here: the download reaches for the disk. import(`snippets/jailbreak_attempt.nail`) greeting:s = malicious_greeting(`friend`); print(greeting);
error: Sandboxed code brought in by import cannot access 'fs_read': the 'fs' module touches the machine
Not a mock: at startup this server handed the two files above to its own compiler and printed the refusal it got back. The sandbox holds through every function the file reaches, so nothing gets laundered through a helper.
import is the default, so a stranger's file never needs your trust. import_dangerous is for your own files and trusts them completely, which is why its name makes you say so. A package repository for Nail would be a shelf of plain files imported like this: nothing to resolve, nothing to lock.
The Model Context Protocol is how AI assistants call outside tools. In Nail it is a tool list and one function.
nail latest // An MCP server in Nail: declare the tools, answer the calls, done. // Point any MCP client (Claude Code, Claude Desktop) at the compiled // binary and the model can roll dice and measure the world. tools:a:MCP_Tool = [ MCP_Tool { name = `roll_dice`, description = `Roll some dice and see every result`, input_schema = `{"type":"object","properties":{"sides":{"type":"integer"},"count":{"type":"integer"}},"required":["sides","count"]}` }, MCP_Tool { name = `distance_km`, description = `Great-circle distance in kilometers between two points`, input_schema = `{"type":"object","properties":{"lat1":{"type":"number"},"lon1":{"type":"number"},"lat2":{"type":"number"},"lon2":{"type":"number"}},"required":["lat1","lon1","lat2","lon2"]}` } ]; f handle_tool(tool_name:s, arguments_json:s):s!e { if { tool_name == `roll_dice` -> { sides:i = danger(json_get_int(arguments_json, `sides`)); count:i = danger(json_get_int(arguments_json, `count`)); rolls:a:i = map roll_number in array_range(0, count) { y danger(rand_int(1, sides)); }; r string_from_array_i64(rolls); }, tool_name == `distance_km` -> { lat1:f = danger(json_get_float(arguments_json, `lat1`)); lon1:f = danger(json_get_float(arguments_json, `lon1`)); lat2:f = danger(json_get_float(arguments_json, `lat2`)); lon2:f = danger(json_get_float(arguments_json, `lon2`)); km:f = danger(geo_distance_km(lat1, lon1, lat2, lon2)); r string_from(danger(math_round_to(km, 1))); }, else -> { r e(string_concat([`no tool called `, tool_name])); } } } // Everything for a person goes to stderr through log, stdout is the protocol. log_info(`nail-tools MCP server starting`); danger(mcp_serve(`nail-tools`, `1.0.0`, tools));
Declare the tools, write handle_tool, call mcp_serve. One command compiles this file into a static binary that plugs into Claude Code or Claude Desktop, with no runtime to install and no dependencies to resolve. Stdout carries the protocol, the log functions write to stderr so they stay out of its way, and an error you return becomes a tool error the model can act on. Elsewhere this is a Node or Python project with a package manager in front of it. Here it is a file.
1189 functions across 84 modules, all built in. This server computed every answer below at startup, by running the expression on its left
money_loan_payment(20000000, 6.0, 360)$1,199.10stats_ab_test(200, 1000, 250, 1000)0.0074geo_distance_km(53.5461, -113.4937, 51.0447, -114.0719)280.9string_best_match("edmontn", cities)Edmontontime_cron_describe("0 3 * * *")every day at 03:00markdown_to_html("**bold** move")<p><strong>bold</strong> move</p>net_ip_in_cidr("10.1.2.3", "10.0.0.0/8")truehtml_sanitize("<script>alert(1)</script>hello")hellocolor_text_on("#2563eb")#ffffffformat_number_words(array_length(stdlib_functions()))one thousand one hundred eighty-nineOne download. No Rust to install, no C compiler, nothing else on the machine.
curl -fsSL https://nail.alex-wilkinson.ca/install | sudo sh # Opens the file in Nail's own IDE nail hello.nail
That installs to /opt/nail and hands the directory to the user who ran
it. The sudo is for that one command and nothing after it: installing a version,
updating, reclaiming disk and removing versions are all yours to do without it.
sudo rm -rf /opt/nail /usr/local/bin/nail is the uninstaller.
Because a release ships with every dependency already compiled, and that only works
at one fixed path. Cargo decides whether compiled code can be reused by fingerprints
that contain absolute paths. A cache warmed at one path and read from another is
thrown away, and the first program you build recompiles five hundred crates instead
of using the ones you just downloaded. Installing everywhere means installing at the
same place everywhere, and /opt is where a machine keeps software that
did not come from its package manager.
What one path buys, beyond the cache being real:
/usr/local/bin:
root shells, cron jobs, build agents and accounts that never log in
interactively
Sharing a machine with someone? Every account on it can build, and they share the
one already-built library, so nothing compiles twice. An account created after the
install joins with sudo nail share bob.
And if giving an installer one sudo is a line you would rather not cross, that is a perfectly reasonable line and Nail is not the right tool for you. Nothing here will try to talk you round. The alternative was a home install that quietly recompiled everything the toolchain had already compiled, and shipping something that pretends to be prebuilt is worse than asking once.
nail is the only command and the only thing on your PATH. It reads which version a file was written for, downloads exactly that version if you do not have it, and opens the file. The compiler, the editor, a pinned Rust toolchain and every library already built come down in one piece, so the first program compiles in seconds and everything after that works with the network unplugged.
The editor comes with it and runs the real compiler over your file as you type, so an error is underlined where it is instead of guessed at by a plugin:
Nothing to configure: no language server to keep in sync, no formatter to argue about, no plugin that quietly disagrees with the compiler. Build scripts call that same compiler through nail build and nail check, at the version the file asked for rather than whichever is newest.
The
install script
is there to read, though reading it proves less than it looks like: a server can
serve one thing to a pipe and another to a browser, and what it fetches is a
compiled toolchain either way. The claim worth having is the one you can check
afterwards, which is that all of it is in a directory you own and deleting that
directory is the end of it. Or skip the script and build from source with
git clone and cargo run. Linux is the supported platform
for the tools. The programs you build are static binaries and run anywhere.
The language specification is the full reference, and this site's own source is the largest Nail program there is to read.
Real frames captured from a terminal. The compiler underneath them is the same one that builds your program.
βFILES [*] - Press Ctrl+S to saveββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββReady β Untitled 1 β hello_world.nail* β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ βNAIL - examples/hello_world.nailββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ 1 βnail latest ββ 2 β// Hello World - Your first Nail program ββ 3 β ββ 4 βgreeting:s = `Hello, World!`; ββ 5 βprint(greeting); ββ 6 βcount:i = 5.5 ββ 7 β ββ 8 β// Demonstrate collection operations with greetings ββ 9 βgreetings:a:s = [`Hello`, `Hola`, `Bonjour`, `Guten Tag`, `Ciao`]; β Expected ';' here, but found the name 'grβ¦ββ 10 βlanguages:a:s = [`English`, `Spanish`, `French`, `German`, `Italian`]; ββ 11 β ββ 12 β// Create formatted greetings using map ββ 13 βformatted_greetings:a:s = map greeting idx in greetings { ββ 14 β language:s = danger(array_get(languages, idx)); ββ 15 β y array_join([greeting, ` (`, language, `)`], ``); ββ 16 β}; ββ 17 β ββ 18 β// Print each greeting ββ 19 βprint(`\n=== International Greetings ===`); ββ 20 βeach formatted_greeting in formatted_greetings { ββ 21 β print(formatted_greeting); ββ 22 β} ββ 23 β ββ 24 β// Find greetings with specific letters ββ 25 βgreetings_with_o:a:s = filter greeting in greetings { ββ 26 β y string_contains(greeting, `o`); ββ 27 β}; ββ 28 βhas_long_greeting:b = any greeting in greetings { ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ examples/hello_world.nail [*] 6:14 38 lines 1179 bytes Tab 2/2 [LN,HL,BR] Ctrl+L: Line# | Ctrl+Shift+H: Highli
The editor runs the real lexer, parser and type checker as you edit, and marks the problem on the line that caused it. There is one implementation of the language, and this is it.
βFILES [*] - Press Ctrl+S to saveββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββReady β Untitled 1 β hello_world.nail* β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ βNAIL - examples/hello_world.nailββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ 1 βnail latest ββ 2 β// Hello World - Your first Nail program ββ 3 β ββ 4 βgreeting:s = `Hello, World!`; ββ 5 βprint(greeting); ββ 6 βcount:i = 5.5 ββ 7 βtotal:i = string_le β Expected ';' here, but found the name 'total' ββ 8 β ββ 9 β// Demonstβ Completions (F1 for docs) βββββββββββββββββββββ ββ 10 βgreetings:βΖ string_length string_length(input:s) -> i βCiao`]; ββ 11 βlanguages:βββββββββββββββββββββββββββββββββββββββββββββββββ`Italian`]; ββ 12 β ββ 13 β// Create formatted greetings using map ββ 14 βformatted_greetings:a:s = map greeting idx in greetings { ββ 15 β language:s = danger(array_get(languages, idx)); ββ 16 β y array_join([greeting, ` (`, language, `)`], ``); ββ 17 β}; ββ 18 β ββ 19 β// Print each greeting ββ 20 βprint(`\n=== International Greetings ===`); ββ 21 βeach formatted_greeting in formatted_greetings { ββ 22 β print(formatted_greeting); ββ 23 β} ββ 24 β ββ 25 β// Find greetings with specific letters ββ 26 βgreetings_with_o:a:s = filter greeting in greetings { ββ 27 β y string_contains(greeting, `o`); ββ 28 β}; ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ examples/hello_world.nail [*] 7:20 39 lines 1199 bytes Tab 2/2 [LN,HL,BR] Ctrl+L: Line# | Ctrl+Shift+H: Highli
Every function ships with the language, so the editor offers all of them with full types. No index to build, nothing to keep in sync.
page_styles:s = css`.hero { color: #86efac; }`; banner:s = html`<section class="hero"><h1>Nail</h1></section>`; lookup:s = sql`SELECT name FROM users WHERE active = 1;`; settings:s = yaml`service: nail # what runs here`; manifest:s = toml`[package]`; behaviour:s = js`const total = items.map(double);`; shader:s = wgsl`@fragment fn glow() -> @location(0) vec4<f32> { return vec4<f32>(hue, 1.0); }`; release:s = md`## Out **today**`; plain:s = `no tag, no change`;
A web program is mostly long strings of other languages. Tag one with what it holds -
html`<p>hi</p>`, or css, sql,
yaml - and the editor and this page both colour it as that language. The
colouring keeps its place across line breaks, so an open <div, a CSS
block or a /* ... */ still reads correctly on the next line.
The tag means nothing to the compiler: same type, same escapes, same Rust out the other end. It is a note to whoever is reading. The tags both highlighters know:
The compiler keeps no list of languages, so a tag from outside this table still compiles, its string simply keeps one plain colour. Nothing here is a plugin: one scanner covers everything built out of words, strings and comments, and each language is a row in its table.
βFILES [*] - Press Ctrl+S to saveββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββReady β Untitled 1 β hello_world.nail* β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ βNAIL - examples/hello_world.nailββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ 1 βnail latest ββ 2 β// Hello World - Your first Nail program ββ 3 β ββ 4 βgreeting:s = `Hello, World!`; ββ 5 βprint(greeting); ββ 6 βcount:i = 5.5 ββ 7 βtotal:i = string_le β Expected ';' here, but found the name 'total' ββ 8 β ββ 9 β// Demonstrate collection operaβ Documentation (F1 to toggle) βββββββββββββββββββ ββ 10 βgreetings:a:s = [`Hello`, `HolaβFunction: string_length β ββ 11 βlanguages:a:s = [`English`, `Spβ β ββ 12 β βSignature: string_length(input:s) -> i β ββ 13 β// Create formatted greetings uβ β ββ 14 βformatted_greetings:a:s = map gβDescription: β ββ 15 β language:s = danger(array_gβReturns the number of characters in the string. β ββ 16 β y array_join([greeting, ` (β β ββ 17 β}; βExample: β ββ 18 β βlength:i = string_length(`hello`); β ββ 19 β// Print each greeting β β ββ 20 βprint(`\n=== International Greeβ β ββ 21 βeach formatted_greeting in formβPress ESC to go back, TAB to insert β ββ 22 β print(formatted_greeting); ββββββββββββββββββββββββββββββββββββββββββββββββββ ββ 23 β} ββ 24 β ββ 25 β// Find greetings with specific letters ββ 26 βgreetings_with_o:a:s = filter greeting in greetings { ββ 27 β y string_contains(greeting, `o`); ββ 28 β}; ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ examples/hello_world.nail [*] 7:20 39 lines 1199 bytes Tab 2/2 [LN,HL,BR] Ctrl+L: Line# | Ctrl+Shift+H: Highli
Documentation lives in the registry the compiler type checks against, so it cannot drift out of date.
What runs in parallel automatically, and what it measures out to
Uses every core, automatically:
Never: each, whose side effects happen in the order you wrote them, and scan, where every value depends on the ones before it.
A hand-written loop is opaque, so it runs in order. A named operation carries its own rules: sum says order does not matter, which is what lets it spread across cores.
reduce parallelizes only where regrouping cannot change the answer: integer addition and multiplication, min, max. Subtraction, division and floats stay in order, with no flag to override it, because a wrong promise is a silently wrong number.
Threads are not free, so parallelism switches on above a measured size:
| Elements | min / max | sum |
|---|---|---|
| 200,000 | 0.42x (slower) | slower |
| 1,000,000 | 1.23x | 0.63x (slower) |
| 4,000,000 | 2.64x | 1.98x |
24-core machine, i64 elements: the cheapest case, so the hardest to win. Costlier comparisons cross over sooner (minimum of a million strings is 2.69x at a fifth of that size). On one core Nail skips the parallel path entirely.
Microbenchmarks of single operations, not whole programs. Most software waits on I/O and gains nothing here, where Nail emits the sequential Rust you would have written anyway.
| Workload | Hand-written sequential Rust | Nail |
|---|---|---|
| Primes below 50,000 by trial division (CPU-heavy filter) | 132 ms | 12 ms (11x faster) |
| Doubling 1,000,000 integers (trivial per-element work) | <1 ms | 1 ms (same) |
24-core Linux machine, both built with --release. Hand-written Rust runs on one core unless you parallelize it yourself. Nail spreads across the rest when the work justifies it.
Every build times every function, with nothing turned on
Not a benchmark: the live profiler dump of the Nail program serving you this page,
every function it has run since it started, counted and timed, refreshed as you watch.
Recording a call costs about 30 nanoseconds, and nailc --no-profile builds
without it.
Reading the profiler dump...
βFILESβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββReady β Untitled 1 β profiling_demo.nail β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ βNAIL - profiling_demo.nailββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ 1 βnail latest ββ 2 β// Every build is profiled. Run this program, then open it in the IDE: ββ 3 β// each function wears the timings the program just measured for itself. ββ 4 β ββ 5 βf factorial(num:i):i { β 53.7Β΅s total (0.0%) 4.5Β΅s avg Γ 12 4.9Β΅s max ββ 6 β if { ββ 7 β num <= 1 -> { r 1; }, ββ 8 β else -> { r num * factorial(num - 1); } ββ 9 β } ββ 10 β} ββ 11 β ββ 12 βf is_prime(num:i):b { β 506.4ms total (92.1%) 50.6Β΅s avg Γ 9998 12.2ms max ββ 13 β if { ββ 14 β num < 2 -> { r false; }, ββ 15 β else -> { ββ 16 β has_divisor:b = any div in array_range(2, num) { y num % div == 0; }; ββ 17 β r !has_divisor; ββ 18 β } ββ 19 β } ββ 20 β} ββ 21 β ββ 22 βf count_primes_below(limit:i):i { β 523.0ms total (95.1%) 523.0ms avg Γ 1 523.0ms max ββ 23 β primes:a:i = filter num in array_range(2, limit) { y is_prime(num); }; ββ 24 β r array_length(primes); ββ 25 β} ββ 26 β ββ 27 βprimes_found:i = count_primes_below(10000); ββ 28 βtwelve_factorial:i = factorial(12); ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ profiling_demo.nail 1:1 30 lines 786 bytes Tab 2/2 [LN,HL,BR] Ctrl+L: Line# | Ctrl+Shift+H: Highlight | Ctrl+S
A real frame from the editor. Each function wears its last run's timings: total, share of wall time, average with call count, max. Edit the file and they dim as stale until the next run.
ββ nail timing sheet ββ function calls total avg max % is_prime 9998 419.0ms 41.9Β΅s 13.9ms 2304.3% count_primes_below 1 18.2ms 18.2ms 18.2ms 99.8% factorial 12 5.2Β΅s 437ns 781ns 0.0% program wall time 18.2ms, it could run 55.0 times a second. Times are cumulative, a caller includes its callees. A share over 100% means that function ran on several cores at once.
Real output from examples/profiling_demo.nail. The last line is the whole program as
one number: over 60 runs a second reads as instant, single digits as sluggish. The
2304% share is is_prime running across every core at once, parallelized by the
compiler. Games close with one more line, like
56 fps shown, 513 fps possible, 1.95ms of real work per frame.
A file says which Nail wrote it, and that is the Nail that compiles it. Forever
Line one of every Nail file names the compiler that wrote it. It is part of the file, so it travels wherever the file goes: into git, into a zip, onto a laptop five years from now.
nail 0.1.0 total:i = array_sum([3, 4, 5]); print(string_from(total));
The command that runs it never mentions a version. nail reads line one, makes sure that exact compiler is on the machine, downloads it if it is not, and hands the file over.
$ nail run my_app.nail nail: 0.1.0 is not installed, fetching it Nail 0.1.0 [ββββββββββββββββββββββββ] 100% 774.2 MB / 774.2 MB 22.4 MB/s unpacking Nail 0.1.0 installed 12
The 12 on the last line is the program's own output: array_sum([3, 4, 5]) is 12. The install happened on the way to running the file, not as a step of its own.
The nail on your PATH is not the compiler. It is a small program that owns the set of installed versions and picks one per file. Versions sit side by side in a directory you own and never see each other, so opening an old file installs the compiler that file asks for and changes nothing about any other file on the machine. There is no global default to move and no project to migrate first.
Elsewhere a compiler version is the start of the story, and a lock file carries the hundreds of package versions it does not pin. Nail has no package manager and a closed set of libraries, all shipped inside the release, already vendored and built. Pin the compiler and every byte underneath it is pinned too. Nothing is resolved at build time, so there is nothing left to write down.
| Line one | What happens | Who writes it |
|---|---|---|
nail 0.1.0 |
Compiled by 0.1.0 and nothing else, today and in ten years | The editor, on save. nail new, on create |
nail latest |
Not settled yet. Every open asks what the newest release is, and fetches it if it is new | You, by hand, for code you are still writing |
| Missing | An error. The compiler refuses a file that has not said what built it | Nobody |
No ranges, no carets, no "any 0.3.x". A range means the same file compiling differently next year, which is the drift this exists to prevent, so the grammar cannot express one. The missing line is an error for the same reason: any default would quietly mean "whatever is newest".
Resolution is never a guess you have to make. Ask, and you get the version, the reason it was chosen, and the exact binary that would run.
$ nail which my_app.nail my_app.nail pins 0.1.0 /opt/nail/versions/0.1.0/bin/nail $ nail which work_in_progress.nail work_in_progress.nail says `nail latest`, newest installed is 0.1.1 /opt/nail/versions/0.1.1/bin/nail
A checkout can pin several versions at once, because one file was frozen last year and another moved last week. nail fetch walks a directory, reads line one of every Nail file under it, and installs every version it finds. When it finishes, every file in that tree opens with the network unplugged.
$ nail fetch . 41 Nail files, pinning 2 version(s), 1 missing Nail 0.1.1 [ββββββββββββββββββββββββ] 100% 800.2 MB / 800.2 MB 22.4 MB/s unpacking Nail 0.1.1 installed $ nail fetch . 41 Nail files, pinning 2 version(s), 0 missing nothing to fetch
Moving a file to a newer Nail means exactly one thing: rewriting line one. The rest of the file is untouched, and no other file is affected. Nothing does this on its own. A pinned file is compiled by its pin and by nothing else, even when a newer compiler would accept it, because compiling cleanly is not proof the program still behaves exactly as it was written. There is no auto upgrade path, only an edit you asked for.
nail update installs the version being moved to, compiles each file against it, and rewrites line one only for the files that pass. The check is a filter, not a guarantee: it stops the files the new version rejects outright. A file that fails keeps the line it had and keeps working, exactly as it did before the command ran. The files that do move are yours to test under their new pin.
Without --yes nothing is written. It is a list of what would be attempted.
$ nail update . 3 file(s) would move to 0.1.1 ./main.nail ./my_app.nail ./my_old_app.nail nothing changed. Add --yes to check each file and restamp the ones that pass $ nail update . --yes 3 file(s) would move to 0.1.1 2 file(s) moved to 0.1.1 1 file(s) do not compile under 0.1.1 and were left alone: ./my_old_app.nail
And on disk, that is the whole of it:
| File | Line one before | Line one after | Why |
|---|---|---|---|
main.nail |
nail 0.1.0 |
nail 0.1.1 |
Compiles under 0.1.1, so the move you asked for went ahead |
my_app.nail |
nail 0.1.0 |
nail 0.1.1 |
Compiles under 0.1.1, so the move you asked for went ahead |
my_old_app.nail |
nail 0.1.0 |
nail 0.1.0 |
0.1.1 rejects it, so the move was refused. Line one untouched, and it opens with 0.1.0 exactly as before |
work_in_progress.nail |
nail latest |
nail latest |
Tracks the newest release already, so there is nothing to move |
my_old_app.nail is not a problem to be solved before the others can move. Fix it and run
the command again, or leave it on 0.1.0 for as long as it is useful. Migration is a
per-file choice, and it is always a choice. --to=0.1.4 moves to a named
version rather than the newest installed one.
A release is one file on one box, served over HTTPS, and a version number names exactly one of them. No index to go stale, no registry to resolve against.