diff --git a/OneWare.slnx b/OneWare.slnx index 0395556d6..65093d645 100644 --- a/OneWare.slnx +++ b/OneWare.slnx @@ -128,6 +128,7 @@ + diff --git a/src/OneWare.Essentials/Services/IToolCommandBuilder.cs b/src/OneWare.Essentials/Services/IToolCommandBuilder.cs new file mode 100644 index 000000000..68d1cf541 --- /dev/null +++ b/src/OneWare.Essentials/Services/IToolCommandBuilder.cs @@ -0,0 +1,153 @@ +using OneWare.Essentials.Enums; +using OneWare.Essentials.ToolEngine; + +namespace OneWare.Essentials.Services; + +/// +/// A fluent builder for creating instances. +/// Supports cross-platform path handling, scripting placeholders, and container/networking configurations. +/// +public interface IToolCommandBuilder +{ + /// + /// Sets the path to the executable file. + /// If not explicitly set, the ToolName provided during initialization will be used as the executable. + /// + IToolCommandBuilder WithExecutable(string path); + + /// + /// Sets the working directory for the tool execution. Defaults to "." + /// + IToolCommandBuilder WithWorkingDirectory(string dir); + + /// + /// Defines the status message and application state to be displayed in the UI during execution. + /// + IToolCommandBuilder WithStatus(string status, AppState state = AppState.Loading); + + /// + /// Determines whether a timer should be displayed in the UI during the tool's execution. + /// + IToolCommandBuilder WithTimer(bool show); + + /// + /// Registers a handler for the standard output stream (stdout). + /// + IToolCommandBuilder WithOutputHandler(Func? handler); + + /// + /// Registers a handler for the error output stream (stderr). + /// + IToolCommandBuilder WithErrorHandler(Func? handler); + + /// + /// Adds a simple string literal as a command-line argument. + /// + IToolCommandBuilder Add(string literal); + + /// + /// Adds multiple string literals as command-line arguments. + /// + IToolCommandBuilder Add(params string[] literals); + + /// + /// Adds a collection of string literals as command-line arguments. + /// + IToolCommandBuilder AddRange(IEnumerable literals); + + /// + /// Adds an argument only if the specified condition is met. + /// + IToolCommandBuilder AddIf(bool condition, string literal); + + /// + /// Adds an argument only if the provided string is not null or whitespace. + /// + IToolCommandBuilder AddIfNotNull(string? literal); + + /// + /// Adds a file or directory path as an argument. + /// The path will be normalized according to the target OS (Windows/Linux) during preparation. + /// + IToolCommandBuilder AddPath(string path); + + /// + /// Adds a collection of paths as arguments, ensuring OS-specific normalization for each. + /// + IToolCommandBuilder AddPaths(IEnumerable paths); + + /// + /// Adds an option consisting of a flag and a value (e.g., "-o", "output.bin"). + /// + IToolCommandBuilder AddOption(string flag, string value); + + /// + /// Adds an option consisting of a flag and a value only if the value is not null or whitespace. + /// + IToolCommandBuilder AddOptionIfNotNull(string flag, string? value); + + /// + /// Adds an option consisting of a flag and a path. The path will be normalized. + /// + IToolCommandBuilder AddPathOption(string flag, string path); + + /// + /// Adds an option consisting of a flag and a path only if the path is not null or whitespace. + /// + IToolCommandBuilder AddPathOptionIfNotNull(string flag, string? path); + + /// + /// Adds a complex command string using template placeholders. + /// Placeholders are treated as simple literals. + /// + IToolCommandBuilder AddScript(string template, params (string placeholder, string value)[] literals); + + /// + /// Adds a complex command string using template placeholders. + /// Allows placeholders to be explicitly marked as paths for OS-specific normalization. + /// + IToolCommandBuilder AddScript(string template, params (string placeholder, string value, bool isPath)[] mappings); + + /// + /// Parses a raw string (e.g., from user settings) into individual arguments, respecting quotes. + /// + IToolCommandBuilder AddRawArguments(string? rawArgs); + + /// + /// Looks up a path in a dictionary by its key and adds it as a normalized path argument. + /// + IToolCommandBuilder AddPathFromMap(TKey key, IDictionary map) where TKey : notnull; + + /// + /// Sets an environment variable for the tool process. + /// + IToolCommandBuilder WithEnvironmentVariable(string key, string value); + + /// + /// Adds a collection of environment variables for the tool process. + /// + IToolCommandBuilder WithEnvironmentVariables(IDictionary variables); + + /// + /// Sets an environment variable only if the specified condition is met. + /// + IToolCommandBuilder WithEnvironmentVariableIf(bool condition, string key, string value); + + /// + /// Declares a network port that the tool listens on internally. + /// Used for firewall configuration or container port exposure. + /// + IToolCommandBuilder WithExposedPort(int port, string protocol = "TCP"); + + /// + /// Defines a network port mapping. + /// Native runners typically use the guestPort directly, while container runners map the hostPort to the guestPort. + /// + IToolCommandBuilder AddPortMapping(int hostPort, int guestPort, string protocol = "TCP"); + + /// + /// Builds the final instance. + /// + /// Thrown if neither ToolName nor Executable is set. + ToolCommand Build(); +} \ No newline at end of file diff --git a/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs b/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs index ebd3d0e56..fc148242a 100644 --- a/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs +++ b/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using OneWare.Essentials.ToolEngine; namespace OneWare.Essentials.Services; @@ -8,4 +9,20 @@ public interface IToolExecutionDispatcherService /// Executes a tool command using the configured execution strategy. /// public Task<(bool success, string output)> ExecuteAsync(ToolCommand command); + + /// + /// Starts the tool as a background process without waiting for its completion or capturing its output. + /// + /// The run configuration for the strategy. + /// A to the started process, allowing it to be garbage collected if no other references exist. + public WeakReference StartWeakProcess(ToolCommand command); + + /// + /// Creates a new instance of for a specific tool. + /// This is the entry point for configuring a tool command with specific arguments, environment variables, and mappings. + /// + /// The name of the tool to be executed (e.g., "yosys", "gcc"). + /// Used for logging and identifying the executable if no path is provided. + /// A fluent builder instance to configure the command. + public IToolCommandBuilder CreateToolCommandBuilder(string toolName); } diff --git a/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs b/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs index b203eab7e..f1de8af56 100644 --- a/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs +++ b/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using OneWare.Essentials.ToolEngine; namespace OneWare.Essentials.Services; @@ -10,6 +11,14 @@ public interface IToolExecutionStrategy /// The run configuration for the strategy /// Task<(bool success, string output)> ExecuteAsync(ToolCommand command); + + + /// + /// Starts the tool as a background process without waiting for its completion or capturing its output. + /// + /// The run configuration for the strategy. + /// A to the started process, allowing it to be garbage collected if no other references exist. + public WeakReference StartWeakProcess(ToolCommand command); /// /// Returns the display name for a strategy. diff --git a/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs b/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs new file mode 100644 index 000000000..3450edb25 --- /dev/null +++ b/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace OneWare.Essentials.ToolEngine; + +public interface ICommandArgument +{ + string GetArgument(); + + void Prepare(OSPlatform osPlatform, Func? pathMapper = null); +} \ No newline at end of file diff --git a/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs b/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs index 6ae15c641..b4bfac9bc 100644 --- a/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs +++ b/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using OneWare.Essentials.Models; using OneWare.Essentials.Services; @@ -5,16 +6,23 @@ namespace OneWare.Essentials.ToolEngine.Strategies; public class NativeStrategy : IToolExecutionStrategy { - private const string ToolKey = "NativeExecutionStrategy"; + public const string ToolKey = "NativeExecutionStrategy"; public Task<(bool success, string output)> ExecuteAsync(ToolCommand command) { - IChildProcessService childProcessService = ContainerLocator.Container.Resolve(); + var childProcessService = ContainerLocator.Container.Resolve(); return childProcessService.ExecuteShellAsync(command.Executable ?? command.ToolName, command.Arguments, command.WorkingDirectory, command.StatusMessage, command.State, command.ShowTimer, command.OutputHandler, command.ErrorHandler); } + public WeakReference StartWeakProcess(ToolCommand command) + { + var childProcessService = ContainerLocator.Container.Resolve(); + return childProcessService.StartWeakProcess(command.Executable ?? command.ToolName, command.Arguments, + command.WorkingDirectory); + } + public string GetStrategyName() { return "Native Execution Strategy"; diff --git a/src/OneWare.Essentials/ToolEngine/ToolCommand.cs b/src/OneWare.Essentials/ToolEngine/ToolCommand.cs index f777425fb..33f481e6a 100644 --- a/src/OneWare.Essentials/ToolEngine/ToolCommand.cs +++ b/src/OneWare.Essentials/ToolEngine/ToolCommand.cs @@ -1,4 +1,7 @@ +using System.Runtime.InteropServices; using OneWare.Essentials.Enums; +using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; namespace OneWare.Essentials.ToolEngine; @@ -6,15 +9,33 @@ public class ToolCommand { public required string ToolName { get; init; } public string? Executable { get; init; } - public IReadOnlyCollection Arguments { get; init; } = []; + + public IReadOnlyCollection ExposedPorts { get; init; } = Array.Empty(); + + public IReadOnlyCollection PortMappings { get; init; } = Array.Empty(); + + public IReadOnlyCollection Arguments => + CommandArguments.Select(x => x.GetArgument()).ToList().AsReadOnly(); + + public required IReadOnlyCollection CommandArguments { get; init; } public string WorkingDirectory { get; init; } = "."; public string StatusMessage { get; init; } = "Running tool..."; public AppState State { get; init; } = AppState.Loading; public bool ShowTimer { get; init; } + public IReadOnlyDictionary EnvironmentVariables { get; init; } = new Dictionary(); + public Func? OutputHandler { get; init; } public Func? ErrorHandler { get; init; } + public void PrepareCommand(OSPlatform osPlatform, Func? pathMapper = null) + { + foreach (var argument in CommandArguments) + { + argument.Prepare(osPlatform, pathMapper); + } + } + [Obsolete("Use IToolExecutionDispatcherService.CreateToolCommandBuilder instead.")] public static ToolCommand FromShellParams( string path, IReadOnlyCollection arguments, @@ -25,39 +46,19 @@ public static ToolCommand FromShellParams( Func? outputAction = null, Func? errorAction = null) { - return new ToolCommand - { - ToolName = Path.GetFileNameWithoutExtension(path), - Executable = path, - Arguments = arguments, - WorkingDirectory = workingDirectory, - StatusMessage = status, - State = state, - ShowTimer = showTimer, - OutputHandler = outputAction, - ErrorHandler = errorAction - }; + return ContainerLocator.Current.Resolve(). + CreateToolCommandBuilder(Path.GetFileNameWithoutExtension(path)) + .WithExecutable(path) + .WithWorkingDirectory(workingDirectory) + .WithStatus(status, state) + .WithOutputHandler(outputAction) + .WithErrorHandler(errorAction) + .WithTimer(showTimer) + .AddRange(arguments) + .Build(); } } -public class ToolContext -{ - public ToolContext(string name, string description, string key, List? toolNames = null) - { - Name = name; - Description = description; - Key = key; - ToolNames = toolNames ?? []; - } - - public string Name { get; init; } - public string Description { get; init; } - public string Key { get; init; } +public record ToolPort(int Number, string Protocol = "TCP"); - public List ToolNames { get; init; } -} - -public class ToolConfiguration -{ - public readonly Dictionary StrategyMapping = new(); -} \ No newline at end of file +public record ToolPortMapping(ToolPort Host, ToolPort Guest); \ No newline at end of file diff --git a/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs b/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs new file mode 100644 index 000000000..8d74caa22 --- /dev/null +++ b/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs @@ -0,0 +1,6 @@ +namespace OneWare.Essentials.ToolEngine; + +public class ToolConfiguration +{ + public readonly Dictionary StrategyMapping = new(); +} \ No newline at end of file diff --git a/src/OneWare.Essentials/ToolEngine/ToolContext.cs b/src/OneWare.Essentials/ToolEngine/ToolContext.cs new file mode 100644 index 000000000..c178d57df --- /dev/null +++ b/src/OneWare.Essentials/ToolEngine/ToolContext.cs @@ -0,0 +1,10 @@ +namespace OneWare.Essentials.ToolEngine; + +public class ToolContext(string name, string description, string key, List? toolNames = null) +{ + public string Name { get; init; } = name; + public string Description { get; init; } = description; + public string Key { get; init; } = key; + + public List ToolNames { get; init; } = toolNames ?? []; +} \ No newline at end of file diff --git a/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs b/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs index 1b7acaa50..238d394a4 100644 --- a/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs +++ b/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs @@ -1,8 +1,6 @@ using Avalonia.Threading; using Microsoft.Extensions.Logging; -using OneWare.Essentials.Enums; using OneWare.Essentials.Services; -using OneWare.Essentials.ToolEngine; using OneWare.OssCadSuiteIntegration.Helpers; using OneWare.UniversalFpgaProjectSystem.Models; using OneWare.UniversalFpgaProjectSystem.Parser; @@ -14,6 +12,14 @@ public class OpenFpgaLoader(ISettingsService settingsService, ILogger logger, IO IToolExecutionDispatcherService toolExecutionDispatcherService) : IFpgaLoader { + + private static readonly Dictionary BitstreamPaths = new() + { + { "bin", "./build/pack.bin" }, + { "bit", "./build/pack.bit" }, + { "fs", "./build/pack.fs" } + }; + public const string LoaderId = "openFpgaLoader"; public string Id => LoaderId; public string Name => "OpenFpgaLoader"; @@ -21,71 +27,36 @@ public class OpenFpgaLoader(ISettingsService settingsService, ILogger logger, IO public async Task DownloadAsync(UniversalFpgaProjectRoot project) { var fpga = project.Properties.GetString("Fpga") ?? "unknown"; - var longTerm = settingsService.GetSettingValue("UniversalFpgaProjectSystem_LongTermProgramming"); - var properties = FpgaSettingsParser.LoadSettings(project, fpga); var board = properties.GetValueOrDefault("openFpgaLoaderBoard"); var cable = properties.GetValueOrDefault("OpenFpgaLoader_Cable"); - - List openFpgaLoaderArguments = []; - if (!string.IsNullOrEmpty(board)) - { - openFpgaLoaderArguments.AddRange(["-b", board]); - } - else if (!string.IsNullOrEmpty(cable)) - { - openFpgaLoaderArguments.AddRange(["-c", cable]); - } - else - { - logger.Error("Board/Cable not supported/configured for openFPGALoader!"); - return; - } - - if (longTerm) - { - if (properties.GetValueOrDefault("openFpgaLoaderLongTermFlags") is { } longFlags) - openFpgaLoaderArguments.Add(longFlags); - - openFpgaLoaderArguments.Add("-f"); - } - else if (properties.GetValueOrDefault("openFpgaLoaderShortTermFlags") is { } shortFlags) - { - openFpgaLoaderArguments.Add(shortFlags); - } - - openFpgaLoaderArguments.AddRange(properties.GetValueOrDefault("OpenFpgaLoaderFlags")?.Split(' ', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []); - + var loaderPath = settingsService.GetSettingValue(OssCadSuiteHelper.OpenFpgaLoaderPathSetting); var bitstreamFormat = properties .GetValueOrDefault("openFpgaLoaderBitstreamFormat", "bin"); + + var command = toolExecutionDispatcherService.CreateToolCommandBuilder("openFPGALoader") + .WithExecutable(loaderPath) + .WithWorkingDirectory(project.FullPath) + .WithStatus($"Running {loaderPath}...") + .WithTimer(true) + .WithOutputHandler(s => + { + Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); return true; + }) + + .AddOptionIfNotNull("-b", board) + .AddOptionIfNotNull("-c", string.IsNullOrEmpty(board) ? cable : null) + + .AddIf(longTerm, "-f") + .AddRawArguments(longTerm + ? properties.GetValueOrDefault("openFpgaLoaderLongTermFlags") + : properties.GetValueOrDefault("openFpgaLoaderShortTermFlags")) + .AddRawArguments(properties.GetValueOrDefault("OpenFpgaLoaderFlags")) + .AddPathFromMap(bitstreamFormat, BitstreamPaths).Build(); - switch (bitstreamFormat) - { - case "bin": - openFpgaLoaderArguments.Add("./build/pack.bin"); - break; - case "bit": - openFpgaLoaderArguments.Add("./build/pack.bit"); - break; - case "fs": - openFpgaLoaderArguments.Add("./build/pack.fs"); - break; - default: - outputService.WriteLine($"Could not find output type: {bitstreamFormat}"); - return; - } - - var path = settingsService.GetSettingValue(OssCadSuiteHelper.OpenFpgaLoaderPathSetting); outputService.WriteLine("Starting OpenFpgaLoader ..."); - var command = ToolCommand.FromShellParams(path, openFpgaLoaderArguments!, - project.FullPath, $"Running {path}...", AppState.Loading, true, null, s => - { - Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); - return true; - }); try { diff --git a/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs b/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs index 15067eec2..ceb3871d8 100644 --- a/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs +++ b/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs @@ -52,12 +52,12 @@ public override void Initialize(IServiceProvider serviceProvider) var toolService = serviceProvider.Resolve(); toolService.Register(new ToolContext("yosys", "Synth Tool", "yosys"), new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-ecp5", "Place and Routing Tool", "nextpnr-ecp5"), new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-generic", "Place and Routing Tool", "nextpnr-generic"),new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-himbaechel", "Place and Routing Tool", "nextpnr-himbaechel"), new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-ice40", "Place and Routing Tool", "nextpnr-ice40"), new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-machxo2", "Place and Routing Tool", "nextpnr-machxo2"), new NativeStrategy()); - toolService.Register(new ToolContext("nextpnr-nexus", "Place and Routing Tool", "nextpnr-nexus"), new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-ecp5", "Place and Routing Tool for ECP5", "nextpnr-ecp5"), new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-generic", "Place and Routing Tool for generic devices", "nextpnr-generic"),new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-himbaechel", "Place and Routing Tool for large archs", "nextpnr-himbaechel"), new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-ice40", "Place and Routing Tool for ICE40", "nextpnr-ice40"), new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-machxo2", "Place and Routing Tool MachXO2", "nextpnr-machxo2"), new NativeStrategy()); + toolService.Register(new ToolContext("nextpnr-nexus", "Place and Routing Tool for nexus", "nextpnr-nexus"), new NativeStrategy()); toolService.Register(new ToolContext("openFPGALoader", "FPGA Loader", "openFPGALoader"), new NativeStrategy()); toolService.Register(new ToolContext("iceprog", "Packing", "FPGA Loader"), new NativeStrategy()); @@ -68,6 +68,8 @@ public override void Initialize(IServiceProvider serviceProvider) toolService.Register(new ToolContext("gmupack", "Packing", "gmupack"), new NativeStrategy()); toolService.Register(new ToolContext("gtkwave", "Visualisation", "gtkwave"), new NativeStrategy()); + toolService.Register(new ToolContext("iverilog", "Simulation", "iverilog"), new NativeStrategy()); + toolService.Register(new ToolContext("vvp", "Simulation", "vvp"), new NativeStrategy()); serviceProvider.Resolve().RegisterPackage(OssCadSuiteHelper.OssCadPackage); diff --git a/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs b/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs index 795bbe31f..8268da80a 100644 --- a/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs +++ b/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs @@ -1,8 +1,10 @@ using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; using OneWare.Essentials.Enums; using OneWare.Essentials.Extensions; using OneWare.Essentials.Models; using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; using OneWare.OssCadSuiteIntegration.Tools; using OneWare.OssCadSuiteIntegration.ViewModels; using OneWare.OssCadSuiteIntegration.Views; @@ -14,15 +16,18 @@ namespace OneWare.OssCadSuiteIntegration.Simulators; public class IcarusVerilogSimulator : IFpgaSimulator { - private readonly IChildProcessService _childProcessService; private readonly IMainDockService _mainDockService; private readonly IProjectExplorerService _projectExplorerService; private readonly GtkWaveService _gtkWaveService; + private readonly IToolExecutionDispatcherService _toolExecutionDispatcherService; + private readonly ILogger _logger; - public IcarusVerilogSimulator(IChildProcessService childProcessService, IMainDockService mainDockService, - IProjectExplorerService projectExplorerService, GtkWaveService gtkWaveService) + public IcarusVerilogSimulator(ILogger logger, IMainDockService mainDockService, + IProjectExplorerService projectExplorerService, GtkWaveService gtkWaveService, + IToolExecutionDispatcherService toolExecutionDispatcherService) { - _childProcessService = childProcessService; + _logger = logger; + _toolExecutionDispatcherService = toolExecutionDispatcherService; _mainDockService = mainDockService; _projectExplorerService = projectExplorerService; _gtkWaveService = gtkWaveService; @@ -59,36 +64,42 @@ public async Task SimulateAsync(string fullPath) .Select(x => x.ToUnixPath()); _mainDockService.Show(); - - List icarusVerilogArguments = []; - icarusVerilogArguments.AddRange(["-o", vvpPath]); var settings = await TestBenchContextManager.LoadContextAsync(fullPath); var waveOutput = settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.WaveOutputFormat)) ?? "VCD"; - - var waveOutputArgument = waveOutput switch - { - "VCD" => "", - "LXT2" => "-lxt2", - "FST" => "-fst", - _ => string.Empty - }; - var additionalGhdlOptions = - settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.IcarusVerilogArguments)); - if (additionalGhdlOptions != null) icarusVerilogArguments.AddRange(additionalGhdlOptions.Split(' ')); + var command = _toolExecutionDispatcherService.CreateToolCommandBuilder("iverilog") + .WithWorkingDirectory(root.FullPath) + .WithStatus("Running IVerilog..", AppState.Loading) + .WithTimer(true) + .AddPathOption("-o", vvpPath) + .AddRawArguments(settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.IcarusVerilogArguments))) + .AddPaths(verilogFiles) + .Build(); - icarusVerilogArguments.AddRange(verilogFiles); + var (resultIVerilog, _) = await _toolExecutionDispatcherService.ExecuteAsync(command); + + if (!resultIVerilog) + { + _logger.LogWarning("IVerilog failed"); + return false; + } - var (result, _) = await _childProcessService.ExecuteShellAsync("iverilog", icarusVerilogArguments, - root.FullPath, "Running IVerilog...", AppState.Loading, true); - - if (!result) return false; - - var (success2, output) = await _childProcessService.ExecuteShellAsync("vvp", [vvpPath, waveOutputArgument], - root.FullPath, "Running VVP Simulation...", AppState.Loading, true); - if (!success2) return false; + var vvpCommand = _toolExecutionDispatcherService.CreateToolCommandBuilder("vvp").WithWorkingDirectory(root.FullPath) + .WithStatus("Running VPP Simulation", AppState.Loading) + .WithTimer(true) + .Add(vvpPath) + .AddIf(waveOutput == "LXT2", "-lxt2") + .AddIf(waveOutput == "FST", "-fst").Build(); + + var (resultVvp, outputVvp) = await _toolExecutionDispatcherService.ExecuteAsync(vvpCommand); + + if (!resultVvp) + { + _logger.LogWarning("VVP failed"); + return false; + } var escapedEnding = waveOutput switch { @@ -100,7 +111,7 @@ public async Task SimulateAsync(string fullPath) var vcdFileRegex = new Regex($@".*info: dumpfile\s+(.+\{escapedEnding})\s+opened for output."); - var match = vcdFileRegex.Match(output); + var match = vcdFileRegex.Match(outputVvp); if (!match.Success) return true; var fileRelativePath = match.Groups[1].Value; diff --git a/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs b/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs index 0eb287f20..ea225edc5 100644 --- a/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs +++ b/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs @@ -1,9 +1,10 @@ using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; using OneWare.UniversalFpgaProjectSystem.Context; namespace OneWare.OssCadSuiteIntegration.Tools; -public class GtkWaveService(IChildProcessService childProcessService) +public class GtkWaveService(IToolExecutionDispatcherService toolExecutionDispatcherService) { private static readonly string[] GtkProperties = ["GtkwSaveFile", "GtkwWaveArgs"]; public static readonly string[] GtkWaveformEndings = [".vcd", ".ghw", ".fst", ".lxt"]; @@ -11,13 +12,22 @@ public class GtkWaveService(IChildProcessService childProcessService) public async Task OpenInGtkWaveAsync(string filePath) { var context = await TestBenchContextManager.LoadContextAsync(filePath); - List args = [Path.GetFileName(filePath)]; - + var directory = Path.GetDirectoryName(filePath) ?? string.Empty; + + var builder = toolExecutionDispatcherService.CreateToolCommandBuilder("gtkwave") + .WithWorkingDirectory(directory) + .Add(Path.GetFileName(filePath)); + foreach (var property in GtkProperties) - if (context.Properties.TryGetPropertyValue(property, out var jsonNode) && jsonNode != null && jsonNode.GetValueKind() == System.Text.Json.JsonValueKind.String) - args.Add(jsonNode.GetValue()); + { + if (context.Properties.TryGetPropertyValue(property, out var jsonNode) && + jsonNode?.GetValueKind() == System.Text.Json.JsonValueKind.String) + { + builder.AddIfNotNull(jsonNode.GetValue()); + } + } - // save file has to be provided as second argument without "--save" - childProcessService.StartWeakProcess("gtkwave", args, Path.GetDirectoryName(filePath) ?? ""); + var toolCommand = builder.Build(); + toolExecutionDispatcherService.StartWeakProcess(toolCommand); } } diff --git a/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs b/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs index 0b5146f0f..167e9b584 100644 --- a/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs +++ b/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs @@ -7,7 +7,6 @@ using OneWare.Essentials.Services; using OneWare.Essentials.ToolEngine; using OneWare.OssCadSuiteIntegration.Models; -using OneWare.ToolEngine.Services; using OneWare.OssCadSuiteIntegration.Tools; using OneWare.UniversalFpgaProjectSystem.Fpga; using OneWare.UniversalFpgaProjectSystem.Models; @@ -16,11 +15,9 @@ namespace OneWare.OssCadSuiteIntegration.Yosys; public class YosysService( - IChildProcessService childProcessService, ILogger logger, IOutputService outputService, IMainDockService dockService, - IToolService toolService, IToolExecutionDispatcherService toolExecutionDispatcherService) { public async Task CompileAsync(UniversalFpgaProjectRoot project, FpgaModel fpgaModel) @@ -63,58 +60,32 @@ public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel f } public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel fpgaModel, - IEnumerable? mandatoryFiles) + IEnumerable? mandatoryFiles = null) { try { var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name); - var top = project.TopEntity ?? throw new Exception("TopEntity not set!"); var includedFiles = project.GetFiles("*.v").Concat(project.GetFiles("*.sv")) .Where(x => !project.IsCompileExcluded(x)) - .Where(x => !project.IsTestBench(x)); - + .Where(x => !project.IsTestBench(x)) + .ToList(); + var genVerilogPath = Path.Combine(project.RootFolderPath, "build", "gen_verilog"); if (Directory.Exists(genVerilogPath)) { - var generatedFiles = Directory.EnumerateFiles(genVerilogPath, "*.v", - SearchOption.AllDirectories); - includedFiles = includedFiles.Concat(generatedFiles); + includedFiles.AddRange(Directory.EnumerateFiles(genVerilogPath, "*.v", SearchOption.AllDirectories)); } var yosysSynthTool = properties.GetValueOrDefault("yosysToolchainYosysSynthTool") ?? throw new Exception("Yosys Tool not set!"); - - var yosysCommand = properties.GetValueOrDefault("yosysToolchainCommand") ?? ""; - var yosysQuiet = Boolean.Parse(properties.GetValueOrDefault("yosysQuietFlag") ?? "true"); - List yosysArguments = []; - - if (yosysQuiet) - yosysArguments.Add("-q"); - - if (string.IsNullOrWhiteSpace(yosysCommand)) - { - yosysArguments.AddRange(["-p", $"{yosysSynthTool} -json build/synth.json"]); - } - else - { - yosysCommand = yosysCommand.Replace("$TOP", Path.GetFileNameWithoutExtension(top)); - yosysCommand = yosysCommand.Replace("$SYNTH_TOOL", yosysSynthTool); - yosysCommand = yosysCommand.Replace("$OUTPUT", "build/synth.json"); - - yosysArguments.AddRange(["-p", yosysCommand]); - } - - yosysArguments.AddRange(properties.GetValueOrDefault("yosysToolchainYosysFlags")?.Split(' ', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []); - - yosysArguments.AddRange(includedFiles); - - yosysArguments.AddRange(mandatoryFiles ?? []); - - var command = ToolCommand.FromShellParams("yosys", yosysArguments, project.FullPath, - "Running yosys...", AppState.Loading, true, x => + + var builder = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys") + .WithWorkingDirectory(project.FullPath) + .WithStatus("Running yosys...") + .WithTimer(true) + .WithOutputHandler(x => { if (x.StartsWith("Error:")) { @@ -126,8 +97,40 @@ public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel f return true; }); - var (success, _) = await toolExecutionDispatcherService.ExecuteAsync(command); + bool.TryParse(properties.GetValueOrDefault("yosysQuietFlag") ?? "true", out var quiet); + builder.AddIf(quiet, "-q"); + + var customCommandTemplate = properties.GetValueOrDefault("yosysToolchainCommand"); + builder.Add("-p"); + + if (string.IsNullOrWhiteSpace(customCommandTemplate)) + { + builder.AddScript("{synthTool} -json {output}", + ("{synthTool}", yosysSynthTool, false), + ("{output}", "build/synth.json", true) + ); + } + else + { + builder.AddScript(customCommandTemplate, + ("$TOP", Path.GetFileNameWithoutExtension(top), false), + ("$SYNTH_TOOL", yosysSynthTool, false), + ("$OUTPUT", "build/synth.json", true) + ); + } + + builder.AddRawArguments(properties.GetValueOrDefault("yosysToolchainYosysFlags")); + builder.AddPaths(includedFiles); + + if (mandatoryFiles != null) + { + builder.AddPaths(mandatoryFiles); + } + + var command = builder.Build(); + + var (success, _) = await toolExecutionDispatcherService.ExecuteAsync(command); return success; } catch (Exception e) @@ -148,82 +151,76 @@ private async Task RunNextpnrAsync(UniversalFpgaProjectRoot project, FpgaM try { var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name); - var nextPnrTool = properties.GetValueOrDefault("yosysToolchainNextPnrTool") ?? throw new Exception("NextPnr Tool not set!"); - var pcfFile = YosysSettingHelper.GetConstraintFile(project); - var cFileType = properties - .GetValueOrDefault("yosysToolchainConstraintFileType", "pcf"); + var builder = toolExecutionDispatcherService.CreateToolCommandBuilder(nextPnrTool) + .WithWorkingDirectory(project.FullPath) + .WithStatus($"Running {nextPnrTool}...") + .WithTimer(true) + .WithErrorHandler(s => + { + Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); + return true; + }); + + builder.AddPathOption("--json", "./build/synth.json"); - var nextPnrArguments = new List - { - "--json", "./build/synth.json", - }; + var pcfFile = YosysSettingHelper.GetConstraintFile(project); + var cFileType = properties.GetValueOrDefault("yosysToolchainConstraintFileType", "pcf"); switch (cFileType) { case "pcf": - nextPnrArguments.Add("--pcf"); - nextPnrArguments.Add(pcfFile); + builder.Add("--pcf").AddPath(pcfFile); break; case "ccf": var absolutePcfPath = Path.Combine(project.RootFolderPath, pcfFile); outputService.WriteLine($"Converting {absolutePcfPath} to CCF File"); - if (Path.Exists(absolutePcfPath)) - ConstraintFileHelper.Convert(absolutePcfPath, Path.Combine(project.RootFolderPath, - "./build/constrains.ccf")); + + if (File.Exists(absolutePcfPath)) + { + ConstraintFileHelper.Convert(absolutePcfPath, + Path.Combine(project.RootFolderPath, "./build/constrains.ccf")); + } else { outputService.WriteLine($"Could not generate CCF file from {pcfFile}"); return false; } - nextPnrArguments.Add("-o"); - nextPnrArguments.Add($"ccf=./build/constrains.ccf"); + builder.Add("-o"); + builder.AddScript("ccf={path}", ("{path}", "./build/constrains.ccf")); break; default: outputService.WriteLine($"Could not find Constraint file type: {cFileType}"); return false; } - var cOutputType = properties - .GetValueOrDefault("yosysToolchainOutputType", "asc"); - + var cOutputType = properties.GetValueOrDefault("yosysToolchainOutputType", "asc"); switch (cOutputType) { case "asc": - nextPnrArguments.Add("--asc"); - nextPnrArguments.Add("./build/nextpnr.asc"); + builder.Add("--asc").AddPath("./build/nextpnr.asc"); break; case "txt": - nextPnrArguments.Add("-o"); - nextPnrArguments.Add("out=./build/impl.txt"); + builder.Add("-o"); + builder.AddScript("out={path}", ("{path}", "./build/impl.txt")); break; default: outputService.WriteLine($"Could not find output type: {cOutputType}"); return false; } + if (withGui) builder.Add("--gui"); - if (withGui) - nextPnrArguments.Add("--gui"); + var extraFlags = properties.GetValueOrDefault("yosysToolchainNextPnrFlags")? + .Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []; - nextPnrArguments.AddRange(properties - .GetValueOrDefault("yosysToolchainNextPnrFlags")? - .Split(' ', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - ?? Array.Empty()); - - var command = ToolCommand.FromShellParams(nextPnrTool, nextPnrArguments, - project.FullPath, $"Running {nextPnrTool}...", AppState.Loading, true, null, s => - { - Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); - return true; - }); + foreach (var flag in extraFlags) builder.Add(flag); + var command = builder.Build(); var status = await toolExecutionDispatcherService.ExecuteAsync(command); - return status.success; } catch (Exception e) @@ -238,62 +235,54 @@ public async Task AssembleAsync(UniversalFpgaProjectRoot project, FpgaMode try { var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name); + var packTool = properties.GetValueOrDefault("yosysToolchainPackTool") + ?? throw new Exception("Pack Tool not set!"); + + var builder = toolExecutionDispatcherService.CreateToolCommandBuilder(packTool) + .WithWorkingDirectory(project.FullPath) + .WithStatus($"Running {packTool}...") + .WithTimer(true) + .WithErrorHandler(s => + { + Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); + return true; + }); - var packTool = properties.GetValueOrDefault("yosysToolchainPackTool") ?? - throw new Exception("Pack Tool not set!"); - - List packToolArguments = []; - - var cOutputType = properties - .GetValueOrDefault("yosysToolchainOutputType", "asc"); + var cOutputType = properties.GetValueOrDefault("yosysToolchainOutputType", "asc"); + string inputPath = cOutputType switch + { + "asc" => "./build/nextpnr.asc", + "txt" => "./build/impl.txt", + _ => throw new ArgumentException($"Unsupported input type: {cOutputType}") + }; + builder.AddPath(inputPath); - switch (cOutputType) + var pOutputFormat = properties.GetValueOrDefault("packToolOutputFormat", "bin"); + string outputPath = pOutputFormat switch { - case "asc": - packToolArguments.Add("./build/nextpnr.asc"); - break; - case "txt": - packToolArguments.Add("./build/impl.txt"); - break; - default: - outputService.WriteLine($"Could not find input type: {cOutputType}"); - return false; - } + "bin" => "./build/pack.bin", + "bit" => "./build/pack.bit", + _ => throw new ArgumentException($"Unsupported output format: {pOutputFormat}") + }; + builder.AddPath(outputPath); - var pOutputFormat = properties - .GetValueOrDefault("packToolOutputFormat", "bin"); + var flags = properties.GetValueOrDefault("yosysToolchainPackFlags")?.Split(' ', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []; - switch (pOutputFormat) + foreach (var flag in flags) { - case "bin": - packToolArguments.Add("./build/pack.bin"); - break; - case "bit": - packToolArguments.Add("./build/pack.bit"); - break; - default: - outputService.WriteLine($"Could not find output type: {pOutputFormat}"); - return false; + builder.Add(flag); } - - packToolArguments.AddRange(properties.GetValueOrDefault("yosysToolchainPackFlags")?.Split(' ', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []); - - var command = ToolCommand.FromShellParams(packTool, packToolArguments, - project.FullPath, $"Running {packTool}...", AppState.Loading, true, null, s => - { - Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); - return true; - }); + var command = builder.Build(); var status = await toolExecutionDispatcherService.ExecuteAsync(command); - return status.success; } catch (Exception e) { logger.Error(e.Message, e); + outputService.WriteLine($"Error: {e.Message}"); return false; } } @@ -301,11 +290,14 @@ public async Task AssembleAsync(UniversalFpgaProjectRoot project, FpgaMode [Obsolete(message: "Use CreateJsonNetListAsync instead")] public async Task CreateNetListJsonAsync(IProjectFile verilog) { - var command = ToolCommand.FromShellParams("yosys", [ - "-p", "hierarchy -auto-top; proc; opt; memory -nomap; wreduce -memx; opt_clean", "-o", - $"{verilog.Header}.json", verilog.Header - ], - Path.GetDirectoryName(verilog.FullPath)!, $"Create Netlist..."); + var command = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys") + .WithWorkingDirectory(Path.GetDirectoryName(verilog.FullPath)!) + .WithStatus("Create Netlist...") + .Add("-p", "hierarchy -auto-top; proc; opt; memory -nomap; wreduce -memx; opt_clean") + .Add("-o") + .AddPath($"{verilog.Header}.json") + .AddPath(verilog.Header) + .Build(); await toolExecutionDispatcherService.ExecuteAsync(command); } @@ -315,9 +307,13 @@ public async Task> ExtractNodesAsync(IProjectFile file) var buildpath = Path.Combine(file.Root.FullPath, "build"); Directory.CreateDirectory(buildpath); - var command = ToolCommand.FromShellParams("yosys", - ["-p", $"read_verilog {file.RelativePath}; proc; write_json build/yosys_nodes.json"], - file.Root.FullPath, $"Running Yosys...", AppState.Loading, true); + var command = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys") + .WithWorkingDirectory(file.Root.FullPath) + .WithStatus("Running Yosys...") + .WithTimer(true) + .Add("-p", $"read_verilog {file.RelativePath}; proc; write_json build/yosys_nodes.json") + .Build(); + await toolExecutionDispatcherService.ExecuteAsync(command); return ReadJson(Path.Combine(buildpath, "yosys_nodes.json")); } diff --git a/src/OneWare.ToolEngine/Arguments/CommandArgument.cs b/src/OneWare.ToolEngine/Arguments/CommandArgument.cs new file mode 100644 index 000000000..8991ef6a8 --- /dev/null +++ b/src/OneWare.ToolEngine/Arguments/CommandArgument.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +namespace OneWare.Essentials.ToolEngine; + +public class CommandArgument(string argument) : ICommandArgument +{ + public void Prepare(OSPlatform osPlatform, Func? pathMapper = null) {} + public string GetArgument() => argument; +} \ No newline at end of file diff --git a/src/OneWare.ToolEngine/Arguments/PathArgument.cs b/src/OneWare.ToolEngine/Arguments/PathArgument.cs new file mode 100644 index 000000000..4bbd2adb3 --- /dev/null +++ b/src/OneWare.ToolEngine/Arguments/PathArgument.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; + +namespace OneWare.Essentials.ToolEngine; + +public class PathArgument(string path) : ICommandArgument +{ + private string _path = path; + + public void Prepare(OSPlatform osPlatform, Func? pathMapper = null) + { + if (pathMapper != null) _path = pathMapper(_path); + + if (osPlatform == OSPlatform.Windows) + _path = _path.Replace("/", "\\"); + else + _path = _path.Replace("\\", "/"); + } + + public string GetArgument() => _path; +} \ No newline at end of file diff --git a/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs b/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs new file mode 100644 index 000000000..20aa83f1c --- /dev/null +++ b/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs @@ -0,0 +1,47 @@ +using System.Runtime.InteropServices; + +namespace OneWare.Essentials.ToolEngine; + +public class TemplateArgument : ICommandArgument +{ + private readonly string _template; + private readonly List<(string placeholder, string value, bool isPath)> _mappings = new(); + private readonly Dictionary _resolvedValues = new(); + + public TemplateArgument(string template, params (string placeholder, string value, bool isPath)[] mappings) + { + _template = template; + foreach (var m in mappings) + { + _mappings.Add(m); + _resolvedValues[m.placeholder] = m.value; + } + } + + public void Prepare(OSPlatform osPlatform, Func? pathMapper = null) + { + foreach (var (placeholder, value, isPath) in _mappings) + { + if (isPath) + { + var p = pathMapper != null ? pathMapper(value) : value; + p = osPlatform == OSPlatform.Windows ? p.Replace("/", "\\") : p.Replace("\\", "/"); + _resolvedValues[placeholder] = p; + } + else + { + _resolvedValues[placeholder] = value; + } + } + } + + public string GetArgument() + { + string result = _template; + foreach (var kvp in _resolvedValues) + { + result = result.Replace(kvp.Key, kvp.Value); + } + return result; + } +} \ No newline at end of file diff --git a/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj b/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj index c786bc734..5b9602aa8 100644 --- a/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj +++ b/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj @@ -8,6 +8,9 @@ + + <_Parameter1>OneWare.ToolEngine.UnitTests + diff --git a/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs b/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs new file mode 100644 index 000000000..3b26ced48 --- /dev/null +++ b/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs @@ -0,0 +1,225 @@ +using System.Text.RegularExpressions; +using OneWare.Essentials.Enums; +using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; + +namespace OneWare.ToolEngine.Services; + +public class ToolCommandBuilder : IToolCommandBuilder +{ + private readonly List _args = new(); + private readonly Dictionary _envVars = new(); + private readonly List _exposedPorts = new(); + private readonly List _portMappings = new(); + private readonly string _toolName; + private Func? _errorHandler; + private string? _executable; + private Func? _outputHandler; + private bool _showTimer; + private AppState _state = AppState.Loading; + private string _status = "Running..."; + private string _workingDir = "."; + + internal ToolCommandBuilder(string toolName) + { + _toolName = toolName; + } + + public IToolCommandBuilder WithExecutable(string path) + { + _executable = path; + return this; + } + + public IToolCommandBuilder AddRange(IEnumerable literals) + { + foreach (var lit in literals) Add(lit); + return this; + } + + public IToolCommandBuilder AddPaths(IEnumerable paths) + { + foreach (var path in paths) AddPath(path); + return this; + } + + public IToolCommandBuilder AddIf(bool condition, string literal) + { + if (condition) Add(literal); + return this; + } + + public IToolCommandBuilder AddIfNotNull(string? literal) + { + if (!string.IsNullOrWhiteSpace(literal)) Add(literal); + return this; + } + + public IToolCommandBuilder AddOption(string flag, string value) + { + Add(flag); + return Add(value); + } + + public IToolCommandBuilder AddPathOption(string flag, string path) + { + Add(flag); + return AddPath(path); + } + + public IToolCommandBuilder Add(string literal) + { + _args.Add(new CommandArgument(literal)); + return this; + } + + public IToolCommandBuilder Add(params string[] literals) + { + foreach (var lit in literals) Add(lit); + return this; + } + + public IToolCommandBuilder AddPath(string path) + { + _args.Add(new PathArgument(path)); + return this; + } + + public IToolCommandBuilder AddScript(string template, params (string placeholder, string value)[] literals) + { + var mappings = literals.Select(x => (x.placeholder, x.value, isPath: false)).ToArray(); + _args.Add(new TemplateArgument(template, mappings)); + return this; + } + + public IToolCommandBuilder AddScript(string template, + params (string placeholder, string value, bool isPath)[] mappings) + { + _args.Add(new TemplateArgument(template, mappings)); + return this; + } + + public IToolCommandBuilder WithWorkingDirectory(string dir) + { + _workingDir = dir; + return this; + } + + public IToolCommandBuilder WithStatus(string status, AppState state = AppState.Loading) + { + _status = status; + _state = state; + return this; + } + + public IToolCommandBuilder WithTimer(bool show) + { + _showTimer = show; + return this; + } + + public IToolCommandBuilder WithOutputHandler(Func? handler) + { + _outputHandler = handler; + return this; + } + + public IToolCommandBuilder WithErrorHandler(Func? handler) + { + _errorHandler = handler; + return this; + } + + public IToolCommandBuilder AddRawArguments(string? rawArgs) + { + if (string.IsNullOrWhiteSpace(rawArgs)) return this; + + var parts = Regex + .Matches(rawArgs, @"[\""].+?[\""]|[^ ]+") + .Select(m => m.Value.Trim('"')); + + return AddRange(parts); + } + + public IToolCommandBuilder AddOptionIfNotNull(string flag, string? value) + { + if (string.IsNullOrWhiteSpace(value)) return this; + + Add(flag); + Add(value); + + return this; + } + + public IToolCommandBuilder AddPathOptionIfNotNull(string flag, string? path) + { + if (string.IsNullOrWhiteSpace(path)) return this; + + Add(flag); + AddPath(path); + + return this; + } + + public IToolCommandBuilder AddPathFromMap(TKey key, IDictionary map) where TKey : notnull + { + if (map.TryGetValue(key, out var path)) AddPath(path); + return this; + } + + public IToolCommandBuilder WithEnvironmentVariable(string key, string value) + { + if (!string.IsNullOrWhiteSpace(key)) _envVars[key] = value; + return this; + } + + public IToolCommandBuilder WithEnvironmentVariables(IDictionary variables) + { + foreach (var kvp in variables) WithEnvironmentVariable(kvp.Key, kvp.Value); + return this; + } + + public IToolCommandBuilder WithEnvironmentVariableIf(bool condition, string key, string value) + { + return condition ? WithEnvironmentVariable(key, value) : this; + } + + public IToolCommandBuilder WithExposedPort(int port, string protocol = "TCP") + { + if (!_exposedPorts.Any(p => p.Number == port && p.Protocol == protocol)) + _exposedPorts.Add(new ToolPort(port, protocol.ToUpper())); + return this; + } + + public IToolCommandBuilder AddPortMapping(int hostPort, int guestPort, string protocol = "TCP") + { + _portMappings.Add(new ToolPortMapping( + new ToolPort(hostPort, protocol.ToUpper()), + new ToolPort(guestPort, protocol.ToUpper()))); + WithExposedPort(guestPort, protocol); + + return this; + } + + public ToolCommand Build() + { + if (string.IsNullOrWhiteSpace(_toolName) && string.IsNullOrWhiteSpace(_executable)) + throw new InvalidOperationException("Tool name or executable must be set."); + + return new ToolCommand + { + ToolName = _toolName, + Executable = _executable ?? _toolName, + CommandArguments = _args.AsReadOnly(), + WorkingDirectory = _workingDir, + StatusMessage = _status, + State = _state, + ShowTimer = _showTimer, + OutputHandler = _outputHandler, + ErrorHandler = _errorHandler, + EnvironmentVariables = new Dictionary(_envVars), + PortMappings = _portMappings, + ExposedPorts = _exposedPorts + }; + } +} \ No newline at end of file diff --git a/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs b/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs index 7fbbdc3b9..95d1ed2f7 100644 --- a/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs +++ b/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs @@ -1,12 +1,45 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; using OneWare.Essentials.Services; using OneWare.Essentials.ToolEngine; namespace OneWare.ToolEngine.Services; -public class ToolExecutionDispatcherService(IToolService service) : IToolExecutionDispatcherService +public class ToolExecutionDispatcherService(IToolService service, ILogger logger) : IToolExecutionDispatcherService { public Task<(bool success, string output)> ExecuteAsync(ToolCommand command) { - return service.GetStrategy(command.ToolName).ExecuteAsync(command); + try + { + return service.GetStrategy(command.ToolName).ExecuteAsync(command); + } + catch (InvalidOperationException exception) + { + logger.LogError(exception, exception.Message); + } + + return Task.FromResult<(bool success, string output)>((false, "")); + + } + + public WeakReference StartWeakProcess(ToolCommand command) + { + try + { + return service.GetStrategy(command.ToolName).StartWeakProcess(command); + } + catch (InvalidOperationException exception) + { + logger.LogError(exception, exception.Message); + } + + return null!; + } + + public IToolCommandBuilder CreateToolCommandBuilder(string toolName) + { + // This is just in case you want to access a different service in the Builder in the future. + // Such as the outputService or similar. + return new ToolCommandBuilder(toolName); } } \ No newline at end of file diff --git a/src/OneWare.ToolEngine/Services/ToolService.cs b/src/OneWare.ToolEngine/Services/ToolService.cs index d5ae394f6..9d4b327ea 100644 --- a/src/OneWare.ToolEngine/Services/ToolService.cs +++ b/src/OneWare.ToolEngine/Services/ToolService.cs @@ -3,6 +3,7 @@ using OneWare.Essentials.Models; using OneWare.Essentials.Services; using OneWare.Essentials.ToolEngine; +using OneWare.Essentials.ToolEngine.Strategies; namespace OneWare.ToolEngine.Services; @@ -93,13 +94,24 @@ public IReadOnlyList GetStrategies(string toolKey) public IToolExecutionStrategy GetStrategy(string toolKey) { + if (!_settingsService.HasSetting(toolKey)) + { + throw new InvalidOperationException( + $"No Setting for key '{toolKey}' was found. Register Tool first bevor you are using it"); + } + var strategyKey = _settingsService.GetSettingValue(toolKey); if (_toolStrategies.TryGetValue(toolKey, out var strategies) && strategies.TryGetValue(strategyKey, out var strategy)) return strategy; - - throw new InvalidOperationException( - $"No execution strategy found for tool '{toolKey}' and strategy '{strategyKey}'"); + + _logger.LogError($"No execution strategy found for tool '{toolKey}' and strategy '{strategyKey}'"); + _logger.LogError("Using default strategy"); + + if (strategies != null && strategies.TryGetValue(NativeStrategy.ToolKey, out var defaultStrategy)) + return defaultStrategy; + + throw new InvalidOperationException($"No strategy with key '{toolKey}' was found."); } private void RegisterToolInSettings(ToolContext description) diff --git a/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj b/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj index 22a1d9145..dcd8f3adc 100644 --- a/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj +++ b/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj b/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj new file mode 100644 index 000000000..a7c216cd9 --- /dev/null +++ b/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + net10.0 + Library + False + True + False + True + enable + + + + + + + + + + + diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs new file mode 100644 index 000000000..80677785b --- /dev/null +++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs @@ -0,0 +1,58 @@ +using System.Runtime.InteropServices; +using OneWare.Essentials.ToolEngine; +using Xunit; + +namespace OneWare.ToolEngine.UnitTests.ToolEngine; + +public class PathArgumentTests +{ + [Theory] + [InlineData("folder/subfolder/file.txt", @"folder\subfolder\file.txt")] + [InlineData("C:/Data/Test", @"C:\Data\Test")] + public void Prepare_ShouldConvertToWindowsBackslashes(string input, string expected) + { + var arg = new PathArgument(input); + + arg.Prepare(OSPlatform.Windows); + var result = arg.GetArgument(); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(@"folder\subfolder\file.txt", "folder/subfolder/file.txt")] + [InlineData("/home/user/test", "/home/user/test")] + public void Prepare_ShouldConvertToLinuxForwardSlashes(string input, string expected) + { + var arg = new PathArgument(input); + + arg.Prepare(OSPlatform.Linux); + var result = arg.GetArgument(); + + Assert.Equal(expected, result); + } + + [Fact] + public void Prepare_WithMapper_ShouldApplyTransformationBeforeNormalization() + { + const string initialPath = "relative/path"; + var arg = new PathArgument(initialPath); + + arg.Prepare(OSPlatform.Windows, Mapper); + Assert.Equal(@"\abs\root\relative\path", arg.GetArgument()); + return; + + string Mapper(string p) => $"/abs/root/{p}"; + } + + [Fact] + public void GetArgument_WithoutPrepare_ShouldReturnInitialPath() + { + const string initialPath = "some/path/here"; + var arg = new PathArgument(initialPath); + + var result = arg.GetArgument(); + + Assert.Equal(initialPath, result); + } +} \ No newline at end of file diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs new file mode 100644 index 000000000..c681cf99b --- /dev/null +++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs @@ -0,0 +1,69 @@ +using System; + +namespace OneWare.ToolEngine.UnitTests.ToolEngine; + +using System.Runtime.InteropServices; +using OneWare.Essentials.ToolEngine; +using Xunit; + + +public class TemplateArgumentTests +{ + [Fact] + public void GetArgument_ShouldReplacePlaceholders() + { + const string template = "gcc -o OUTPUT INPUT"; + var mappings = new[] + { + ("OUTPUT", "main.exe", false), + ("INPUT", "main.c", false) + }; + var arg = new TemplateArgument(template, mappings); + arg.Prepare(OSPlatform.Windows); + + var result = arg.GetArgument(); + + Assert.Equal("gcc -o main.exe main.c", result); + } + + [Theory] + [InlineData("C:/Users/Test", @"C:\Users\Test")] + public void Prepare_ShouldAdjustPathsForWindows(string inputPath, string expectedPath) + { + const string template = "path: PATH"; + var arg = new TemplateArgument(template, ("PATH", inputPath, true)); + + arg.Prepare(OSPlatform.Windows); + var result = arg.GetArgument(); + + Assert.Contains(expectedPath, result); + } + + [Theory] + [InlineData(@"C:\Users\Test", "C:/Users/Test")] + public void Prepare_ShouldAdjustPathsForLinux(string inputPath, string expectedPath) + { + const string template = "path: PATH"; + var arg = new TemplateArgument(template, ("PATH", inputPath, true)); + + arg.Prepare(OSPlatform.Linux); + var result = arg.GetArgument(); + + Assert.Contains(expectedPath, result); + } + + [Fact] + public void Prepare_WithPathMapper_ShouldTransformValue() + { + const string template = "file: FILE"; + var arg = new TemplateArgument(template, ("FILE", "relative/path", true)); + + arg.Prepare(OSPlatform.Linux, Mapper); + var result = arg.GetArgument(); + + Assert.Equal("file: /base/relative/path", result); + return; + + string Mapper(string p) => $"/base/{p}"; + } +} \ No newline at end of file diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs new file mode 100644 index 000000000..0397b5eeb --- /dev/null +++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OneWare.Essentials.Enums; +using OneWare.Essentials.ToolEngine; +using OneWare.ToolEngine.Services; +using Xunit; + +namespace OneWare.ToolEngine.UnitTests.ToolEngine; + +public class ToolCommandBuilderTests +{ + private const string DefaultTool = "gcc"; + + [Fact] + public void Build_MinimumSetup_ReturnsCorrectCommand() + { + var command = new ToolCommandBuilder(DefaultTool).Build(); + + Assert.Equal(DefaultTool, command.ToolName); + Assert.Equal(DefaultTool, command.Executable); + Assert.Empty(command.CommandArguments); + Assert.Equal(".", command.WorkingDirectory); + } + + [Fact] + public void Build_WithoutNameOrExecutable_ThrowsException() + { + var builder = new ToolCommandBuilder(""); + + Assert.Throws(builder.Build); + } + + [Fact] + public void WithMethods_SetCorrectProperties() + { + var outputHandler = (string s) => true; + var errorHandler = (string s) => false; + + var command = new ToolCommandBuilder(DefaultTool) + .WithExecutable("/usr/bin/gcc") + .WithWorkingDirectory("/temp") + .WithStatus("Compiling...", AppState.Idle) + .WithTimer(true) + .WithOutputHandler(outputHandler) + .WithErrorHandler(errorHandler) + .Build(); + + Assert.Equal("/usr/bin/gcc", command.Executable); + Assert.Equal("/temp", command.WorkingDirectory); + Assert.Equal("Compiling...", command.StatusMessage); + Assert.Equal(AppState.Idle, command.State); + Assert.True(command.ShowTimer); + Assert.Equal(outputHandler, command.OutputHandler); + Assert.Equal(errorHandler, command.ErrorHandler); + } + + [Fact] + public void AddMethods_StoreCorrectArgumentTypes() + { + var command = new ToolCommandBuilder(DefaultTool) + .Add("-v") + .AddPath("/src/main.c") + .AddOption("--level", "3") + .AddPathOption("-o", "/bin/out") + .Build(); + + var args = command.CommandArguments.ToList(); + Assert.Equal(6, args.Count); + Assert.IsType(args[0]); + Assert.IsType(args[1]); + Assert.IsType(args[2]); + Assert.IsType(args[3]); + Assert.IsType(args[4]); + Assert.IsType(args[5]); + } + + [Fact] + public void ConditionalAdds_WorkCorrectly() + { + var command = new ToolCommandBuilder(DefaultTool) + .AddIf(true, "--enabled") + .AddIf(false, "--disabled") + .AddIfNotNull("valid") + .AddIfNotNull(null) + .AddIfNotNull(" ") + .Build(); + + Assert.Equal(2, command.Arguments.Count); + Assert.Contains("--enabled", command.Arguments); + Assert.Contains("valid", command.Arguments); + Assert.DoesNotContain("--disabled", command.Arguments); + } + + [Fact] + public void AddRawArguments_ParsesCorrectWithQuotes() + { + const string raw = "--opt1 \"C:\\Path With Spaces\\file.txt\" --opt2 simpleValue"; + + var command = new ToolCommandBuilder(DefaultTool) + .AddRawArguments(raw) + .Build(); + + var args = command.Arguments.ToList(); + Assert.Equal(4, args.Count); + Assert.Equal("--opt1", args[0]); + Assert.Equal(@"C:\Path With Spaces\file.txt", args[1]); + Assert.Equal("--opt2", args[2]); + Assert.Equal("simpleValue", args[3]); + } + + [Fact] + public void AddScript_CreatesTemplateArgument() + { + var command = new ToolCommandBuilder(DefaultTool) + .AddScript("echo PLACEHOLDER", ("PLACEHOLDER", "Hello")) + .Build(); + + var arg = Assert.Single(command.CommandArguments); + Assert.IsType(arg); + Assert.Equal("echo Hello", command.Arguments.First()); + } + + [Fact] + public void AddRangeAndAddPaths_WorkForEnumerables() + { + + var lits = new List { "a", "b" }; + var paths = new List { "/p1", "/p2" }; + + var command = new ToolCommandBuilder(DefaultTool) + .AddRange(lits) + .AddPaths(paths) + .Build(); + + Assert.Equal(4, command.Arguments.Count); + Assert.IsType(command.CommandArguments.First()); + Assert.IsType(command.CommandArguments.Last()); + } +} \ No newline at end of file diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs new file mode 100644 index 000000000..29f8618d9 --- /dev/null +++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using OneWare.Essentials.ToolEngine; +using OneWare.ToolEngine.Services; +using Xunit; + +namespace OneWare.ToolEngine.UnitTests.ToolEngine; + +public class ToolCommandTests +{ + [Fact] + public void PrepareCommand_ShouldInvokePrepareOnAllArguments() + { + var pathArg = new PathArgument("folder/file.txt"); + var tempArg = new TemplateArgument("echo T", ("T", "val", false)); + + var command = new ToolCommand + { + ToolName = "Test", + CommandArguments = new List { pathArg, tempArg } + }; + + command.PrepareCommand(OSPlatform.Windows); + + var resultArgs = command.Arguments.ToList(); + Assert.Equal("folder\\file.txt", resultArgs[0]); + Assert.Equal("echo val", resultArgs[1]); + } + + [Fact] + public void ArgumentsProperty_ShouldReflectCurrentStateOfCommandArguments() + { + var arg = new PathArgument("a/b"); + var command = new ToolCommand + { + ToolName = "Test", + CommandArguments = new List { arg } + }; + + Assert.Equal("a/b", command.Arguments.First()); + + command.PrepareCommand(OSPlatform.Windows); + + Assert.Equal("a\\b", command.Arguments.First()); + } + + /// + /// Verifies that local host paths are correctly mapped to container/remote paths. + /// + [Fact] + public void PrepareCommand_ChangePaths() + { + var command = new ToolCommandBuilder("test") + .Add("-k") + .AddIf(false, "notPresent") + .AddIf(true, "Present") + .AddPath(@"C:\Users\User\OneWareStudio\Projects\Example\Example.v") + .AddPath("folder/file.txt") + .WithWorkingDirectory("/home/user") + .AddPathOption("-o", "folder2/file.txt") + .AddPaths(["folder/file2.txt", "folder2/file3.txt"]) + .Build(); + + + const string localProjectRoot = @"C:\Users\User\OneWareStudio\Projects\Example"; + const string remoteWorkDir = "/home/user/Example"; + + + command.PrepareCommand(OSPlatform.Linux, ContainerMapper); + + var resultArgs = command.Arguments.ToList(); + Assert.Equal("-k", resultArgs[0]); + Assert.Equal("Present", resultArgs[1]); + Assert.Equal("/home/user/Example/Example.v", resultArgs[2]); + Assert.Equal("/home/user/Example/folder/file.txt", resultArgs[3]); + Assert.Equal("-o", resultArgs[4]); + Assert.Equal("/home/user/Example/folder2/file.txt", resultArgs[5]); + Assert.Equal("/home/user/Example/folder/file2.txt", resultArgs[6]); + Assert.Equal("/home/user/Example/folder2/file3.txt", resultArgs[7]); + return; + + + string ContainerMapper(string inputPath) + { + if (string.IsNullOrEmpty(inputPath)) return inputPath; + + var normalizedInput = inputPath.Replace('\\', '/'); + var normalizedRoot = localProjectRoot.Replace('\\', '/'); + + string fullPath; + + if (normalizedInput.StartsWith('/') || (normalizedInput.Length > 1 && normalizedInput[1] == ':')) + { + fullPath = normalizedInput; + } + else + { + fullPath = $"{normalizedRoot.TrimEnd('/')}/{normalizedInput.TrimStart('/')}"; + } + + if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + return fullPath; + + var relativePart = fullPath[normalizedRoot.Length..].TrimStart('/'); + + return string.IsNullOrEmpty(relativePart) + ? remoteWorkDir + : $"{remoteWorkDir}/{relativePart}"; + + } + } +} \ No newline at end of file