This describes the language as implemented, not as designed. Everything documented here has been transpiled, compiled, and run.
Harsh is a layout transformation over Rust. The transpiler understands block structure and applies a small set of local token substitutions; it has no semantic model, no type knowledge, and no name resolution. Everything not mentioned in this guide is passed through to Rust unchanged, which means generics, lifetimes, closures, impl Trait, async/.await, macros, patterns, operators, and every future Rust syntax addition work without the transpiler knowing they exist.
Source files use the .hrs extension. The package is harsh-lang; the binaries are hrs, hrs-from, hrs-remap and hrs-lsp.
Why no braces
Here is what a brace language looks and reads like, once you notice it:
I(would, like, to, go) {
to the market so I(could buy) {
some groceries
}
to the laundromat in order to(get) {
clothes washed
}
}
and here is the message it was trying to convey:
I would like to go
to the market so I could buy
some groceries
to the laundromat in order to get
clothes washed
The second is already in the first — the indentation was there all along; the braces and parentheses only repeat it, in a form the eye has to skip past. That is sometimes hard to see, and because of it some very simple concepts become harder to learn than they are. Harsh weeds out the braces to let Rust shine.
Early in the work, Claude asked the author to justify removing braces from Rust at all. After a long enough exchange it put the answer in one line, and the line stayed:
Braces are for the compiler; indentation is for humans. — Claude
Rust without braces. Rust with pipes, partial application, comprehensions and linear algebra. Rust for functional programming, data science and machine learning.
What Harsh adds
Braces are where Harsh starts, not where it stops. It adds four things Rust has no syntax for — and they are the reason to use it. Each below is Harsh, then the Rust it replaces.
Pipes and partial application. Give a function fewer arguments than it takes, and you get a function waiting for the rest:
let double = 2.0 |> scale
let doubled: Vec<f64> =
readings <- iter$
<- map (|&x| double x)
<- collect$
let double = |x| scale(2.0, x);
let doubled: Vec<f64> = readings.iter().map(|&x| double(x)).collect();
Generator comprehensions. Say what a collection holds, not how to fill it:
let triples =
list~ (a, b, c)
for a in 1..20
for b in a..20
for c in b..20 if a * a + b * b == c * c
let triples: Vec<_> = (1..20)
.flat_map(|a| (a..20).flat_map(move |b| (b..20).map(move |c| (a, b, c))))
.filter(|&(a, b, c)| a * a + b * b == c * c)
.collect();
Linear algebra, as in Julia. Julia's matrix literal, Julia's *, and Julia's X \ y, here fitting a line by least squares:
let x = m~ [1.0 1.0; 1.0 2.0; 1.0 3.0]
let y = v~ [1.0, 2.0, 2.9]
let beta = x <- solve (&y)
let x = DMatrix::from_row_slice(3, 2, &[1.0, 1.0, 1.0, 2.0, 1.0, 3.0]);
let y = DVector::from_vec(vec![1.0, 2.0, 2.9]);
let beta = x.svd(true, true).solve(&y, 1e-12).unwrap();
Each has its section below: Pipes and partial application, Generator comprehensions, Matrices and linear algebra.
The mark
#[Ha.<rs>.h]
│ │
│ └── .h
└───── rs
Eleven characters, five readings, none contradicting the others.
- A word. Read straight through it spells
Hars.h. - An attribute.
#[…]is Rust's attribute syntax, so the mark reads as this code is Harsh. - A generic.
Ha<rs>parses as a type instantiation before the eye notices it spells a word. - A C header.
hars.his the thing you drop into any project and it works, which is close to Harsh's actual claim about Rust crates. - The file extension. Read
.handrsas one unit and it is.hrs— the real extension, hiding inside a spelling that looks like C.- This is why the orange is structural rather than decorative: it marks the half of
.hrsthat was separated from the dot to make the word work, and it is what lets a reader findrsagain once the letters have been rearranged.
- This is why the orange is structural rather than decorative: it marks the half of
Typographic rules for the mark are in docs/BRAND.md.
How to read this guide
The guide is in two parts. The tutorial builds one program from a single function to a small application, introducing each construct where it is first needed. The reference then covers every construct with all the forms it can take — indented, inline, braced, and any alternative spellings — with what each transpiles to.
Every snippet in both parts was transpiled, compiled and run before it was written down; the programs they were taken from are in examples/guide/, one per section, and the tutorial's finished program is examples/guide/30_tutorial.hrs. Where a form has a limit, the limit is stated where the form is described rather than collected at the end, though a summary of the limits found while writing this guide sits near the end for anyone checking before they rely on one.
Three rules do most of the work in Harsh, and everything else follows from them:
- A colon ending a logical line opens a block; a more-indented line anywhere else continues the current line.
- Application is by juxtaposition,
f a b, and parentheses isolate one argument — a parenthesised group with commas is a tuple, always. The reason explains every case: spaces separate arguments, so an argument of several tokens is isolated,f (n * 2) (-1) (&x), and where nothing needs separating nothing is — a lone parameter,fn greet name: &str, or a single argument after a constructor the program declares orSome,Ok,Err:Some &x,let Some mut line = row$ else:. The constructors a program declares are its structs and its enums' variants — those one level under theenum, and those written inline after its\— never a record variant's fields: with a variantEmailholdingfirst: u8, a localfirstis a name andfirst & 0x7fa bit-and. A range of atoms is an atom,f 0..3; a tuple index or a tight index applies to one atom, so a call's result is indexed isolated,(f src).0,(f src)[i], and passed as an argument isolated again,show ((f src).0). .is the path separator and<-is member access, both directions, no exceptions.
Setting up
Everything below was run, as written, before it was written down. Harsh needs one thing you may already have and one thing you build.
What you need
- A Rust toolchain —
rustcandcargo, installed with rustup.hrs lintalso needs clippy, which rustup installs as a component:rustup component add clippy. Check withcargo --version. - The Harsh repository — the archive or a clone. It is an ordinary Cargo package.
Building the tools
From the published crate:
cargo install harsh-lang
or, from the repository root, cargo install --path . to get the tree as it is. Either installs four executables into ~/.cargo/bin, which rustup already put on your PATH:
| Executable | What it does |
|---|---|
hrs |
The transpiler and the build driver: hrs new, hrs build, hrs run, and the rest below |
hrs-from |
The converter, Rust to Harsh |
hrs-remap |
Maps cargo's diagnostics back to .hrs lines; hrs calls it for you |
hrs-lsp |
The language server; the editor extensions launch it for you |
Check with hrs alone, which prints its usage. (If you would rather not install, cargo build --release leaves the same four under target/release/.)
Your first project
hrs new hello
cd hello
hrs run
prints Hello from Harsh. The project hrs new creates has four files:
hello/
Cargo.toml
Hrs.toml
.gitignore
src/main.hrs
Hrs.toml lists Harsh's dependencies — hrs_std, Harsh crates — and Cargo.toml Rust's; hrs handles Harsh's before Cargo runs (since 0.3.0).
Cargo.toml is a normal manifest, as cargo writes it. Each build writes the project as Rust into export/ (0.9.0):
hello/
Cargo.toml Hrs.toml src/main.hrs yours
export/ the Rust project
Cargo.toml yours, Harsh's crates added
src/main.rs src/ transpiled, line for line
target/ cargo's
.hrs/ hrs's own: source maps, a stamp
That is the whole arrangement. You write src/**.hrs; hrs build transpiles each one to export/src/**.rs, with its source map in .hrs/; the project's other files are mirrored into export/ beside them — assets/, Dioxus.toml, a data file in src/ an include_str! reads — so export/ builds alone; cargo compiles it there; errors come back pointing at your .hrs line and column. export/ and .hrs/ are written again from your sources, and hrs new lists both in .gitignore. Cargo.lock stays beside your Cargo.toml: hrs copies it into export/ and back.
To convert an existing cargo project, turn its src/**.rs into .hrs with hrs-from (below): a .rs file left in src/ is Rust, mirrored into export/src/ as it is, unless a .hrs of the same name replaces it. A project from before 0.9.0, whose Cargo.toml points at target/src/, is stopped with a message; hrs migrate moves it.
The commands
hrs build [cargo args] transpile, then cargo build
hrs run [cargo args] transpile, then cargo run
hrs test [cargo args] transpile, then cargo test
hrs check [cargo args] transpile, then cargo check
hrs lint [cargo args] transpile, then cargo clippy
hrs watch [subcommand] rebuild on every save (default: check)
hrs new [--lib] <name> create a project laid out for Harsh: a program,
or with --lib a library
hrs add <crate>.. add Harsh crates to Hrs.toml (hrs_std, ...);
a Rust crate is added to Cargo.toml with cargo add
hrs -c <cmd> [args] cargo <cmd> in export/, after writing it:
hrs -c publish sends the Rust to crates.io
hrs -- <program> [args] any program, run in export/: hrs -- dx serve
hrs migrate bring an older project up to date: Harsh's crates
from Cargo.toml to Hrs.toml (0.3.0), the targets
from target/src/ (0.9.0)
hrs publish check the package for Harsh's registry (not open
yet: it says how to share today)
hrs <a.hrs> [b.hrs ..] transpile files in a Rust project, each beside its
.hrs, seeing each other's functions; --check
writes nothing and fails on a stale .rs
hrs dist <dir> write Harsh's standard distribution (hrs_std and
the macro crates) into <dir>, for building
outside a Harsh project -- the Jupyter kernel's use
hrs <input.hrs> [-o out.rs] [--map out.map.json]
transpile a single file
A workspace has one export/, at its root: the whole workspace as Rust, each Harsh member transpiled at its place (export/app/src/main.rs), each Rust member mirrored, one target/ and one Cargo.lock for all. At the workspace's root, a Cargo.toml with [workspace] and no [package], hrs build, check, test, lint and fmt go through the members in the order members lists them: a Harsh member is built by hrs, a Rust member by cargo, and the first failure stops the run. hrs -c … there is cargo in the root's export/, for the whole workspace. hrs run acts on one program, so there it names the members instead.
export/ is how Harsh code is shared with Rust users: hrs -c publish publishes it to crates.io, and they get Rust, with the part of hrs_std it uses as its own module. Harsh's ~ macros unfold before transpiling, so they are not in export/; to share a library with Harsh users, macros included, share its Harsh (a path dependency, for now; a Harsh registry is planned). The Book's chapter 17 has both.
Anything after the subcommand goes to cargo unchanged: hrs run --release, hrs test -- --nocapture. hrs run runs the program in the folder you are in, as cargo run does; hrs -c run runs it in export/, as cd export && cargo run would. Each command first reports what the transpile step did — hrs: transpiled 1 of 3 file(s) or hrs: 3 file(s) up to date — so a build that does nothing says so.
The Rust in export/ is there to be read — by you, by a reviewer, by a collaborator without hrs, by crates.io. It keeps the .hrs file's lines, one for one, so that every diagnostic maps back to the right line; for the same reason it is not formatted, since formatting would move every byte the source map depends on. It builds with cargo alone (cd export && cargo build), and nothing in it points back at the .hrs files: it is written again by every build.
Bringing existing Rust in
hrs-from src/old.rs -o src/old.hrs
The converter writes Harsh from Rust: braces become indentation, :: becomes ., .method() becomes <- method$, a fn's parameter list becomes one group per parameter, use a::{b, c} becomes use a.(b, c). Its output is valid Harsh that transpiles back to token-identical Rust — the transpiler's own source goes through this round trip in the test suite — and it is the fastest way to see what a file of your own looks like in Harsh.
Editors
- VSCode —
code --install-extension harsh-lang.harsh-lang, or search for Harsh in the Extensions view (the listing); to run the tree's own version instead,npm installthennpx @vscode/vsce packageineditors/vscode-harshand install the.vsix. Highlighting for.hrs, folding by indentation, and — withhrs-lspon yourPATH, whichcargo install --path .puts there — layout-aware Enter and Tab: Enter after a chain line lands under the previous arrow, afterlet d =one level in, after a block opener inside the block; Tab on a fresh line moves to the next column the layout allows, and past the last of them keeps indenting a unit at a time; Shift-Tab comes back a unit at a time to column 0. Setharsh.serverPathif the binary lives elsewhere. Marketplace listing to come. - Zed —
editors/zed-harshis a Zed extension backed by the tree-sitter grammar ineditors/tree-sitter-harsh; install it as a dev extension from the Zed command palette once the grammar repository it names is reachable. It launcheshrs-lsptoo, so Enter behaves as in VSCode; Zed has no way to bind Tab to a language-server request, so Tab there stays Zed's own. - Anything with tree-sitter —
editors/tree-sitter-harshis the grammar; it parses every example in this repository with no error nodes.
None of this is required. A .hrs file is plain text and hrs does not care which editor wrote it.
What is not there yet
The language server, hrs-lsp, does on-type formatting, as described under Editors, and formatting on save — hrs fmt on the buffer — and, since 0.1.38, types on hover and go-to-definition, asked of rust-analyzer about the Rust the file became and mapped back to your lines (install it with rustup component add rust-analyzer, or have VS Code's rust-analyzer extension, whose copy is used; the answers are converted to Harsh -- fn adding (x: i32) (y: i32) -> i32 -- and a documentation example too long to convert stays Rust). They follow your last save: a line you have changed since is answered after the next. Since 0.1.39, completion too, on the text as you type it -- names, as Rust's language server offers them. If an answer is empty, set HRS_LSP_LOG to a file and read what was asked. Inline errors are not there yet; errors appear when you run hrs check or hrs watch, mapped to the right line. hrs fmt applies the layout style this guide follows: hrs fmt --check lists the files that would change, hrs fmt rewrites them in place; it changes no token and never joins lines. Design in docs/FMT.md.
Part one — a tutorial
The program counts words in a text and prints the most frequent ones in a chosen format. It is around sixty lines when finished and uses most of the language on the way there.
A first function
Harsh has no braces. A colon at the end of a line opens a block, and the block is whatever is indented beneath it.
fn tokenize text: &str -> Vec<String>:
text <- split_whitespace$
<- map (|w| w <- to_lowercase$)
<- collect$
Three things are visible already.
fn tokenize text: &strdeclares one parameter without parentheses. Parameters are one group each,fn f (a: T) (b: U); a single parameter may drop its parentheses.<-is method call and field access — Rust's.. Reading left to right,text <- split_whitespace$istext.split_whitespace().split_whitespace$applies the function to nothing.$is the zero-argument call, written tight against the name like the!of a macro; a bare name is a value — the function itself — rather than a call. Rust's()is not used for this because in Harsh()is only ever the unit value.
The Rust it becomes:
fn tokenize(text: &str) -> Vec<String> {
text.split_whitespace().map(|w| w.to_lowercase()).collect()
}
Calling it
Arguments are juxtaposed: the function, then each argument separated by spaces.
fn main$:
let words = tokenize "the cat sat on the mat"
println! "{} words" (words <- len$)
The second line shows the one rule about parentheses that matters most. words <- len$ is not an atom — it contains an operator — so to pass it as an argument it must be isolated in parentheses. Parentheses never group several arguments; they mark the boundary of one. println! "{} words" (words <- len$) is a macro applied to two arguments, a string and an isolated expression.
Macros and functions follow the same rule. println! "{} words" x is println!("{} words", x).
Counting
use std.collections.HashMap
fn count (words: &[String]) -> HashMap<String, usize>:
let mut m = HashMap.new$
for w in words:
*m <- entry (w <- clone$) <- or_insert 0 += 1
m
use std.collections.HashMap— the path separator is.. Rust's::is a syntax error in Harsh.HashMap.new$— a path to an associated function, then a zero-argument call.- The
forloop opens a block with its colon, like a function does. - The last line of a block is its value.
mis returned because it is last and has no;. Semicolons are inserted for you on every other line.
A chain across lines
sorted turns the map into a list ordered by count. The chain of method calls is long, so it is broken across lines: a more-indented line continues the current one, and the leading <- makes each link visible.
fn sorted counts: HashMap<String, usize> -> Vec<(String, usize)>:
let mut v: Vec<(String, usize)> = counts <- into_iter$ <- collect$
v <- sort_by
|a, b|:
b.1 <- cmp (&a.1) <- then_with (|| a.0 <- cmp (&b.0))
v
The closure passed to sort_by is written with its body indented rather than in braces: |a, b|: opens a block, and the block is the closure's body. When a closure ends a line this way, the transpiler closes the argument list after the block, producing v.sort_by(|a, b| { … }). The closure starts its own continuation line so that its body indents from it, not from v — the header and the body then form one shape, which is the layout style this guide follows throughout.
The whole of v <- sort_by |a, b|: … is one statement — the closure is an expression inside it — so it ends with ; in the Rust because another statement follows it, exactly as any statement does. The v on the last line is the function's value.
Pipes
The three functions compose into a pipeline. |> passes the values on its left to the function on its right — and if fewer are given than the function takes, the rest is deferred as a closure, which is how the pipes double as partial application. Here every function takes one value, so each step is a plain call.
fn pipeline text: &str -> Vec<(String, usize)>:
text |> tokenize |> (|w: Vec<String>| count (&w)) |> sorted
text |> tokenizeistokenize(text); the chain reads left to right.- The middle step is a closure in isolating parentheses, because the function a pipe applies must be one atom, and a closure with a body is not one until it is isolated.
count (&w)—&wis an operator expression, so it too is isolated.count &wwould be bitwise-and.
The backward pipe <| exists as well: tokenize <| text. It is right-associative, so f <| g <| x is f(g(x)).
The pipes do one more thing, and it is the thing that makes them Harsh's rather than borrowed. A pipe may carry several values — 3 5 |> sub — and if it carries fewer than the function takes, the rest is deferred: the result is a closure waiting for what is missing. |> fills parameters from the left, <| from the right, and both on one function leave a hole in the middle.
fn scale (factor: f64) (offset: f64) (x: f64) -> f64:
x * factor + offset
let f = 2.0 0.5 |> scale → move |__hrs1| scale(2.0, 0.5, __hrs1)
let g = scale <| 10.0 → move |__hrs1, __hrs2| scale(__hrs1, __hrs2, 10.0)
f 3.0 → 6.5
g 2.0 0.5 → 20.5
fis a closure over the last parameter;gover the first two. Both are values: bind them, pass them tomap, return them.- With every parameter supplied the closure disappears and it is a plain call:
2.0 0.5 3.0 |> scaleisscale(2.0, 0.5, 3.0). - Harsh knows how many parameters
scaletakes becausescaleis declared in this project. For a function it cannot see into — the standard library, a crate — a pipe is a plain call with the values given.
The reference section "Pipes" and "Partial application with the pipes" have every form and every error.
A type for the output
#[derive Debug Clone PartialEq]
enum Format
Plain
Ranked
Boxed
width: usize
An enum body lists its variants one per line, and nothing marks the body: only a name can follow enum, so the deeper lines can only be variants. Each variant is one of the three struct forms: Plain is unit-like, a tuple variant would be Circle f64 — the payload types juxtaposed after the name — and Boxed is a record variant whose fields are indented beneath it, or written Boxed\ width: usize on one line when that reads better.
struct Report
counts: Vec<(String, usize)>
format: Format
impl Report
fn new (counts: Vec<(String, usize)>) (format: Format) -> Self:
Report\ counts, format
fn top (&self) (n: usize) -> Vec<(String, usize)>:
self <- counts <- iter$ <- take n <- cloned$ <- collect$
Struct fields go one per line with no commas, and the declaration's body follows its name with no mark. A struct is built with \: Report\ counts, format — see "Building a struct".
Matching
fn render_line (&self) (idx: usize) (word: &str) (n: usize) -> String:
match &self <- format\
Format.Plain => format! "{word} {n}"
Format.Ranked => format! "{}. {word} {n}" (idx + 1)
Format.Boxed\ width => format!
"|{:<w$}|{n:>3}|"
word
(w = *width)
matchopens a block; each arm is one line, pattern=>body, and the commas Rust needs are inserted.Format.Plainin a pattern uses the same.path separator as everywhere else.(w = *width)— a named format argument contains=, which terminates an application run, so it is isolated like any other non-atom.
An arm whose body needs several lines ends with => and indents:
fn render (&self) (n: usize) -> String:
let mut out = Vec.<String>.new$
for (i, (w, c)) in self <- top n <- iter$ <- enumerate$:
out <- push (self <- render_line i w (*c))
out <- join "\n"
Vec.<String>.new$ is Rust's Vec::<String>::new(), its :: written . as everywhere. (*c) is isolated because *c is an operator expression.
Putting it together
fn main$:
let text = "the cat sat on the mat. The mat sat still; the cat did not."
let counts = pipeline text
let boxed = Report.new (counts <- clone$) (Format.Boxed\ width = 6)
let ranked = Report.new counts Format.Ranked
println! "{}" (boxed <- render 3)
println! "{}" (ranked <- render 2)
|the | 4|
|cat | 2|
|mat | 2|
1. the 4
2. cat 2
Report.new counts Format.Ranked is three atoms — a path, a name, a path — and needs no parentheses at all. Report.new (counts <- clone$) (Format.Boxed { width: 6 }) isolates its two non-atom arguments. That contrast is the whole of the application rule.
What the tutorial skipped
Partial application (the pipes), inline blocks on one line, while let, labelled loops, let … else, the three closure spellings, and the full set of match patterns. Each is in the reference below with every form it takes.
Part two — the language
Blocks
The three kinds, and what opens each
A block's opener says how its entries end. There are three kinds, and every block in Harsh is one of them:
| opener | the body | separator Harsh writes |
|---|---|---|
| (no mark) | items, after a header that ends itself — struct, enum, union, impl, trait, mod, extern |
none (or , for a declaration's fields) |
\ |
a comma-separated list: a literal's fields, an inline declaration, a match's arms |
, |
: / do: |
statements, and a tail value | ; |
impl, trait, mod and extern take no mark because nothing but a name
can follow them, exactly as for struct and enum: the header ends itself,
so what follows deeper can only be the body.
impl Point impl Point {
fn x (&self) -> f64: fn x(&self) -> f64 {
self <- x self.x
}
}
A grouping whose grammar is not Harsh's — a macro's DSL — is not a block at
all: it is written in the macro's braces, as Rust, with Harsh in its holes
(A macro's own language, below). #:, which once served it, is retired.
Opening a block
A logical line whose last significant token is a colon opens a block. The colon is replaced by an opening brace, the following more-indented lines become the body, and a closing brace is emitted when indentation returns to the header's level.
fn greet (name: &str) -> String:
format! "Hello, {name}"
fn greet(name: &str) -> String {
format!("Hello, {name}")
}
A match is a specification block in use, like a literal's fields, so it is opened by \ — match value\ with the arms beneath, or match value\ p => e, q => f inline — and its arms are comma-separated in the Rust. Each arm's body opens with a fat arrow, since an arm already has one:
match value\
Some n =>
log n
n * 2
None => 0
- The colon must be the last significant token on the logical line, which is why it never collides with type annotations — those are always mid-line.
- Trailing comments do not count, so
fn f$: // notestill opens a block. - A block opener with no indented body following it is an error, reported against the colon.
The three ways to write any block
- Every block can be written indented, inline on one line, or with Rust's braces, and all three emit identical Rust.
fn indented (t: bool) -> i32:
if t:
1
else:
2
fn inline (t: bool) -> i32:
if t: 1 else: 2
fn braced (t: bool) -> i32:
if t { 1 } else { 2 }
- The inline form is a colon that is not the last token on its line; it is described under Inline blocks.
- The braced form is Rust's, on one line only:
{ let u = 3; u * u }. A{and its}on different lines is an error naming:/do:— two syntaxes for one block across files is the drift the language refuses. Inside a one-line brace pair, Rust's rules apply unchanged. - Two places keep multi-line braces because there they are not a block: a hole in markup or a tree (
on:click={move |_|:with the body beneath and}closing it — the brace supplies an expression to an attribute, and the layout inside is the hole's), and a brace group handed as an argument,json! ({ … }), an object literal. - Which to use is a style question. The indented form is the idiom; the inline form suits one-line conditionals; braces are the one-line spelling, and what you reach for when pasting a line of Rust.
- A block in operand position —
a && { … }in Rust — is ado:block isolated in parens:a && (do:with the body beneath and); the parens are optional grouping and are not emitted. A block as a value islet x = do:with the body beneath. An empty function body is written as the unit:fn main$:/(), with any comment above it.
The two block openers
:anddo:open a block interchangeably, on any construct that takes one. (A declaration takes none —struct,enum,union,impl,trait,mod,extern: the body follows the header, andstruct P dois refused likestruct P:; a literal is marked\.) A:inside a declaration's header is the header's own, as in Rust: supertraits,trait Loggable: Debug + Display, and bounds,impl<T: Debug> Loggable for T. Atrait,implorenumwith no body is its header alone, one line, and becomes Rust's{}-- a marker trait and its blanket implementation (0.1.44), a type with no values,enum Void(0.1.45); a unitstructkeeps Rust's;.
fn f (x: i32) -> i32 do:
x + 1
fn g$:
if true do:
print! "a"
let h = |x| do:
x * 2
- They emit identical Rust.
dois reserved in Rust and collides with nothing. - The reason for the second spelling is the eye:
-> String:can be read as a type annotation for a moment, and-> String do:cannot.dois familiar from several languages and never looks like a declaration, since nothing precedes it the wayletprecedes a binding. - Bare
do:— with nothing before it — is the standalone block, described under Standalone blocks.
Continuation lines
A more-indented line that does not follow a block opener is a continuation of the current logical line rather than a new block. This is what makes multi-line method chains work with no leading-dot rule and no explicit line-continuation marker.
let listener =
TcpListener.bind "127.0.0.1:8089"
<- await
<- unwrap$
let d =
a_name_too_long_to_chain_from
<- iter$
<- map (|n| n * 2)
<- sum.<i32>$
- A logical line ends when the next line is at the same indentation or shallower.
- Continuation applies to any construct, including multi-line
whereclauses before a block-opening colon.
fn parse_all<'a, I> (items: I) -> Result<Vec<i64>, ParseErr>
where I: Iterator<Item = &'a str>:
// body
Bracketed regions
Inside ( or [, a line break continues the expression: a multi-line argument list or array literal needs no mark.
- Parentheses are transparent to layout for blocks: a closure body written inside a call may be indented beneath it, as Parens are transparent to layout shows.
- Braces are not a region of free layout. In Harsh code they are Rust's one-line block,
{ let u = 3; u * u }, and a{with its}on different lines is an error; in a Rust macro's own language they belong to the macro, which may break them as it likes.
Layout style
hrs fmt applies everything in this section — hrs fmt --check lists the files that would change, hrs fmt rewrites them, the editors format on save — and it changes no token and never joins lines: it adds the breaks the style asks for and puts every line at its column.
The layout rules require a block to be indented past its statement. The recommended style is stricter: a block's lines are indented past the column of the construct that opened it, so a header and its body form one shape. When the opener is the first token on its line — fn, if, a match at the margin — the two rules coincide. They differ only when the opener is mid-line, and that is where the weaker form reads badly: the header far right, the body far left, nothing joining them.
The way to keep the opener at the start of its line is to break after =, so the closure or match begins the continuation and its body indents from there:
let a = not let a = |x: i32| -> i32:
|x: i32| -> i32: let d = x * 2
let d = x * 2 d + 1
d + 1
let f = not let f = |x: i32| -> i32:
|x: i32| -> i32: x * 2
x * 2
let g = not let g = |x: i32| do:
|x: i32| do: x * 4
x * 4
let z = || 7 not let z = ||:
7
A chain's shape follows its length. One or two links sit on one line; three or more take one link per line, the arrows aligned under the first. The one-line form gives way when the line would pass 72 columns of code: then every link stands on its own line. A chain that is the value of a = starts on the line after it — = ends its line — and if it is one or two links that fit there, it stays horizontal on that line. A block-bodied closure never sits inside a horizontal chain, since its dedent breaks the line the chain is drawing:
let message = not let message = receiver <- lock$ <- unwrap$ <- recv$
receiver <- lock$
<- unwrap$
<- recv$
let statuses: Vec<Status> = not let statuses: Vec<Status> = (0u32..3) <- map Status.Value <- collect$
(0u32..3) <- map Status.Value <- collect$
v <- iter$ not v <- iter$ <- map (|x|:
<- map (|x| x * 2) x * 2)
<- filter (|x| x > &2) <- filter (|x| x > &2)
<- count$ <- count$
In a chain with one link per line the arrows align under the first arrow when it follows a single name, whatever column that is — alignment outranks the indentation unit, which governs only the first line of a construct. When the receiver is a longer expression the links sit one unit past its line instead, so they do not drift to the right of a long receiver:
["/sleep", "/", "/nope", "/"] <- iter$
<- map (|path| fetch path)
<- collect$
A chain is a run of arrows each applied to the result of the one before. The four arrows of self <- width > other <- width && self <- height > other <- height are four chains of one link each, on four operands, and the rule leaves that line alone. hrs fmt applies all of this.
When the chain has a left-hand side, break after =; the = always ends its line and never starts one. A block-bodied closure, if one is needed, is isolated in parentheses with the ) on its own line:
let d: Vec<i32> = not let d: Vec<i32> = v <- iter$ <- map (|x|:
v <- iter$ x * 10
<- map (|x| x * 10) ) <- collect$
<- collect$
let d: Vec<i32> = not let d: Vec<i32>
v <- iter$ = v <- iter$
<- map ( <- map (|x| x * 10)
|x|: <- collect$
x * 10
)
<- collect$
Every bracket group — the parentheses isolating an argument, a tuple's, a list's brackets, a macro's — has an anchor: the callee when a name precedes the bracket (map (, foo.bar (), the bracket itself when nothing does (let t = (). The group's contents are one unit past the anchor, and its closer stands on its own line under the anchor. The group then has one shape wherever it sits, and the shape survives a rename, since no column depends on the length of a name. Inside the brackets, Rust's rules hold as always — a tuple or array spelled one element per line keeps its commas:
let t = let xs =
( [
1, 1,
2 2
) ]
A call with several arguments does not use parentheses this way — a parenthesised group is one argument — so it breaks by juxtaposition, each argument a continuation line one unit past the callee, and there is no closer:
let t =
compute
1
2
Breaking after = is again what puts the anchor at the start of its line; let xs = [ with the elements one unit past the [ and the ] under it is accepted and the same rule, only wider. Braces are not in this sentence: { is Rust's, and what follows it is laid out by Rust's rules.
One compact form is accepted: when the nested construct begins on the same line as the bracket and its body is a single line, the closer ends that line rather than taking one of its own —
v <- iter$
<- map (|x|:
x * 10)
<- collect$
— the body still one unit past the construct that opened it (|x|, not the line). With a body of more than one line the closer goes back under the anchor; a closer at the end of an arbitrary last statement is the trailing-comma problem in a new coat.
When the receiver is long — a long name, or a path call — it takes the continuation line alone and the arrows hang under it, one level in:
let listener =
TcpListener.bind "127.0.0.1:8089"
<- await
<- unwrap$
None of this is enforced by the transpiler; all of it is what the formatter will produce. Every form on this page, on both sides, is valid Harsh.
Breaking long lines
The rules the Harsh in this guide and the books is written by (2026-09-29).
They are for people writing Harsh. The transpiler accepts every form;
hrs fmt applies what can be counted -- chains, lists and if chains of
three or more, and calls made long by long arguments -- and leaves the rest
to the writer: a literal inside a literal, an if of three clauses as a
let's value (it takes do:), a call deep inside another, and an inline
if c: a else: b -- too long, write it as a block if, each value beneath
its branch:
let what =
if s <- subject <- is_empty$:
s <- body <- clone$
else:
s <- subject <- clone$
Never break an inline if at its <-: the condition and both values end up
on one column, and a link after else: reads as if it applied to the whole
if when it applies to the else value only.
- A chain of more than two steps — three or more
<-in one chain — is broken: the value goes on the line after the=, and each step beneath the first, its<-under the first<-.
let args =
args <- into_iter$
<- map (|a| fold a)
<- collect$
- A struct literal of more than two fields takes one field per line,
beneath its
\, with no commas — the newline separates them.
let v =
Value\
value = None
getter = getter
status = |x: &i32| if *x > 1: Status.Stale else: Status.Fresh
- More than two clauses of
ifandelsego on several lines, eachelseunder itsif. As the value of alet, the chain is the last line of ado:block.
let x = do:
if n == 1: 10
else:
if n == 2: 20
else: 30
- A
matchon a chain of more than two steps matches a name: bind the chain first. An inlinematchhas at most two arms; more go beneath, one per line.
let count =
items <- iter$
<- filter (|x| **x > 1)
<- count$
let n = match count\
0 => "none"
_ => "some"
- Long function and macro calls caused by long arguments, not the number of
arguments, go vertical: the name on its own line, each argument beneath
it, one per line. It is the length of the arguments that decides — a long list of short
arguments stays on its line (
v~ 2 3 5 6 7 8 8 89 9 …), and a short call with one long argument breaks. Some calls sit on the fence; the writer decides. Where an argument's position is awkward — deep inside another call, after a=>— naming it first often reads better than breaking.
fn describe (good: bool) (name: &str) -> String:
format!
"{} ({})"
name // on the fence: short
(if good: "good dog" else: "dog") // a long argument
- A chain of one link is never broken, however long:
let label = message <- subject_line_for_privilege_log_with_redactions_applied$stays as it is. When long arguments make it long, the long-call rule applies to the method: the chain moves to the line after its=, and its arguments go beneath the method, one unit past it — both at once. Without a=, the arguments go beneath (the user's ruling, 2026-10-12).
let label =
message <- subject_line_for_privilege_log
(first_custodian_name_argument)
(second_matter_reference)
message <- send_to_reviewer_queue_for_second_pass
first_custodian_name_argument
second_matter_reference
- A literal holding another literal: on one line the inner one is
parenthesised — the transpiler refuses it otherwise, since where it ends
cannot be seen:
inner = (Animal\\ name = "Rex"), good = truewhengoodis the outer literal's,inner = (Animal\\ name = "Rex", good = true)when it is the inner's. Or the outer literal goes one field per line, where the line ends the inner one:
let d =
Dog\
inner = Animal\ name = String.from "Rex"
good = true
Blank lines and comments
- Blank lines are insignificant. Up to two consecutive blank lines are preserved in the output.
- Comment-only lines float to the next real line, so their indentation never opens or closes a block accidentally.
- Comments are preserved in the generated Rust, including doc comments, which rustdoc then consumes normally.
Exercises
Harshlings — fifty small exercises, Rustlings-style, in Harsh: cargo install harsh-lang, clone, hrs run. The topics follow The Harsh Programming Language, and Harsh's own rules — the layout, $, the \ literal, the pipes, a spaced [ inside an application — sit where the construct is taught.
Doc examples
A fenced block inside a /// or //! comment is the one part of a comment the transpiler reads, because rustdoc reads it too: it lifts the block out, compiles it and runs it as a test. It is code, so it is Harsh, and the transpiler writes the Rust rustdoc expects.
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5
/// let answer = my_crate.add_one arg
///
/// assert_eq! 6 answer
/// ```
pub fn add_one x: i32 -> i32:
x + 1
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}
- Which fences: exactly the ones rustdoc compiles — an empty info string,
rust, or the doctest attributes (ignore,no_run,should_panic,compile_fail,edition2021, ...). A fence that names another language (text,toml) is prose and is copied through untouched. - An example is a list of statements: it is transpiled inside a wrapper, as rustdoc also wraps it, so its last line keeps its
;rather than becoming a block's tail expression. - rustdoc's hidden lines (
#inside the fence) are its mark, not Harsh's#: the marker is taken off before the line is read and put back after. - An error in an example is a transpile error, reported on the line of the
.hrsfile where you wrote it. hrs-fromconverts in the other direction, so a crate brought in from Rust has its doc examples in Harsh like the rest of it.
Function parameters
A function has one parameter syntax: one parenthesised group per parameter. The groups concatenate into Rust's comma list. A single parameter may drop its parentheses — that is not a second form, only the one-group case with nothing to separate.
fn greet2 (name: &str) (age: i32) -> String:
format! "Hello, {name}, age {}" age
fn greet name: &str -> String:
format! "Hello, {name}"
fn greet2(name: &str, age: i32) -> String { .. }
fn greet(name: &str) -> String { .. }
- Adjacent parenthesised groups concatenate, with commas inserted between them.
- A group holds exactly one parameter. Rust's comma list,
fn f (a: T, b: U), is a syntax error at the comma — it would be a second spelling of the same declaration.hrs-fromwrites the groups for you when bringing Rust in. Commas inside a parameter are not separators and are fine: a tuple pattern((a, b): (i32, i32)), a payload(i32, i32). A function pointer type is an application,(g: fn i32 i32 -> i32). - A function with no parameters is
fn f$— the same$that applies a name to nothing at a call. An empty group()in a declaration is an error, since a group holds a parameter and()is the unit value; a parameter of type unit is an ordinary group,(u: ()). - The rewrite is bounded: it applies only between the function name (after any generics) and the
->or the block-opening colon, so no expression parsing is involved.
The bare form has one restriction. A parameter type may itself contain ->, and there is then no way to tell which arrow ends the parameter, so a type containing a top-level -> must be parenthesised:
fn apply (g: fn i32 -> i32) (v: i32) -> i32:
g v
Arguments juxtapose in the same way; see Application.
Every parameter form
- All of these are legal and produce the Rust shown.
fn curried (a: i32) (b: i32) -> i32: fn curried (a: i32, b: i32) -> i32
fn bare name: &str -> String: fn bare (name: &str) -> String
fn tuple ((a, b): (i32, i32)) -> i32: fn tuple ((a, b): (i32, i32)) -> i32
fn unit$ -> i32: fn unit() -> i32
fn takes_unit (u: ()) -> i32: fn takes_unit(u: ()) -> i32
fn generic<T: std.fmt.Debug> (label: &str) (item: T) -> String:
fn fnptr (g: fn i32 -> i32) (v: i32) -> i32:
takes_unit (u: ())— the unit type in a parameter is Rust's, untouched; only the group around the parameter is Harsh's.fnptr (g: fn i32 -> i32)— the parameter's type contains->, so it must be parenthesised; the bare form would not know which arrow ends the parameter.- Generic parameters go where Rust puts them, between the name and the first group.
selfis a parameter like any other under these rules: alone it may be bare or grouped,fn top &self:orfn top (&self):; with others it is a group,fn top (&self) (n: usize). A bare parameter followed by a group —fn top &self (n: usize)— is an error naming the grouped form.
Inline blocks
A colon that is not the last token on a line opens an inline block. It ends at the end of the logical line, at else, or at a separator that is not its own. Items inside carry the separator the indented form would have inserted.
| Block kind | Separator | Inline form |
|---|---|---|
| match arms | , |
match x\ A => 1, B => 2 |
| fields | , |
struct P: x: i32, y: i32 |
| statements | — | one expression: do: a * 2, if c: f$ else: g$ |
An inline block holds one expression — one rule, no separator. Several statements on one line are Rust's braces, if c { f$; g$ } else { h$ } or { let a = 1; a * 2 } on its own, and a ; inside an inline colon-block is an error. The reason is that an inline statement block nested in a comma-block has no end the layout can find: A => do: f(); g(), B => 2 would put g() outside the arm. Braces say where a block ends; the colon form does not, on one line. The two are not combined: if c: { .. } is an error, since the braces open the block by themselves and the colon would open a second one inside it. Braces are for short blocks — Rust's line and Rust's judgement; anything longer takes the indented form. (A ; that ends the whole line still discards the tail value, as everywhere.)
A block passed as an argument is isolated like any argument: myfunc ({ let u = 3; u * u }). Braces right after a name are refused — myfunc { … } is a struct literal to Rust, and Harsh builds a struct with \ (since 0.5.0).
if t: 1 else: 2 → if t { 1 } else { 2 }
for i in 0..3: f i → for i in 0..3 { f(i) }
Each else binds to the nearest open if; written on several lines — the
style this guide keeps for more than two clauses — the indentation shows
which. Braces select the other reading:
if a: if a { if b { 1 } else { 2 } } else { 3 }
if b: 1
else: 2
else: 3
if a: if a { if b { 1 } else { 0 } } else { 2 }
if b { 1 } else { 0 }
else: 2
The inline and indented forms of the same program emit identical Rust; this is enforced by a test.
Inline forms of each construct
- Every block-opening construct has an inline form, and what separates its items is whatever the indented form would have inserted.
if t: 1 else: 2
if let Some n = v: n else: -1
match n\ 0 => "zero", _ => "some"
for i in 0..3: s += i
while let Some top = st <- pop$: popped += top
{ let a = 1; a * 2 }
struct P\ x: i32, y: i32
- A
letbinding may take an inline block as its value:let r = if let Some n = v: n * 2 else: 0. - An inline block ends at the end of its logical line. Writing
else: if …on one line andelse:on the next therefore orphans the secondelse, and it is rejected; both the fully inline and the fully indented chain work.
if n == 1: 10 ✓ several lines
else:
if n == 2: 20
else: 30
if n == 1: ✓ indented
10
else if n == 2:
20
else:
30
if n == 1: 10 ✗ rejected
else: if n == 2: 20
else: 30
Precedence
Rust's operators keep Rust's precedence. Harsh's own forms sit above them:
application binds tighter than anything but an atom, the chain <- next,
then ? and the prefix operators, as in Rust. The Book's appendix
Precedence gives the whole table in words; here, each rule beside the Rust
it becomes.
Application, chains, and operators:
let a = f$ let a = f();
let a = f a b let a = f(a, b);
let a = f (-1) (&x) (*y) (a + b) let a = f(-1, &x, *y, a + b);
let a = T.new "s" <- run (&mut p) let a = T::new("s").run(&mut p);
let a = x <- f a <- g b let a = x.f(a).g(b);
let a = f a b <- m$ let a = f(a, b).m();
let a = x <- field let a = x.field;
let a = let a = x.f().g.h();
x <- f$
<- g
<- h$
let a = x <- get i? let a = x.get(i)?;
let a = !x <- is_empty$ let a = !x.is_empty();
let a = x <- ptr$ as usize + 1 let a = x.ptr() as usize + 1;
let a = f a * 2 == g b let a = f(a) * 2 == g(b);
let a = (1..=10) <- map f let a = (1..=10).map(f);
Dereference is a prefix operator on the whole chain after it:
let a = *p <- field let a = *p.field;
let a = (*p) <- field let a = (*p).field;
let a = *p <- m$ let a = *p.m();
let a = (*p) <- m$ let a = (*p).m();
let a = *x <- get i? let a = *x.get(i)?;
let a = **x let a = **x;
*s <- flag = true *s.flag = true;
Pipes: atoms on the left, one function on the right, |> left to right and
<| right to left:
let a = 2 3 |> add let a = add(2, 3);
let a = a |> f |> g let a = g(f(a));
let a = (f x) |> g let a = g(f(x));
let a = f <| g <| x let a = f(g(x));
let a = (a |> f) + 1 let a = (f(a)) + 1;
a |> f + 1 is refused — after a pipe's function only <| may follow — and
x + 1 |> f too: a pipe's left side is atoms. And the traps, each of which
transpiles to something that is not a call:
let a = f -1 let a = f -1; subtraction: write f (-1)
let a = f *x let a = f *x; multiplication: write f (*x)
let a = f x |> g let a = g(f, x); two values piped: write (f x) |> g
let a = f a.b let a = f(a::b); a path: the field is a <- b
Closures
A closure body may be indented, using either |x|: or |x| do: — the two are identical, because do: is the existing bare-block marker and a closure body is a brace block.
let f =
|x: i32| -> i32:
x * 2
let g =
|x: i32| do:
x * 4
let z = || 7
As a trailing argument, the closure's argument list closes after the block. If the chain continues past it, the closure is isolated in parentheses so the ) says where it ends:
v <- iter$ v.iter()
<- map (|n|: .map(|n| {
n * 2 n * 2
) })
<- collect$ .collect()
Closure parameters are written as in Rust: a comma list inside the pipes, typed or not — |a, b|, |a: i32, b: i32|, |a: &i32, b: &i32| -> bool: with a block body. There is no curried closure form, because Rust already gives |a| |b| body a meaning — a closure returning a closure — and Harsh never changes what Rust means. So the asymmetry is Rust's: fn parameters are one group each; closure parameters are a comma list. When what you want is a closure over the remaining parameters of a function, a pipe gives it without writing one.
let add = |a: i32, b: i32| a + b // two parameters, comma list
let curried = |a: i32| move |b: i32| a * b // a closure returning a closure, as in Rust
let sub2 = 10 |> sub // a closure over the remaining parameters
A closure is recognised only where it is unambiguous — parameters ending a block header. Elsewhere | and || remain operators, and a closure needs isolating parens: f (|x| body).
Every closure form
let a =
|x: i32| -> i32: // indented body, with return type
let d = x * 2
d + 1
let b =
|x: i32| do: // indented body, `do:` marker — identical to `|x|:`
x * 3
let z = || 7 // zero parameters, inline body
let e =
move |x: i32| -> i32: // capturing by move
x + base
let c = apply (|x| x + 100) 1 // inline body, isolated as an argument
|x|:and|x| do:are the same thing.do:is the ordinary bare-block marker and a closure body is a brace block.- A closure with an inline body inside a call must be isolated in parentheses, because
|is also an operator and only the block-header position is unambiguous.
Closures with indented bodies as arguments
- When a closure's parameter list ends a line, the indented block beneath is its body, and the statement containing it is an ordinary statement — it ends where its block ends and takes a
;if another statement follows.
v <- sort_by v.sort_by(|a, b| {
|a, b|: a.cmp(b)
a <- cmp b });
println! "{:?}" v println!("{:?}", v)
- Whether a following line is inside the closure or after it is decided by indentation alone, like any block: indent it under the body and it is part of the closure.
- If a chain continues after the closure, the closure must be isolated in parens. The
)is what says where the closure ends and the chain resumes; without it the chain has nothing to attach to, and it is rejected with a message naming this fix.
v <- iter$ v.iter()
<- map (|x|: .map(|x| {
x * 2 x * 2
) })
<- filter (|x| x > &2) .filter(|x| x > &2)
<- count$ .count()
- The
)sits on a line of its own at the closure's column, as above; it may also end the body's last line,x * 2), which the layout accepts but the style does not use.
let d: Vec<i32> =
v <- iter$
<- map (|x|:
x * 10
)
<- collect$
- Parens are transparent to layout: the
:opens a block inside them exactly as it would outside, and the body is governed by the same rules — several statements, nested blocks, its own closures. Parens isolate, or raise precedence; they neither open nor close anything. A block written inside a group simply owns no token outside it, so it ends where the group ends. - The same holds on one line. A closure's prototype
|p|followed by:opens an inline block, and the block ends at the)of its group; whatever follows the)carries on the statement, and may open another such block:
xs <- iter$ xs.iter().map(|p| { if *p > 1 { *p } else { 0 } }).collect()
<- map (|p|: if *p > 1: *p else: 0)
<- collect$
xs <- iter$ xs.iter().map(|p: &i32| { *p + 1 }).sum()
<- map (|p: &i32|: *p + 1)
<- sum$
xs <- iter$ <- fold 0 (|acc, x|: acc + x) xs.iter().fold(0, |acc, x| { acc + x })
let f = |x|: x + 1 let f = |x| { x + 1 };
- A line inside an open
(indents past the line that opened it, or is the)that closes it; a line at the opener's column is an error, as it would be outside a group. |x|:and|x| do:are the same closure: a prototype followed by a block, in either of the block's spellings.do:emits one pair of braces wherever it stands.- The bare form and the isolated form emit the same Rust for the closure; the isolated form additionally lets the statement carry on.
Statements and semicolons
Harsh writes no semicolons. The transpiler inserts them from the layout, and a ; ending a line is refused (since 0.2.0).
- Every logical line in a statement block gets a semicolon except the last, so the last line is a tail expression exactly as in Rust.
- A line beginning with
let,use,const,static,type,mod,extern, orreturnalways gets a semicolon, including when it is last, because those are statements rather than tail values. - To discard a block's last value, end the block with a line
(): the block is then worth(), as Rust'sexpr;makes it. The Rust is written as Rust is written —expr;, with no(). - A nested block gets a semicolon after its closing brace only when its header began with
let,const,static,type, oruse. Aniformatchused as a statement does not. - Attribute lines never take a separator and never count as a block's tail.
fn total (xs: &[i64]) -> i64:
let mut sum = 0
for x in xs:
sum += x
sum
fn total(xs: &[i64]) -> i64 {
let mut sum = 0;
for x in xs {
sum += x
}
sum
}
The three rules
- A statement ends at a newline, unless an unclosed bracket or a deeper-indented next line carries it on.
let xs = [1,followed by2]on the next line is one statement. - A block whose last value is to be discarded ends with a line
(), which says so where the block's value goes — as a Jupyter cell is silent when its value is(). A;ending a line is refused, with that fix in the message. - In the Rust, every statement ends with
;except the last in its block, which gets one only when a()line follows it. This applies at every nesting level independently — a closure body is a block and is governed by the same rule at its own level.
parent_statement parent_statement;
my_func my_func(|| {
|| do: closure_statement;
closure_statement last_closure_statement
last_closure_statement });
parent_statement parent_statement;
last_parent_statement last_parent_statement
parent_statement parent_statement;
my_func my_func(|| {
|| do: closure_statement;
closure_statement last_closure_statement;
last_closure_statement });
() parent_statement;
parent_statement last_parent_statement;
last_parent_statement
()
A call whose last argument is an indented block — a closure, an async move: body — is one statement, and its () goes on the line after the block, at the call's own column:
spawn async move: spawn(async move {
work$ work()
() });
- Going the other way,
hrs-fromremoves every;, and writes a()line where a block's last statement discarded its value — except after alet, which Harsh terminates itself, and after a macro that produces no value (println!,assert!,panic!and the like), whose;changes nothing. That is why the round trip is exact.
Worked forms
fn tail$ -> i32: fn tail() -> i32 {
let a = 1 let a = 1;
a + 1 a + 1
}
fn discard$ -> (): fn discard() -> () {
let a = 1 let a = 1;
a a;
() }
fn ret$ -> i32: fn ret() -> i32 {
if true: if true {
return 9 return 9
0 }
0
}
return 9gets no;even though it is not the block's last line in the source, because{ return 9 }is valid Rust and the line is last in its block.let v = if true: 1 else: 2— a block used as a value gets its;after the closing brace, because theletneeds one.
Paths and member access
This is the largest departure from Rust, and the one that costs paste-compatibility.
The path separator
A dot is the path separator, replacing ::.
use std.collections.HashMap
let v = geometry.Vec2\ x = 1.0, y = 2.0
let e = ParseErr.BadDigit c
:: is not valid in Harsh. Writing it is a syntax error, because allowing both spellings for one thing is exactly the drift that a permissive rule produces:
error: `::` is not valid in Harsh; the path separator is `.`
--> general.hrs:30:9
|
30| impl fmt::Display for Circle:
| ^^
Member access
A left arrow is field access and method call, replacing Rust's dot.
self <- x * self <- x + self <- y * self <- y
let name =
params <- name
<- as_deref$
<- unwrap_or "World"
- The arrow binds tightly, so whitespace around it is dropped in the output while line breaks are kept. This is what allows the vertical chain style.
<-is written with a space on each side,value <- method. Rust lexes<-as one token too, and so writes a comparison with a negative with a space between<and-:x < -1. Written tight,x<-ywould be the fieldx.y— and compile, whenxhas a fieldyof the right type — so Harsh refuses<-without its spaces (since 0.5.0).- A name always follows
<-— a field or a method; that is what the arrow means, reaching into a value. A number, a negative or a bracket after it is refused:t <- 3is the tuple itemt.3, written with a dot, andx <- -3the comparisonx < -3(since 0.5.1).
The mapping is a plain substitution in both directions, with no context and no exceptions:
| Rust | Harsh |
|---|---|
:: |
. |
. |
<- |
"empty" <- to_string$
String.from "hi" <- len$
s <- len$
params <- name
self <- field
Chain forms
<-always has a space on each side.p<-y,p <-y,p<- yandx<-1are refused, the message naming both readings: a method step isvalue <- method, a comparison with a negative isvalue < -x.
let a = p <- x // a field
let b = v <- len$ <- to_string$ // two steps
let c = x < -1 // a comparison with a negative
- A horizontal chain and a vertical one are the same expression; the vertical form is the continuation rule with
<-leading each line.
let e =
v <- iter$
<- max$
<- copied$
<- unwrap_or 0
let d =
v <- iter$
<- map (|n| n * 2)
<- sum.<i32>$
sum.<i32>$— a generic method call takes its list with a dot, as Rust'ssum::<i32>().- A tuple index or float keeps its dot —
t.0,t.1,2.5— and a tuple index is part of its atom, soshow t.0 t.1is two arguments:show(t.0, t.1).
Where the dot is not a path separator
Three cases, and they are different tokens rather than exceptions to the rule above:
- After a numeric literal it is part of a float:
1.0,2.5e3. - Before a digit it is a tuple index:
t.0,t.0.1. Safe because a path segment can never begin with a digit. - Doubled it is a range or rest pattern:
0..3,1..=5,..base,Foo { x, .. }.
Everywhere else a . becomes :: and a <- becomes ., unconditionally.
Writing s.len() in Harsh therefore produces s::len(), which rustc rejects with a diagnostic that remaps to the right column and suggests an instance method. The transpiler does not try to catch this itself: a . following a value could only come from :: following a value in Rust, which is not valid Rust either, so the converter can never produce one. The only source is a typo, and rustc already reports those.
There is no accommodation for pasted Rust. Use hrs-from, which converts completely and predictably.
Generics
Rust's :: is Harsh's . — everywhere, the turbofish included. A generic list applied in an expression is written .<T>, as Rust writes ::<T>; in type position it is written as in Rust.
let v = Vec.<i32>.new$
let m = HashMap.<String, usize>.new$
let counts: Vec<(String, usize)> =
tally (&words) <- into_iter$
<- collect$
let v = Vec::<i32>::new();
let m = HashMap::<String, usize>::new();
let counts: Vec<(String, usize)> = tally(&words).into_iter().collect();
- One rule, no exception: a reader who knows Rust's
::<T>knows Harsh's.<T>. - The mapping is exact:
.<for::<, and<for<. Nothing is inserted and nothing guessed; rustc judges the result, soVec<i32>.new$is Rust'sVec<i32>::new(), which rustc rejects. - A
<opens a generic list in a definition's header (fn f<T>,struct S<T>); in a type — after an annotation's:, after->, afteras, inimpl … forandwherebounds, up to the next=,),,,|,]or block opener; after a.; and at the start of a qualified path,<T as Trait>.f$. Everywhere else it is a comparison:s < to && a.lo > b.
Generics in every position
fn generic<T: std.fmt.Debug> (label: &str) (item: T) -> String: function
struct Generic<T> struct
inner: T
impl<T: std.fmt.Debug> Generic<T> impl
fn show (&self) -> String:
format! "{:?}" (self <- inner)
let m = HashMap.<String, i32>.new$ call — `.<T>`, Rust's `::<T>`
let parsed = "42" <- parse.<i32>$ <- unwrap$ method — `.<T>` after the method
let v: Vec<i32> = q$ type — untouched
- In type position a generic list is written as in Rust, so
let v: Vec<i32>emits exactly that; in an expression it takes its dot,Vec.<i32>.
Items
Use declarations
A dot followed by a parenthesised group is a use-tree group. It is the only grouped form: use never opens an indented block, and use a.b: with entries below is a syntax error. Inside the parentheses the commas are yours, as in Rust.
use axum.(
serve,
Router,
routing.get,
response.(Html, IntoResponse),
extract.(Query, Path)
)
use tokio.net.TcpListener
use axum::{
serve,
Router,
routing::get,
response::{Html, IntoResponse},
extract::{Query, Path}
};
use tokio::net::TcpListener;
- The group spans lines freely because parentheses suppress layout.
-
.(is not valid Rust in any position, so the rewrite needs no context. -
Nested groups and
selfwork inside a group:
use std.io.(self, Write)
use std.collections.(
BTreeMap,
HashSet,
)
Structs and enums
A declaration's body follows its name with no mark: struct and enum take exactly one name (and its generics, and at most a [where …]), so what stands deeper can only be the body. Fields and variants are one per line; the commas are supplied.
#[derive Debug Clone] #[derive(Debug, Clone)]
struct Point struct Point {
x: f64 x: f64,
y: f64 y: f64,
}
enum Shape enum Shape {
Empty Empty,
Circle f64 Circle(f64),
Rect Point Point Rect(Point, Point),
Named Named {
name: String name: String,
sides: u8 sides: u8,
},
}
There are three forms of struct — record (named fields; Rust's docs call this one simply a struct), tuple (fields by position), unit-like (no fields) — and an enum is a union of variants each of which is one of the three, spelled as that struct is spelled with the struct and the name's line taken away. A tuple variant's payload is an application — the variant applied to its types, one atom each, a generic type isolated: Boxed (Box<List>). A record variant is its bare name with the fields beneath, or Named\ name: String, sides: u8 on its own line inside the block: the block and inline forms mix freely, and which reads better depends on the names and their lengths.
Inline, the field list is marked with \ and its items comma-separated. The \ says where the header ends and the parts begin, as the newline does in the block form:
struct Point\ x: f64, y: f64
enum Shape\ Circle f64, (Named\ name: String, sides: u8)
struct Wrapper<T> [where T: Display]\ field: T, other: u32
Every struct form:
struct Unit struct Unit; // unit-like struct
struct Meters f64 struct Meters(f64); // tuple struct: the name applied to its payload
struct Pair i32 i32 struct Pair(i32, i32);
struct Wrapper<T> (Vec<T>) struct Wrapper<T>(Vec<T>); // a generic payload, isolated
struct Point struct Point { // record struct
x: f64 x: f64,
y: f64 y: f64,
}
struct Inline\ x: f64, y: f64 struct Inline { // record struct, inline
x: f64,
y: f64,
}
struct Bounded<T> [where T: Display] struct Bounded<T> // with a where clause: brackets on the header
field: T where
T: Display,
{
field: T,
}
struct Point: — a colon on the header — is an error naming the markless form: braces build nothing in Harsh, and a colon would be the one block opener that is not a keyword or a prototype.
Building a struct — the literal
A value is always marked \. In expression position a bare Point is a complete expression already (a unit struct's value, a constant, a function), so the mark is what says "the name is the head, and fields follow". Fields take = — the : is the declaration's — a bare field is the shorthand, ..base is last:
let p = let p = Point {
Point\ x: 5.0,
x = 5.0 y: 7.0,
y = 7.0 };
let p = Point\ x = 5.0, y = 7.0 let p = Point { x: 5.0, y: 7.0 }; // inline: to the end of the line, or the `)` isolating it
let q = Point\ x, y let q = Point { x, y }; // shorthand
let r = Point\ x = 1.0, ..base let r = Point { x: 1.0, ..base }; // update syntax
let m = Message.Move\ x = 1, y = 2 let m = Message::Move { x: 1, y: 2 }; // a record variant
An inline literal reads to the end of its line or to the ) that isolates it, which is what lets it stand anywhere an expression can — in a header, in a list, as an operand:
match (Point\ x = 1.0, y = 4.0)\ match (Point { x: 1.0, y: 4.0 }) {
Point\ x, .. => x
if p == (Point\ x = 1.0): if p == (Point { x: 1.0 }) {
let v = vec! (Point\ x = 1) (Point\ x = 2)
(Point\ x = 1, y = 0) + (Point\ x = 2, y = 3)
A literal passed as an argument is isolated like any argument of several tokens -- inline, or with its fields beneath and the ) after them -- and a constructor's argument is no exception (since 0.8.2; the bare form, push Card\, is refused with the isolated one named -- it became push(Card) { … }):
cards <- push (Card\ id = 1, number = 2) cards.push(Card { id: 1, number: 2 });
Ok (View\ title = t) Ok(View { title: t })
With its fields beneath, the literal's fields stand one unit past its name and its ) under its (; when that is too far for the line, the ( goes beneath the callee, one unit past it (hrs fmt lays it out so, 0.8.4):
cards <- push (Card\ cards.push(Card {
id = 2 id: 2,
title = t title: t,
) });
cards <- very_long_method_called_push cards.very_long_method_called_push(Card {
(Card\ id: 2,
id = 2 title: t,
title = t });
)
Because the field list reaches its group's ), a name after the last = field is a shorthand field, never something outside the literal — the transpiler reads no type to decide, so the line says what it means on its own, and rustc says whether the fields fit:
struct Point struct Point {
x: i32 x: i32,
msg: String msg: String,
}
fn a msg: String -> Point: fn a(msg: String) -> Point {
Point\ x = 1, msg Point { x: 1, msg } // preferred: no parens to wonder about
}
fn b (msg: String) -> Point: fn b(msg: String) -> Point {
(Point\ x = 1, msg) Point { x: 1, msg } // the same literal, grouped
}
fn c msg: String -> (Point, String): fn c(msg: String) -> (Point, String) {
((Point\ x = 1, msg = m$), msg) (Point { x: 1, msg: m() }, msg) // a tuple: the literal has its own group
}
Written (Point\ x = 1, msg) when Point has no field msg, it is still one literal, and rustc names the missing field. Whether a literal is satisfied is rustc's question; where it ends is the layout's, and that never depends on a definition. hrs-from isolates a literal that is one element of a Rust tuple for the same reason.
Point { x: 1 } — a brace literal — is an error, on one line or several: one spelling per construct.
Patterns
A pattern destructures with the same mark: Point\ x, y, Point\ x: px, .. (a rename keeps Rust's :). It is the one spelling: a braced pattern, Point { x, y }, is refused (since 0.6.0). Nested in another pattern — a tuple's element, a closure's parameter — it is isolated like a nested literal: for (i, (Point\ x, ..)) in v, |(Point\ x, ..)| x.
let Point\ x, y = p
match q\
Point\ x: 0, y => println! "on the y axis at {y}"
Some (Point\ x, ..) => x
Traits and impls
Item bodies take no separator, except that a declaration without a body gets a semicolon.
pub trait Shape
fn area (&self) -> f64
fn describe (&self) -> String:
format! "shape with area {:.2}" (self <- area$)
impl Shape for Circle
fn area (&self) -> f64:
3.14159 * self <- r * self <- r
fn area (&self) -> f64has no body, so it is a required method and terminates with a semicolon.fn describeopens a block and becomes a default method.-
The same rule covers associated types, associated constants,
useinside an impl, and item macros. -
A generic
impland a traitimplfor a tuple struct:
impl<T: std.fmt.Debug> Generic<T>
fn show (&self) -> String:
format! "{:?}" (self <- inner)
impl Area for Tuple
fn area (&self) -> f64:
(self.0 * self.1) as f64
Modules
mod geometry
pub struct Vec2
pub x: f64
pub y: f64
impl Vec2
pub fn dot (self) (o: Vec2) -> f64:
self <- x * o <- x + self <- y * o <- y
A module declaration without a body is just mod name and gets its semicolon automatically.
Constants and statics
const MAX_DEPTH: usize = 8
static GREETING: &str = "tokenizing"
Type aliases
type Pair = (i32, i32)
let p: Pair = (8, 9)
- A
typeline always terminates with;, likeconstandstatic.
Where clauses
- Two forms, both legal. The single-line bare form needs nothing:
fn one<T> (x: T) -> T where T: Clone:
x
- The multi-line form is bracketed, so that the clause reads as part of the header rather than as a sibling of the body — which lets the body sit at the same indent as the clause instead of one level further right:
fn three<T, U> (a: T) (b: U) -> String
[where
T: std.fmt.Debug,
U: std.fmt.Display]:
format! "{a:?} {b}"
- The brackets are stripped on emission. They are a reading aid, not a parsing necessity.
A where clause that spans lines must be bracketed, because the : in a bound would otherwise be read as a block opener. Brackets suppress layout, so the bound is safe inside them; they are stripped on emission.
fn f<T> (x: T) -> T
[where T: Clone + Send]:
x
fn g<T, U> (a: T) (b: U) -> String
[where
T: std.fmt.Debug,
U: std.fmt.Display]:
format! "{a:?} {b}"
- The body may be indented to any depth greater than the
fn; bothxandxwork, since the bracketed line is a continuation of the signature. - A single-line
whereneeds no brackets:fn f<T> (x: T) -> T where T: Clone:is fine.
Application
A run of atoms is a call. Parentheses isolate one argument and never group several, so a group with commas is always a tuple, and an empty group () is the unit value. Applying to nothing is $, a suffix.
f a b → f(a, b) two arguments
f (a, b) → f((a, b)) one tuple argument
f$ → f() zero arguments: `$` applies a name to nothing
f () → f(()) one argument, the unit value
f (a + 1) (g b) → f(a + 1, g(b)) a non-atom argument needs isolating parens
An atom is a non-keyword identifier (with optional path, generics and macro bang), a literal, a tuple index like t.0, a parenthesised group, or any of these followed by $ — f$ is one atom, so g f$ 5 is g(f(), 5) and f$ 5 applies the result of f() to 5. An index written tight, a[i], is part of its atom: f a[i] is f(a[i]), as f t.0 is f(t.0), and a[i].0[j] is one atom too. Brackets never apply — a [i] is the same index — but inside an application the spaced form is refused, since it could only mean the index of the wrong thing: write a[i], or (a [i]) to make it one argument. An array passed as an argument is isolated: f ([1, 2, 3]).
Application binds tighter than <-: f x <- g$ is (f x) <- g$. To apply the arrow to the argument instead, isolate it.
show "abc" <- to_string$ → show("abc").to_string()
show ("abc" <- trim$) → show("abc".trim())
Macros follow the identical rule:
println! "{}" (greet "Ben") → println!("{}", greet("Ben"))
format! "Hello, {name}" → format!("Hello, {name}")
- Rust call syntax written into a macro —
println!("{}", greet("Ben"))— is rejected, since under these rules it passes a single tuple. - A macro with a pattern argument juxtaposes like anything else, the pattern isolated as one argument:
matches! x (Some n if n > 1). A stream that is not a list of expressions is written in braces and kept as written:vec! { 0; 4 },quote! { … }. - Braces are opaque to application.
What is and is not an atom
- An application run is a head followed by atoms. The run ends at the first thing that is not an atom, and everything after that is emitted as written.
| Atom | Not an atom — isolate it |
|---|---|
x, 42, "s", 'c' |
a + 1, *c, &w, !flag |
Some, Point.new, std.io.stdout |
w = *width (named format argument) |
(a + 1), (g b), t.0 — a group, or a tuple index |
|x| body — a closure with an inline body |
m~ — a macro name |
f(a, b) — an argument list, which is an error |
f$, s <- len$, (add 10)$ — an application to nothing |
f $ — the $ is a suffix and is never spaced |
- The rule that decides every row: if it contains an operator, isolate it.
count &wis bitwise-and;count (&w)is a call.render i w *cis a multiplication;render i w (*c)is a call.
let b = two (a + 1) (one 2) → let b = two(a + 1, one(2))
let e = takes_unit () → let e = takes_unit(())
let f = (one) 3 → let f = (one)(3)
- A parenthesised group may itself be the head of an application:
(add 10) 7applies the closure thatadd 10returns, and(add 10)$applies it to nothing. $is the one suffix. It means apply to nothing in a call —main$,String.new$,v <- iter$ <- collect$,m~$— and no parameters in a declaration,fn main$:. The two are the same rule read from either side. It is tight, like!;f $with a space is an error namingf$. Because$carries this meaning,()never has to:f ()passes the unit value,Ok ()isOk(()),tx <- send ()sends unit, and there is no(()). (A macro transcriber's$ais a prefix, a metavariable, and is an atom there.)
The four uses of parentheses
- Parentheses have exactly four jobs in Harsh. Anything else is an error.
- A tuple. A parenthesised group containing top-level commas is a tuple, always:
(2, "good", 4.0), andf (a, b)passes one tuple argument. The empty group()is the empty tuple, unit, and nothing else. - One parameter group in a declaration.
fn f (a: T) (b: U)— each group holds one parameter, or several with commas in Rust's form. - Isolating one argument. A multi-token argument is wrapped so that its boundary is unambiguous:
f (a + 1) (g b). This is needed whenever an argument is more than one token, and for a closure whenever its body is on the same line —map (|x| x * 2)— or whenever a chain follows an indented one. - Grouping in a
usetree.use axum.(serve, Router).
- A tuple. A parenthesised group containing top-level commas is a tuple, always:
- Passing an argument list is not one of them.
f(a, b)reads under these rules as a call with a single tuple argument, and since that is almost never what was meant, a macro written that way is rejected outright; a function written that way is a type error in the Rust.f()reads asf ()— spacing never matters — and passes unit; rustc reports the wrong count on the right line. The zero-argument call isf$. - Parens change precedence and isolate. They never change the layout or the meaning of what they surround: a block opened inside them is still a block, governed by the same rules.
- Square brackets have one job outside Rust's own arrays and indexing:
[where …], which visually separates a multi-line where clause from the body beneath it so the body need not indent further. The bare single-line formfn f<T> (x: T) -> T where T: Clone:is equally legal.
Pipes
Two operators, and one idea: a pipe fills a function's parameters — |> from the left, <| from the right. When the count reaches the function's arity the result is a plain call; when it falls short, what is missing is deferred as one flat closure. That makes the pipes both the way to apply without parentheses and the way to partially apply.
fn sub (a: i32) (b: i32) (c: i32) -> i32:
a - b - c
3 |> sub → move |__hrs1, __hrs2| sub(3, __hrs1, __hrs2)
3 5 |> sub → move |__hrs1| sub(3, 5, __hrs1)
3 5 10 |> sub → sub(3, 5, 10)
sub <| 3 → move |__hrs1, __hrs2| sub(__hrs1, __hrs2, 3)
sub <| 3 5 → move |__hrs1| sub(__hrs1, 3, 5)
sub <| 3 5 10 → sub(3, 5, 10)
1 |> sub <| 3 → move |__hrs1| sub(1, __hrs1, 3) the middle hole
- Each side is a sequence of atoms, all arguments.
f a |> gisg(f, a), notg(f(a)); to pipe the result of an application, isolate it:(f a) |> g. The same on the right of<|:merge <| (f a). An empty call is one atom, somerge <| routes_hello$needs no isolation; and()is the unit value, an atom like any other:() |> sendissend(()). - The function is one atom, and
a |> f <| bis one application:afills from the left,bfrom the right. |>is left-associative,<|right-associative, and a chained result is one value:x |> f |> gisg(f(x)),f <| g <| xisf(g(x)).
double <| 21 → double(21)
show <| double <| 3 → show(double(3))
5 |> double → double(5)
5 |> double |> show → show(double(5))
show (double <| 4) → show(double(4)) a pipe inside an argument
(double 4) |> show → show(double(4)) a result, isolated
Partial application with the pipes
A pipe that supplies fewer values than the function takes does not fail; it defers. The result is a closure over the parameters that were not filled, and that closure is an ordinary value.
fn sub (a: i32) (b: i32) (c: i32) -> i32:
a - b - c
let s3 = 3 |> sub → move |__hrs1, __hrs2| sub(3, __hrs1, __hrs2) two left
let s3_5 = 3 5 |> sub → move |__hrs1| sub(3, 5, __hrs1) one left
let last = sub <| 1 → move |__hrs1, __hrs2| sub(__hrs1, __hrs2, 1) filled from the right
let mid = 3 |> sub <| 1 → move |__hrs1| sub(3, __hrs1, 1) the middle hole
s3 5 1 → s3(5, 1) = -3
- Which parameters are filled is decided by the side:
|>fills the first ones,<|the last ones, and the two together leave a hole in the middle. There is no placeholder token; the hole is what is left. - Arity is what tells a call from a partial. Harsh knows it for a
fndeclared anywhere in the project, whatever declaration form was used, and for a partial bound bylet. It does not know it for an imported function, a method, or a closure parameter — those live in Rust's type checker. Unknown arity: the atoms given are taken as all of them, and it is a plain call, sotext |> String.fromandx |> println!work as they read; if the count was wrong, rustc says so. Known arity with too many arguments is a Harsh error naming the function and both counts. - A partial is a value: bind it, pass it, store it, return it, apply it by juxtaposition like any function. Bound by
let, its remaining arity is known, so it composes.
fn sub (a: i32) (b: i32) (c: i32) -> i32: fn sub(a: i32, b: i32, c: i32) -> i32 {
a - b - c a - b - c
}
fn apply2 (f: impl Fn (i32) (i32) -> i32) fn apply2(f: impl Fn(i32, i32) -> i32, a: i32, b: i32) -> i32 {
(a: i32) (b: i32) -> i32:
f a b f(a, b)
}
fn main$: fn main() {
let sub3 = 3 |> sub let sub3 = move |__hrs1, __hrs2| sub(3, __hrs1, __hrs2);
let sub3_5 = 5 |> sub3 let sub3_5 = move |__hrs1| sub3(5, __hrs1);
let a = sub3_5 100 let a = sub3_5(100); // = -102
let b = 5 |> (3 |> sub) let b = move |__hrs1| (move |__hrs1, __hrs2| sub(3, __hrs1, __hrs2))(5, __hrs1); // the same as sub3_5, inline: a partial's remaining arity is known
let c = apply2 sub3 5 100 let c = apply2(sub3, 5, 100);
let d = 7 |> (30 40 |> sub) let d = (move |__hrs1| sub(30, 40, __hrs1))(7); // arity met: a plain call, the closure gone
println! "{a} {} {c} {d}" (b 100) println!("{a} {} {c} {d}", b(100))
}
- The closure is flat — one closure with all the remaining parameters, not a chain of one-parameter closures — which is what lets
sub3 5 100apply it in one step. It costs nothing at runtime: the compiler aliases(move |p| f(a, p))(b)tof(a, b), verified in the generated assembly. - A function with nothing supplied is already a value in Rust, so there is nothing to defer: pass
one, not a pipe. - Errors, each naming the fix:
1 2 3 4 |> sub `sub` takes 3 parameter(s) and 4 were piped in
x |> f y after a pipe's function only `<|` may follow; isolate: `x |> (f a)`
1 + 2 |> f a pipe's arguments are atoms; isolate an expression in parentheses
f $ `$` applies a name to nothing and is written tight against it: `f$`
fn f (): `()` is the unit value; a function with no parameters is `fn f$`
- A prefix
$ain amacro_rules~body is Rust's metavariable sigil and is an atom.
Functions returning closures
Written as a function returning a closure, with no special support needed — the ordinary rules apply the result:
fn add a: i32 -> impl Fn i32 -> i32:
move |b| a + b
let inc = add 1 → let inc = add(1);
let r = inc 5 → let r = inc(5);
let s = (add 10) 7 → let s = (add(10))(7);
let t = 3 |> (add 100) → let t = (add(100))(3);
A parenthesised group may head an application, which is what makes (add 10) 7 work.
Match
Arms are separated by line breaks rather than commas. An arm body may be inline after the fat arrow, or an indented block when the arrow ends the line.
match parse_all items\
Ok ns =>
let total: i64 = ns <- iter$ <- sum$
println! "parsed {:?} total {}" ns total
Err e => println! "error: {:?}" e
- Commas are inserted after every arm, including the last.
- Guards and or-patterns pass through unchanged.
- A
;at the end of an arm is rejected.;discards a block's tail value, and an arm body is not a block. The same applies to struct fields, enum variants and use-tree entries.
A struct literal may appear in a scrutinee or condition, which Rust forbids unparenthesised. In Harsh it is isolated, as any argument with structure is, and the parentheses carry into the Rust; the arm's pattern takes \\ like the literal:
match (P\ x = 1, y = 2)\
P\ x, y => x + y
match (P { x: 1, y: 2 }) {
P { x, y } => x + y,
}
Every match form
match s\ // indented arms
Shape.Empty => "empty" <- to_string$
Shape.Circle r => format! "circle {r}"
Shape.Rect w h => // block-bodied arm
let area = w * h
format! "rect {area}"
Shape.Named\ name, sides => format! "{name}/{sides}"
match n\ 0 => "zero", _ => "other" // inline arms
match n { 0 => "zero", _ => "other" } // braced
Patterns
- Patterns juxtapose with the same grammar as application:
Shape.Circle r,Shape.Rect w h,Some x,Ok (a, b)for a tuple payload. - Record patterns keep braces:
Shape.Named { name, sides },E.Rec { a, b }.
match n\ // guards
x if x < 0 => "neg"
0 => "zero"
x if x % 2 == 0 => "even"
_ => "odd"
match p\ // tuple patterns
(0, y) => y
(x, 0) => x
(x, y) => x * y
match n\ // ranges
0..=9 => "digit"
b'a'..=b'z' => "lower"
_ => "other"
match n\ // bindings
v @ 1..=5 => format! "low {v}"
v => format! "hi {v}"
match n\ 1 | 2 => "small", _ => "other" // or-patterns
Match as a value and nested matches
let r = match n\
1 => 10
_ => 20
match a\
Some x =>
match b\
Some y => x + y
None => x
None => 0
- A
matchused as aletinitialiser gets its;after the closing brace. - A nested
matchin an arm body is an ordinary block-bodied arm; indentation does the rest.
If
Every if form
if n < 0: indented, with else-if chain
"neg"
else if n == 0:
"zero"
else:
"pos"
if n < 0: "neg" inline arms, several lines
else:
if n == 0: "zero"
else: "pos"
if n < 0 { "neg" } else if n == 0 { "zero" } else { "pos" } braced
if a: else binds to the nearest if → 2 when a && !b
if b: 1
else: 2
else: 3
if a: if b { 1 } else { 0 } else: 2 braces select the outer binding → 0 when a && !b
If let
if let Some n = v: // indented
n
else:
-1
if let Some n = v: n else: -1 // inline
let r = if let Some n = v: n * 2 else: 0 // as a value
- The pattern juxtaposes:
Some n, notSome(n). - A struct literal is allowed in the condition, which Rust forbids; the condition is parenthesised on emission.
Control flow
Every Rust control-flow construct opens its block with a colon.
for i in 0..3:
if i % 2 == 0:
println! "even {i}"
else:
println! "odd {i}"
while let Some top = stack <- pop$:
print! "{top} "
if let Some first = words <- first$:
println! "first = {first}"
else currently emits on its own line after the closing brace. This is valid Rust; rustfmt would join it.
Loops
for i in 0..4: // for, indented
s += i
for i in 0..3: s += i // for, inline
for (a, b) in &pairs: s += a * b // for over a pattern
for i in 0..2 { w += i } // braced
while n < 3: // while
n += 1
while let Some top = st <- pop$: popped += top // while let
let found = loop: // loop with a break value
k += 1
if k == 5: break k * 10
'outer: for i in 0..3: // labelled loops
for j in 0..3:
if i * j == 2: continue 'outer
hits += 1
break k * 10inside an inlineifworks becausebreakterminates an application run, so the value is not read as arguments to something.- The label
'outer:is a lifetime-shaped token followed by a colon, and the colon is mid-line, so it does not open a block; theforthat follows does.
Let bindings
Every let form
let a = 1 // plain
let b: i64 = 2 // typed
let mut c = 3 // mutable
let (d, e) = (4, 5) // destructuring a tuple
let P\ x, y = P\ x = 6, y = 7 // destructuring a struct
let Some f = get$ // let-else, indented
else:
return
let a = a + 100 // shadowing
let g = // a block as the value
do:
let t = 2
t * t
let h = { let u = 3; u * u } // several statements on one line: Rust's braces
let … else:opens a block like any other; the body must diverge, as in Rust.- The
elseblock may also be inline:let Some f = get$ else: return.
Standalone blocks
A bare block is written do:. The keyword is a marker only and never reaches the output — Rust reserves do but has no syntax for it, which is exactly why it was chosen: it collides with no real identifier.
let scoped =
do:
let a = 10
a * 2
let scoped = {
let a = 10;
a * 2
};
do is therefore not available as an identifier in hrs. It is the only keyword the language adds.
Struct literals
A struct literal is the name with \, its fields after it as name = value; Rust's brace form, Point { x: 1 }, is refused (one spelling per construct).
let c = Circle\ r = 2.0
let p = Point\ x = 3, y = 4
Struct literals: Point\ with the fields beneath, or inline; the mark is what distinguishes a value from a bare name (see "Building a struct").
Error handling
- Rust's
?operator,Result,Optionandlet … elseall pass through, since none of them involve braces or the tokens Harsh rewrites.
fn fallible (n: i32) -> Result<i32, String>:
if n < 0: Err ("negative" <- to_string$) else: Ok (n * 2)
fn uses_question (n: i32) -> Result<i32, String>:
let v = fallible n ?
Ok (v + 1)
fallible n ?— the?terminates the application run, so it applies to the whole call:fallible(n)?.Err ("negative" <- to_string$)andOk (n * 2)isolate their non-atom arguments in the usual way.
Unsafe and async
fn unsafe_block$ -> i32:
let x = 5
let p = &x as *const i32
unsafe:
*p
#[tokio.main]
async fn main$:
let listener =
TcpListener.bind "127.0.0.1:8089"
<- await
<- unwrap$
unsafe:opens a block like any other keyword..awaitis written<- await, since it is a postfix on a value; it fits into a vertical chain like any method.
Macros
A macro is code that writes code before the program is compiled. Which macro you are looking at depends on who wrote it, in what, and whether it is yours: the mark says whether it is written in Harsh or in Rust, name~ or name!, and it is either your own or imported from a library. The four parts below follow that: Harsh macros (~), your own, written in Harsh; Rust macros (!), your own, written in Rust inside a Harsh project; Harsh DSLs (~), imported from Harsh libraries; Rust DSLs (!), imported from Rust libraries. Your own macros, Harsh's and Rust's, come in Rust's two families, declarative and procedural, and a procedural macro in Rust's three kinds -- custom derive, attribute-like and function-like -- as the Rust Book presents them; Harsh's own mirror Rust's.
Harsh macros (~)
Your own macros, written in Harsh. hrs expands them into Harsh before anything is transpiled, so the generated Rust holds none of them.
Declarative macros
Both sides of a macro_rules~ are written in Harsh, and both follow rules already stated: a matcher is a parameter list, a transcriber is a block. The macro unfolds into Harsh before anything is transpiled, so the generated Rust holds no macro at all — only what the expansion came to.
macro_rules~ twice fn main() {
(($e:expr)) => do: let v = {
$e * 2 let mut tmp = Vec::new();
tmp.push(1);
macro_rules~ my_vec tmp.push(2);
( $( ($x:expr) )* ) => do: tmp.push(3);
do: tmp
let mut tmp = Vec.new$ };
$(tmp <- push $x)*
tmp println!("{}", 21 * 2)
}
fn main$:
let v = my_vec~ 1 2 3
println! "{}" (twice~ 21)
macro_rules~ nametakes no mark; its lines are arms, each( matcher ) => do:with the transcriber beneath (or inline afterdo:). The matcher's outer parens are its own delimiter; a DSL's brackets go inside them,([ ($e:expr) ; ($n:expr) ]).- A call is a stream, not an application. Everything after
name~— the rest of its line and every following line indented deeper than the line the call sits on, or to the close of the group the call sits in, whichever comes first — reaches the matcher exactly as written. Solst~ 1 2with3 4indented beneath it passes four tokens, and(lst~ 1 2) <- len$chains on the result. A\after the mark is a token like any other. A Rust!macro whose stream is not a list of values takes it in braces,m! { a => x, b => y }; them!\spelling is retired. A comma is the DSL's token, not Harsh's; a(…)is a group, one token, so a tuple is written once:pair~ (1, 2). A call inside a larger expression is isolated,((twice~ 4), 0), as any application is. - A fragment's extent follows the matcher's shape. It runs to the next literal the matcher names or the repetition's separator; when another fragment follows directly —
($a:expr) ($b:expr)— it takes one atom, as an application's argument does; when nothing follows, it runs to the stream's end. So a Harsh macro juxtaposes its arguments,my_macro~ (1+3) "more", and writes commas only where its DSL spells a construct that owns them. - Arms are tried in order and the first that fits wins, so the more specific arm comes first: to
$( ($x:expr) ),*, a paren group is one expression. - A metavariable is written in its parentheses,
($name:spec)(the user's ruling, 2026-09-25): the parentheses are part of the construct, as$( … )*'s are the repetition's, and a bare$name:specis refused, naming the form. Every other token in a matcher is literal, further parentheses included:(($k:expr))requires( )in the call. A specifier gives its capture meaning: to($v:expr),(1+2)and((1+2))are one expression;:identis one identifier token, as in Rust, so(adding)is none. The transcriber stays bare,$v $( $x )*. A bracketed or braced matcher inside the arm's parentheses --([ ($elem:expr) ; ($n:expr) ])-- is Rust's syntax, its tokens literal, exactly asvec! [0u8; 4]is on the call side. - The transcriber is a block,
=> do:with the body beneath or=> do: exprinline. Its lines follow the block rules: statements take;, a line with a top-level=>is an arm and takes,, and$xis an atom, sopush $xis a call. A transcriber's braces are Rust's delimiters, not a block: an expansion that is several statements with a value writes its block, the innerdo:above, as Rust writes{ { … } }. A transcriber may also be Rust's delimited group,=> { … }, for a DSL of its own (quote!'s output, say); that form is for such transcribers, not a place to write Rust'sifandmatch. A bare=> $e * 2is an error naming the forms. - A repetition
$( … )*that is the whole of its line repeats what its block holds — statements, each with its;— and may span lines:$(line-final, the body beneath,)*as its own line. One inside an expression follows the matcher's brick from the other side:$( ($x) )*is a list of arguments and becomes$($x),*; a repetition of bare tokens,$($arg)*, forwardstts and is copied as it stands. A written separator,$( … );*, is an error.
Procedural macros
A procedural macro is a program: a function that receives tokens and returns the code to put in their place. Harsh's are written in Harsh, in a crate of their own marked proc-macro = true under [package.metadata.harsh], which the crate that calls them lists by path, proc-macros = ["../hello"]; hrs runs them when it transpiles the code that calls them. Each is a pub fn from a TokenStream to a TokenStream, marked with an attribute that names its kind -- the Rust Book's general shape, #[some_attribute~] above pub fn some_name (input: TokenStream) -> TokenStream: -- in Rust's three kinds, below; the recipe and rules are in Macros (docs/MACROS.md).
Custom derive Macros
A derive mirrors Rust's: #[proc_macro_derive~ Describe] registers
it (#[proc_macro_derive~ Describe (attributes describe)] declares a helper
attribute it reads), and #[derive~ Describe] above a struct, an enum
or a union runs it on that item, beside Rust's own #[derive Debug] if
you like. It receives the item as written, with every derive attribute
removed; what it returns is added after the item, which it cannot change;
and its helper attributes leave the item once it has run.
Attribute-Like Macros
An attribute-like macro is placed on any item -- a function, a module, a struct -- and what it returns replaces the item. It is a pub fn marked #[proc_macro_attribute~] taking two streams, as Rust's takes them: (attr: TokenStream) (item: TokenStream). #[route~ GET "/"] above fn index$: hands it the arguments as written, GET "/", and the function; #[route~], without arguments, an empty first stream. The shape of the arguments is the macro's author's to decide -- written with Harsh in mind, usually juxtaposed, with isolation parentheses around one of several tokens, #[route~ GET "/" (some_attr = some_value)]. The macro receives the item with every other attribute it carries, above and below its own, and its output is all that remains of it. hrs_syn's ItemFn parses a function into its signature, name and body:
#[proc_macro_attribute~]
pub fn route (attr: TokenStream) (item: TokenStream) -> TokenStream:
let route = attr <- to_string$
let f = parse_macro_input! { item as ItemFn }
let vis = &f <- vis
let sig = &f <- sig
let name = f <- ident <- to_string$
let body = &f <- block
quote~ do:
#vis #sig:
println!
"[ROUTE LOG] Dispatched handler '{}' for {}"
#name
#route
#body
Function-Like Macros
A proc macro is an ordinary Harsh function marked #[proc_macro~], taking
and returning a TokenStream -- text, in this version:
use hrs_proc_macro.TokenStream
#[proc_macro~]
pub fn hello_macro (input: TokenStream) -> TokenStream:
let input_str = input <- to_string$
let output = format! "\"Hello, {}!\"" input_str
output <- parse$ <- unwrap$
It is called like any ~ macro, hello_macro~ world, and runs when hrs
transpiles the caller: what it returns is Harsh, put where the call stood,
so the generated Rust holds no macro -- here println!("{}", "Hello,
world!"). The macro lives in its own crate, marked proc-macro = true
under [package.metadata.harsh]; the crate that calls it lists it,
proc-macros = ["../hello"]. The recipe and the rules are in Macros
(docs/MACROS.md). hrs expand shows a file with its proc macros
expanded.
Rust macros (!)
Your own macros, written in Rust inside a Harsh project, are still supported, and are called with Rust's mark by the rules of Rust DSLs below.
Declarative macros
A macro_rules! — with Rust's ! — is a zone of Rust: the transpiler copies it out byte for byte and hrs-from copies one in the same way. It carries no Harsh opener, since its { … } delimit it and its body is laid out as Rust, not as Harsh. Its calls are still Harsh calls (my_vec! 1 2 3 → my_vec!(1, 2, 3)); only the definition is foreign.
Procedural macros
A Rust procedural macro lives in a Rust crate that says proc-macro = true under [lib], Rust's own setting; a Harsh crate depends on it as on any crate and brings the macro in with use. It is written in Rust, with syn and quote if you like. Each kind is a function from a token stream to a token stream, marked with the Rust attribute that names it.
Custom derive Macros
A function marked with Rust's proc_macro_derive attribute, naming the derive, called #[derive Name]: use hello_macro_derive.HelloMacro, then #[derive HelloMacro] above the type.
Attribute-Like Macros
A function marked with Rust's proc_macro_attribute takes two streams -- the attribute's arguments and the item -- and what it returns replaces the item. A framework's route, called #[route GET "/"] above fn index$: -- Rust's #[route(GET, "/")], its arguments juxtaposed -- receives GET, "/" and the function; called #[route], an empty first stream. (#[route (GET, "/")] would hand it one tuple, #[route((GET, "/"))]: parentheses isolate a single argument.) It takes two even then: rustc refuses the function with one.
Function-Like Macros
A function marked with Rust's proc_macro attribute, called from Harsh as any Rust macro: name! a b, or name! { … } when its stream is a language of its own.
Harsh DSLs (~)
Macros imported from Harsh libraries are Harsh throughout: each call follows the grammar the library's guide describes, and what it expands to is Harsh. There is nothing to learn beyond that guide.
- The prelude.
g~(a comprehension,g~ x * 2 for x in xs if x > 1),list~,set~anddict~need no definition and nouse. A file's own macro of the same name shadows them;hrs_std.g~always reaches the prelude's.
Rust DSLs (!)
Macros imported from Rust libraries -- println! and vec! from the standard library, a framework's view!, serde's #[derive Serialize], Tokio's #[tokio.main] -- are called by Harsh's rules.
Calling one
println! "{} {}" a (twice~ 4) → println!("{} {}", a, twice!(4)) juxtaposed
let s = format! "value={a}" → let s = format!("value={a}") a single argument
let v = vec! 1 2 3 → let v = vec!(1, 2, 3) juxtaposed, like any macro
let z = vec! { 0u8; 4 } → let z = vec! { 0u8; 4 } not a list: braces, kept
let m1 = matches! a (1..=9) → let m1 = matches!(a, 1..=9) a pattern is one argument
let m2 = matches! a (n if n > 3) → let m2 = matches!(a, n if n > 3) a guarded pattern, isolated
let m3 = matches! r (Ok 7) → let m3 = matches!(r, Ok(7)) the pattern juxtaposes inside
assert_eq! a 5 → assert_eq!(a, 5)
- Macros follow the function rule exactly, with no exemption: one atom per argument, isolate anything that is not one. A pattern with a guard or an or-pattern is a multi-token argument like any other, so it is isolated —
matches! a (n if n > 3)— and inside the isolating parens the ordinary rules apply, which is why(Ok 7)becomesOk(7). - This is the reason Rust and Julia give macros the same syntax as the language, where C gives them a foreign one: a macro call written in Harsh is checked against Harsh's rules, and whether the result means what the macro wants is the macro's business in Rust. The transpiler removes the parens and commas and puts them back; it does not need to know what
matches!expects. - Rust call syntax written into a macro —
println!("{}", 1)— is rejected, since under these rules it passes a single tuple. So ismatches! (a, n if n > 3), for the same reason. - The delimiter is the caller's choice, not the macro's. Rust hands a macro the stream inside its delimiter, so
vec![a, b],vec!(a, b)andvec!{a, b}are one call. Harsh gives the delimiters meanings of its own: a macro whose stream is a comma-separated list of expressions is applied,vec! 1 2 3, and emitted in parentheses; any other stream is written in braces,vec! { 0; 4 },quote! { … }, and kept as written — inside a brace body nothing juxtaposes. A bracket after!is an ordinary argument, an array:m! [a, b] cism!([a, b], c). Rust'svec![1, 2, 3]written as is would therefore mean a vector holding one array, and is refused with the Harsh spelling named;vec! ([1, 2, 3])passes one array on purpose. A macro with a body of its own is writtenname~ do:— see "Macro bodies" below.
Derives and attributes
Rust's own derives are written as any attribute, by juxtaposition: #[derive Debug Clone] is #[derive(Debug, Clone)]. The two kinds may sit on one item. Rust's attribute macros take Harsh between their brackets, by the application and path rules: #[tokio.main], #[route "/api/:id"] → #[route("/api/:id")], #[tokio.main (flavor = "multi_thread")] → #[tokio::main(flavor = "multi_thread")].
A macro's own language: braces and holes
A Rust macro whose stream is not a list of values — tokio.select!'s arms, a framework's view! markup or rsx! tree, a quote! template — takes it in braces, and what is inside the braces is Rust, or the macro's own language, written exactly as its documentation shows. Harsh reads none of it and changes none of it. The only Harsh inside is what you mark: a hole opens with @: and always closes with :@, and between the two is ordinary Harsh, transpiled in place.
let winner = tokio.select! { let winner = tokio::select! {
n = @: slow "slow" :@ => n, n = slow("slow") => n,
n = @: fast "fast" :@ => n, n = fast("fast") => n,
} };
view! { view! {
<div class="app"> <div class="app">
<p>{count}</p> <p>{count}</p>
<button on:click={@: move |_| <button on:click={move |_|
println! "+1" :@}>"+"</button> println!("+1")}>"+"</button>
</div> </div>
} }
- A hole is Harsh; everything else is the macro's.
{count}needs no mark — a name reads the same in both languages — while a handler written in Harsh sits in a hole. A hole may span lines:@:where it opens, the Harsh beneath at that line's indentation,:@where it ends. It may hold a macro call with braces of its own, and holes inside those. - Braces may span lines, as in Rust: the body is the macro's, so Harsh's layout never applies to it. The formatter leaves it byte for byte.
- A hole goes wherever the macro's language takes Rust code — one rule, with no positions to learn. Dioxus takes Rust code in a format slot inside its strings, so a hole goes there too:
rsx! { rsx! {
strong { "count: {@: n$ :@}" } strong { "count: {n()}" }
} }
- A hole of several lines may start its code on the
@:line or on the next, and close:@at the end of its last line or on a line of its own. Lines beneath align with the code as anywhere in Harsh — anelse:under itsif, a chain's<-under the first link — and a closure's body underonclick: @: move |_|:is indented from that line.hrs fmtwrites the compact form, the code on the@:line and:@ending the last (unless that line ends in a//comment), with one space inside each mark and none between a mark and the DSL's brace; and Enter in an editor lands on these columns:
rsx! {
p {
{@: if n > 2:
"many"
else:
"few" :@}
}
}
@@:is a literal@:in the body's text.@:anywhere else is refused.- A hole's code is Harsh, so a mark inside one of its strings, characters or comments is text: in
@: format! "from:@{domain}" :@the hole ends at the last:@, andhrs fmtspaces only the marks that open and close it (fixed in 0.7.3; it had written"from :@{domain}"). hrs-fromwrites the holes. Converting a Leptos, Dioxus or Tokio file keeps each body's DSL as written and turns every piece of Rust code in it into a hole of Harsh: a{ … }group, an attribute's value (name=value,name: value), afor/ifexpression, a paren splice (Maud's(items.len())), aselect!arm's pattern, future and handler. A stretch becomes a hole when it is more than a single word and calls on a binding -- a variable, a function, a name the DSL binds -- or uses an operator,.and::included (2026-10-12):{count}stays,{items.len()}is a hole. The guide to macros catalogues where the holes go in the DSLs of popular libraries -- Leptos, Dioxus, Yew, Maud, serde_json, Tokio,thread_local!,lazy_static!,bitflags!,tracing. A literal stays as written. Inside the DSL's own strings, the Rust in a format slot becomes a hole too:"Hello {name.to_uppercase()}"is written"Hello {@: name <- to_uppercase$ :@}", and{v.len():>4}keeps its spec outside,{@: v <- len$ :@:>4}; a plain name{count}, a position{0}and{{stay as written (2026-10-12). A piece it cannot write as Harsh stops the conversion, named by line — never silently left as Rust; a slot that does not come back identical is the string's text, and stays.- A block passed to a macro as an argument is isolated like any argument,
m! (do: …) x.m! do:as the macro's body, and#:, are retired (2026-09-23); each is refused with the braces named.
Generator comprehensions
g~ is the generator comprehension, in Harsh's prelude: every file may use it with no use, and a file that never calls it is untouched. The names are Python's — g~ is lazy, as a generator is; list~, set~ and dict~ are the list, set and dict comprehensions that collect it. (A generator here is a lazy iterator, not Rust's unstable feature of that name.)
g~ value for pattern in iterable if condition … for pattern in iterable if condition …
- Each
ifbelongs to theforbefore it. A condition sees every name bound up to that point and nothing after, and a condition on an outerforfilters before the inner one runs. So a test goes after the lastforwhose name it uses:
fn main$:
let triples =
list~ (a, b, c)
for a in 1..20 // only a is available for the condition
for b in a..20 // only a, b are available for the condition
for c in b..20 // a, b, c are available for the condition
if a * a + b * b == c * c
Attached to for a, the same test fails: cannot find value b in this scope, reported on the Harsh line.
- Each for is one closure, its ifs folded into one test under bool::then, and every level but the innermost is flattened:
g~ x * 2 for x in xs if x > 1 if x < 9
→ (xs).into_iter().flat_map(move |x| ((true && (x > 1) && (x < 9)).then(|| x * 2)))
g~ (x, y) for x in 0..3 for y in 0..3 if y > x
→ (0..3).into_iter().flat_map(move |x| ((true).then(||
((0..3).into_iter().flat_map(move |y| ((true && (y > x)).then(|| (x, y))))))))
.flatten()
(Spacing tidied; the structure is exactly what hrs emits.) In Harsh, as hrs expand shows it:
g~ x * 10 + y
for x in (0..6) if x % 2 == 0 if x > 0
for y in (0..6) if y > x if y % 2 == 1
→ (0..6) <- into_iter$
<- flat_map (move |x|
((true && (x % 2 == 0) && (x > 0)) <- then (||
(0..6) <- into_iter$
<- flat_map (move |y|
((true && (y > x) && (y % 2 == 1)) <- then (|| x * 10 + y))))))
<- flatten$
-
flatten$is part of the generator;collect$is not. Every level but the innermost ends inflatten$, so it is never written by hand —(g~ …) <- flatten$fails, trying to flatten the values. And a generator is lazy, so it collects nothing: write(g~ …) <- collect$, or uselist~,set~ordict~. -
It is a lazy iterator. Nothing runs until it is consumed, so
for n in 1..with atakeafter it is fine. fortakes its iterable by value, as the language'sfordoes (IntoIterator): pass(xs <- iter$)to borrow.- The call takes the rest of its line and every line indented beneath it, so a long comprehension is laid out one clause per line; isolate the whole call to chain on it,
(g~ …) <- collect$. - Shorthands collect:
list~ …into aVec,set~ …into aHashSet,dict~ key => value for …into aHashMap— where a repeated key keeps its last value, asinsertdoes.
fn main$:
let evens = list~ x for x in 0..10 if x % 2 == 0
let pairs = list~ (x, y) for x in 1..4 for y in 1..4 if y > x
let squares: Vec<u64> =
(g~ n * n for n in 1..) <- take 5
<- collect$
println! "{evens:?} {pairs:?} {squares:?}"
- Shadowing. A file's own
macro_rules~ g(orlist,set,dict) replaces the prelude's in that file;hrs_std.g~always reaches the prelude's, and the shorthands call it by that name, so they keep working.
Matrices and linear algebra
Julia's notation, for Julia's operations. The literals m~ and v~ are in the prelude; the types they build, hrs_std::Matrix<T> and hrs_std::Vector<T>, are in the hrs_std crate, which a project lists once. hrs stops a project that calls m~ without it, and names the line to add.
hrs add hrs_std writes it, in Hrs.toml:
# Hrs.toml
[dependencies]
hrs_std = "0.1"
The literal. Julia's three rules, composed:
- a space is
hcat, side by side; - a
;or a line break isvcat, one above another; - a comma makes the entries of a vector, a column.
Every entry is a block: a number is 1×1, a matrix is itself, and a nested bracket is built by the same rules.
m~ [1 2 3; 4 5 6] → hrs_std::Matrix::from_rows(vec!(vec!(1, 2, 3), vec!(4, 5, 6)))
m~ [1 2 3 the same: a line break is a `;`
4 5 6]
m~ [[1, 4] [2, 5] [3, 6]] → the same, from three columns side by side
m~ [1, 2, 3] → a 3×1 matrix
v~ [1, 2, 3] → hrs_std::Vector::from_vec(vec!(1, 2, 3))
m~ [(&a) (&b)] → hrs_std::vcat(vec!(hrs_std::hcat(vec!(hrs_std::block(&a), hrs_std::block(&b)))))
m~ [[1 2], [3 4]] refused: in Julia a vector of two matrices — commas never join
v~ [1 2 3] refused: spaces make a row, which is a matrix
An entry with an operator in it is isolated, (k + 1), as any argument is; a negative number is one entry, [1 -2], as in Julia.
The operations — Julia's names wherever Julia has one:
&a * &b the matrix product k * a scaling
&a * &x matrix × vector: a vector &a + &b entrywise, as is -
a <- solve (&b) Julia's a \ b: exact for a square matrix, least squares for a tall one
a <- inv$ a <- det$ a <- transpose$ a <- size$
x <- norm$ x <- dot (&y) x[0] a[0, 1]
&a + UniformScaling 2 &a + I Julia's identity
*is chosen by the operand types, at compile time — the dispatch Julia does at run time.&a * &bborrows;a * bconsumes, by Rust's ownership rule.- Sizes are values, not types:
Matrix<i64>is any size. A mismatch stops the program with Julia's message —DimensionMismatch: matrix A has dimensions (2, 3), matrix B has dimensions (2, 3)— reported at the caller's line; a singular matrix, withSingularException. - Indexes start at 0, as for every collection in Harsh; Julia counts from 1.
- The formatter aligns a matrix's rows under its first entry, as they are written in Julia, and never adds or removes a line break inside a
~call — there a line break may mean something, as it does inm~, where it is a row. - Not yet:
.^and the dotted comparisons as operators (writef64.powf<> a 2.0,(|x, y| x > y)<> a b), Julia's in-place.=, and Julia'sA', whose'begins a character or a lifetime in Rust.
Slicing — Julia's a[1:2, :], in Harsh's own ranges: 0-based, the end left out, and .. alone for Julia's :.
a <- slice (0..2) (..) a copy, as Julia's is: rows 0 and 1, every column
a <- slice 1 (..) an axis taken by a number is dropped: a row, as a vector
a <- view (0..2) (..) a view: borrowed, nothing copied -- Julia's view(a, 1:2, :)
a <- view 1 (..) a row, borrowed, read as a vector
a <- view (1..) (..=1) <- copy$ the view made a matrix of its own
a[1, 1] a[1, 1] = 50 an element, read and written
a <- view_mut 2 (..) <- fill 0 a row written through
- An index takes its axes with a comma,
a[i, j], as Julia writes it. Rust's index takes one value, so the axes are a tuple anda[(i, j)]means the same: a top-level comma in an index makes a tuple. The rule is the language's, not the matrix's — any type indexed by a tuple may be written so. slicecopies,viewborrows. A view is a value -- a window onto the matrix, guarded by the borrow checker like any borrow, so it cannot outlive the matrix or be held while the matrix is written. It prints, stretches and combines in.*like the matrix it looks into;<- copy$is Julia's copy.view_mutwrites through:<- fill xfor the whole window,*(w <- at i j) = xfor one entry.- An index is one element.
a[i, j]reads or writes a number; a part of a matrix is asliceor aview. (Until 0.1.37 a view was written&a[0..2, ..]: Rust'sIndexmust hand back a reference to something stored in the matrix, and a window is not, sohrs_stdmade one up, and Miri found that undefined behaviour. The index form may come back if a sound way is found.) - A range past the end stops the program with
BoundsError, reported at the caller's line.
Broadcasting — a dot before an operator applies it element by element, Julia's own spelling: .*, .+, .-, ./.
&a .* &b element by element; &a * &b is still the matrix product
&a .* 2.0 .+ &row shapes stretch: along an axis two lengths are equal, or one of them is 1
x + &a .* &b precedence is the operator's own: the product first
relu<> a f<> — apply to each: Julia's relu.(a)
f64.powf<> a 2.0 two arguments, stretched the same way: Julia's a .^ 2
(|x, y| x > y)<> a b a closure in parentheses; the element type may change: a Matrix<bool>
a |> relu<> |> f64.sqrt<> it pipes
a <- map relu the method beneath f<>, as slice is beneath the index
f<>is a mark, tight against the function, asf$is apply to nothing andm!a macro: it says how the name is applied. It borrows its arguments — applying a function to each element consumes nothing — and takes up to three,f64.clamp<> a 0.0 1.0.- A dotted operator is two operators round a dot:
a .* bis written to Rust asa * hrs_std::DOT * b.a * DOTis half an operation and* bcompletes it, so Rust's precedence and associativity are Julia's with nothing parsed..is otherwise the path separator, which an operator never follows — except the glob of ause, and auseis left alone. - Rust's empty generic list is dropped.
f::<>(x)andP<>mean nothing in Rust; in Harshf<>is the mark, andhrs-fromleaves Rust's out. It is the one place Harsh is not a superset of Rust. - A mismatch is Julia's:
DimensionMismatch: arrays could not be broadcast to a common size; got a dimension with lengths 2 and 3.
fn main$:
let hours = [1.0, 2.0, 3.0, 4.0]
let score = v~ [52.0, 55.0, 61.0, 64.0]
let x = hrs_std.Matrix.from_rows (list~ (vec! 1.0 h) for h in hours)
let beta = x <- solve (&score)
println! "score = {:.2} + {:.2} × hours" beta[0] beta[1]
Attributes and comments
An attribute is an application inside its brackets, exactly as a macro call is after its bang: a name, then its arguments one atom each, parens only to isolate what is not one atom. Dots are path separators inside it as everywhere.
#[derive Debug Clone] #[derive(Debug, Clone)]
#[cfg (feature = "hydrate")] #[cfg(feature = "hydrate")]
#[cfg (not (feature = "ssr"))] #[cfg(not(feature = "ssr"))]
#[cfg_attr (feature = "ssr") (derive Serialize)] #[cfg_attr(feature = "ssr", derive(Serialize))]
#[serde (rename = "id") default] #[serde(rename = "id", default)]
#[tokio.main (flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
#[cfg test] #[cfg(test)]
#[doc = "a single token is one argument"] #[doc = "a single token is one argument"]
- Attribute lines never take a separator and never count as a block's tail expression.
- Rust's
#[derive Debug Clone]is an error, asf(x)is anywhere: see "One syntax for applying". - Doc comments on items and on fields both survive into the Rust, so
cargo docdocuments your.hrssource.
/// A doc comment survives into the Rust, so rustdoc sees it.
#[derive Debug Default]
struct Config
/// Field docs too.
verbose: bool
One syntax for applying
Harsh has one way to apply a name to arguments, and it is juxtaposition. A parenthesised group after a name isolates one argument and is separated from the name by a space; when the argument is a single token the group is not needed. Written tight, f(x) reads as Rust's call, and Harsh has no call syntax — so it is an error, everywhere:
f (a + b) / f x f(a + b) / f(x)
Some (a + b) / Some n patterns and constructors alike
m~ (a + b) / m~ a macros
#[cfg (feature = "x")] attributes
fn f (a: T) (b: U) declarations
Circle (f64) a variant's payload — the group is Rust's payload list and keeps its commas
The parenthesised types follow the same rule. Fn, FnMut, FnOnce and a fn in type position are applications, and $ applies to nothing there as everywhere, so that () is only ever the unit value:
type A = fn (x: i32) (y: f64) -> &str type A = fn(x: i32, y: f64) -> &str;
type B = Box<dyn Fn i32 -> i32> type B = Box<dyn Fn(i32) -> i32>;
type C = Box<dyn Fn (i32) (f64) -> i32> type C = Box<dyn Fn(i32, f64) -> i32>;
type D = Box<dyn FnOnce$ + Send> type D = Box<dyn FnOnce() + Send>;
Documented parameters. A parameter that carries a doc comment or an attribute — a Leptos component's props — is written with its doc lines above its group and its attributes inside it, one group per line, the return type on the line after. The Rust that comes out is the one #[component] expects, the ( opening before the first parameter's doc:
#[component]
pub fn ServiceCard
/// The service to render.
(service: Service)
/// Stagger for the entry animation, in seconds.
(#[prop (default = 0.0)] delay: f32)
-> impl IntoView:
…
A chain hanging off a block expression — if … {} else {}.into_iter() — is isolated in a group, the ( on its own line, the chain continuing after the ); the parens are mandatory grouping (the chain follows them) and reach the Rust:
let brand_name =
(
if s <- brand_lines <- is_empty$:
vec! (s <- name <- clone$)
else:
s <- brand_lines <- clone$
) <- into_iter$ <- map (…) <- collect_view$
A paren block's tail may itself open the statement's block — if xs <- iter$ <- any (|x|: / body / ): / the if's body — and paren blocks nest.
A foreign block is a layout block, its bodiless functions taking their ; from the layout:
extern "C": extern "C" {
fn abs (input: i32) -> i32 fn abs(input: i32) -> i32;
}
Parens do three things, and the transpiler tells them apart by what they hold. A group with a top-level comma is a tuple — a construct, kept. A group that shares its expression with other tokens raises precedence — (a + b) * c, (a + b) <- abs$ — and is kept, since the transpiler cannot tell precedence from habit and must not guess. A group that is the whole of a statement or the whole of a value after = or => encloses nothing it could bind tighter than: it is optional grouping and is not emitted, so let s = (1 + 2) is let s = 1 + 2; and quote! do: (fn #name$ -> u32 do: #body) emits the same tokens as without the parens — which matters inside quote!, where the tokens are the value. A macro's brace body is Harsh in one line like any other brace: quote! { fn #name$ -> u32 { #body } }.
Everything else is Rust
Because the transpiler substitutes tokens rather than reconstructing a tree, the following need no support and simply work: lifetimes and lifetime bounds, where clauses, generic constraints, impl Trait in argument and return position, closures, iterator chains, async and .await, the ? operator, all operators and their precedence, all patterns, ranges, slices, tuples, references and raw pointers, unsafe, macro invocations of every kind, raw strings, byte strings, and numeric suffixes.
Not supported
- Mixing inline and multi-line
else. An inline block closes at the end of its line, soelse: if …on one line followed byelse:on the next orphans the second. Both the fully inline chain and the fully indented chain work; the mixed form is rejected with an error.
Not yet supported
Known limits
hrs-fromproduces Harsh in the recommended layout (its output goes throughhrs fmt), but without pipes: a chain stays a chain.
What is rejected
- Each of these is a syntax error with a message naming the fix.
let x = a::b `::` is not valid in Harsh; the path separator is `.`
println!("{}", 1) a macro takes juxtaposed arguments, not a parenthesised list
matches! (a, n if n > 3) the same — a parenthesised list is a tuple
match n\ `;` is not valid at the end of a match arm
1 => 10;
let a = x |> f y after a pipe's function only `<|` may follow; isolate: `x |> (f a)`
let a = 1 + 2 |> f a pipe's arguments are atoms; isolate an expression in parentheses
let a = 1 2 3 4 |> sub `sub` takes 3 parameter(s) and 4 were piped in
let a = f $ `$` applies a name to nothing and is written tight against it: `f$`
fn f (): `()` is the unit value; a function with no parameters is `fn f$`
fn f (a: i32, b: i32) -> i32: a parameter group holds one parameter; write `(a: T) (b: U)`
use std.io: `use` does not open a block; group imports in parentheses
match n\ `,` is not written at the end of a match arm
1 => 10,
if n == 1: 10 this `else` has no `if` to attach to
else: if n == 2: 20
else: 30
let a = 1; `;` is only written after a block's last statement
let b = 2
v <- map |x|: a chain cannot continue after a bare closure block;
x * 2 isolate the closure in parens
<- count$
Command line
hrs input.hrs -o out/main.rs --map out/main.map.json
cargo build --message-format=json | hrs-remap --map out/main.map.json
hrs-from existing.rs -o existing.hrs
hrs-from converts Rust source to Harsh, for bringing existing code in. It matches braces from the lexer's bracket depth, so nesting is exact; the only judgement is whether each { becomes indentation, and when that is not clear-cut the braces are left alone, which is always valid Harsh.
The source map records one entry per token as byte offsets. The remapper rewrites every span in every diagnostic and sub-diagnostic, then re-renders against the original source, so errors report the correct line and column in your .hrs file rather than in the generated Rust.
Quick reference
| Construct | hrs | Rust |
|---|---|---|
| Block | : then indent |
{ … } |
| Match arm block | => then indent |
=> { … } |
| Path separator | . |
:: |
| Field or method | <- |
. |
| Use group | .( … ) |
::{ … } |
| Generic call | Vec.<i32>.new$ |
Vec::<i32>::new() |
| Tuple index | t.0 |
t.0 |
| Range | 0..3, 1..=5 |
same |
| Bare block | do: |
{ … } |
| Struct literal | Point\ x = 1 |
Point { x: 1 } |
| Curried parameters | (a: T) (b: U) |
(a: T, b: U) |
| Bare parameter | name: &str |
(name: &str) |
| Application | f a b |
f(a, b) |
| Tuple argument | f (a, b) |
f((a, b)) |
| Pipe back | f <| x |
f(x) |
| Pipe forward | x |> f |
f(x) |
| Pipe, several values | 3 5 10 |> sub |
sub(3, 5, 10) |
| Partial, from the left | 3 |> sub |
move |p1, p2| sub(3, p1, p2) |
| Partial, from the right | sub <| 3 |
move |p1, p2| sub(p1, p2, 3) |
| Partial, middle hole | 1 |> sub <| 3 |
move |p1| sub(1, p1, 3) |
| Pipe a result | (f a) |> g |
g(f(a)) |
| Inline block | if t: 1 else: 2 |
if t { 1 } else { 2 } |
| Trailing closure | map |n|: + indent |
map(|n| { … }) |
| Statement end | newline | ; |
| Discard tail value | a last line () |
expr; |
Extended quick reference
| Construct | Harsh | Rust |
|---|---|---|
| Bare parameter | fn f name: &str |
fn f (name: &str) |
| Curried parameters | fn f (a: T) (b: U) |
fn f (a: T, b: U) |
| No parameters | fn f$: |
fn f() { |
| Zero-argument call | g$ |
g() |
| Unit argument | g () |
g(()) |
| Group as head | (add 10) 7 |
(add(10))(7) |
| If-let | if let Some n = v: … |
if let Some(n) = v { … } |
| Let-else | let Some f = g$ else: return |
let Some(f) = g() else { return } |
| While-let | while let Some t = s <- pop$: … |
while let Some(t) = s.pop() { … } |
| Loop value | let x = loop: … break v |
let x = loop { … break v } |
| Label | 'outer: for …: |
'outer: for … { |
| Await | f$ <- await |
f().await |
| Try | f n ? |
f(n)? |
| Unsafe | unsafe: |
unsafe { |
| Type alias | type P = (i32, i32) |
type P = (i32, i32); |
| Tuple struct | struct T i32 i32 / T 3 4 |
struct T(i32, i32); / T(3, 4) |
| Tuple struct holding a tuple | struct U (i32, i32) |
struct U((i32, i32)); |
| Record variant | Rec + fields beneath |
Rec { … } |
| Macro, a stream that is not a list | vec! { 0; 4 } |
vec! { 0; 4 } |
| Harsh macro definition | macro_rules~ m + arms beneath |
none: hrs expands its calls |
| Rust macro definition | macro_rules! m { … } |
the same, copied as written |
| Pipe into closure | x |> (|w| f w) |
(|w| f(w))(x) |
| Isolated closure, chained | map (|x|: + body + ) <- f$ |
map(|x| { … }).f() |
| Block opener synonym | fn f$ do: |
fn f() { |
| Tuple index argument | f t.0 t.1 |
f(t.0, t.1) |
| Index argument | f a[i] |
f(a[i]) |
| Array argument | f ([1, 2]) |
f([1, 2]) |