Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -920,10 +920,11 @@ include config.tup
include ../common/rules.tup
```

**`include_rules`** - Include Tuprules.tup from current and parent directories:
**`include_rules`** - Include all `Tuprules.tup` files from the project root down to the current directory, in root-first order. Each directory in the path from root to the Tupfile's directory is checked; gaps (directories without a `Tuprules.tup`) are silently skipped. For `sub/deep/Tupfile`, this is equivalent to:
```tup
include_rules
# Searches upward for Tuprules.tup files
include ../../Tuprules.tup # root
include ../Tuprules.tup # sub/ (if it exists)
include Tuprules.tup # sub/deep/ (if it exists)
```

**`export`** - Export variable to command environment:
Expand Down Expand Up @@ -1529,6 +1530,67 @@ When `-C` is not specified:
2. If output has `Tupfile.ini` → use output as config root (two-tree)
3. Otherwise → use source as config root (simple projects)

### 7.6 Self-Contained Library Convention

Large projects often contain multiple libraries that share a build (GMP + MPFR + MPC inside GCC, for example). This convention makes each library buildable on its own while staying composable in the larger project.

**The pattern:**

1. **Root `Tuprules.tup`** sets the project layout — toolchain variables and directory names for each library:

```tup
# Root Tuprules.tup
S = $(TUP_CWD)
B = $(TUP_VARIANT_OUTPUTDIR)/$(S)
CC = @(CC)
AR = @(AR)

GMP_DIR = gmp
MPFR_DIR = mpfr
```

2. **Each library's `Tuprules.tup`** provides `?=` defaults for standalone use. In composed mode, root's assignments win and the defaults are no-ops:

```tup
# mpfr/Tuprules.tup
S ?= $(TUP_CWD)
B ?= $(TUP_VARIANT_OUTPUTDIR)/$(S)
CC ?= gcc
AR ?= ar
GMP_DIR ?= ../gmp
MPFR_DIR ?= .

CFLAGS = -O2 -DHAVE_CONFIG_H
CFLAGS += -I$(S)/$(MPFR_DIR)/src
CFLAGS += -I$(S)/$(GMP_DIR)

!cc = | $(S)/$(GMP_DIR)/<gen-headers> |> ^ CC %b^ $(CC) $(CFLAGS) -c %f -o %o |> %B.o
```

3. **Tupfiles** use unprefixed names (`CFLAGS`, `!cc`) — each library's `Tuprules.tup` is only visible to its own subtree via `include_rules`.

**Why directory names are prefixed (`GMP_DIR`, not `DIR`):**

A library that depends on another needs to reference both directories in the same file. MPC's `Tuprules.tup` has `-I$(S)/$(GMP_DIR)`, `-I$(S)/$(MPFR_DIR)/src`, and `-I$(S)/$(MPC_DIR)/src` — three different paths that need three different names. The root also sets all of them in one shared scope, so a single `DIR` variable would collide.

CFLAGS and bang macros (`!cc`, `!gen-config`) don't need prefixes because each library's `Tuprules.tup` is only included by Tupfiles in its own subtree — there's no shared scope where they could collide.

**How `include_rules` enables this:**

`include_rules` includes every `Tuprules.tup` from the project root down to the current directory. For `mpfr/src/Tupfile`, this means root's `Tuprules.tup` runs first (setting `S`, `GMP_DIR = gmp`, `MPFR_DIR = mpfr`), then `mpfr/Tuprules.tup` runs second (its `?=` defaults are no-ops, but it defines `CFLAGS` and `!cc`).

**Standalone vs composed paths:**

```
# Composed (from mpfr/src/, root is ../..)
S = ../.. GMP_DIR = gmp → $(S)/$(GMP_DIR) = ../../gmp ✓

# Standalone (mpfr is root, from mpfr/src/)
S = .. GMP_DIR = ../gmp → $(S)/$(GMP_DIR) = ../../gmp ✓
```

See `examples/gcc/` for a complete working example with three interdependent libraries.

## 8. Implicit Dependencies

Header files included by C/C++ sources aren't listed in Tupfiles, but changes to them should trigger rebuilds. Putup tracks these "implicit dependencies" automatically.
Expand Down
98 changes: 52 additions & 46 deletions src/graph/builder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -511,27 +511,37 @@ auto create_command_node(
std::string const& display
) -> Result<NodeId>;

/// Search up the directory tree for Tuprules.tup
/// Returns empty path if not found
auto find_tuprules_file(
/// Collect all Tuprules.tup files from root to start_dir (root-first order).
/// Per tup semantics, include_rules includes every Tuprules.tup from the
/// project root down to the current directory. Gaps are allowed.
auto find_tuprules_files(
fs::path const& start_dir,
fs::path const& root
) -> fs::path
) -> std::vector<fs::path>
{
auto dirs = std::vector<fs::path> {};
auto search_dir = start_dir;

while (search_dir >= root) {
auto tuprules = fs::path { search_dir / "Tuprules.tup" };
if (fs::exists(tuprules)) {
return tuprules;
}
dirs.push_back(search_dir);
if (search_dir == root) {
break;
}
search_dir = search_dir.parent_path();
}

return {};
// Reverse to root-first order
std::reverse(dirs.begin(), dirs.end());

auto results = std::vector<fs::path> {};
for (auto const& dir : dirs) {
auto tuprules = dir / "Tuprules.tup";
if (fs::exists(tuprules)) {
results.push_back(tuprules);
}
}

return results;
}

/// Resolve an explicit include path (not include_rules)
Expand Down Expand Up @@ -1120,47 +1130,25 @@ auto process_conditional(
return {};
}

auto process_include(
auto include_single_file(
BuilderContext& ctx,
BuilderState& state,
parser::Include const& inc
fs::path const& include_root,
std::string const& include_path,
bool is_rules
) -> Result<void>
{
// Include files (Tuprules.tup, etc.) live in config_root (same as Tupfiles)
// Use config_root if set, otherwise fall back to source_root for traditional builds
auto const& include_root = ctx.options.config_root.empty() ? ctx.options.source_root : ctx.options.config_root;

// Find the include file path
auto include_path = std::string {};
if (inc.is_rules) {
auto tuprules = find_tuprules_file(include_root / ctx.current_dir, include_root);
if (tuprules.empty()) {
return {}; // No Tuprules.tup found, silently continue
}
include_path = tuprules.generic_string();
} else {
auto resolved = resolve_include_path(ctx, include_root, inc.path);
if (!resolved) {
return pup::unexpected<Error>(resolved.error());
}
include_path = resolved->generic_string();
}

// Prevent infinite recursion
if (ctx.included_files.contains(include_path)) {
return {};
}
ctx.included_files.insert(include_path);

// Add included file to sticky_sources for dependency tracking
// Included files live in config_root, so use include_root for relative path
auto inc_rel = fs::relative(include_path, include_root).generic_string();
auto inc_node_result = get_or_create_file_node(ctx, inc_rel, NodeType::File);
if (inc_node_result) {
ctx.sticky_sources.push_back(*inc_node_result);
}

// Read the include file
auto file = std::ifstream { include_path };
if (!file) {
return make_error<void>(ErrorCode::IoError, "Cannot open include file: " + include_path);
Expand All @@ -1170,7 +1158,6 @@ auto process_include(
ss << file.rdbuf();
auto source = std::string { ss.str() };

// Parse the include file
auto parse_result = parser::parse_tupfile(source, include_path);
if (!parse_result.success()) {
for (auto const& err : parse_result.errors) {
Expand All @@ -1179,43 +1166,62 @@ auto process_include(
return make_error<void>(ErrorCode::ParseError, "Parse error in include file: " + include_path);
}

// For include_rules, temporarily set TUP_CWD to the relative path from
// the Tupfile directory back to the Tuprules.tup directory. This allows
// patterns like ROOT = $(TUP_CWD) to work correctly.
auto old_tup_cwd = std::string {};
if (inc.is_rules && ctx.eval) {
if (is_rules && ctx.eval) {
old_tup_cwd = ctx.eval->tup_cwd;
// Compute relative path from Tupfile directory to include file's directory
auto include_dir = fs::path { include_path }.parent_path();
auto rel_path = fs::relative(include_dir, include_root / ctx.current_dir);
ctx.eval->tup_cwd = rel_path.empty() ? "." : rel_path.generic_string();
}

// Save and update current_file for variable tracking callback
auto old_current_file = ctx.current_file;
ctx.current_file = include_path;

// Process statements from the included file
for (auto const& stmt : parse_result.tupfile.statements) {
auto result = Result<void> { process_statement(ctx, state, *stmt) };
if (!result) {
ctx.current_file = old_current_file;
if (inc.is_rules && ctx.eval) {
if (is_rules && ctx.eval) {
ctx.eval->tup_cwd = old_tup_cwd;
}
return pup::unexpected<Error>(result.error());
}
}

// Restore original current_file and TUP_CWD
ctx.current_file = old_current_file;
if (inc.is_rules && ctx.eval) {
if (is_rules && ctx.eval) {
ctx.eval->tup_cwd = old_tup_cwd;
}

return {};
}

auto process_include(
BuilderContext& ctx,
BuilderState& state,
parser::Include const& inc
) -> Result<void>
{
auto const& include_root = ctx.options.config_root.empty() ? ctx.options.source_root : ctx.options.config_root;

if (inc.is_rules) {
auto tuprules_files = find_tuprules_files(include_root / ctx.current_dir, include_root);
for (auto const& tuprules : tuprules_files) {
auto result = include_single_file(ctx, state, include_root, tuprules.generic_string(), true);
if (!result) {
return pup::unexpected<Error>(result.error());
}
}
return {};
}

auto resolved = resolve_include_path(ctx, include_root, inc.path);
if (!resolved) {
return pup::unexpected<Error>(resolved.error());
}
return include_single_file(ctx, state, include_root, resolved->generic_string(), false);
}

auto process_import(
BuilderContext& ctx,
BuilderState& state,
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions test/e2e/fixtures/include_rules_nested/Tuprules.tup
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ROOT = $(TUP_CWD)
CC ?= gcc
2 changes: 2 additions & 0 deletions test/e2e/fixtures/include_rules_nested/sub/Tupfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
include_rules
: |> echo $(ROOT) $(CC) $(SUBVAR) > %o |> result.txt
2 changes: 2 additions & 0 deletions test/e2e/fixtures/include_rules_nested/sub/Tuprules.tup
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SUBVAR = from_sub
CC ?= clang
31 changes: 31 additions & 0 deletions test/unit/test_e2e.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4868,6 +4868,37 @@ SCENARIO("Rules with empty input patterns are skipped", "[e2e][empty-input]")
// Incremental Build Command String Mismatch Tests
// =============================================================================

SCENARIO("include_rules includes all Tuprules.tup from root to leaf", "[e2e][build]")
{
GIVEN("a project with Tuprules.tup at root and in a subdirectory")
{
auto f = E2EFixture { "include_rules_nested" };
REQUIRE(f.init().success());

WHEN("building the subdirectory Tupfile")
{
auto result = f.build();

THEN("both root and sub Tuprules.tup are included")
{
INFO("stdout: " << result.stdout_output);
INFO("stderr: " << result.stderr_output);
REQUIRE(result.success());

auto content = f.read_file("sub/result.txt");
INFO("result.txt: " << content);

// ROOT comes from root Tuprules.tup (TUP_CWD = .. from sub/)
REQUIRE(content.find("..") != std::string::npos);
// CC = gcc (root sets it first, sub's ?= doesn't override)
REQUIRE(content.find("gcc") != std::string::npos);
// SUBVAR comes from sub/Tuprules.tup
REQUIRE(content.find("from_sub") != std::string::npos);
}
}
}
}

SCENARIO("Sibling directory inputs work with incremental variant builds", "[e2e][incremental][variant]")
{
// This tests the command string matching between graph and index.
Expand Down