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 }