A language without a module system is a language that only works for small programs. The moment a project grows beyond a few hundred lines, developers need to split code across files, share functionality between modules, and manage dependencies. Without a module system, every function and every entity lives in one giant file, and collaboration becomes impossible.
FLIN's module system was built across five sessions (103 through 107, with runtime integration in Session 129), evolving from syntax definitions to path resolution, type binding, and finally runtime execution. The result is a system that feels familiar to JavaScript and Python developers while avoiding the complexity traps that plague those ecosystems.
Import Syntax
FLIN supports four forms of imports, mirroring ES6 module syntax:
// Simple module import (brings all exports into scope)
import "utils/helpers"// Named imports (selective) import { format, validate } from "utils/helpers"
// Named imports with aliases import { format as fmt, validate as check } from "utils"
// Namespace import (all exports under a single name) import * as helpers from "utils/helpers" ```
And three forms of exports:
// Direct exports (using pub keyword)
pub fn formatDate(d: time) -> text {
// ...
}pub PI = 3.14159
// Named re-exports export { User, Product } from "models"
// Wildcard re-exports export * from "types" ```
The pub keyword on declarations makes them available to other modules. Functions, variables, enums, types, and structs can be public. Entities are always exported -- they represent data models that are inherently shared across the application.
Soft Keywords
The module keywords (import, from, as, export) are implemented as "soft keywords." They act as keywords in module contexts but can be used as identifiers elsewhere:
// This is valid FLIN
from = 1
import = "hello"
as = trueThis backward compatibility decision means that adding the module system does not break any existing code. A file that used from as a variable name continues to work. The parser disambiguates by context: import at the start of a statement is a keyword; import in an expression is an identifier.
The implementation adds four keyword entries to the lexer:
// In token.rs
"import" => TokenKind::Import,
"from" => TokenKind::From,
"as" => TokenKind::As,
"export" => TokenKind::Export,And marks them in the soft keyword list so that the parser can treat them as identifiers when they appear outside module syntax.
Path Resolution
When the compiler encounters import { format } from "./utils", it needs to find the file that contains format. FLIN's path resolution follows three rules:
1. Relative paths (./, ../) resolve from the current file's directory.
2. Absolute paths (/) are used as-is.
3. Package paths (no prefix) resolve from the project root.
// From app/index.flin:
import { format } from "./utils" // -> app/utils.flin
import { helpers } from "../lib/utils" // -> lib/utils.flin
import { User } from "models/user" // -> <root>/models/user.flinThe resolver automatically appends .flin to the path if not present. It canonicalizes paths to prevent the same module from being loaded twice under different names (e.g., ./utils and ./sub/../utils resolve to the same file).
The resolution system lives in src/resolver/, a dedicated module with four files:
// src/resolver/mod.rs
pub struct ModuleResolver {
root_dir: PathBuf,
search_paths: Vec<PathBuf>,
}impl ModuleResolver {
pub fn resolve(
&self,
import_path: &str,
current_file: &Path,
) -> Result
Module Caching
Loading the same module twice wastes time and risks inconsistency. FLIN's module cache ensures each file is parsed exactly once:
// src/resolver/cache.rs
pub struct ModuleCache {
modules: HashMap<PathBuf, CachedModule>,
}pub struct CachedModule { pub program: Program, pub exports: ModuleScope, pub load_time: Instant, } ```
When a module is requested, the cache is checked first. If the module has been loaded, its parsed AST and export list are returned immediately. If not, the file is read, parsed, and cached for future use. This makes importing the same utility from ten different files no more expensive than importing it from one.
Circular Dependency Detection
Circular dependencies -- where module A imports module B which imports module A -- are detected during loading. The resolver maintains a stack of modules currently being loaded. If a module appears twice in the stack, a circular dependency error is raised with a clear trace:
Circular dependency detected
Module '/project/a.flin' creates a cycle:
-> /project/main.flin
-> /project/b.flin
-> /project/a.flin (cycle)This is a hard error, not a warning. Circular dependencies in a language with a single-pass compiler lead to undefined behavior -- the imported names may or may not exist depending on load order. Rather than allowing subtle bugs, FLIN rejects circular dependencies outright.
Export Tracking
The ModuleScope class tracks what each module exports:
// src/resolver/scope.rs
pub struct ModuleScope {
pub exports: HashMap<String, ExportKind>,
}pub enum ExportKind { Function(String), Entity(String), Variable(String), Type(String), Enum(String), Struct(String), } ```
Export extraction follows simple rules: anything marked pub is exported, entities are always exported, and private items are never exported. The scope is computed once during loading and cached alongside the parsed program.
The Module Inlining Strategy
This is the critical insight of Session 129: FLIN does not need complex bytecode for cross-module references. Instead, it inlines imported code directly into the main program before compilation.
Import Statement -> Resolve Path -> Load Module -> Find Exports -> Clone & Inline -> Compile as One ProgramThe inline_imports function processes each import statement, finds the corresponding exported declaration in the loaded module, clones it, optionally renames it (for aliased imports), and prepends it to the main program's statement list:
fn inline_imports(
program: &Program,
current_file: &Path,
loader: &ModuleLoader,
) -> Result<Program, String> {
let mut inlined_statements = Vec::new();for stmt in &program.statements { if let Stmt::Import { kind, .. } = stmt { match kind { ImportKind::Named { items, path } => { let module = loader.get_module(path)?; for item in items { let export = module.find_export(&item.name)?; let mut cloned = export.clone(); if let Some(alias) = &item.alias { cloned.rename(alias); } inlined_statements.push(cloned); } } // Handle other import kinds... } } }
let mut combined = inlined_statements; combined.extend(program.statements.iter() .filter(|s| !s.is_import()) .cloned()); Ok(Program { statements: combined }) } ```
This approach is elegantly simple. After inlining, the compiler sees a single program with no import statements. The type checker validates all names. The code generator emits bytecode for the combined program. No module boundaries exist at runtime.
The trade-off is that each imported declaration is duplicated if multiple files import the same module. For FLIN's use case -- single-file applications with a handful of shared utilities -- this duplication is negligible. For a large project with hundreds of shared functions, a more sophisticated approach (shared constant pools, module-level bytecode) would be needed. That optimization is planned for a future version.
Type Binding for Imports
Before the inlining step, the type checker needs to know about imported names. The ImportBinder connects the module loader to the type environment:
// src/typechecker/import_binding.rs
pub fn bind_imports(
program: &Program,
loader: &ModuleLoader,
env: &mut TypeEnv,
) -> Result<(), TypeError> {
for stmt in &program.statements {
if let Stmt::Import { kind, .. } = stmt {
match kind {
ImportKind::Named { items, path } => {
let module = loader.get_module(path)?;
for item in items {
let ty = module.export_type(&item.name)?;
let local = item.alias.as_deref().unwrap_or(&item.name);
env.define(local, ty);
}
}
ImportKind::Namespace { alias, path } => {
let module = loader.get_module(path)?;
let ns_type = FlinType::Namespace {
name: alias.clone(),
members: module.all_export_types(),
};
env.define(alias, ns_type);
}
// ...
}
}
}
Ok(())
}Namespace imports create a FlinType::Namespace value that supports member access. When the type checker encounters helpers.format(x), it looks up helpers as a namespace, finds format in its members, and type-checks the call.
Dynamic Imports
Session 141 added dynamic import expressions, allowing modules to be loaded as values:
utils = import("./utils.flin") // Returns a map of exports
pi_value = utils["PI"] // Access exports via mapDynamic imports compile to a map construction at compile time. The transform_dynamic_imports function replaces each import("path") expression with a map literal containing all of the module's exports. This preserves the compile-time resolution model while giving developers a way to treat modules as first-class values.
The Complete Pipeline
The full compilation pipeline with module support:
Source Code (.flin)
|
v
Lexer -> Tokens
|
v
Parser -> AST (with Import/Export statements)
|
v
ModuleLoader -> Load referenced modules, cache, detect cycles
|
v
ImportBinder -> Add imported names to TypeEnv
|
v
TypeChecker -> Validate types (with imported names in scope)
|
v
inline_imports -> Replace import statements with inlined code
|
v
CodeGenerator -> Emit bytecode (single combined program)
|
v
VM -> ExecuteThis pipeline was tested with 30+ module-specific tests covering relative imports, parent directory imports, package imports, aliased imports, namespace imports, re-exports, circular dependency detection, and missing export errors.
The module system transforms FLIN from a language for single-file applications into a language for real projects. Code organization, reuse, and collaboration become possible. And because the module system resolves everything at compile time through inlining, there is zero runtime overhead for using imports.
---
This is Part 178 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [177] The FLIN VSCode Extension - [178] The Module System and Imports (you are here) - [179] Template Literals and String Formatting