Editor/Builder.cs
#nullable enable
using System;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Threading;
using System.Collections.Generic;
using Sandbox;
sealed class Builder : IDisposable {
const int BuildDebounceMs = 250;
volatile bool disposed;
int port;
string haxeCommand;
string srcPath;
string outPath;
string outPathAbs;
string[] exclude;
bool building, pending, pendingRelease;
readonly object buildLock = new();
readonly object processLock = new();
readonly List<Action<bool>> pendingCallbacks = new();
ProcessStartInfo startInfo;
ProcessStartInfo serverInfo;
Timer? timer;
Watcher codeWatcher;
Process? server;
Process? buildProcess;
public bool enabled;
public Builder(int port, string haxeCommand, string srcPath, string outPath, string[] exclude) {
this.port = port is > 0 and <= 65535 ? port : 6060;
this.haxeCommand = string.IsNullOrWhiteSpace(haxeCommand) ? "haxe" : haxeCommand.Trim();
this.srcPath = NormalizePath(srcPath, "code");
this.outPath = NormalizePath(outPath, "code/__haxe__");
this.outPathAbs = Path.GetFullPath(Path.Combine(HaxeBox.root, this.outPath));
this.exclude = exclude;
startInfo = CreateBuildStartInfo();
serverInfo = CreateServerStartInfo();
codeWatcher = CreateCodeWatcher(this.srcPath);
StartServer();
HaxeBox.logger.Info("Builder created");
}
public void ApplySettings(int port, string haxeCommand, string srcPath, string outPath, string[] exclude) {
if (disposed)
return;
var normalizedPort = port is > 0 and <= 65535 ? port : 6060;
var normalizedCommand = string.IsNullOrWhiteSpace(haxeCommand) ? "haxe" : haxeCommand.Trim();
var normalizedSrcPath = NormalizePath(srcPath, "code");
var normalizedOutPath = NormalizePath(outPath, "code/__haxe__");
var restartServer = this.port != normalizedPort || !string.Equals(this.haxeCommand, normalizedCommand, StringComparison.Ordinal);
var recreateWatcher = !string.Equals(this.srcPath, normalizedSrcPath, StringComparison.OrdinalIgnoreCase);
this.port = normalizedPort;
this.haxeCommand = normalizedCommand;
this.srcPath = normalizedSrcPath;
this.outPath = normalizedOutPath;
this.outPathAbs = Path.GetFullPath(Path.Combine(HaxeBox.root, this.outPath));
this.exclude = exclude;
startInfo = CreateBuildStartInfo();
serverInfo = CreateServerStartInfo();
if (recreateWatcher)
RecreateWatcher();
if (restartServer)
StartServer();
}
string NormalizePath(string? path, string fallback) {
var normalized = path?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(normalized))
normalized = fallback;
return normalized.Replace('\\', '/').Trim('/');
}
Watcher CreateCodeWatcher(string relativePath) {
var fullPath = Path.Combine(HaxeBox.root, relativePath);
Directory.CreateDirectory(fullPath);
return new Watcher(fullPath, ["*.hx"], Queue);
}
void RecreateWatcher() {
var wasEnabled = enabled;
codeWatcher.Dispose();
codeWatcher = CreateCodeWatcher(srcPath);
if (wasEnabled)
codeWatcher.Start();
}
ProcessStartInfo CreateBuildStartInfo() {
return new ProcessStartInfo {
FileName = haxeCommand,
Arguments = $"--connect {port} build.hxml",
WorkingDirectory = HaxeBox.root,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
}
ProcessStartInfo CreateServerStartInfo() {
return new ProcessStartInfo {
FileName = haxeCommand,
Arguments = $"--wait {port}",
WorkingDirectory = HaxeBox.root,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
}
public void Pause() {
if (!enabled)
return;
enabled = false;
codeWatcher.Stop();
HaxeBox.logger.Info("Hotload paused");
}
public void Resume() {
if (enabled)
return;
enabled = true;
codeWatcher.Start();
HaxeBox.logger.Info("Hotload resumed");
BuildAsync();
}
public void Dispose() {
disposed = true;
enabled = false;
timer?.Dispose();
timer = null;
codeWatcher.Dispose();
StopBuildProcess();
StopServer();
HaxeBox.logger.Info("Builder stopped");
}
public void BuildAsync() {
BuildAsync(false, null);
}
public void BuildAsync(Action<bool> onCompleted) {
BuildAsync(true, onCompleted);
}
public void BuildAsync(bool isRelease, Action<bool>? onCompleted) {
ThreadPool.QueueUserWorkItem(_ => {
if (disposed) {
onCompleted?.Invoke(false);
return;
}
lock (buildLock) {
if (building) {
pending = true;
pendingRelease |= isRelease;
if (onCompleted != null)
pendingCallbacks.Add(onCompleted);
return;
}
building = true;
}
var ok = Build(isRelease);
onCompleted?.Invoke(ok);
bool runPending = false;
bool runPendingRelease = false;
List<Action<bool>>? runPendingCallbacks = null;
lock (buildLock) {
building = false;
if (pending) {
runPending = true;
runPendingRelease = pendingRelease;
if (pendingCallbacks.Count > 0) {
runPendingCallbacks = new List<Action<bool>>(pendingCallbacks);
pendingCallbacks.Clear();
}
pending = false;
pendingRelease = false;
}
}
if (runPending)
BuildAsync(runPendingRelease, ok2 => {
if (runPendingCallbacks == null)
return;
foreach (var cb in runPendingCallbacks)
cb(ok2);
});
});
}
public bool Build(bool isRelease = false) {
Toaster.CompileStarted("Haxe", "Building...");
var resumeWatcher = enabled;
Process? process = null;
if (resumeWatcher)
codeWatcher.Stop();
try {
var includeIgnore = BuildHaxeArray(exclude);
var includeClassPaths = BuildHaxeArray([srcPath]);
var hxml = new StringBuilder(2048);
hxml.AppendLine($"--cs {outPath}")
.AppendLine($"-cp {srcPath}")
.AppendLine($"-cp {HaxeBox.path}/haxe/haxebox")
.AppendLine($"-cp {HaxeBox.path}/haxe/extern")
.AppendLine($"--macro include('', true, {includeIgnore}, {includeClassPaths}, true)")
.AppendLine("--macro HaxeBoxMacro.init()");
foreach (var library in HaxeBox.GetLibraries())
hxml.AppendLine("-lib " + library);
hxml.AppendLine("# config")
.AppendLine("-dce no");
if (HaxeBox.config.release || isRelease)
hxml.AppendLine("-D no-traces")
.AppendLine("-D real-position")
.AppendLine("-D analyzer-optimize");
else
hxml.AppendLine("--no-opt")
.AppendLine("-debug")
.AppendLine("-D no-inline");
if (HaxeBox.config.whitelist)
hxml.AppendLine("-D WHITELIST");
hxml.AppendLine($"-D PROJECT_PATH={HaxeBox.root}")
.AppendLine($"-D HAXEBOX_PATH={HaxeBox.path}")
.AppendLine($"-D SOURCES_PATH={srcPath}")
.AppendLine($"-D SOURCES_EXCL={string.Join(";", exclude)}")
.AppendLine("-D no-compilation")
.AppendLine($"-D source-header=Generated with HaxeBox for {Game.Ident}");
foreach (var s in HaxeBox.config.symbols)
if (!s.Equals("DEBUG", StringComparison.OrdinalIgnoreCase))
hxml.AppendLine("-D " + s);
File.WriteAllText(Path.Combine(HaxeBox.root, "build.hxml"), hxml.ToString());
process = Process.Start(startInfo);
if (process == null) {
HaxeBox.logger.Error("Haxe build start failed");
return false;
}
lock (processLock)
buildProcess = process;
List<string> diagnostics = [];
process.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) diagnostics.Add(e.Data); };
process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) diagnostics.Add(e.Data); };
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
if (process.ExitCode != 0) {
Toaster.CompileFailed("Haxe", diagnostics, "Build failed");
return false;
}
} catch (Exception ex) {
Toaster.CompileFailed("Haxe", [ex.Message], "Build failed");
return false;
} finally {
lock (processLock) {
if (ReferenceEquals(buildProcess, process))
buildProcess = null;
}
process?.Dispose();
if (!disposed && resumeWatcher && enabled)
codeWatcher.Start();
}
Toaster.RemoveCurrent();
return true;
}
static string BuildHaxeArray(IEnumerable<string> items) {
var sb = new StringBuilder("[");
var first = true;
foreach (var item in items) {
if (string.IsNullOrWhiteSpace(item))
continue;
if (!first)
sb.Append(", ");
first = false;
sb.Append('\'')
.Append(item.Replace("\\", "\\\\").Replace("'", "\\'"))
.Append('\'');
}
sb.Append(']');
return sb.ToString();
}
void StartServer() {
if (disposed)
return;
StopServer();
var p = Process.Start(serverInfo);
if (p == null) {
HaxeBox.logger.Error("Compilation server start failed");
return;
}
if (disposed) {
try {
if (!p.HasExited)
p.Kill();
} catch {
// ignore
}
p.Dispose();
return;
}
lock (processLock)
server = p;
p.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) HaxeBox.logger.Info(e.Data); };
p.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) HaxeBox.logger.Error(e.Data); };
p.BeginOutputReadLine();
p.BeginErrorReadLine();
HaxeBox.logger.Info("Compilation server started");
}
void StopServer() {
Process? p;
lock (processLock) {
p = server;
server = null;
}
if (p == null)
return;
try {
if (!p.HasExited)
p.Kill();
} catch {
// ignore: process may already be gone
}
p.Dispose();
HaxeBox.logger.Info("Compilation server stopped");
}
void StopBuildProcess() {
Process? p;
lock (processLock) {
p = buildProcess;
buildProcess = null;
}
if (p == null)
return;
try {
if (!p.HasExited)
p.Kill();
} catch {
// ignore: process may already be gone
}
p.Dispose();
}
void Queue(string path) {
if (disposed || !enabled)
return;
if (!string.IsNullOrEmpty(path)) {
var fullPath = Path.GetFullPath(path);
if (fullPath.StartsWith(outPathAbs, StringComparison.OrdinalIgnoreCase))
return;
}
timer ??= new Timer(_ => BuildAsync(), null, Timeout.Infinite, Timeout.Infinite);
timer.Change(BuildDebounceMs, Timeout.Infinite);
}
}