Imports, Modules, and a Lesson We Had to Learn the Hard Way
Working on a programming language is a strange experience. Some days everything feels obvious: you add a feature, tests pass, and the language grows a little stronger. Other days, you spend hours staring at code that technically works, yet somehow feels wrong - and you can’t immediately explain why.
The recent work on modules and imports in Luma was very much the second kind.
At the beginning, the goal looked innocent enough. We wanted Luma to support multiple files, to allow code to be split up, reused, and composed in a natural way. Nothing ambitious. Every language does this. Surely this would be a straightforward step forward.
It wasn’t.
An Import That Is Just a Value
One design decision shaped everything that followed, even before we fully realized it. In Luma, an import is not a compiler directive. It is not a magical statement that mutates some global table behind the scenes. Instead, an import is an expression that produces a value.
That’s why Luma uses this syntax:
math = import "math_utils"
print(math.add(1, 2))Here, math is just a variable. It happens to contain exported symbols from another file, but conceptually it’s no different from assigning a struct, a number, or a function. If you understand variables and dot-access, you already understand imports.
At least, that was the theory.
When “It Works” Isn’t Enough
The first implementation worked. Then we extended it. Then we extended it again.
We added public exports with pub.
We added function calls through namespaces.
We added inline imports, like (import "math_utils").add(1, 2).
And gradually, something subtle started to happen.
Tests that had passed for a long time began to fail - not the new tests, but the old ones. Features that had nothing to do with modules suddenly behaved differently. List helpers broke. Method calls changed shape. Things that used to be obvious no longer were.
The natural reaction was to patch. Add a condition. Add a guard. Handle “just one more case”. And for a while, that seemed reasonable. After all, the compiler still compiled. The language still ran.
But the code was getting heavier, not clearer.
At some point, we realized that we weren’t implementing a design anymore. We were reacting.
The Moment We Stopped Guessing
There was a turning point, and it didn’t come from writing more code. It came from stopping.
Instead of asking “how do we fix this test?”, we asked more uncomfortable questions:
What is a function call in Luma?
What exactly is the difference between a free function and a method call?
What does it really mean to call something through a module namespace?
Once we slowed down and answered those questions honestly, a pattern emerged. A lot of the complexity wasn’t required by the language at all - it was accidental. It came from guessing, from assuming intent instead of deriving it from the AST and the language rules.
That realization was painful, because it meant admitting that some of the recent work had to be undone. But it was also relieving. When the model became clear again, the code followed naturally.
Conditions disappeared.
Special cases collapsed into one path.
The compiler started to read like a story again.
And then something quietly satisfying happened: all tests passed. Not just the new ones. All of them.
Where We Finally Landed
Today, modules in Luma behave exactly the way the language reads.
A file defines symbols.
pub decides which ones are visible.
import evaluates to a namespace value containing those symbols.
You can export a variable:
pub pi: float = 3.14159You can export a function:
pub fn add(a: int, b: int) -> int {
return a + b
}And you can use them naturally from another file:
m = import "math_utils"
print(m.pi)
print(m.add(1, 2))No hidden rules. No special compiler modes. No “you just have to know this one exception”.
If you can read the code, you can understand what it does.
The Real Takeaway
This episode reminded us of something that’s easy to forget when building systems from scratch: complexity rarely comes from ambition. More often, it comes from losing clarity.
When the mental model is clean, the implementation stays clean.
When the model blurs, the code starts to fight back.
The struggle around modules wasn’t wasted effort. It was the cost of rediscovering the boundaries of the language - of learning where Luma wants to be simple, and where it refuses to be clever.
And now that those boundaries are clear again, the language feels lighter.
More importantly, it feels trustworthy.
That matters more than any single feature.
Here’s a simple “real world” example that feels like something you’d actually do: centralized config + a tiny helper function, exported from one file and used in main.luma.
config.luma
pub app_name: str = "Luma Notes"
pub version: str = "0.1.0"
pub fn banner() -> str {
return app_name + " v" + version
}main.luma
cfg = import "config"
print(cfg.banner())
print("Starting " + cfg.app_name)That’s it: one exported variable (app_name / version) and one exported function (banner()), used via the imported namespace (cfg.*).