From 3afc4d88e7de96bad523fce45ffeea0344fa3a41 Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:11:21 -0300 Subject: [PATCH 1/4] Add state variables and fix var threading --- Silkstring.sln | 6 ++ .../Services/Conditions/ConditionEvaluator.cs | 44 +++++++++++++ .../Services/Conditions/ConditionException.cs | 8 +++ .../Services/Conditions/ConditionNode.cs | 6 ++ Silkstring/Services/Conditions/Parser.cs | 64 +++++++++++++++++++ Silkstring/Services/Conditions/Tokenizer.cs | 45 +++++++++++++ Tests/ConditionEvaluatorTests.cs | 52 +++++++++++++++ Tests/Tests.csproj | 25 ++++++++ 8 files changed, 250 insertions(+) create mode 100644 Silkstring/Services/Conditions/ConditionEvaluator.cs create mode 100644 Silkstring/Services/Conditions/ConditionException.cs create mode 100644 Silkstring/Services/Conditions/ConditionNode.cs create mode 100644 Silkstring/Services/Conditions/Parser.cs create mode 100644 Silkstring/Services/Conditions/Tokenizer.cs create mode 100644 Tests/ConditionEvaluatorTests.cs create mode 100644 Tests/Tests.csproj diff --git a/Silkstring.sln b/Silkstring.sln index 9166170..abca2b9 100644 --- a/Silkstring.sln +++ b/Silkstring.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.29709.97 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silkstring", "Silkstring\Silkstring.csproj", "{13C812E9-0D42-4B95-8646-40EEBF30636F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -19,6 +21,10 @@ Global {4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Debug|x64.Build.0 = Debug|x64 {4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Release|x64.ActiveCfg = Release|x64 {4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Release|x64.Build.0 = Release|x64 + {BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Debug|x64.Build.0 = Debug|Any CPU + {BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Release|x64.ActiveCfg = Release|Any CPU + {BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Silkstring/Services/Conditions/ConditionEvaluator.cs b/Silkstring/Services/Conditions/ConditionEvaluator.cs new file mode 100644 index 0000000..46c6988 --- /dev/null +++ b/Silkstring/Services/Conditions/ConditionEvaluator.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +namespace Silkstring.Services.Conditions; + +public sealed class ConditionEvaluator +{ + private readonly Func, string> _resolve; + + public ConditionEvaluator(Func, string> resolve) => _resolve = resolve; + + public bool Evaluate(string expression, IReadOnlyList args) + { + var ast = new Parser(Tokenizer.Tokenize(expression)).Parse(); + return Eval(ast, args); + } + + private bool Eval(ConditionNode node, IReadOnlyList args) => node switch + { + OrNode o => Eval(o.L, args) || Eval(o.R, args), + AndNode a => Eval(a.L, args) && Eval(a.R, args), + CmpNode c => Compare(_resolve(c.L, args), c.Op, _resolve(c.R, args)), + BareNode b => string.Equals(_resolve(b.Value, args), "true", StringComparison.OrdinalIgnoreCase), + _ => false + }; + + private static bool Compare(string left, string op, string right) + { + var numeric = double.TryParse(left, NumberStyles.Any, CultureInfo.InvariantCulture, out var ln) + & double.TryParse(right, NumberStyles.Any, CultureInfo.InvariantCulture, out var rn); + return op switch + { + "==" => numeric ? ln == rn : string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "!=" => numeric ? ln != rn : !string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "<" => numeric ? ln < rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) < 0, + ">" => numeric ? ln > rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) > 0, + "<=" => numeric ? ln <= rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) <= 0, + ">=" => numeric ? ln >= rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) >= 0, + _ => false + + }; + } + +} diff --git a/Silkstring/Services/Conditions/ConditionException.cs b/Silkstring/Services/Conditions/ConditionException.cs new file mode 100644 index 0000000..a0be9ce --- /dev/null +++ b/Silkstring/Services/Conditions/ConditionException.cs @@ -0,0 +1,8 @@ +using System; + +namespace Silkstring.Services.Conditions; + +public sealed class ConditionException : Exception +{ + public ConditionException(string message) : base(message) { } +} diff --git a/Silkstring/Services/Conditions/ConditionNode.cs b/Silkstring/Services/Conditions/ConditionNode.cs new file mode 100644 index 0000000..92f0f6e --- /dev/null +++ b/Silkstring/Services/Conditions/ConditionNode.cs @@ -0,0 +1,6 @@ +internal abstract record ConditionNode; + +internal sealed record OrNode(ConditionNode L, ConditionNode R) : ConditionNode; +internal sealed record AndNode(ConditionNode L, ConditionNode R) : ConditionNode; +internal sealed record CmpNode(string L, string Op, string R) : ConditionNode; +internal sealed record BareNode(string Value) : ConditionNode; diff --git a/Silkstring/Services/Conditions/Parser.cs b/Silkstring/Services/Conditions/Parser.cs new file mode 100644 index 0000000..93d57ff --- /dev/null +++ b/Silkstring/Services/Conditions/Parser.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace Silkstring.Services.Conditions; + +internal sealed class Parser +{ + private readonly List _tokens; + private int _pos; + + public Parser(List tokens) => _tokens = tokens; + + private Token? Peek => _pos < _tokens.Count ? _tokens[_pos] : null; + private void Next() => _pos++; + private bool IsOp(string s) => Peek is { Kind: TokenKind.Op } t && t.Text == s; + private static bool IsComparison(string s) => s is "==" or "!=" or "<" or ">" or "<=" or ">="; + + public ConditionNode Parse() + { + var n = ParseOr(); + if (Peek is { } leftover) throw new ConditionException($"Unexpected '{leftover.Text}'"); + return n; + } + + private ConditionNode ParseOr() + { + var n = ParseAnd(); + while (IsOp("||")) { Next(); n = new OrNode(n, ParseAnd()); } + return n; + } + + private ConditionNode ParseAnd() + { + var n = ParseCmp(); + while (IsOp("&&")) { Next(); n = new AndNode(n, ParseCmp()); } + return n; + } + + private ConditionNode ParseCmp() + { + if (Peek is { Kind: TokenKind.LParen }) + { + Next(); + var n = ParseOr(); + if (Peek is not { Kind: TokenKind.RParen }) throw new ConditionException("Expected ')'"); + Next(); + return n; + } + + var left = ExpectOperand(); + if (Peek is { Kind: TokenKind.Op } op && IsComparison(op.Text)) + { + Next(); + return new CmpNode(left, op.Text, ExpectOperand()); + } + return new BareNode(left); + } + + private string ExpectOperand() + { + if (Peek is not { Kind: TokenKind.Operand } t) throw new ConditionException("Expected a value"); + Next(); + return t.Text; + } +} diff --git a/Silkstring/Services/Conditions/Tokenizer.cs b/Silkstring/Services/Conditions/Tokenizer.cs new file mode 100644 index 0000000..2377b03 --- /dev/null +++ b/Silkstring/Services/Conditions/Tokenizer.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Silkstring.Services.Conditions; + +internal enum TokenKind { Operand, Op, LParen, RParen } +internal readonly record struct Token(TokenKind Kind, string Text); +internal static class Tokenizer +{ + public static List Tokenize(string expr) + { + var tokens = new List(); + var i = 0; + while (i < expr.Length) + { + var c = expr[i]; + if (char.IsWhiteSpace(c)) { i++; continue; } + if (c == '(') { tokens.Add(new(TokenKind.LParen, "(")); i++; continue; } + if (c == ')') { tokens.Add(new(TokenKind.RParen, ")")); i++; continue; } + + if (i + 1 < expr.Length) + { + var two = expr.Substring(i, 2); + if (two is "&&" or "||" or "==" or "!=" or "<=" or ">=") { tokens.Add(new(TokenKind.Op, two)); i += 2; continue; } + } + + if (c is '<' or '>') { tokens.Add(new(TokenKind.Op, c.ToString())); i++; continue; } + if (c is '&' or '|' or '=' or '!') throw new ConditionException($"Unexpected '{c}'"); + + if (c == '"') + { + var end = expr.IndexOf('"', i + 1); + if (end < 0) throw new ConditionException("Unterminated Quote"); + tokens.Add(new(TokenKind.Operand, expr[(i + 1)..end])); + i = end + 1; + continue; + } + + var start = i; + while (i < expr.Length && !char.IsWhiteSpace(expr[i]) && expr[i] is not ('(' or ')' or '"' or '&' or '|' or '=' or '!' or '<' or '>')) i++; + tokens.Add(new(TokenKind.Operand, expr[start..i])); + } + + return tokens; + } +} diff --git a/Tests/ConditionEvaluatorTests.cs b/Tests/ConditionEvaluatorTests.cs new file mode 100644 index 0000000..5af85e8 --- /dev/null +++ b/Tests/ConditionEvaluatorTests.cs @@ -0,0 +1,52 @@ +using Silkstring.Services.Conditions; + +public class ConditionEvaluatorTests +{ + private static ConditionEvaluator Make() + { + var vars = new Dictionary + { + ["{hpp}"] = "40", + ["{job}"] = "WHM", + ["{incombat}"] = "true", + ["{title}"] = "white mage", + }; + return new ConditionEvaluator((token, args) => + { + if (vars.TryGetValue(token, out var v)) return v; + if (token.Length > 2 && token[0] == '{' && token[^1] == '}' && int.TryParse(token[1..^1], out var i) && i < args.Count) return args[i]; + return token; + }); + } + + [Theory] + [InlineData("{hpp} < 50", true)] + [InlineData("{hpp} >= 50", false)] + [InlineData("{hpp}<50", true)] + [InlineData("{hpp} == 40", true)] + [InlineData("{hpp} != 50", true)] + [InlineData("{job} == whm", true)] + [InlineData("{job} != BLM", true)] + [InlineData("{job} == \"WHM\"", true)] + [InlineData("{title} == \"white mage\"", true)] + [InlineData("{incombat}", true)] + [InlineData("{hpp} >= 40 && {hpp} <= 90", true)] + [InlineData("{hpp} > 50 && {incombat}", false)] + [InlineData("{hpp} > 50 || {job} == WHM", true)] + [InlineData("{incombat} || {hpp} > 50 && {hpp} > 90", true)] + [InlineData("({hpp} < 50 || {hpp} > 90) && {incombat}", true)] + public void Evaluates(string expr, bool expected) => Assert.Equal(expected, Make().Evaluate(expr, Array.Empty())); + + [Theory] + [InlineData("on", true)] + [InlineData("off", false)] + public void ResolvesParameters(string arg, bool expected) => Assert.Equal(expected, Make().Evaluate("{0} == on", new[] { arg })); + + [Theory] + [InlineData("{hpp} <=")] + [InlineData("({hpp} < 50")] + [InlineData("")] + [InlineData("&&")] + [InlineData("{a} {b}")] + public void ThrowsOnMalformed(string expr) => Assert.Throws(() => Make().Evaluate(expr, Array.Empty())); +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj new file mode 100644 index 0000000..1f9f0e0 --- /dev/null +++ b/Tests/Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0-windows + enable + enable + false + + + + + + + + + + + + + + + + + + From caef9b3c91c84552025d9ded9aa4a672ffc24f2e Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:38:18 -0300 Subject: [PATCH 2/4] Add conditionals and block handling, additional test suite --- Silkstring/Services/CommandHandler.cs | 46 +++++++-- .../Services/Conditions/BlockInterpreter.cs | 51 ++++++++++ Silkstring/Silkstring.csproj | 4 + Tests/AliasEntryTests.cs | 36 +++++++ Tests/AliasMatcherTests.cs | 38 ++++++++ Tests/AliasValidatorTests.cs | 46 +++++++++ Tests/ArgumentParserTests.cs | 17 ++++ Tests/BlockInterpreterTests.cs | 97 +++++++++++++++++++ Tests/CommandResolverTests.cs | 35 +++++++ Tests/Tests.csproj | 1 + 10 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 Silkstring/Services/Conditions/BlockInterpreter.cs create mode 100644 Tests/AliasEntryTests.cs create mode 100644 Tests/AliasMatcherTests.cs create mode 100644 Tests/AliasValidatorTests.cs create mode 100644 Tests/ArgumentParserTests.cs create mode 100644 Tests/BlockInterpreterTests.cs create mode 100644 Tests/CommandResolverTests.cs diff --git a/Silkstring/Services/CommandHandler.cs b/Silkstring/Services/CommandHandler.cs index 78d2473..2f65763 100644 --- a/Silkstring/Services/CommandHandler.cs +++ b/Silkstring/Services/CommandHandler.cs @@ -5,6 +5,7 @@ using Dalamud.Plugin.Services; using ECommons.Automation; using Serilog; +using Silkstring.Services.Conditions; namespace Silkstring.Services; @@ -12,32 +13,65 @@ public class CommandHandler { private readonly IFramework _framework; private readonly CommandResolver _resolver; + private readonly ConditionEvaluator _conditions; public CommandHandler(CommandResolver resolver, IFramework framework) { _resolver = resolver; _framework = framework; + _conditions = new ConditionEvaluator(_resolver.Resolve); } public async Task ExecuteAsync(IReadOnlyList commands, IReadOnlyList args, int delayMs = 100, CancellationToken cancellationToken = default, Func? shouldSkip = null) { - for (var i = 0; i < commands.Count; i++) + var blocks = new BlockInterpreter(); + var sent = false; + + foreach (var line in commands) { - var cmd = commands[i]; - cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(cmd, args)); + var (kind, expression) = BlockInterpreter.Classify(line); + + if (kind == BlockKind.If) + { + var met = blocks.Active && await _framework.RunOnFrameworkThread(() => EvaluateSafe(expression, args)); + blocks.EnterIf(met); + continue; + } + + if (kind == BlockKind.Else) { blocks.Else(); continue; } + if (kind == BlockKind.EndIf) { blocks.EndIf(); continue; } + + if (!blocks.Active) continue; + + var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args)); if (shouldSkip != null && cmd.StartsWith("/")) { var parts = cmd.TrimStart('/').Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) continue; - var commandName = parts[0]; - if (shouldSkip(commandName)) + if (shouldSkip(parts[0])) { Log.Warning("Skipping recursive command: {Command}", cmd); continue; } } + + if (sent) await Task.Delay(delayMs, cancellationToken); await _framework.RunOnFrameworkThread(() => Chat.SendMessage(cmd)); - if (i < commands.Count - 1) await Task.Delay(delayMs, cancellationToken); + sent = true; + + } + } + + private bool EvaluateSafe(string expression, IReadOnlyList args) + { + try + { + return _conditions.Evaluate(expression, args); + } + catch (ConditionException ex) + { + Log.Warning(ex, "Invalid condition: {Expression}", expression); + return false; } } } diff --git a/Silkstring/Services/Conditions/BlockInterpreter.cs b/Silkstring/Services/Conditions/BlockInterpreter.cs new file mode 100644 index 0000000..c082867 --- /dev/null +++ b/Silkstring/Services/Conditions/BlockInterpreter.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Silkstring.Services.Conditions; + +internal enum BlockKind +{ + Command, + If, + Else, + EndIf +} + +internal sealed class BlockInterpreter +{ + private readonly Stack _stack = new(); + public bool Active => _stack.Count == 0 || _stack.Peek().Active; + + public static (BlockKind Kind, string Expression) Classify(string line) + { + if (line.StartsWith(":if ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.If, line[4..]); + if (line.Equals(":else", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Else, ""); + if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, ""); + return (BlockKind.Command, line); + } + + public void EnterIf(bool conditionMet) + { + var parentActive = Active; + _stack.Push(new Block { ParentActive = parentActive, ConditionTrue = conditionMet, Active = parentActive && conditionMet }); + } + + public void Else() + { + if (_stack.Count == 0) return; + var block = _stack.Peek(); + block.Active = block.ParentActive && !block.ConditionTrue; + } + + public void EndIf() + { + if (_stack.Count > 0) _stack.Pop(); + } + + private sealed class Block + { + public bool ParentActive; + public bool ConditionTrue; + public bool Active; + } +} diff --git a/Silkstring/Silkstring.csproj b/Silkstring/Silkstring.csproj index 4cb99d9..8bc1ab1 100644 --- a/Silkstring/Silkstring.csproj +++ b/Silkstring/Silkstring.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/Tests/AliasEntryTests.cs b/Tests/AliasEntryTests.cs new file mode 100644 index 0000000..e405e06 --- /dev/null +++ b/Tests/AliasEntryTests.cs @@ -0,0 +1,36 @@ +using Silkstring.Models; + +public class AliasEntryTests +{ + private static AliasEntry WithName(string name) + => new() { Name = name, Output = new() { new CommandEntry { Command = "/say hi" } } }; + + [Theory] + [InlineData("greet", true)] + [InlineData("mew|meow", true)] + [InlineData("silkstring", false)] + [InlineData("xlplugins", false)] + [InlineData("greet|silkstring", false)] + [InlineData("gr eet", false)] + [InlineData("gr/eet", false)] + [InlineData("", false)] + public void ValidatesName(string name, bool expected) => Assert.Equal(expected, WithName(name).IsValid()); + + [Fact] + public void InvalidWithoutOutput() => Assert.False(new AliasEntry { Name = "greet" }.IsValid()); + + [Fact] + public void ValidWithOneNonBlankCommand() + => Assert.True(new AliasEntry { Name = "greet", Output = new() { new CommandEntry { Command = "/say" }, new CommandEntry() } }.IsValid()); + + [Fact] + public void InvalidWithOnlyBlankCommands() + => Assert.False(new AliasEntry { Name = "greet", Output = new() { new CommandEntry() } }.IsValid()); + + [Theory] + [InlineData("Greeting", "greet", "Greeting")] + [InlineData("", "greet", "greet")] + [InlineData(" ", "greet", "greet")] + public void EffectiveNamePrefersDisplayName(string display, string name, string expected) + => Assert.Equal(expected, new AliasEntry { DisplayName = display, Name = name }.EffectiveName); +} diff --git a/Tests/AliasMatcherTests.cs b/Tests/AliasMatcherTests.cs new file mode 100644 index 0000000..a2401e2 --- /dev/null +++ b/Tests/AliasMatcherTests.cs @@ -0,0 +1,38 @@ +using Silkstring.Models; +using Silkstring.Services; + +public class AliasMatcherTests +{ + private static AliasEntry Valid(string name, bool enabled = true) + => new() { Name = name, Enabled = enabled, Output = new() { new CommandEntry { Command = "/say hi" } } }; + + [Fact] + public void MatchesByTrigger() + { + var a = Valid("greet"); + Assert.Same(a, AliasMatcher.Match("greet", new[] { a })); + } + + [Fact] + public void MatchesByPipeName() + { + var a = Valid("mew|meow"); + Assert.Same(a, AliasMatcher.Match("meow", new[] { a })); + } + + [Fact] + public void MatchIsCaseInsensitive() + { + var a = Valid("greet"); + Assert.Same(a, AliasMatcher.Match("GREET", new[] { a })); + } + + [Fact] + public void DoesNotMatchUnknown() => Assert.Null(AliasMatcher.Match("nope", new[] { Valid("greet") })); + + [Fact] + public void DoesNotMatchDisabled() => Assert.Null(AliasMatcher.Match("greet", new[] { Valid("greet", false) })); + + [Fact] + public void DoesNotMatchInvalid() => Assert.Null(AliasMatcher.Match("greet", new[] { new AliasEntry { Name = "greet" } })); +} diff --git a/Tests/AliasValidatorTests.cs b/Tests/AliasValidatorTests.cs new file mode 100644 index 0000000..984bfe8 --- /dev/null +++ b/Tests/AliasValidatorTests.cs @@ -0,0 +1,46 @@ +using Silkstring.Models; +using Silkstring.Services; + +public class AliasValidatorTests +{ + private static AliasEntry Alias(string name, params string[] output) + => new() { Name = name, Output = output.Select(o => new CommandEntry { Command = o }).ToList() }; + + [Fact] + public void DetectsDirectCycle() + { + var a = Alias("a", "/a"); + Assert.NotEmpty(AliasValidator.FindCycle(a, new[] { a })); + } + + [Fact] + public void DetectsIndirectCycle() + { + var a = Alias("a", "/b"); + var b = Alias("b", "/a"); + Assert.NotEmpty(AliasValidator.FindCycle(a, new[] { a, b })); + } + + [Fact] + public void NoCycleForPlainCommands() + { + var a = Alias("a", "/say hi"); + Assert.Empty(AliasValidator.FindCycle(a, new[] { a })); + } + + [Fact] + public void NoCycleForChainWithoutLoop() + { + var a = Alias("a", "/b"); + var b = Alias("b", "/say hi"); + Assert.Empty(AliasValidator.FindCycle(a, new[] { a, b })); + } + + [Fact] + public void NonSlashLineIsNotADependency() + { + var a = Alias("a", "a"); + Assert.Empty(AliasValidator.FindCycle(a, new[] { a })); + } + +} diff --git a/Tests/ArgumentParserTests.cs b/Tests/ArgumentParserTests.cs new file mode 100644 index 0000000..f86cae0 --- /dev/null +++ b/Tests/ArgumentParserTests.cs @@ -0,0 +1,17 @@ +using Silkstring.Services; + +public class ArgumentParserTests +{ + [Theory] + [InlineData("a b c", "a|b|c")] + [InlineData("\"a b\" c", "a b|c")] + [InlineData("a b", "a|b")] + [InlineData("one \"two three\" four", "one|two three|four")] + [InlineData("\"ab", "\"ab")] + public void Parses(string input, string expected) => Assert.Equal(expected.Split('|'), ArgumentParser.Parse(input)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyInputYieldsNoArgs(string input) => Assert.Empty(ArgumentParser.Parse(input)); +} diff --git a/Tests/BlockInterpreterTests.cs b/Tests/BlockInterpreterTests.cs new file mode 100644 index 0000000..55ec60d --- /dev/null +++ b/Tests/BlockInterpreterTests.cs @@ -0,0 +1,97 @@ +using Silkstring.Services.Conditions; + +public class BlockInterpreterTests +{ + [Theory] + [InlineData(":if {hp} < 50", "If", "{hp} < 50")] + [InlineData(":IF {hp}", "If", "{hp}")] + [InlineData(":else", "Else", "")] + [InlineData(":endif", "EndIf", "")] + [InlineData("/say hi", "Command", "/say hi")] + [InlineData(":ifx", "Command", ":ifx")] + public void Classifies(string line, string kind, string expression) + { + var (k, e) = BlockInterpreter.Classify(line); + Assert.Equal(kind, k.ToString()); + Assert.Equal(expression, e); + } + + [Fact] + public void EmptyIsActive() => Assert.True(new BlockInterpreter().Active); + + [Fact] + public void TrueIfRuns() + { + var b = new BlockInterpreter(); + b.EnterIf(true); + Assert.True(b.Active); + } + + [Fact] + public void FalseIfSuppresses() + { + var b = new BlockInterpreter(); + b.EnterIf(false); + Assert.False(b.Active); + } + + [Fact] + public void ElseRunsWhenIfFalse() + { + var b = new BlockInterpreter(); + b.EnterIf(false); + b.Else(); + Assert.True(b.Active); + } + + [Fact] + public void ElseSkippedWhenIfTrue() + { + var b = new BlockInterpreter(); + b.EnterIf(true); + b.Else(); + Assert.False(b.Active); + } + + [Fact] + public void EndIfRestoresOuterScope() + { + var b = new BlockInterpreter(); + b.EnterIf(false); + b.EndIf(); + Assert.True(b.Active); + } + + [Fact] + public void NestedIfInactiveWhenParentInactive() + { + var b = new BlockInterpreter(); + b.EnterIf(false); + b.EnterIf(true); + Assert.False(b.Active); + b.EndIf(); + Assert.False(b.Active); + b.EndIf(); + Assert.True(b.Active); + } + + [Fact] + public void NestedIfActiveWhenParentActive() + { + var b = new BlockInterpreter(); + b.EnterIf(true); + b.EnterIf(true); + Assert.True(b.Active); + b.EndIf(); + Assert.True(b.Active); + } + + [Fact] + public void OrphanElseAndEndIfAreIgnored() + { + var b = new BlockInterpreter(); + b.Else(); + b.EndIf(); + Assert.True(b.Active); + } +} diff --git a/Tests/CommandResolverTests.cs b/Tests/CommandResolverTests.cs new file mode 100644 index 0000000..065f4b1 --- /dev/null +++ b/Tests/CommandResolverTests.cs @@ -0,0 +1,35 @@ +using Silkstring.Services; +using Silkstring.Services.Variables; + +public class CommandResolverTests +{ + private sealed class FakeProvider : IVariableProvider + { + public IEnumerable GetVariables() + { + yield return new("job", "", "", () => "WHM"); + yield return new("hpp", "", "", () => "40"); + yield return new("none", "", "", () => null); + } + } + + private static CommandResolver Resolver() => new(new[] { new FakeProvider() }); + + [Theory] + [InlineData("{job}", "", "WHM")] + [InlineData("{JOB}", "", "WHM")] + [InlineData("{none}", "", "{none}")] + [InlineData("{bogus}", "", "{bogus}")] + [InlineData("hi {job}!", "", "hi WHM!")] + [InlineData("{0}", "a b", "a")] + [InlineData("{1}", "a b", "b")] + [InlineData("{2}", "a b", "{2}")] + [InlineData("{*}", "a b c", "a b c")] + [InlineData("{1..}", "a b c", "b c")] + [InlineData("{..2}", "a b c", "a b")] + [InlineData("{0..2}", "a b c", "a b")] + [InlineData("{5..}", "a", "")] + [InlineData("{0} {job}", "x", "x WHM")] + public void Resolves(string command, string args, string expected) + => Assert.Equal(expected, Resolver().Resolve(command, args.Split(' ', StringSplitOptions.RemoveEmptyEntries))); +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 1f9f0e0..4e68615 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -10,6 +10,7 @@ + From 443dc8cf84ad9394b5e99b7f2169d53da7152e36 Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:00:48 -0300 Subject: [PATCH 3/4] Provide validation for conditionals --- Silkstring/Services/AliasValidator.cs | 32 ++++++++++++++++++++++++++ Silkstring/UI/Panels/AliasEditPanel.cs | 9 +++++++- Tests/AliasValidatorTests.cs | 9 ++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Silkstring/Services/AliasValidator.cs b/Silkstring/Services/AliasValidator.cs index 7ce5ad7..d9e016d 100644 --- a/Silkstring/Services/AliasValidator.cs +++ b/Silkstring/Services/AliasValidator.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Silkstring.Models; +using Silkstring.Services.Conditions; namespace Silkstring.Services; @@ -14,6 +15,37 @@ public static List FindCycle(AliasEntry target, IEnumerable return Dfs(target, lookup, visited, path); } + public static string? ValidateBlocks(AliasEntry Alias) + { + var elseSeen = new Stack(); + + foreach (var command in Alias.Output) + { + var (kind, expression) = BlockInterpreter.Classify((command.Command.Trim())); + + switch (kind) + { + case BlockKind.If: + try { new Parser(Tokenizer.Tokenize(expression)).Parse(); } + catch (ConditionException ex) { return $"Invalid condition: {ex.Message}"; } + elseSeen.Push(false); + break; + case BlockKind.Else: + if (elseSeen.Count == 0) return ":else without a matching :if"; + if (elseSeen.Peek()) return "Duplicate :else in a block"; + elseSeen.Pop(); + elseSeen.Push(true); + break; + case BlockKind.EndIf: + if (elseSeen.Count == 0) return ":endif without a matching :if"; + elseSeen.Pop(); + break; + } + } + + return elseSeen.Count > 0 ? "Unclosed :if (missing :endif)" : null; + } + private static Dictionary BuildTriggerLookup(IEnumerable allAliases) { var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/Silkstring/UI/Panels/AliasEditPanel.cs b/Silkstring/UI/Panels/AliasEditPanel.cs index f192b17..a692dc0 100644 --- a/Silkstring/UI/Panels/AliasEditPanel.cs +++ b/Silkstring/UI/Panels/AliasEditPanel.cs @@ -17,6 +17,7 @@ public class AliasEditPanel private AliasEntry? _selectedAlias; private List? _detectedCycle; + private string? _blockError; private string _multilineBuffer = string.Empty; private int _multilineAliasId = -1; @@ -27,7 +28,7 @@ public AliasEditPanel(Configuration configuration, MainWindow mainWindow) { _selectedAlias = alias; if (alias != null) RefreshCycleCheck(); - else _detectedCycle = null; + else { _detectedCycle = null; _blockError = null; } }; _configuration = configuration; } @@ -44,6 +45,11 @@ public void Draw() DrawAliasHeader(alias); ImGui.Separator(); + if (_blockError != null) + { + ImGui.TextColored(new Vector4(1f, 0.4f, 0.4f, 1f), _blockError); + ImGui.Spacing(); + } DrawCommandList(alias); } @@ -148,6 +154,7 @@ private void RefreshCycleCheck() { if (_selectedAlias == null) return; _detectedCycle = AliasValidator.FindCycle(_selectedAlias, _configuration.GetAliases()); + _blockError = AliasValidator.ValidateBlocks(_selectedAlias); } private void SyncMultilineBuffer(AliasEntry alias) diff --git a/Tests/AliasValidatorTests.cs b/Tests/AliasValidatorTests.cs index 984bfe8..5f962bd 100644 --- a/Tests/AliasValidatorTests.cs +++ b/Tests/AliasValidatorTests.cs @@ -43,4 +43,13 @@ public void NonSlashLineIsNotADependency() Assert.Empty(AliasValidator.FindCycle(a, new[] { a })); } + [Fact] public void ValidIf() => Assert.Null(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/echo", ":endif"))); + [Fact] public void ValidIfElse() => Assert.Null(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/a", ":else", "/b", ":endif"))); + [Fact] public void NestedValid() => Assert.Null(AliasValidator.ValidateBlocks(Alias("a", ":if {a} == 1", ":if {b} == 2", "/c", ":endif", ":endif"))); + [Fact] public void NoBlocks() => Assert.Null(AliasValidator.ValidateBlocks(Alias("a", "/say hi"))); + [Fact] public void Unclosed() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/a"))); + [Fact] public void OrphanElse() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":else"))); + [Fact] public void OrphanEndIf() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":endif"))); + [Fact] public void DuplicateElse() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":if {a} == 1", ":else", ":else", ":endif"))); + [Fact] public void BadExpression() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} <=", ":endif"))); } From 51956a635585515f4e9249c0ef613c4e6b0ac9d3 Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:23:57 -0300 Subject: [PATCH 4/4] Update documentation --- CHANGELOG.md | 6 ++++ README.md | 24 +++++++++++++++ Silkstring/Windows/HelpWindow.cs | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98da16b..7d7735f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.2.0.0 - 2026-06-29 +### Added +- Conditionals: run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks. Everything in a block runs only when its condition holds, with an optional `:else` for the false case, and blocks can be nested +- Conditions compare values with `==`, `!=`, `<`, `>`, `<=`, `>=` and combine with `&&` (and) and `||` (or). Either side can be a variable, a parameter, or plain text, for example `:if {hpp} < 50 && {incombat}` or `:if {0} == on` +- The alias editor warns you when a conditional block is left open or a condition cannot be understood + ## v1.1.0.0 - 2026-06-27 ### Added - New variables: your HP and MP (including percentages), whether you are in combat, whether you have a target, and your Gil and MGP diff --git a/README.md b/README.md index f53cdfe..74a8f7a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,30 @@ You can also pull ranges of arguments. Range ends are exclusive, the same as C# If you reference an argument that was not supplied, it is left as written (e.g. `{3}` stays `{3}`). +### Conditionals + +Aliases can run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks: + +``` +:if {hpp} < 50 +/ac Cure +:else +/say all good +:endif +``` + +Everything between `:if` and `:endif` runs only when the condition holds; the optional `:else` block runs when it does not. A block can contain multiple commands, and blocks can be nested. + +A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can combine comparisons with `&&` (and) and `||` (or). Either side can be a variable, a parameter, or plain text, so conditions can react to game state or to what you typed: + +``` +:if {incombat} && {hpp} < 50 +:if {job} == WHM || {job} == SCH +:if {0} == on +``` + +Text comparisons are case-insensitive, and numbers compare as numbers. The `:if`, `:else`, and `:endif` lines are never sent to chat, and the alias editor warns you if a block is left open or a condition cannot be understood. + ### Folders Create a folder with the **New Folder** button, then drag aliases into or out of it. Folders can be collapsed, renamed, and deleted from their right-click menu. diff --git a/Silkstring/Windows/HelpWindow.cs b/Silkstring/Windows/HelpWindow.cs index 2e7e973..e33504e 100644 --- a/Silkstring/Windows/HelpWindow.cs +++ b/Silkstring/Windows/HelpWindow.cs @@ -14,6 +14,7 @@ private enum Tab Commands = 0, Variables = 1, Parameters = 2, + Conditionals = 3, } private Tab _selectedTab = Tab.Commands; @@ -66,6 +67,7 @@ public override void Draw() case Tab.Commands: DrawCommandsHelp(); break; case Tab.Variables: DrawVariablesHelp(); break; case Tab.Parameters: DrawParametersHelp(); break; + case Tab.Conditionals: DrawConditionalsHelp(); break; } ImGui.EndChild(); @@ -169,6 +171,12 @@ private void DrawCommandsHelp() ImGui.TextWrapped("Aliases can take arguments typed after the trigger, inserted with numbered braces like {0}. See the Parameters tab for the full syntax."); ImGui.Spacing(); + ImGui.TextColored(HeadingColor, "Conditionals"); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("Run commands only when a condition is true with :if / :else / :endif blocks. See the Conditionals tab."); + ImGui.Spacing(); + ImGui.TextColored(HeadingColor, "Macros"); ImGui.Separator(); ImGui.Spacing(); @@ -290,4 +298,46 @@ private void DrawParameterRow(string token, string meaning) ImGui.TableNextColumn(); ImGui.TextWrapped(meaning); } + + private void DrawConditionalsHelp() + { + ImGui.TextColored(HeadingColor, "What are Conditionals?"); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("Conditionals let an alias run some commands only when a condition is true. They use block syntax: everything between :if and :endif runs only when the condition holds."); + ImGui.Spacing(); + ImGui.Indent(); + ImGui.TextDisabled(":if {hpp} < 50"); + ImGui.TextDisabled("/ac Cure"); + ImGui.TextDisabled(":else"); + ImGui.TextDisabled("/say all good"); + ImGui.TextDisabled(":endif"); + ImGui.Unindent(); + ImGui.Spacing(); + ImGui.BulletText("A block can hold multiple commands, all gated by the same condition."); + ImGui.BulletText("The :else block is optional and runs when the condition is false."); + ImGui.BulletText("Blocks can be nested inside other blocks."); + ImGui.BulletText("The :if, :else, and :endif lines are never sent to chat."); + ImGui.Spacing(); + + ImGui.TextColored(HeadingColor, "Conditions"); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("A condition either compares two values, or checks a true/false variable on its own. Comparisons can be combined with and (&&) and or (||)."); + ImGui.Spacing(); + ImGui.Indent(); + ImGui.TextDisabled(":if {hpp} < 50 && {incombat}"); + ImGui.TextDisabled(":if {job} == WHM || {job} == SCH"); + ImGui.TextDisabled(":if {incombat}"); + ImGui.Unindent(); + ImGui.Spacing(); + ImGui.TextWrapped("Operators: == and != for equality, < > <= >= for numbers, plus && and || to combine. Text comparisons are case-insensitive, and numbers compare as numbers."); + ImGui.Spacing(); + ImGui.TextWrapped("Either side of a comparison can be a variable, a parameter, or plain text, so a condition can react to game state or to what you typed:"); + ImGui.Spacing(); + ImGui.Indent(); + ImGui.TextDisabled(":if {0} == on"); + ImGui.Unindent(); + ImGui.Spacing(); + } }