1 /* MIT License
2 * Copyright (c) 2025 Matheus C. França
3 * See LICENSE file for details
4 */
5 
6 /// Wraps compiler commands.
7 module builder;
8 
9 import std.stdio;
10 import std.process;
11 import std.array;
12 import std.string;
13 import std.algorithm : canFind, filter, any;
14 import std.typecons : Nullable;
15 import std.path : extension;
16 import std.exception : enforce;
17 
18 /// Stores build configuration options.
19 struct BuildOptions
20 {
21     /// Target triple (e.g., x86_64-linux-gnu).
22     static Nullable!string triple;
23     /// CPU features (e.g., generic).
24     static Nullable!string cpu;
25 }
26 
27 /// Provides flag filtering and transformation utilities.
28 mixin template FlagChecks()
29 {
30     /// Transforms or skips flags for Zig compatibility.
31     static string[] processFlag(string arg) @safe pure nothrow
32     {
33         static immutable string[] skipExact = [
34             "--exclude-libs", "ALL", "--no-as-needed", "/nologo", "/NOLOGO"
35         ];
36         if (skipExact.canFind(arg))
37             return [];
38         if (arg.endsWith("-group"))
39             return ["-Wl,--start-group", "-Wl,--end-group"];
40         if (arg.endsWith("-dynamic"))
41             return ["-Wl,--export-dynamic"];
42         return [arg];
43     }
44 
45     /// Checks if a flag is Clang-specific for -cflags.
46     static bool isClangFlag(string arg) @safe pure nothrow
47     {
48         return !arg.startsWith("-Wl,") && arg != "-Wl,--start-group" &&
49             arg != "-Wl,--end-group" && arg != "-Wl,--export-dynamic";
50     }
51 }
52 
53 /// Builds and executes Zig subcommands.
54 class Builder
55 {
56     /// Minimum command length for sanitizer flags (zig, cc/c++, +1 arg).
57     private enum MIN_SANITIZE_COMMAND_LENGTH = 3;
58     /// Base command length (zig, cc/c++).
59     private enum BASE_COMMAND_LENGTH = 2;
60     /// Length of "arm64-apple" prefix.
61     private enum ARM64_APPLE_PREFIX_LENGTH = "arm64-apple".length;
62     /// Length of "x86_64-apple" prefix.
63     private enum X86_64_APPLE_PREFIX_LENGTH = "x86_64-apple".length;
64     /// Length of "-unknown-unknown" suffix.
65     private enum UNKNOWN_UNKNOWN_LENGTH = "-unknown-unknown".length;
66     /// Allowed DMD architectures.
67     private static immutable ALLOWED_DMD_ARCHES = ["x86_64", "i386", "i686"];
68 
69     private Appender!(string[]) cmds;
70     private Appender!(string[]) sourceFiles;
71     private string targetTriple;
72     private string cpu;
73     private bool isCPlusPlus;
74 
75     /// Creates a builder for Zig cc or c++.
76     /// Params:
77     ///   useCpp = Use C++ mode if true, C mode if false.
78     this(bool useCpp = false) @safe pure nothrow
79     {
80         cmds = appender!(string[]);
81         sourceFiles = appender!(string[]);
82         cmds.put("zig");
83         cmds.put(useCpp ? "c++" : "cc");
84         isCPlusPlus = useCpp;
85     }
86 
87     /// Adds a compiler flag, ignoring source files and target options.
88     /// Params:
89     ///   arg = Flag to add.
90     /// Returns: This builder for chaining.
91     Builder addArg(string arg) @safe pure
92     {
93         auto ext = extension(arg).toLower;
94         if (ext == ".c" || ext == ".o" || ext == ".obj" || ext == ".s"
95             || ext == ".cpp" || ext == ".cxx" || ext == ".cc" || ext == ".c++")
96             return this;
97         if (arg == "-target" || arg.startsWith("--target="))
98             return this;
99         mixin FlagChecks;
100         cmds.put(processFlag(arg));
101         return this;
102     }
103 
104     /// Adds multiple compiler flags.
105     /// Params:
106     ///   args = Flags to add.
107     /// Returns: This builder for chaining.
108     Builder addArgs(string[] args) @safe pure
109     {
110         foreach (arg; args)
111             addArg(arg);
112         return this;
113     }
114 
115     /// Adds a source file, enabling C++ mode for .cpp/.cxx/.cc/.c++ files.
116     /// Params:
117     ///   file = Source file path (.c, .cpp, .cxx, .cc, .c++, .o, .obj, .s).
118     /// Returns: This builder for chaining.
119     Builder file(string file) @safe pure
120     {
121         auto ext = extension(file).toLower;
122         if (ext == ".cpp" || ext == ".cxx" || ext == ".cc" || ext == ".c++")
123         {
124             if (!targetTriple.endsWith("msvc"))
125             {
126                 isCPlusPlus = true;
127                 cmds.data[1] = "c++";
128             }
129         }
130         else if (ext != ".c" && ext != ".o" && ext != ".obj" && ext != ".s")
131             return this;
132         sourceFiles.put(file);
133         return this;
134     }
135 
136     /// Adds multiple source files.
137     /// Params:
138     ///   files = Source file paths.
139     /// Returns: This builder for chaining.
140     Builder files(string[] files) @safe pure
141     {
142         foreach (f; files)
143             file(f);
144         return this;
145     }
146 
147     /// Sets the target triple, transforming Apple and GNU-style triples.
148     /// Params:
149     ///   triple = Target triple (e.g., x86_64-linux-gnu).
150     /// Returns: This builder for chaining.
151     Builder setTargetTriple(string triple) @safe pure nothrow
152     {
153         if (triple.startsWith("arm64-apple"))
154             targetTriple = "aarch64" ~ triple[ARM64_APPLE_PREFIX_LENGTH .. $];
155         else if (triple.startsWith("x86_64-apple"))
156             targetTriple = "x86_64" ~ triple[X86_64_APPLE_PREFIX_LENGTH .. $];
157         else if (triple.endsWith("-unknown-unknown"))
158             targetTriple = triple[0 .. $ - UNKNOWN_UNKNOWN_LENGTH] ~ "-freestanding";
159         else if (triple.canFind("-unknown-"))
160         {
161             auto parts = triple.split("-unknown-");
162             if (parts.length == 2)
163                 targetTriple = parts[0] ~ "-" ~ parts[1];
164             else
165                 targetTriple = triple;
166         }
167         else
168             targetTriple = triple;
169         return this;
170     }
171 
172     /// Sets CPU features.
173     /// Params:
174     ///   cpu = CPU feature string (e.g., generic).
175     /// Returns: This builder for chaining.
176     Builder setCpu(string cpu) @safe pure nothrow
177     {
178         this.cpu = cpu;
179         return this;
180     }
181 
182     /// Builds the Zig command with sanitizer flags if needed.
183     /// Returns: Command array for execution.
184     string[] build() @safe pure
185     {
186         auto result = cmds.data.dup ~ sourceFiles.data;
187         if (!targetTriple.empty)
188             result ~= ["-target", targetTriple];
189         if (!cpu.empty)
190             result ~= ["-mcpu=" ~ cpu];
191         if (result.length > MIN_SANITIZE_COMMAND_LENGTH)
192             result ~= "-fno-sanitize=all";
193         return result;
194     }
195 
196     /// Builds a static or dynamic library with Zig build-lib.
197     /// Params:
198     ///   libpath = Output file path.
199     ///   isShared = Build a dynamic library if true, static if false.
200     /// Returns: Exit status (0 for success).
201     int buildLibrary(string libpath, bool isShared = false) @trusted
202     {
203         if (sourceFiles.data.length == 0)
204         {
205             stderr.writeln("Error: No source files specified for library build");
206             return 1;
207         }
208         auto cmd = ["zig", "build-lib"] ~ sourceFiles.data;
209         cmd ~= ["-femit-bin=" ~ libpath, "-OReleaseFast"]; // compiler-rt + ubsan disabled
210         if (isShared)
211             cmd ~= ["-dynamic"];
212         if (!targetTriple.empty)
213             cmd ~= ["-target", targetTriple];
214         if (!cpu.empty)
215             cmd ~= ["-mcpu=" ~ cpu];
216 
217         mixin FlagChecks;
218         auto clangFlags = cmds.data.filter!(isClangFlag).array;
219         if (clangFlags.length)
220             cmd ~= ["-cflags"] ~ clangFlags[2 .. $] ~ ["--"];
221         cmd ~= isCPlusPlus && !targetTriple.endsWith("msvc") ? "-lc++" : "-lc";
222 
223         debug
224         {
225             write("[zig build-lib] flags: \"");
226             foreach (c; cmd[2 .. $])
227                 write(c, " ");
228             writeln("\"");
229         }
230 
231         return executeCommand(cmd, "build-lib");
232     }
233 
234     /// Executes the Zig command, printing flags in debug mode.
235     /// Returns: Exit status (0 for success).
236     int execute() @trusted
237     {
238         auto cmd = build();
239         if (cmd.length > BASE_COMMAND_LENGTH)
240         {
241             debug
242             {
243                 write("[zig ", cmds.data[1], "] flags: \"");
244                 foreach (c; cmd[BASE_COMMAND_LENGTH .. $])
245                     write(c, " ");
246                 writeln("\"");
247             }
248         }
249         return executeCommand(cmd, cmds.data[1]);
250     }
251 
252     /// Executes a command, enforcing DMD architecture restrictions.
253     /// Params:
254     ///   cmd = Command array to execute.
255     ///   mode = Command mode (e.g., cc, c++, build-lib).
256     /// Returns: Exit status (0 for success).
257     private int executeCommand(string[] cmd, string mode) @trusted
258     {
259         version (DMD)
260         {
261             if (!targetTriple.empty && targetTriple != "native-native" &&
262                 !ALLOWED_DMD_ARCHES.any!(arch => targetTriple.canFind(arch)))
263             {
264                 stderr.writeln("Error: DMD only supports x86/x86_64 or -target native-native");
265                 return 1;
266             }
267         }
268 
269         try
270         {
271             auto result = std.process.execute(cmd);
272             if (result.output.length)
273                 write(result.output);
274             enforce(result.status == 0, format("Zig %s failed with exit code %d: %s",
275                     mode, result.status, result.output));
276             return result.status;
277         }
278         catch (ProcessException e)
279         {
280             stderr.writeln("Error executing zig ", mode, ": ", e.msg);
281             return 1;
282         }
283     }
284 }
285 
286 /// Unit tests for Builder.
287 version (unittest)
288 {
289     import std.exception : assertThrown;
290     import std.algorithm : any;
291 
292     @("Skip excluded flags")
293     unittest
294     {
295         auto builder = new Builder();
296         builder.addArg("--no-as-needed").addArg("--exclude-libs").addArg("/nologo");
297         assert(builder.build() == ["zig", "cc"]);
298     }
299 
300     @("Transform -group and -dynamic flags")
301     unittest
302     {
303         auto builder = new Builder();
304         builder.addArg("-group");
305         assert(builder.build() == [
306             "zig", "cc", "-Wl,--start-group", "-Wl,--end-group",
307             "-fno-sanitize=all"
308         ]);
309     }
310 
311     @("Preserve explicit library flags")
312     unittest
313     {
314         auto builder = new Builder();
315         builder.addArg("-lm");
316         assert(builder.build() == ["zig", "cc", "-lm"]);
317     }
318 
319     @("Set target triple and CPU")
320     unittest
321     {
322         auto builder = new Builder();
323         builder.setTargetTriple("arm64-apple-macos").setCpu("generic");
324         assert(builder.build() == [
325             "zig", "cc", "-target", "aarch64-macos", "-mcpu=generic",
326             "-fno-sanitize=all"
327         ]);
328     }
329 
330     @("Detect C++ mode from file extension")
331     unittest
332     {
333         auto builder = new Builder();
334         builder.file("test.cpp");
335         assert(builder.build() == ["zig", "c++", "test.cpp"]);
336     }
337 
338     @("DMD rejects non-x86/x86_64 targets")
339     unittest
340     {
341         version (DMD)
342         {
343             auto builder = new Builder();
344             builder.setTargetTriple("aarch64-freestanding");
345             assert(builder.execute() == 1);
346         }
347     }
348 
349     @("LDC allows all targets")
350     unittest
351     {
352         version (LDC)
353         {
354             auto builder = new Builder();
355             builder.setTargetTriple("aarch64-linux-gnu");
356             assert(builder.build().canFind("aarch64-linux-gnu"));
357         }
358     }
359 
360     @("Pass through Clang flags")
361     unittest
362     {
363         auto builder = new Builder();
364         builder.file("test.c").addArg("-Wall").addArg("-std=c99").addArg("-h");
365         assert(builder.build() == [
366             "zig", "cc", "-Wall", "-std=c99", "-h", "test.c", "-fno-sanitize=all"
367         ]);
368     }
369 
370     @("DMD allows x86_64 target")
371     unittest
372     {
373         version (DMD)
374         {
375             auto builder = new Builder();
376             builder.setTargetTriple("x86_64-linux-gnu");
377             assert(builder.build().canFind("x86_64-linux-gnu"));
378         }
379     }
380 
381     @("Pass --help to zig")
382     unittest
383     {
384         auto builder = new Builder();
385         builder.addArg("--help");
386         assert(builder.build() == ["zig", "cc", "--help"]);
387     }
388 
389     @("Throw on failed execution")
390     unittest
391     {
392         auto builder = new Builder();
393         builder.file("nonexistent.c");
394         assertThrown!Exception(builder.execute());
395     }
396 
397     @("Detect C++ mode with any flag")
398     unittest
399     {
400         auto builder = new Builder();
401         builder.file("test.cpp").addArg("-some-flag");
402         assert(builder.build() == [
403             "zig", "c++", "-some-flag", "test.cpp", "-fno-sanitize=all"
404         ]);
405     }
406 
407     @("DMD allows native-native target")
408     unittest
409     {
410         version (DMD)
411         {
412             auto builder = new Builder();
413             builder.setTargetTriple("native-native");
414             assert(builder.build() == [
415                 "zig", "cc", "-target", "native-native", "-fno-sanitize=all"
416             ]);
417         }
418     }
419 
420     @("Throw on invalid Clang flag")
421     unittest
422     {
423         auto builder = new Builder();
424         builder.file("test.c").addArg("-invalid-flag");
425         assertThrown!Exception(builder.execute());
426     }
427 
428     @("Pass -h to zig")
429     unittest
430     {
431         auto builder = new Builder();
432         builder.addArg("-h");
433         assert(builder.build() == ["zig", "cc", "-h"]);
434     }
435 
436     @("MSVC target avoids zig c++")
437     unittest
438     {
439         auto builder = new Builder();
440         builder.setTargetTriple("x86_64-windows-msvc").file("test.cc");
441         assert(builder.build() == [
442             "zig", "cc", "test.cc", "-target", "x86_64-windows-msvc",
443             "-fno-sanitize=all"
444         ]);
445     }
446 
447     @("Transform arm64-apple-ios to aarch64-ios")
448     unittest
449     {
450         auto builder = new Builder();
451         builder.setTargetTriple("arm64-apple-ios").file("test.c");
452         assert(builder.build() == [
453             "zig", "cc", "test.c", "-target", "aarch64-ios", "-fno-sanitize=all"
454         ]);
455     }
456 
457     @("MSVC target with native-windows-msvc")
458     unittest
459     {
460         auto builder = new Builder();
461         builder.setTargetTriple("native-windows-msvc").file("test.cc");
462         assert(builder.build() == [
463             "zig", "cc", "test.cc", "-target", "native-windows-msvc",
464             "-fno-sanitize=all"
465         ]);
466     }
467 
468     @("Build library with C source")
469     unittest
470     {
471         auto builder = new Builder();
472         builder.file("test.c").addArg("-Wall");
473         assertThrown!Exception(builder.buildLibrary("test.lib"));
474     }
475 
476     @("Build library with C++ source")
477     unittest
478     {
479         auto builder = new Builder();
480         builder.file("test.cpp").addArg("-std=c++11");
481         assertThrown!Exception(builder.buildLibrary("libtest.a"));
482     }
483 
484     @("Build library with extra flags")
485     unittest
486     {
487         auto builder = new Builder();
488         builder.file("test.c").addArg("-Wall").file("rc.s");
489         assertThrown!Exception(builder.buildLibrary("test.dylib"));
490     }
491 
492     @("Add object file")
493     unittest
494     {
495         auto builder = new Builder();
496         builder.file("test.o");
497         assert(builder.build() == ["zig", "cc", "test.o"]);
498     }
499 
500     @("Add assembly file")
501     unittest
502     {
503         auto builder = new Builder();
504         builder.file("test.s");
505         assert(builder.build() == ["zig", "cc", "test.s"]);
506     }
507 }