The current title is 101 characters, one over the limit. I'll shorten it by removing "Implementing":
"Technical deep-dive into building a bilingual compiler with ownership semantics, lambda closures, and type inference in C."
# Building a Bilingual Compiler: Implementing Ownership, Closures, and Type Inference in 10K Lines of C
## Introduction
Over the past several months, I built KORE — a programming language that compiles to C and supports both Turkish and English keywords natively. The compiler is written entirely in C11 (10,667 lines) and implements features like ownership semantics, lambda closures with capture, type inference, pattern matching, and taint analysis.
This post details the technical challenges I encountered and the solutions I implemented, focusing on three core problems:
-
Bilingual lexing and parsing — How to support two languages in one compiler
-
Ownership tracking — Move vs borrow semantics without a borrow checker
-
Lambda closures — Capturing outer variables in a C transpiler
GitHub: kore-lang
— -
## The Bilingual Challenge
### Problem: Two Languages, One Compiler
Most programming languages choose one set of keywords. KORE needed to support both Turkish and English simultaneously — even in the same file:
func main():
eger true: # Turkish “if”
yazdir(“Merhaba”) # Turkish “print”
else:
print(“Hello”) # English “print”
The naive approach would be to maintain two separate lexers or parsers. But that doubles the maintenance burden and creates consistency problems.
### Solution: Unified Token Space with Language-Aware Lexer
I designed a single token enum where each logical concept has one token:
typedef enum {
TOK_FUNC, // “func” (both languages)
TOK_EGER, // “eger” (Turkish) or “if” (English)
TOK_YAZDIR, // “yazdir” (Turkish) or “print” (English)
// …
} TokenKind;
The lexer uses a keyword table with three columns:
static const KW keywords[] = {
{“func”, TOK_FUNC, ‘b’}, // ‘b’ = both languages
{“eger”, TOK_EGER, ‘t’}, // ‘t’ = Turkish only
{“if”, TOK_EGER, ‘e’}, // ‘e’ = English only
{“yazdir”, TOK_YAZDIR, ‘t’},
{“print”, TOK_YAZDIR, ‘e’},
// …
};
During lexing, I check the current language mode against the keyword’s language column:
typedef enum { LANG_AUTO, LANG_TR, LANG_EN } LangMode;
static TokenKind match_keyword(Scanner *s, const char *text) {
for (int i = 0; i < keyword_count; i++) {
if (strcmp(keywords[i].word, text) == 0) {
char lang = keywords[i].lang;
// Match if: ‘b’ (both), or matches current mode
if (lang == ‘b’ ||
(s->lang_mode == LANG_TR && lang == ‘t’) ||
(s->lang_mode == LANG_EN && lang == ‘e’) ||
s->lang_mode == LANG_AUTO) {
return keywords[i].token;
}
}
}
return TOK_IDENT; // Not a keyword
}
Auto-detection: If the file doesn’t specify a language with #lang tr or #lang en, the lexer operates in LANG_AUTO mode and accepts both keyword sets. This allows mixed-language code.
Result: Only one parser, one AST, one codegen. The parser sees TOK_EGER whether the source said eger or if.
— -
## Ownership Without a Borrow Checker
### Problem: Memory Safety in a C Transpiler
Rust’s ownership system prevents use-after-free and double-free bugs at compile time. I wanted similar safety for heap-allocated strings in KORE, but without implementing a full borrow checker (which is a PhD-level project).
Example problem:
val name = “Alice” # heap string (from concat or conversion)
val other = name # move or copy?
print(name) # valid or error?
Should the second line move ownership (invalidating name) or copy the value?
### Solution: Type-Based Ownership Rules
I implemented a simplified ownership system with three rules:
-
Primitives are copied:
int,float,boolvalues are always copied -
Strings are moved by default: Assigning a
strtransfers ownership -
odunckeyword borrows:val ref = odunc namecreates a borrow
The compiler tracks ownership in a Symbol table:
typedef struct {
char *name;
TypeKind type;
bool is_mutable;
bool is_moved; // Ownership transferred?
bool is_borrow; // Borrowed reference?
} Symbol;
During semantic analysis (sahiplik.c — 540 lines), I walk the AST and check:
void check_ownership_assign(Context *ctx, AstNode *node) {
Symbol *src = lookup(ctx, node->as.assign.name);
if (src->type == TYPE_STR && !src->is_borrow) {
if (src->is_moved) {
error(“Variable ‘%s’ already moved”, src->name);
return;
}
src->is_moved = true; // Mark as moved
}
}
Heap tracking for automatic free: Variables assigned from heap-returning functions are marked is_heap = true:
// In VAR_DECL codegen
if (init->type == NODE_CALL) {
const char *fn = init->as.call.callee->as.ident.name;
if (strcmp(fn, “to_string”) == 0 || strcmp(fn, “substring”) == 0) {
symbol->is_heap = true; // Will be freed at scope exit
}
}
At the end of each scope (or function), I emit cleanup code:
void emit_scope_free(Codegen *cg, Symbol *s) {
if (s->is_heap && !s->is_borrow && s->type == TYPE_STR) {
emit_indent(cg);
sb_appendf(&cg->out, “free((void*)%s);\n”, s->name);
}
}
Challenge encountered: Initially, I only tracked 9 builtin functions as heap-returning. While fixing memory leaks, I discovered 6 more functions (harf(), hex(), ikili(), etc.) that also malloc() but weren’t tracked. The fix required adding them to the is_heap whitelist:
if (name_is(cfn, “harf”, “char_str”) ||
name_is(cfn, “hex”, “hex”) ||
name_is(cfn, “ikili”, “binary”) ||
// … 6 more
) {
ns->is_heap = true;
}
Limitation: User-defined functions that return str aren’t automatically marked as heap because they might return string literals:
func grade(score: int) -> str:
if score >= 90:
return “A” # literal, not heap
return “F”
Interprocedural analysis would be needed to distinguish heap vs literal returns.
— -
## Lambda Closures: Capturing the Environment
### Problem: How to Implement Closures in C?
KORE supports lambda functions with closure:
val multiplier = 3
val triple = |x| -> x * multiplier # captures ‘multiplier’
print(triple(4)) # 12
C doesn’t have closures. Function pointers can’t carry environment data. How do you compile this to C?
### Solution: Capture Struct + Wrapper Function
I implemented a three-step transformation:
#### Step 1: Collect Captured Variables
During AST traversal, I identify which outer-scope variables the lambda body references:
typedef struct {
char *names[16];
int count;
} CaptureList;
void collect_captures(Codegen *cg, AstNode *body, CaptureList *caps) {
if (body->type == NODE_IDENT) {
Symbol *s = lookup_symbol(cg, body->as.ident.name);
// If variable is from outer scope (scope_depth < current)
if (s && s->scope_depth < cg->scope_depth) {
// Add to capture list (if not already present)
add_capture(caps, s->name);
}
}
// Recursively visit children…
}
#### Step 2: Generate Capture Struct
For the lambda |x| -> x * multiplier, I generate:
typedef struct {
int64_t multiplier; // captured variables
} _kore_cap_0;
#### Step 3: Generate Static Function + Wrapper
The actual lambda becomes a static function that takes the capture struct as an extra parameter:
// Lambda body as static function
static int64_t _kore_lambda_0(int64_t x, _kore_cap_0 *_cap) {
return (x * _cap->multiplier);
}
// Wrapper that bundles captures and calls the lambda
static int64_t _kore_lcall_0(int64_t x) {
_kore_cap_0 _cap_inst = { .multiplier = multiplier };
return _kore_lambda_0(x, &_cap_inst);
}
At the call site, the user invokes _kore_lcall_0(4), which creates the capture struct and calls the real lambda.
Type handling: I support capturing int64_t, const char*, and double. Each captured variable’s type is inferred:
TypeKind cap_type = lookup_symbol(cg, caps->names[i])->type;
if (cap_type == TYPE_INT) {
sb_append(&cg->cap_struct, “ int64_t “);
} else if (cap_type == TYPE_STR) {
sb_append(&cg->cap_struct, “ const char* “);
} // …
Challenge encountered: Nested lambdas. If a lambda captures another lambda, the capture struct needs to store a function pointer. I haven’t fully implemented this yet — current limitation is 1 level of closure nesting.
Limitation: Captured variables are copied by value into the struct. If the outer variable is mutated after lambda creation, the lambda sees the old value:
var count = 0
val adder = |x| -> x + count
count = 5
print(adder(10)) # Outputs 10, not 15
Fixing this requires capturing by reference (pointer), which complicates lifetime management.
— -
## String Interpolation with Nested Expressions
### Problem: Parsing {expr} Inside String Literals
KORE supports:
val name = “Alice”
val age = 30
print(“Name: {name}, Age: {age + 1}”) # “Name: Alice, Age: 31”
The challenge: the lexer has already tokenized ”Name: {name}, Age: {age + 1}” as a single TOK_STR_LIT token. How do you parse the expressions inside {}?
### Solution: Sub-Lexer and Desugaring at Parse Time
When the parser sees a string literal, it scans for unescaped {:
static bool has_interpolation(const char *str) {
for (int i = 0; str[i]; i++) {
if (str[i] == ‘{‘ && (i == 0 || str[i-1] != ‘\\’)) {
return true;
}
}
return false;
}
If interpolation is detected, I create a sub-scanner that re-tokenizes just the expression inside {}:
AstNode *parse_interpolation(Parser *p, const char *str) {
// Split: “prefix{expr}suffix” -> [“prefix”, expr, “suffix”]
// Find ‘{‘ position
int start = find_brace_start(str);
int end = find_matching_brace(str, start); // handles nested {}
// Extract parts
char *prefix = substring(str, 0, start);
char *expr_text = substring(str, start+1, end);
char *suffix = substring(str, end+1, strlen(str));
// Create sub-lexer for expression
Scanner sub_scanner = init_scanner(expr_text);
Parser sub_parser = init_parser(&sub_scanner);
AstNode *expr_node = parse_expr(&sub_parser);
// Desugar to: prefix + to_string(expr) + suffix
AstNode *result = make_concat(prefix, expr_node, suffix);
return result;
}
The result is a binary + chain:
“Name: “ + to_string(name) + “, Age: “ + to_string(age + 1)
Challenge encountered: Handling nested braces in the expression itself:
print(“Data: {dict.get(“key”)}”)
The naive approach breaks on the first } inside the string ”key”. I had to implement a brace matcher that tracks string literal state:
int find_matching_brace(const char *str, int start) {
int depth = 1;
bool in_string = false;
for (int i = start + 1; str[i]; i++) {
if (str[i] == ‘“‘ && str[i-1] != ‘\\’) {
in_string = !in_string;
}
if (!in_string) {
if (str[i] == ‘{‘) depth++;
if (str[i] == ‘}’) depth — ;
if (depth == 0) return i;
}
}
return -1; // Unmatched
}
— -
## Type Checking: Argument and Return Type Validation
### Problem: Catching Type Errors Before C Compilation
KORE is transpiled to C, so type errors could be caught by gcc. But C error messages reference generated C code lines, not KORE source lines. I needed KORE-level type checking.
### Solution: Two-Pass Type Analysis
Pass 1: Collect Function Signatures
During initial AST walk, I collect all function names, parameter types, and return types:
typedef struct {
char *name;
TypeKind ret_type;
TypeKind param_types[16];
int param_count;
} FuncInfo;
void collect_func_signatures(TypeChecker *tc, AstNode *program) {
for each function_def in program:
FuncInfo *fi = &tc->funcs[tc->func_count++];
fi->name = strdup(function_def->name);
fi->ret_type = resolve_type(function_def->ret_type);
fi->param_count = function_def->param_count;
for (int i = 0; i < fi->param_count; i++) {
fi->param_types[i] = resolve_type(function_def->params[i].type);
}
}
Builtin functions get TYPE_INFERRED for params to avoid false positives:
register_builtin(tc, “print”, TYPE_VOID, TYPE_INFERRED);
register_builtin(tc, “to_string”, TYPE_STR, TYPE_INFERRED);
Pass 2: Check Call Sites
At each function call, I compare argument types against the signature:
void check_call(TypeChecker *tc, AstNode *call) {
FuncInfo *fi = lookup_func_sig(tc, call->callee_name);
if (call->arg_count != fi->param_count) {
error(“Function ‘%s’ expects %d args, got %d”,
fi->name, fi->param_count, call->arg_count);
return;
}
for (int i = 0; i < call->arg_count; i++) {
TypeKind arg_type = infer_type(tc, call->args[i]);
TypeKind param_type = fi->param_types[i];
if (param_type == TYPE_INFERRED) continue; // Builtin, skip
if (arg_type != param_type) {
// Special case: int ↔ float mismatch is a warning, not error
if ((arg_type == TYPE_INT && param_type == TYPE_FLOAT) ||
(arg_type == TYPE_FLOAT && param_type == TYPE_INT)) {
warning(“Type mismatch at arg %d: %s expected, got %s”,
i, type_name(param_type), type_name(arg_type));
} else {
error(“Type mismatch at arg %d”, i);
}
}
}
}
Return type checking: At return statements, I compare the expression type against the function’s declared return type:
void check_return(TypeChecker *tc, AstNode *ret) {
TypeKind ret_type = infer_type(tc, ret->value);
TypeKind expected = tc->current_func_ret_type;
if (ret_type != expected) {
error(“Return type mismatch: expected %s, got %s”,
type_name(expected), type_name(ret_type));
}
}
Challenge encountered: Type inference for complex expressions like string concatenation:
val result = 42 + “ items” # int + str -> should be error or coercion?
My initial implementation allowed int + str and coerced to string (like JavaScript). But this hid bugs. I changed it to require explicit conversion:
val result = to_string(42) + “ items” # Explicit
— -
## Multi-Type Channels: Union-Based Message Passing
### Problem: Type-Safe Channels for Concurrent Programming
KORE supports Go-style channels:
val ch = kanal<int>()
gonder ch, 42
val x = al(ch)
Initially, channels only supported int64_t. Adding str and float support required changing the underlying struct.
### Solution: Tagged Union
I refactored KoreKanal to use a union:
typedef struct {
pthread_mutex_t _mtx;
pthread_cond_t _cond;
union {
int64_t _ival;
const char* _sval;
double _fval;
} _val;
bool _ready;
} KoreKanal;
The element type is tracked at compile time in the Symbol table:
Symbol *ch = lookup_symbol(cg, “ch”);
ch->elem_type = TYPE_STR; // Set during kanal<str>() declaration
Send/receive operations dispatch based on elem_type:
void gen_chan_send(Codegen *cg, AstNode *node) {
Symbol *ch = lookup_symbol(cg, node->chan_name);
if (ch->elem_type == TYPE_STR) {
sb_appendf(&cg->out, “_kore_chan_send_str(&%s, “, node->chan_name);
} else if (ch->elem_type == TYPE_FLOAT) {
sb_appendf(&cg->out, “_kore_chan_send_float(&%s, “, node->chan_name);
} else {
sb_appendf(&cg->out, “_kore_chan_send(&%s, “, node->chan_name);
}
// … emit value expression
}
Each type gets its own send/receive functions:
// int channel
static void _kore_chan_send(KoreKanal *ch, int64_t val) {
pthread_mutex_lock(&ch->_mtx);
ch->_val._ival = val;
ch->_ready = true;
pthread_cond_signal(&ch->_cond);
pthread_mutex_unlock(&ch->_mtx);
}
// str channel
static void _kore_chan_send_str(KoreKanal *ch, const char* val) {
pthread_mutex_lock(&ch->_mtx);
ch->_val._sval = val;
ch->_ready = true;
pthread_cond_signal(&ch->_cond);
pthread_mutex_unlock(&ch->_mtx);
}
// float channel
static void _kore_chan_send_float(KoreKanal *ch, double val) {
// … similar
}
Type inference fix: The infer_type() function needed a case for NODE_CHAN_RECV:
case NODE_CHAN_RECV: {
Symbol *ch = lookup_symbol(cg, node->as.chan_recv.chan_name);
return ch ? ch->elem_type : TYPE_INT; // Return channel’s element type
}
Without this, all al(ch) calls were inferred as int, causing compilation errors when receiving strings.
Limitation: Channels can’t send structs yet. The union would need a void* field, which complicates memory management (who frees the struct?).
— -
## Compiler Architecture
KORE uses a classic multi-pass architecture:
Source (.kore)
↓
Lexer (lexer.c, 569 lines) → Tokens
↓
Parser (parser.c, 1790 lines) → AST
↓
Module Resolver (modul.c, 660 lines) → Merged AST
↓
Type Checker (tipkontrol.c, 976 lines) → Annotated AST
↓
Security Analysis (guvenlik.c, 359 lines) → Taint tracking
↓
Ownership Checker (sahiplik.c, 540 lines) → Move/borrow validation
↓
Codegen (codegen.c, 3845 lines) → C source
↓
GCC/Clang → Executable
Each pass is independent — I can modify type checking without touching codegen. The AST is immutable after parsing; later passes only read and annotate.
Total compiler size: 10,363 lines of C (excluding kore-pm).
— -
## Testing Strategy
I use a bash-based test runner (run_tests.sh) that compiles and executes 46 test programs:
-
41 success tests: Must compile and run without error
-
5 negative tests: Must fail compilation with specific error messages
Each test is a .kore file:
./build/korec examples/closure_test.kore -o build/closure_test.c
gcc -o build/closure_test build/closure_test.c
./build/closure_test > /dev/null 2>&1
if [ $? -eq 0 ]; then echo “PASS”; else echo “FAIL”; fi
Regression testing: After every feature addition, I run all 46 tests. If any fail, I revert and debug.
Example negative test (tests/hatali/sahiplik_hata_test.kore):
func ana():
val x = “data”
val y = x # x moves to y
yazdir(x) # ERROR: x already moved
Expected output: HATA: Degisken ‘x’ sahipligi devredildi. The test runner checks the error message matches.
— -
## Current Limitations and Future Work
### What’s Missing
-
Formal grammar specification: No BNF/EBNF document exists. The grammar is implicit in
parser.c. -
Interprocedural analysis: Type inference and ownership tracking only work within a single function. Cross-function analysis would enable:
-
Detecting if a function returns heap memory
-
Tracking ownership across function calls
-
Generic functions: You can define
struct Kutu<T>but notfunc identity<T>(x: T) -> T. -
Standard library: Only 4 small modules (~75 lines total). No regex, datetime, advanced math.
-
REPL: No interactive mode yet.
### What’s Next
-
Self-hosting: I started a bootstrap lexer in KORE itself (
examples/bootstrap_lexer.kore). Goal: compile the KORE compiler with KORE. -
LLVM backend: Currently transpiles to C. A native LLVM IR backend would enable better optimizations and eliminate the GCC dependency.
-
Package manager:
kore-pmexists (304 lines) but only handles project initialization. Need dependency resolution and remote fetching.
— -
## Conclusion
Building a compiler from scratch in C taught me more about language design than any course or book. The challenges I faced — bilingual lexing, ownership tracking, closure capture, type inference — forced me to deeply understand how compilers work.
Key takeaways:
-
Start simple: My first version had no types, no ownership, no closures. I added features incrementally.
-
Test continuously: 46 regression tests caught bugs early.
-
Transpilation is underrated: Compiling to C gave me a huge ecosystem (GCC, debuggers, profilers) for free.
-
Bilingual design is hard but valuable: Supporting two languages doubled the complexity but made the project unique.
The complete source code (10,667 lines) is on GitHub: kore-lang
Try it yourself:
git clone [https://github.com/dbeyzade/kore-lang.git](https://github.com/dbeyzade/kore-lang.git)
cd kore-lang
make
./build/korec examples/closure_test.kore -o closure.c
gcc -o closure closure.c
./closure
I’m happy to answer questions about the implementation details or design decisions in the comments.
— -
This post was written as a technical deep-dive into compiler construction. If you’re interested in building your own language, I highly recommend starting with a simple transpiler to C — it’s much easier than generating machine code directly, and you’ll learn the same core concepts.
메타데이터
- post_id
- ef3d5002de3f
- slug
- the-current-title-is-101-characters-one-over-the-limit-ill-shorten-it-by-removing-implementing-ef3d5002de3f
- url
- https://medium.com/@dbeyzadem/the-current-title-is-101-characters-one-over-the-limit-ill-shorten-it-by-removing-implementing-ef3d5002de3f
- canonical_url
- https://medium.com/@dbeyzadem/the-current-title-is-101-characters-one-over-the-limit-ill-shorten-it-by-removing-implementing-ef3d5002de3f
- author_url
- https://medium.com/@dbeyzadem
- status
- ok
- fetched_at
- 2026-06-28 04:42:08