Introduction
Renk is an indentation-based syntax for Rust, inspired by
Rust’s Ugly Syntax. renk
transpiles .rk files to Rust and converts Rust back to Renk.
#[derive(Debug, Clone)]
pub struct User:
pub id: u64
pub name: String
impl User:
pub fn new(id: u64, name: String) -> Self = Self{ id, name }
pub fn display_name(&self) -> &str = &self.name
pub fn main():
let user = User.new(1, "Ada".to_string())
println!("{user.display_name()}")
Generated Rust:
#[derive(Debug, Clone)]
pub struct User {
pub id: u64,
pub name: String,
}
impl User {
pub fn new(id: u64, name: String) -> Self {
Self{ id, name }
}
pub fn display_name(&self) -> &str {
&self.name
}
}
pub fn main() {
let user = User::new(1, "Ada".to_string());
println!("{}", user.display_name());
}
Renk changes syntax only. Types, borrowing, macros, and crates are Rust’s, and tokens the
transpiler does not rewrite pass through as Rust. A Rust { ... } block, a :: path, or a
::<T> turbofish is always accepted.
Getting started
Install
Install the CLI and the language server from a checkout:
cargo install --path crates/renk --bin renk --bin renk-lsp
Scripts also need a nightly toolchain (rustup toolchain install nightly). The
language server uses rust-analyzer from PATH when it is installed.
A first program
Save this as hello.rk:
pub fn sum_until(values: &[i32], limit: usize) -> i32:
let mut index = 0
let mut total = 0
while index < limit:
total += values[index]
index += 1
if total < 0 then 0 else total
pub fn main():
println!("{sum_until(&[1, 2, 3], 2)}")
Then run renk transpile hello.rk -o hello.rs && rustc --edition 2024 hello.rs && ./hello, which prints
3.
The examples directory has more programs.
Commands
renk transpile input.rk -o output.rs # Renk to Rust
renk transpile input.rk --source-map out.json # also write a JSON source map
cat input.rk | renk # stdin to stdout
renk from-rust input.rs -o output.rk # Rust to Renk
renk fmt input.rk -o input.rk # format
renk run # transpile a crate, then cargo run
renk cargo test # any cargo command
renk script tool.rk arg # run a single-file script
renk add tool.rk regex # add a script dependency
renk-lsp # language server on stdio
Without -o, output goes to stdout. Run without an input while stdin is a terminal, renk
prints its help.
Syntax
Renk differs from Rust in four places:
- Blocks open with
:and end by indentation. Statements end at line ends. - Expressions add
if c then a else b, andif,match, and closure blocks can stand where a value goes. - Paths and generics use
.and[T]where Rust uses::and<T>. - Format strings take inline expressions such as
{user.name()}.
The rest is Rust. A Rust { ... } block, a :: path, or a ::<T> turbofish is always
accepted and kept as written.
Blocks
Indentation
A line ending with : opens a block that holds the more indented lines after it. This works
for struct, enum, union, trait, impl, mod, macro_rules!, fn, if, else,
match, loop, while, for, unsafe, and async. Renk writes the braces, the , after
fields and arms, and the ; after statements.
pub enum Shape:
Circle(f64)
Square(f64)
pub fn area(shape: &Shape) -> f64:
match shape:
Shape.Circle(radius) => 3.14 * radius * radius
Shape.Square(side) => side * side
Any number of spaces is one level, as long as a block’s lines agree. Tabs are an error.
unsafe:, async:, and loop: open a block as a statement or a match arm body. As a let
value or a call argument they need Rust braces, as in let task = async move { ... }. An
extern block also needs braces.
A : block may keep Rust’s opening brace, as in fn main() { or fn main() {: followed by
indented lines; no closing } is needed. A Rust { block is accepted anywhere, for example to
keep a one-line body:
pub fn main():
let value = { 1 + 2 }
if value > 2 { println!("big") }
Semicolons
A statement ends at the end of its line. Renk infers the ; from the signature: a function
returning a value keeps its last expression as the value, and a unit function ends it with
;. Write ; yourself only to discard a value that inference keeps.
Tails selected by #[cfg(...)] are each a value. One that is not already a block becomes one,
since Rust takes attributes only on statements and blocks:
pub fn separator() -> char:
#[cfg(unix)]
'/'
#[cfg(not(unix))]
'\\'
becomes #[cfg(unix)] / { '/' } / #[cfg(not(unix))] / { '\\' }.
Function shorthand
fn name(...) -> T = expr is a function whose body is one expression. The expression can open a
block, as in = match n: followed by indented arms.
pub fn twice(value: i32) -> i32 = value * 2
pub fn name(n: i32) -> &'static str = match n:
0 => "zero"
_ => "many"
Multi-line headers
A header continued over more indented lines, such as a where clause or a long condition,
ends its last line with :. The body is indented one level past the header’s first line:
pub fn show[T](value: T) -> String
where
T: std.fmt.Debug:
format!("{value:?}")
pub fn check(a: bool, b: bool) -> bool:
if a
&& b:
return true
false
A bound that rustfmt continues on a deeper line, as in Self::Item: / Clone + Default:,
ends the header on that deeper line. A where clause of an item without a body ends at its
last bound.
Groups
A line ending with ( or [, a struct literal or use tree ending with {, an unterminated
string, or a trailing = continues until the group closes. The group’s contents are Rust
syntax. .method() or ? lines at the same indent after it extend the expression.
pub fn total(values: &[i32]) -> i32:
values
.iter()
.map(|value| value * 2)
.sum()
Expressions
if expressions
if condition then a else b is a one-line if expression:
pub fn clamp(total: i32) -> i32 = if total < 0 then 0 else total
An if that opens blocks can stand where a value goes, as in let x = if c:, x += if c:,
return if c:, max(if c:, or .map(|x| if c:, with else if d: and else: lines at the
if’s indent. So can a match, as in let x = match y:, .filter(|v| match v:, or
[match x:, and else: branches after f(if c {.
pub fn sign(value: i32) -> i32:
let sign = if value > 0:
1
else if value < 0:
-1
else:
0
sign
A } line at the head’s indent continues the value with an operator:
pub fn bucket(value: i32) -> i32:
let base = match value:
0 => 1
_ => 2
} * 10
base
let ... else
pub fn first(values: &[i32]) -> i32:
let [first, ..] = values else:
return 0
*first
Closures
A closure head can open a block, as in let f = |n: u8| -> String:, move ||:, or
xs.sort_by(|a, b|:. A block whose head leaves ( or [ open ends with a line
at the head’s indent that closes them, and the rest of that line continues the expression:
pub fn sorted(mut values: Vec[i32]) -> Vec[i32]:
values.sort_by(|a, b|:
b.cmp(a)
)
values
pub fn total(values: &[i32]) -> i32:
values.iter().map(|value|:
value * 2
).sum()
A { head closes the same way with }). A closing line such as })? or }).method() keeps
its suffix after a { block, a trailing match head, or the last else of a chain whose head
ends with {. A last else on one line ends the chain with what closes the group, as in
else { floor }):
pub fn raised(values: &[i32], floor: i32) -> i32:
values.iter().map(|value| if *value > floor:
*value
else { floor }).sum()
A closing line can open the next block argument:
pub fn size(value: Option[usize]) -> usize:
value.map_or_else(||:
0
, |n|:
n * 2
)
Match arms
A match arm body that opens a block owns the indented lines after it, as in
Some(x) => match x:, Ok(v) => unsafe:, or None => Ok(unsafe:.
pub fn describe(value: Option[i32]) -> &'static str:
match value:
Some(x) => match x:
0 => "zero"
_ => "some"
None => "none"
Struct literals
Struct literals keep Rust syntax: Self{ id, name }.
Paths and generics
Dotted paths
a.b is a field or method when a is self, a SCREAMING_CASE constant, or a value bound
around it by a let, a parameter (const generics included), or a pattern. Any other a names
a type, module, or crate, so a.b is a path.
| Renk | Rust |
|---|---|
std.io.Read | std::io::Read |
Foo.new() | Foo::new() |
env.args() | env::args() |
user.name() after let user = ... | user.name() |
A binding is a value from its let to the end of its block, in its if let, while let, or
for block, in its match arm, and on its whole line, as in
if let Some(v) = x then v.len() else 0.
After an explicit :: path, a CamelCase last segment makes the following . a path:
regex::Regex.new(s) is regex::Regex::new(s), as is regex.Regex.new(s).
Config::DEFAULT.len() keeps its value receiver.
A let env = load() makes a later env.len() a method call. Parentheses keep a value receiver
the rule misses, as in (env).len(). Unicode identifiers follow Rust’s XID rules, as in
목록.변환[Vec[i32]]().
Generics
Generic parameters and arguments use [T]:
pub struct Boxed[T]:
pub value: T
impl[T] Boxed[T]:
pub fn new(value: T) -> Self = Self{ value }
pub fn parse(raw: &str) -> Result[Boxed[i32], std.num.ParseIntError]:
let value = raw.parse[i32]()?
Ok(Boxed.new(value))
| Renk | Rust |
|---|---|
values.iter().collect[Vec[i32]]() | values.iter().collect::<Vec<i32>>() |
size_of[T]() | size_of::<T>() |
serde_json::from_str[T](s) | serde_json::from_str::<T>(s) |
Vec[String].new() | Vec::<String>::new() |
values[index] | values[index] |
TABLE[i].len() | TABLE[i].len() |
name[...]( and a CamelCase Name[...] before :: or .name are generic applications. Every
other [...] is indexing; it is never inferred from its contents. To index and then call,
parenthesize as in Rust: (handlers[i])().
Any other generic application uses Rust syntax, as in .map(str::parse::<i32>).
Type positions inside statements keep [T]: let, static, and const annotations and
closure parameter and return types, as in let found: Option[i32] = None.
Escape hatches
::and::<...>force a path or generic application.- Parentheses force a value receiver; the Rust output drops them:
(Unit).method()->Unit.method(),(Ordering::Less).reverse()->Ordering::Less.reverse().
Macros and format strings
Format strings
The std format macros take inline expressions. {expr} and {expr:spec} become positional
arguments. A string inside a placeholder uses the literal’s escapes, as in
"{tags.join(\", \")}"; in a raw literal its quotes are plain, as in r#"{v.join("-")}"#.
pub fn report(name: &str, values: &[i32], tags: &[&str]):
println!("{name} has {values.len()} values")
println!("{tags.join(\", \")}")
assert!(!values.is_empty(), "{values.len():>4}")
becomes
#![allow(unused)]
fn main() {
pub fn report(name: &str, values: &[i32], tags: &[&str]) {
println!("{} has {} values", name, values.len());
println!("{}", tags.join(", "));
assert!(!values.is_empty(), "{:>4}", values.len());
}
}
A string whose placeholders are all plain identifiers, positions, or {} is left as Rust. When
a string with an inline expression has no explicit arguments, its plain identifiers become
arguments too, so the arguments keep the string’s order.
This covers format!, print!, println!, eprint!, eprintln!, write!, writeln!,
panic!, format_args!, todo!, unreachable!, and unimplemented!. The arguments of these
macros, assert!, assert_eq!, assert_ne!, their debug_ forms, dbg!, vec!, and
matches! are Renk expressions: vec![Foo.new(); 3] is vec![Foo::new(); 3].
Other macros, such as log::info!, are opaque Rust tokens, as are the payloads of #[...] and
#![...] attributes.
view!
In a view! body, such as Leptos’s, each { block that spans several lines holds Renk
statements, so they need no ;. Tags, attributes, and one-line blocks such as {count} stay
Rust tokens.
view! {
<button on:click=move |_| {
set_count.update(|n| *n += 1)
log::info!("clicked")
}>
{count}
</button>
}
becomes
#![allow(unused)]
fn main() {
view! {
<button on:click=move |_| {
set_count.update(|n| *n += 1);
log::info!("clicked")
}>
{count}
</button>
}
}
macro_rules!
Arms are indentation-based. Patterns and bodies are Rust tokens, kept as written.
macro_rules! make_message:
($name:expr) => format!("hello {}", $name)
($name:expr, $greeting:expr) =>:
format!("{} {}", $greeting, $name)
A multi-line pattern =>: arm keeps every indented body line.
Scripts
renk script [--cargo] FILE.rk [ARGS...], or just renk FILE.rk [ARGS...], transpiles a single-file script, builds it with
cargo +nightly -Zscript, and runs it with ARGS. It needs a nightly toolchain.
#!/usr/bin/env -S renk script
---
[dependencies]
regex = "1"
---
fn main():
println!("{}", regex.Regex.new("a+").unwrap().is_match("caab"))
With that #! line and chmod +x tool.rk, ./tool.rk ARGS runs the script.
renk transpile tool.rk prints the Rust that renk script builds: the frontmatter is kept and
top-level lines are wrapped in fn main. --source-map is rejected for scripts. A file is a
script when it starts with a #! line or --- frontmatter.
Dependencies
Dependencies go in the cargo-script --- frontmatter, which is a Cargo manifest. A script that
names no edition gets package.edition = "2024".
renk add FILE.rk [CARGO_ADD_ARGS...] runs cargo add on the frontmatter and writes the
result back. It creates the frontmatter after the #! line when there is none and leaves the
rest of the script untouched. Relative --path dependencies resolve from the script’s
directory.
renk add tool.rk regex
renk add tool.rk serde --features derive
A build that fails on a crate missing from the frontmatter suggests the command, as in
add it with `renk add tool.rk regex` .
Top-level statements
A script without a top-level fn main runs its top-level lines in order inside
fn main() -> Result<(), Box<dyn std::error::Error>>, so they can use ?:
#!/usr/bin/env -S renk script
let args = std.env.args().skip(1).collect[Vec[String]]()
let n: i32 = args.first().map_or("2", |s| s.as_str()).parse()?
println!("{n * 2}")
A script that depends on anyhow, eyre, or color-eyre returns that crate’s Result<()>
instead, so its bail! works. A top-level return must be return Ok(()).
Environment
RENK_SCRIPTholds the script’s absolute path.CARGO_MANIFEST_DIRandCARGO_PKG_NAMEare set ascargo runsets them.- renk exits with the script’s exit code, or 128 plus the signal number when a signal kills it.
Builds and caching
- The generated Rust goes to
$XDG_CACHE_HOME/renk/scripts/(~/.cache/renk/scripts/whenXDG_CACHE_HOMEis unset) and is rewritten only when it changes. - A run starts the cached binary without cargo and rebuilds only when the generated Rust
changes.
renk script --cargo FILE.rkbuilds through cargo on every run, which picks up dependency and toolchain updates. - All scripts build in one shared
$XDG_CACHE_HOME/renk/build/, so each dependency compiles once. Scripts building at the same time wait for each other. Deleting that directory costs a rebuild and drops the scripts’Cargo.lockfiles, so the next build can resolve newer dependency versions. - renk names a script’s package
<stem>-<hash>, whichCARGO_PKG_NAMEand the binary name show. A script that sets its ownpackage.namekeeps it and builds in a target directory next to its cached Rust instead, even under a userCARGO_TARGET_DIR.
Diagnostics
- Compiler warnings print only on runs that compile the script.
- Diagnostics point at the script’s lines and columns and quote its source. A span on a line renk synthesizes, such as a closing brace, points at the end of the script line it closes.
- rustc’s suggested replacements are Rust code, so they are left out.
- A name that does not resolve at
a.bgets a help line saying how renk read the dot. See Limits. - Panic messages and backtraces point at the script’s lines and columns.
- When renk named the package, cargo’s failure summary names the script, as in
could not compile `tool.rk`.
Cargo projects
Put .rk files where Cargo expects .rs files. These commands transpile every .rk file
under src, examples, tests, and benches, plus build.rk, to a .rs file beside it,
then run the same Cargo command:
renk build
renk check
renk run
renk test
renk clippy
renk doc
renk bench
renk cargo ARGS... # any other cargo command, as cargo ARGS...
A generated file starts with // @generated by renk; do not edit. and is rewritten only when
its content changes. renk stops with an error instead of overwriting a .rs file without that
header.
src/main.rk:
fn main():
println!("{double(21)}")
fn double(n: i32) -> i32 = n * 2
#[cfg(test)]
mod tests:
#[test]
fn doubles():
assert_eq!(super.double(2), 4)
renk run prints 42, and renk test runs doubles. mod name without a body declares a
module file, as mod name; does in Rust.
Diagnostics
- Compiler diagnostics about generated files point at the
.rkline and column and quote the.rksource. Diagnostics about other files print as rustc renders them. - rustc’s suggested replacements are Rust code, so they are left out of
.rkspans. --message-format=shortprintssrc/main.rk:LINE:COLUMN: error: ...lines.--message-format=json...prints cargo’s JSON messages with spans, quoted lines, andrenderedtext moved to the.rkfile.- Panic messages and backtraces from
renk run,renk test, andrenk benchshow.rklocations. A program whose output is a terminal still writes to a terminal. - A name that does not resolve at
a.bgets a help line saying how renk read the dot. See Limits.
Editor setup
renk-lsp is a Language Server Protocol server on stdio for .rk files. Install it with
cargo install --path crates/renk --bin renk-lsp. It provides:
- diagnostics for Renk syntax errors,
- document symbols,
- from
rust-analyzeron the generated Rust, whenrust-analyzeris onPATH: its diagnostics, including type errors from itscargo checkon save; hover; go to definition; completion; find references; rename; code actions; and inlay hints.
Positions map back to the .rk file. A rename fails when it would change Renk text that
differs from the generated Rust, and a code action is left out when its edit does not map to
the .rk lines. A resolution error at an a.b dot carries the same hint as renk check (see
Limits).
A script, which is a .rk file with a #! line or frontmatter or outside a Cargo package,
transpiles as renk script does. It gets a rust-analyzer of its own over its generated
cargo-script file in $XDG_CACHE_HOME/renk/lsp/, so its frontmatter dependencies resolve.
Zed
The extension in editors/zed
registers the Renk language, starts renk-lsp, and highlights with Tree-sitter. Install it
with Install Dev Extension in Zed’s extensions page, selecting editors/zed.
Neovim
Neovim 0.11 or later:
vim.filetype.add({ extension = { rk = "renk" } })
vim.lsp.config("renk", {
cmd = { "renk-lsp" },
filetypes = { "renk" },
root_markers = { "Cargo.toml", ".git" },
})
vim.lsp.enable("renk")
Other editors
Configure a language server with the command renk-lsp, no arguments, over stdio, for files
ending in .rk.
Converting from Rust
renk from-rust converts Rust to Renk:
renk from-rust src/lib.rs -o src/lib.rk
renk from-rust src/lib.rs -o src/lib.rk --aggressive
The output uses dotted paths, [T] generics, fn f() -> T = expr, inferred ;, and
if c then a else b only where the result transpiles back to the same Rust tokens. Format
arguments that each fill one placeholder in order are inlined: format!("{}", cli.url) becomes
format!("{cli.url}"). :: paths become dotted paths where that reads back the same:
std::env::args() becomes std.env.args(), while Config::DEFAULT.len() keeps its ::. A
turbofish call becomes name[...](, as in s.parse[i32](), and a turbofish on a CamelCase path
becomes Name[...], as in Vec[String].new(); other turbofish stays Rust.
Index-then-call receivers and path value receivers are parenthesized, as in (handlers[i])()
and (std.cmp.Ordering.Less).reverse().
--aggressive also accepts rewrites that keep the Rust tokens but not their line layout, such
as multi-line if/else value chains.
Comments, string literals, and macro bodies are kept as written. Anything Renk has no
indentation form for, such as a { block holding a match scrutinee, stays Rust syntax.
Round-trip tests
The repository tests the round trip Rust -> Renk -> Rust on a corpus of Rust files and on
pinned real crates. For each real crate, the round-tripped crate must compile, keep
every comment, and produce the same MIR as the original after normalizing source locations,
local and basic block numbering, and unit temporaries. MIR is compared with default features,
with every feature, and for the unit tests, wherever the original builds that way.
cargo test -p renk --test real_crates -- --ignored --nocapture
The test downloads the crates from crates.io and needs a nightly toolchain for
--emit=mir.
Tools
Formatter
renk fmt lays out a file the way rustfmt lays out Rust: indentation, line breaks, and spacing.
It runs rustfmt --edition 2024 on the generated Rust, so it needs rustfmt
(rustup component add rustfmt) and follows the rustfmt.toml of the working directory.
Comments, blank lines, and string contents stay. A script keeps its #! line and frontmatter.
An item that cannot be formatted without changing its Rust keeps its lines, with trailing
whitespace trimmed.
renk fmt src/main.rk -o src/main.rk
renk fmt --check src/*.rk # prints a diff and exits with 1 if a file is not formatted
Source maps
renk transpile FILE.rk --source-map MAP.json also writes a JSON tree of the Renk syntax
nodes, each with its source line, column, and text and the generated Rust lines it became.
Web runner
podman compose up --build serves the web editor at http://localhost:8080. It transpiles
in the browser and compiles and runs snippets with the image’s rustc. just web starts the
editor from a checkout.
- The image builds the web UI at build time and bundles the
renkCLI and a smallrenk-webHTTP server. Deno is used only in the build stage. - The runner bundles
trpl0.3.0, so snippets can use helpers such astrpl::block_onwithout a Cargo manifest. - The service binds to
127.0.0.1:8080on the host. The container is read-only except for/tmp, where snippets are compiled and run underRENK_RUN_TIMEOUT_MS. - Run requests are limited by default to 64 KiB of Rust source, 256 KiB of captured output, two concurrent runs, and ten runs per minute.
- Behind a Cloudflare Tunnel, put Cloudflare Access and a
/api/runrate limit in front of the service.
Limits
Renk is a syntax front end. It reads text, not types, so:
a.bis decided by name binding, not by type. A misread name usually fails to compile. Usefoo::new()to force a path, or(Unit).method()to force a value receiver. A name that does not resolve at such a dot gets a help line naming the other spelling, as inrenk read `String.nope` as the path `String::nope`; write `(String).nope` for a value.[...]after an expression is indexing unless it isname[...](or a CamelCaseName[...]before::or.name. Any other generic application uses Rust::<...>.- Macro and attribute token trees are opaque Rust tokens. Only the std format and expression macros listed in Macros are rewritten.
- Proc-macro DSLs are not parsed, except the multi-line
{blocks ofview!.
Conversion gaps
renk from-rust keeps the compiled meaning but not always the exact text:
- A unit or diverging expression that ends a block can gain a
;, as afterpanic!()or amatchstatement whose arms are expressions such asNone => v.clear(). - A
macro_rules!arm that starts on the item’s line, as inmacro_rules! m {() => {, moves to its own lines with a;after it.