A simple, predictable build tool that fixes the most common annoyances of GNU Make.
make-lite is a macro and command automation tool born from the practical need to solve the most common and frustrating aspects of traditional Make. It is designed to be a powerful command runner that prioritizes simplicity, predictability, and a great developer experience over supporting every esoteric feature.
If you love the core dependency-graph concept of Make but are tired of .PHONY, tab errors, and confusing variable expansion rules, make-lite is for you.
-
Intuitive Dependency Rules (The Core Fix)
This is the most important feature. A rule runs if any of its target files are missing, or if any source file is newer than the oldest target file. This elegant logic solves multiple GNU Make frustrations at once:- Multi-target rules just work. The freshness check is performed against all targets in the rule, not just the first. A rule like
file_pb.go file_grpc.pb.go: file.protowill correctly re-run if either generated file is deleted or iffile.protois updated. - Code generators are handled perfectly.
protoccreating two files from one source is no longer a problem.
- Multi-target rules just work. The freshness check is performed against all targets in the rule, not just the first. A rule like
-
Flexible Recipe Indentation
While GNU Make's principle that recipes must be indented is sound, its implementation is famously brittle.make-litefixes this:- Any indentation (spaces or tabs) is valid. This completely eliminates the strict 'tab-only' requirement and the infamous 'missing separator' errors it causes, embracing the philosophy that indentation is for human readability, and humans should be free to choose their style.
-
Implicit & Non-Infectious Phony Targets
Any target that doesn't correspond to a file on disk is automatically treated as "phony" (it will always run). This has two major benefits:- It completely eliminates the need for
.PHONYboilerplate. - It fixes the confusing GNU Make behavior where a phony target can cause its file-based dependencies to be rebuilt unnecessarily.
- It completely eliminates the need for
-
Automatic Directory Creation
If a rule's target is in a directory that doesn't exist (e.g.,bin/my_app),make-litewill create the parent directory (bin/) automatically before running the recipe. No moremkdir -pboilerplate. -
Practical
.envParsing
When usingload_env .env,make-liteuses a practical parsing approach that automatically strips surrounding quotes ("or') from values, which is the behavior users almost always want.
make-lite is designed to be simple and predictable. It achieves this by adhering to a few core principles that differ from GNU Make in key ways. Understanding these principles is essential for using the tool effectively.
make-lite uses a strict two-pass parser to ensure predictable behavior.
- Pass 1: Variable & Rule Collection:
make-litefirst reads allincluded Makefiles from top to bottom and populates its variable store. If a variable is defined multiple times, the last definition wins. This process is completed before any rules are evaluated. - Pass 2: Rule Expansion: After the variable store is complete,
make-liteexpands the variables within the targets and dependencies of each rule.
This means the order of variable definitions matters, but the location of a rule relative to its variable definitions does not.
Example:
# File: Makefile.mk-lite
VAR = first
include extra.mk
all:
@echo "The value is: $(VAR)"# File: extra.mk
VAR = lastRunning make-lite will output The value is: last, because the definition in extra.mk was the last one processed during the first pass.
make-lite uses eager expansion for all standard variable assignments (=). The right-hand side of an assignment is evaluated once, at the moment it is defined during the first parsing pass. The resulting literal string is then stored.
This is equivalent to GNU Make's := operator and is a core part of make-lite's "simple and predictable" philosophy. It means a variable's value is fixed before any recipes are run and will not change during execution.
Example:
# The `shell date` command is run only once, during parsing.
TIMESTAMP = $(shell date)
all:
@sleep 2
@echo "Timestamp from parse time: $(TIMESTAMP)"
@echo "Timestamp from execution time: $(shell date)"The output will show two different timestamps, demonstrating that $(TIMESTAMP) stored the value from when the file was first parsed, not when the recipe was executed.
To make debugging easy and prevent silent "action at a distance" errors, make-lite provides clear feedback:
-
Variable Redefinition Warnings: If you define a variable that has already been defined in another makefile context,
make-litewill print a clear warning tostderr, showing you the exact file and line number of both the new and previous definitions. This helps you track down unintended overrides immediately.make-lite: Warning: variable 'VAR' redefined at extra.mk:2. Previous definition at Makefile.mk-lite:1. The last definition will be used. -
Precise Error Locations: All parsing errors point to the exact
file:linewhere the error occurred, so you never have to guess.
This combination of a predictable parsing model and clear, precise feedback makes make-lite robust and easy to maintain.
- Rules: A non-indented line with a colon (
:) defines a rule (e.g.,target: dep1 dep2). - Recipes: A line is part of a rule's recipe if and only if it is indented. The recipe consists of the contiguous block of indented lines immediately following a rule. It is terminated by the first non-indented line or the end of the file.
- Comments: A line is a comment if it starts with an unescaped
#.
- Assignments:
VAR = value: Unconditional assignment.VAR ?= value: Conditional assignment (only sets ifVARis not already defined).
- Expansion Model: Eager by Default:
make-litehas a single, simple expansion model: all variable assignments are expanded eagerly at the time they are parsed. The right-hand side is fully resolved (including any$(shell ...)calls), and the resulting literal string is stored. This is equivalent to GNU Make's:=operator and ensures a variable's value is fixed and predictable throughout the build. - Precedence (Highest to Lowest):
- Makefile Unconditional (
=): Has the final say. - Environment Variables: Includes variables from
exportor command-line prefixes (e.g.,VAR=val make-lite). - Makefile Conditional (
?=): Use this to provide a default that can be overridden by the environment.
- Makefile Unconditional (
- Expansion Syntax:
$(...): The primary expansion form.$VAR: A shell-style convenience form for simple variables.
- Escaping Special Characters:
- Backslash (
\): Use a backslash to escape the next character frommake-lite's parser. This is for passing literal characters like$,#,(,),:,=, or\itself to the value of a variable or a recipe. Example:GREETING = echo Hello \#worldsets the variable's value toecho Hello #world. - Double Dollar (
$$): Use a double dollar sign to pass a single literal$to the shell. This is the primary mechanism for using shell variables ($$PATH) or shell command substitution (LATEST_COMMIT=$$(git rev-parse HEAD)) inside a recipe.
- Backslash (
- Expansion Precedence within
$(...):$(shell command): Explicitly runscommandin a sub-shell and substitutes its output.$(VAR): IfVARis a definedmake-litevariable, it is expanded.$(command): Ifcommandis not a definedmake-litevariable, it is treated as an implicit shell command, executed, and its output is substituted.
When make-lite is called from within a recipe (e.g., make-lite clean), it is a new process. This new process inherits its environment from the recipe's shell, not from the original make-lite process that launched the recipe.
This means that any variables modified within the recipe's command line will be seen by the recursive call.
Example:
VAR = original
all:
@echo "Top level sees: $(VAR)"
VAR=changed make-lite inner
inner:
@echo "Inner level sees: $(VAR)"Output:
Top level sees: original
Inner level sees: changed
This is the correct and expected behavior, but it's important to be aware of when writing complex recursive Makefiles.
- A rule's recipe runs if any of its targets don't exist, or if any of its sources are newer than the oldest target.
- If a dependency is missing from the filesystem and there is no rule to create it,
make-liteexits with a fatal error.
This section covers common mistakes, especially those made when migrating from GNU Make or using LLM-generated code.
Problem: You define a variable VAR = "my value" and expect VAR to be my value, but it's actually "my value".
Why: Variable assignments in a Makefile.mk-lite are literal. Unlike the load_env directive, the parser does not strip surrounding quotes from the value.
Solution: Do not quote string values in assignments unless you explicitly want the quotes to be part of the final command.
- Incorrect:
GREETING = "Hello World" - Correct:
GREETING = Hello World
Problem: You try to use $(HOME) or $PATH in a variable assignment and it's either blank or causes an error.
Why: make-lite is not a shell. The $(...) syntax first looks for a make-lite variable. It does not automatically access the shell's environment.
Solution: Use the $(shell ...) function combined with $$ to explicitly query the shell's environment.
- Incorrect:
MY_HOME = $(HOME) - Correct:
MY_HOME = $(shell echo $$HOME)
Problem: You're not sure if a $(shell ...) command will run once or many times.
Why: This depends entirely on where you define it, due to eager expansion.
Solution: Remember this simple rule:
- In a variable assignment:
VAR = $(shell ...)runs once when the Makefile is first parsed. It's great for configuration that doesn't change. - In a recipe:
\t echo $(shell ...)runs every time that recipe line is executed. It's used for capturing dynamic state during the build.
Problem: You need a URL or other string with a colon in a variable, like API_URL = http://localhost:8080.
Why: This is perfectly safe. The parser checks for an = on a line first. If it finds one, it treats the line as an assignment and does not look for a : to define a rule.
Solution: No change is needed. You do not need to escape colons that appear in the value part of a variable assignment.
This project "eats its own dog food." Here is the actual Makefile.mk-lite used to build this tool, which serves as a great example of best practices.
# ==============================================================================
# Main Makefile for the make-lite project.
#
# This file uses make-lite itself to build, test, and install the tool.
# ==============================================================================
# --- Variables ---
# The name of the final executable.
BINARY_NAME = make-lite
# The path to the main Go package.
CMD_PATH = ./cmd/make-lite
# The directory where the binary will be installed.
INSTALL_DIR ?= $(shell echo $HOME)/.local/bin
# Versioning
# Get the version string from git tags. Fallback to "dev" if not in a git repo.
APP_VERSION = $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
# Go linker flags to inject the version string into the binary.
LDFLAGS = -ldflags="-X main.AppVersion=$(APP_VERSION)"
# Test suite runner
TEST_RUNNER = ./test_suite/run_tests.py
# Find all Go source files to use as dependencies for the build.
GO_SOURCES = $(shell find $(CMD_PATH) -name '*.go')
# --- Default Target ---
# The default target is `build`. Running `make` or `make-lite` will build the binary.
all: build
# --- Main Targets ---
# Build the make-lite binary with the version injected.
build: $(GO_SOURCES)
@echo "Tidying modules..."
go mod tidy
@echo "Building $(BINARY_NAME) version $(APP_VERSION)..."
go build $(LDFLAGS) -o $(BINARY_NAME) $(CMD_PATH)
# Install the make-lite binary to the user's local bin directory.
install: build
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
mkdir -p $(INSTALL_DIR)
cp $(BINARY_NAME) $(INSTALL_DIR)/
# Run the Python test suite.
test:
@echo "Running test suite..."
python3 $(TEST_RUNNER)
# Clean build artifacts and Go caches.
clean:
@echo "Cleaning artifacts and caches..."
rm -f $(BINARY_NAME)
rm -f ./test_suite/make-lite-test
go clean -cache -modcache -testcacheYou can use direnv to automatically shadow the system's make command with make-lite whenever you are in this project's directory. This provides a completely seamless workflow.
1. Create the .envrc file:
Create a file named .envrc in the project root with the following content. This tells direnv to add a local mk-lite directory to the front of your PATH.
# .envrc
#
# Use this with `direnv` to shadow the system 'make' with 'make-lite'.
# This prepends the ./mk-lite directory to your PATH.
PATH_add ./mk-lite2. Create the symbolic link:
Create a symbolic link named make inside the mk-lite directory that points to your make-lite executable. This is the "executable" that direnv will find in the path.
# Ensure the directory exists
mkdir -p mk-lite
# Create the symlink (adjust path if you installed make-lite elsewhere)
ln -sf ~/.local/bin/make-lite mk-lite/make3. Allow direnv:
Finally, run direnv allow in your terminal. Now, any time you type make, the shell will find and execute your local make-lite symlink instead of the system make.
You can use a capable LLM (like GPT-4, Claude 3, etc.) to automate much of the conversion process.
You are an expert build system engineer specializing in migrating projects from GNU Make to simpler, more modern alternatives. Your task is to analyze the provided GNU Makefile and convert it into the `make-lite` format.
First, understand the core principles of `make-lite`, which differ from GNU Make:
- **Premise:** `make-lite` is a simple, predictable command runner that fixes common Make annoyances.
- **Parsing Model:** `make-lite` uses a two-pass parser. In the first pass, it reads all files and populates all variables. If a variable is defined multiple times, the last definition wins. In the second pass, it expands variables in rules. This means you do not need to manually reorder variable definitions to appear before their use.
- **What is Supported:** Basic rules (`target: deps`), `VAR = value`, `VAR ?= value`, `$(shell ...)` and implicit shell fallbacks `$(command)`, multi-target rules, `$$` for shell passthrough, `load_env`, `include`.
- **What is NOT Supported:** Deferred assignment (the `=` operator is always eagerly expanded like `:=`), `.DEFAULT_GOAL`, automatic variables (`$@`, `$<`, `$^`), complex functions (`patsubst`, `foreach`, `wildcard`, etc.), command-line variable overrides (`make VAR=value`).
Follow these conversion rules precisely:
**1. File Structure & Simplification:**
- **Root Makefile:** The main file must be named `Makefile.mk-lite`.
- **Default Target:** Remove any `.DEFAULT_GOAL` directive. The default target in `make-lite` is simply the first rule in the root `Makefile.mk-lite`. By convention, this should be `all: help` if a `help` target exists.
- **Indentation:** Ensure every recipe line is indented. Any whitespace (tabs or spaces) is acceptable.
- **Environment Files:** Replace conditional `include .env` logic (e.g., `ifneq (,$(wildcard ./.env))`) with a single `load_env .env` directive.
- **Assignments:** Convert both GNU Make's simple `:=` and deferred `=` assignments to `make-lite`'s standard `=` operator. Because `make-lite` uses eager expansion, you may need to refactor rules that depend on deferred expansion.
- **Recursive Calls:** Replace `$(MAKE)` or `make` with `make-lite`.
**2. Remove Boilerplate & GNU Make Workarounds:**
- Aggressively simplify common workarounds for GNU Make's limitations, as `make-lite` often makes them obsolete.
- **Unconditional Generation:** Find and remove any `-force` targets (e.g., `protos-force`). `make-lite`'s dependency tracking is robust enough to not need them.
- **Stamp/Sentinel Files:** Find and remove rules that use empty "stamp" files (e.g., `touch .some_task_complete`) merely to trigger other rules.
- **Merge Split Multi-Target Rules:** GNU Make sometimes struggles with multi-target rules, so users split them (e.g., one rule for `file.pb.go` and another for `file_grpc.pb.go`). Identify these and **merge them back into a single, clean multi-target rule**.
- **Remove `.PHONY`**, `mkdir -p` (when creating a target's parent directory), and the `export` keyword.
**3. Convert Functions & Variables:**
- **Automatic Variables**: Replace `$@` (target), `$<` (first dependency), and `$^` (all dependencies) with their explicit string values.
- **Unsupported Functions**: Rewrite complex GNU Make functions (`patsubst`, `wildcard`, etc.) using `$(shell ...)` with common shell commands like `find` or `sed`. If a direct conversion is not possible, add a `# TODO:` comment explaining that the function needs manual review.
Convert the following GNU Makefile to `make-lite` format.
**GNU Makefile Input:**
When generating a Makefile, use the make-lite format. The file must be named Makefile.mk-lite. Follow these rules:
- Syntax: Use
target: dependencyfor rules. Recipe commands must be indented with one or more spaces or tabs. - Variables: All variables (
VAR = value) are expanded eagerly before any rules run. A$(shell ...)command in a variable assignment runs only once, during parsing. UseVAR ?= valueto set a default that can be overridden by the environment. Use $$ within a shell command to make the shell expand a variable. - CRITICAL: No Automatic Variables: Do not use automatic variables like
$@,$<, or$^. You must write out the explicit target and dependency names in your recipes. - No Boilerplate: Do not add GNU Make boilerplate or workarounds.
make-litehandles them automatically:- Do not use
.PHONYtargets. - Do not use
mkdir -pfor a target's parent directory. - Do not use stamp files. A rule with multiple targets runs if any target is missing or outdated.
- Do not
exportor prefix recipe commands with envs, anything assigned is assigned in command's environment too.
- Do not use
- File Lists: Use
VAR = $(shell find ...)to gather source file lists.
You can build from source or, once available, download a pre-compiled binary from the releases page.
# Build from source
make-lite build
# Install to a local directory (e.g., ~/.local/bin)
make-lite installUsage: make-lite [options] [target]
A simple, predictable build tool inspired by Make.
Options:
-h, --help Display help message.
-v, --version Display program version.
- Default Makefile:
Makefile.mk-lite - Default Target: The first rule defined in the Makefile.
- Debugging: Set the environment variable
MAKE_LITE_LOG_LEVEL=DEBUGto see verbose output, including the exact commands being sent to the shell.
MAKE_LITE_LOG_LEVEL=DEBUG make-lite