Finch 2.4.0: methods, maps, errors as values; str() of any value; macOS and ARM64 docs; Kate support

- fn Struct.method(...) with self by reference, also on C structs
- map[K]V: insertion-ordered hash maps, literals, m[k] += 1, has/get/remove/keys/values
- -> T! / -> !, return error(...), or <value>, or { err }, try; fallible int/float/read_file/write_file/delete_file
- for i, x in list / for k, v in map
- print and str() share one text formatter
- LSP: method and map completion, method signatures; grammar and snippets for VS Code and Kate
- tests: 74 (methods, errors, maps + 11 failure cases); docs at all three levels, en and pl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-09 01:11:12 +02:00
commit b872be3176
55 changed files with 3400 additions and 336 deletions

86
tests/run/errors.fch Normal file
View file

@ -0,0 +1,86 @@
struct Config {
str name
int port = 8080
}
fn parse_port(str text) -> int! {
n := int(text) or { return error("the port '" + text + "' is not a number") }
if n < 1 || n > 65535 {
return error("the port " + str(n) + " is out of range")
}
return n
}
fn parse_config(str line) -> Config! {
parts := line.split("=")
if parts.len != 2 {
return error("expected name=port, got '" + line + "'")
}
port := try parse_port(parts[1].trim()) // a failure here fails parse_config too
return Config(parts[0].trim(), port)
}
fn save(str path, str text) -> ! {
if path.len == 0 {
return error("no file name")
}
try write_file(path, text)
}
struct Account {
int balance
}
fn Account.withdraw(int amount) -> ! {
if amount > self.balance {
return error("not enough money: " + str(self.balance) + " < " + str(amount))
}
self.balance -= amount
}
fn first_number([]str words) -> int! {
for w in words {
n := int(w) or { continue }
return n
}
return error("no numbers")
}
fn main() -> ! {
print(parse_port("80") or 0)
print(parse_port("abc") or -1)
print(parse_port("99999") or -1)
for line in ["web = 80", "db=5432", "bad", "x = y", "big = 70000"] {
c := parse_config(line) or {
print("skipped:", err)
continue
}
print(c)
}
print(int("12") or 0, int(" 7 ") or 0, int("seven") or 0, float("2.5") or 0, float("") or -1.0)
text := read_file("does/not/exist.txt") or "default text"
print(text)
read_file("does/not/exist.txt") or { print("read failed:", err.starts_with("can't read the file")) }
save("", "x") or { print("save failed:", err) }
try save("errors_tmp.txt", "saved")
print(try read_file("errors_tmp.txt"))
try delete_file("errors_tmp.txt")
delete_file("errors_tmp.txt") or { print("second delete failed") }
a := Account(100)
a.withdraw(30) or { print("no") }
a.withdraw(500) or { print(err) }
print(a.balance)
print(first_number(["a", "b", "42", "7"]) or 0, first_number(["x"]) or -1)
// nested: the fallback can be another call that may fail
print(parse_port("x") or parse_port("443") or 0)
try parse_port("not a port") // main fails: prints the error, exit code 1
print("never printed")
}

19
tests/run/errors.out Normal file
View file

@ -0,0 +1,19 @@
80
-1
-1
Config(name: "web", port: 80)
Config(name: "db", port: 5432)
skipped: expected name=port, got 'bad'
skipped: the port 'y' is not a number
skipped: the port 70000 is out of range
12 7 0 2.5 -1
default text
read failed: true
save failed: no file name
saved
second delete failed
not enough money: 70 < 500
70
42 -1
443
error: the port 'not a port' is not a number

View file

@ -16,3 +16,8 @@ fn add(Vec a, Vec b) -> Vec {
fn describe(Vec v) -> str {
return "(" + str(v.x) + ", " + str(v.y) + ") of length " + str(length(v))
}
// a method in a module: callable as v.scaled(2) wherever Vec is used
fn Vec.scaled(float k) -> Vec {
return Vec(self.x * k, self.y * k)
}

101
tests/run/maps.fch Normal file
View file

@ -0,0 +1,101 @@
struct Stats {
int count
[]str words
}
fn count_words(str text) -> map[str]int {
map[str]int counts
for w in text.split(" ") {
if w.len > 0 {
counts[w.lower()] += 1 // a missing key starts at 0
}
}
return counts
}
fn main() {
ages := ["anna": 31, "bob": 25]
ages["cyril"] = 40
ages["bob"] = 26
print(ages, ages.len)
print(ages["anna"], ages.has("bob"), ages.has("zoe"), ages.get("zoe", -1))
for name, age in ages {
print(name, "is", age)
}
for name in ages {
print(name)
}
print(ages.remove("bob"), ages.remove("bob"), ages)
ages["dave"] = 19 // goes to the end; insertion order is kept
print(ages.keys(), ages.values())
counts := count_words("the cat and The dog and THE bird")
print(counts)
print(str(counts))
// values that own memory
map[str][]int groups
for i in 0..10 {
key := "odd"
if i % 2 == 0 {
key = "even"
}
groups[key].push(i) // m[k].push(x) adds the key with an empty array first
}
print(groups)
copy := groups
copy["odd"].clear()
print(groups["odd"].len, copy["odd"].len)
map[int]Stats by_len
for w in ["a", "bb", "cc", "ddd", "e"] {
by_len[w.len].count += 1
by_len[w.len].words.push(w)
}
for n, s in by_len {
print(n, s.count, s.words)
}
// many keys: growing, removing and adding again
map[int]int squares
for i in 0..1000 {
squares[i] = i * i
}
for i in 0..1000 {
if i % 3 != 0 {
squares.remove(i)
}
}
for i in 2000..2010 {
squares[i] = 1
}
total := 0
for k, v in squares {
total += v
}
print(squares.len, total, squares[999], squares.has(998))
map[char]bool seen
for c in "hello" {
seen[c] = true
}
print(seen.keys())
empty := ages
empty.clear()
print(empty, empty.len, ages.len)
map[str]str m = [:]
m["x"] = "1"
print(m)
for i, w in ["zero", "one", "two"] {
print(i, w)
}
for i, c in "hey" {
print(i, c)
}
}

27
tests/run/maps.out Normal file
View file

@ -0,0 +1,27 @@
{"anna": 31, "bob": 26, "cyril": 40} 3
31 true false -1
anna is 31
bob is 26
cyril is 40
anna
bob
cyril
true false {"anna": 31, "cyril": 40}
["anna", "cyril", "dave"] [31, 40, 19]
{"the": 3, "cat": 1, "and": 2, "dog": 1, "bird": 1}
{"the": 3, "cat": 1, "and": 2, "dog": 1, "bird": 1}
{"even": [0, 2, 4, 6, 8], "odd": [1, 3, 5, 7, 9]}
5 0
1 2 ["a", "e"]
2 2 ["bb", "cc"]
3 1 ["ddd"]
344 111277621 998001 false
['h', 'e', 'l', 'o']
{} 0 3
{"x": "1"}
0 zero
1 one
2 two
0 h
1 e
2 y

101
tests/run/methods.fch Normal file
View file

@ -0,0 +1,101 @@
import geometry
import "abi.h"
link "abi.c"
struct Counter {
str name
int count
[]int history
}
fn Counter.add(int n) {
self.count += n
self.history.push(n)
}
fn Counter.add_twice(int n) {
self.add(n) // a method calling another method on self
self.add(n)
}
fn Counter.total() -> int {
sum := 0
for x in self.history {
sum += x
}
return sum
}
fn Counter.describe() -> str {
return self.name + ": " + str(self.count) + " " + str(self.history)
}
fn Counter.copy_named(str name) -> Counter {
c := self // a copy of the struct, not the original
c.name = name
return c
}
fn Counter.merge(Counter other) {
for x in other.history {
self.add(x)
}
}
struct Node {
int value
ptr[Node] next
}
fn Node.sum() -> int {
total := self.value
if self.next != null {
total += self.next.sum() // through a pointer
}
return total
}
// a method on a struct from C
fn Vec2.length2() -> f32 {
return self.x * self.x + self.y * self.y
}
fn main() {
c := Counter(name: "clicks")
c.add(3)
c.add_twice(2)
print(c.describe(), c.total())
d := c.copy_named("copy")
d.add(10)
print(c.describe())
print(d.describe())
c.merge(c) // the argument lives inside self: the method gets its own copy
print(c.describe())
[]Counter all = [Counter(name: "a"), Counter(name: "b")]
all[1].add(7)
for i in 0..all.len {
all[i].add(i)
}
print(all[0].describe(), "|", all[1].describe())
print(Counter(name: "temp", count: 5).describe())
last := new(Node(3, null))
first := new(Node(1, new(Node(2, last))))
print(first.sum())
p := first.next
p.value = 20
print(first.sum(), p.sum())
free(last)
free(first.next)
free(first)
v := geometry.Vec(3, 4)
print(geometry.length(v.scaled(2)))
w := Vec2(3, 4)
print(w.length2())
}

10
tests/run/methods.out Normal file
View file

@ -0,0 +1,10 @@
clicks: 7 [3, 2, 2] 7
clicks: 7 [3, 2, 2]
copy: 17 [3, 2, 2, 10]
clicks: 14 [3, 2, 2, 3, 2, 2]
a: 0 [0] | b: 8 [7, 1]
temp: 5 []
6
24 23
10
25