From 5f891dab1611fb04cdce8515e11c63fc26253bb0 Mon Sep 17 00:00:00 2001 From: HuntedByTheIRS Date: Mon, 27 Jul 2026 23:01:30 -0400 Subject: [PATCH] first commit :) --- .gitignore | 18 + CHANGELOG.md | 5 + CONTRIBUTING.md | 3 + LICENSE | 5 + README.md | 8 + dub.json | 9 + examples/advanced/.gitkeep | 0 examples/basic/.gitkeep | 0 examples/parallel/.gitkeep | 0 source/antelope/app.d | 32 + source/antelope/build/dependency.d | 213 +++ source/antelope/build/executor.d | 62 + source/antelope/build/graph.d | 301 ++++ source/antelope/build/scheduler.d | 132 ++ source/antelope/build/target.d | 20 + source/antelope/cli/args.d | 465 +++++ source/antelope/cli/help.d | 29 + source/antelope/cli/subcommands.d | 356 ++++ source/antelope/cli/version.d | 12 + .../antelope/compatibility/automatic_vars.d | 263 +++ source/antelope/compatibility/gnu_make.d | 28 + .../antelope/compatibility/implicit_rules.d | 254 +++ .../antelope/compatibility/include_handling.d | 33 + source/antelope/compatibility/order_only.d | 34 + source/antelope/compatibility/parallel.d | 30 + source/antelope/compatibility/pattern_rules.d | 92 + source/antelope/compatibility/posix_make.d | 37 + source/antelope/compatibility/quirks.d | 90 + .../compatibility/secondary_expansion.d | 54 + source/antelope/compatibility/submake.d | 57 + source/antelope/compatibility/target_vars.d | 36 + source/antelope/compatibility/vpath.d | 106 ++ source/antelope/diagnostics/errors.d | 24 + source/antelope/diagnostics/output.d | 100 ++ source/antelope/diagnostics/warnings.d | 76 + source/antelope/evaluator/conditionals.d | 205 +++ source/antelope/evaluator/evaluator.d | 1498 +++++++++++++++++ source/antelope/evaluator/expansion.d | 881 ++++++++++ source/antelope/evaluator/functions.d | 1299 ++++++++++++++ source/antelope/filesystem/files.d | 143 ++ source/antelope/filesystem/paths.d | 44 + source/antelope/filesystem/timestamps.d | 115 ++ source/antelope/parser/ast.d | 22 + source/antelope/parser/directives.d | 19 + source/antelope/parser/functions.d | 45 + source/antelope/parser/lexer.d | 536 ++++++ source/antelope/parser/parser.d | 1010 +++++++++++ source/antelope/parser/variables.d | 17 + source/antelope/shell/command.d | 340 ++++ source/antelope/shell/environment.d | 182 ++ source/antelope/shell/process.d | 67 + tests/build/.gitkeep | 0 tests/compatibility/.gitkeep | 0 tests/evaluator/.gitkeep | 0 tests/integration/.gitkeep | 0 tests/integration/basic_build.d | 28 + tests/integration/conditional.mk | 7 + tests/integration/include_main.mk | 2 + tests/integration/include_sub.mk | 2 + tests/integration/parse_test.d | 27 + tests/integration/simple.mk | 6 + tests/integration/variables.mk | 12 + tests/parser/.gitkeep | 0 63 files changed, 9491 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dub.json create mode 100644 examples/advanced/.gitkeep create mode 100644 examples/basic/.gitkeep create mode 100644 examples/parallel/.gitkeep create mode 100644 source/antelope/app.d create mode 100644 source/antelope/build/dependency.d create mode 100644 source/antelope/build/executor.d create mode 100644 source/antelope/build/graph.d create mode 100644 source/antelope/build/scheduler.d create mode 100644 source/antelope/build/target.d create mode 100644 source/antelope/cli/args.d create mode 100644 source/antelope/cli/help.d create mode 100644 source/antelope/cli/subcommands.d create mode 100644 source/antelope/cli/version.d create mode 100644 source/antelope/compatibility/automatic_vars.d create mode 100644 source/antelope/compatibility/gnu_make.d create mode 100644 source/antelope/compatibility/implicit_rules.d create mode 100644 source/antelope/compatibility/include_handling.d create mode 100644 source/antelope/compatibility/order_only.d create mode 100644 source/antelope/compatibility/parallel.d create mode 100644 source/antelope/compatibility/pattern_rules.d create mode 100644 source/antelope/compatibility/posix_make.d create mode 100644 source/antelope/compatibility/quirks.d create mode 100644 source/antelope/compatibility/secondary_expansion.d create mode 100644 source/antelope/compatibility/submake.d create mode 100644 source/antelope/compatibility/target_vars.d create mode 100644 source/antelope/compatibility/vpath.d create mode 100644 source/antelope/diagnostics/errors.d create mode 100644 source/antelope/diagnostics/output.d create mode 100644 source/antelope/diagnostics/warnings.d create mode 100644 source/antelope/evaluator/conditionals.d create mode 100644 source/antelope/evaluator/evaluator.d create mode 100644 source/antelope/evaluator/expansion.d create mode 100644 source/antelope/evaluator/functions.d create mode 100644 source/antelope/filesystem/files.d create mode 100644 source/antelope/filesystem/paths.d create mode 100644 source/antelope/filesystem/timestamps.d create mode 100644 source/antelope/parser/ast.d create mode 100644 source/antelope/parser/directives.d create mode 100644 source/antelope/parser/functions.d create mode 100644 source/antelope/parser/lexer.d create mode 100644 source/antelope/parser/parser.d create mode 100644 source/antelope/parser/variables.d create mode 100644 source/antelope/shell/command.d create mode 100644 source/antelope/shell/environment.d create mode 100644 source/antelope/shell/process.d create mode 100644 tests/build/.gitkeep create mode 100644 tests/compatibility/.gitkeep create mode 100644 tests/evaluator/.gitkeep create mode 100644 tests/integration/.gitkeep create mode 100644 tests/integration/basic_build.d create mode 100644 tests/integration/conditional.mk create mode 100644 tests/integration/include_main.mk create mode 100644 tests/integration/include_sub.mk create mode 100644 tests/integration/parse_test.d create mode 100644 tests/integration/simple.mk create mode 100644 tests/integration/variables.mk create mode 100644 tests/parser/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f66b0bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.dub +docs.json +__dummy.html +docs/ +/antelope +antelope.so +antelope.dylib +antelope.dll +libantelope.a +antelope.a +antelope.lib +antelope-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +references/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83469cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [Unreleased] + +- Initial project scaffolding. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6fc9227 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +See the [architecture docs](docs/architecture.md) to understand the design. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..977b8df --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +BSD 3-Clause License + +Copyright (c) 2026, Antelope Contributors + +All rights reserved. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4c1c89 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Antelope + +A full replacement for GNU Make, with compatibility in the 99th percentile. +Written in D. + +## Status + +Early development. diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..9a7e92f --- /dev/null +++ b/dub.json @@ -0,0 +1,9 @@ +{ + "description": "A full replacement for GNU Make, with compatability in the 99th percentile. Written in D. Fuck you GNU.", + "license": "BSD-3-Clause", + "authors": [ + "Specter" + ], + "copyright": "Copyright (c) 2026 Antelope Contributors", + "name": "antelope" +} \ No newline at end of file diff --git a/examples/advanced/.gitkeep b/examples/advanced/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/basic/.gitkeep b/examples/basic/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/parallel/.gitkeep b/examples/parallel/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/source/antelope/app.d b/source/antelope/app.d new file mode 100644 index 0000000..59cf55f --- /dev/null +++ b/source/antelope/app.d @@ -0,0 +1,32 @@ +/// Entry point for the Antelope build system. +/// +/// CLI pattern: antelope --[flags] +/// Subcommand defaults to `build` when omitted. +module antelope.app; + +import std.stdio; +import antelope.cli.args; +import antelope.cli.subcommands; +import antelope.cli.help; +import antelope.cli.verinfo; + +int main(string[] args) +{ + // Parse CLI arguments into config + CliConfig config = parseArgs(args); + + // Handle help/version quickly before dispatch + if (config.showHelp) + { + printHelp(); + return 0; + } + if (config.showVersion) + { + printVersion(); + return 0; + } + + // Dispatch to the appropriate subcommand + return dispatchSubcommand(config); +} diff --git a/source/antelope/build/dependency.d b/source/antelope/build/dependency.d new file mode 100644 index 0000000..9b1477f --- /dev/null +++ b/source/antelope/build/dependency.d @@ -0,0 +1,213 @@ +/// Dependency resolution and ordering logic. +/// +/// Uses Kahn's algorithm (BFS-based topological sort) to produce ordered +/// build batches. Each batch contains targets that can be built in parallel. +/// The first batch contains leaf targets (no unresolved prereqs in the +/// graph), and the last batch contains the requested root target. +module antelope.build.dependency; + +import antelope.build.graph; +import antelope.build.target; +import antelope.diagnostics.errors; + +/// Resolve the full transitive closure of prerequisites and return an +/// array of build batches using Kahn's algorithm. +/// +/// Each batch is a slice of targets that can be built in parallel — all +/// of their in-graph prerequisites have been satisfied by earlier batches. +/// +/// Params: +/// graph = The dependency graph containing all known targets. +/// target = The root build target to resolve dependencies for. +/// +/// Returns: +/// An array of batches `Target[][]` ordered from leaf to root. +/// Returns an empty array if the target is not found in the graph. +/// +/// Example: +/// target `program` with deps `main.o` → `main.c`, `util.o` → `util.c` +/// Returns: `[[main.c, util.c], [main.o, util.o], [program]]` +Target[][] resolveDependencies(DependencyGraph graph, string target) +{ + // 1. Find the root target + auto rootTarget = graph.findTarget(target); + if (rootTarget is null) + return []; + + // 2. Compute transitive closure — only follow prereqs that exist + // as graph targets. Missing prereqs are treated as external files + // (already satisfied, don't contribute to in-degree). + bool[string] inClosure; + string[] stack = [target]; + + while (stack.length > 0) + { + string current = stack[$ - 1]; + stack = stack[0 .. $ - 1]; + + if (current in inClosure) + continue; + inClosure[current] = true; + + auto tp = graph.findTarget(current); + if (tp is null) + continue; + + // Only follow prereqs that exist as graph targets. + // External prereqs (source files, etc.) are treated as + // already-satisfied and do not trigger stub creation. + foreach (prereq; tp.prerequisites) + { + if (prereq !in inClosure && graph.hasTarget(prereq)) + stack ~= prereq; + } + foreach (prereq; tp.orderOnlyPrereqs) + { + if (prereq !in inClosure && graph.hasTarget(prereq)) + stack ~= prereq; + } + } + + // 3. Build a fast name → Target lookup for graph targets in the closure. + Target[string] targetMap; + foreach (ref t; graph.targets) + if (t.name in inClosure) + targetMap[t.name] = t; + + // 4. Compute in-degree for each target: the number of its prereqs + // that are also targets in the graph (and therefore need building). + size_t[string] inDegree; + string[][string] dependents; // prereq → list of dependents + + foreach (name; inClosure.keys) + inDegree[name] = 0; + + foreach (name, ref tgt; targetMap) + { + size_t unresolved; + foreach (prereq; tgt.prerequisites) + { + if (prereq in targetMap) + { + unresolved++; + dependents[prereq] ~= name; + } + } + // Order-only prereqs also need building + foreach (prereq; tgt.orderOnlyPrereqs) + { + if (prereq in targetMap) + { + unresolved++; + dependents[prereq] ~= name; + } + } + inDegree[name] = unresolved; + } + + // 5. Kahn's algorithm — process in batches. + Target[][] batches; + string[] currentBatch; + + // Seed with all nodes that have no unresolved in-graph prereqs. + foreach (name; inClosure.keys) + { + if (inDegree[name] == 0) + currentBatch ~= name; + } + + while (currentBatch.length > 0) + { + Target[] batch; + foreach (name; currentBatch) + { + auto tp = name in targetMap; + if (tp !is null) + batch ~= *tp; + } + if (batch.length > 0) + batches ~= batch; + + // Decrement in-degree for all dependents of the current batch. + string[] nextBatch; + foreach (name; currentBatch) + { + auto deps = name in dependents; + if (deps is null) + continue; + foreach (dep; *deps) + { + inDegree[dep]--; + if (inDegree[dep] == 0) + nextBatch ~= dep; + } + } + currentBatch = nextBatch; + } + + // 6. If any nodes still have inDegree > 0, there's a cycle. + // Those targets will never reach batch 0 and won't appear in + // the output — the caller should detect that some targets are + // missing from the batches. + return batches; +} + +/// +unittest +{ + // Build a test graph: + // program → main.o → main.c + // program → util.o → util.c + DependencyGraph g; + g.addTarget(Target("main.c", TargetKind.file, [], [])); + g.addTarget(Target("util.c", TargetKind.file, [], [])); + g.addTarget(Target("main.o", TargetKind.file, ["main.c"], ["gcc -c main.c"])); + g.addTarget(Target("util.o", TargetKind.file, ["util.c"], ["gcc -c util.c"])); + g.addTarget(Target("program", TargetKind.file, + ["main.o", "util.o"], ["gcc -o program main.o util.o"])); + + auto batches = resolveDependencies(g, "program"); + + // Expected: [[main.c, util.c], [main.o, util.o], [program]] + assert(batches.length == 3); + assert(batches[0].length == 2); + assert(batches[1].length == 2); + assert(batches[2].length == 1); + assert(batches[2][0].name == "program"); + + // Leaf batch can be in any order, but both leaves must be present. + bool hasMainC, hasUtilC; + foreach (t; batches[0]) + { + if (t.name == "main.c") hasMainC = true; + if (t.name == "util.c") hasUtilC = true; + } + assert(hasMainC && hasUtilC); +} + +/// Regression: missing target returns empty. +unittest +{ + DependencyGraph g; + auto batches = resolveDependencies(g, "nonexistent"); + assert(batches.length == 0); +} + +/// Regression: external prerequisite (not in graph) is treated as already +/// satisfied and does not contribute to in-degree. +unittest +{ + DependencyGraph g; + // main.o depends on main.c, but main.c is NOT in the graph + g.addTarget(Target("main.o", TargetKind.file, ["main.c"], ["gcc -c main.c"])); + g.addTarget(Target("program", TargetKind.file, ["main.o"], ["gcc -o program main.o"])); + + auto batches = resolveDependencies(g, "program"); + // main.c is external → main.o has effective in-degree 0 + // Expected: [[main.o], [program]] + assert(batches.length == 2); + assert(batches[0].length == 1); + assert(batches[0][0].name == "main.o"); + assert(batches[1].length == 1); + assert(batches[1][0].name == "program"); +} diff --git a/source/antelope/build/executor.d b/source/antelope/build/executor.d new file mode 100644 index 0000000..17245d0 --- /dev/null +++ b/source/antelope/build/executor.d @@ -0,0 +1,62 @@ +/// Command execution engine — runs recipe lines and reports results. +module antelope.build.executor; + +import antelope.shell.process; + +/// Result of executing a single recipe line. +struct ExecResult +{ + bool success; /// True if the command succeeded or ignoreErrors was set + string output; /// (reserved for future output capture) + int exitCode; /// Exit code from the process +} + +/// Execute a command string and return the result. +/// +/// Handles GNU Make recipe prefix characters (@, -, +), then passes the +/// remaining line directly to /bin/sh. Tokenization is deliberately +/// avoided — the shell interprets metacharacters (;, >, <, |, &&, ||) +/// that would be broken by argument-level quoting. +/// +/// Params: +/// command = The raw recipe line text (may include prefix chars) +/// environment = Optional environment variables (KEY=VALUE) to pass to +/// the subprocess. If empty, the parent env is inherited. +/// +/// Returns: ExecResult with success flag, output, and exit code. +ExecResult execute(string command, string[] environment = []) +{ + import std.string : stripLeft; + + string trimmed = command.stripLeft(); + if (trimmed.length == 0) + return ExecResult(true, "", 0); + + // Strip GNU Make prefix characters (@, -, +) from the start of the + // line. These flags affect behaviour; the rest of the line is + // passed verbatim to the shell. + bool ignoreErrors; + + while (true) + { + if (trimmed.length == 0) break; + switch (trimmed[0]) + { + case '@': trimmed = trimmed[1..$]; continue; + case '-': ignoreErrors = true; trimmed = trimmed[1..$]; continue; + case '+': trimmed = trimmed[1..$]; continue; + default: break; + } + break; + } + + if (trimmed.length == 0) + return ExecResult(true, "", 0); + + // Execute via /bin/sh — shells handle metacharacters natively. + int code = runProcess(trimmed, environment); + + // Success if exit code is 0 OR the ignoreErrors (-) flag is set + bool ok = (code == 0 || ignoreErrors); + return ExecResult(ok, "", code); +} diff --git a/source/antelope/build/graph.d b/source/antelope/build/graph.d new file mode 100644 index 0000000..4faed51 --- /dev/null +++ b/source/antelope/build/graph.d @@ -0,0 +1,301 @@ +/// Dependency graph construction from parsed rules. +module antelope.build.graph; + +import antelope.parser.ast; +import antelope.build.target; +import antelope.diagnostics.errors; + +/// A directed graph of target → prerequisite relationships. +struct DependencyGraph +{ + Target[] targets; /// All known targets. + string[string] variables; /// Parsed variable assignments. + AntelopeError[] cycleErrors; /// Cycle detection results. + bool[string] phonyTargets; /// Names marked as .PHONY. + + /// Build and return the dependency graph from the AST. + static DependencyGraph fromAst(AstNode root) + { + DependencyGraph graph; + + // Walk top-level rule_list children: rules, variable assignments, + // directives, etc. + foreach (child; root.children) + { + final switch (child.type) + { + case AstType.rule: + graph.addRuleFromAst(child); + break; + case AstType.variable_assignment: + // data holds "name=value" — store for later expansion + graph.parseVariableAssignment(child.data); + break; + case AstType.directive: + // Directives (include, vpath, etc.) are handled + // by the evaluator layer — skip for now. + break; + case AstType.rule_list: + // Nested rule_list (e.g., from include merging). + // Recurse into it. + foreach (nested; child.children) + { + if (nested.type == AstType.rule) + graph.addRuleFromAst(nested); + else if (nested.type == AstType.variable_assignment) + graph.parseVariableAssignment(nested.data); + } + break; + case AstType.prerequisite: + case AstType.recipe_line: + case AstType.function_call: + // Should not appear as direct children of rule_list. + break; + } + } + + // .PHONY handling: mark its prerequisites as phony targets + graph.handlePhony(); + + // Cycle detection: DFS with three-color marking + graph.detectCycles(); + + return graph; + } + + /// Add a target to the graph. + void addTarget(Target t) + { + targets ~= t; + } + + /// Find a target by name. Returns null if not found. + Target* findTarget(string name) + { + foreach (ref t; targets) + if (t.name == name) return &t; + return null; + } + + /// Check if a target exists in the graph. + bool hasTarget(string name) + { + return findTarget(name) !is null; + } + +private: + /// Extract target name, prerequisites, and recipe from a rule AST node. + void addRuleFromAst(AstNode ruleNode) + { + string targetName = ruleNode.data; + string[] prereqs; + string[] recipe; + + foreach (child; ruleNode.children) + { + if (child.type == AstType.prerequisite) + prereqs ~= child.data; + else if (child.type == AstType.recipe_line) + recipe ~= child.data; + } + + Target t; + t.name = targetName; + t.kind = TargetKind.file; + t.prerequisites = prereqs; + t.recipe = recipe; + addTarget(t); + } + + /// Parse a "name=value" variable assignment string into the variables map. + void parseVariableAssignment(string data) + { + import std.string : indexOf; + auto eq = data.indexOf('='); + if (eq > 0) + { + string name = data[0 .. eq]; + string value = data[eq + 1 .. $]; + variables[name] = value; + } + } + + /// Find the ".PHONY" target (if it exists) and mark all of its + /// prerequisites as phony targets. + void handlePhony() + { + Target* phony = findTarget(".PHONY"); + if (phony is null) + return; + + foreach (name; phony.prerequisites) + { + Target* t = findTarget(name); + if (t !is null) + t.kind = TargetKind.phony; + phonyTargets[name] = true; + } + } + + /// Detect cycles in the dependency graph using three-color DFS. + /// Stores found cycles as AntelopeError in cycleErrors. + void detectCycles() + { + // Three colors for DFS state. + enum Color : ubyte { white, gray, black } + + // Adjacency list: target name → prerequisite names. + string[][string] adjacency; + foreach (t; targets) + adjacency[t.name] = t.prerequisites; + + Color[string] colors; // Default-initialized to white (0). + string[] stack; // Current DFS path for cycle reporting. + + // Recursive DFS visit; returns true if a cycle was found. + bool dfsVisit(string node) + { + colors[node] = Color.gray; + stack ~= node; + + auto depsPtr = node in adjacency; + if (depsPtr) + { + foreach (dep; *depsPtr) + { + Color* cPtr = dep in colors; + + if (cPtr is null || *cPtr == Color.white) + { + // Not yet visited — descend. + if (dfsVisit(dep)) + return true; + } + else if (*cPtr == Color.gray) + { + // Back-edge found — construct cycle description. + // Find where `dep` first appears in stack. + import std.string : join; + ptrdiff_t cycleStart = -1; + foreach (i, s; stack) + { + if (s == dep) + { + cycleStart = cast(ptrdiff_t) i; + break; + } + } + string[] cyclePath = stack[cast(size_t) cycleStart .. $]; + cyclePath ~= dep; // Close the loop. + + string cycleMsg = "cycle: " ~ cyclePath.join(" \u2192 "); + cycleErrors ~= AntelopeError( + ErrorKind.cyclicDependency, + cycleMsg, + "", 0, 0 + ); + return true; + } + // black nodes are already fully processed — ignore. + } + } + + colors[node] = Color.black; + stack = stack[0 .. $ - 1]; // Pop. + return false; + } + + foreach (t; targets) + { + if (!(t.name in colors)) + dfsVisit(t.name); + } + } +} + +// ─── Unittests ─────────────────────────────────────────────────────────── + +// Two-rule graph with no cycles. +unittest +{ + // AST: all: program + // ./program + // program: main.o + // cc -o program main.o + + auto all = AstNode(AstType.rule, [], "all"); + all.children ~= AstNode(AstType.prerequisite, [], "program"); + all.children ~= AstNode(AstType.recipe_line, [], "./program"); + + auto program = AstNode(AstType.rule, [], "program"); + program.children ~= AstNode(AstType.prerequisite, [], "main.o"); + program.children ~= AstNode(AstType.recipe_line, [], "cc -o program main.o"); + + auto root = AstNode(AstType.rule_list, [all, program], ""); + + auto graph = DependencyGraph.fromAst(root); + + assert(graph.targets.length == 2); + assert(graph.hasTarget("all")); + assert(graph.hasTarget("program")); + + auto allTarget = graph.findTarget("all"); + assert(allTarget !is null); + assert(allTarget.prerequisites == ["program"]); + assert(allTarget.recipe == ["./program"]); + + // No cycles expected. + assert(graph.cycleErrors.length == 0); +} + +// Cycle detection: a → b → c → a. +unittest +{ + // AST: a: b + // b: c + // c: a + + auto a = AstNode(AstType.rule, [], "a"); + a.children ~= AstNode(AstType.prerequisite, [], "b"); + + auto b = AstNode(AstType.rule, [], "b"); + b.children ~= AstNode(AstType.prerequisite, [], "c"); + + auto c = AstNode(AstType.rule, [], "c"); + c.children ~= AstNode(AstType.prerequisite, [], "a"); + + auto root = AstNode(AstType.rule_list, [a, b, c], ""); + + auto graph = DependencyGraph.fromAst(root); + + // Graph should still be built with 3 targets. + assert(graph.targets.length == 3); + + // Cycle must be detected. + assert(graph.cycleErrors.length > 0); + assert(graph.cycleErrors[0].kind == ErrorKind.cyclicDependency); +} + +// .PHONY handling: mark prerequisites as phony. +unittest +{ + // AST: .PHONY: clean + // clean: + // rm -f *.o + + auto phony = AstNode(AstType.rule, [], ".PHONY"); + phony.children ~= AstNode(AstType.prerequisite, [], "clean"); + + auto clean = AstNode(AstType.rule, [], "clean"); + clean.children ~= AstNode(AstType.recipe_line, [], "rm -f *.o"); + + auto root = AstNode(AstType.rule_list, [phony, clean], ""); + + auto graph = DependencyGraph.fromAst(root); + + assert(graph.targets.length == 2); + + auto cleanTarget = graph.findTarget("clean"); + assert(cleanTarget !is null); + assert(cleanTarget.kind == TargetKind.phony); +} diff --git a/source/antelope/build/scheduler.d b/source/antelope/build/scheduler.d new file mode 100644 index 0000000..5f5f178 --- /dev/null +++ b/source/antelope/build/scheduler.d @@ -0,0 +1,132 @@ +/// Build job scheduler — decides execution order and parallelism. +/// +/// Uses Kahn's-algorithm topological sort (via `resolveDependencies`) to +/// determine build order, then filters targets that actually need rebuilding +/// by comparing file timestamps. +module antelope.build.scheduler; + +import antelope.build.graph; +import antelope.build.dependency; +import antelope.build.target; +import antelope.filesystem.timestamps; + +/// Available scheduler strategies. +/// +/// The strategy controls how the build order is consumed by the executor: +/// serial — Process targets one at a time in dependency order. +/// parallel — Build targets within each topological batch concurrently. +/// topological — Return the pure dependency order without filtering +/// for up-to-date targets (useful for inspection). +/// +/// The current implementation always uses topological batching; the +/// strategy is threaded through so the executor can decide whether to +/// run batches serially or in parallel. +enum SchedulerStrategy +{ + serial, + parallel, + topological, +} + +/// Schedule and return an ordered list of target names to build. +/// +/// Steps: +/// 1. Determine the root target — tries "all" (GNU Make convention), +/// falls back to the first target in the graph. +/// 2. Run Kahn's topological sort to produce build batches. +/// 3. For each target, check `needsRebuild()` against its prerequisites +/// to decide whether it actually needs to be built. +/// 4. Flatten batches into a single ordered `string[]`. +/// +/// Targets that are up-to-date (target file exists and is newer than all +/// prereqs) are skipped. +/// +/// Params: +/// graph = The dependency graph containing all known targets. +/// strategy = How the executor should consume the output (serial, +/// parallel, or raw topological). +/// +/// Returns: +/// Ordered list of target names that need building. Returns an empty +/// array when the graph has no targets or the root target is untraceable. +string[] schedule(DependencyGraph graph, SchedulerStrategy strategy) +{ + // 1. Pick the default target. + string targetName; + if (graph.hasTarget("all")) + targetName = "all"; + else if (graph.targets.length > 0) + targetName = graph.targets[0].name; + else + return []; + + // 2. Resolve full dependency tree into topological batches. + Target[][] batches = resolveDependencies(graph, targetName); + if (batches.length == 0) + return []; + + // 3. Collect targets that actually need building. + string[] buildOrder; + + foreach (batch; batches) + { + foreach (ref tgt; batch) + { + // In topological mode, include every target regardless of + // freshness (useful for inspection / dry-run analysis). + final switch (strategy) + { + case SchedulerStrategy.topological: + buildOrder ~= tgt.name; + break; + case SchedulerStrategy.serial: + case SchedulerStrategy.parallel: + if (needsRebuild(tgt.name, tgt.prerequisites, &graph.phonyTargets)) + buildOrder ~= tgt.name; + break; + } + } + } + + return buildOrder; +} + +/// +unittest +{ + // Simple chain: leaf1, leaf2 → middle. + // middle must be the first target added (and thus the default) + // so that the scheduler picks it up as the root. + DependencyGraph g; + g.addTarget(Target("middle", TargetKind.file, ["leaf1", "leaf2"], [])); + g.addTarget(Target("leaf1", TargetKind.file, [], [])); + g.addTarget(Target("leaf2", TargetKind.file, [], [])); + + // All targets should need rebuilding (files don't exist on disk). + auto order = schedule(g, SchedulerStrategy.serial); + assert(order.length == 3); + + // In topological mode, no filtering is applied. + auto topoOrder = schedule(g, SchedulerStrategy.topological); + assert(topoOrder.length == 3); +} + +/// Regression: "all" target takes priority as the default root. +unittest +{ + DependencyGraph g; + g.addTarget(Target("somethingElse", TargetKind.file, [], [])); + g.addTarget(Target("all", TargetKind.phony, ["somethingElse"], [])); + + auto order = schedule(g, SchedulerStrategy.serial); + // All targets should be in the build order (phony + file). + assert(order.length == 2); +} + +/// Regression: empty graph returns empty order. +unittest +{ + DependencyGraph g; + auto order = schedule(g, SchedulerStrategy.serial); + assert(order.length == 0); +} diff --git a/source/antelope/build/target.d b/source/antelope/build/target.d new file mode 100644 index 0000000..a6bcf07 --- /dev/null +++ b/source/antelope/build/target.d @@ -0,0 +1,20 @@ +/// Target representation — files, phony targets, and their metadata. +module antelope.build.target; + +/// What kind of target this is. +enum TargetKind +{ + file, + phony, + intermediate, +} + +/// A single build target. +struct Target +{ + string name; + TargetKind kind; + string[] prerequisites; /// Normal prerequisites (trigger rebuild) + string[] recipe; /// Shell commands to build this target + string[] orderOnlyPrereqs; /// Order-only prerequisites (| — must exist, no rebuild trigger) +} diff --git a/source/antelope/cli/args.d b/source/antelope/cli/args.d new file mode 100644 index 0000000..d2dc02c --- /dev/null +++ b/source/antelope/cli/args.d @@ -0,0 +1,465 @@ +/// Command-line argument parsing. +/// +/// Pattern: antelope --[flags] +/// Subcommand defaults to `build` when omitted. +module antelope.cli.args; + +import std.algorithm.searching : startsWith; +import std.conv : to; +import std.string : strip; +import std.ascii : isDigit; + +/// Available subcommands. +enum Subcommand +{ + build, /// Run the build (default) + hunt, /// Makefile → Antefile converter (late-stage) + configure, /// Autotools configure.ac handler (future) +} + +/// Parsed CLI configuration. +struct CliConfig +{ + /// Which subcommand to run. + Subcommand subcommand = Subcommand.build; + + /// Build targets to execute. + string[] targets; + + /// Explicit build file (--file / -f ). + string file; + + /// Enable GNU Make compatibility mode (-gnu / --gnu). + /// Enables Makefile/makefile reading, implicit rules, + /// automatic variables, VPATH, and all GNU Make semantics. + bool gnuMode; + + /// Dry run: show what would be done without executing (-n / --dry-run). + bool dryRun; + + /// Debug output (-d / --debug). + bool debugMode; + + /// POSIX conformance mode (-P / --posix). + bool posix; + + /// Parallel job count (-j ). 0 = unlimited, 1 = serial (default). + uint jobs = 1; + + /// Change to directory before execution (-C ). + string directory; + + /// Show help (--help). + bool showHelp; + + /// Show version (--version). + bool showVersion; +} + +// --- Helpers --- + +/// Check whether a string consists entirely of digit characters. +private bool allDigits(string s) +{ + if (s.length == 0) + return false; + foreach (c; s) + { + if (!isDigit(c)) + return false; + } + return true; +} + +/// Parse a subcommand string into the enum. +private Subcommand parseSubcommand(string s) +{ + switch (s) + { + case "build": + return Subcommand.build; + case "hunt": + return Subcommand.hunt; + case "configure": + return Subcommand.configure; + default: + return Subcommand.build; + } +} + +/// Check whether a string names a recognized subcommand. +private bool isSubcommand(string s) +{ + return s == "build" || s == "hunt" || s == "configure"; +} + +// --- Public API --- + +/// Parse command-line arguments into a CliConfig. +/// +/// Pattern: antelope [targets...] -gnu [flags] +/// The first positional argument that matches a subcommand name +/// (build, hunt, configure) sets the subcommand. Otherwise all +/// positionals become build targets. +/// +/// Unknown flags are silently ignored (GNU Make compatibility). +CliConfig parseArgs(string[] args) +{ + CliConfig config; + bool firstPositional = true; + bool doneWithFlags = false; + + for (size_t i = 1; i < args.length; i++) + { + string arg = args[i]; + + // -- stops flag parsing; everything after is a target + if (!doneWithFlags && arg == "--") + { + doneWithFlags = true; + continue; + } + + // After --, everything is a target + if (doneWithFlags) + { + config.targets ~= arg; + continue; + } + + // --help / --version stop immediately + if (arg == "--help") + { + config.showHelp = true; + break; + } + if (arg == "--version") + { + config.showVersion = true; + break; + } + + // Boolean flags + if (arg == "-gnu" || arg == "--gnu") + { + config.gnuMode = true; + continue; + } + if (arg == "-n" || arg == "--dry-run") + { + config.dryRun = true; + continue; + } + if (arg == "-d" || arg == "--debug") + { + config.debugMode = true; + continue; + } + if (arg == "-P" || arg == "--posix") + { + config.posix = true; + continue; + } + + // -jN (combined) or -j N (space-separated) + if (arg == "-j") + { + if (i + 1 < args.length && allDigits(args[i + 1])) + { + i++; + config.jobs = args[i].to!uint; + } + // else: missing/ambiguous argument → keep default jobs=1 + continue; + } + if (arg.startsWith("-j") && arg.length > 2) + { + string numPart = arg[2 .. $]; + if (allDigits(numPart)) + { + config.jobs = numPart.to!uint; + } + // else: invalid number → silently ignore (keep default) + continue; + } + + // -f file or --file file + if (arg == "-f" || arg == "--file") + { + if (i + 1 < args.length) + { + i++; + config.file = args[i]; + } + // else: missing argument → keep default empty file + continue; + } + + // -Cdir (combined) or -C dir (space-separated) + if (arg == "-C") + { + if (i + 1 < args.length) + { + i++; + config.directory = args[i]; + } + // else: missing argument → keep default empty directory + continue; + } + if (arg.startsWith("-C") && arg.length > 2) + { + config.directory = strip(arg[2 .. $]); + continue; + } + + // Subcommand detection: only the first positional is checked + if (firstPositional) + { + firstPositional = false; + if (isSubcommand(arg)) + { + config.subcommand = parseSubcommand(arg); + continue; + } + } + + // Unknown flags → silently ignore (GNU Make compatibility) + if (arg.startsWith("-")) + { + continue; + } + + // Everything else is a target + config.targets ~= arg; + } + + return config; +} + +// --- Unittests --- + +unittest +{ + // Default: empty args + { + auto c = parseArgs(["antelope"]); + assert(c.subcommand == Subcommand.build); + assert(c.targets.length == 0); + assert(c.jobs == 1); + assert(!c.gnuMode); + assert(!c.dryRun); + assert(!c.debugMode); + assert(!c.posix); + assert(!c.showHelp); + assert(!c.showVersion); + assert(c.file.length == 0); + assert(c.directory.length == 0); + } + + // -gnu flag + { + auto c = parseArgs(["antelope", "-gnu"]); + assert(c.gnuMode); + assert(c.targets.length == 0); + } + { + auto c = parseArgs(["antelope", "--gnu"]); + assert(c.gnuMode); + } + + // -gnu with targets + { + auto c = parseArgs(["antelope", "-gnu", "all", "clean"]); + assert(c.gnuMode); + assert(c.targets == ["all", "clean"]); + } + + // Targets without -gnu + { + auto c = parseArgs(["antelope", "release", "-gnu"]); + assert(c.gnuMode); + assert(c.targets == ["release"]); + } + + // Subcommand: build + { + auto c = parseArgs(["antelope", "build", "-j4"]); + assert(c.subcommand == Subcommand.build); + assert(c.jobs == 4); + } + { + auto c = parseArgs(["antelope", "build"]); + assert(c.subcommand == Subcommand.build); + } + + // Subcommand: hunt + { + auto c = parseArgs(["antelope", "hunt"]); + assert(c.subcommand == Subcommand.hunt); + } + + // Subcommand: configure + { + auto c = parseArgs(["antelope", "configure"]); + assert(c.subcommand == Subcommand.configure); + } + + // -jN combined form + { + auto c = parseArgs(["antelope", "-j8"]); + assert(c.jobs == 8); + } + { + auto c = parseArgs(["antelope", "-j0"]); // unlimited + assert(c.jobs == 0); + } + + // -j N space-separated + { + auto c = parseArgs(["antelope", "-j", "4"]); + assert(c.jobs == 4); + } + + // -j alone (missing argument) → default + { + auto c = parseArgs(["antelope", "-j"]); + assert(c.jobs == 1); + } + + // -j followed by non-numeric → default, next arg not consumed + { + auto c = parseArgs(["antelope", "-j", "all"]); + assert(c.jobs == 1); + assert(c.targets == ["all"]); + } + + // -C dir space-separated + { + auto c = parseArgs(["antelope", "-C", "/tmp"]); + assert(c.directory == "/tmp"); + } + + // -Cdir combined + { + auto c = parseArgs(["antelope", "-C/tmp"]); + assert(c.directory == "/tmp"); + } + + // -C alone (missing argument) → default + { + auto c = parseArgs(["antelope", "-C"]); + assert(c.directory.length == 0); + } + + // -n / --dry-run + { + auto c = parseArgs(["antelope", "-n"]); + assert(c.dryRun); + } + { + auto c = parseArgs(["antelope", "--dry-run"]); + assert(c.dryRun); + } + + // -d / --debug + { + auto c = parseArgs(["antelope", "-d"]); + assert(c.debugMode); + } + { + auto c = parseArgs(["antelope", "--debug"]); + assert(c.debugMode); + } + + // -P / --posix + { + auto c = parseArgs(["antelope", "-P"]); + assert(c.posix); + } + { + auto c = parseArgs(["antelope", "--posix"]); + assert(c.posix); + } + + // -f file / --file file + { + auto c = parseArgs(["antelope", "-f", "mymakefile"]); + assert(c.file == "mymakefile"); + } + { + auto c = parseArgs(["antelope", "--file", "mymakefile"]); + assert(c.file == "mymakefile"); + } + + // -f alone (missing argument) → default + { + auto c = parseArgs(["antelope", "-f"]); + assert(c.file.length == 0); + } + + // --help stops parsing + { + auto c = parseArgs(["antelope", "--help", "-gnu", "target"]); + assert(c.showHelp); + assert(!c.gnuMode); + assert(c.targets.length == 0); + } + + // --version stops parsing + { + auto c = parseArgs(["antelope", "--version", "-gnu"]); + assert(c.showVersion); + assert(!c.gnuMode); + } + + // -- separates targets from flags + { + auto c = parseArgs(["antelope", "--", "-gnu", "all"]); + assert(c.targets == ["-gnu", "all"]); + assert(!c.gnuMode); + } + + // -- with targets before and after + { + auto c = parseArgs(["antelope", "a", "--", "b", "c"]); + assert(c.targets == ["a", "b", "c"]); + } + + // Unknown flags silently ignored + { + auto c = parseArgs(["antelope", "-k", "--keep-going", "target"]); + assert(c.targets == ["target"]); + } + + // Multiple flags combined + { + auto c = parseArgs(["antelope", "-gnu", "-d", "-P", "-n", "-j", "16"]); + assert(c.gnuMode); + assert(c.debugMode); + assert(c.posix); + assert(c.dryRun); + assert(c.jobs == 16); + } + + // Interleaved flags and positionals + { + auto c = parseArgs(["antelope", "-gnu", "all", "-j8", "clean"]); + assert(c.gnuMode); + assert(c.jobs == 8); + assert(c.targets == ["all", "clean"]); + } + + // Subcommand with other positionals become targets + { + auto c = parseArgs(["antelope", "build", "release"]); + assert(c.subcommand == Subcommand.build); + assert(c.targets == ["release"]); + } + + // "build" as a target name (not first positional) + { + auto c = parseArgs(["antelope", "release", "build"]); + assert(c.subcommand == Subcommand.build); // default + assert(c.targets == ["release", "build"]); + } +} diff --git a/source/antelope/cli/help.d b/source/antelope/cli/help.d new file mode 100644 index 0000000..0caec4f --- /dev/null +++ b/source/antelope/cli/help.d @@ -0,0 +1,29 @@ +/// Help text display. +module antelope.cli.help; + +/// Print usage information. +void printHelp() +{ + import std.stdio; + writeln("Antelope — a GNU Make replacement and superset."); + writeln("Usage: antelope [options] [targets]"); + writeln(); + writeln("Subcommands:"); + writeln(" build Run the build (default)"); + writeln(" hunt Convert Makefile to Antefile (late-stage)"); + writeln(" configure Autotools configure.ac replacement (future)"); + writeln(); + writeln("Flags (common):"); + writeln(" -gnu Enable GNU Make compatibility mode"); + writeln(" -f Use as the build file"); + writeln(" -j Run jobs in parallel"); + writeln(" -C Change to before executing"); + writeln(" -n Dry run (print commands, don't execute)"); + writeln(" -d Enable debug output"); + writeln(" -P POSIX conformance mode"); + writeln(" --help Show this help"); + writeln(" --version Show version"); + writeln(); + writeln("Native mode (default): reads antefile or antelope (case-insensitive)"); + writeln("GNU mode (-gnu): reads GNUmakefile, Makefile, or makefile"); +} diff --git a/source/antelope/cli/subcommands.d b/source/antelope/cli/subcommands.d new file mode 100644 index 0000000..8165973 --- /dev/null +++ b/source/antelope/cli/subcommands.d @@ -0,0 +1,356 @@ +/// Subcommand dispatch — routes to the appropriate handler. +/// +/// Antelope's CLI pattern is: antelope --[flags] +/// The subcommand is the first positional argument; it defaults to `build`. +module antelope.cli.subcommands; + +import antelope.cli.args; +import antelope.cli.help; +import antelope.cli.verinfo; +import antelope.diagnostics.output; +import std.file : exists, readText; +import std.conv : to; + +/// Dispatch to the correct handler based on CliConfig.subcommand. +/// Returns an exit code (0 = success). +int dispatchSubcommand(CliConfig config) +{ + final switch (config.subcommand) + { + case Subcommand.build: + return runBuild(config); + case Subcommand.hunt: + return runHunt(config); + case Subcommand.configure: + return runConfigure(config); + } +} + +/// Execute the build (default subcommand). +/// +/// Full pipeline: find build file → parse → evaluate → schedule → execute. +int runBuild(CliConfig config) +{ + import antelope.parser.parser; + import antelope.parser.ast; + import antelope.evaluator.evaluator; + import antelope.evaluator.expansion; + import antelope.build.graph; + import antelope.build.target; + import antelope.build.dependency; + import antelope.build.scheduler; + import antelope.build.executor; + import antelope.shell.environment; + import antelope.filesystem.timestamps; + + // Set log level + if (config.debugMode) + setLogLevel(LogLevel.dbg); + + // Change to target directory (-C ) + if (config.directory.length > 0) + { + import std.file : chdir; + try { chdir(config.directory); } + catch (Exception e) + { + log(LogLevel.normal, "antelope: cannot chdir to " ~ config.directory ~ ": " ~ e.msg); + return 1; + } + } + + // Find build file + string buildFile = findBuildFile(config); + if (buildFile.length == 0) + { + if (config.gnuMode) + log(LogLevel.normal, "antelope: *** No targets specified and no makefile found. Stop."); + else + log(LogLevel.normal, "antelope: *** No build file found. Stop."); + return 1; + } + log(LogLevel.verbose, "Using build file: " ~ buildFile); + + // Read the build file + string content; + try + { + content = readText(buildFile); + } + catch (Exception e) + { + log(LogLevel.normal, "antelope: *** Cannot read build file: " ~ buildFile); + return 1; + } + + // Parse into AST + AstNode ast; + try + { + ast = parse(content); + } + catch (Exception e) + { + log(LogLevel.normal, "antelope: *** Parse error: " ~ e.msg); + return 1; + } + + // Setup environment with OS env + auto env = new Environment(); + { + import std.process : environment; + env.mergeEnv(environment.toAA()); + } + + // Set MAKE to the antelope binary path for $(MAKE) in recipes. + // Include -gnu so recursive sub-makes inherit GNU compat mode. + import std.file : thisExePath; + string makeCmd = thisExePath(); + if (config.gnuMode) makeCmd ~= " -gnu"; + if (config.file.length > 0) makeCmd ~= " -f " ~ config.file; + env.set("MAKE", makeCmd); + + // Set MAKECMDGOALS from command-line targets (autotools compat) + if (config.targets.length > 0) + { + import std.string : join; + env.set("MAKECMDGOALS", config.targets.join(" ")); + } + + // Evaluate AST → populate env + build graph + auto graph = new DependencyGraph(); + + // --- GNU Make compatibility (gnu_make.d) --- + import antelope.compatibility.gnu_make; + GnuMakeCompat gnuCompat; + if (config.gnuMode) + gnuCompat = GnuMakeCompat.withDefaults(); + + // --- POSIX conformance mode (posix_make.d) --- + import antelope.compatibility.posix_make; + PosixCompat posixCompat; + if (config.posix) + posixCompat.mode = PosixConformance.strict; + + try + { + evaluate(ast, env, graph, &gnuCompat, &posixCompat); + } + catch (Exception e) + { + log(LogLevel.normal, "antelope: *** Evaluation error: " ~ e.msg); + return 1; + } + + // Check for cycle errors + if (graph.cycleErrors.length > 0) + { + foreach (err; graph.cycleErrors) + log(LogLevel.normal, "antelope: *** " ~ err.message); + return 1; + } + + // In -gnu mode (and not POSIX strict), resolve implicit rules for + // targets without recipes. POSIX strict mode disables GNU extensions + // including implicit rule resolution. + // This fills in recipes from the built-in rule database (e.g. + // %.o: %.c and %: %.o) so that targets with no explicit recipe + // can still be built. Multiple passes handle rule chaining: + // "program" → link rule adds "program.o" → compile rule adds + // "program.c". Safety cap of 10 passes prevents infinite loops. + if (config.gnuMode && !config.posix) + { + import antelope.evaluator.evaluator : resolveImplicitRules; + const size_t maxPasses = 5; // deep enough for .l → .c → .s → .o chains + for (size_t pass = 0; pass < maxPasses; pass++) + { + auto resolved = resolveImplicitRules(*graph, env); + log(LogLevel.dbg, "Implicit rule pass " ~ + (pass + 1).to!string ~ ": " ~ resolved.to!string ~ + " target(s) resolved"); + if (resolved == 0) + break; + } + } + + if (graph.targets.length == 0) + { + log(LogLevel.verbose, "No targets defined."); + return 0; + } + + // Determine which targets to build + string[] buildTargets; + if (config.targets.length > 0) + buildTargets = config.targets; + else if (graph.hasTarget("all")) + buildTargets = ["all"]; + else + buildTargets = [graph.targets[0].name]; + + // .PHONY targets are tracked in the graph automatically via handlePhony() + + // --- VPATH configuration (GNU Make compat) --- + import antelope.compatibility.vpath; + VPathConfig vpath; + if (config.gnuMode && env.hasKey("VPATH")) + { + import std.string : split; + string vpathVal = env.get("VPATH"); + foreach (dir; vpathVal.split(":")) + { + if (dir.length > 0) + vpath.globalSearchDirs ~= dir; + } + } + // Also read pattern-scoped vpath entries stored by handleDirective + if (config.gnuMode) + { + import std.string : split, startsWith; + foreach (key; env.keys()) + { + if (key.length > 8 && key[0..8] == "__vpath_") + { + string pattern = key[8..$]; + string dirsStr = env.get(key); + VPathEntry entry; + entry.pattern = pattern; + foreach (dir; dirsStr.split(" ")) + if (dir.length > 0) entry.directories ~= dir; + if (entry.directories.length > 0) + vpath.patternEntries ~= entry; + } + } + } + + // Build each requested target + int exitCode = 0; + foreach (targetName; buildTargets) + { + if (!graph.hasTarget(targetName)) + { + log(LogLevel.normal, "antelope: *** No rule to make target '" ~ + targetName ~ "'. Stop."); + return 1; + } + + // Resolve dependencies and check what needs building + auto batches = resolveDependencies(*graph, targetName); + + import std.stdio; + if (targetName == "libgnu.a" || targetName == "all") { + stderr.writefln(" batches=%d", batches.length); + foreach (i, batch; batches) { + size_t w; + foreach (ref t; batch) if (t.recipe.length > 0) w++; + stderr.writefln(" batch[%d]: %d targets, %d with recipe", i, batch.length, w); + } + } + + bool builtSomething = false; + foreach (batch; batches) + { + foreach (ref t; batch) + { + if (!needsRebuild(t.name, t.prerequisites, + &graph.phonyTargets, &vpath, &t.orderOnlyPrereqs)) + continue; + + builtSomething = true; + + // Execute recipe lines + foreach (recipeLine; t.recipe) + { + // Expand variables in the recipe + string expanded = expand(recipeLine, env, t.name, + t.prerequisites); + + // Print the command unless silent (@ prefix) + import std.string : stripLeft; + string trimmed = recipeLine.stripLeft(); + if (config.dryRun || config.debugMode || recipeLine.length == 0 || + (trimmed.length > 0 && trimmed[0] != '@')) + { + log(LogLevel.normal, expanded); + } + + // Build environment for recipe execution + // (propagate SHELL from Makefile if set) + string[] execEnv; + if (env.hasKey("SHELL")) + execEnv ~= "SHELL=" ~ env.get("SHELL"); + + // Serialize MAKEFLAGS for recursive $(MAKE) calls + import antelope.compatibility.submake; + string makeFlags = serializeMakeFlags(config); + if (makeFlags.length > 0) + execEnv ~= "MAKEFLAGS=" ~ makeFlags; + + // Execute unless dry run + if (!config.dryRun) + { + auto result = execute(expanded, execEnv); + if (!result.success) + { + log(LogLevel.normal, "antelope: *** [" ~ t.name ~ + "] Error " ~ result.exitCode.to!string); + return result.exitCode; + } + } + } + } + } + + if (!builtSomething) + { + log(LogLevel.normal, "antelope: '" ~ targetName ~ + "' is up to date."); + } + } + + return exitCode; +} + +/// Find which build file to use based on mode and config. +private string findBuildFile(CliConfig config) +{ + // Explicitly specified file + if (config.file.length > 0) + { + if (exists(config.file)) + return config.file; + log(LogLevel.normal, "antelope: " ~ config.file ~ ": No such file"); + return ""; + } + + // GNU Make mode: GNUmakefile, Makefile, makefile + if (config.gnuMode) + { + if (exists("GNUmakefile")) return "GNUmakefile"; + if (exists("Makefile")) return "Makefile"; + if (exists("makefile")) return "makefile"; + return ""; + } + + // Native mode: antefile, antelope (case-insensitive) + if (exists("antefile")) return "antefile"; + if (exists("Antefile")) return "Antefile"; + if (exists("ANTEFILE")) return "ANTEFILE"; + if (exists("antelope")) return "antelope"; + if (exists("Antelope")) return "Antelope"; + return ""; +} + +/// Convert a Makefile to an Antefile (context-aware, late-stage feature). +int runHunt(CliConfig config) +{ + log(LogLevel.normal, "Antelope hunt — not yet implemented"); + return 0; +} + +/// Run autotools configure.ac replacement (future feature). +int runConfigure(CliConfig config) +{ + log(LogLevel.normal, "Antelope configure — not yet implemented"); + return 0; +} diff --git a/source/antelope/cli/version.d b/source/antelope/cli/version.d new file mode 100644 index 0000000..78fbdef --- /dev/null +++ b/source/antelope/cli/version.d @@ -0,0 +1,12 @@ +/// Version information. +module antelope.cli.verinfo; + +/// Package version string. +immutable string versionString = "0.1.0"; + +/// Print version and exit. +void printVersion() +{ + import std.stdio; + writeln("Antelope ", versionString); +} diff --git a/source/antelope/compatibility/automatic_vars.d b/source/antelope/compatibility/automatic_vars.d new file mode 100644 index 0000000..1890395 --- /dev/null +++ b/source/antelope/compatibility/automatic_vars.d @@ -0,0 +1,263 @@ +/// GNU Make automatic variables: $@, $<, $^, $+, $*, $?, $%, $|, and their variants. +/// +/// Automatic variables are set implicitly by GNU Make during rule execution. +/// This module defines them and controls when they are available, including +/// the directory/file-component variants ($(@D), $(@F), $( $@"], false), + ImplicitRule("Generate C source with Yacc", + ["", ""], "%.c", "%.y", + ["$(YACC) $(YFLAGS) $< && mv y.tab.c $@"], false), + ImplicitRule("Generate C header with Yacc", + ["", ""], "%.h", "%.y", + ["$(YACC) $(YFLAGS) -d $< && mv y.tab.h $@"], false), + + // --- Linker (C and C++) --- + ImplicitRule("Link C object file into executable", + ["", ""], "%", "%.o", + ["$(CC) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + ImplicitRule("Link C++ object (.cc) into executable", + ["", ""], "%", "%.cc", + ["$(CXX) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + ImplicitRule("Link C++ object (.cpp) into executable", + ["", ""], "%", "%.cpp", + ["$(CXX) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + ImplicitRule("Link C++ object (.C) into executable", + ["", ""], "%", "%.C", + ["$(CXX) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + ImplicitRule("Link Fortran object (.f) into executable", + ["", ""], "%", "%.f", + ["$(FC) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + ImplicitRule("Link Fortran object (.f90) into executable", + ["", ""], "%", "%.f90", + ["$(FC) $(LDFLAGS) $^ $(LOADLIBES) $(LDLIBS) -o $@"], false), + + // --- Preprocessing and assembly generation --- + ImplicitRule("Preprocess C source to output file", + ["", ""], "%.i", "%.c", + ["$(CC) $(CPPFLAGS) -E $< -o $@"], false), + ImplicitRule("Generate assembly from C source", + ["", ""], "%.s", "%.c", + ["$(CC) $(CPPFLAGS) $(CFLAGS) -S $< -o $@"], false), + ImplicitRule("Preprocess assembly source", + ["", ""], "%.s", "%.S", + ["$(CPP) $(CPPFLAGS) $< -o $@"], false), + + // --- Texinfo / TeX --- + ImplicitRule("Generate DVI from Texinfo source", + ["", ""], "%.dvi", "%.texinfo", + ["$(TEXI2DVI) $(TEXI2DVI_FLAGS) $<"], false), + ImplicitRule("Generate DVI from TeX source", + ["", ""], "%.dvi", "%.tex", + ["$(TEX) $<"], false), + + // --- CWEB --- + ImplicitRule("Generate C source from CWEB", + ["", ""], "%.c", "%.w", + ["$(CTANGLE) $< -o $@"], false), + ImplicitRule("Generate TeX source from CWEB", + ["", ""], "%.tex", "%.w", + ["$(CWEAVE) $< -o $@"], false), + + // --- SCCS --- + ImplicitRule("Get source file from SCCS", + ["", ""], "%", "s.%", + ["$(GET) $(GFLAGS) $<"], false), + ImplicitRule("Get source file from SCCS subdirectory", + ["", ""], "%", "SCCS/s.%", + ["$(GET) $(GFLAGS) $<"], false), + + // --- Archive --- + ImplicitRule("Update archive from object file", + ["", ""], "%.a", "%.o", + ["$(AR) $(ARFLAGS) $@ $^"], false), + + // --- Suffix rules (legacy equivalents) --- + ImplicitRule("Suffix rule: .c to .o", + ["c", "o"], "", "", + ["$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@"], true), + ImplicitRule("Suffix rule: .cc to .o", + ["cc", "o"], "", "", + ["$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@"], true), + ImplicitRule("Suffix rule: .f to .o", + ["f", "o"], "", "", + ["$(FC) $(FFLAGS) -c $< -o $@"], true), + ImplicitRule("Suffix rule: .s to .o", + ["s", "o"], "", "", + ["$(AS) $(ASFLAGS) $< -o $@"], true), + ImplicitRule("Suffix rule: .y to .c", + ["y", "c"], "", "", + ["$(YACC) $(YFLAGS) $< && mv y.tab.c $@"], true), + ImplicitRule("Suffix rule: .l to .c", + ["l", "c"], "", "", + ["$(LEX) $(LFLAGS) -t $< > $@"], true), + ]; +} + +/// Result of a successful implicit rule match. +struct ImplicitMatch +{ + ImplicitRule rule; + string stem; + string resolvedTarget; + string resolvedPrereq; +} + +/// Try to match an implicit rule for the given target. +/// +/// Iterates built-in rules in order, trying each pattern against the target. +/// The first matching pattern rule wins (matching GNU Make semantics). +/// Returns null if no rule matches. +ImplicitMatch* matchImplicitRule(string target) +{ + auto rules = builtinRules(); + + foreach (ref rule; rules) + { + string pattern = rule.targetPattern; + if (pattern.length == 0) + continue; + + // Split pattern on '%' — must have exactly one '%' (or two for edge cases) + size_t pct = pattern.indexOf('%'); + if (pct == size_t.max) + { + // No wildcard — exact match + if (target == pattern) + return createMatch(rule, target, "", ""); + continue; + } + + string prefix = pattern[0 .. pct]; + string suffix = pattern[pct + 1 .. $]; + + // Target must start with prefix and end with suffix + if (target.length < prefix.length + suffix.length) + continue; + if (target[0 .. prefix.length] != prefix) + continue; + if (target[target.length - suffix.length .. $] != suffix) + continue; + + // Extract the stem (the part matching %) + string stem = target[prefix.length .. target.length - suffix.length]; + + // Resolve prerequisite by substituting stem into prereqPattern + string prereqPattern = rule.prereqPattern; + string resolvedPrereq = substitutePercent(prereqPattern, stem); + + return createMatch(rule, target, stem, resolvedPrereq); + } + + return null; +} + +private ImplicitMatch* createMatch(ImplicitRule rule, string target, string stem, string resolvedPrereq) +{ + auto match = new ImplicitMatch(); + match.rule = rule; + match.stem = stem; + match.resolvedTarget = target; + match.resolvedPrereq = resolvedPrereq; + return match; +} + +/// Substitute the first '%' in pattern with replacement. +private string substitutePercent(string pattern, string replacement) +{ + size_t pct = pattern.indexOf('%'); + if (pct == size_t.max) + return pattern; + return pattern[0 .. pct] ~ replacement ~ pattern[pct + 1 .. $]; +} + +/// Convenience: get the recipe lines from a match result. +string[] recipeLines(ImplicitMatch* match) +{ + if (match is null) + return null; + return match.rule.recipe; +} diff --git a/source/antelope/compatibility/include_handling.d b/source/antelope/compatibility/include_handling.d new file mode 100644 index 0000000..fa014aa --- /dev/null +++ b/source/antelope/compatibility/include_handling.d @@ -0,0 +1,33 @@ +/// GNU Make include directive handling. +/// +/// GNU Make supports three forms of the include directive: +/// include filename — error if missing +/// -include filename — warn (not error) if missing +/// sinclude filename — same as -include (POSIX compat) +/// +/// Included files are read, parsed, and their rules merged into the current +/// makefile. If a missing included file can be rebuilt from an implicit rule, +/// GNU Make will rebuild it and restart. +module antelope.compatibility.include_handling; + +/// How a missing include file is handled. +enum IncludeFailureMode +{ + /// include — error out. + fatal, + /// -include / sinclude — warn and continue. + ignore, +} + +/// An include directive parsed from a Makefile. +struct IncludeDirective +{ + string[] files; + IncludeFailureMode onFailure; +} + +/// Try to resolve an include file (VPATH, implicit rule, restart). +string resolveInclude(string filename) +{ + return filename; +} diff --git a/source/antelope/compatibility/order_only.d b/source/antelope/compatibility/order_only.d new file mode 100644 index 0000000..b835b99 --- /dev/null +++ b/source/antelope/compatibility/order_only.d @@ -0,0 +1,34 @@ +/// GNU Make order-only prerequisites (|) — must exist but don't trigger rebuilds. +/// +/// GNU Make supports order-only prerequisites via the pipe (|) separator. +/// Prerequisites after the pipe must exist and be up-to-date before the +/// target is built, but they do NOT cause the target to be considered out +/// of date when they change. +/// +/// Antelope rule syntax: +/// target: prereqs... | order-only-prereqs... +module antelope.compatibility.order_only; + +/// A set of prerequisites split into normal and order-only. +struct PrereqSplit +{ + string[] normal; + string[] orderOnly; +} + +/// Split a flat prerequisite list at the first | separator. +PrereqSplit splitPrereqs(string[] allPrereqs) +{ + PrereqSplit result; + bool pastPipe; + foreach (p; allPrereqs) + { + if (p == "|") + pastPipe = true; + else if (pastPipe) + result.orderOnly ~= p; + else + result.normal ~= p; + } + return result; +} diff --git a/source/antelope/compatibility/parallel.d b/source/antelope/compatibility/parallel.d new file mode 100644 index 0000000..298df9b --- /dev/null +++ b/source/antelope/compatibility/parallel.d @@ -0,0 +1,30 @@ +/// GNU Make parallel execution semantics. +/// +/// GNU Make's -j flag and related features control parallel execution: +/// - .NOTPARALLEL — disable parallelism for specific targets +/// - .WAIT — wait for previous prerequisites before continuing +/// - .JOBS — job pool (GNU Make 4.4+) +/// - jobserver protocol — pipe-based job token passing +/// +/// This module defines the parallel execution model that Antelope uses +/// when compatibility with GNU Make's -j behavior is required. +module antelope.compatibility.parallel; + +/// Parallel execution special targets. +enum ParallelSpecialTarget +{ + notparallel, /// .NOTPARALLEL + wait, /// .WAIT + jobs, /// .JOBS +} + +/// Parallel execution configuration. +struct ParallelConfig +{ + /// Maximum parallel jobs (0 = unlimited, 1 = serial). + uint jobs = 1; + /// Targets excluded from parallel builds. + string[] notParallelTargets; + /// Whether to use jobserver protocol for sub-makes. + bool useJobserver = true; +} diff --git a/source/antelope/compatibility/pattern_rules.d b/source/antelope/compatibility/pattern_rules.d new file mode 100644 index 0000000..5237278 --- /dev/null +++ b/source/antelope/compatibility/pattern_rules.d @@ -0,0 +1,92 @@ +/// GNU Make pattern rules and static pattern rules. +/// +/// Pattern rules are GNU Make's primary mechanism for implicit and +/// static-pattern compilation rules. This module covers the matching +/// algorithm, stem extraction, order of precedence, and cancellation +/// (matching a rule with no recipe removes it). +module antelope.compatibility.pattern_rules; + +import antelope.build.target; +import std.string : indexOf; + +/// A pattern rule: %.o → %.c via recipe. +struct PatternRule +{ + string targetPattern; /// e.g. "%.o" + string[] prereqPatterns; /// e.g. ["%.c"] + string[] recipe; + bool terminal; /// true = cancels inherited rules +} + +/// Result of matching a pattern rule against a target. +struct PatternMatch +{ + PatternRule rule; + string stem; /// The matched wildcard portion. + string[] resolvedPrereqs; +} + +/// Find all pattern rules matching a target, ordered by GNU Make priority. +/// +/// Checks against user-defined pattern rules stored in the list. +/// Pattern: "%.o" matches "foo.o", extracting stem "foo". +PatternMatch[] matchPatternRules(string target, PatternRule[] userRules) +{ + PatternMatch[] matches; + foreach (ref rule; userRules) + { + if (rule.targetPattern.length == 0) + continue; + auto pct = indexOf(rule.targetPattern, '%'); + if (pct < 0) + continue; + string prefix = rule.targetPattern[0 .. pct]; + string suffix = rule.targetPattern[pct + 1 .. $]; + + // Must have at least one char for the stem + if (target.length < prefix.length + suffix.length + 1) + continue; + if (prefix.length > 0 && target[0 .. prefix.length] != prefix) + continue; + if (suffix.length > 0 && target[$ - suffix.length .. $] != suffix) + continue; + + // Extract stem and resolve prereqs + string stem = target[prefix.length .. target.length - suffix.length]; + PatternMatch m; + m.rule = rule; + m.stem = stem; + m.resolvedPrereqs = []; + foreach (pp; rule.prereqPatterns) + { + auto pct2 = indexOf(pp, '%'); + if (pct2 >= 0) + m.resolvedPrereqs ~= pp[0 .. pct2] ~ stem ~ pp[pct2 + 1 .. $]; + else + m.resolvedPrereqs ~= pp; + } + matches ~= m; + if (rule.terminal) + break; + } + return matches; +} + +/// Check if a target name contains a % wildcard (i.e., is a pattern rule). +bool isPatternTarget(string name) +{ + import std.string : indexOf; + return indexOf(name, '%') >= 0; +} + +/// Create a PatternRule from a Target that was declared as a pattern rule +/// (target name contains '%'). +PatternRule toPatternRule(Target t) +{ + PatternRule r; + r.targetPattern = t.name; + r.prereqPatterns = t.prerequisites; + r.recipe = t.recipe; + r.terminal = false; + return r; +} diff --git a/source/antelope/compatibility/posix_make.d b/source/antelope/compatibility/posix_make.d new file mode 100644 index 0000000..796542f --- /dev/null +++ b/source/antelope/compatibility/posix_make.d @@ -0,0 +1,37 @@ +/// GNU Make's POSIX conformance mode (.POSIX target, -P flag). +/// +/// GNU Make can run in POSIX-compatible mode, which disables certain GNU +/// extensions to conform to POSIX.1-2024. This module tracks which features +/// are affected and manages the conformance flags. +module antelope.compatibility.posix_make; + +/// POSIX conformance level in GNU Make. +enum PosixConformance +{ + /// Default GNU Make behavior — full extensions enabled. + gnu_mode, + /// .POSIX target declared — disable conflicting extensions. + posix_target, + /// -P / --posix flag — strict POSIX mode. + strict, +} + +/// GNU Make features affected by POSIX conformance mode. +enum PosixAffectedFeature +{ + /// $(shell ...) error handling differs. + shellErrorHandling, + /// The $$@ variable works differently in prerequisites. + automaticVarInPrereqs, + /// Order of pattern rule matching differs. + patternRuleOrder, + /// Archive member syntax behaviour. + archiveMembers, +} + +/// Track which features are restricted in the current conformance mode. +struct PosixCompat +{ + PosixConformance mode = PosixConformance.gnu_mode; + PosixAffectedFeature[] restrictedFeatures; +} diff --git a/source/antelope/compatibility/quirks.d b/source/antelope/compatibility/quirks.d new file mode 100644 index 0000000..77cd302 --- /dev/null +++ b/source/antelope/compatibility/quirks.d @@ -0,0 +1,90 @@ +/// Known GNU Make behavioral quirks and edge cases that Antelope must replicate. +/// +/// GNU Make has accumulated decades of subtle behaviors that existing +/// Makefiles may depend on. This module catalogs them so Antelope can +/// emulate them when compatibility mode is active. +module antelope.compatibility.quirks; + +/// A single GNU Make quirk Antelope can emulate. +struct GnuQuirk +{ + string name; + string description; + uint introducedVersion; + bool enabledByDefault; +} + +/// All known GNU Make quirks. +GnuQuirk[] knownQuirks() +{ + return [ + GnuQuirk("Suspended line continuation in comments", + "Backslash-newline inside comments still joins lines.", + 0, true), + GnuQuirk("Unescaped # in recipe lines", + "A # in a recipe line starts a comment, but only after expansion.", + 0, true), + GnuQuirk("Variable assignment with trailing semicolon", + "foo=bar; is silently accepted as assignment.", 0, true), + GnuQuirk("Empty prerequisite list with pipe", + "target: | with no regular prereqs is allowed.", 0, true), + GnuQuirk("Automatic variable propagation", + "$(@D) and $(@F) work even when $@ is empty.", 0, true), + GnuQuirk("Recursive variable assignment on command line", + "Command-line overrides apply recursively.", 0, true), + GnuQuirk("Secondary expansion of .EXTRA_PREREQS", + ".EXTRA_PREREQS undergoes secondary expansion.", 0, true), + + GnuQuirk("Backslash-newline in variable values", + "A backslash followed by a newline inside a variable value is " + ~ "consumed, joining the lines into one.", + 0, true), + GnuQuirk("Trailing whitespace in = assignments", + "In a recursive variable assignment, trailing whitespace after " + ~ "the value is preserved verbatim.", + 0, true), + GnuQuirk("Empty recipe lines", + "A recipe line consisting of only a tab is a valid no-op.", + 0, true), + GnuQuirk("Double-colon rules", + "A double-colon rule (target:: prereqs) allows multiple " + ~ "independent recipes for the same target.", + 0, true), + GnuQuirk("Pattern rule matching order", + "Match pattern rules in declaration order; the first matching " + ~ "rule wins, including built-in rules.", + 0, true), + GnuQuirk("MAKEFLAGS propagation", + "MAKEFLAGS contains condensed flag letters (e.g. 'n', 'd') " + ~ "and is automatically passed to sub-make invocations.", + 0, true), + GnuQuirk("export/unexport timing", + "The export directive takes effect immediately at parse time, " + ~ "not deferred to recipe execution time.", + 0, true), + GnuQuirk("Comment inside variable value after expansion", + "When a variable expands to a value containing #, that # " + ~ "starts a comment in recipe context.", + 0, true), + GnuQuirk("Nested include handling", + "Files included via include can themselves contain include " + ~ "directives, recursively.", + 0, true), + GnuQuirk("Target-specific variable override order", + "Override precedence: command-line > target-specific > " + ~ "pattern-specific > global variable.", + 0, true), + GnuQuirk(".ONESHELL behavior", + "With .ONESHELL, all recipe lines for a rule run in a single " + ~ "shell invocation, not one per line.", + 400, true), + GnuQuirk("Archive member syntax with ()", + "Archive members are referenced as libfoo.a(member.o) and " + ~ "support implicit rules for extraction.", + 0, true), + GnuQuirk("Automatic variables in prerequisite lists with .SECONDEXPANSION", + "Under secondary expansion, automatic variables ($@, $<, etc.) " + ~ "are available in prerequisite lists.", + 381, true), + ]; +} diff --git a/source/antelope/compatibility/secondary_expansion.d b/source/antelope/compatibility/secondary_expansion.d new file mode 100644 index 0000000..d260c17 --- /dev/null +++ b/source/antelope/compatibility/secondary_expansion.d @@ -0,0 +1,54 @@ +/// GNU Make secondary expansion (.SECONDEXPANSION). +/// +/// When .SECONDEXPANSION is declared as a target, GNU Make performs a +/// second expansion pass on the prerequisite list of all (or specified) +/// targets. This allows automatic variables ($@, $<, etc.) in prerequisite +/// lists, enabling patterns like: +/// +/// .SECONDEXPANSION: +/// main.o: $$(patsubst %.c,%.o,$$@) +/// +/// In the first pass, $$ becomes $; in the second pass, the result is +/// expanded with automatic variables set. +module antelope.compatibility.secondary_expansion; + +/// Secondary expansion state. +struct SecondaryExpansion +{ + /// Whether .SECONDEXPANSION is active. + bool enabled; + /// If non-empty, only these targets get secondary expansion. + string[] targetWhitelist; +} + +/// Perform secondary expansion on a prerequisite list. +/// +/// When .SECONDEXPANSION is active, prerequisite lists undergo a second +/// expansion pass. In the first pass, `$$` becomes `$`. In the second pass, +/// the result is expanded with automatic variables set (since the target +/// is now known). +/// +/// This function handles the second pass: each prerequisite string is +/// expanded with the current target context. +string[] expandSecondPass(string target, string[] prereqs, + void* expandFn = null, void* env = null) +{ + // For now, just return prereqs as-is. Full implementation requires + // access to the expansion engine with automatic variable context. + // The expansion engine is called during recipe execution anyway. + return prereqs; +} + +/// Check if secondary expansion should be applied for a target. +/// Returns true if .SECONDEXPANSION is active and the target +/// is in the whitelist (or whitelist is empty = all targets). +bool shouldExpand(string target, SecondaryExpansion sec) +{ + if (!sec.enabled) + return false; + if (sec.targetWhitelist.length == 0) + return true; + foreach (t; sec.targetWhitelist) + if (t == target) return true; + return false; +} diff --git a/source/antelope/compatibility/submake.d b/source/antelope/compatibility/submake.d new file mode 100644 index 0000000..b2dd673 --- /dev/null +++ b/source/antelope/compatibility/submake.d @@ -0,0 +1,57 @@ +/// GNU Make sub-make and recursive make communication. +/// +/// GNU Make has specific protocols for communicating between parent and +/// child make processes: MAKEFLAGS, MAKE, variable export/unexport, +/// and the jobserver (--jobserver-style pipe). +/// +/// This module handles: +/// - $(MAKE) / $(MAKECMDGOALS) propagation +/// - MAKEFLAGS / GNUMAKEFLAGS serialization +/// - Variable export to sub-makes +/// - Jobserver pipe inheritance (--jobserver-style) +module antelope.compatibility.submake; + +import antelope.cli.args; +import std.conv : to; +import std.string : strip; +import std.string : strip; + +/// Sub-make communication options. +struct SubMakeConfig +{ + /// Serialize and pass MAKEFLAGS. + bool passMakeFlags = true; + /// Pass jobserver file descriptors. + bool passJobserver = true; + /// Export variables marked with `export`. + bool respectExportDirective = true; + /// Track recursion depth to avoid infinite loops. + uint maxRecursionDepth = 10; +} + +/// Serialize MAKEFLAGS for a sub-make invocation. +/// +/// Builds a space-separated string of GNU Make-compatible flags from the +/// current CLI configuration. The resulting string can be set as the +/// MAKEFLAGS environment variable for recursive $(MAKE) invocations. +/// +/// Serialized flags: +/// -j — parallel job count (only when jobs > 1) +/// -n — dry run +/// -P — POSIX conformance mode +/// -d — debug output +/// +/// Returns: a space-delimited MAKEFLAGS string, or "" if no flags are active. +string serializeMakeFlags(CliConfig config) +{ + string flags; + if (config.jobs > 1) + flags ~= " -j" ~ config.jobs.to!string; + if (config.dryRun) + flags ~= " -n"; + if (config.posix) + flags ~= " -P"; + if (config.debugMode) + flags ~= " -d"; + return flags.strip; +} diff --git a/source/antelope/compatibility/target_vars.d b/source/antelope/compatibility/target_vars.d new file mode 100644 index 0000000..c9c5eb0 --- /dev/null +++ b/source/antelope/compatibility/target_vars.d @@ -0,0 +1,36 @@ +/// GNU Make target-specific and pattern-specific variable assignments. +/// +/// GNU Make allows variable values to be scoped to specific targets or +/// patterns, overriding global values only when building that target: +/// target: VAR = value +/// target: VAR := value +/// target: VAR ::= value +/// target: VAR += value +/// target: VAR ?= value +/// %: VAR = value (pattern-specific) +module antelope.compatibility.target_vars; + +/// The scope kind of a variable override. +enum TargetVarScope +{ + /// target: VAR = value + targetSpecific, + /// pattern: VAR = value + patternSpecific, +} + +/// A target-specific or pattern-specific variable assignment. +struct ScopedVariable +{ + string targetPattern; + string name; + string value; + bool recursive; /// true for =, false for := + TargetVarScope varScope; +} + +/// Look up a variable respecting target-scoped overrides. +string lookupScopedVar(string target, string varName) +{ + return ""; +} diff --git a/source/antelope/compatibility/vpath.d b/source/antelope/compatibility/vpath.d new file mode 100644 index 0000000..b70140d --- /dev/null +++ b/source/antelope/compatibility/vpath.d @@ -0,0 +1,106 @@ +/// GNU Make VPATH / vpath directive — directory search path for prerequisites. +/// +/// GNU Make supports two mechanisms for searching directories: +/// - VPATH variable: search path for all prerequisites +/// - vpath directive: search path scoped by pattern (e.g. vpath %.h src/) +/// +/// This module handles both the simple variable-based and the pattern-scoped +/// search, plus the interaction with implicit rule chaining. +module antelope.compatibility.vpath; + +/// A single vpath pattern → directory mapping. +struct VPathEntry +{ + string pattern; /// e.g. "%.h" — empty for VPATH variable entries. + string[] directories; +} + +/// Complete VPATH configuration. +struct VPathConfig +{ + /// From VPATH = dir1:dir2 + string[] globalSearchDirs; + /// From vpath %.h src/ + VPathEntry[] patternEntries; +} + +/// Search for a file across all VPATH directories. +/// +/// Resolution order (matching GNU Make): +/// 1. Check if file exists locally → return as-is +/// 2. Check pattern-specific vpath entries (if filename matches pattern) +/// 3. Check global VPATH search dirs +/// 4. Return original filename if not found anywhere +/// +/// Params: +/// filename = The prerequisite file to search for +/// config = VPATH configuration (global dirs + pattern entries) +/// +/// Returns: The resolved path if found in a search directory, or the +/// original filename if not found anywhere. +string vpathResolve(string filename, const(VPathConfig) config) +{ + import std.file : exists; + import std.path : buildPath; + + // Local file exists → use it directly + if (exists(filename)) + return filename; + + // Check pattern-specific entries + foreach (entry; config.patternEntries) + { + if (entry.pattern.length == 0) + continue; + // Simple pattern matching: if pattern is "%.h", check if filename + // ends with ".h" (after the %) + if (patternMatches(filename, entry.pattern)) + { + foreach (dir; entry.directories) + { + string candidate = buildPath(dir, filename); + if (exists(candidate)) + return candidate; + } + } + } + + // Check global search dirs + foreach (dir; config.globalSearchDirs) + { + if (dir.length == 0) + continue; + string candidate = buildPath(dir, filename); + if (exists(candidate)) + return candidate; + } + + // Not found — return original + return filename; +} + +/// Simple pattern match: "%.h" matches "foo.h", "bar/baz.h", etc. +/// Splits pattern on '%' into prefix and suffix; checks if filename +/// starts with prefix and ends with suffix, with at least one char in between. +private bool patternMatches(string filename, string pattern) +{ + import std.string : indexOf; + auto pct = indexOf(pattern, '%'); + if (pct < 0) + return filename == pattern; + + string prefix = pattern[0 .. pct]; + string suffix = pattern[pct + 1 .. $]; + + // Must be at least one char between prefix and suffix + if (filename.length < prefix.length + suffix.length + 1) + return false; + + if (prefix.length > 0 && filename[0 .. prefix.length] != prefix) + return false; + + if (suffix.length > 0 && filename[$ - suffix.length .. $] != suffix) + return false; + + return true; +} diff --git a/source/antelope/diagnostics/errors.d b/source/antelope/diagnostics/errors.d new file mode 100644 index 0000000..f8f51da --- /dev/null +++ b/source/antelope/diagnostics/errors.d @@ -0,0 +1,24 @@ +/// Error types and structured error reporting. +module antelope.diagnostics.errors; + +/// Categories of errors Antelope can produce. +enum ErrorKind +{ + parseError, + undefinedVariable, + cyclicDependency, + missingTarget, + commandFailed, + fileNotFound, + internalError, +} + +/// A structured error with location information. +struct AntelopeError +{ + ErrorKind kind; + string message; + string file; + size_t line; + size_t column; +} diff --git a/source/antelope/diagnostics/output.d b/source/antelope/diagnostics/output.d new file mode 100644 index 0000000..e803bbd --- /dev/null +++ b/source/antelope/diagnostics/output.d @@ -0,0 +1,100 @@ +/// Output formatting — colored terminal output, logging levels, and verbosity. +module antelope.diagnostics.output; + +import std.stdio; +import core.sys.posix.unistd : isatty; + +/// Log verbosity level. +enum LogLevel +{ + quiet, + normal, + verbose, + dbg, +} + +/// Current global log level threshold. +/// Messages below this level are suppressed. +LogLevel currentLogLevel = LogLevel.normal; + +/// Set the current log level threshold. +/// Params: level = new verbosity threshold. +void setLogLevel(LogLevel level) +{ + currentLogLevel = level; +} + +/// Write a log message at the given level. +/// +/// Filters by `currentLogLevel`: messages below the threshold are suppressed. +/// When stdout is a TTY, the output is colored: +/// - `normal` → bold +/// - `verbose` → plain +/// - `dbg` → dim/grey +/// +/// Params: +/// level = severity of this message +/// message = the text to write +void log(LogLevel level, string message) +{ + // Quiet mode suppresses everything. + if (currentLogLevel == LogLevel.quiet) + return; + + // Suppress messages below the threshold (unless in debug mode). + if (currentLogLevel != LogLevel.dbg && level < currentLogLevel) + return; + + // Plain output when not a terminal. + if (isatty(stdout.fileno) == 0) + { + writeln(message); + return; + } + + // Colored output on TTY. + final switch (level) + { + case LogLevel.quiet: + // Quiet messages are filtered above; this is unreachable. + return; + case LogLevel.normal: + writefln("\x1b[1m%s\x1b[22m", message); + break; + case LogLevel.verbose: + writeln(message); + break; + case LogLevel.dbg: + writefln("\x1b[2m%s\x1b[22m", message); + break; + } +} + +// --- Tests --- + +unittest +{ + import std.exception : assertNotThrown; + + // Default level is normal. + assert(currentLogLevel == LogLevel.normal); + + // setLogLevel round-trip. + setLogLevel(LogLevel.quiet); + assert(currentLogLevel == LogLevel.quiet); + setLogLevel(LogLevel.normal); + assert(currentLogLevel == LogLevel.normal); + + // All log levels can be called without throwing. + assertNotThrown!Exception(log(LogLevel.normal, "normal test message")); + assertNotThrown!Exception(log(LogLevel.verbose, "verbose test message")); + assertNotThrown!Exception(log(LogLevel.dbg, "dbg test message")); + + // Quiet suppresses everything — no crash, no output. + setLogLevel(LogLevel.quiet); + assertNotThrown!Exception(log(LogLevel.normal, "should be suppressed")); + assertNotThrown!Exception(log(LogLevel.dbg, "should be suppressed")); + + // Restore default for subsequent tests. + setLogLevel(LogLevel.normal); +} diff --git a/source/antelope/diagnostics/warnings.d b/source/antelope/diagnostics/warnings.d new file mode 100644 index 0000000..10d518a --- /dev/null +++ b/source/antelope/diagnostics/warnings.d @@ -0,0 +1,76 @@ +/// Warning diagnostics for non-fatal issues. +module antelope.diagnostics.warnings; + +import std.stdio; + +/// Categories of warnings. +enum WarningKind +{ + deprecatedFeature, + undefinedVariable, + orderOnlyCircular, + phonyPrerequisite, + overrideConflict, +} + +/// Whether warnings are currently enabled. Default: true. +__gshared bool warningsEnabled = true; + +/// Enable or disable warnings globally. +/// Params: enabled = set to false to suppress all warning output. +void setWarningsEnabled(bool enabled) +{ + warningsEnabled = enabled; +} + +/// Map a WarningKind to a human-readable string. +private string warningKindString(WarningKind kind) +{ + final switch (kind) + { + case WarningKind.deprecatedFeature: return "deprecated-feature"; + case WarningKind.undefinedVariable: return "undefined-variable"; + case WarningKind.orderOnlyCircular: return "order-only-circular"; + case WarningKind.phonyPrerequisite: return "phony-prerequisite"; + case WarningKind.overrideConflict: return "override-conflict"; + } +} + +/// Issue a warning with location. +/// Writes to stderr in the format: antelope: warning: at : +/// Params: +/// kind = the category of warning +/// message = descriptive message for the user +/// file = source file where the warning originates (default: __FILE__) +/// line = source line where the warning originates (default: __LINE__) +void warn(WarningKind kind, string message, string file = __FILE__, size_t line = __LINE__) +{ + if (!warningsEnabled) + return; + + stderr.writeln("antelope: warning: ", warningKindString(kind), + " ", message, " at ", file, ":", line); +} + +// --- Tests --- + +unittest +{ + // Verify warningsEnabled guard works correctly. + auto oldEnabled = warningsEnabled; + + // With warnings disabled, nothing should happen — just verify no crash. + warningsEnabled = false; + warn(WarningKind.deprecatedFeature, "should not appear", "test.d", 1); + + // Re-enable and verify a basic call doesn't throw. + warningsEnabled = true; + warn(WarningKind.deprecatedFeature, "test warning", "test.d", 42); + warn(WarningKind.undefinedVariable, "undefined var", "foo.d", 10); + warn(WarningKind.orderOnlyCircular, "circular dep", "bar.d", 5); + warn(WarningKind.phonyPrerequisite, "phony prereq", "baz.d", 7); + warn(WarningKind.overrideConflict, "override conflict", "qux.d", 3); + + // Restore original state. + warningsEnabled = oldEnabled; +} diff --git a/source/antelope/evaluator/conditionals.d b/source/antelope/evaluator/conditionals.d new file mode 100644 index 0000000..381631a --- /dev/null +++ b/source/antelope/evaluator/conditionals.d @@ -0,0 +1,205 @@ +/// Conditional evaluation (ifeq/ifneq/ifdef/ifndef). +/// +/// Evaluates GNU Make conditional expressions at parse/evaluate time. +/// Supports three syntactic forms: +/// - Parenthesized: `ifeq (a, b)` +/// - Quoted: `ifeq "a" "b"` +/// - Bare: `ifdef VAR_NAME` +module antelope.evaluator.conditionals; + +import antelope.shell.environment; +import std.string : strip, indexOf; +import std.ascii : isWhite; + +/// Evaluate a GNU Make conditional expression. +/// +/// Params: +/// op = conditional operator: "ifeq", "ifneq", "ifdef", or "ifndef" +/// lhs = left-hand argument (for paren form `ifeq (a, b)`, +/// lhs contains `(a, b)` and rhs is empty) +/// rhs = right-hand argument (empty for ifdef/ifndef and paren form) +/// env = optional Environment pointer for ifdef/ifndef variable lookups +/// +/// Returns: true if the condition holds, false otherwise. +bool evaluateConditional(string op, string lhs, string rhs, + Environment* env = null) +{ + switch (op) + { + case "ifeq": + { + string a, b; + splitArgs(lhs, rhs, a, b); + // Expand variable references before comparison (GNU Make behavior) + if (env !is null) + { + import antelope.evaluator.expansion; + a = expand(a, env); + b = expand(b, env); + } + return a == b; + } + + case "ifneq": + { + string a, b; + splitArgs(lhs, rhs, a, b); + if (env !is null) + { + import antelope.evaluator.expansion; + a = expand(a, env); + b = expand(b, env); + } + return a != b; + } + + case "ifdef": + { + auto key = stripQuotes(lhs.strip); + return (env !is null) && env.hasKey(key); + } + + case "ifndef": + { + auto key = stripQuotes(lhs.strip); + return (env is null) || !env.hasKey(key); + } + + default: + return false; + } +} + +/// Split conditional arguments handling both parenthesized and +/// two-argument forms. +/// +/// **Parenthesized form:** `ifeq (a, b)` +/// - `lhs` = `(a, b)`, `rhs` = `""` +/// - Strips parens, splits on the first comma, strips whitespace +/// and surrounding quotes from each part. +/// +/// **Two-argument form:** `ifeq "a" "b"` or `ifeq a b` +/// - Both `lhs` and `rhs` are provided. +/// - Strips whitespace and surrounding quotes from each. +private void splitArgs(string lhs, string rhs, out string a, + out string b) +{ + // Paren form: ifeq (a, b) + if (lhs.length > 0 && lhs[0] == '(') + { + auto inner = lhs[1 .. $].strip; + // Strip closing paren + if (inner.length > 0 && inner[$ - 1] == ')') + inner = inner[0 .. $ - 1].strip; + + auto comma = indexOf(inner, ','); + if (comma >= 0) + { + a = stripQuotes(inner[0 .. comma].strip); + b = stripQuotes(inner[comma + 1 .. $].strip); + } + else + { + // No comma — entire inner is a (malformed but handle gracefully) + a = stripQuotes(inner.strip); + b = ""; + } + } + // Two-argument form: ifeq "a" "b" or ifeq a b + else + { + a = stripQuotes(lhs.strip); + b = stripQuotes(rhs.strip); + } +} + +/// Strip matching quote characters (" or ') from both ends of a string. +private string stripQuotes(string s) +{ + if (s.length >= 2) + { + if ((s[0] == '"' && s[$ - 1] == '"') + || (s[0] == '\'' && s[$ - 1] == '\'')) + return s[1 .. $ - 1]; + } + return s; +} + +// --------------------------------------------------------------------------- +// Unittests +// --------------------------------------------------------------------------- + +unittest +{ + import std.stdio : writeln; + + // --- ifeq simple --- + assert(evaluateConditional("ifeq", "a", "a"), + "ifeq: 'a' == 'a' should be true"); + assert(!evaluateConditional("ifeq", "a", "b"), + "ifeq: 'a' == 'b' should be false"); + + // --- ifeq with double quotes --- + assert(evaluateConditional("ifeq", `"a"`, `"a"`), + "ifeq: '\"a\"' == '\"a\"' should be true"); + assert(!evaluateConditional("ifeq", `"a"`, `"b"`), + "ifeq: '\"a\"' == '\"b\"' should be false"); + + // --- ifeq with single quotes --- + assert(evaluateConditional("ifeq", "'a'", "'a'"), + "ifeq: single-quoted equal should be true"); + + // --- ifneq --- + assert(evaluateConditional("ifneq", "a", "b"), + "ifneq: 'a' != 'b' should be true"); + assert(!evaluateConditional("ifneq", "a", "a"), + "ifneq: 'a' != 'a' should be false"); + + // --- Paren form: ifeq (a, b) --- + assert(evaluateConditional("ifeq", "(a, a)", ""), + "ifeq paren form: (a, a) should be true"); + assert(!evaluateConditional("ifeq", "(a, b)", ""), + "ifeq paren form: (a, b) should be false"); + + // --- Paren form with whitespace --- + assert(evaluateConditional("ifeq", "( a , a )", ""), + "ifeq paren form with whitespace should be true"); + + // --- Quoted form with whitespace --- + assert(evaluateConditional("ifeq", `" a "`, `" a "`), + "ifeq quoted with internal whitespace should be true"); + + // --- ifdef / ifndef with Environment --- + Environment env; + env.set("EXISTING_VAR", "value"); + env.set("ANOTHER_VAR", "42"); + + assert(evaluateConditional("ifdef", "EXISTING_VAR", "", &env), + "ifdef: EXISTING_VAR exists should be true"); + assert(!evaluateConditional("ifdef", "NONEXISTENT_VAR", "", &env), + "ifdef: NONEXISTENT_VAR missing should be false"); + assert(!evaluateConditional("ifndef", "EXISTING_VAR", "", &env), + "ifndef: EXISTING_VAR exists should be false"); + assert(evaluateConditional("ifndef", "NONEXISTENT_VAR", "", &env), + "ifndef: NONEXISTENT_VAR missing should be true"); + + // --- ifdef with null env (no environment passed) --- + assert(!evaluateConditional("ifdef", "ANYTHING", ""), + "ifdef: null env should return false"); + assert(evaluateConditional("ifndef", "ANYTHING", ""), + "ifndef: null env should return true"); + + // --- Mixed: ifneq with paren form --- + assert(evaluateConditional("ifneq", "(a, b)", ""), + "ifneq paren form: (a, b) should be true"); + assert(!evaluateConditional("ifneq", "(a, a)", ""), + "ifneq paren form: (a, a) should be false"); + + // --- Empty strings --- + assert(evaluateConditional("ifeq", "", ""), + "ifeq: empty == empty should be true"); + assert(!evaluateConditional("ifeq", "", "x"), + "ifeq: empty != x should be false"); + + writeln("All conditional evaluation tests passed."); +} diff --git a/source/antelope/evaluator/evaluator.d b/source/antelope/evaluator/evaluator.d new file mode 100644 index 0000000..e755cc7 --- /dev/null +++ b/source/antelope/evaluator/evaluator.d @@ -0,0 +1,1498 @@ +/// Top-level evaluator that walks the AST and drives execution. +/// +/// The evaluator is the central dispatch between the parser and the build +/// engine. It walks every node in the parsed AST and: +/// * Adds rules as targets to the dependency graph (via `build.graph`) +/// * Populates the variable environment (via `shell.environment`) +/// * Evaluates directives — includes, conditionals, VPATH configuration +/// +/// This is called by `runBuild()` after parsing to populate the graph and +/// environment before scheduling and executing the build. +module antelope.evaluator.evaluator; + +import antelope.parser.ast; +import antelope.evaluator.expansion; +import antelope.evaluator.conditionals; +import antelope.evaluator.functions; +import antelope.build.graph; +import antelope.shell.environment; +import antelope.build.target; +import antelope.compatibility.implicit_rules; +import antelope.compatibility.order_only; +import antelope.compatibility.pattern_rules; +import antelope.compatibility.gnu_make; +import antelope.compatibility.posix_make; +import antelope.diagnostics.output; +import std.string : indexOf, strip; +import std.conv : to; +import std.file : exists, readText; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Evaluate a parsed AST, populating the environment and dependency graph. +/// +/// Walks the AST recursively, dispatching each node type to the appropriate +/// handler. Rules become targets in the dependency graph. Variable +/// assignments populate the environment. Directives trigger include / +/// conditional / VPATH behaviour. +/// +/// Params: +/// root = the AST root (usually a `rule_list` node) +/// env = pointer to the variable environment (may be null for +/// read-only scanning) +/// graph = pointer to the dependency graph to populate +void evaluate(AstNode root, Environment* env, DependencyGraph* graph, + GnuMakeCompat* gnuCompat = null, + PosixCompat* posixCompat = null) +{ + foreach (child; root.children) + { + final switch (child.type) + { + case AstType.rule_list: + evaluate(child, env, graph, gnuCompat, posixCompat); // recurse into nested rule lists + break; + case AstType.rule: + handleRule(child, env, graph); + break; + case AstType.variable_assignment: + handleVariableAssignment(child, env); + break; + case AstType.directive: + handleDirective(child, env, graph, gnuCompat, posixCompat); + break; + case AstType.prerequisite: + case AstType.recipe_line: + case AstType.function_call: + // These are leaf / child nodes — they are processed by their + // parent (e.g. a rule node collects its own prereqs and recipes). + break; + } + } +} + +// --------------------------------------------------------------------------- +// Private handlers +// --------------------------------------------------------------------------- + +/// Resolve implicit rules for all targets in the graph that have no recipe. +/// +/// In GNU Make mode (`-gnu`), targets without an explicit recipe are tried +/// against the built-in implicit rule database (e.g. `%.o: %.c`). When a +/// rule matches, the target's recipe and prerequisites are populated from +/// the rule. Any prerequisite that does not yet exist in the graph is added +/// as a stub target so it can itself be resolved in a subsequent pass. +/// +/// This function should be called after `evaluate()` has populated the graph +/// but before dependency resolution, and only when `-gnu` mode is active. +/// +/// Params: +/// graph = the populated dependency graph (mutated in place) +/// +/// Returns: the number of targets that had their recipe resolved in this pass. +/// A return value of 0 means no more implicit rules can be applied. +size_t resolveImplicitRules(ref DependencyGraph graph, Environment* env = null) +{ + import antelope.compatibility.implicit_rules; + size_t resolved; + + // Work on a copy of the target names so mutations (adding new stub + // targets via addTarget) don't skew the iteration. + string[] snapshot; + foreach (ref t; graph.targets) + snapshot ~= t.name; + + foreach (targetName; snapshot) + { + auto tp = graph.findTarget(targetName); + if (tp is null) + continue; + + // Only fill in targets that have no recipe yet. + if (tp.recipe.length > 0) + continue; + + // Skip phony targets — they never get implicit recipes. + if (tp.kind == TargetKind.phony) + continue; + + // Try to match a built-in implicit rule first. + auto match = matchImplicitRule(tp.name); + bool applied; + + // If built-in matched and satisfiable, apply it + if (match !is null) + { + import std.file : exists; + if (prereqSatisfiable(match.resolvedPrereq, graph)) + { + tp.recipe = match.rule.recipe; + if (match.resolvedPrereq.length > 0) + { + bool alreadyPresent; + foreach (p; tp.prerequisites) + if (p == match.resolvedPrereq) { alreadyPresent = true; break; } + if (!alreadyPresent) + { + string[] np = [match.resolvedPrereq]; + np ~= tp.prerequisites; + tp.prerequisites = np; + } + if (!graph.hasTarget(match.resolvedPrereq)) + { + import std.file : exists; + if (!exists(match.resolvedPrereq)) + { + Target stub; + stub.name = match.resolvedPrereq; + stub.kind = TargetKind.file; + graph.addTarget(stub); + } + } + } + resolved++; + applied = true; + } + } + + // If built-in didn't match or wasn't satisfiable, try user-defined pattern rules + if (!applied) + { + import antelope.compatibility.pattern_rules; + PatternRule[] userRules; + foreach (ref gt; graph.targets) + if (isPatternTarget(gt.name)) + userRules ~= toPatternRule(gt); + auto userMatches = matchPatternRules(tp.name, userRules); + // Use the LAST matching pattern rule (most recently defined, + // which is typically the most specific/default one like %.c) + if (userMatches.length > 0) + { + auto um = userMatches[$ - 1]; + resolveUserPatternRule(tp, um, graph, env); + resolved++; + applied = true; + } + } + + } + + return resolved; +} + +/// Apply a matched user-defined pattern rule to a target. +/// +/// Sets the target's recipe from the matched pattern rule and adds the +/// resolved prerequisites (e.g., for a `%.o: %.c` rule matching `foo.o`, +/// sets recipe to the rule's recipe and adds `foo.c` as a prerequisite). +/// Any prerequisite not already in the graph is added as a stub target +/// so it can be resolved in a subsequent pass. +private void resolveUserPatternRule(Target* tp, PatternMatch match, + ref DependencyGraph graph, Environment* env) +{ + tp.recipe = match.rule.recipe; + // Clear existing prereqs and set from pattern match + tp.prerequisites = []; + foreach (prereq; match.resolvedPrereqs) + { + // Already resolved by matchPatternRules + bool alreadyPresent; + foreach (p; tp.prerequisites) + { + if (p == prereq) + { + alreadyPresent = true; + break; + } + } + if (!alreadyPresent) + tp.prerequisites ~= prereq; + + if (!graph.hasTarget(prereq)) + { + import std.file : exists; + if (!exists(prereq)) + { + Target stub; + stub.name = prereq; + stub.kind = TargetKind.file; + graph.addTarget(stub); + } + } + } +} + +/// Check whether an implicit rule prerequisite chain is satisfiable. +/// Recursively checks if the prerequisite eventually resolves to a file +/// that exists on disk or is already in the dependency graph. +private bool prereqSatisfiable(string prereq, ref DependencyGraph graph, int depth = 0) +{ + import std.file : exists; + if (depth > 3) return false; // safety limit + + // Already exists as a file or as a graph target + if (exists(prereq) || graph.hasTarget(prereq)) + return true; + + // Try to resolve further via implicit rules + import antelope.compatibility.implicit_rules; + auto match = matchImplicitRule(prereq); + if (match is null) + return false; + + return prereqSatisfiable(match.resolvedPrereq, graph, depth + 1); +} + +/// Process a rule node: extract the target name, collect prerequisites and +/// recipe lines from children, create a `Target` and add it to the graph. +/// +/// The `data` field of the rule node holds the target name (e.g. "foo.o"). +/// Children of type `AstType.prerequisite` contribute their `data` field to +/// the prerequisite list; children of type `AstType.recipe_line` contribute +/// their `data` field to the recipe body. +/// +/// Target-specific variable assignments (e.g. `target: VAR = value`) are +/// also detected here. When the parser produces consecutive prerequisite +/// children that form an assignment pattern (name, operator, value), those +/// children are extracted from the prereq list and stored as scoped +/// variables on the environment instead. +private void handleRule(AstNode node, Environment* env, DependencyGraph* graph) +{ + string rawData = node.data; + bool isDoubleColon = (rawData.length > 2 && rawData[$-2..$] == "::"); + + string targetName = isDoubleColon ? rawData[0..$-2] : rawData; + + if (env) + { + import antelope.evaluator.expansion; + import std.string; + targetName = expand(targetName, env).strip; + } + + string[] prereqs; + string[] recipe; + + foreach (child; node.children) + { + switch (child.type) + { + case AstType.prerequisite: + prereqs ~= child.data; + break; + case AstType.recipe_line: + recipe ~= child.data; + break; + default: + break; + } + } + + // Detect target-specific variable assignments embedded in the prereq + // list. The current parser emits `target: VAR = value` as three + // consecutive prereq children ("VAR", "=", "value"). We scan the + // list for this pattern and extract matching entries into scoped + // variables, removing them from the actual prerequisite list. + if (env && prereqs.length >= 3) + { + string[] realPrereqs; + size_t i = 0; + while (i < prereqs.length) + { + // Check whether the next three prereq tokens form an assignment + // pattern: NAME OPERATOR VALUE + if (i + 2 < prereqs.length) + { + string maybeName = prereqs[i]; + string maybeOp = prereqs[i + 1]; + string maybeValue = prereqs[i + 2]; + + // Accept =, :=, +=, and ?= as assignment operators. + if (maybeOp == "=" || maybeOp == ":=" || + maybeOp == "+=" || maybeOp == "?=") + { + bool isRecursive = (maybeOp == "="); + env.addScopedVar(targetName, maybeName, maybeValue, + isRecursive); + i += 3; // consume all three + continue; + } + } + realPrereqs ~= prereqs[i]; + i++; + } + prereqs = realPrereqs; + } + + // Expand prerequisite names too (GNU Make compat). + // Also split expanded prereqs on whitespace for multi-word expansion. + if (env) + { + import antelope.evaluator.expansion; + import std.string; + import std.array : array; + string[] expandedPrereqs; + foreach (ref p; prereqs) + { + string expanded = expand(p, env); + foreach (word; expanded.split(" ")) + { + if (word.length > 0) + expandedPrereqs ~= word; + } + } + prereqs = expandedPrereqs; + + // Create stub targets for unresolved .a prerequisites. + // Autotools-generated Makefiles list archive libraries (e.g., + // lib/libgnu.a) as prerequisites but define no top-level rule — + // they're built in subdirectories. We create self-contained stubs + // that compile sources in-place and archive them. + if (graph) + { + foreach (prereq; prereqs) + { + if (prereq.length > 2 && prereq[$-2..$] == ".a" && + !graph.hasTarget(prereq) && !exists(prereq)) + { + import std.path : dirName, baseName; + Target stub; + stub.name = prereq; + stub.kind = TargetKind.file; + auto dir = dirName(prereq); + if (dir.length == 0) dir = "."; + // Compile .c sources in the archive's directory, then archive + stub.recipe = [ + "@cd " ~ dir ~ " && for f in *.c; do " ~ + "$(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) " ~ + "$(AM_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS) " ~ + "-c \"$f\" -o \"$(basename $f).o\"; done && " ~ + "$(AR) cr " ~ baseName(prereq) ~ " *.o" + ]; + graph.addTarget(stub); + } + } + } + } + + Target t; + t.name = targetName; + t.kind = TargetKind.file; + + // Split prerequisites into normal and order-only at | separator. + auto prereqSplit = splitPrereqs(prereqs); + t.prerequisites = prereqSplit.normal; + t.orderOnlyPrereqs = prereqSplit.orderOnly; + t.recipe = recipe; + + // Handle multi-target rules: when target expands to multiple words, + // create separate targets for each (same prereqs + recipe). + import std.string; + import std.algorithm : filter; + import std.array : array; + auto expandedTargets = targetName.split(" ").filter!(s => s.length > 0).array; + if (expandedTargets.length > 1) + { + foreach (tn; expandedTargets) + { + Target mt; + mt.name = tn; + mt.kind = TargetKind.file; + mt.prerequisites = prereqSplit.normal; + mt.orderOnlyPrereqs = prereqSplit.orderOnly; + mt.recipe = recipe; + if (graph) graph.addTarget(mt); + } + return; + } + + if (graph) + { + Target* existing = graph.findTarget(t.name); + if (existing !is null && isDoubleColon) + { + // Double-colon: each :: rule gets independent execution. + // Append recipes and prerequisites to the existing target. + existing.recipe ~= t.recipe; + foreach (p; t.prerequisites) + existing.prerequisites ~= p; + foreach (p; t.orderOnlyPrereqs) + existing.orderOnlyPrereqs ~= p; + } + else if (existing is null || t.name.indexOf('%') >= 0) + { + // Pattern rules (containing %) must have separate entries + // so each can provide a different prerequisite chain + graph.addTarget(t); + } + else + { + // Merge prereqs into existing target + foreach (p; t.prerequisites) + { + bool found; + foreach (ep; existing.prerequisites) + if (ep == p) { found = true; break; } + if (!found) existing.prerequisites ~= p; + } + foreach (p; t.orderOnlyPrereqs) + { + bool found; + foreach (ep; existing.orderOnlyPrereqs) + if (ep == p) { found = true; break; } + if (!found) existing.orderOnlyPrereqs ~= p; + } + if (t.recipe.length > 0 && existing.recipe.length == 0) + existing.recipe = t.recipe; + } + } +} + +/// Parse and apply a variable assignment. +/// +/// Supports four GNU Make assignment forms: +/// `NAME = VALUE` — recursive (value stored unexpanded; expanded on use) +/// `NAME := VALUE` — simple (value expanded at definition time) +/// `NAME += VALUE` — append (appended to existing value with space) +/// `NAME ?= VALUE` — conditional (set only if currently undefined) +/// +/// Whitespace around the operator and value is trimmed. +/// The `data` field of the node contains the raw assignment string. +private void handleVariableAssignment(AstNode node, Environment* env) +{ + if (!env) + return; + + string data = node.data; + + // Check multi-character operators first to avoid false matches on plain '='. + auto colonEq = indexOf(data, ":="); + auto plusEq = indexOf(data, "+="); + auto condEq = indexOf(data, "?="); + + if (colonEq >= 0) + { + // Simple assignment — expand immediately. + string name = data[0 .. colonEq].strip; + string value = data[colonEq + 2 .. $].strip; + env.set(name, expand(value, env)); + } + else if (plusEq >= 0) + { + // Append — add to existing value, space-separated. + string name = data[0 .. plusEq].strip; + string value = data[plusEq + 2 .. $].strip; + if (env.hasKey(name)) + env.set(name, env.get(name) ~ " " ~ value); + else + env.set(name, value); + } + else if (condEq >= 0) + { + // Conditional — only set if the variable is currently undefined. + string name = data[0 .. condEq].strip; + string value = data[condEq + 2 .. $].strip; + if (!env.hasKey(name)) + env.set(name, value); + } + else + { + // Recursive assignment — store unexpanded; expansion happens on use. + auto plainEq = indexOf(data, "="); + if (plainEq >= 0) + { + string name = data[0 .. plainEq].strip; + string value = data[plainEq + 1 .. $].strip; + env.set(name, value); + } + } +} + +/// Dispatch a directive node: parse the directive name from `node.data` and +/// handle includes, conditionals, and VPATH configuration. +/// +/// Directive types recognised: +/// `include` / `-include` / `sinclude` — file inclusion (stub, not yet +/// implemented; filesystem reading is needed for a full implementation) +/// `ifdef` / `ifndef` / `ifeq` / `ifneq` — conditional blocks; the +/// condition is evaluated and the children are processed only when +/// the conditional is true +/// `vpath` — VPATH directory search pattern (stub) +/// +/// Other directives (`define`, `undefine`, `export`, `unexport`, `else_`, +/// `endif`) are silently ignored for now. +private void handleDirective(AstNode node, Environment* env, DependencyGraph* graph, + GnuMakeCompat* gnuCompat, PosixCompat* posixCompat) +{ + string data = node.data; + auto space = indexOf(data, " "); + string dirName = space >= 0 ? data[0 .. space] : data; + + switch (dirName) + { + case "include": + case "-include": + case "sinclude": + handleInclude(dirName, data, space, env, graph, gnuCompat, posixCompat); + break; + + case "ifdef": + case "ifndef": + case "ifeq": + case "ifneq": + handleConditionalDirective(dirName, data, space, node, env, graph, + gnuCompat, posixCompat); + break; + + case "vpath": + { + // Format: "vpath PATTERN DIRS..." + // e.g., "vpath %.h include/" or "vpath %.h include src" + if (space < 0) break; + string rest = data[space + 1 .. $].strip; + auto space2 = indexOf(rest, " "); + if (space2 < 0) break; + string pattern = rest[0 .. space2].strip; + string dirs = rest[space2 + 1 .. $].strip; + + import antelope.compatibility.vpath; + import std.string : split; + VPathEntry entry; + entry.pattern = pattern; + foreach (dir; dirs.split(" ")) + if (dir.length > 0) entry.directories ~= dir; + // Store in env for later use by the resolver + if (env !is null) + env.set("__vpath_" ~ pattern, dirs); + break; + } + + default: + // Stub — define, undefine, export_, unexport, else_, endif. + break; + } +} + +/// Evaluate a conditional directive and process the true-branch children. +/// +/// The directive text is split into the operator and its arguments (lhs/rhs) +/// to match the `evaluateConditional` signature. If the condition is true, +/// all children of the directive node are recursively evaluated; if false, +/// they are skipped. +private void handleConditionalDirective(string dirName, string data, ptrdiff_t space, + AstNode node, Environment* env, + DependencyGraph* graph, + GnuMakeCompat* gnuCompat, + PosixCompat* posixCompat) +{ + string lhs, rhs; + + if (space >= 0) + { + string rest = data[space + 1 .. $].strip; + // Paren form: ifeq (a, b) — pass the entire rest as lhs, empty rhs. + if (rest.length > 0 && rest[0] == '(') + { + lhs = rest; + rhs = ""; + } + else + { + // Two-argument form: ifeq a b + auto sp2 = indexOf(rest, " "); + if (sp2 >= 0) + { + lhs = rest[0 .. sp2]; + rhs = rest[sp2 + 1 .. $]; + } + else + { + // Single argument (ifdef VAR_NAME) + lhs = rest; + } + } + } + + bool condResult = evaluateConditional(dirName, lhs, rhs, env); + + // Split children into then-branch and (optional) else-branch + AstNode[] thenChildren; + AstNode[] elseChildren; + bool inElse; + foreach (child; node.children) + { + if (child.type == AstType.directive && child.data == "else") + { + inElse = true; + elseChildren = child.children; + } + else if (!inElse) + { + thenChildren ~= child; + } + } + + // Evaluate the correct branch + AstNode wrapper; + wrapper.type = AstType.rule_list; + if (condResult) + { + wrapper.children = thenChildren; + } + else + { + wrapper.children = elseChildren; + } + if (wrapper.children.length > 0) + { + evaluate(wrapper, env, graph, gnuCompat, posixCompat); + } +} + +/// Handle an include directive: parse the file path(s) from the directive text, +/// read the file(s), parse their content, and evaluate them into the environment +/// and dependency graph. +/// +/// For `include`, a missing file is logged as a message and silently skipped. +/// For `-include` / `sinclude`, a missing file produces no output at all. +/// +/// Multi-file includes (e.g., `include $(DEP_FILES)` where DEP_FILES expands +/// to multiple space-separated paths) are common in autotools-generated +/// Makefiles. Each path is processed individually. +private void handleInclude(string dirName, string data, ptrdiff_t space, + Environment* env, DependencyGraph* graph, + GnuMakeCompat* gnuCompat, PosixCompat* posixCompat) +{ + if (space < 0) return; + + string rawPath = data[space + 1 .. $].strip; + if (rawPath.length == 0) return; + + // Expand variable references in the include path (e.g., $(DEP_FILES) + // commonly expands to "./.deps/a.Po ./.deps/b.Po" in autotools projects). + import antelope.evaluator.expansion; + import std.string : split; + auto paths = expand(rawPath, env).split(" "); + + foreach (path; paths) + { + if (path.length == 0) continue; + + if (!exists(path)) + { + if (dirName == "include") + log(LogLevel.normal, "antelope: " ~ path ~ ": No such file"); + continue; + } + + import antelope.parser.parser; + string includedContent = readText(path); + auto includedAst = parse(includedContent); + evaluate(includedAst, env, graph, gnuCompat, posixCompat); + } +} + +// --------------------------------------------------------------------------- +// Unittests +// --------------------------------------------------------------------------- + +/// +unittest +{ + // --- Simple one-rule AST → graph has one target --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "hello"; + + AstNode recipe; + recipe.type = AstType.recipe_line; + recipe.data = "echo hello"; + rule.children = [recipe]; + + root.children = [rule]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "graph should contain exactly 1 target"); + assert(graph.targets[0].name == "hello", + "target name should be 'hello', got: " ~ graph.targets[0].name); + assert(graph.targets[0].recipe.length == 1, + "target should have 1 recipe line"); + assert(graph.targets[0].recipe[0] == "echo hello", + "recipe should be 'echo hello', got: " ~ graph.targets[0].recipe[0]); + assert(graph.targets[0].kind == TargetKind.file, + "default kind should be file"); +} + +/// +unittest +{ + // --- Variable assignment → env.get returns correct value --- + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode varAssign; + varAssign.type = AstType.variable_assignment; + varAssign.data = "CC=gcc"; + root.children = [varAssign]; + + evaluate(root, &env, null); + + assert(env.get("CC") == "gcc", + "CC should be 'gcc', got: " ~ env.get("CC")); + assert(env.hasKey("CC"), + "CC should exist in environment"); +} + +/// +unittest +{ + // --- Simple assignment (:=) expands immediately --- + Environment env; + env.set("SRC", "main.c"); + + AstNode root; + root.type = AstType.rule_list; + + AstNode varAssign; + varAssign.type = AstType.variable_assignment; + varAssign.data = "OBJ:=$(SRC:.c=.o)"; + root.children = [varAssign]; + + evaluate(root, &env, null); + + // $(SRC:.c=.o) is not a standard GNU Make expansion — the expand() function + // will try to resolve $(SRC:.c=.o) as a variable name after expanding + // nested references. Since there is no variable named "SRC:.c=.o" or + // "main.c:.c=.o", the result is empty. + // This test just verifies that the := path executes without error. + assert(env.hasKey("OBJ"), + "OBJ should be set via :="); +} + +/// +unittest +{ + // --- Rule with prerequisites → Target has correct prereq list --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "program"; + + AstNode prereq1; + prereq1.type = AstType.prerequisite; + prereq1.data = "main.o"; + + AstNode prereq2; + prereq2.type = AstType.prerequisite; + prereq2.data = "util.o"; + + AstNode recipe; + recipe.type = AstType.recipe_line; + recipe.data = "gcc -o program main.o util.o"; + + rule.children = [prereq1, prereq2, recipe]; + root.children = [rule]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "graph should contain exactly 1 target"); + assert(graph.targets[0].name == "program", + "target name should be 'program', got: " ~ graph.targets[0].name); + assert(graph.targets[0].prerequisites.length == 2, + "target should have 2 prerequisites, got: " ~ + to!string(graph.targets[0].prerequisites.length)); + assert(graph.targets[0].prerequisites[0] == "main.o", + "first prereq should be 'main.o', got: " ~ + graph.targets[0].prerequisites[0]); + assert(graph.targets[0].prerequisites[1] == "util.o", + "second prereq should be 'util.o', got: " ~ + graph.targets[0].prerequisites[1]); + assert(graph.targets[0].recipe.length == 1, + "target should have 1 recipe line"); +} + +/// +unittest +{ + // --- += append assignment --- + Environment env; + env.set("CFLAGS", "-O2"); + + AstNode root; + root.type = AstType.rule_list; + + AstNode appendAssign; + appendAssign.type = AstType.variable_assignment; + appendAssign.data = "CFLAGS+=-Wall"; + root.children = [appendAssign]; + + evaluate(root, &env, null); + + assert(env.get("CFLAGS") == "-O2 -Wall", + "CFLAGS should be '-O2 -Wall', got: " ~ env.get("CFLAGS")); +} + +/// +unittest +{ + // --- ?= conditional assignment — sets when undefined --- + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode condAssign; + condAssign.type = AstType.variable_assignment; + condAssign.data = "CC?=gcc"; + root.children = [condAssign]; + + evaluate(root, &env, null); + + assert(env.get("CC") == "gcc", + "?= should set CC to 'gcc', got: " ~ env.get("CC")); + + // --- ?= conditional assignment — does NOT overwrite existing --- + AstNode condAssign2; + condAssign2.type = AstType.variable_assignment; + condAssign2.data = "CC?=clang"; + AstNode root2; + root2.type = AstType.rule_list; + root2.children = [condAssign2]; + + evaluate(root2, &env, null); + + assert(env.get("CC") == "gcc", + "?= should NOT overwrite existing CC, got: " ~ env.get("CC")); +} + +/// +unittest +{ + // --- Null graph: evaluate should not crash when graph is null --- + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "noop"; + + AstNode recipe; + recipe.type = AstType.recipe_line; + recipe.data = "true"; + rule.children = [recipe]; + + root.children = [rule]; + + // This must not segfault. + evaluate(root, &env, null); + assert(true, "evaluate with null graph should complete without error"); +} + +/// +unittest +{ + // --- Null env: variable assignments should be silently skipped --- + AstNode root; + root.type = AstType.rule_list; + + AstNode varAssign; + varAssign.type = AstType.variable_assignment; + varAssign.data = "X=y"; + root.children = [varAssign]; + + // This must not segfault. + evaluate(root, null, null); + assert(true, "evaluate with null env should complete without error"); +} + +/// +unittest +{ + // --- Conditional directive (ifeq true) — processes children --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode dirNode; + dirNode.type = AstType.directive; + dirNode.data = "ifeq (gcc, gcc)"; + + AstNode ruleInside; + ruleInside.type = AstType.rule; + ruleInside.data = "true_branch_target"; + + dirNode.children = [ruleInside]; + root.children = [dirNode]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "true-branch target should be added to graph"); + assert(graph.targets[0].name == "true_branch_target", + "true-branch target name should match"); +} + +/// +unittest +{ + // --- Conditional directive (ifeq false) — skips children --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode dirNode; + dirNode.type = AstType.directive; + dirNode.data = "ifeq (gcc, clang)"; + + AstNode ruleInside; + ruleInside.type = AstType.rule; + ruleInside.data = "false_branch_target"; + + dirNode.children = [ruleInside]; + root.children = [dirNode]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 0, + "false-branch target should NOT be added to graph"); +} + +/// +unittest +{ + // --- Two-argument conditional form --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode dirNode; + dirNode.type = AstType.directive; + dirNode.data = "ifneq a b"; + + AstNode ruleInside; + ruleInside.type = AstType.rule; + ruleInside.data = "different_test"; + + dirNode.children = [ruleInside]; + root.children = [dirNode]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "ifneq true-branch target should be added"); + assert(graph.targets[0].name == "different_test"); +} + +/// +unittest +{ + // --- ifdef directive with existing variable --- + DependencyGraph graph; + Environment env; + env.set("DEBUG", "1"); + + AstNode root; + root.type = AstType.rule_list; + + AstNode dirNode; + dirNode.type = AstType.directive; + dirNode.data = "ifdef DEBUG"; + + AstNode ruleInside; + ruleInside.type = AstType.rule; + ruleInside.data = "debug_build"; + + dirNode.children = [ruleInside]; + root.children = [dirNode]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "ifdef DEBUG (exists) should process children"); + assert(graph.targets[0].name == "debug_build"); +} + +/// +unittest +{ + // --- ifndef with missing variable should process children --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode dirNode; + dirNode.type = AstType.directive; + dirNode.data = "ifndef MISSING"; + + AstNode ruleInside; + ruleInside.type = AstType.rule; + ruleInside.data = "missing_conditional_target"; + + dirNode.children = [ruleInside]; + root.children = [dirNode]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "ifndef MISSING (not present) should process children"); + assert(graph.targets[0].name == "missing_conditional_target"); +} + +/// +unittest +{ + // --- Nested rule_list recursion --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode innerList; + innerList.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "nested_target"; + + innerList.children = [rule]; + root.children = [innerList]; + + evaluate(root, &env, &graph); + + assert(graph.targets.length == 1, + "nested rule_list should be recursed into"); + assert(graph.targets[0].name == "nested_target"); +} + +/// +unittest +{ + // --- Multiple rules and variables in one evaluation pass --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode var1; + var1.type = AstType.variable_assignment; + var1.data = "CC=gcc"; + + AstNode rule1; + rule1.type = AstType.rule; + rule1.data = "foo.o"; + + AstNode prereq; + prereq.type = AstType.prerequisite; + prereq.data = "foo.c"; + rule1.children = [prereq]; + + AstNode var2; + var2.type = AstType.variable_assignment; + var2.data = "CFLAGS=-Wall"; + + AstNode rule2; + rule2.type = AstType.rule; + rule2.data = "bar.o"; + + root.children = [var1, rule1, var2, rule2]; + + evaluate(root, &env, &graph); + + assert(env.get("CC") == "gcc"); + assert(env.get("CFLAGS") == "-Wall"); + assert(graph.targets.length == 2); + assert(graph.targets[0].name == "foo.o"); + assert(graph.targets[1].name == "bar.o"); +} + +/// +unittest +{ + // --- Implicit rule resolution: target without recipe gets one --- + DependencyGraph graph; + + // Add a target with no recipe — like `program:` with no body + Target t; + t.name = "program"; + t.kind = TargetKind.file; + t.prerequisites = []; + t.recipe = []; + graph.addTarget(t); + + // Create files to satisfy the implicit rule chain: + // program → program.o → program.c (exists) + import std.file : write, remove; + write("program.c", "int main(){}"); + scope (exit) remove("program.c"); + + size_t resolved = resolveImplicitRules(graph); + assert(resolved == 1, "should resolve 1 target"); + + auto program = graph.findTarget("program"); + assert(program !is null); + assert(program.recipe.length > 0, + "program should have recipe from implicit rule"); + assert(program.prerequisites.length >= 1, + "program should have prereq from implicit rule"); + assert(program.prerequisites[0] == "program.o", + "first prereq should be 'program.o', got: " ~ program.prerequisites[0]); + + // The prerequisite `program.o` should be added as a stub target. + assert(graph.hasTarget("program.o"), + "program.o should be added as stub target"); +} + +/// +unittest +{ + // --- Implicit rule chaining: .o → .c in two passes --- + DependencyGraph graph; + + // Only `program` is declared; no explicit recipe. + Target t; + t.name = "program"; + t.kind = TargetKind.file; + graph.addTarget(t); + + // Create program.c so the implicit rule chain can resolve + import std.file : write, remove; + write("program.c", "int main(){}"); + scope (exit) remove("program.c"); + + // Pass 1: resolve `program` via link rule → adds `program.o` stub + size_t r1 = resolveImplicitRules(graph); + assert(r1 == 1, "pass 1 should resolve program"); + assert(graph.hasTarget("program.o")); + auto progO = graph.findTarget("program.o"); + assert(progO !is null); + assert(progO.recipe.length == 0, "program.o should have no recipe yet"); + + // Pass 2: `program.o` should match `%.o: %.c` → sets prereq `program.c` + // (No stub needed — program.c exists on disk) + size_t r2 = resolveImplicitRules(graph); + assert(r2 == 1, "pass 2 should resolve program.o"); + progO = graph.findTarget("program.o"); + assert(progO.recipe.length > 0, + "program.o should now have recipe"); + assert(progO.prerequisites.length >= 1); + assert(progO.prerequisites[0] == "program.c", + "program.o prereq should be program.c"); + // program.c exists on disk, so no stub was added + assert(!graph.hasTarget("program.c"), + "program.c should NOT be added — it exists on disk"); + + // Pass 3: `program.c` may match rules (e.g. %.c: %.l for Lex) — + // the pattern matcher doesn't check prerequisite existence yet. + // No assertion on whether it resolves; just verify no crash. + size_t r3 = resolveImplicitRules(graph); + // Just ensure it doesn't crash; r3 may be 0 or 1. + cast(void) r3; +} + +/// +unittest +{ + // --- Double-colon: graph holds two targets with the same name --- + // + // In GNU Make, double-colon rules (target:: prereqs) are treated as + // independent — each recipe runs separately. This test verifies + // that the graph stores multiple targets with the identical name + // (the precondition for double-colon execution). The actual + // double-colon semantic (independent execution) is blocked on the + // parser storing :: vs : in the AST — see TODO in handleRule. + // + // This test uses addTarget directly (bypassing evaluate) because + // the current parser does not annotate double-colon rules. + + DependencyGraph graph; + + Target t1; + t1.name = "double_colon_target"; + t1.kind = TargetKind.file; + t1.recipe = ["echo first"]; + graph.addTarget(t1); + + Target t2; + t2.name = "double_colon_target"; + t2.kind = TargetKind.file; + t2.recipe = ["echo second"]; + graph.addTarget(t2); + + // Verify both entries exist independently. + size_t count; + foreach (ref t; graph.targets) + if (t.name == "double_colon_target") + count++; + + assert(count == 2, + "double-colon target should have 2 entries in graph, got: " ~ + count.to!string); + assert(graph.targets[0].recipe[0] == "echo first"); + assert(graph.targets[1].recipe[0] == "echo second"); +} + +/// +unittest +{ + // --- Target-specific variable assignment detected in handleRule --- + // + // `target: VAR = value` is parsed by the current parser as a rule + // with three prereq children ("VAR", "=", "value"). handleRule + // detects this pattern and stores the assignment as a scoped + // variable on the environment. + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + // Build a pseudo-rule node that mimics what the parser produces + // for `my_target: CFLAGS = -O2 -g` + AstNode rule; + rule.type = AstType.rule; + rule.data = "my_target"; + + AstNode nameNode; + nameNode.type = AstType.prerequisite; + nameNode.data = "CFLAGS"; + + AstNode opNode; + opNode.type = AstType.prerequisite; + opNode.data = "="; + + AstNode valNode; + valNode.type = AstType.prerequisite; + valNode.data = "-O2 -g"; + + rule.children = [nameNode, opNode, valNode]; + root.children = [rule]; + + evaluate(root, &env, &graph); + + // Target should be in the graph, but the variable assignment + // should have been extracted, NOT left as a prereq. + assert(graph.targets.length == 1, + "graph should contain the target"); + assert(graph.targets[0].name == "my_target"); + assert(graph.targets[0].prerequisites.length == 0, + "prereq list should be empty (assignment was extracted)"); + + // The scoped variable should be stored on the environment. + assert(env.getScoped("CFLAGS", "my_target") == "-O2 -g", + "scoped CFLAGS should be '-O2 -g', got: " ~ + env.getScoped("CFLAGS", "my_target")); + + // Global lookup should return empty (not set globally). + assert(env.get("CFLAGS") == "", + "CFLAGS should not be set globally, got: " ~ env.get("CFLAGS")); +} + +/// +unittest +{ + // --- Target-specific with := (non-recursive) operator --- + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "debug.o"; + + AstNode nameNode; + nameNode.type = AstType.prerequisite; + nameNode.data = "CFLAGS"; + + AstNode opNode; + opNode.type = AstType.prerequisite; + opNode.data = ":="; + + AstNode valNode; + valNode.type = AstType.prerequisite; + valNode.data = "-O0 -g"; + + rule.children = [nameNode, opNode, valNode]; + root.children = [rule]; + + evaluate(root, &env, &graph); + + assert(env.getScoped("CFLAGS", "debug.o") == "-O0 -g", + "scoped CFLAGS via := should be '-O0 -g'"); +} + +/// +unittest +{ + // --- Target-specific var with real prerequisites mixed in --- + // + // `my_target: dep1.o CFLAGS = -O2 dep2.o` — the assignment is + // extracted while dep1.o and dep2.o remain as real prereqs. + DependencyGraph graph; + Environment env; + + AstNode root; + root.type = AstType.rule_list; + + AstNode rule; + rule.type = AstType.rule; + rule.data = "my_prog"; + + AstNode dep1; + dep1.type = AstType.prerequisite; + dep1.data = "dep1.o"; + + AstNode varName; + varName.type = AstType.prerequisite; + varName.data = "LDFLAGS"; + + AstNode op; + op.type = AstType.prerequisite; + op.data = "+="; + + AstNode varVal; + varVal.type = AstType.prerequisite; + varVal.data = "-lm"; + + AstNode dep2; + dep2.type = AstType.prerequisite; + dep2.data = "dep2.o"; + + rule.children = [dep1, varName, op, varVal, dep2]; + root.children = [rule]; + + evaluate(root, &env, &graph); + + // Target should exist with only real prereqs. + // Target should exist with only real prereqs. + assert(graph.targets.length == 1); + assert(graph.targets[0].prerequisites.length == 2, + "should have 2 real prereqs, got: " ~ + graph.targets[0].prerequisites.length.to!string); + assert(graph.targets[0].prerequisites[0] == "dep1.o"); + assert(graph.targets[0].prerequisites[1] == "dep2.o"); + + // Scoped variable should be stored. + assert(env.getScoped("LDFLAGS", "my_prog") == "-lm", + "scoped LDFLAGS += should be '-lm', got: " ~ + env.getScoped("LDFLAGS", "my_prog")); +} + +/// +unittest +{ + // --- Target that already has a recipe is NOT overwritten --- + DependencyGraph graph; + + Target t; + t.name = "hello.o"; + t.kind = TargetKind.file; + t.prerequisites = ["hello.c"]; + t.recipe = ["gcc -O3 -c hello.c -o hello.o"]; + graph.addTarget(t); + + size_t resolved = resolveImplicitRules(graph); + assert(resolved == 0, "target with existing recipe should not be touched"); + + auto hello = graph.findTarget("hello.o"); + assert(hello.recipe[0] == "gcc -O3 -c hello.c -o hello.o", + "explicit recipe should be preserved"); +} + +/// +unittest +{ + // --- Phony targets are skipped --- + DependencyGraph graph; + + Target t; + t.name = "clean"; + t.kind = TargetKind.phony; + graph.addTarget(t); + + size_t resolved = resolveImplicitRules(graph); + assert(resolved == 0, "phony target should not get implicit recipe"); +} + +/// +unittest +{ + // --- Empty graph: no crash --- + DependencyGraph graph; + size_t resolved = resolveImplicitRules(graph); + assert(resolved == 0, "empty graph should resolve 0"); +} + +/// +unittest +{ + // --- Multi-file include via variable expansion --- + // + // Autotools-generated Makefiles use `include $(DEP_FILES)` where + // DEP_FILES expands to multiple space-separated paths (e.g., + // "./.deps/a.Po ./.deps/b.Po"). This test verifies that each + // path is expanded, split, and processed individually. + import std.file : write, remove; + + // Create two temporary include files + write("antelope_test_include_a.mk", "VAR_A = from_a\n"); + write("antelope_test_include_b.mk", "VAR_B = from_b\n"); + scope (exit) + { + remove("antelope_test_include_a.mk"); + remove("antelope_test_include_b.mk"); + } + + Environment env; + env.set("DEP_FILES", "antelope_test_include_a.mk antelope_test_include_b.mk"); + + AstNode root; + root.type = AstType.rule_list; + + AstNode includeDir; + includeDir.type = AstType.directive; + includeDir.data = "include $(DEP_FILES)"; + root.children = [includeDir]; + + evaluate(root, &env, null); + + assert(env.hasKey("VAR_A"), + "VAR_A should be set from first included file"); + assert(env.get("VAR_A") == "from_a", + "VAR_A should be 'from_a', got: '" ~ env.get("VAR_A") ~ "'"); + assert(env.hasKey("VAR_B"), + "VAR_B should be set from second included file"); + assert(env.get("VAR_B") == "from_b", + "VAR_B should be 'from_b', got: '" ~ env.get("VAR_B") ~ "'"); +} diff --git a/source/antelope/evaluator/expansion.d b/source/antelope/evaluator/expansion.d new file mode 100644 index 0000000..0e0162a --- /dev/null +++ b/source/antelope/evaluator/expansion.d @@ -0,0 +1,881 @@ +/// Variable expansion and substitution at evaluation time. +/// +/// This module implements GNU Make-compatible recursive variable expansion. +/// It is the core string-processing engine responsible for resolving +/// `$(VAR)`, `$$`, automatic variables ($@, $<, etc.), D/F suffix extraction, +/// and `$(call ...)` substitution — all with circular-reference detection. +module antelope.evaluator.expansion; + +import antelope.shell.environment; +import antelope.diagnostics.errors; +import antelope.parser.functions; +import std.string : lastIndexOf, strip, indexOf; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Expand all variable references in a string. +/// +/// The expansion is recursive: when a variable's value itself contains +/// variable references, those are expanded in turn. Undefined variables +/// expand to the empty string (standard GNU Make behaviour). Circular +/// references (A → B → A) are detected and reported rather than looping. +/// +/// Supported syntax: +/// * `$$` → literal `$` +/// * `$@` → current target name +/// * `$<` → first prerequisite +/// * `$^` → all prerequisites (unique, space-separated) +/// * `$+` → all prerequisites (duplicates preserved) +/// * `$?` → prerequisites newer than target (all prereqs for now) +/// * `$*` → pattern stem +/// * `$%` → archive member (not yet implemented → "") +/// * `$|` → order-only prerequisites (not yet implemented → "") +/// * `$(VAR)` → environment variable lookup + recursive expansion +/// * `${VAR}` → same as `$(VAR)` +/// * `$(@D)` / `$(@F)` → directory / file part of target +/// * `$( 0 ? currentPrereqs[0] : ""; + case '^': + { + // Unique prerequisites (order preserved, first occurrence kept). + if (currentPrereqs.length == 0) + return ""; + import std.array : appender; + auto buf = appender!string(); + bool[string] seen; + bool first = true; + foreach (p; currentPrereqs) + { + if (p in seen) continue; + seen[p] = true; + if (!first) buf.put(" "); + first = false; + buf.put(p); + } + return buf.data; + } + case '+': // All prereqs, duplicates preserved, space-separated. + { + if (currentPrereqs.length == 0) + return ""; + import std.array : appender; + auto buf = appender!string(); + foreach (i, p; currentPrereqs) + { + if (i > 0) buf.put(" "); + buf.put(p); + } + return buf.data; + } + case '?': + { + // "Prereqs newer than target" — compare timestamps and + // return only those prerequisites that are newer than the + // target file. This matches GNU Make's $? semantics. + if (currentPrereqs.length == 0) return ""; + import antelope.filesystem.timestamps; + import std.array : appender; + auto buf = appender!string(); + bool first = true; + long targetTime = getTimestamp(currentTarget); + foreach (p; currentPrereqs) + { + long pt = getTimestamp(p); + if (pt > targetTime || targetTime == -1) + { + if (!first) buf.put(" "); + first = false; + buf.put(p); + } + } + return buf.data; + } + case '*': return stem; + case '%': return ""; // Archive member — not yet implemented. + case '|': return ""; // Order-only prereqs — not yet implemented. + } +} + +/// Resolve content found between `(` / `)` or `{` / `}`. +/// +/// The content may be: +/// * `call FUNC,arg1,arg2,…` → call-style expansion +/// * `@D`, `= 5 && content[0 .. 5] == "call ") + { + return expandCall(content[5 .. $], env, currentTarget, currentPrereqs, stem, chain); + } + + // --- Built-in function calls: $(shell ...), $(subst ...), etc. ----------- + // Detect if the first word is a known GNU Make function name. + import std.string : indexOf; + auto firstSpace = indexOf(content, ' '); + string firstWord = firstSpace >= 0 ? content[0 .. firstSpace] : content; + if (isBuiltinFunction(firstWord)) + { + return evaluateBuiltinCall(firstWord, content, env, currentTarget, currentPrereqs, stem); + } + + // --- Automatic var with D/F suffix ($(@D), $(= 0 && colonIdx < content.length - 1 && + content[colonIdx + 1] != '=' && indexOf(content[colonIdx + 1 .. $], '=') >= 0) + { + // Syntax: VAR:old=new + string varName = content[0 .. colonIdx]; + auto eqIdx = indexOf(content[colonIdx + 1 .. $], '='); + string oldPat = content[colonIdx + 1 .. colonIdx + 1 + eqIdx]; + string newPat = content[colonIdx + 1 + eqIdx + 1 .. $]; + // Equivalent to $(patsubst oldPat,newPat,$(varName)) + string varValue = env ? env.get(varName) : ""; + if (varValue.length > 0) + { + import antelope.evaluator.functions; + import antelope.parser.functions; + return evaluateFunction(BuiltinFunction.patsubst, [oldPat, newPat, varValue], env); + } + return ""; + } + + // --- Regular variable lookup ------------------------------------------- + // First, recursively expand any nested `$` references in the content. + // This handles $(VAR_$(NESTED)) — expand $(NESTED) first, then look up. + string expandedName = expandImpl(content, env, currentTarget, currentPrereqs, stem, chain); + + if (expandedName.length == 0) + return ""; + + // Environment lookup — consult target-scoped variables first. + string value = env ? env.getScoped(expandedName, currentTarget) : ""; + if (value.length == 0) + return ""; + + // Circular-reference guard. + foreach (c; chain) + { + if (c == expandedName) + { + // Found a cycle — report and bail out. + // (In the future this could use a diagnostic emitter.) + return ""; + } + } + + chain ~= expandedName; + scope (exit) chain = chain[0 .. $ - 1]; + return expandImpl(value, env, currentTarget, currentPrereqs, stem, chain); +} + +/// Expand a `$(call FUNC,arg1,arg2,…)` reference. +/// +/// `rest` is everything after `"call "` — i.e. `"FUNC,arg1,arg2,…"`. +/// Steps: +/// 1. Split on top-level commas to get variable name + args. +/// 2. Expand the variable name to find which variable to invoke. +/// 3. Look up its value from the environment. +/// 4. Substitute `$1`, `$2`, … in the value with the expanded args. +/// 5. Recursively expand the result. +private string expandCall(string rest, Environment* env, + string currentTarget, string[] currentPrereqs, + string stem, ref string[] chain) +{ + // Split `FUNC,arg1,arg2,…` on top-level commas. + string[] parts; + { + import std.array : appender; + auto buf = appender!(string[])(); + size_t pos = 0; + size_t depth = 0; + size_t start = 0; + while (pos < rest.length) + { + char ch = rest[pos]; + if (ch == '(' || ch == '{') depth++; + else if (ch == ')' || ch == '}') depth--; + else if (ch == ',' && depth == 0) + { + buf.put(rest[start .. pos]); + start = pos + 1; + } + pos++; + } + // Last segment. + buf.put(rest[start .. $]); + parts = buf.data; + } + + if (parts.length == 0) + return ""; + + // Parts[0] is the function name — expand it to get the variable name. + string funcName = expandImpl(parts[0], env, currentTarget, currentPrereqs, stem, chain); + + if (funcName.length == 0) + return ""; + + // Look up the function body. + string body = env ? env.get(funcName) : ""; + if (body.length == 0) + return ""; + + // Expand each argument. + string[] expandedArgs; + foreach (i; 1 .. parts.length) + { + expandedArgs ~= expandImpl(parts[i], env, currentTarget, currentPrereqs, stem, chain); + } + + // Substitute $1, $2, … in the body. + // We scan the body character by character for $N patterns. + string bodyWithSubs = substituteCallArgs(body, expandedArgs); + + // Recursively expand the result (it may contain further variable refs). + return expandImpl(bodyWithSubs, env, currentTarget, currentPrereqs, stem, chain); +} + +/// Substitute `$1`, `$2`, …, `$0` (the function name) in `body` with the +/// corresponding values from `args`. +/// +/// `args[0]` corresponds to `$1`, `args[1]` to `$2`, etc. +/// `$0` is set to the function name (already resolved). +/// +/// `$$` in the body is left as a literal `$` (it was already quoted). +/// Unknown `$N` is replaced by the empty string. +private string substituteCallArgs(string body, string[] args) +{ + import std.array : appender; + auto buf = appender!string(); + size_t i = 0; + while (i < body.length) + { + if (body[i] == '$') + { + i++; + if (i >= body.length) + { + buf.put('$'); + break; + } + char c = body[i]; + if (c == '$') + { + buf.put('$'); + i++; + continue; + } + // Is it a digit? $0, $1, …, $9 + if (c >= '0' && c <= '9') + { + uint n = c - '0'; + if (n == 0) + { + // $0 is replaced by empty string (or function name — but + // GNU Make doesn't use $0 in call; it's the function name + // and is not substituted). Leave as empty for now. + buf.put(""); + } + else if (n <= args.length) + { + buf.put(args[n - 1]); + } + else + { + // Unknown $N → "" + } + i++; + continue; + } + // Not a digit — output $ and the char (e.g. $( inside body). + buf.put('$'); + buf.put(c); + i++; + } + else + { + buf.put(body[i]); + i++; + } + } + return buf.data; +} + +/// Core expansion implementation — recursive, with chain tracking for +/// circular-reference detection. +private string expandImpl(string input, Environment* env, string currentTarget, + string[] currentPrereqs, string stem, ref string[] chain) +{ + import std.array : appender; + auto buf = appender!string(); + size_t i = 0; + + while (i < input.length) + { + if (input[i] == '$') + { + i++; + if (i >= input.length) + { + buf.put('$'); + break; + } + + char c = input[i]; + + // $$ → literal $ + if (c == '$') + { + buf.put('$'); + i++; + continue; + } + + // Single-character $X variable references. + // $@, $<, $^, etc. → automatic variables + if (c == '@' || c == '<' || c == '^' || c == '+' || + c == '?' || c == '*' || c == '%' || c == '|') + { + buf.put(resolveAutoVar(c, currentTarget, currentPrereqs, stem)); + i++; + continue; + } + // $1..$9, $0 → positional parameters (call, foreach, etc.) + // and any other single-char variable reference + if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_') + { + string varName = [c]; + string val = env ? env.get(varName) : ""; + buf.put(val); + i++; + continue; + } + + // $(…) or ${…} + if (c == '(' || c == '{') + { + char openChar = c; + char closeChar = (c == '(') ? ')' : '}'; + i++; // skip opening delimiter + + size_t contentStart = i; + int depth = 1; + while (i < input.length && depth > 0) + { + if (input[i] == openChar) + depth++; + else if (input[i] == closeChar) + depth--; + if (depth > 0) + i++; + } + + if (i >= input.length) + { + // Unterminated $('… or ${… — output literally. + buf.put('$'); + buf.put(openChar); + buf.put(input[contentStart .. $]); + break; + } + + string content = input[contentStart .. i]; + i++; // skip closing delimiter + + buf.put(resolveParenContent(content, closeChar, env, currentTarget, + currentPrereqs, stem, chain)); + continue; + } + + // Unknown $X — output literally. + buf.put('$'); + buf.put(c); + i++; + } + else + { + buf.put(input[i]); + i++; + } + } + + return buf.data; +} + +// --------------------------------------------------------------------------- +// Unittests +// --------------------------------------------------------------------------- + +/// +unittest +{ + // --- Simple $(VAR) expansion --- + { + Environment env; + env.set("FOO", "bar"); + string result = expand("$(FOO)", &env); + assert(result == "bar", "Simple $(FOO) should expand to 'bar', got: " ~ result); + } + + // --- ${VAR} syntax --- + { + Environment env; + env.set("FOO", "baz"); + string result = expand("${FOO}", &env); + assert(result == "baz", "${FOO} should expand to 'baz', got: " ~ result); + } + + // --- $$ → literal $ --- + { + Environment env; + string result = expand("prefix $$ suffix", &env); + assert(result == "prefix $ suffix", "$$ should become '$', got: " ~ result); + + result = expand("$$$$", &env); + assert(result == "$$", "$$$$ should become '$$', got: " ~ result); + } + + // --- $@ → current target --- + { + Environment env; + string result = expand("$@", &env, "myfile.o"); + assert(result == "myfile.o", "$@ should be target name, got: " ~ result); + } + + // --- $< → first prerequisite --- + { + Environment env; + string result = expand("$<", &env, "", ["a.c", "b.c"]); + assert(result == "a.c", "$< should be first prereq, got: " ~ result); + + result = expand("$<", &env, "", []); + assert(result == "", "$< with no prereqs should be empty, got: " ~ result); + } + + // --- $^ → all prereqs, unique --- + { + Environment env; + string result = expand("$^", &env, "", ["a.c", "b.c", "a.c"]); + assert(result == "a.c b.c", "$^ should dedupe prereqs, got: " ~ result); + } + + // --- $+ → all prereqs, duplicates preserved --- + { + Environment env; + string result = expand("$+", &env, "", ["a.c", "b.c", "a.c"]); + assert(result == "a.c b.c a.c", "$+ should preserve duplicates, got: " ~ result); + } + + // --- $* → stem --- + { + Environment env; + string result = expand("$*", &env, "", [], "foo"); + assert(result == "foo", "$* should be stem, got: " ~ result); + } + + // --- $% → "" (not implemented) --- + { + Environment env; + string result = expand("$%", &env); + assert(result == "", "$% should be empty (not implemented), got: " ~ result); + } + + // --- $| → "" (not implemented) --- + { + Environment env; + string result = expand("$|", &env); + assert(result == "", "$| should be empty (not implemented), got: " ~ result); + } + + // --- $(@D) → directory part of target --- + { + Environment env; + string result = expand("$(@D)", &env, "src/sub/file.o"); + assert(result == "src/sub/", "$(@D) should be dir part, got: " ~ result); + + result = expand("$(@D)", &env, "file.o"); + assert(result == "./", "$(@D) with no slash should be './', got: " ~ result); + } + + // --- $(@F) → file part of target --- + { + Environment env; + string result = expand("$(@F)", &env, "src/sub/file.o"); + assert(result == "file.o", "$(@F) should be file part, got: " ~ result); + + result = expand("$(@F)", &env, "file.o"); + assert(result == "file.o", "$(@F) with no slash should be full name, got: " ~ result); + } + + // --- $("); + string result = expand("$(call wrap,$(VAL))", &env); + assert(result == "", "call with nested var in arg failed, got: " ~ result); + } + + // --- Circular reference detection --- + { + Environment env; + env.set("A", "$(B)"); + env.set("B", "$(A)"); + string result = expand("$(A)", &env); + assert(result == "", "Circular A→B→A should return empty, got: " ~ result); + } + + // --- Circular self-reference --- + { + Environment env; + env.set("X", "$(X)"); + string result = expand("$(X)", &env); + assert(result == "", "Self-circular X→X should return empty, got: " ~ result); + } + + // --- Deeply nested expansion --- + { + Environment env; + env.set("A", "1$(B)"); + env.set("B", "2$(C)"); + env.set("C", "3"); + string result = expand("$(A)", &env); + assert(result == "123", "Deeply nested A→B→C failed, got: " ~ result); + } + + // --- Multiple $ in same string --- + { + Environment env; + env.set("NAME", "Antelope"); + env.set("VER", "1.0"); + string result = expand("$(NAME) v$(VER) $$HOME", &env); + assert(result == "Antelope v1.0 $HOME", + "Multiple $ expansion failed, got: " ~ result); + } + + // --- Literal string passthrough (no $) --- + { + Environment env; + string result = expand("hello world", &env); + assert(result == "hello world", "Plain string should pass through, got: " ~ result); + } + + // --- $? (newer prereqs) — all prereqs returned when target doesn't exist --- + { + Environment env; + // Both target ("") and prereqs ("a.c", "b.c") are non-existent files, + // so getTimestamp returns -1 for all. Since targetTime == -1, + // all prereqs are considered newer. + string result = expand("$?", &env, "", ["a.c", "b.c", "a.c"]); + assert(result == "a.c b.c a.c", "$? should return all when target absent, got: " ~ result); + } + + // --- Null env pointer — all vars → "" --- + { + string result = expand("$(ANYTHING)", null); + assert(result == "", "Null env should treat all vars as undefined, got: " ~ result); + } + + // --- call with $0 → empty --- + { + Environment env; + env.set("showall", "$0 $1 $2"); + string result = expand("$(call showall,foo,bar)", &env); + // $0 is empty, $1=foo, $2=bar + assert(result == " foo bar", "call with $0, got: " ~ result); + } + + // --- call with nested function name expansion --- + { + Environment env; + env.set("WHICH", "upper"); + env.set("upper", "[$1]"); + string result = expand("$(call $(WHICH),text)", &env); + assert(result == "[text]", "call with nested func name, got: " ~ result); + } + + // --- Unclosed $( → output literally --- + { + Environment env; + env.set("FOO", "bar"); + string result = expand("start $(FOO", &env); + assert(result == "start $(FOO", "Unclosed $( should be literal, got: " ~ result); + } +} + +/// Check if a word is a known GNU Make built-in function name. +bool isBuiltinFunction(string word) +{ + switch (word) + { + case "subst": case "patsubst": case "strip": case "findstring": + case "filter": case "filter-out": case "sort": case "word": + case "words": case "wordlist": case "firstword": case "lastword": + case "dir": case "notdir": case "suffix": case "basename": + case "addsuffix": case "addprefix": case "join": + case "wildcard": case "realpath": case "abspath": + case "shell": case "error": case "warning": case "info": + case "foreach": case "call": case "value": case "origin": case "flavor": + return true; + default: + return false; + } +} + +/// Evaluate a built-in function call from expansion context. +private string evaluateBuiltinCall(string funcName, string content, Environment* env, + string currentTarget = "", string[] currentPrereqs = [], string stem = "") +{ + import antelope.evaluator.functions; + import antelope.parser.functions; + + // Parse arguments with nesting-aware comma splitting. + // Commas inside nested $(...) or ${...} are NOT argument separators. + string[] args; + size_t start = funcName.length; + if (start < content.length && content[start] == ' ') + start++; + + string rest = content[start .. $]; + size_t segStart = 0; + int depth = 0; // paren/brace nesting depth + for (size_t i = 0; i < rest.length; i++) + { + char c = rest[i]; + if (c == '(' || c == '{') depth++; + else if (c == ')' || c == '}') { if (depth > 0) depth--; } + else if (c == ',' && depth == 0) + { + args ~= rest[segStart .. i].strip; + segStart = i + 1; + } + } + // Last segment + if (segStart < rest.length) + args ~= rest[segStart .. $].strip; + + // Recursively expand each argument before dispatching to the function. + // Pass currentTarget/prereqs/stem so $@, $<, $^ work inside function args. + // Functions that manage their own expansion (foreach, if, or, and) + // receive raw (unexpanded) arguments. + import antelope.parser.functions; + BuiltinFunction bf = builtinFromName(funcName); + bool preExpand = !isSelfExpanding(bf); + string[] expandedArgs; + foreach (arg; args) + { + if (preExpand) + expandedArgs ~= expand(arg, env, currentTarget, currentPrereqs, stem); + else + expandedArgs ~= arg; + } + + return evaluateFunction(bf, expandedArgs, env); +} + +/// Returns true for functions that manage their own argument expansion +/// (matching GNU Make's `expand_args=0` flag). +private bool isSelfExpanding(BuiltinFunction bf) +{ + import antelope.parser.functions; + switch (bf) + { + case BuiltinFunction.foreach_: + return true; + default: + return false; + } +} + +/// Map function name string to BuiltinFunction enum. +BuiltinFunction builtinFromName(string name) +{ + import antelope.parser.functions; + switch (name) + { + case "subst": return BuiltinFunction.subst; + case "patsubst": return BuiltinFunction.patsubst; + case "strip": return BuiltinFunction.strip; + case "findstring": return BuiltinFunction.findstring; + case "filter": return BuiltinFunction.filter; + case "filter-out": return BuiltinFunction.filter_out; + case "sort": return BuiltinFunction.sort; + case "word": return BuiltinFunction.word; + case "words": return BuiltinFunction.words; + case "wordlist": return BuiltinFunction.wordlist; + case "firstword": return BuiltinFunction.firstword; + case "lastword": return BuiltinFunction.lastword; + case "dir": return BuiltinFunction.dir; + case "notdir": return BuiltinFunction.notdir; + case "suffix": return BuiltinFunction.suffix; + case "basename": return BuiltinFunction.basename; + case "addsuffix": return BuiltinFunction.addsuffix; + case "addprefix": return BuiltinFunction.addprefix; + case "join": return BuiltinFunction.join; + case "wildcard": return BuiltinFunction.wildcard; + case "realpath": return BuiltinFunction.realpath; + case "abspath": return BuiltinFunction.abspath; + case "shell": return BuiltinFunction.shell; + case "error": return BuiltinFunction.error; + case "warning": return BuiltinFunction.warning; + case "info": return BuiltinFunction.info; + case "foreach": return BuiltinFunction.foreach_; + case "call": return BuiltinFunction.call; + case "value": return BuiltinFunction.value; + case "origin": return BuiltinFunction.origin; + case "flavor": return BuiltinFunction.flavor; + default: return BuiltinFunction.info; + } +} diff --git a/source/antelope/evaluator/functions.d b/source/antelope/evaluator/functions.d new file mode 100644 index 0000000..06dcf7a --- /dev/null +++ b/source/antelope/evaluator/functions.d @@ -0,0 +1,1299 @@ +/// Built-in function evaluation at runtime. +/// +/// Dispatches GNU Make built-in function calls to their implementations. +/// Critical functions (subst, patsubst, strip, wildcard, shell, error, +/// warning, info, sort, words) are fully implemented. Unsupported +/// functions return an empty string, matching GNU Make behaviour. +module antelope.evaluator.functions; + +import antelope.parser.functions; +import antelope.shell.environment; +import antelope.shell.process; +import antelope.filesystem.files; +import antelope.diagnostics.output; +import antelope.diagnostics.warnings; +import antelope.diagnostics.errors; +import std.algorithm : canFind, sort; +import std.array : split; +import std.conv : to; +import std.file : exists; +import std.stdio : stderr; +import std.string : indexOf, lastIndexOf, join, strip; + +// --- Helper: substring replace-all (no regex dependency) --- + +/// Replace every non-overlapping occurrence of `from` with `to` in `text`. +/// Returns `text` unchanged when `from` is empty (no infinite loop). +private string replaceAll(string text, string from, string to) +{ + if (from.length == 0) + return text; + + string result; + size_t lastIdx = 0; + while (true) + { + auto idx = indexOf(text, from, lastIdx); + if (idx == -1) + { + result ~= text[lastIdx .. $]; + break; + } + result ~= text[lastIdx .. idx]; + result ~= to; + lastIdx = idx + from.length; + } + return result; +} + +/// Match a single word against a `%`-wildcard pattern. +/// +/// If the pattern contains no `%`, the word must match exactly. +/// Otherwise `%` matches zero or more characters. +private bool matchPattern(string word, string pattern) +{ + auto wildPos = indexOf(pattern, '%'); + if (wildPos == -1) + return word == pattern; + + string prefix = pattern[0 .. wildPos]; + string suffix = pattern[wildPos + 1 .. $]; + + return word.length >= prefix.length + suffix.length + && word[0 .. prefix.length] == prefix + && word[$ - suffix.length .. $] == suffix; +} + +/// Split `text` on whitespace, keep or discard words matching `pattern`. +private string filterWords(string pattern, string text, bool keepMatches) +{ + auto words = split(text); + string[] result; + foreach (word; words) + { + if (matchPattern(word, pattern) == keepMatches) + result ~= word; + } + return join(result, " "); +} + +// --- Public API --- + +/// Evaluate a built-in function call and return the result. +/// +/// Params: +/// func = the built-in function to invoke +/// args = positional arguments as parsed from the call site +/// env = optional pointer to an Environment for variable lookups +/// +/// Returns: the expanded string result (empty string for unsupported +/// functions and for functions whose return value is void). +string evaluateFunction(BuiltinFunction func, string[] args, Environment* env = null) +{ + final switch (func) + { + // --- String substitution --- + case BuiltinFunction.subst: + // $(subst from,to,text) — replace all occurrences of from with to + if (args.length < 3) + return ""; + return replaceAll(args[2], args[0], args[1]); + + // --- Pattern substitution --- + case BuiltinFunction.patsubst: + // $(patsubst pattern,replacement,text) + // Words in text matching pattern (where % matches a stem) are + // replaced with the replacement where % expands to that stem. + // Non-matching words pass through unchanged. + if (args.length < 3) + return ""; + + { + string pattern = args[0]; + string replacement = args[1]; + string text = args[2]; + + auto wildPos = indexOf(pattern, '%'); + // Pattern must contain exactly one '%' + if (wildPos == -1) + return text; + + string prefix = pattern[0 .. wildPos]; + string suffix = pattern[wildPos + 1 .. $]; + + auto words = split(text); + string[] result; + foreach (word; words) + { + if (word.length >= prefix.length + suffix.length + && word[0 .. prefix.length] == prefix + && word[$ - suffix.length .. $] == suffix) + { + string stem = word[prefix.length .. $ - suffix.length]; + result ~= replaceAll(replacement, "%", stem); + } + else + { + result ~= word; + } + } + return join(result, " "); + } + + // --- Whitespace stripping --- + case BuiltinFunction.strip: + // $(strip text) — remove leading/trailing whitespace, + // collapse internal whitespace to single spaces. + if (args.length < 1) + return ""; + { + auto trimmed = strip(args[0]); + auto words = split(trimmed); + return join(words, " "); + } + + // --- File globbing --- + case BuiltinFunction.wildcard: + // $(wildcard patterns...) — delegates to filesystem glob + // Handles space-separated pattern lists + if (args.length < 1) + return ""; + { + import std.string : split; + import std.array : appender; + auto allMatches = appender!(string[]); + foreach (pattern; args[0].split(" ")) + { + if (pattern.length == 0) continue; + auto matches = glob(pattern); + foreach (m; matches) + allMatches.put(m); + } + auto result = allMatches.data; + import std.algorithm : sort; + sort(result); + return join(result, " "); + } + + // --- Shell command execution --- + case BuiltinFunction.shell: + // $(shell command) — execute via shell and capture stdout + if (args.length < 1) + return ""; + try + { + import std.process : executeShell; + auto result = executeShell(args[0]); + import std.string : stripRight; + return result.output.stripRight(); + } + catch (Exception) + { + return ""; + } + + // --- Error (fatal message) --- + case BuiltinFunction.error: + // $(error text) — print text to stderr, return empty string + // GNU Make: $(error ...) causes make to stop with an error. + // Future enhancement: raise an AntelopeError to abort. + if (args.length > 0) + stderr.writeln("antelope: error: ", args[0]); + return ""; + + // --- Warning --- + case BuiltinFunction.warning: + // $(warning text) — issue a warning, return empty string + if (args.length > 0) + warn(WarningKind.deprecatedFeature, args[0]); + return ""; + + // --- Info --- + case BuiltinFunction.info: + // $(info text) — log at normal level, return empty string + if (args.length > 0) + log(LogLevel.normal, args[0]); + return ""; + + // --- Sort --- + case BuiltinFunction.sort: + // $(sort list) — split on whitespace, sort lexicographically, + // remove duplicates, join with space. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + sort(words); + // Remove duplicate adjacent entries + string[] deduped; + foreach (i, word; words) + { + if (i == 0 || word != words[i - 1]) + deduped ~= word; + } + return join(deduped, " "); + } + + // --- Word count --- + case BuiltinFunction.words: + // $(words text) — return number of whitespace-separated words + if (args.length < 1) + return "0"; + { + auto w = split(args[0]); + return w.length.to!string; + } + + // --- Substring search --- + case BuiltinFunction.findstring: + // $(findstring find,in) — return `find` if it appears in `in`, + // otherwise return the empty string. + if (args.length < 2) + return ""; + return args[1].canFind(args[0]) ? args[0] : ""; + + // --- Filter (keep matching words) --- + case BuiltinFunction.filter: + // $(filter pattern,text) — return words that match %-wildcard pattern. + if (args.length < 2) + return ""; + return filterWords(args[0], args[1], true); + + // --- Filter-out (remove matching words) --- + case BuiltinFunction.filter_out: + // $(filter-out pattern,text) — return words that do NOT match. + if (args.length < 2) + return ""; + return filterWords(args[0], args[1], false); + + // --- Nth word (1-indexed) --- + case BuiltinFunction.word: + // $(word n,text) — return the nth whitespace-separated word, + // or empty string if n is out of range. + if (args.length < 2) + return ""; + { + auto words = split(args[1]); + auto n = args[0].strip.to!ptrdiff_t; + if (n < 1 || n > cast(ptrdiff_t) words.length) + return ""; + return words[n - 1]; + } + + // --- Sublist of words --- + case BuiltinFunction.wordlist: + // $(wordlist s,e,text) — return words from index s to e inclusive + // (1-indexed). Clamp to available range; return "" if s > e. + if (args.length < 3) + return ""; + { + auto words = split(args[2]); + auto s = args[0].strip.to!ptrdiff_t; + auto e = args[1].strip.to!ptrdiff_t; + if (s < 1) + s = 1; + if (e > cast(ptrdiff_t) words.length) + e = cast(ptrdiff_t) words.length; + if (s > e) + return ""; + return join(words[s - 1 .. e], " "); + } + + // --- First word --- + case BuiltinFunction.firstword: + // $(firstword text) — return the first whitespace-separated word. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + return words.length > 0 ? words[0] : ""; + } + + // --- Last word --- + case BuiltinFunction.lastword: + // $(lastword text) — return the last whitespace-separated word. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + return words.length > 0 ? words[$ - 1] : ""; + } + + // --- Directory part --- + case BuiltinFunction.dir: + // $(dir names…) — extract directory part (up to and including + // the last `/`). Names without `/` become `./`. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + string[] result; + foreach (w; words) + { + auto idx = lastIndexOf(w, '/'); + if (idx == -1) + result ~= "./"; + else + result ~= w[0 .. idx + 1]; + } + return join(result, " "); + } + + // --- Non-directory (filename) part --- + case BuiltinFunction.notdir: + // $(notdir names…) — extract everything after the last `/`. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + string[] result; + foreach (w; words) + { + auto idx = lastIndexOf(w, '/'); + if (idx == -1) + result ~= w; + else + result ~= w[idx + 1 .. $]; + } + return join(result, " "); + } + + // --- Suffix --- + case BuiltinFunction.suffix: + // $(suffix names…) — extract the suffix (starting with the last `.`). + // Names with no `.` return the empty string. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + string[] result; + foreach (w; words) + { + auto idx = lastIndexOf(w, '.'); + if (idx == -1) + result ~= ""; + else + result ~= w[idx .. $]; + } + return join(result, " "); + } + + // --- Basename (strip suffix) --- + case BuiltinFunction.basename: + // $(basename names…) — strip the suffix (everything from the last `.` + // onward). Names with no `.` are returned unchanged. + if (args.length < 1) + return ""; + { + auto words = split(args[0]); + string[] result; + foreach (w; words) + { + auto idx = lastIndexOf(w, '.'); + if (idx == -1) + result ~= w; + else + result ~= w[0 .. idx]; + } + return join(result, " "); + } + + // --- Append suffix to each word --- + case BuiltinFunction.addsuffix: + // $(addsuffix suffix,names…) — append `suffix` to each + // whitespace-separated word in `names`. + if (args.length < 2) + return ""; + { + auto suffix = args[0]; + auto words = split(args[1]); + string[] result; + foreach (w; words) + result ~= w ~ suffix; + return join(result, " "); + } + + // --- Prepend prefix to each word --- + case BuiltinFunction.addprefix: + // $(addprefix prefix,names…) — prepend `prefix` to each + // whitespace-separated word in `names`. + if (args.length < 2) + return ""; + { + auto prefix = args[0]; + auto words = split(args[1]); + string[] result; + foreach (w; words) + result ~= prefix ~ w; + return join(result, " "); + } + + // --- Word-by-word concatenation --- + case BuiltinFunction.join: + // $(join list1,list2) — concatenate list1 and list2 word by word. + // Extra words from the longer list pass through unchanged. + if (args.length < 2) + return ""; + { + auto list1 = split(args[0]); + auto list2 = split(args[1]); + string[] result; + auto len = list1.length > list2.length ? list1.length : list2.length; + for (size_t i = 0; i < len; i++) + { + string left = i < list1.length ? list1[i] : ""; + string right = i < list2.length ? list2[i] : ""; + result ~= left ~ right; + } + return join(result, " "); + } + + // --- Canonical absolute path (resolves symlinks) --- + case BuiltinFunction.realpath: + // $(realpath names…) — return the canonical absolute path for each + // name, resolving symlinks when the path exists. + if (args.length < 1) + return ""; + { + import antelope.filesystem.paths : resolvePath; + auto words = split(args[0]); + string[] result; + foreach (w; words) + result ~= resolvePath(w); + return join(result, " "); + } + + // --- Absolute path (no symlink resolution) --- + case BuiltinFunction.abspath: + // $(abspath names…) — return the absolute path for each name + // WITHOUT resolving symlinks. + if (args.length < 1) + return ""; + { + import std.path : absolutePath; + auto words = split(args[0]); + string[] result; + foreach (w; words) + result ~= absolutePath(w); + return join(result, " "); + } + + // --- Foreach loop --- + case BuiltinFunction.foreach_: + // $(foreach var,list,text) — for each whitespace-separated word + // in `list`, set `var` to that word and expand `text`, joining + // the results with spaces. + if (args.length < 3) + return ""; + { + auto body = args[2]; + string[] result; + if (env) + { + import antelope.evaluator.expansion; + auto varName = expand(args[0], env); + auto listWords = split(expand(args[1], env)); + foreach (word; listWords) + { + if (word.length == 0) continue; + string saved = env.get(varName); + env.set(varName, word); + scope(exit) env.set(varName, saved); + result ~= expand(body, env); + } + } + else + { + auto varName = strip(args[0]); + auto listWords = split(args[1]); + foreach (word; listWords) + { + if (word.length == 0) continue; + string expanded = body; + expanded = replaceAll(expanded, "$(" ~ varName ~ ")", word); + expanded = replaceAll(expanded, "${" ~ varName ~ "}", word); + result ~= expanded; + } + } + return join(result, " "); + } + + // --- Call (variable invocation with positional args) --- + case BuiltinFunction.call: + // $(call variable,param1,…) — expand the variable's value, + // substituting $1, $2, … with the given parameters. + if (args.length < 1) + return ""; + { + import antelope.evaluator.expansion; + auto varName = strip(args[0]); + string body = env ? env.get(varName) : ""; + if (body.length == 0) + return ""; + // Set positional params in env and expand + string[] saved; + foreach (i, param; args[1 .. $]) + { + auto num = (i + 1).to!string; + saved ~= env ? env.get(num) : ""; + if (env) env.set(num, param); + } + scope(exit) { + foreach (i, param; args[1 .. $]) + if (env) env.set((i + 1).to!string, i < saved.length ? saved[i] : ""); + } + return expand(body, env); + } + + // --- Un-expanded variable value --- + case BuiltinFunction.value: + // $(value var) — return the un-expanded value of the variable. + if (args.length < 1) + return ""; + return env ? env.get(args[0]) : ""; + + // --- Variable origin --- + case BuiltinFunction.origin: + // $(origin var) — return a string describing where the variable + // was defined: "undefined", "default", "environment", "file", + // "command line", "override", or "automatic". + if (args.length < 1) + return "undefined"; + return env && env.hasKey(args[0]) ? "file" : "undefined"; + + // --- Variable flavor --- + case BuiltinFunction.flavor: + // $(flavor var) — return "recursive" (= assignment), "simple" + // (:= assignment), or "undefined" if the variable does not exist. + if (args.length < 1) + return "undefined"; + return env && env.hasKey(args[0]) ? "recursive" : "undefined"; + } +} + +// --- Unit tests --- + +/// +unittest +{ + // subst: basic replacement + { + auto r = evaluateFunction(BuiltinFunction.subst, + ["ee", "EE", "feet on the street"]); + assert(r == "fEEt on the strEEt"); + } + + // subst: no match + { + auto r = evaluateFunction(BuiltinFunction.subst, + ["xx", "XX", "hello world"]); + assert(r == "hello world"); + } + + // subst: empty from string returns text unchanged + { + auto r = evaluateFunction(BuiltinFunction.subst, + ["", "X", "hello"]); + assert(r == "hello"); + } + + // subst: too few args + { + auto r = evaluateFunction(BuiltinFunction.subst, + ["a"]); + assert(r == ""); + } +} + +/// +unittest +{ + // patsubst: basic pattern substitution + { + auto r = evaluateFunction(BuiltinFunction.patsubst, + ["%.c", "%.o", "foo.c bar.c baz.h"]); + assert(r == "foo.o bar.o baz.h"); + } + + // patsubst: no % in pattern passes through + { + auto r = evaluateFunction(BuiltinFunction.patsubst, + ["xyz", "abc", "hello world"]); + assert(r == "hello world"); + } + + // patsubst: stem extraction + { + auto r = evaluateFunction(BuiltinFunction.patsubst, + ["src/%.c", "obj/%.o", "src/foo.c src/bar.c"]); + assert(r == "obj/foo.o obj/bar.o"); + } + + // patsubst: too few args + { + auto r = evaluateFunction(BuiltinFunction.patsubst, + ["a"]); + assert(r == ""); + } +} + +/// +unittest +{ + // strip: leading/trailing whitespace + { + auto r = evaluateFunction(BuiltinFunction.strip, + [" hello world "]); + assert(r == "hello world"); + } + + // strip: collapse internal whitespace + { + auto r = evaluateFunction(BuiltinFunction.strip, + ["a b\t\tc\n\nd"]); + assert(r == "a b c d"); + } + + // strip: already clean + { + auto r = evaluateFunction(BuiltinFunction.strip, + ["hello"]); + assert(r == "hello"); + } + + // strip: empty args + { + auto r = evaluateFunction(BuiltinFunction.strip, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // wildcard: delegate to glob (test with a temp file) + import std.file : exists, mkdir, rmdir, write; + import std.path : buildPath; + + auto testDir = "fn_wildcard_test_xx"; + scope (exit) + { + if (exists(testDir)) + { + import std.file : remove; + remove(testDir ~ "/a.txt"); + remove(testDir ~ "/b.txt"); + remove(testDir ~ "/skip.d"); + rmdir(testDir); + } + } + + mkdir(testDir); + write(testDir ~ "/a.txt", ""); + write(testDir ~ "/b.txt", ""); + write(testDir ~ "/skip.d", ""); + + auto r = evaluateFunction(BuiltinFunction.wildcard, + [testDir ~ "/*.txt"]); + assert(r.canFind("a.txt")); + assert(r.canFind("b.txt")); + assert(!r.canFind("skip.d")); +} + +/// +unittest +{ + // wildcard: no matches returns empty string + auto r = evaluateFunction(BuiltinFunction.wildcard, + ["/nonexistent_path_abc123/*.xyz"]); + assert(r == ""); +} + +/// +unittest +{ + // sort: basic sorting + { + auto r = evaluateFunction(BuiltinFunction.sort, + ["z y x a b c"]); + assert(r == "a b c x y z"); + } + + // sort: remove duplicates + { + auto r = evaluateFunction(BuiltinFunction.sort, + ["b a b a c c"]); + assert(r == "a b c"); + } + + // sort: single word + { + auto r = evaluateFunction(BuiltinFunction.sort, + ["hello"]); + assert(r == "hello"); + } + + // sort: empty args + { + auto r = evaluateFunction(BuiltinFunction.sort, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // words: count words + { + auto r = evaluateFunction(BuiltinFunction.words, + ["a b c d"]); + assert(r == "4"); + } + + // words: single word + { + auto r = evaluateFunction(BuiltinFunction.words, + ["hello"]); + assert(r == "1"); + } + + // words: empty string + { + auto r = evaluateFunction(BuiltinFunction.words, + [""]); + assert(r == "0"); + } + + // words: no args + { + auto r = evaluateFunction(BuiltinFunction.words, + []); + assert(r == "0"); + } +} + +/// +unittest +{ + // shell: basic command captures stdout + { + auto r = evaluateFunction(BuiltinFunction.shell, + ["echo hello"]); + assert(r == "hello"); + } + + // shell: empty args + { + auto r = evaluateFunction(BuiltinFunction.shell, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // error: prints to stderr, returns empty + { + import std.stdio; + auto r = evaluateFunction(BuiltinFunction.error, + ["something went wrong"]); + assert(r == ""); + } + + // error: no args + { + auto r = evaluateFunction(BuiltinFunction.error, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // warning: calls warn, returns empty + { + auto r = evaluateFunction(BuiltinFunction.warning, + ["deprecated usage"]); + assert(r == ""); + } + + // warning: no args + { + auto r = evaluateFunction(BuiltinFunction.warning, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // info: calls log, returns empty + { + auto r = evaluateFunction(BuiltinFunction.info, + ["build started"]); + assert(r == ""); + } + + // info: no args + { + auto r = evaluateFunction(BuiltinFunction.info, + []); + assert(r == ""); + } +} + +/// +unittest +{ + // findstring: found + assert(evaluateFunction(BuiltinFunction.findstring, + ["foo", "foobar"]) == "foo"); + // findstring: not found + assert(evaluateFunction(BuiltinFunction.findstring, + ["baz", "foobar"]) == ""); + // findstring: empty find string always matches + assert(evaluateFunction(BuiltinFunction.findstring, + ["", "anything"]) == ""); + // findstring: too few args + assert(evaluateFunction(BuiltinFunction.findstring, ["x"]) == ""); +} + +/// +unittest +{ + // filter: basic %-match + { + auto r = evaluateFunction(BuiltinFunction.filter, + ["%.c", "foo.c bar.h baz.c"]); + assert(r == "foo.c baz.c"); + } + // filter: exact match (no %) + { + auto r = evaluateFunction(BuiltinFunction.filter, + ["foo", "foo bar baz"]); + assert(r == "foo"); + } + // filter: % matches everything + { + auto r = evaluateFunction(BuiltinFunction.filter, + ["%", "a b c"]); + assert(r == "a b c"); + } + // filter: too few args + assert(evaluateFunction(BuiltinFunction.filter, ["x"]) == ""); +} + +/// +unittest +{ + // filter-out: basic + { + auto r = evaluateFunction(BuiltinFunction.filter_out, + ["%.h", "foo.c bar.h baz.c"]); + assert(r == "foo.c baz.c"); + } + // filter-out: exact match + { + auto r = evaluateFunction(BuiltinFunction.filter_out, + ["bar", "foo bar baz"]); + assert(r == "foo baz"); + } + // filter-out: too few args + assert(evaluateFunction(BuiltinFunction.filter_out, ["x"]) == ""); +} + +/// +unittest +{ + // word: basic + assert(evaluateFunction(BuiltinFunction.word, ["2", "a b c"]) == "b"); + assert(evaluateFunction(BuiltinFunction.word, ["1", "a b c"]) == "a"); + assert(evaluateFunction(BuiltinFunction.word, ["3", "a b c"]) == "c"); + // word: out of range + assert(evaluateFunction(BuiltinFunction.word, ["0", "a b c"]) == ""); + assert(evaluateFunction(BuiltinFunction.word, ["4", "a b c"]) == ""); + // word: too few args + assert(evaluateFunction(BuiltinFunction.word, ["x"]) == ""); +} + +/// +unittest +{ + // wordlist: basic range + assert(evaluateFunction(BuiltinFunction.wordlist, + ["2", "3", "a b c d"]) == "b c"); + // wordlist: full range + assert(evaluateFunction(BuiltinFunction.wordlist, + ["1", "4", "a b c d"]) == "a b c d"); + // wordlist: s > e → empty + assert(evaluateFunction(BuiltinFunction.wordlist, + ["3", "2", "a b c d"]) == ""); + // wordlist: e beyond end → clamped + assert(evaluateFunction(BuiltinFunction.wordlist, + ["3", "10", "a b c d"]) == "c d"); + // wordlist: s < 1 → clamped to 1 + assert(evaluateFunction(BuiltinFunction.wordlist, + ["0", "2", "a b c d"]) == "a b"); + // wordlist: too few args + assert(evaluateFunction(BuiltinFunction.wordlist, ["x"]) == ""); +} + +/// +unittest +{ + // firstword: basic + assert(evaluateFunction(BuiltinFunction.firstword, + ["a b c"]) == "a"); + // firstword: single word + assert(evaluateFunction(BuiltinFunction.firstword, + ["hello"]) == "hello"); + // firstword: empty input + assert(evaluateFunction(BuiltinFunction.firstword, []) == ""); + assert(evaluateFunction(BuiltinFunction.firstword, [""]) == ""); +} + +/// +unittest +{ + // lastword: basic + assert(evaluateFunction(BuiltinFunction.lastword, + ["a b c"]) == "c"); + // lastword: single word + assert(evaluateFunction(BuiltinFunction.lastword, + ["hello"]) == "hello"); + // lastword: empty input + assert(evaluateFunction(BuiltinFunction.lastword, []) == ""); +} + +/// +unittest +{ + // dir: extract directory part + { + auto r = evaluateFunction(BuiltinFunction.dir, + ["src/foo.c include/bar.h"]); + assert(r == "src/ include/"); + } + // dir: no slash → ./ + { + auto r = evaluateFunction(BuiltinFunction.dir, + ["Makefile"]); + assert(r == "./"); + } + // dir: mixed + { + auto r = evaluateFunction(BuiltinFunction.dir, + ["src/sub/file.o plain.txt"]); + assert(r == "src/sub/ ./"); + } + // dir: too few args + assert(evaluateFunction(BuiltinFunction.dir, []) == ""); +} + +/// +unittest +{ + // notdir: extract filename + { + auto r = evaluateFunction(BuiltinFunction.notdir, + ["src/foo.c include/bar.h"]); + assert(r == "foo.c bar.h"); + } + // notdir: no slash → unchanged + { + auto r = evaluateFunction(BuiltinFunction.notdir, + ["Makefile"]); + assert(r == "Makefile"); + } + // notdir: mixed + { + auto r = evaluateFunction(BuiltinFunction.notdir, + ["src/sub/file.o plain.txt"]); + assert(r == "file.o plain.txt"); + } + // notdir: too few args + assert(evaluateFunction(BuiltinFunction.notdir, []) == ""); +} + +/// +unittest +{ + // suffix: extract suffix + { + auto r = evaluateFunction(BuiltinFunction.suffix, + ["foo.c bar.h baz"]); + assert(r == ".c .h "); + } + // suffix: no dot → empty + { + auto r = evaluateFunction(BuiltinFunction.suffix, + ["Makefile"]); + assert(r == ""); + } + // suffix: double extension takes last + { + auto r = evaluateFunction(BuiltinFunction.suffix, + ["archive.tar.gz"]); + assert(r == ".gz"); + } + // suffix: too few args + assert(evaluateFunction(BuiltinFunction.suffix, []) == ""); +} + +/// +unittest +{ + // basename: strip suffix + { + auto r = evaluateFunction(BuiltinFunction.basename, + ["foo.c bar.h baz"]); + assert(r == "foo bar baz"); + } + // basename: no dot → unchanged + { + auto r = evaluateFunction(BuiltinFunction.basename, + ["Makefile"]); + assert(r == "Makefile"); + } + // basename: dotfiles + { + auto r = evaluateFunction(BuiltinFunction.basename, + [".hidden.c secret"]); + assert(r == ".hidden secret"); + } + // basename: too few args + assert(evaluateFunction(BuiltinFunction.basename, []) == ""); +} + +/// +unittest +{ + // addsuffix: basic + assert(evaluateFunction(BuiltinFunction.addsuffix, + [".o", "foo bar baz"]) == "foo.o bar.o baz.o"); + // addsuffix: single word + assert(evaluateFunction(BuiltinFunction.addsuffix, + [".c", "main"]) == "main.c"); + // addsuffix: empty suffix + assert(evaluateFunction(BuiltinFunction.addsuffix, + ["", "a b c"]) == "a b c"); + // addsuffix: too few args + assert(evaluateFunction(BuiltinFunction.addsuffix, ["x"]) == ""); +} + +/// +unittest +{ + // addprefix: basic + assert(evaluateFunction(BuiltinFunction.addprefix, + ["src/", "foo.c bar.c"]) == "src/foo.c src/bar.c"); + // addprefix: empty prefix + assert(evaluateFunction(BuiltinFunction.addprefix, + ["", "a b c"]) == "a b c"); + // addprefix: too few args + assert(evaluateFunction(BuiltinFunction.addprefix, ["x"]) == ""); +} + +/// +unittest +{ + // join: equal length + assert(evaluateFunction(BuiltinFunction.join, + ["a b", "1 2"]) == "a1 b2"); + // join: first list longer + assert(evaluateFunction(BuiltinFunction.join, + ["a b c", "1 2"]) == "a1 b2 c"); + // join: second list longer + assert(evaluateFunction(BuiltinFunction.join, + ["a", "1 2 3"]) == "a1 2 3"); + // join: both empty + assert(evaluateFunction(BuiltinFunction.join, + ["", ""]) == ""); + // join: too few args + assert(evaluateFunction(BuiltinFunction.join, ["x"]) == ""); +} + +/// +unittest +{ + // foreach: basic + assert(evaluateFunction(BuiltinFunction.foreach_, + ["f", "foo bar", "$(f).o"]) == "foo.o bar.o"); + // foreach: single word in list + assert(evaluateFunction(BuiltinFunction.foreach_, + ["x", "hello", "<$(x)>"]) == ""); + // foreach: empty list + assert(evaluateFunction(BuiltinFunction.foreach_, + ["x", "", "$(x)"]) == ""); + // foreach: too few args + assert(evaluateFunction(BuiltinFunction.foreach_, ["x"]) == ""); +} + +/// +unittest +{ + // call: basic positional substitution + { + Environment env; + env.set("reverse", "$2 $1"); + auto r = evaluateFunction(BuiltinFunction.call, + ["reverse", "a", "b"], &env); + assert(r == "b a"); + } + // call: single argument + { + Environment env; + env.set("wrap", "[$1]"); + auto r = evaluateFunction(BuiltinFunction.call, + ["wrap", "hello"], &env); + assert(r == "[hello]"); + } + // call: no env → empty + assert(evaluateFunction(BuiltinFunction.call, + ["unknown", "x"]) == ""); + // call: too few args + assert(evaluateFunction(BuiltinFunction.call, []) == ""); +} + +/// +unittest +{ + // value: returns unexpanded variable value + { + Environment env; + env.set("FOO", "bar"); + auto r = evaluateFunction(BuiltinFunction.value, + ["FOO"], &env); + assert(r == "bar"); + } + // value: undefined variable → "" + assert(evaluateFunction(BuiltinFunction.value, + ["UNDEFINED"]) == ""); + // value: too few args + assert(evaluateFunction(BuiltinFunction.value, []) == ""); +} + +/// +unittest +{ + // origin: defined variable + { + Environment env; + env.set("FOO", "bar"); + auto r = evaluateFunction(BuiltinFunction.origin, + ["FOO"], &env); + assert(r == "file"); + } + // origin: undefined variable + assert(evaluateFunction(BuiltinFunction.origin, + ["UNDEFINED"]) == "undefined"); + // origin: too few args + assert(evaluateFunction(BuiltinFunction.origin, []) == "undefined"); +} + +/// +unittest +{ + // flavor: defined variable (all = assignments are "recursive") + { + Environment env; + env.set("FOO", "bar"); + auto r = evaluateFunction(BuiltinFunction.flavor, + ["FOO"], &env); + assert(r == "recursive"); + } + // flavor: undefined variable + assert(evaluateFunction(BuiltinFunction.flavor, + ["UNDEFINED"]) == "undefined"); + // flavor: too few args + assert(evaluateFunction(BuiltinFunction.flavor, []) == "undefined"); +} + +/// +unittest +{ + // realpath: resolves path (must exist to resolve symlinks) + import std.file : mkdir, rmdir, write; + import std.path : buildPath; + auto testDir = "fn_realpath_test_xx"; + scope (exit) + { + import std.file : remove; + if (exists(testDir)) + { + remove(testDir ~ "/a.txt"); + rmdir(testDir); + } + } + mkdir(testDir); + write(testDir ~ "/a.txt", ""); + { + auto r = evaluateFunction(BuiltinFunction.realpath, + [testDir]); + assert(r.length > 0); + assert(r.canFind(testDir)); + } + // realpath: too few args + assert(evaluateFunction(BuiltinFunction.realpath, []) == ""); +} + +/// +unittest +{ + // abspath: returns absolute path + { + auto r = evaluateFunction(BuiltinFunction.abspath, + ["."]); + assert(r.length > 1); + assert(r[0] == '/'); + } + // abspath: multiple paths + { + auto r = evaluateFunction(BuiltinFunction.abspath, + ["./src ./include"]); + assert(r.canFind("/src")); + assert(r.canFind("/include")); + } + // abspath: too few args + assert(evaluateFunction(BuiltinFunction.abspath, []) == ""); +} + +/// +unittest +{ + // Verify that all previously-stubbed functions still return "" + // when given insufficient arguments (edge-case coverage). + assert(evaluateFunction(BuiltinFunction.findstring, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.filter, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.filter_out, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.word, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.wordlist, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.dir, []) == ""); + assert(evaluateFunction(BuiltinFunction.notdir, []) == ""); + assert(evaluateFunction(BuiltinFunction.suffix, []) == ""); + assert(evaluateFunction(BuiltinFunction.basename, []) == ""); + assert(evaluateFunction(BuiltinFunction.addsuffix, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.addprefix, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.join, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.realpath, []) == ""); + assert(evaluateFunction(BuiltinFunction.abspath, []) == ""); + assert(evaluateFunction(BuiltinFunction.foreach_, ["x"]) == ""); + assert(evaluateFunction(BuiltinFunction.call, []) == ""); + assert(evaluateFunction(BuiltinFunction.value, []) == ""); + assert(evaluateFunction(BuiltinFunction.origin, []) == "undefined"); + assert(evaluateFunction(BuiltinFunction.flavor, []) == "undefined"); +} + +/// +unittest +{ + // null/empty Environment pointer works + auto r = evaluateFunction(BuiltinFunction.subst, + ["x", "X", "x marks the spot"]); + assert(r == "X marks the spot"); +} diff --git a/source/antelope/filesystem/files.d b/source/antelope/filesystem/files.d new file mode 100644 index 0000000..d4b5aed --- /dev/null +++ b/source/antelope/filesystem/files.d @@ -0,0 +1,143 @@ +/// File discovery and globbing utilities. +module antelope.filesystem.files; + +import std.algorithm; +import std.array; +import std.file; +import std.path; +import std.string; + +/// Find files matching a glob pattern. +/// +/// Supports `*` (single-directory wildcard) and `**` (recursive wildcard). +/// +/// Examples: +/// --- +/// glob("*.d") // all D files in current directory +/// glob("src/**/*.d") // all D files under src/ recursively +/// glob("**") // all files recursively from current directory +/// --- +/// +/// Returns a sorted array of relative paths, or an empty array if no +/// matches are found or the directory does not exist. +string[] glob(string pattern) +{ + string dir; + string filePattern; + SpanMode mode; + + auto doubleStar = pattern.indexOf("**"); + if (doubleStar >= 0) + { + // Extract directory prefix before ** + dir = pattern[0 .. doubleStar]; + if (dir.length == 0) + dir = "."; + else if (dir[$ - 1] == '/' || dir[$ - 1] == '\\') + dir = dir[0 .. $ - 1]; + + // Extract file pattern after **/ + filePattern = pattern[doubleStar + 2 .. $]; + while (filePattern.length > 0 + && (filePattern[0] == '/' || filePattern[0] == '\\')) + filePattern = filePattern[1 .. $]; + + // Bare ** matches everything recursively + if (filePattern.length == 0) + filePattern = "*"; + + mode = SpanMode.depth; + } + else + { + dir = pattern.dirName(); + if (dir.length == 0) + dir = "."; + filePattern = pattern.baseName(); + mode = SpanMode.shallow; + + // When directory contains wildcards (e.g., "src/*"), + // dirEntries can't use them as literal paths. + // Fall back to depth-scan from the last non-wildcard base. + if (dir.indexOf('*') >= 0 || dir.indexOf('?') >= 0) + { + // Find the last component before any wildcard + import std.path : dirSeparator; + auto slash = dir.lastIndexOf('/'); + if (slash < 0) slash = dir.lastIndexOf('\\'); + string baseDir = (slash < 0) ? "." : dir[0 .. slash]; + if (baseDir.length == 0) baseDir = "."; + + // Use depth scan from base, filter by filename pattern + mode = SpanMode.depth; + dir = baseDir; + // filePattern already set to basename (e.g., "*.c") + } + } + + try + { + auto entries = dirEntries(dir, filePattern, mode) + .map!(e => e.name) + .array; + sort(entries); + return entries; + } + catch (Exception) + { + return []; + } +} + +// Coverage for glob function. +unittest +{ + import std.file : mkdir, rmdir, remove; + import std.path : buildPath; + + auto testDir = "glob_test_temp_xx"; + + // Ensure clean state. + scope (exit) + { + if (exists(buildPath(testDir, "sub"))) + rmdir(buildPath(testDir, "sub")); + if (exists(testDir)) + rmdir(testDir); + } + + mkdir(testDir); + std.file.write(buildPath(testDir, "foo.d"), ""); + std.file.write(buildPath(testDir, "bar.d"), ""); + std.file.write(buildPath(testDir, "baz.txt"), ""); + + mkdir(buildPath(testDir, "sub")); + std.file.write(buildPath(testDir, "sub", "qux.d"), ""); + + // Shallow glob — matches *.d in test dir only. + auto dFiles = glob(buildPath(testDir, "*.d")); + assert(dFiles.length == 2); + assert(dFiles.canFind(buildPath(testDir, "foo.d"))); + assert(dFiles.canFind(buildPath(testDir, "bar.d"))); + + // Recursive glob — matches all .d files under test dir. + auto allDFiles = glob(buildPath(testDir, "**/*.d")); + assert(allDFiles.length == 3); + assert(allDFiles.canFind(buildPath(testDir, "sub", "qux.d"))); + + // No matches returns empty array. + auto noMatch = glob(buildPath(testDir, "*.xyz")); + assert(noMatch.length == 0); + + // Nonexistent directory returns empty array. + auto badDir = glob(buildPath("nonexistent_dir_abcdef", "*.d")); + assert(badDir.length == 0); + + // Clean up test files. + remove(buildPath(testDir, "foo.d")); + remove(buildPath(testDir, "bar.d")); + remove(buildPath(testDir, "baz.txt")); + remove(buildPath(testDir, "sub", "qux.d")); + rmdir(buildPath(testDir, "sub")); + rmdir(testDir); +} diff --git a/source/antelope/filesystem/paths.d b/source/antelope/filesystem/paths.d new file mode 100644 index 0000000..63d6b72 --- /dev/null +++ b/source/antelope/filesystem/paths.d @@ -0,0 +1,44 @@ +/// Path resolution, normalization, and working-directory tracking. +module antelope.filesystem.paths; + +import std.path : absolutePath, buildNormalizedPath; +import std.file : exists, readLink; + +/// Resolve a path to its canonical absolute form. +/// +/// Converts relative paths to absolute, normalizes `.` and `..` segments, +/// and resolves symlinks if the path exists. If the path does not exist, +/// returns the best-effort normalized absolute form. +/// +/// Returns: The canonical absolute form of `path`. +string resolvePath(string path) +{ + // Resolve relative to working directory + string result = absolutePath(path); + + // Normalize `.` and `..` segments + result = buildNormalizedPath(result); + + // Resolve symlinks if the path exists + if (exists(result)) + { + try + { + result = readLink(result); + // readLink may return a relative path; make it absolute + result = buildNormalizedPath(absolutePath(result)); + } + catch (Exception) + { + // Not a symlink or readLink failed — keep the normalized path + } + } + + return result; +} + +/// +unittest +{ + assert(resolvePath(".").length > 0); +} diff --git a/source/antelope/filesystem/timestamps.d b/source/antelope/filesystem/timestamps.d new file mode 100644 index 0000000..7a7b48d --- /dev/null +++ b/source/antelope/filesystem/timestamps.d @@ -0,0 +1,115 @@ +/// File timestamp comparison for out-of-date detection. +module antelope.filesystem.timestamps; + +import std.file; +import std.datetime; +import antelope.compatibility.vpath; + +/// Get the last-modified time of a file as a Unix timestamp. +/// +/// Returns: The modification time in seconds since the Unix epoch +/// (1970-01-01T00:00:00Z), or -1 if the file does not +/// exist or cannot be accessed (e.g., permission denied). +long getTimestamp(string path) +{ + try + { + auto mtime = timeLastModified(path); + return mtime.toUnixTime(); + } + catch (FileException) + { + return -1; + } +} + +/// Determine whether a target needs to be rebuilt based on its +/// prerequisites. +/// +/// A target is considered out of date (returning true) when: +/// - It is listed in the phonyTargets set (always rebuild). +/// - The target file does not exist. +/// - Any prerequisite file does not exist (after VPATH search). +/// - Any prerequisite has a newer timestamp than the target. +/// +/// Order-only prerequisites (those after `|` in the rule) are NOT +/// checked for timestamps — they must exist but their modification +/// time does not trigger a rebuild. +/// +/// Params: +/// target = The file or phony target to check. +/// prerequisites = List of prerequisite file paths. +/// phonySet = Optional set of phony target names (may be null). +/// vpath = Optional VPATH config for prerequisite search (may be null). +/// orderOnlyPrereqs = Optional list of order-only prereqs to skip (may be null). +/// +/// Returns: true if the target needs to be rebuilt, false otherwise. +bool needsRebuild(string target, string[] prerequisites, + const bool[string]* phonySet = null, + const(VPathConfig)* vpathConfig = null, + const string[]* orderOnlyPrereqs = null) +{ + // Phony targets always need rebuilding. + if (phonySet !is null && (target in *phonySet) !is null) + return true; + + auto targetTime = getTimestamp(target); + + // Non-existent target must be built. + if (targetTime == -1) + return true; + + foreach (prereq; prerequisites) + { + // Skip order-only prerequisites — they must exist but their + // timestamps should NOT trigger a rebuild. + if (orderOnlyPrereqs !is null) + { + bool isOrderOnly; + foreach (oo; *orderOnlyPrereqs) + { + if (prereq == oo) + { + isOrderOnly = true; + break; + } + } + if (isOrderOnly) + continue; + } + + // Try VPATH resolution if config provided + string resolved = prereq; + if (vpathConfig !is null) + resolved = vpathResolve(prereq, *vpathConfig); + + auto prereqTime = getTimestamp(resolved); + + // Missing prerequisite forces a rebuild. + if (prereqTime == -1) + return true; + + // Prerequisite newer than target → out of date. + if (prereqTime > targetTime) + return true; + } + + return false; +} + +// unittest +unittest +{ + // getTimestamp returns -1 for non-existent files + assert(getTimestamp("/nonexistent_path_xyz_antelope_test") == -1); + + // needsRebuild for non-existent target + assert(needsRebuild("/nonexistent_target_antelope_test", [])); + + // needsRebuild for phony target (using local phony set) + bool[string] phonySet = ["testPhony_antelope": true]; + assert(needsRebuild("testPhony_antelope", [], &phonySet)); + + // needsRebuild with missing prerequisite + assert(needsRebuild("/nonexistent_target_foo", ["/nonexistent_prereq_bar"])); +} diff --git a/source/antelope/parser/ast.d b/source/antelope/parser/ast.d new file mode 100644 index 0000000..e19027b --- /dev/null +++ b/source/antelope/parser/ast.d @@ -0,0 +1,22 @@ +/// Abstract Syntax Tree node definitions. +module antelope.parser.ast; + +/// All node kinds in the AST. +enum AstType +{ + rule_list, + rule, + prerequisite, + recipe_line, + variable_assignment, + directive, + function_call, +} + +/// A node in the AST. +struct AstNode +{ + AstType type; + AstNode[] children; + string data; +} diff --git a/source/antelope/parser/directives.d b/source/antelope/parser/directives.d new file mode 100644 index 0000000..6a20108 --- /dev/null +++ b/source/antelope/parser/directives.d @@ -0,0 +1,19 @@ +/// Parser support for Makefile directives (include, ifdef, ifeq, etc.). +module antelope.parser.directives; + +/// Supported directives. +enum DirectiveType +{ + include, + define, + undefine, + ifdef, + ifndef, + ifeq, + ifneq, + else_, + endif, + export_, + unexport, + vpath, +} diff --git a/source/antelope/parser/functions.d b/source/antelope/parser/functions.d new file mode 100644 index 0000000..ca32ea3 --- /dev/null +++ b/source/antelope/parser/functions.d @@ -0,0 +1,45 @@ +/// Parsing for GNU Make function calls: $(func ...). +module antelope.parser.functions; + +/// Known GNU Make functions. +enum BuiltinFunction +{ + subst, + patsubst, + strip, + findstring, + filter, + filter_out, + sort, + word, + words, + wordlist, + firstword, + lastword, + dir, + notdir, + suffix, + basename, + addsuffix, + addprefix, + join, + wildcard, + realpath, + abspath, + shell, + error, + warning, + info, + foreach_, + call, + value, + origin, + flavor, +} + +/// A parsed function call. +struct FunctionCall +{ + BuiltinFunction func; + string[] arguments; +} diff --git a/source/antelope/parser/lexer.d b/source/antelope/parser/lexer.d new file mode 100644 index 0000000..d1b1f05 --- /dev/null +++ b/source/antelope/parser/lexer.d @@ -0,0 +1,536 @@ +/// Lexical analysis for Antelope build files. +/// +/// Tokenizes GNU Make-compatible syntax including tab-prefixed recipe lines, +/// backslash-newline line continuations, `#` comments, variable references, +/// and all Makefile operators. +module antelope.parser.lexer; + +import antelope.diagnostics.errors; + +/// Token kinds recognized by the lexer. +enum TokenType +{ + identifier, + colon, + doubleColon, /// `::` — double-colon rules + equals, /// `=` — recursive assignment + plusEquals, /// `+=` — append assignment + colonEquals, /// `:=` — immediate assignment + questionEquals, /// `?=` — conditional assignment + dollar, /// `$` — variable/function reference prefix + lparen, /// `(` + rparen, /// `)` + lbrace, /// `{` + rbrace, /// `}` + pipe, /// `|` — order-only prerequisite separator + comma, /// `,` — function argument separator + semicolon, /// `;` — inline recipe separator + hash, /// `#` — comment prefix (not emitted; comments produce newline) + newline, /// end of logical line + eof, /// end of input + tab, /// column-0 tab — recipe line indicator +} + +/// A single token from the input. +struct Token +{ + TokenType type; + string value; + size_t line; + size_t column; +} + +/// Returns true if `c` is a valid character inside an identifier. +private static bool isIdentChar(char c) +{ + import std.ascii : isAlphaNum; + switch (c) + { + case '-', '_', '.', '/', '+', '?', '%', '*', '~', '\\', '@', '<', '^': + return true; + default: + return isAlphaNum(c); + } +} + +/// Lexer state. +struct Lexer +{ + string input; + size_t pos; + size_t line = 1; /// 1-indexed + size_t column; /// 0-indexed + bool done; /// true after eof has been emitted + + /// Produce the next token from the input stream. + Token nextToken() + { + if (done) + return Token(TokenType.eof, "", line, column); + + // --- Skip whitespace and line continuations --- + while (true) + { + // Skip spaces and non-BOL tabs (tabs at column != 0 are whitespace) + while (pos < input.length) + { + char ch = input[pos]; + if (ch == ' ' || (ch == '\t' && column != 0)) + { + pos++; + column++; + } + else + { + break; + } + } + + // Backslash-newline: line continuation — consume both and restart + if (pos < input.length && input[pos] == '\\' && + pos + 1 < input.length && input[pos + 1] == '\n') + { + pos += 2; + line++; + column = 0; + continue; + } + + break; + } + + // --- End of input --- + if (pos >= input.length) + { + done = true; + return Token(TokenType.eof, "", line, column); + } + + // --- Tab at column 0 signals a recipe line --- + if (input[pos] == '\t' && column == 0) + { + size_t tokLine = line; + size_t tokCol = column; + pos++; + column++; + return Token(TokenType.tab, "\t", tokLine, tokCol); + } + + // Save position before consuming the first character + size_t startLine = line; + size_t startCol = column; + char ch = input[pos]; + + // --- Newline --- + if (ch == '\n') + { + pos++; + line++; + column = 0; + return Token(TokenType.newline, "\n", startLine, startCol); + } + + // --- Comment: `#` to end of line (or EOF), returns newline token --- + if (ch == '#') + { + while (pos < input.length && input[pos] != '\n') + { + pos++; + column++; + } + if (pos < input.length && input[pos] == '\n') + { + pos++; + line++; + column = 0; + } + return Token(TokenType.newline, "", startLine, startCol); + } + + // --- Dollar sign (variable/function reference prefix) --- + if (ch == '$') + { + pos++; + column++; + return Token(TokenType.dollar, "$", startLine, startCol); + } + + // --- Colon (single, double, or colon-equals) --- + if (ch == ':') + { + pos++; + column++; + if (pos < input.length) + { + if (input[pos] == ':') + { + pos++; + column++; + return Token(TokenType.doubleColon, "::", startLine, startCol); + } + if (input[pos] == '=') + { + pos++; + column++; + return Token(TokenType.colonEquals, ":=", startLine, startCol); + } + } + return Token(TokenType.colon, ":", startLine, startCol); + } + + // --- Bare equals --- + if (ch == '=') + { + pos++; + column++; + return Token(TokenType.equals, "=", startLine, startCol); + } + + // --- Plus-equals (+=) or plus as identifier start --- + if (ch == '+') + { + if (pos + 1 < input.length && input[pos + 1] == '=') + { + pos += 2; + column += 2; + return Token(TokenType.plusEquals, "+=", startLine, startCol); + } + // Not += — fall through to identifier accumulation with + as first char + } + + // --- Question-equals (?=) or question as identifier start --- + if (ch == '?') + { + if (pos + 1 < input.length && input[pos + 1] == '=') + { + pos += 2; + column += 2; + return Token(TokenType.questionEquals, "?=", startLine, startCol); + } + // Not ?= — fall through to identifier accumulation with ? as first char + } + + // --- Single-character tokens --- + switch (ch) + { + case '|': + pos++; column++; + return Token(TokenType.pipe, "|", startLine, startCol); + case ',': + pos++; column++; + return Token(TokenType.comma, ",", startLine, startCol); + case ';': + pos++; column++; + return Token(TokenType.semicolon, ";", startLine, startCol); + case '(': + pos++; column++; + return Token(TokenType.lparen, "(", startLine, startCol); + case ')': + pos++; column++; + return Token(TokenType.rparen, ")", startLine, startCol); + case '{': + pos++; column++; + return Token(TokenType.lbrace, "{", startLine, startCol); + case '}': + pos++; column++; + return Token(TokenType.rbrace, "}", startLine, startCol); + default: + break; + } + + // --- Accumulate identifier --- + // Build the value character-by-character so that line continuations + // (backslash-newline pairs) are excluded from the identifier text. + char[] buf; + + while (pos < input.length) + { + char c = input[pos]; + + // Backslash-newline inside identifier → line continuation + if (c == '\\' && pos + 1 < input.length && input[pos + 1] == '\n') + { + pos += 2; + line++; + column = 0; + continue; + } + + // += and ?= are operators; if found mid-identifier, stop here + if ((c == '+' || c == '?') && + pos + 1 < input.length && input[pos + 1] == '=') + { + break; + } + + if (isIdentChar(c)) + { + buf ~= c; + pos++; + column++; + continue; + } + + break; + } + + string value = buf.idup; + return Token(TokenType.identifier, value, startLine, startCol); + } +} + +/// +unittest +{ + // --- Basic tokens --- + { + auto lex = Lexer("target: prereq"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier, "expected identifier"); + assert(t.value == "target"); + assert(t.line == 1); + assert(t.column == 0); + + t = lex.nextToken(); + assert(t.type == TokenType.colon); + assert(t.value == ":"); + + t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "prereq"); + assert(t.column == 8); + } + + // --- Tab detection at column 0 --- + { + auto lex = Lexer("\trecipe line"); + auto t = lex.nextToken(); + assert(t.type == TokenType.tab, "column-0 tab should produce tab token"); + assert(t.line == 1); + assert(t.column == 0); + + t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "recipe"); + } + + // --- Tab not at column 0 is whitespace --- + { + auto lex = Lexer("target: \tprereq"); + auto t = lex.nextToken(); // target + t = lex.nextToken(); // : + t = lex.nextToken(); // prereq (tab skipped as whitespace) + assert(t.type == TokenType.identifier); + assert(t.value == "prereq"); + } + + // --- Backslash-newline continuation --- + { + auto lex = Lexer("foo\\\nbar"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "foobar", "line continuation should join identifiers"); + assert(t.line == 1, "token should report starting line"); + } + + // --- Comment to end of line --- + { + auto lex = Lexer("# this is a comment\nnextline"); + auto t = lex.nextToken(); + assert(t.type == TokenType.newline, "comment should produce newline"); + assert(t.line == 1); + + t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "nextline"); + assert(t.line == 2); + } + + // --- Comment at EOF (no trailing newline) --- + { + auto lex = Lexer("# comment at eof"); + auto t = lex.nextToken(); + assert(t.type == TokenType.newline); + } + + // --- Line / column tracking --- + { + auto lex = Lexer("line1\n line2"); + auto t = lex.nextToken(); + assert(t.value == "line1"); + assert(t.line == 1); + + t = lex.nextToken(); + assert(t.type == TokenType.newline); + assert(t.line == 1); + + t = lex.nextToken(); + assert(t.value == "line2"); + assert(t.line == 2); + assert(t.column == 2); + } + + // --- Empty input --- + { + auto lex = Lexer(""); + auto t = lex.nextToken(); + assert(t.type == TokenType.eof); + + t = lex.nextToken(); + assert(t.type == TokenType.eof, "subsequent calls after eof must return eof"); + } + + // --- Double colon --- + { + auto lex = Lexer("target:: prereq"); + auto t = lex.nextToken(); + assert(t.value == "target"); + + t = lex.nextToken(); + assert(t.type == TokenType.doubleColon); + assert(t.value == "::"); + } + + // --- All assignment operators --- + { + // Simple = + auto lex = Lexer("VAR = val"); + auto t = lex.nextToken(); // VAR + t = lex.nextToken(); + assert(t.type == TokenType.equals); + assert(t.value == "="); + } + { + // Immediate := + auto lex = Lexer("VAR := val"); + auto t = lex.nextToken(); // VAR + t = lex.nextToken(); + assert(t.type == TokenType.colonEquals); + assert(t.value == ":="); + } + { + // Append += + auto lex = Lexer("VAR += val"); + auto t = lex.nextToken(); // VAR + t = lex.nextToken(); + assert(t.type == TokenType.plusEquals); + assert(t.value == "+="); + } + { + // Conditional ?= + auto lex = Lexer("VAR ?= val"); + auto t = lex.nextToken(); // VAR + t = lex.nextToken(); + assert(t.type == TokenType.questionEquals); + assert(t.value == "?="); + } + + // --- Dollar, parens, braces --- + { + auto lex = Lexer("$(VAR) ${VAR}"); + auto t = lex.nextToken(); + assert(t.type == TokenType.dollar); + + t = lex.nextToken(); + assert(t.type == TokenType.lparen); + + t = lex.nextToken(); + assert(t.value == "VAR"); + + t = lex.nextToken(); + assert(t.type == TokenType.rparen); + + t = lex.nextToken(); + assert(t.type == TokenType.dollar); + + t = lex.nextToken(); + assert(t.type == TokenType.lbrace); + + t = lex.nextToken(); + assert(t.value == "VAR"); + + t = lex.nextToken(); + assert(t.type == TokenType.rbrace); + } + + // --- Pipe, comma, semicolon --- + { + auto lex = Lexer("a | b , c ; d"); + auto t = lex.nextToken(); // a + t = lex.nextToken(); + assert(t.type == TokenType.pipe); + t = lex.nextToken(); // b + assert(t.value == "b"); + t = lex.nextToken(); + assert(t.type == TokenType.comma); + t = lex.nextToken(); // c + assert(t.value == "c"); + t = lex.nextToken(); + assert(t.type == TokenType.semicolon); + t = lex.nextToken(); // d + assert(t.value == "d"); + } + + // --- Identifier with special chars --- + { + auto lex = Lexer("foo-bar_baz.dir/file%*.c~"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "foo-bar_baz.dir/file%*.c~"); + } + + // --- Plus in identifier (not followed by =) --- + { + auto lex = Lexer("g++ main.c"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "g++"); + + t = lex.nextToken(); + assert(t.value == "main.c"); + } + + // --- Question in identifier (not followed by =) --- + { + auto lex = Lexer("file?"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "file?"); + } + + // --- Backslash-newline inside identifier --- + { + auto lex = Lexer("foo\\\nbar"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "foobar"); + assert(t.line == 1); + } + + // --- Multiple line continuations --- + { + auto lex = Lexer("a\\\n\\\nb"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "ab"); + assert(t.line == 1); // starts on line 1 even if it spans 3 lines + } + + // --- Backslash literal (not followed by newline) --- + { + auto lex = Lexer("foo\\bar"); + auto t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "foo\\bar"); + } + + // --- Dollar as variable prefix --- + { + auto lex = Lexer("$@ $< $^"); + auto t = lex.nextToken(); + assert(t.type == TokenType.dollar); + t = lex.nextToken(); + assert(t.type == TokenType.identifier); + assert(t.value == "@"); + } +} diff --git a/source/antelope/parser/parser.d b/source/antelope/parser/parser.d new file mode 100644 index 0000000..e2944e6 --- /dev/null +++ b/source/antelope/parser/parser.d @@ -0,0 +1,1010 @@ +/// Recursive-descent parser for Makefile syntax. +/// +/// Consumes tokens from the lexer to produce an AST following +/// GNU Make grammar rules. Supports rules, variable assignments, +/// conditional blocks, and directives. +module antelope.parser.parser; + +import std.conv : to; +import std.string : strip, stripLeft, startsWith; +import std.array : join; +import antelope.parser.lexer; +import antelope.parser.ast; + +/// Parse a string containing Makefile syntax into an AST. +/// +/// Creates a Lexer internally and drives the recursive-descent parser. +/// Returns an AstNode of type `rule_list` containing all top-level +/// statements as children. +/// +/// Params: +/// input = Raw Makefile source text +/// Returns: +/// Root AST node with complete parse tree +AstNode parse(string input) +{ + auto lexer = Lexer(input); + Parser parser; + parser.lexer = lexer; + return parser.parseMakefile(); +} + +/// Internal parser state tracking the token stream. +private struct Parser +{ + Lexer lexer; + Token current; /// Current lookahead token + Token previous; /// Most recently consumed token + + // ----------------------------------------------------------------------- + // Token stream helpers + // ----------------------------------------------------------------------- + + /// Advance to the next token, returning the previously-current token. + Token advance() + { + previous = current; + current = lexer.nextToken(); + return previous; + } + + /// True when current token has the given type. + bool check(TokenType t) const + { + return current.type == t; + } + + /// True when current token is an identifier with the given value. + bool checkIdent(string value) const + { + return current.type == TokenType.identifier && current.value == value; + } + + /// True when lexer position has a backslash-newline continuation. + bool isBackslashNewline() const + { + return lexer.pos + 1 < lexer.input.length && + lexer.input[lexer.pos] == '\\' && + lexer.input[lexer.pos + 1] == '\n'; + } + + /// Consume current token; throw if it doesn't match the expected type. + Token eat(TokenType t, string errMsg) + { + if (!check(t)) + throw new Exception(errMsg ~ " (got '" ~ current.value ~ + "' at line " ~ current.line.to!string ~ ")"); + return advance(); + } + + /// Skip consecutive newline tokens (blank lines and comments). + void skipNewlines() + { + while (check(TokenType.newline)) + advance(); + } + + // ----------------------------------------------------------------------- + // Raw line reader — used for recipe text, variable values, directive args + // ----------------------------------------------------------------------- + + /// Read raw characters from the lexer input until end of line or EOF. + /// + /// Handles backslash-newline line continuations inside the raw text. + /// Does NOT consume the terminating newline; leaves it for the token + /// stream to process. Updates lexer position/line/column tracking. + string readRawLine() + { + char[] buf; + while (lexer.pos < lexer.input.length) + { + char c = lexer.input[lexer.pos]; + + // Backslash-newline — line continuation + if (c == '\\' && lexer.pos + 1 < lexer.input.length && + lexer.input[lexer.pos + 1] == '\n') + { + lexer.pos += 2; + lexer.line++; + lexer.column = 0; + continue; + } + + // Unescaped newline ends the raw line + if (c == '\n') + break; + + buf ~= c; + lexer.pos++; + lexer.column++; + } + return buf.idup; + } + + // ----------------------------------------------------------------------- + // Prerequisite text accumulation + // ----------------------------------------------------------------------- + + /// Accumulate a single prerequisite token group. + /// + /// Simple identifiers are returned as-is. Variable references ($@, + /// $(VAR), ${VAR}) are accumulated through their matching paren/brace + /// to produce the complete reference string (e.g. "$(VAR)"). + string accumulatePrereq() + { + if (check(TokenType.dollar)) + { + string result = advance().value; // "$" + + if (check(TokenType.identifier)) + { + result ~= advance().value; // $@, $<, $^, etc. + } + else if (check(TokenType.lparen)) + { + result ~= advance().value; // "(" + int depth = 1; + while (depth > 0 && !check(TokenType.eof) && + !check(TokenType.newline) && !check(TokenType.tab)) + { + if (check(TokenType.lparen)) + { + depth++; + result ~= advance().value; + } + else if (check(TokenType.rparen)) + { + depth--; + if (depth > 0) + result ~= advance().value; + } + else + { + // Lexer skips whitespace — add a space between + // tokens to preserve $(func arg1 arg2) syntax. + if (result[$-1] != '(' && result[$-1] != ' ') + result ~= ' '; + result ~= advance().value; + } + } + if (check(TokenType.rparen)) + result ~= advance().value; // ")" + } + else if (check(TokenType.lbrace)) + { + result ~= advance().value; // "{" + int depth = 1; + while (depth > 0 && !check(TokenType.eof) && + !check(TokenType.newline) && !check(TokenType.tab)) + { + if (check(TokenType.lbrace)) + { + depth++; + result ~= advance().value; + } + else if (check(TokenType.rbrace)) + { + depth--; + if (depth > 0) + result ~= advance().value; + } + else + { + // Same: preserve whitespace between tokens + if (result[$-1] != '{' && result[$-1] != ' ') + result ~= ' '; + result ~= advance().value; + } + } + if (check(TokenType.rbrace)) + result ~= advance().value; // "}" + } + return result; + } + else + { + return advance().value; + } + } + + // ----------------------------------------------------------------------- + // Top-level driver + // ----------------------------------------------------------------------- + + /// Parse the entire token stream into a rule_list AST node. + AstNode parseMakefile() + { + auto root = AstNode(AstType.rule_list); + current = lexer.nextToken(); + skipNewlines(); + + while (!check(TokenType.eof)) + { + auto stmt = parseStatement(); + // Skip dummy nodes returned for empty/whitespace lines + if (stmt.type != AstType.rule_list || stmt.children.length > 0) + root.children ~= stmt; + skipNewlines(); + } + + return root; + } + + // ----------------------------------------------------------------------- + // Statement dispatch + // ----------------------------------------------------------------------- + + /// Parse a single top-level statement: rule, variable, conditional, + /// directive, or error. + AstNode parseStatement() + { + // Tab without preceding rule: in GNU Make, tabs inside conditionals + // are just indentation. Consume the tab and retry as a normal statement. + if (check(TokenType.tab)) + { + advance(); // consume tab + return parseStatement(); // retry + } + + // Blank line / EOF + if (check(TokenType.newline) || check(TokenType.eof)) + { + advance(); + return AstNode(AstType.rule_list); + } + + // Statements start with an identifier or variable reference ($) + if (!check(TokenType.identifier) && !check(TokenType.dollar)) + { + throw new Exception( + "Unexpected token '" ~ current.value ~ + "' at line " ~ current.line.to!string); + } + + // --- Directive keywords — checked BEFORE buffering identifiers --- + // These consume their arguments via readRawLine() so that the raw + // text after the keyword is captured before any token advancement. + + // Conditional block openers: consume keyword, read args, parse body + if (checkIdent("ifdef") || checkIdent("ifndef") || + checkIdent("ifeq") || checkIdent("ifneq")) + { + string kw = current.value; + // lexer.pos is right after the keyword — read args until newline + string args = readRawLine(); + advance(); // consume the newline (or eof) after the args + return parseConditional(kw, args); + } + + // define: multi-line variable definition — capture body up to endef + // Must be handled BEFORE the generic directive block so that the + // body lines (which may look like identifiers) are not tokenized. + if (checkIdent("define")) + { + string kw = current.value; + string args = readRawLine(); // read variable name after "define" + advance(); // consume newline after define line + + string varName = args.strip; + + // Read raw lines from the input until a line starting with + // "endef" is found. The body is stored as literal text — no + // tokenization is applied to the lines between define and endef. + string[] bodyLines; + while (lexer.pos < lexer.input.length) + { + string line = readRawLine(); + + if (stripLeft(line).startsWith("endef")) + { + // Consume the newline after endef + if (lexer.pos < lexer.input.length && + lexer.input[lexer.pos] == '\n') + { + lexer.pos++; + lexer.line++; + lexer.column = 0; + } + break; + } + + bodyLines ~= line; + + // Consume the newline after this body line + if (lexer.pos < lexer.input.length && + lexer.input[lexer.pos] == '\n') + { + lexer.pos++; + lexer.line++; + lexer.column = 0; + } + else if (lexer.pos >= lexer.input.length) + { + throw new Exception( + "Unterminated define block: missing 'endef' at line " ~ + lexer.line.to!string); + } + } + + string body = bodyLines.join("\n"); + + auto node = AstNode(AstType.variable_assignment); + node.data = varName ~ "=" ~ body; + return node; + } + + // Standalone directives: consume keyword, read args, done + if (checkIdent("include") || checkIdent("sinclude") || + checkIdent("-include") || + checkIdent("vpath") || + checkIdent("undefine") || + checkIdent("export") || checkIdent("unexport")) + { + string kw = current.value; + string args = readRawLine(); + advance(); // consume the newline + + auto node = AstNode(AstType.directive); + node.data = kw ~ args; // args already includes leading space + return node; + } + + // else/endif should only appear inside conditionals; parse as + // standalone directives for error robustness + if (checkIdent("else") || checkIdent("endif")) + { + string kw = current.value; + // No args — just the keyword on a line + advance(); // consume the keyword + if (check(TokenType.newline)) + advance(); + + auto node = AstNode(AstType.directive); + node.data = kw; + return node; + } + + // --- Rules and variable assignments: buffer identifiers --- + // At this point current is still the first identifier, untouched. + // Buffer identifiers and variable references as potential targets + Token[] idents; + while (check(TokenType.identifier)) + idents ~= advance(); + // Also collect $ references like $(OBJS) — reused from prereq parsing + while (idents.length == 0 || (check(TokenType.dollar) && idents.length >= 0)) + { + if (!check(TokenType.dollar)) + break; + string value = accumulatePrereq(); + // If we already have a token and it ends with a non-space character + // (like `src/ar.`), merge the $() result into it instead of creating + // a new token. This produces `src/ar.$(OBJEXT)` not `src/ar. $(OBJEXT)`. + if (idents.length > 0) + idents[$-1].value ~= value; + else + { + Token t; + t.type = TokenType.identifier; + t.value = value; + t.line = current.line; + t.column = current.column; + idents ~= t; + } + // Merge trailing text after $() into same token: $(srcdir)/Makefile.in + while (check(TokenType.identifier) || check(TokenType.dollar)) + { + if (check(TokenType.dollar) && idents[$-1].value[$-1] != '(' && idents[$-1].value[$-1] != ')') + break; + if (check(TokenType.dollar)) + idents[$-1].value ~= accumulatePrereq(); + else + idents[$-1].value ~= advance().value; + } + } + + // Variable assignment: exactly one identifier followed by operator + if (idents.length == 1 && + (check(TokenType.equals) || check(TokenType.colonEquals) || + check(TokenType.plusEquals) || check(TokenType.questionEquals))) + { + Token op = current; // capture operator WITHOUT advancing + return parseVariableAssignment(idents[0], op); + } + + // Rule: identifiers followed by colon (single or double) + if (check(TokenType.colon) || check(TokenType.doubleColon)) + return parseRule(idents); + + // If none of the above, skip the unknown line silently. + // This handles bodies of define/endef blocks and other non-syntax + // content. Consume tokens until newline. + while (!check(TokenType.newline) && !check(TokenType.eof)) + advance(); + if (check(TokenType.newline)) + advance(); + return AstNode(AstType.rule_list); + } + + // ----------------------------------------------------------------------- + // Rule parsing + // ----------------------------------------------------------------------- + + /// Parse a rule: targets : prerequisites ; recipe NL (TAB recipe NL)* + /// + /// Params: + /// targets = All target-identifier tokens accumulated before the colon + AstNode parseRule(Token[] targets) + { + auto colon = advance(); // colon or doubleColon + + // Store ALL target names space-separated for multi-target rules + import std.array : join; + import std.algorithm : map; + string allTargets = targets.map!(t => t.value).join(" "); + bool isDouble = (colon.type == TokenType.doubleColon); + auto rule = AstNode(AstType.rule); + rule.data = allTargets ~ (isDouble ? "::" : ""); + + // --- Parse prerequisites until newline / semicolon / tab --- + // When we hit `|`, continue accumulating (order-only prereqs + // are stored the same way in the AST; the evaluator can + // distinguish by the pipe position). + // Tabs from backslash-continued lines are consumed as whitespace. + while (!check(TokenType.newline) && !check(TokenType.eof) && + !check(TokenType.semicolon)) + { + // Tab in the middle of prerequisites (from line continuation): + // consume it silently and continue. + if (check(TokenType.tab)) + { + advance(); + continue; + } + if (check(TokenType.pipe)) + { + advance(); // consume pipe token + // Store a | marker so splitPrereqs can detect order-only prereqs + auto pipeNode = AstNode(AstType.prerequisite); + pipeNode.data = "|"; + rule.children ~= pipeNode; + continue; + } + { + auto prereq = AstNode(AstType.prerequisite); + prereq.data = accumulatePrereq(); + // Merge adjacent tokens into a single prerequisite + // when the first token was a $ reference, but stop + // at standalone $ refs like $(OBJS) ${LIBS} + bool startsWithDollar = (prereq.data.length > 0 && prereq.data[0] == '$'); + if (startsWithDollar) + { + while (check(TokenType.identifier) || check(TokenType.dollar)) + { + // Don't merge separate standalone $ refs + if (check(TokenType.dollar) && (prereq.data[$-1] == ')' || prereq.data[$-1] == '}')) + break; + if (check(TokenType.dollar)) + prereq.data ~= accumulatePrereq(); + else + prereq.data ~= advance().value; + } + } + rule.children ~= prereq; + } + } + + // --- Optional inline recipe after semicolon --- + // NOTE: current is already semicolon (prereq loop exited on it); + // lexer.pos is already after the ; character. + // Do NOT advance() here — just read raw text directly. + if (check(TokenType.semicolon)) + { + string recipe = readRawLine(); + auto rl = AstNode(AstType.recipe_line); + rl.data = recipe; + rule.children ~= rl; + advance(); // consume the newline that terminated the inline recipe + } + + // --- Recipe lines (start with column-0 tab) --- + // NOTE: when we enter this loop with current==tab, lexer.pos is + // already positioned right after the \t character (the tab token + // was consumed by an earlier advance(), not by us here). + // So readRawLine() will start at the first character of recipe text. + while (check(TokenType.tab) || check(TokenType.newline)) + { + if (check(TokenType.newline)) + { + advance(); // blank/comment line between recipe lines + continue; + } + // current is tab — lexer.pos is already right after \t + string recipe = readRawLine(); + auto rl = AstNode(AstType.recipe_line); + rl.data = recipe; + rule.children ~= rl; + advance(); // consume the terminating newline + } + + return rule; + } + + // ----------------------------------------------------------------------- + // Variable assignment parsing + // ----------------------------------------------------------------------- + + /// Parse a variable assignment: NAME OP value + /// + /// Params: + /// name = The variable name identifier token + /// op = The assignment operator token (current, not yet advanced past) + AstNode parseVariableAssignment(Token name, Token op) + { + // lexer.pos is already right after the operator character + string rawValue = readRawLine(); + // Now advance past the newline (or eof) that terminated the line + advance(); + + auto node = AstNode(AstType.variable_assignment); + node.data = name.value ~ op.value ~ rawValue; + return node; + } + + // ----------------------------------------------------------------------- + // Conditional block parsing + // ----------------------------------------------------------------------- + + /// Parse a conditional block: ifXXX args NL body [else NL body] endif + /// + /// Params: + /// ifKind = The conditional keyword ("ifdef", "ifndef", "ifeq", "ifneq") + /// args = Raw args text already read from the line (may be empty) + AstNode parseConditional(string ifKind, string args) + { + auto dirNode = AstNode(AstType.directive); + dirNode.data = ifKind ~ args; // args already includes leading space + + // Parse the "then" body and optional "else" branch + parseConditionalBody(dirNode, false); + + return dirNode; + } + + /// Parse statements inside a conditional block until `else` or `endif`. + /// + /// Params: + /// dirNode = The directive node to append child statements to + /// inElse = True if we are parsing the else-branch body already + private void parseConditionalBody(ref AstNode dirNode, bool inElse) + { + while (!check(TokenType.eof)) + { + skipNewlines(); + + if (check(TokenType.eof)) + throw new Exception("Unterminated conditional block: " + ~ "missing 'endif'"); + + // Check for `endif` + if (checkIdent("endif")) + { + advance(); // endif + if (check(TokenType.newline)) + advance(); + return; + } + + // Check for `else` + if (!inElse && checkIdent("else")) + { + advance(); // else + if (check(TokenType.newline)) + advance(); + + // Create an else node — populate children BEFORE + // appending to parent (struct copy semantics!) + auto elseNode = AstNode(AstType.directive); + elseNode.data = "else"; + + // Parse the else-branch body + parseConditionalBody(elseNode, true); + + dirNode.children ~= elseNode; + return; + } + + // Normal statement in the conditional body + auto stmt = parseStatement(); + if (stmt.type != AstType.rule_list || stmt.children.length > 0) + dirNode.children ~= stmt; + } + } + +} + +// ======================================================================= +// Unittests +// ======================================================================= + +/// Rule with one prerequisite and one recipe line. +unittest +{ + auto ast = parse("all: main.o\n\tcc -o all main.o"); + assert(ast.type == AstType.rule_list); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "all"); + + // One prerequisite + one recipe line = 2 children + assert(rule.children.length == 2); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "main.o"); + assert(rule.children[1].type == AstType.recipe_line); + assert(rule.children[1].data == "cc -o all main.o"); +} + +/// Variable assignment. +unittest +{ + auto ast = parse("CC = gcc"); + assert(ast.type == AstType.rule_list); + assert(ast.children.length == 1); + + auto var = ast.children[0]; + assert(var.type == AstType.variable_assignment); + assert(var.data == "CC= gcc"); +} + +/// Variable assignment with different operators. +unittest +{ + { + auto ast = parse("VAR := value"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "VAR:= value"); + } + { + auto ast = parse("VAR += append"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "VAR+= append"); + } + { + auto ast = parse("VAR ?= cond"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "VAR?= cond"); + } +} + +/// Rule with two prerequisites. +unittest +{ + auto ast = parse("all: main.o util.o\n\tcc $^ -o $@"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "all"); + assert(rule.children.length == 3); // 2 prereqs + 1 recipe + + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "main.o"); + assert(rule.children[1].type == AstType.prerequisite); + assert(rule.children[1].data == "util.o"); + assert(rule.children[2].type == AstType.recipe_line); + assert(rule.children[2].data == "cc $^ -o $@"); +} + +/// Rule with no prerequisites (target only, with recipe). +unittest +{ + auto ast = parse("clean:\n\trm -f *.o"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "clean"); + assert(rule.children.length == 1); // only recipe + assert(rule.children[0].type == AstType.recipe_line); + assert(rule.children[0].data == "rm -f *.o"); +} + +/// Multiple rules in sequence. +unittest +{ + auto ast = parse("all: main.o\n\nclean:\n\trm -f *.o"); + assert(ast.children.length == 2); + + assert(ast.children[0].type == AstType.rule); + assert(ast.children[0].data == "all"); + assert(ast.children[0].children.length == 1); // prereq only + assert(ast.children[0].children[0].data == "main.o"); + + assert(ast.children[1].type == AstType.rule); + assert(ast.children[1].data == "clean"); + assert(ast.children[1].children.length == 1); // recipe only +} + +/// Order-only prerequisites with pipe separator. +unittest +{ + auto ast = parse("target: normal | orderonly"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "target"); + assert(rule.children.length == 3, "should have normal, |, orderonly"); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "normal"); + assert(rule.children[1].type == AstType.prerequisite); + assert(rule.children[1].data == "|"); + assert(rule.children[2].type == AstType.prerequisite); + assert(rule.children[2].data == "orderonly"); +} + +/// Double-colon rule. +unittest +{ + auto ast = parse("target:: prereq"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "target::", "double-colon rule should encode ::"); + assert(rule.children.length == 1); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "prereq"); +} + +/// Multiple target identifiers in a rule. +unittest +{ + auto ast = parse("target1 target2: prereq"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.type == AstType.rule); + assert(rule.data == "target1 target2"); // all targets space-separated +} + +/// Inline recipe via semicolon. +unittest +{ + auto ast = parse("target: prereq; echo building\n\tcc -o target prereq"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.data == "target"); + // prereq + inline recipe + recipe line = 3 children + assert(rule.children.length == 3); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "prereq"); + assert(rule.children[1].type == AstType.recipe_line); + assert(rule.children[1].data == " echo building"); + assert(rule.children[2].type == AstType.recipe_line); + assert(rule.children[2].data == "cc -o target prereq"); +} + +/// Recipe line with leading whitespace after tab (the lexer skips it). +unittest +{ + auto ast = parse("all:\n\t cc -o all main.o"); + assert(ast.children.length == 1); + auto rule = ast.children[0]; + assert(rule.children[0].type == AstType.recipe_line); + // readRawLine preserves internal spaces + assert(rule.children[0].data == " cc -o all main.o"); +} + +/// Multiple recipe lines for a single rule. +unittest +{ + auto ast = parse("all: main.o\n\tcc -c main.c\n\tmv main.o build/"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.data == "all"); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "main.o"); + assert(rule.children[1].type == AstType.recipe_line); + assert(rule.children[1].data == "cc -c main.c"); + assert(rule.children[2].type == AstType.recipe_line); + assert(rule.children[2].data == "mv main.o build/"); +} + +/// Comments between recipe lines (they produce newline tokens). +unittest +{ + auto ast = parse("target:\n\tcmd1\n# comment\n\tcmd2"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.children.length == 2); // 2 recipes + assert(rule.children[0].data == "cmd1"); + assert(rule.children[1].data == "cmd2"); +} + +/// Conditional block: ifdef with else. +unittest +{ + auto ast = parse("ifdef DEBUG\nCC = gcc -g\nelse\nCC = gcc\nendif"); + assert(ast.children.length == 1); + + auto dir = ast.children[0]; + assert(dir.type == AstType.directive); + assert(dir.data == "ifdef DEBUG"); + + // "then" branch: 1 child (the variable assignment) + assert(dir.children.length >= 1); + assert(dir.children[0].type == AstType.variable_assignment); + assert(dir.children[0].data == "CC= gcc -g"); + + // "else" branch + assert(dir.children.length == 2); + assert(dir.children[1].type == AstType.directive); + assert(dir.children[1].data == "else"); + assert(dir.children[1].children.length == 1); + assert(dir.children[1].children[0].type == AstType.variable_assignment); + assert(dir.children[1].children[0].data == "CC= gcc"); +} + +/// Conditional block: ifndef without else. +unittest +{ + auto ast = parse("ifndef FOO\nBAR = baz\nendif"); + assert(ast.children.length == 1); + + auto dir = ast.children[0]; + assert(dir.type == AstType.directive); + assert(dir.data == "ifndef FOO"); + assert(dir.children.length == 1); + assert(dir.children[0].type == AstType.variable_assignment); + assert(dir.children[0].data == "BAR= baz"); +} + +/// Conditional block: ifeq with paren syntax. +unittest +{ + auto ast = parse("ifeq ($(ARCH), x86)\nCC = gcc\nendif"); + assert(ast.children.length == 1); + + auto dir = ast.children[0]; + assert(dir.type == AstType.directive); + assert(dir.data == "ifeq ($(ARCH), x86)"); +} + +/// Generic directives: include, define, export, vpath. +unittest +{ + { + auto ast = parse("include foo.mk"); + assert(ast.children[0].type == AstType.directive); + assert(ast.children[0].data == "include foo.mk"); + } + { + auto ast = parse("vpath %.h include"); + assert(ast.children[0].type == AstType.directive); + assert(ast.children[0].data == "vpath %.h include"); + } + { + auto ast = parse("export FOO"); + assert(ast.children[0].type == AstType.directive); + assert(ast.children[0].data == "export FOO"); + } + { + auto ast = parse("define mymacro\ncontent\nendef"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "mymacro=content"); + } + { + // Multi-line define with multiple body lines + auto ast = parse("define CMD\n\t@echo hello\n\t@echo world\nendef"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "CMD=\t@echo hello\n\t@echo world"); + } + { + // define with empty body + auto ast = parse("define EMPTY\nendef"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "EMPTY="); + } + { + // endef embedded in body text does NOT terminate early + auto ast = parse("define VAR\nline 1\nthis mentions endef inline\nendef"); + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "VAR=line 1\nthis mentions endef inline"); + } +} + +/// Tab without preceding rule is an error. +unittest +{ + bool caught = false; + try + { + parse("\torphan recipe"); + } + catch (Exception e) + { + caught = true; + } + // Tab without preceding rule is now silently consumed (GNU Make compat: + // tabs inside conditionals are just indentation, not recipe markers). + assert(!caught, "tab without rule should be silently consumed"); +} + +/// Variable reference in prerequisite (preserved as raw text). +unittest +{ + auto ast = parse("all: $(OBJS) ${LIBS} $<"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.children.length == 3); + assert(rule.children[0].type == AstType.prerequisite); + assert(rule.children[0].data == "$(OBJS)"); + assert(rule.children[1].type == AstType.prerequisite); + assert(rule.children[1].data == "${LIBS}"); + assert(rule.children[2].type == AstType.prerequisite); + assert(rule.children[2].data == "$<"); +} + +/// Empty input produces empty rule_list. +unittest +{ + auto ast = parse(""); + assert(ast.type == AstType.rule_list); + assert(ast.children.length == 0); +} + +/// Input with only whitespace/blank lines. +unittest +{ + auto ast = parse("\n\n"); + assert(ast.type == AstType.rule_list); + assert(ast.children.length == 0); +} + +/// Mix of rules and variable assignments. +unittest +{ + auto input = "CC = gcc\n\nall: main.o\n\t$(CC) -o all main.o"; + auto ast = parse(input); + assert(ast.children.length == 2); + + assert(ast.children[0].type == AstType.variable_assignment); + assert(ast.children[0].data == "CC= gcc"); + + assert(ast.children[1].type == AstType.rule); + assert(ast.children[1].data == "all"); +} + +/// Backslash-newline continuation in recipe lines. +unittest +{ + auto ast = parse("all:\n\tcc -o all \\\n\t main.o"); + assert(ast.children.length == 1); + + auto rule = ast.children[0]; + assert(rule.children.length == 1); // one recipe line (continuation joined) + assert(rule.children[0].type == AstType.recipe_line); + // readRawLine joins the continuation — the raw text is "cc -o all main.o" + // because the \ is consumed and the leading tab/whitespace on the + // continuation line is kept +} + +/// Conditional with nested rules. +unittest +{ + auto input = "ifdef BUILD_TESTS\ntest: test.o\n\tcc -o test test.o\nendif"; + auto ast = parse(input); + assert(ast.children.length == 1); + + auto dir = ast.children[0]; + assert(dir.type == AstType.directive); + assert(dir.children.length == 1); + assert(dir.children[0].type == AstType.rule); + assert(dir.children[0].data == "test"); +} diff --git a/source/antelope/parser/variables.d b/source/antelope/parser/variables.d new file mode 100644 index 0000000..4addee3 --- /dev/null +++ b/source/antelope/parser/variables.d @@ -0,0 +1,17 @@ +/// Variable parsing and substitution logic. +module antelope.parser.variables; + +/// Variable reference types: $(VAR), ${VAR}, $@, etc. +enum VarRefType +{ + simple, /// $(VAR) + brace, /// ${VAR} + automatic, /// $@, $<, $^, etc. +} + +/// A parsed variable reference. +struct VariableRef +{ + VarRefType refType; + string name; +} diff --git a/source/antelope/shell/command.d b/source/antelope/shell/command.d new file mode 100644 index 0000000..72a5459 --- /dev/null +++ b/source/antelope/shell/command.d @@ -0,0 +1,340 @@ +/// Shell command parsing and escaping. +module antelope.shell.command; + +import std.algorithm; +import std.array; +import std.ascii; + +/// A single shell command parsed from a recipe line. +struct ShellCommand +{ + string program; + string[] arguments; + bool ignoreErrors; /// `-` prefix — continue on non-zero exit + bool silent; /// `@` prefix — don't echo the command + bool alwaysExecute; /// `+` prefix — execute even with -n dry run +} + +// --- Private helpers --- + +/// Strip consecutive `@`, `-`, `+` prefixes from the start of `line`, +/// returning the remaining line and the three flag values. +private struct PrefixResult +{ + bool silent; + bool ignoreErrors; + bool alwaysExecute; + string rest; +} + +private PrefixResult stripPrefixes(string line) +{ + PrefixResult result; + result.rest = line; + + // Consume prefix characters until a non-prefix appears. + // Example: "@-echo" → silent, ignoreErrors; rest = "echo" + bool consumed; + do + { + consumed = false; + if (result.rest.length > 0) + { + switch (result.rest[0]) + { + case '@': + result.silent = true; + result.rest = result.rest[1 .. $]; + consumed = true; + break; + case '-': + result.ignoreErrors = true; + result.rest = result.rest[1 .. $]; + consumed = true; + break; + case '+': + result.alwaysExecute = true; + result.rest = result.rest[1 .. $]; + consumed = true; + break; + default: + break; + } + } + } + while (consumed); + + return result; +} + +/// Split the rest of a (prefix-stripped) recipe line into tokens +/// respecting single-quote, double-quote, and backslash escapes. +private string[] tokenize(string line) +{ + string[] tokens; + + size_t i = 0; + while (i < line.length) + { + // Skip whitespace between tokens. + if (isWhite(line[i])) + { + ++i; + continue; + } + + // Parse one token. + char[] buf; + inToken: while (i < line.length && !isWhite(line[i])) + { + switch (line[i]) + { + case '"': + // Double-quoted segment — honour backslash escapes. + ++i; // skip opening quote + while (i < line.length && line[i] != '"') + { + if (line[i] == '\\' && i + 1 < line.length) + { + ++i; // skip backslash, take next char literally + buf ~= line[i]; + ++i; + } + else + { + buf ~= line[i]; + ++i; + } + } + if (i < line.length) + ++i; // skip closing quote + break; + + case '\'': + // Single-quoted segment — everything literal, no escapes. + ++i; // skip opening quote + while (i < line.length && line[i] != '\'') + { + buf ~= line[i]; + ++i; + } + if (i < line.length) + ++i; // skip closing quote + break; + + case '\\': + // Unquoted backslash — escape next character. + ++i; // skip backslash + if (i < line.length) + { + buf ~= line[i]; + ++i; + } + break; + + default: + buf ~= line[i]; + ++i; + break; + } + } + + if (buf.length > 0) + tokens ~= buf.idup; + } + + return tokens; +} + +/// Parse a recipe line into a ShellCommand. +/// +/// GNU Make applies three optional prefix characters to recipe lines: +/// $(UL @) = silent — don't echo the command before execution +/// $(UL -) = ignoreErrors — continue the build even when this command fails +/// $(UL +) = alwaysExecute — run even in dry-run (-n) mode +/// +/// Multiple prefixes may be combined (e.g. `@-echo` makes the command +/// both silent and error-tolerant). +/// +/// After stripping prefixes the remaining string is split into a program +/// name and arguments. Splitting respects shell-style quoting so that +/// `"hello world"` is preserved as a single argument. +/// +/// Params: +/// line = raw recipe line (may include leading/trailing whitespace) +/// +/// Returns: +/// A ShellCommand with prefix flags set and the command split into +/// program + arguments. An empty or whitespace-only line returns a +/// default-initialised ShellCommand. +ShellCommand parseCommand(string line) +{ + // Strip surrounding whitespace first. + auto trimmed = line.strip!isWhite; + + // Empty line → all defaults. + if (trimmed.length == 0) + return ShellCommand(); + + // Consume optional prefixes. + auto pref = stripPrefixes(trimmed); + + // Tokenise the remainder. + auto tokens = tokenize(pref.rest); + + ShellCommand cmd; + cmd.silent = pref.silent; + cmd.ignoreErrors = pref.ignoreErrors; + cmd.alwaysExecute = pref.alwaysExecute; + + if (tokens.length > 0) + cmd.program = tokens[0]; + cmd.arguments = tokens.length > 1 ? tokens[1 .. $] : []; + + return cmd; +} + +// --- Unittests --- + +unittest +{ + // Basic: no prefixes. + { + auto cmd = parseCommand("gcc -o out main.c"); + assert(!cmd.silent); + assert(!cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + assert(cmd.program == "gcc"); + assert(cmd.arguments == ["-o", "out", "main.c"]); + } + + // Silent prefix. + { + auto cmd = parseCommand("@echo hello"); + assert(cmd.silent); + assert(!cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello"]); + } + + // Ignore-errors prefix. + { + auto cmd = parseCommand("-rm -rf foo"); + assert(!cmd.silent); + assert(cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + assert(cmd.program == "rm"); + assert(cmd.arguments == ["-rf", "foo"]); + } + + // Always-execute prefix. + { + auto cmd = parseCommand("+make sub"); + assert(!cmd.silent); + assert(!cmd.ignoreErrors); + assert(cmd.alwaysExecute); + assert(cmd.program == "make"); + assert(cmd.arguments == ["sub"]); + } + + // Multiple prefixes: @-echo → silent + ignoreErrors. + { + auto cmd = parseCommand("@-echo hello"); + assert(cmd.silent); + assert(cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello"]); + } + + // Multiple prefixes in different order: -@echo. + { + auto cmd = parseCommand("-@echo hello"); + assert(cmd.silent); + assert(cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello"]); + } + + // All three prefixes. + { + auto cmd = parseCommand("@-+make all"); + assert(cmd.silent); + assert(cmd.ignoreErrors); + assert(cmd.alwaysExecute); + assert(cmd.program == "make"); + assert(cmd.arguments == ["all"]); + } + + // Double-quoted argument. + { + auto cmd = parseCommand(`echo "hello world"`); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello world"]); + } + + // Single-quoted argument. + { + auto cmd = parseCommand(`echo 'hello world'`); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello world"]); + } + + // Backslash escape within double quotes. + { + auto cmd = parseCommand(`echo "hello \"world\""`); + assert(cmd.program == "echo"); + assert(cmd.arguments == [`hello "world"`]); + } + + // Backslash escape within double quotes — backslash itself. + { + auto cmd = parseCommand(`echo "a\\b"`); + assert(cmd.program == "echo"); + assert(cmd.arguments == [`a\b`]); + } + + // Empty string. + { + auto cmd = parseCommand(""); + assert(cmd.program == ""); + assert(cmd.arguments == []); + assert(!cmd.silent); + assert(!cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + } + + // Whitespace only. + { + auto cmd = parseCommand(" \t "); + assert(cmd.program == ""); + assert(cmd.arguments == []); + assert(!cmd.silent); + assert(!cmd.ignoreErrors); + assert(!cmd.alwaysExecute); + } + + // Leading/trailing whitespace with prefix. + { + auto cmd = parseCommand(" @echo hello "); + assert(cmd.silent); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello"]); + } + + // Unquoted backslash escape. + { + auto cmd = parseCommand(`echo hello\ world`); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["hello world"]); + } + + // Mixed quotes in separate arguments. + { + auto cmd = parseCommand(`echo "arg one" 'arg two'`); + assert(cmd.program == "echo"); + assert(cmd.arguments == ["arg one", "arg two"]); + } +} diff --git a/source/antelope/shell/environment.d b/source/antelope/shell/environment.d new file mode 100644 index 0000000..b7cc11f --- /dev/null +++ b/source/antelope/shell/environment.d @@ -0,0 +1,182 @@ +/// Environment variable management — inheriting, overriding, exporting. +module antelope.shell.environment; + +import std.process : environment; +import antelope.compatibility.target_vars; + +/// Key-value store for environment variables. +/// +/// Mirrors GNU Make's variable environment: stores key-value pairs, +/// tracks which variables are exported to child processes, and can +/// be seeded from the OS environment on startup. +struct Environment +{ + private string[string] vars; + private bool[string] exported; + private ScopedVariable[] scopedVars; + + /// Get a variable value. + /// Returns: the value, or `""` (empty string) if undefined. + /// GNU Make treats unset variables as empty strings. + string get(string key) + { + return key in vars ? vars[key] : ""; + } + + /// Return all stored variable keys. + string[] keys() + { + return vars.keys; + } + + /// Set a variable value. + void set(string key, string value) + { + vars[key] = value; + } + + /// Export a variable to child processes. + /// Marked variables are included in the environment block + /// passed to sub-make and other spawned processes. + void exportVar(string key) + { + exported[key] = true; + } + + /// Retrieve all exported variables as an associative array. + /// Only variables marked via `exportVar` are returned. + string[string] getExportedVars() + { + string[string] result; + foreach (key; exported.keys) + { + if (key in vars) + result[key] = vars[key]; + } + return result; + } + + /// Merge entries from an external environment map. + /// Typically called with `environment.toAA()` to seed the store + /// with the OS environment on startup. + void mergeEnv(string[string] envp) + { + foreach (key, value; envp) + { + vars[key] = value; + } + } + + /// Check whether a variable exists in the store. + /// Returns: `true` if the key has been set (including via `mergeEnv`). + bool hasKey(string key) + { + return (key in vars) !is null; + } + + /// Add a target-specific variable override. + /// + /// When a Makefile declares `target: VAR = value`, VAR takes this value + /// only when building `target`. The override is stored here and + /// consulted by `getScoped` during recipe expansion. + void addScopedVar(string targetPattern, string name, string value, + bool recursive) + { + ScopedVariable sv; + sv.targetPattern = targetPattern; + sv.name = name; + sv.value = value; + sv.recursive = recursive; + sv.varScope = TargetVarScope.targetSpecific; + scopedVars ~= sv; + } + + /// Get a variable value, checking target-scoped overrides first. + /// + /// When `targetName` is non-empty, all scoped-variable records that + /// match both the variable name and the target pattern are checked + /// before falling back to the global variable store. + /// + /// Params: + /// key = Variable name to look up + /// targetName = Current target being built (empty = global lookup only) + /// + /// Returns: the scoped value if a match exists; otherwise the global + /// value (which is `""` when the variable is unset). + string getScoped(string key, string targetName = "") + { + if (targetName.length > 0) + { + foreach (sv; scopedVars) + { + if (sv.name == key && sv.targetPattern == targetName) + return sv.value; + } + } + return get(key); + } +} + +/// +unittest +{ + import std.stdio : writeln; + + // --- set / get roundtrip --- + Environment env; + env.set("FOO", "bar"); + assert(env.get("FOO") == "bar", "set/get roundtrip failed"); + + // --- undefined variable returns empty string --- + assert(env.get("NONEXISTENT") == "", "undefined var should return empty string"); + + // --- hasKey --- + assert(env.hasKey("FOO"), "hasKey should return true for set var"); + assert(!env.hasKey("BAR"), "hasKey should return false for unset var"); + + // --- export tracking --- + env.set("EXPORTED_VAR", "value1"); + env.set("NOT_EXPORTED", "value2"); + env.exportVar("EXPORTED_VAR"); + + string[string] exported = env.getExportedVars(); + assert(exported.length == 1, "only one var should be exported"); + assert("EXPORTED_VAR" in exported, "EXPORTED_VAR should be in exported set"); + assert(exported["EXPORTED_VAR"] == "value1", "exported value should match"); + + // --- mergeEnv --- + Environment env2; + env2.mergeEnv(["PATH": "/usr/bin", "HOME": "/root"]); + assert(env2.get("PATH") == "/usr/bin", "mergeEnv should copy PATH"); + assert(env2.get("HOME") == "/root", "mergeEnv should copy HOME"); + assert(env2.get("SHELL") == "", "unmerged var should be empty"); + + writeln("All environment tests passed."); +} + +/// +unittest +{ + // --- scoped variable lookup --- + Environment env; + env.set("CFLAGS", "-O2"); + + // Target-specific override + env.addScopedVar("debug.o", "CFLAGS", "-O0 -g", true); + + // Global lookup + assert(env.getScoped("CFLAGS") == "-O2", + "global CFLAGS should be -O2"); + + // Target-specific lookup — match + assert(env.getScoped("CFLAGS", "debug.o") == "-O0 -g", + "debug.o CFLAGS should be -O0 -g"); + + // Target-specific lookup — no match (different target) + assert(env.getScoped("CFLAGS", "release.o") == "-O2", + "release.o should fall back to global CFLAGS"); + + // Non-existent key + assert(env.getScoped("NONEXIST") == "", + "non-existent key should return empty string"); +} diff --git a/source/antelope/shell/process.d b/source/antelope/shell/process.d new file mode 100644 index 0000000..1eca9a5 --- /dev/null +++ b/source/antelope/shell/process.d @@ -0,0 +1,67 @@ +/// Subprocess creation and management. +/// +/// Provides low-level process execution for recipe lines. +/// Commands are run via `/bin/sh -c` on POSIX systems. +module antelope.shell.process; + +import std.process : spawnProcess, wait; +import std.string : indexOf; + +/// Run a command via shell and return its exit code. +/// +/// If `command` is empty, returns 0 immediately without spawning. +/// The shell used is determined by the SHELL environment variable, +/// defaulting to /bin/sh if not set. +/// If `environment` is non-empty, it is parsed as KEY=VALUE pairs and +/// passed as the process environment; otherwise the parent +/// process environment is inherited. +/// Stdout and stderr are inherited from the parent (not captured here). +/// Returns: the exit code of the command, or -1 if spawning failed. +int runProcess(string command, string[] environment) +{ + if (command.length == 0) + return 0; + + // Determine shell — respect SHELL variable, default to /bin/sh + string shell = "/bin/sh"; + foreach (env; environment) + { + if (env.length > 6 && env[0..6] == "SHELL=") + { + shell = env[6..$]; + break; + } + } + + try + { + string[] shellArgs = [shell, "-c", command]; + if (environment.length > 0) + { + string[string] envMap; + foreach (env; environment) + { + auto idx = env.indexOf('='); + if (idx != -1) + envMap[env[0 .. idx]] = env[idx + 1 .. $]; + } + auto pid = spawnProcess(shellArgs, envMap); + return wait(pid); + } + else + { + auto pid = spawnProcess(shellArgs); + return wait(pid); + } + } + catch (Exception e) + { + return -1; + } +} + +unittest +{ + assert(runProcess("echo hello", []) == 0); + assert(runProcess("exit 42", []) == 42); +} diff --git a/tests/build/.gitkeep b/tests/build/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/compatibility/.gitkeep b/tests/compatibility/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/evaluator/.gitkeep b/tests/evaluator/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/basic_build.d b/tests/integration/basic_build.d new file mode 100644 index 0000000..050213e --- /dev/null +++ b/tests/integration/basic_build.d @@ -0,0 +1,28 @@ +/// Integration test: basic Makefile with variable expansion and implicit rules. +module antelope.tests.integration.basic_build; + +import antelope.cli.args; +import antelope.cli.subcommands; + +/// Test that antelope -gnu can build a simple C project using implicit rules. +unittest +{ + import std.file : write, mkdir, rmdir, exists; + import std.process : environment; + + // Create a temp build directory + string testDir = "__antelope_int_test_basic"; + if (exists(testDir)) + rmdir(testDir); + + // We can't easily run the full build pipeline in a unittest, + // so this test verifies parseArgs works correctly. + auto config = parseArgs(["antelope", "-gnu"]); + assert(config.gnuMode); + assert(config.targets.length == 0); + + config = parseArgs(["antelope", "release", "-gnu"]); + assert(config.gnuMode); + assert(config.targets.length == 1); + assert(config.targets[0] == "release"); +} diff --git a/tests/integration/conditional.mk b/tests/integration/conditional.mk new file mode 100644 index 0000000..c6b25fc --- /dev/null +++ b/tests/integration/conditional.mk @@ -0,0 +1,7 @@ +ARCH = x86 +ifeq ($(ARCH),x86) + CFLAGS = -m32 +else + CFLAGS = -m64 +endif +all: ; @echo ARCH=$(ARCH) CFLAGS=$(CFLAGS) diff --git a/tests/integration/include_main.mk b/tests/integration/include_main.mk new file mode 100644 index 0000000..21f3481 --- /dev/null +++ b/tests/integration/include_main.mk @@ -0,0 +1,2 @@ +include include_sub.mk +all: ; @echo CC=$(CC) VERSION=$(VERSION) diff --git a/tests/integration/include_sub.mk b/tests/integration/include_sub.mk new file mode 100644 index 0000000..45057f8 --- /dev/null +++ b/tests/integration/include_sub.mk @@ -0,0 +1,2 @@ +CC = gcc +VERSION = 1.0 diff --git a/tests/integration/parse_test.d b/tests/integration/parse_test.d new file mode 100644 index 0000000..cceee9f --- /dev/null +++ b/tests/integration/parse_test.d @@ -0,0 +1,27 @@ +/// Integration test: parser handles full Makefile syntax. +module antelope.tests.integration.parse_test; + +import antelope.parser.parser; +import antelope.parser.ast; + +unittest +{ + // Test parsing a complete Makefile + string makefile = "CC = gcc\nall: hello\nhello: hello.c\n\t$(CC) -o $@ $<\nclean: ; rm -f hello\n"; + auto ast = parse(makefile); + assert(ast.type == AstType.rule_list); + assert(ast.children.length >= 2); + + // Find the variable assignment + bool foundVar = false; + bool foundRule = false; + foreach (child; ast.children) + { + if (child.type == AstType.variable_assignment) + foundVar = true; + if (child.type == AstType.rule) + foundRule = true; + } + assert(foundVar, "Should find variable assignment"); + assert(foundRule, "Should find rule"); +} diff --git a/tests/integration/simple.mk b/tests/integration/simple.mk new file mode 100644 index 0000000..3b39778 --- /dev/null +++ b/tests/integration/simple.mk @@ -0,0 +1,6 @@ +CC = gcc +CFLAGS = -Wall +all: hello + +hello: hello.o +hello.o: hello.c diff --git a/tests/integration/variables.mk b/tests/integration/variables.mk new file mode 100644 index 0000000..12a5fec --- /dev/null +++ b/tests/integration/variables.mk @@ -0,0 +1,12 @@ +CC = gcc +CFLAGS = -O2 -Wall +TARGET = myapp +OBJS = main.o util.o + +$(TARGET): $(OBJS) + $(CC) $(CFLAGS) -o $@ $^ +main.o: main.c + $(CC) $(CFLAGS) -c $< +util.o: util.c + $(CC) $(CFLAGS) -c $< +clean: ; rm -f $(TARGET) $(OBJS) diff --git a/tests/parser/.gitkeep b/tests/parser/.gitkeep new file mode 100644 index 0000000..e69de29