Back to Kindler Main Page

Kindler Module System Guide

Kindler Logo

Learn how to extend Kindler with custom modules and replicate scripting tasks from CMake and Meson.


Table of Contents


Module System Overview

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:

Module Locations

Modules are searched in this order:

  1. ./modules/ - Project-local modules (committed with your project)
  2. ~/.config/kindler/modules/ - User-installed modules
  3. /path/to/kindler/modules/ - Built-in modules shipped with Kindler

Writing Your First Module

A module is a Lua file that returns a table with specific fields.

Minimal Module Template

-- 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,
}

Using the Module

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

Hook Points

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

Choosing the Right Hook

Use post-parse when:

Use pre-generate when:

Use post-generate when:


Context Object

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>,
}

Accessing Project Information

["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

Modifying Project Configuration

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

Built-in Modules

config_header

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 */

Module Examples

Example 1: Version from Git

-- 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,
}

Example 2: Protocol Buffer Compiler

-- 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,
}

Example 3: Resource Embedding

-- 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,
}

Example 4: Custom Validation

-- 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,
}

Replacing CMake/Meson Scripts

Here's how to accomplish common scripting tasks from CMake and Meson using Kindler modules.

CMake: configure_file()

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()

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: run_command()

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 ...)

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: subdir() and Custom Logic

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: Feature Detection with Custom Tests

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,
}

Best Practices

Error Handling

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

Performance

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

Cross-Platform Compatibility

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

Documenting Modules

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,
}

Diagnostic Integration

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,
}

Module Distribution

Project-Local Modules

Commit modules to your project repository:

myproject/
  |-- myproject.kindler
  |-- modules/
      |-- custom_check.lua
      |-- version_gen.lua
  |-- src/
      |-- main.c

User Modules

Install to user directory for reuse across projects:

mkdir -p ~/.config/kindler/modules
cp mymodule.lua ~/.config/kindler/modules/

Sharing Modules

Publish modules as standalone files:


Troubleshooting

Module Not Found

Error: E201: Module not found

Check:

Module Syntax Error

Error: E202: Module failed to load

Check:

Hook Function Failed

Error: E205: Module hook function failed

Check:


Further Reading


Copyright 2026 Setsuna Software L.C. and Kazuo Kuroi

Back to Kindler Main Page | Back to Setsuna Software