Lisette is a little language inspired by Rust that compiles to Go, combining compile-time safety, expressive syntax and Go's ecosystem.
import "go:encoding/json" import "go:fmt" import "go:os" fn load_config(path: string) -> Result<Cfg, error> { let data = os.ReadFile(path)? let mut config = Cfg { port: 8080, tls: true } json.Unmarshal(data, &config)? Ok(config) } fn main() { match load_config("app.json") { Ok(config) => start(config), Err(e) => fmt.Println("error:", e), } }
Syntactically similar to Rust, conceptually simpler
enum Message { Ready, Write(string), Move { x: int, y: int }, } fn handle(msg: Message) -> string { match msg { Ready => "ready", Write(text) => f"wrote: {text}", Move { x, y } => f"move to ({x}, {y})", } }
import "go:math" struct Point { x: float64, y: float64, } impl Point { fn distance(self, other: Point) -> float64 { let (dx, dy) = (self.x - other.x, self.y - other.y) math.Sqrt(dx*dx + dy*dy) } }
fn shift(p: Point, by: int) -> Point { let Point { x, y } = p Point { x: x + by, y } } fn bounds(items: Slice<int>) -> string { let [first, ..rest] = items else { return "empty" } f"{first}, then {rest.length()} more" } fn print_rows(rows: Map<string, int>) { for (name, count) in rows { fmt.Println(name, count) } }
type Headers = Map<string, string> fn handle_headers(h: Headers) -> Result<(), string> { if let Some(token) = h.get("Authorization") { let user = authenticate(token)? authorize(user)? } else { return Err("missing credentials") } let Some(id) = h.get("X-Request-ID") else { return Err("missing request ID") } process(id) }
fn describe(score: int) -> string { let grade = if score >= 90 { "A" } else if score >= 70 { "B" } else if score >= 50 { "C" } else { "D" } let stars = { let count = score / 20 strings.Repeat("*", count) } f"{grade} {stars}" }
fn revenue(sales: Slice<Sale>) -> int { sales .filter(|s| s.paid) .map(|s| { let net = s.total - s.refund net * s.qty }) .fold(0, |a, b| a + b) } fn discounted(total: int) -> int { total * 90 / 100 } fn taxed(total: int) -> int { total * 121 / 100 } fn payable(sale: Sale) -> int { sale.total |> discounted |> taxed }
interface Metric { fn label() -> string fn value() -> float64 } struct Latency { ms: float64 } impl Latency { fn label(self) -> string { "latency" } fn value(self) -> float64 { self.ms } } /// Finds the metric with the highest value. fn highest<T: Metric>(metrics: Slice<T>) -> T { metrics.fold(metrics[0], |a, b| if a.value() > b.value() { a } else { b } ) }
interface Reader { fn read(buf: mut Slice<byte>) -> Result<int, error> } interface Writer { fn write(buf: Slice<byte>) -> Result<int, error> } interface ReadWriter { embed Reader embed Writer } fn echo(rw: ReadWriter) -> Result<int, error> { let mut buf = Slice.make<byte>(64) rw.read(buf)? rw.write(buf) }
fn read_config(path: string) -> Result<Config, error> { let bytes = os.ReadFile(path).wrap_err("config")? parse_config(bytes) } fn primary_email(users: Slice<User>) -> Option<string> { let user = users.get(0)? let email = user.email? Some(email) }
fn load_config() -> Config { let result = try { let data = os.ReadFile("app.toml")? parse_toml(data)? } match result { Ok(config) => config, Err(_) => Config.default(), } }
fn sum(numbers: Slice<int>) -> int { numbers.fold(0, |a, b| a + b) } fn totals() { let ch = Channel.buffered<int>(2) let (tx, rx) = ch.split() task { tx.send(sum([1, 2, 3])) tx.send(sum([4, 5])) tx.close() } for total in rx { fmt.Println(total) } }
fn quickest() -> string { let primary = Channel.new<string>() let replica = Channel.new<string>() task primary.send(fetch_primary()) task replica.send(fetch_replica()) select { match primary.receive() { Some(body) => body, None => "closed", }, match replica.receive() { Some(body) => body, None => "closed", }, } }
Go runtime failures, caught at compile time
✕ match is not exhaustive ╭─[example.lis:5:3] 4 │ fn should_alert(s: Severity) -> bool { 5 │ match s { · ───┬─── · ╰── not all patterns covered 6 │ Severity.Low => false, 7 │ Severity.High => true, 8 │ } 9 │ } ╰──── help: Handle the missing case Severity.Critical, e.g. Severity.Critical => { ... }
✕ nil is not supported ╭─[users.lis:5:12] 3 │ fn find(name: string) -> Option<User> { 4 │ if name.is_empty() { 5 │ return nil · ─┬─ · ╰── does not exist 6 │ } 7 │ lookup(name) 8 │ } ╰──── help: Absence is encoded with Option<T> in Lisette. Use None to represent absent values
▲ Result is silently discarded ╭─[files.lis:9:3] 8 │ fn shutdown() { 9 │ cleanup() · ────┬──── · ╰── failure will go unnoticed 10 │ fmt.Println("done") 11 │ } ╰──── help: Handle this Result with ? or match, or explicitly discard it with let _ = ...
▲ Option is silently discarded ╭─[cache.lis:10:3] 9 │ fn warm(c: Cache) { 10 │ hit_count(c, "home") · ──────────┬───────── · ╰── absence will go unnoticed 11 │ fmt.Println("warmed") 12 │ } ╰──── help: Handle this Option with ? or match, or explicitly discard it with let _ = ...
✕ Immutable variable ╭─[sorting.lis:7:8] 5 │ fn run_sort() { 6 │ let nums = [3, 1, 2] 7 │ sort(nums) · ──┬─ · ╰── nums was declared without mut 8 │ } ╰──── help: sort() writes to nums. Declare using let mut nums to make the variable mutable
▲ os.Exit skips defer ╭─[exit.lis:10:3] 9 │ defer flush() 10 │ os.Exit(code) · ──────┬────── · ╰── exits before defer can run 11 │ } ╰──── help: os.Exit will terminate the process without running deferred calls. Run the cleanup before exiting instead of deferring it
▲ Context leaking ╭─[work.lis:4:13] 3 │ fn serve(p: context.Context) { 4 │ let (ctx, stop) = context.WithCancel(p) · ──┬─ · ╰── never called 5 │ handle(ctx) 6 │ } ╰──── help: Call this cancel function (usually defer cancel()) to release the context, or it leaks until the parent is canceled
▲ WaitGroup.Add inside a task ╭─[pool.lis:8:7] 7 │ task { 8 │ wg.Add(1) · ────┬──── · ╰── may run after Wait 9 │ defer wg.Done() 10 │ } ╰──── help: Prefer wg.Go(|| ...), which counts the task and starts it in one step and runs Done for you, or move Add before the task
▲ Lost query mutation ╭─[link.lis:4:3] 3 │ fn tag(u: Ref<url.URL>, value: string) { 4 │ u.Query().Set("tag", value) · ─────────────┬───────────── · ╰── mutates a discarded copy 5 │ } ╰──── help: URL.Query returns a fresh copy, so this Set has no effect. Bind it, mutate it, then assign values.Encode() to RawQuery
✕ File permission written in decimal ╭─[perms.lis:4:24] 3 │ fn lock() -> Result<(), error> { 4 │ os.Chmod("app.conf", 600) · ─┬─ · ╰── decimal 600 is octal 0o1130 5 │ } ╰──── help: File permissions are conventionally written in octal. Use a 0o prefix (for example 0o644) so the bits line up with the rwx triples
▲ Appending to a zero-filled slice ╭─[buffer.lis:2:13] 1 │ fn ids() -> Slice<int> { 2 │ let out = Slice.make<int>(3) · ─────────┬──────── · ╰── already all zeros 3 │ out.append(7) · ──────┬────── · ╰── append adds element 4 here 4 │ } ╰──── help: For an empty slice with room for 3, use Slice.new<int>().reserve(3)
✕ Duplicate map key ╭─[flags.lis:3:14] 1 │ /// Feature flags and their default values. 2 │ fn defaults() -> Map<string, int> { 3 │ Map.from([("debug", 1), ("debug", 0)]) · ───┬─── ───┬─── · │ ╰── overwrites first · ╰── key for first entry 4 │ } ╰──── help: Map.from keeps the last entry for a key, so the earlier entry never reaches the map. Remove one of the two entries
✕ if condition repeated in else if ╭─[logic.lis:4:13] 2 │ if score > 90 { 3 │ "gold" 4 │ } else if score > 90 { · ─────┬──── · ╰── same as prior condition 5 │ "silver" ╰──── help: This branch is unreachable because its condition duplicates the preceding condition. Did you mean a different condition?
▲ Variables are not swapped ╭─[logic.lis:12:3] 11 │ let mut b = second 12 │ ╭─▶ a = b 13 │ ├─▶ b = a · ╰──── does not swap values 14 │ a + b 15 │ } ╰──── help: a = b overwrites a, so b = a writes b's own value back and the original a is lost. Save one value in a temporary variable first
▲ Comparison is always true ╭─[logic.lis:18:3] 17 │ fn in_range(n: uint) -> bool { 18 │ n >= 0 · ───┬── · ╰── an unsigned integer is never negative 19 │ } ╰──── help: An unsigned integer is never negative, so this comparison always has the same result. Did you mean to compare to a different value?
▲ Exact float comparison ╭─[logic.lis:22:3] 21 │ fn is_paid(paid: float64, due: float64) -> bool { 22 │ paid == due · ─────┬───── · ╰── floats compared with == 23 │ } ╰──── help: Floating-point results are rarely bit-exact, so == and != may not behave as intended. Compare within a tolerance instead, e.g. math.Abs(a - b) < c
● Regexp recompiled on every iteration ╭─[scan.lis:7:34] 6 │ for l in lines { 7 │ let hit = regexp.MatchString("^lis", l)? · ───┬── · ╰── recompiled 8 │ if hit { ╰──── help: Compile the pattern once outside the loop and reuse it: regexp.MustCompile, or regexp.Compile to keep the error
● Unnecessary eager evaluation ╭─[scan.lis:16:30] 15 │ fn caption(name: Option<string>) -> string { 16 │ let shown = name.unwrap_or(fallback()) · ─────┬──── · ╰── always evaluated 17 │ strings.TrimSpace(shown) 18 │ } ╰──── help: Replace .unwrap_or(...) with .unwrap_or_else(...) so the fallback runs only when needed
● Filtered slice thrown away ╭─[jobs.lis:4:3] 3 │ fn pending(jobs: Slice<Job>) -> Option<Job> { 4 │ jobs.filter(|j| j.state == "pending").get(0) · ──────────────────────┬───────────────────── · ╰── can use find 5 │ } ╰──── help: filter(...).get(0) builds the whole filtered slice. Use jobs.find(|j| j.state == "pending") to return the first match directly
● Inefficient string comparison ╭─[scan.lis:27:3] 25 │ let left = strings.TrimSpace(a) 26 │ let right = strings.TrimSpace(b) 27 │ strings.ToLower(left) == strings.ToLower(right) · ───────────────────────┬─────────────────────── · ╰── can use strings.EqualFold 28 │ } ╰──── help: Use strings.EqualFold(left, right) to compare case-insensitively in one call
Import Go packages, stay on Go's runtime
import "go:os" fn server_url() -> string { let scheme = os.LookupEnv("HTTPS") .filter(|s| s == "1") .map(|_| "https") .unwrap_or("http") let host = os.LookupEnv("HOST") .unwrap_or("localhost") let port = os.LookupEnv("PORT") .unwrap_or("8080") scheme + "://" + host + ":" + port }
// func Getenv(key string) string pub fn Getenv(key: string) -> string // func LookupEnv(key string) (string, bool) pub fn LookupEnv(key: string) -> Option<string> // func Open(name string) (*File, error) pub fn Open(name: string) -> Result<Ref<File>, error> // func Remove(name string) error pub fn Remove(name: string) -> Result<(), error> // func Hostname() (string, error) pub fn Hostname() -> Result<string, error>
$ lis add google/uuid · Fetching github.com/google/uuid@latest ✓ Added github.com/google/uuid v1.6.0
[project] name = "checkout" version = "0.1.0" [dependencies.go] "github.com/google/uuid" = "v1.6.0"
import "go:github.com/google/uuid" pub struct Order { pub id: string, pub sku: string, pub qty: int, } impl Order { /// An order with a freshly generated ID. pub fn new(sku: string, qty: int) -> Order { Order { id: uuid.New().String(), sku, qty } } }
#[json(camel_case)] struct UserProfile { user_name: string, #[json("userID")] account_id: int, #[json(omitempty)] bio: Option<string>, #[json(skip)] internal_id: int, }
package main import lis "github.com/ivov/lisette/prelude" type UserProfile struct { UserName string `json:"userName"` AccountId int `json:"userID"` Bio lis.Option[string] `json:"bio,omitzero"` InternalId int `json:"-"` }
import "go:errors" import "go:io" fn drain(r: io.Reader) -> Result<int, error> { let mut buf = Slice.make<byte>(1024) match r.Read(buf) { Ok(n) => Ok(n), Both(n, err) if errors.Is(err, io.EOF) => Ok(n), Both(_, err) => Err(err), Err(err) => Err(err), } }
import "go:context" fn request_id(ctx: context.Context) -> Option<string> { let value = ctx.Value("request_id") assert_type<string>(value) } fn log_prefix(ctx: context.Context) -> string { match request_id(ctx) { Some(id) => f"[{id}]", None => "[anonymous]", } }
Little to learn, and the rest is Go
import "go:errors"
import "go:fmt"
import "go:strconv"
import "go:strings"
import "go:time"
import "display"
import "models"
import "store"
pub fn add(args: Slice<string>) -> Result<(), error> {
let Some(title) = args.get(2) else {
return Err(errors.New(
"usage: add <title> [--priority low|medium|high] [--tags a,b,c]",
))
}
let mut priority = models.Priority.Medium
let mut tags: Slice<string> = []
let mut i = 3
while i < args.length() {
match args[i] {
"--priority" => {
let Some(val) = args.get(i + 1) else {
return Err(errors.New("--priority requires a value"))
}
priority = models.parse_priority(val)?
i += 2
},
"--tags" => {
let Some(val) = args.get(i + 1) else {
return Err(errors.New("--tags requires a value"))
}
tags = strings.Split(val, ",")
i += 2
},
other => return Err(errors.New(f"unknown flag: '{other}'")),
}
}
let items = store.load()?
let id = store.next_id(items)
let item = models.Task.new(id, title, priority, tags)
let items = items.append(item)
store.save(items)?
fmt.Println(f"Added task #{id}: {title} ({priority.label()} priority)")
Ok(())
}
pub fn done(args: Slice<string>) -> Result<(), error> {
let Some(raw) = args.get(2) else {
return Err(errors.New("usage: done <id>"))
}
let id = parse_id(raw)?
let items = store.load()?
let items = store.set_status(items, id, models.Status.Done)?
store.save(items)?
fmt.Println(f"Completed task #{id}")
Ok(())
}
pub fn cancel(args: Slice<string>) -> Result<(), error> {
let Some(raw) = args.get(2) else {
return Err(errors.New("usage: cancel <id> <reason>"))
}
let Some(reason) = args.get(3) else {
return Err(errors.New("usage: cancel <id> <reason>"))
}
let id = parse_id(raw)?
let items = store.load()?
let items = store.set_status(items, id, models.Status.Cancelled(reason))?
store.save(items)?
fmt.Println(f"Cancelled task #{id}")
Ok(())
}
pub fn list() -> Result<(), error> {
let items = store.load()?
let pending = items.filter(|t| t.status == models.Status.Pending)
let done = items.filter(|t| t.status == models.Status.Done)
let cancelled = items.filter(|t| t.status.is_cancelled())
display.print_tasks("Pending", pending)
display.print_tasks("Done", done)
display.print_tasks("Cancelled", cancelled)
Ok(())
}
pub fn stats() -> Result<(), error> {
let items = store.load()?
display.print_stats(items)
Ok(())
}
pub fn watch() -> Result<(), error> {
let ch = Channel.new<bool>()
task {
loop {
time.Sleep(2 * time.Second)
ch.send(true)
}
}
fmt.Println("Watching tasks (Ctrl+C to stop)...")
list()?
for _ in ch {
fmt.Println("\x1b[2J\x1b[H")
list()?
}
Ok(())
}
fn parse_id(s: string) -> Result<int, error> {
strconv.Atoi(s).wrap_err(f"'{s}' is not a valid task ID")
}import "go:fmt"
import "models"
/// Prints nothing when the list is empty.
pub fn print_tasks(title: string, items: Slice<models.Task>) {
if items.is_empty() { return }
fmt.Println(f"\n{title}:")
for item in items {
fmt.Println(item.display())
}
}
pub fn print_stats(items: Slice<models.Task>) {
let total = items.length()
let done = items.filter(|t| t.status == models.Status.Done).length()
let pending = items.filter(|t| t.status == models.Status.Pending).length()
let cancelled = total - done - pending
let tag_count = items.fold(0, |sum, t| sum + t.tags.length())
fmt.Println(f"\n total: {total}")
fmt.Println(f" done: {done}")
fmt.Println(f" pending: {pending}")
fmt.Println(f" cancelled: {cancelled}")
fmt.Println(f" tags: {tag_count}")
}
pub fn print_usage() {
fmt.Println("Usage: tasks <command> [args]")
fmt.Println("")
fmt.Println("Commands:")
fmt.Println(" add <title> [--priority low|medium|high] [--tags a,b,c]")
fmt.Println(" done <id>")
fmt.Println(" cancel <id> <reason>")
fmt.Println(" list")
fmt.Println(" stats")
fmt.Println(" watch")
}import "go:errors"
import "go:fmt"
import "go:os"
import "commands"
import "display"
fn main() {
let Some(command) = os.Args.get(1) else {
display.print_usage()
return
}
let result = match command {
"add" => commands.add(os.Args),
"done" => commands.done(os.Args),
"cancel" => commands.cancel(os.Args),
"list" => commands.list(),
"stats" => commands.stats(),
"watch" => commands.watch(),
other => Err(errors.New(f"unknown command: '{other}'")),
}
if let Err(err) = result {
fmt.Println(f"Error: {err}")
os.Exit(1)
}
}import "go:errors"
pub enum Priority {
Low,
Medium,
High,
}
impl Priority {
pub fn label(self) -> string {
match self {
Low => "low",
Medium => "medium",
High => "high",
}
}
}
pub fn parse_priority(s: string) -> Result<Priority, error> {
match s {
"low" => Ok(Priority.Low),
"medium" => Ok(Priority.Medium),
"high" => Ok(Priority.High),
other => Err(errors.New(
f"unknown priority: '{other}' (expected low, medium, or high)",
)),
}
}
pub enum Status {
Pending,
Done,
Cancelled(string),
}
impl Status {
pub fn is_cancelled(self) -> bool {
match self {
Cancelled(_) => true,
_ => false,
}
}
pub fn label(self) -> string {
match self {
Pending => "pending",
Done => "done",
Cancelled(reason) => f"cancelled: {reason}",
}
}
}#[test]
fn parse_priority_accepts_known_levels() {
let assert Ok(low) = parse_priority("low")
assert low.label() == "low"
let assert Ok(medium) = parse_priority("medium")
assert medium.label() == "medium"
let assert Ok(high) = parse_priority("high")
assert high.label() == "high"
}
#[test]
fn parse_priority_rejects_unknown() {
let assert Err(err) = parse_priority("urgent")
assert err.Error().contains("unknown priority")
}
#[test]
fn cancelled_status_carries_its_reason() {
let status = Status.Cancelled("duplicate")
assert status.is_cancelled()
assert status.label() == "cancelled: duplicate"
}
#[test]
fn pending_status_is_not_cancelled() {
assert !Status.Pending.is_cancelled()
}#[json]
pub struct Task {
pub id: int,
pub title: string,
pub priority: Priority,
pub status: Status,
pub tags: Slice<string>,
}
impl Task {
pub fn new(
id: int,
title: string,
priority: Priority,
tags: Slice<string>,
) -> Task {
Task { id, title, priority, status: Status.Pending, tags }
}
pub fn display(self) -> string {
let icon = match self.status {
Pending => "○",
Done => "●",
Cancelled(_) => "✕",
}
let priority = match self.priority {
High => " !!",
Medium => " !",
Low => "",
}
let tags = match self.tags {
[] => "",
_ => f" [{self.tags.join(", ")}]",
}
let note = match self.status {
Cancelled(_) => f" ({self.status.label()})",
_ => "",
}
f" {icon} #{self.id} {self.title}{priority}{tags}{note}"
}
}#[test]
fn new_task_starts_pending() {
let item = Task.new(1, "write the docs", Priority.High, [])
assert item.id == 1
assert item.title == "write the docs"
assert item.status == Status.Pending
}
#[test]
fn display_shows_id_priority_and_tags() {
let item = Task.new(7, "ship release", Priority.High, ["urgent", "release"])
let line = item.display()
assert line.contains("#7 ship release")
assert line.contains("!!")
assert line.contains("[urgent, release]")
}
#[test]
fn display_omits_tags_when_empty() {
let item = Task.new(2, "tidy up", Priority.Low, [])
assert !item.display().contains("[")
}import "go:encoding/json"
import "go:errors"
import "go:io/fs"
import "go:os"
import "models"
const FILE = "tasks.json"
/// A missing file yields an empty list, not an error.
pub fn load() -> Result<Slice<models.Task>, error> {
let bytes = match os.ReadFile(FILE) {
Ok(bytes) => bytes,
Err(err) => {
if errors.Is(err, fs.ErrNotExist) { return Ok([]) }
return Err(err).wrap_err(f"reading {FILE}")
},
}
let mut items: Slice<models.Task> = []
json.Unmarshal(bytes, &items).wrap_err(f"parsing {FILE}")?
Ok(items)
}
pub fn save(items: Slice<models.Task>) -> Result<(), error> {
let data = json.MarshalIndent(items, "", " ").wrap_err("encoding tasks")?
let permissions = 0o644 as fs.FileMode
os.WriteFile(FILE, data, permissions).wrap_err(f"writing {FILE}")?
Ok(())
}
pub fn next_id(items: Slice<models.Task>) -> int {
items.fold(0, |max, t| if t.id > max { t.id } else { max }) + 1
}
pub fn set_status(
items: Slice<models.Task>,
id: int,
status: models.Status,
) -> Result<Slice<models.Task>, error> {
let Some(_) = items.find(|t| t.id == id) else {
return Err(errors.New(f"task #{id} not found"))
}
let updated = items.map(|t| {
if t.id == id { models.Task { status, ..t } } else { t }
})
Ok(updated)
}import "models"
fn sample() -> Slice<models.Task> {
[
models.Task.new(1, "first", models.Priority.Low, []),
models.Task.new(2, "second", models.Priority.High, []),
]
}
#[test]
fn next_id_starts_at_one_for_empty() {
let empty: Slice<models.Task> = []
assert next_id(empty) == 1
}
#[test]
fn next_id_is_one_past_the_max() {
assert next_id(sample()) == 3
}
#[test]
fn set_status_marks_a_task_done() {
let assert Ok(updated) = set_status(sample(), 1, models.Status.Done)
let assert Some(item) = updated.find(|t| t.id == 1)
assert item.status == models.Status.Done
}
#[test]
fn set_status_reports_a_missing_task() {
let assert Err(err) = set_status(sample(), 99, models.Status.Done)
assert err.Error().contains("not found")
}
#[test]
fn set_status_records_a_cancel_reason() {
let assert Ok(updated) = set_status(
sample(),
2,
models.Status.Cancelled("obsolete"),
)
let assert Some(item) = updated.find(|t| t.id == 2)
assert item.status.label() == "cancelled: obsolete"
}