
Learn how to extend Kindler with custom modules and replicate scripting tasks from CMake and Meson.
Kindler project files are purely declarative with no scripting. For tasks that require logic or code generation, use modules.
What modules can do:
What modules cannot do:
Modules are searched in this order:
A module is a Lua file that returns a table with specific fields.
-- modules/hello.lua
return {
name = "hello",
version = "1.0.0",
description = "Example module that prints hello",
hooks = {"post-parse"},
["post-parse"] = function(context)
print("Hello from module!")
print("Building project: " .. context.project.project.name)
end,
}
In your .kindler file:
project {
name = "myapp";
lang = "c99";
}
build {
sources = ["main.c"];
}
modules {
load = ["hello"];
}
Run kindler.lua generate and you'll see:
=== Loading Modules === Loading module: hello (./modules/hello.lua) === Executing post-parse hooks === Executing post-parse hook: hello Hello from module! Building project: myapp
Modules execute at specific points during build generation. Choose the appropriate hook for your task.
| Hook | When It Runs | Common Uses |
|---|---|---|
| pre-parse | Before parsing .kindler file | Modify project file path, preprocess project file |
| post-parse | After parsing, before validation | Generate config.h, validate custom fields, modify project table |
| pre-generate | Before generating Makefiles | Generate source files, run code generators, create version.h |
| post-generate | After Makefiles are written | Create additional files, run post-processing, generate documentation |
Use post-parse when:
Use pre-generate when:
Use post-generate when:
Every hook function receives a context table with project information:
context = {
project = <parsed project table>,
cache = <bootstrap cache>,
compiler = <selected compiler info>,
compiler_name = <string: "gcc", "clang", etc.>,
kindler_dir = <path to kindler installation>,
project_dir = <directory containing .kindler file>,
}
["post-parse"] = function(context)
local proj = context.project
-- Access project fields
print("Name: " .. proj.project.name)
print("Language: " .. proj.project.lang)
-- Access build info
print("Sources: " .. table.concat(proj.build.sources, ", "))
-- Access OS info
print("OS: " .. context.cache.os.name)
print("Arch: " .. context.cache.os.arch)
-- Access compiler
print("Compiler: " .. context.compiler_name)
print("Version: " .. context.compiler.version)
end
You can modify the project table in post-parse:
["post-parse"] = function(context)
-- Add a define based on OS
if context.cache.os.name == "irix65" then
table.insert(context.project.build.defines, "PLATFORM_IRIX")
end
-- Add conditional sources
if context.cache.os.name == "linux" then
table.insert(context.project.build.sources, "linux_specific.c")
end
end
Generates config.h with platform and capability detection.
Usage:
config-header {
output = "config.h";
platform = "auto";
check-headers = ["unistd.h", "sys/stat.h"];
check-functions = ["strlcpy", "getopt_long"];
check-types = ["uint64_t", "socklen_t"];
defines = ["VERSION=\"1.0.0\""];
}
modules {
load = ["config_header"];
}
Generated config.h:
/* Generated by Kindler - DO NOT EDIT */ #ifndef CONFIG_H #define CONFIG_H /* Platform */ #define OS_LINUX 1 #define ARCH_X86_64 1 /* Headers */ #define HAVE_UNISTD_H 1 #define HAVE_SYS_STAT_H 1 /* Functions */ /* #undef HAVE_STRLCPY */ #define HAVE_GETOPT_LONG 1 /* Types */ #define HAVE_UINT64_T 1 #define HAVE_SOCKLEN_T 1 /* Project defines */ #define VERSION "1.0.0" #endif /* CONFIG_H */
-- modules/version_gen.lua
return {
name = "version_gen",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
-- Get version from git
local handle = io.popen("git describe --tags --always 2>/dev/null")
if not handle then return end
local version = handle:read("*a"):gsub("\n", "")
handle:close()
if version == "" then
version = "unknown"
end
-- Generate version.h
local f = io.open("version.h", "w")
f:write('#define GIT_VERSION "' .. version .. '"\n')
f:close()
print("Generated version.h: " .. version)
end,
}
-- modules/protobuf.lua
return {
name = "protobuf",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
-- Find .proto files in project directory
local handle = io.popen("ls " .. context.project_dir .. "/*.proto 2>/dev/null")
if not handle then return end
for proto_file in handle:lines() do
print("Compiling: " .. proto_file)
local cmd = "protoc --c_out=. " .. proto_file
local result = os.execute(cmd)
if result ~= 0 then
error("protoc failed for: " .. proto_file)
end
end
handle:close()
end,
}
-- modules/embed_resources.lua
return {
name = "embed_resources",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
-- Convert resource files to C arrays
local resources = {"logo.png", "config.json"}
local f = io.open("resources.h", "w")
f:write("/* Generated by embed_resources module */\n")
f:write("#ifndef RESOURCES_H\n#define RESOURCES_H\n\n")
for _, res in ipairs(resources) do
local res_file = io.open(res, "rb")
if res_file then
local data = res_file:read("*a")
res_file:close()
local var_name = res:gsub("[^%w]", "_")
f:write("static const unsigned char " .. var_name .. "[] = {")
for i = 1, #data do
if i % 12 == 1 then f:write("\n ") end
f:write(string.format("0x%02x,", string.byte(data, i)))
end
f:write("\n};\n")
f:write("static const size_t " .. var_name .. "_size = " .. #data .. ";\n\n")
end
end
f:write("#endif /* RESOURCES_H */\n")
f:close()
print("Generated resources.h")
end,
}
-- modules/validate_naming.lua
return {
name = "validate_naming",
hooks = {"post-parse"},
["post-parse"] = function(context)
-- Enforce naming conventions
local proj_name = context.project.project.name
-- Project names must be lowercase
if proj_name ~= proj_name:lower() then
error("Project name must be lowercase: " .. proj_name)
end
-- Check source files follow naming convention
for _, src in ipairs(context.project.build.sources) do
if not src:match("^[a-z_]+%.c$") then
print("Warning: " .. src .. " doesn't follow snake_case.c convention")
end
end
end,
}
Here's how to accomplish common scripting tasks from CMake and Meson using Kindler modules.
CMake:
# config.h.in #define VERSION "@PROJECT_VERSION@" #define INSTALL_PREFIX "@CMAKE_INSTALL_PREFIX@" # CMakeLists.txt configure_file(config.h.in config.h)
Kindler module:
-- modules/configure.lua
return {
name = "configure",
hooks = {"post-parse"},
["post-parse"] = function(context)
local template = io.open("config.h.in", "r"):read("*a")
-- Replace variables
template = template:gsub("@PROJECT_VERSION@", context.project.project.version or "unknown")
template = template:gsub("@INSTALL_PREFIX@", context.project.install.prefix or "/usr/local")
local f = io.open("config.h", "w")
f:write(template)
f:close()
end,
}
CMake:
add_custom_command( OUTPUT protocol.c COMMAND protoc --c_out=. protocol.proto DEPENDS protocol.proto )
Kindler module:
-- modules/protoc.lua
return {
name = "protoc",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
local proto_files = {"protocol.proto"}
for _, proto in ipairs(proto_files) do
local cmd = "protoc --c_out=. " .. proto
print("Running: " .. cmd)
local result = os.execute(cmd)
if result ~= 0 then
error("protoc failed")
end
end
end,
}
Meson:
version_h = custom_target('version.h',
output: 'version.h',
command: ['git', 'describe', '--tags'],
capture: true
)
Kindler module:
-- modules/git_version.lua
return {
name = "git_version",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
local handle = io.popen("git describe --tags 2>/dev/null")
local version = handle:read("*a"):gsub("\n", "")
handle:close()
local f = io.open("version.h", "w")
f:write('#define VERSION "' .. version .. '"\n')
f:close()
end,
}
CMake:
file(GLOB SOURCES "src/*.c")
add_executable(myapp ${SOURCES})
Kindler module:
-- modules/glob_sources.lua
return {
name = "glob_sources",
hooks = {"post-parse"},
["post-parse"] = function(context)
-- Find all .c files in src/
local handle = io.popen("ls src/*.c 2>/dev/null")
if not handle then return end
context.project.build.sources = {}
for file in handle:lines() do
table.insert(context.project.build.sources, file)
end
handle:close()
end,
}
Note: Globbing is discouraged in Kindler (see philosophy), but modules allow it if needed.
Meson:
# meson.build if host_machine.system() == 'linux' sources += ['linux.c'] elif host_machine.system() == 'windows' sources += ['windows.c'] endif
Kindler module:
-- modules/platform_sources.lua
return {
name = "platform_sources",
hooks = {"post-parse"},
["post-parse"] = function(context)
local os_name = context.cache.os.name
if os_name == "linux" then
table.insert(context.project.build.sources, "linux.c")
elseif os_name == "netbsd" or os_name == "freebsd" then
table.insert(context.project.build.sources, "bsd.c")
elseif os_name == "irix65" then
table.insert(context.project.build.sources, "irix.c")
end
end,
}
CMake:
check_c_source_compiles("
#include <stdio.h>
int main() { __builtin_expect(1, 1); return 0; }
" HAVE_BUILTIN_EXPECT)
Kindler module:
-- modules/check_builtin.lua
return {
name = "check_builtin",
hooks = {"post-parse"},
["post-parse"] = function(context)
local test_code = [[
#include <stdio.h>
int main() { __builtin_expect(1, 1); return 0; }
]]
local f = io.open("/tmp/test_builtin.c", "w")
f:write(test_code)
f:close()
local cmd = context.compiler.path .. " /tmp/test_builtin.c -o /tmp/test_builtin 2>/dev/null"
local result = os.execute(cmd)
os.remove("/tmp/test_builtin.c")
os.remove("/tmp/test_builtin")
if result == 0 then
table.insert(context.project.build.defines, "HAVE_BUILTIN_EXPECT")
end
end,
}
Use pcall() for operations that might fail:
["pre-generate"] = function(context)
local ok, result = pcall(function()
-- Risky operation
return io.open("somefile.txt", "r")
end)
if not ok then
print("Warning: couldn't open file")
return
end
-- Use result
end
Avoid expensive operations if possible:
-- Bad: Runs every time
["post-parse"] = function(context)
os.execute("find . -name '*.c'") -- Expensive!
end
-- Good: Cache results
local cached_files = nil
["post-parse"] = function(context)
if not cached_files then
cached_files = {}
-- Populate cache once
end
-- Use cached_files
end
Check OS before using OS-specific commands:
["pre-generate"] = function(context)
local os_name = context.cache.os.name
if os_name == "linux" then
os.execute("linux-specific-command")
elseif os_name == "irix65" then
os.execute("irix-specific-command")
else
print("Unsupported OS: " .. os_name)
end
end
Include clear documentation in your module:
-- modules/mymodule.lua
--
-- Description: Generates version.h from git tags
-- Usage: Add to modules.load: ["mymodule"]
-- Requirements: git must be in PATH
-- Output: version.h in project directory
--
return {
name = "mymodule",
version = "1.0.0",
description = "Generates version.h from git tags",
author = "Your Name",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
-- Implementation
end,
}
Use Kindler's diagnostic system in modules:
local diag = require("lib.diagnostics")
return {
name = "mymodule",
hooks = {"pre-generate"},
["pre-generate"] = function(context)
local f = io.open("important.txt", "r")
if not f then
diag.warn("W401", "important.txt not found")
return
end
-- Process file
f:close()
end,
}
Commit modules to your project repository:
myproject/
|-- myproject.kindler
|-- modules/
|-- custom_check.lua
|-- version_gen.lua
|-- src/
|-- main.c
Install to user directory for reuse across projects:
mkdir -p ~/.config/kindler/modules cp mymodule.lua ~/.config/kindler/modules/
Publish modules as standalone files:
Error: E201: Module not found
Check:
Error: E202: Module failed to load
Check:
Error: E205: Module hook function failed
Check:
Copyright 2026 Setsuna Software L.C. and Kazuo Kuroi