export fn and finch build --shared: libraries a C program loads (game scripts)

export fn name(...) is a C function by that name (numbers, bool, char, pointers in and out).
--shared builds lib<name>.so / <name>.dll with no main. finch_module_on_fatal(handler) lets the
host take runtime errors over (longjmp out) instead of the process exiting.
tests/shared.sh: a C host loads one, calls it and survives an index error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-11 02:14:09 +02:00
commit 68637a35a7
16 changed files with 148 additions and 10 deletions

View file

@ -34,6 +34,8 @@ jobs:
build/finch version build/finch version
- name: Tests - name: Tests
run: tests/run.sh run: tests/run.sh
- name: Shared library
run: tests/shared.sh
- name: Tests under valgrind - name: Tests under valgrind
if: matrix.arch == 'x64' if: matrix.arch == 'x64'
run: MEMCHECK=1 tests/run.sh run: MEMCHECK=1 tests/run.sh

View file

@ -12,6 +12,7 @@ Everything in Finch on one page. For explanations, see the [guides](../../README
|---|---| |---|---|
| `finch run file.fch [args…]` | compile and run (args go to the program) | | `finch run file.fch [args…]` | compile and run (args go to the program) |
| `finch build file.fch [-o name]` | make a program (`name.exe` on Windows) | | `finch build file.fch [-o name]` | make a program (`name.exe` on Windows) |
| `finch build file.fch --shared` | make a library (`libfile.so`, `file.dll`) that a C program loads; its `export fn`s are its C functions (numbers, bool, char, pointers) |
| `finch ir file.fch` | print the LLVM IR | | `finch ir file.fch` | print the LLVM IR |
| `finch lsp` | the language server (editors use it) | | `finch lsp` | the language server (editors use it) |
| `finch version` | version | | `finch version` | version |

View file

@ -37,12 +37,26 @@ static char empty[1] = "";
// ---------- runtime errors ---------- // ---------- runtime errors ----------
__attribute__((noreturn, cold)) void finch_panic(const char *file, int64_t line, const char *msg) { // A program that loads Finch libraries (finch build --shared) can take runtime errors over
// instead of the process exiting: finch_module_on_fatal(handler) in each library. The handler
// gets the message and must not return (longjmp out, or exit itself).
static void (*fatal_hook)(const char *msg);
void finch_on_fatal(void (*hook)(const char *msg)) { fatal_hook = hook; }
static __attribute__((noreturn)) void fatal(const char *msg) {
if (fatal_hook) fatal_hook(msg);
fflush(stdout); fflush(stdout);
fprintf(stderr, "%s:%lld: runtime error: %s\n", file, (long long)line, msg); fprintf(stderr, "%s\n", msg);
exit(1); exit(1);
} }
__attribute__((noreturn, cold)) void finch_panic(const char *file, int64_t line, const char *msg) {
char full[1024];
snprintf(full, sizeof full, "%s:%lld: runtime error: %s", file, (long long)line, msg);
fflush(stdout);
fatal(full);
}
__attribute__((noreturn, cold)) void finch_panic_index(const char *file, int64_t line, int64_t i, int64_t len) { __attribute__((noreturn, cold)) void finch_panic_index(const char *file, int64_t line, int64_t i, int64_t len) {
char msg[128]; char msg[128];
snprintf(msg, sizeof msg, "index %lld is out of range (the length is %lld)", (long long)i, (long long)len); snprintf(msg, sizeof msg, "index %lld is out of range (the length is %lld)", (long long)i, (long long)len);
@ -52,8 +66,7 @@ __attribute__((noreturn, cold)) void finch_panic_index(const char *file, int64_t
static void *must(void *p) { static void *must(void *p) {
if (!p) { if (!p) {
fflush(stdout); fflush(stdout);
fprintf(stderr, "runtime error: out of memory\n"); fatal("runtime error: out of memory");
exit(1);
} }
return p; return p;
} }

View file

@ -315,6 +315,7 @@ struct FnDecl {
Pos recvPos; Pos recvPos;
Type ret; // Void when there is no `-> type` Type ret; // Void when there is no `-> type`
bool fallible = false; // -> int! / -> ! : can fail with an error bool fallible = false; // -> int! / -> ! : can fail with an error
bool exported = false; // export fn: callable from C by its own name (a library's entry point)
std::vector<Param> params; std::vector<Param> params;
BlockPtr body; BlockPtr body;
}; };

View file

@ -4,6 +4,7 @@
#include "codegen_impl.h" #include "codegen_impl.h"
#include "builtins_doc.h" #include "builtins_doc.h"
#include "target.h"
using namespace llvm; using namespace llvm;
@ -36,12 +37,29 @@ Codegen::Codegen(LLVMContext &c, const std::vector<Program> &ps, const CImports
std::unique_ptr<Module> Codegen::run() { std::unique_ptr<Module> Codegen::run() {
declareStructs(); declareStructs();
declareFns(); declareFns();
if (!modules[""].fns.count("main")) failAt(0, 1, 1, "there is no main function (add 'fn main() { ... }')"); if (g_target.shared) {
if (modules[""].fns.count("main")) {
const Pos &pos = modules[""].fns["main"].decl->namePos;
failAt(pos.file, pos.line, pos.col, "a library (--shared) has no main: the program that loads it calls its 'export fn's");
}
} else if (!modules[""].fns.count("main")) {
failAt(0, 1, 1, "there is no main function (add 'fn main() { ... }')");
}
for (auto &[name, m] : modules) for (auto &[name, m] : modules)
for (auto &[fname, fn] : m.fns) define(fn); for (auto &[fname, fn] : m.fns) define(fn);
for (auto &[s, ms] : methods) for (auto &[s, ms] : methods)
for (auto &[mname, fn] : ms) define(fn); for (auto &[mname, fn] : ms) define(fn);
defineMainWrapper(); if (!g_target.shared) defineMainWrapper();
for (const auto &[module, name] : exportNames) defineExport(modules[module].fns[name]);
if (g_target.shared) { // finch_module_on_fatal(handler): the host's say on runtime errors (see finch_rt.c)
Function *w = Function::Create(FunctionType::get(b.getVoidTy(), {b.getPtrTy()}, false), Function::ExternalLinkage,
"finch_module_on_fatal", mod.get());
if (g_target.windows) w->setDLLStorageClass(GlobalValue::DLLExportStorageClass);
b.SetInsertPoint(BasicBlock::Create(ctx, "entry", w));
b.SetCurrentDebugLocation(DebugLoc());
b.CreateCall(rt("finch_on_fatal"), {w->getArg(0)});
b.CreateRetVoid();
}
if (di) di->finalize(); if (di) di->finalize();
std::string err; std::string err;
@ -188,8 +206,44 @@ void Codegen::declare(const Program &p, const FnDecl &fn) {
// User functions get a prefix so they never clash with C library names. // User functions get a prefix so they never clash with C library names.
std::string name = "finch." + (p.module.empty() ? "" : p.module + ".") + (recv ? recv->name + "." : "") + fn.name; std::string name = "finch." + (p.module.empty() ? "" : p.module + ".") + (recv ? recv->name + "." : "") + fn.name;
f.llvm = Function::Create(FunctionType::get(retType(f), params, false), Function::InternalLinkage, name, mod.get()); f.llvm = Function::Create(FunctionType::get(retType(f), params, false), Function::InternalLinkage, name, mod.get());
if (fn.exported) {
if (recv) failAt(pos.file, pos.line, pos.col, "methods can't be exported (export a plain fn that calls it)");
if (f.fallible) failAt(pos.file, pos.line, pos.col, "an export fn can't fail (C has no errors as values): handle them inside it");
auto cType = [](const FType &t) { return t.isNumber() || t.kind == FType::Bool || t.kind == FType::Char || t.kind == FType::Ptr; };
for (size_t i = 0; i < f.params.size(); i++)
if (!cType(f.params[i]))
failAt(fn.params[i].pos.file, fn.params[i].pos.line, fn.params[i].pos.col,
"an export fn takes numbers, bool, char and pointers only (this is " + f.params[i].show() +
"; pass text as ptr[char] and make it a str inside)");
if (f.ret.kind != FType::Void && !cType(f.ret))
failAt(pos.file, pos.line, pos.col, "an export fn returns nothing, a number, bool, char or a pointer (not " + f.ret.show() + ")");
for (const auto &[mname, other] : modules)
if (const auto it = other.fns.find(fn.name); it != other.fns.end() && it->second.decl->exported)
failAt(pos.file, pos.line, pos.col, "'" + fn.name + "' is exported twice (from two modules)");
}
if (recv) methods[recv][fn.name] = f; if (recv) methods[recv][fn.name] = f;
else m.fns[fn.name] = f; else m.fns[fn.name] = f;
if (fn.exported) exportNames.push_back({p.module, fn.name});
}
// export fn name(...): a C function called `name` that calls the Finch one (bool is a C byte).
void Codegen::defineExport(Fn &fn) {
auto cTy = [&](const FType &t) -> llvm::Type * { return t.kind == FType::Bool ? b.getInt8Ty() : ty(t); };
std::vector<llvm::Type *> params;
for (const FType &t : fn.params) params.push_back(cTy(t));
llvm::Type *ret = fn.ret.kind == FType::Void ? b.getVoidTy() : cTy(fn.ret);
Function *w = Function::Create(FunctionType::get(ret, params, false), Function::ExternalLinkage, fn.decl->name, mod.get());
if (g_target.windows) w->setDLLStorageClass(GlobalValue::DLLExportStorageClass);
b.SetInsertPoint(BasicBlock::Create(ctx, "entry", w));
b.SetCurrentDebugLocation(DebugLoc());
std::vector<Value *> args;
for (size_t i = 0; i < fn.params.size(); i++) {
Value *a = w->getArg(unsigned(i));
args.push_back(fn.params[i].kind == FType::Bool ? b.CreateICmpNE(a, b.getInt8(0)) : a);
}
Value *r = b.CreateCall(fn.llvm, args);
if (fn.ret.kind == FType::Void) b.CreateRetVoid();
else b.CreateRet(fn.ret.kind == FType::Bool ? b.CreateZExt(r, b.getInt8Ty()) : r);
} }
// The real C `main` calls the Finch one and turns its result into an exit code. // The real C `main` calls the Finch one and turns its result into an exit code.

View file

@ -50,6 +50,7 @@ Function *Codegen::rt(const std::string &name) {
struct Sig { llvm::Type *ret; std::vector<llvm::Type *> params; }; struct Sig { llvm::Type *ret; std::vector<llvm::Type *> params; };
std::map<std::string, Sig> sigs = { std::map<std::string, Sig> sigs = {
{"finch_panic", {V, {P, I, P}}}, {"finch_panic", {V, {P, I, P}}},
{"finch_on_fatal", {V, {P}}},
{"finch_panic_index", {V, {P, I, I, I}}}, {"finch_panic_index", {V, {P, I, I, I}}},
{"finch_alloc", {P, {I}}}, {"finch_alloc", {P, {I}}},
{"finch_zalloc", {P, {I}}}, {"finch_zalloc", {P, {I}}},

View file

@ -174,6 +174,8 @@ public:
void declare(const Program &p, const FnDecl &fn); void declare(const Program &p, const FnDecl &fn);
void define(Fn &fn); void define(Fn &fn);
void defineMainWrapper(); void defineMainWrapper();
void defineExport(Fn &fn);
std::vector<std::pair<std::string, std::string>> exportNames; // (module, name) of each export fn
void block(const BlockStmt &blk); void block(const BlockStmt &blk);
void blockBody(const BlockStmt &blk); void blockBody(const BlockStmt &blk);
void stmt(const Stmt &s); void stmt(const Stmt &s);

View file

@ -5,7 +5,7 @@
#include <unordered_map> #include <unordered_map>
static const std::unordered_map<std::string, Tok> keywords = { static const std::unordered_map<std::string, Tok> keywords = {
{"fn", Tok::Fn}, {"return", Tok::Return}, {"if", Tok::If}, {"fn", Tok::Fn}, {"export", Tok::Export}, {"return", Tok::Return}, {"if", Tok::If},
{"else", Tok::Else}, {"while", Tok::While}, {"for", Tok::For}, {"else", Tok::Else}, {"while", Tok::While}, {"for", Tok::For},
{"in", Tok::In}, {"break", Tok::Break}, {"continue", Tok::Continue}, {"in", Tok::In}, {"break", Tok::Break}, {"continue", Tok::Continue},
{"true", Tok::True}, {"false", Tok::False}, {"null", Tok::Null}, {"true", Tok::True}, {"false", Tok::False}, {"null", Tok::Null},
@ -154,6 +154,7 @@ const char *tokName(Tok t) {
case Tok::String: return "a string"; case Tok::String: return "a string";
case Tok::Char: return "a char"; case Tok::Char: return "a char";
case Tok::Fn: return "'fn'"; case Tok::Fn: return "'fn'";
case Tok::Export: return "'export'";
case Tok::Return: return "'return'"; case Tok::Return: return "'return'";
case Tok::If: return "'if'"; case Tok::If: return "'if'";
case Tok::Else: return "'else'"; case Tok::Else: return "'else'";

View file

@ -8,7 +8,7 @@ enum class Tok {
Ident, Int, Float, String, Char, Ident, Int, Float, String, Char,
// keywords // keywords
Fn, Return, If, Else, While, For, In, Break, Continue, True, False, Null, Import, Link, Struct, Defer, Fn, Export, Return, If, Else, While, For, In, Break, Continue, True, False, Null, Import, Link, Struct, Defer,
OrElse, Try, // or, try OrElse, Try, // or, try
// punctuation // punctuation

View file

@ -42,6 +42,7 @@ static void usage() {
" finch lsp the language server, for editors (VS Code, Kate, ...)\n" " finch lsp the language server, for editors (VS Code, Kate, ...)\n"
"\n" "\n"
" -l <lib> link a C library, same as link \"lib\" in the file\n" " -l <lib> link a C library, same as link \"lib\" in the file\n"
" --shared (build) a library with no main: lib<name>.so / <name>.dll, its export fn's callable from C\n"
" --target <name> build for another system: windows, linux, arm64 (Linux), macos,\n" " --target <name> build for another system: windows, linux, arm64 (Linux), macos,\n"
" or an LLVM triple\n" " or an LLVM triple\n"
" -g add debug info (gdb / lldb / Visual Studio)\n" " -g add debug info (gdb / lldb / Visual Studio)\n"
@ -230,6 +231,7 @@ static bool endsWith(const std::string &s, const char *x) {
static void link(const std::string &obj, const std::string &exe, const std::vector<std::string> &libs, static void link(const std::string &obj, const std::string &exe, const std::vector<std::string> &libs,
const CImports &c) { const CImports &c) {
std::string cmd = g_target.cc + " " + shellQuote(obj) + " " + shellQuote(runtimeObject()) + " -o " + shellQuote(exe); std::string cmd = g_target.cc + " " + shellQuote(obj) + " " + shellQuote(runtimeObject()) + " -o " + shellQuote(exe);
if (g_target.shared) cmd += g_target.darwin ? " -dynamiclib" : " -shared";
if (!g_target.msvc) cmd += " -lm"; if (!g_target.msvc) cmd += " -lm";
std::vector<std::string> temps; std::vector<std::string> temps;
for (const std::string &l : libs) { for (const std::string &l : libs) {
@ -289,6 +291,7 @@ int main(int argc, char **argv) {
else if (a == "--target" && i + 1 < argc) targetName = argv[++i]; else if (a == "--target" && i + 1 < argc) targetName = argv[++i];
else if (a == "-O0") optimizeCode = false; else if (a == "-O0") optimizeCode = false;
else if (a == "-g") debug = true; else if (a == "-g") debug = true;
else if (a == "--shared" && cmd == "build") g_target.shared = true;
else if (a == "-l" && i + 1 < argc) libs.push_back(argv[++i]); else if (a == "-l" && i + 1 < argc) libs.push_back(argv[++i]);
else if (a.rfind("-l", 0) == 0 && a.size() > 2) libs.push_back(a.substr(2)); else if (a.rfind("-l", 0) == 0 && a.size() > 2) libs.push_back(a.substr(2));
else if (cmd == "run") programArgs.push_back(a); else if (cmd == "run") programArgs.push_back(a);
@ -296,7 +299,8 @@ int main(int argc, char **argv) {
} }
setTarget(targetName); setTarget(targetName);
if (!outGiven) out += g_target.exe; if (!outGiven) out = g_target.shared ? (g_target.windows ? out + ".dll" : "lib" + out + (g_target.darwin ? ".dylib" : ".so"))
: out + g_target.exe;
Loader loader; Loader loader;
loader.load(file, "", Pos{}); loader.load(file, "", Pos{});

View file

@ -180,9 +180,10 @@ private:
FnDecl function() { FnDecl function() {
FnDecl fn; FnDecl fn;
fn.pos = pos(); fn.pos = pos();
fn.exported = accept(Tok::Export);
if (at(Tok::Ident) && isTypeName(cur().text)) if (at(Tok::Ident) && isTypeName(cur().text))
fail(fn.pos.line, fn.pos.col, "functions start with 'fn', like: fn add(int a, int b) -> int { ... }"); fail(fn.pos.line, fn.pos.col, "functions start with 'fn', like: fn add(int a, int b) -> int { ... }");
expect(Tok::Fn, "'fn', 'struct', 'import' or 'link' (every function starts with fn)"); expect(Tok::Fn, fn.exported ? "'fn' (export fn name(...))" : "'fn', 'struct', 'import' or 'link' (every function starts with fn)");
fn.namePos = pos(); fn.namePos = pos();
fn.name = expect(Tok::Ident, "a function name").text; fn.name = expect(Tok::Ident, "a function name").text;

View file

@ -14,6 +14,7 @@ struct TargetInfo {
bool cross = false; // compiling for another system than the one we run on bool cross = false; // compiling for another system than the one we run on
std::string cc; // the C compiler that links programs and builds the runtime std::string cc; // the C compiler that links programs and builds the runtime
std::string exe; // ".exe" or "" std::string exe; // ".exe" or ""
bool shared = false; // finch build --shared: a library (.so / .dll / .dylib) with no main
}; };
inline TargetInfo g_target; inline TargetInfo g_target;

17
tests/shared.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
# finch build --shared: a library a C program loads, calls by name, and that hands runtime
# errors to the host instead of exiting.
set -e
cd "$(dirname "$0")/shared"
FINCH=${FINCH:-../../build/finch}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
"$FINCH" build mod.fch --shared -o "$tmp/libmod.so"
${CC:-cc} host.c -rdynamic -o "$tmp/host" -ldl
out=$("$tmp/host" "$tmp/libmod.so")
expected='add 42
even 1 0
host_print: hello Rakya
caught: mod.fch:17: runtime error: index 7 is out of range (the length is 3)
still alive'
if [ "$out" = "$expected" ]; then echo "shared library: passed"; else printf 'shared library: FAILED\n%s\n' "$out"; exit 1; fi

17
tests/shared/host.c Normal file
View file

@ -0,0 +1,17 @@
#include <dlfcn.h>
#include <setjmp.h>
#include <stdio.h>
static jmp_buf jb;
void host_print(const char *s) { printf("host_print: %s\n", s); }
static void onFatal(const char *m) { printf("caught: %s\n", m); longjmp(jb, 1); }
int main(int argc, char **argv) {
void *h = dlopen(argc > 1 ? argv[1] : "./libmod.so", RTLD_NOW | RTLD_LOCAL);
if (!h) { printf("%s\n", dlerror()); return 1; }
((void (*)(void (*)(const char *)))dlsym(h, "finch_module_on_fatal"))(onFatal);
printf("add %ld\n", ((long (*)(long, long))dlsym(h, "add"))(2, 40));
printf("even %d %d\n", ((_Bool (*)(long))dlsym(h, "is_even"))(4), ((_Bool (*)(long))dlsym(h, "is_even"))(5));
((void (*)(const char *))dlsym(h, "hello"))("Rakya");
if (!setjmp(jb)) ((long (*)(long))dlsym(h, "crash"))(7);
printf("still alive\n");
return 0;
}

1
tests/shared/host.h Normal file
View file

@ -0,0 +1 @@
void host_print(const char *s);

22
tests/shared/mod.fch Normal file
View file

@ -0,0 +1,22 @@
import "host.h"
fn greet(str who) -> str {
return "hello " + who
}
export fn add(int a, int b) -> int {
return a + b
}
export fn hello(ptr[char] name) {
host_print(greet(str(name)))
}
export fn crash(int i) -> int {
xs := [1, 2, 3]
return xs[i]
}
export fn is_even(int n) -> bool {
return n % 2 == 0
}