diff --git a/CHANGELOG.md b/CHANGELOG.md index 443ba39..b23fc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v2.0.0.0 - 2026-07-12 +### Changed +- Conditionals now use braces. Open a block with `if (condition) {`, close it with `}`, and use `else {` or `else if (condition) {` for the other branches. This replaces the old `:if`, `:else`, and `:endif` lines. Your existing conditional aliases are converted to the new format automatically the first time you update +- The alias editor is now always the syntax highlighted multiline text box. The old row by row list view has been removed +- R.I.P List view (I don't think anyone used this, you won't be missed.. hopefully? c:) + +### Added +- `else if` chains conditions together, running the first branch whose condition is true +- A `:return` line stops the rest of an alias right away, which makes a handy early exit, for example checking something at the top of an alias and bailing out +- The editor now marks any line with a problem: the line is highlighted and hovering it tells you what is wrong, on top of the list of problems shown above the editor +- The editor warns you when an alias has no trigger, or a trigger with a space, a slash, or a reserved name +- Tab indents lines in the editor and Shift + Tab removes indentation, so you can format blocks quickly + +### Notes +- Your conditional aliases are rewritten from the old `:if` style to braces automatically on update, and a backup of your previous configuration is saved first (for example `Silkstring.v2.backup.json`) + ## v1.6.3.0 - 2026-07-11 ### Added - Comments: any line that starts with `#` is now a note to yourself. It is left out when the alias runs and shown in its own color (which you can change in the settings), so you can label your aliases or jot down reminders. diff --git a/README.md b/README.md index cd4dc87..00eb027 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali - Define custom aliases through an in-game GUI - Run multiple lines per alias, in order, with a configurable delay between them - Mix game commands and chat messages in a single alias +- Leave notes in an alias with `#` comment lines - Insert live game state with variables such as `{character}` and `{job}` - Define your own variables and change them on the fly from inside an alias - Give an alias several triggers using a `|` separator @@ -16,6 +17,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali - Enable or disable an alias without deleting it - Built-in help window with a live command tester - Syntax highlighting in the multiline editor, with customizable colors +- Run commands only when a condition is true with `if` / `else if` / `else` blocks - Hold an alias until a condition is met with `:until`, and stop running aliases with `/silkstring cancel` - Cycle detection warns you if aliases would trigger each other in a loop @@ -79,6 +81,19 @@ good luck, everyone! ``` This runs `/say Hello!`, then `/emote waves`, then sends "good luck, everyone!" to your current channel. +### Comments + +Any line that starts with `#` is a comment. It is left out when the alias runs and shown in its own color in the editor, so you can label an alias or leave yourself notes: + +``` +# heal the party if anyone is hurt +if ({hpp} < 50) { + /ac Medica II +} +``` + +Only whole lines are comments. A `#` partway through a line is treated as normal text, so `/say I'm #1!` still sends as written. You can change the comment color in the settings. + ### Variables Variables let you insert live game values into a line using curly brace syntax. They are case-insensitive and resolved at the moment the alias fires, so they always reflect your current state. If a value cannot be read (for example when you are not logged in) the variable is left as written rather than blanked out. @@ -119,30 +134,55 @@ If you reference an argument that was not supplied, it is left as written (e.g. ### Conditionals -Aliases can run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks: +Aliases can run commands only when a condition is true, using `if` blocks with braces: ``` -:if {hpp} < 50 -/ac Cure -:else -/say all good -:endif +if ({hpp} < 50) { + /ac Cure +} +else { + /say all good +} ``` -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. +Everything inside the braces after `if (...)` runs only when the condition holds. Add an `else { }` block for the other case, or chain several checks with `else if (...)`, which runs the first branch whose condition is true: + +``` +if ({hpp} < 25) { + /ac Benediction +} +else if ({hpp} < 50) { + /ac Cure +} +else { + /say all good +} +``` + +Blocks can hold multiple lines and can be nested inside each other. Indent with Tab and remove indentation with Shift + Tab to keep nested blocks readable. 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 -:if {time24} >= 17:00 || {time24} <= 05:00 +if ({incombat} && {hpp} < 50) { +if ({job} == WHM || {job} == SCH) { +if ({0} == on) { +if ({time24} >= 17:00 || {time24} <= 05:00) { ``` That last line reacts to the clock: because `{time24}` and `{date}` are written with leading zeros (like 09:00 and 2026-07-09), they sort in the right order and can be compared with `<`, `>`, `<=`, and `>=`. This is why `{time24}` is the one to compare against, while `{time}` (like 3:45 PM) is just for showing. -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. +Text comparisons are case-insensitive, and numbers compare as numbers. The editor colors a brace or condition red when it is not valid, marks the line, and lists any open block or condition it cannot understand above the editor. + +You can stop the rest of an alias early with a `:return` line, which is handy as a guard near the top: + +``` +if ({incombat}) { + :return +} +/wave +good luck, everyone! +``` You can also pause between lines with `:wait`, followed by a number of seconds: @@ -182,22 +222,22 @@ The value is resolved when the line runs, so it can include other variables and Because conditions treat `true` and `false` the same way as the built-in switches, a variable makes a handy on/off flag. Toggle it in one alias with `:set burst true` (or `false`), and check it in another: ``` -:if {burst} -/say bursting -:endif +if ({burst}) { + /say bursting +} ``` ### 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. -### Multiline entry +### The editor -By default each line of an alias is its own row. You can switch to a single multiline editor in the settings, which is handy for pasting or editing longer aliases. The editor highlights your syntax as you type: commands, keywords, variables, parameters, quoted text, and option flags each get their own color, and anything malformed (a bad `:wait`, an unfinished `:if`, an unknown `:set` name, a mistyped keyword, or an unclosed `{`) is shown in red. It also shows line numbers, which you can turn off in the settings. +Aliases are edited in a single multiline text box that highlights your syntax as you type: commands, keywords, variables, parameters, quoted text, and option flags each get their own color, and anything malformed (a bad `:wait`, an unclosed block, an unknown `:set` name, or a broken condition) is shown in red and marked on the line. It shows line numbers, which you can turn off in the settings. Tab indents the current lines and Shift + Tab removes indentation, so you can format blocks quickly. ### Settings -Open settings with the cog icon in the Silkstring title bar, or the cog next to Silkstring in `/xlplugins`. You can set the delay between lines in milliseconds (0 to 1000, default 100), set how long an `:until` waits before giving up, allow unsafe waits that have no time limit, toggle multiline command entry, toggle line numbers in the editor, and open the Colors section to recolor the editor and interface to your taste. +Open settings with the cog icon in the Silkstring title bar, or the cog next to Silkstring in `/xlplugins`. You can set the delay between lines in milliseconds (0 to 1000, default 100), set how long an `:until` waits before giving up, allow unsafe waits that have no time limit, toggle line numbers in the editor, and open the Colors section to recolor the editor and interface to your taste. ## Notes diff --git a/Silkstring/Configuration.cs b/Silkstring/Configuration.cs index a7bdc38..a01af7f 100644 --- a/Silkstring/Configuration.cs +++ b/Silkstring/Configuration.cs @@ -29,7 +29,7 @@ public int UntilTimeoutSeconds } public bool AllowUnsafeWaits { get; set; } - public const int CurrentVersion = 2; + public const int CurrentVersion = 3; public string? LastSeenVersion { get; set; } public int Version { get; set; } = CurrentVersion; @@ -39,7 +39,6 @@ public int UntilTimeoutSeconds public ThemeColors Theme = new(); public bool ShowLineNumbers = true; - public bool MultilineCommands { get; set; } public void Save() { diff --git a/Silkstring/Models/AliasEntry.cs b/Silkstring/Models/AliasEntry.cs index c83ed57..beca41c 100644 --- a/Silkstring/Models/AliasEntry.cs +++ b/Silkstring/Models/AliasEntry.cs @@ -20,7 +20,7 @@ public class AliasEntry public string DisplayName = string.Empty; public string Name = string.Empty; - [JsonIgnore] public string[] triggers => Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + [JsonIgnore] public string[] Triggers => Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); [JsonIgnore] public string EffectiveName => string.IsNullOrWhiteSpace(DisplayName) ? Name : DisplayName; public bool Enabled = true; public List Output = new(); @@ -34,9 +34,9 @@ public AliasEntry() public bool IsValid() { - if (triggers.Length == 0) return false; + if (Triggers.Length == 0) return false; - foreach (var trigger in triggers) + foreach (var trigger in Triggers) { if (Blacklist.Contains(trigger)) return false; if (trigger.Contains(' ')) return false; diff --git a/Silkstring/Models/CommandEntry.cs b/Silkstring/Models/CommandEntry.cs index 373188e..de8a9ae 100644 --- a/Silkstring/Models/CommandEntry.cs +++ b/Silkstring/Models/CommandEntry.cs @@ -10,10 +10,6 @@ public class CommandEntry public string Command = string.Empty; - [NonSerialized] - [JsonIgnore] - public bool Delete; - [NonSerialized] [JsonIgnore] public int UniqueId; diff --git a/Silkstring/Services/AliasMatcher.cs b/Silkstring/Services/AliasMatcher.cs index 74793f9..99dfa54 100644 --- a/Silkstring/Services/AliasMatcher.cs +++ b/Silkstring/Services/AliasMatcher.cs @@ -11,7 +11,7 @@ public static class AliasMatcher { return aliases.FirstOrDefault(a => a.Enabled && a.IsValid() && - a.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + a.Triggers .Any(n => n.Equals(commandName, StringComparison.OrdinalIgnoreCase))); } } diff --git a/Silkstring/Services/AliasValidator.cs b/Silkstring/Services/AliasValidator.cs index 2b788be..f214cea 100644 --- a/Silkstring/Services/AliasValidator.cs +++ b/Silkstring/Services/AliasValidator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Silkstring.Models; using Silkstring.Services.Conditions; @@ -10,6 +11,7 @@ public static class AliasValidator public static List Validate(AliasEntry alias, ISet definedVariables, bool allowUnsafe, IEnumerable allAliases) { var diagnostics = new List(); + diagnostics.AddRange(ValidateTrigger(alias)); diagnostics.AddRange(ValidateBlocks(alias)); diagnostics.AddRange(ValidateSets(alias, definedVariables)); diagnostics.AddRange(ValidateWaits(alias)); @@ -19,6 +21,22 @@ public static List Validate(AliasEntry alias, ISet definedVa return diagnostics; } + public static IEnumerable ValidateTrigger(AliasEntry alias) + { + var triggers = alias.Triggers; + if (alias.Triggers.Length == 0) + { + yield return new("This alias needs a trigger"); + yield break; + } + foreach (var t in triggers) + { + if (AliasEntry.Blacklist.Contains(t)) yield return new($"\"{t}\" is a reserved name and cannot be used as a trigger"); + else if (t.Contains(' ')) yield return new($"Trigger \"{t}\" cannot contain spaces"); + else if (t.Contains('/')) yield return new($"Trigger \"{t}\" cannot contain a slash"); + } + } + public static List FindCycle(AliasEntry target, IEnumerable allAliases) { var lookup = BuildTriggerLookup(allAliases); @@ -27,32 +45,7 @@ public static List FindCycle(AliasEntry target, IEnumerable return Dfs(target, lookup, visited, path); } - public static IEnumerable ValidateBlocks(AliasEntry alias) - { - var elseSeen = new Stack(); - for (var i = 0; i < alias.Output.Count; i++) - { - var (kind, expression) = BlockInterpreter.Classify(alias.Output[i].Command.Trim()); - switch (kind) - { - case BlockKind.If: - if (!Condition.TryParse(expression, out _, out var error)) { yield return new($"Invalid condition: {error}", i); yield break; } - elseSeen.Push(false); - break; - case BlockKind.Else: - if (elseSeen.Count == 0) { yield return new(":else without a matching :if", i); yield break; } - if (elseSeen.Peek()) { yield return new("Duplicate :else in a block", i); yield break; } - elseSeen.Pop(); - elseSeen.Push(true); - break; - case BlockKind.EndIf: - if (elseSeen.Count == 0) { yield return new(":endif without a matching :if", i); yield break; } - elseSeen.Pop(); - break; - } - } - if (elseSeen.Count > 0) yield return new("Unclosed :if (missing :endif)"); - } + public static IEnumerable ValidateBlocks(AliasEntry alias) => BlockParser.Parse(alias.Output.Select(c => c.Command).ToList()).Diagnostics; public static IEnumerable ValidateSets(AliasEntry alias, ISet defined) { @@ -97,7 +90,7 @@ private static Dictionary BuildTriggerLookup(IEnumerable(StringComparer.OrdinalIgnoreCase); foreach (var alias in allAliases) { - var triggers = alias.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var triggers = alias.Triggers; foreach (var trigger in triggers) lookup.TryAdd(trigger, alias); } return lookup; @@ -121,7 +114,7 @@ private static IEnumerable GetDependencies(AliasEntry alias, Diction private static List Dfs( AliasEntry current, Dictionary lookup, HashSet visited, List path) { - var triggers = current.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var triggers = current.Triggers; if (triggers.Length == 0) return new List(); var trigger = triggers[0]; @@ -129,7 +122,7 @@ private static List Dfs( foreach (var dependency in GetDependencies(current, lookup)) { - var depTrigger = dependency.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)[0]; + var depTrigger = dependency.Triggers[0]; if (path.Contains(depTrigger)) { diff --git a/Silkstring/Services/ChatInterceptor.cs b/Silkstring/Services/ChatInterceptor.cs index 9287a34..966f3c7 100644 --- a/Silkstring/Services/ChatInterceptor.cs +++ b/Silkstring/Services/ChatInterceptor.cs @@ -70,8 +70,7 @@ private void ProcessChatInputDetour(ShellCommandModule* shellCommandModule, Utf8 .Where(c => !string.IsNullOrWhiteSpace(c.Command) && !c.Command.TrimStart().StartsWith('#')) .Select(c => c.Command.Trim()) .ToList(); - var names = alias.Name.Split('|', StringSplitOptions.RemoveEmptyEntries | - StringSplitOptions.TrimEntries); + var names = alias.Triggers; foreach (var name in names) _executingAliases.Add(name); diff --git a/Silkstring/Services/CommandHandler.cs b/Silkstring/Services/CommandHandler.cs index ea16af5..04c89a4 100644 --- a/Silkstring/Services/CommandHandler.cs +++ b/Silkstring/Services/CommandHandler.cs @@ -25,79 +25,82 @@ public CommandHandler(CommandResolver resolver, IFramework framework, Func commands, IReadOnlyList args, int delayMs = 100, CancellationToken cancellationToken = default, Func? shouldSkip = null, int untilTimeoutMs = 30000, bool allowUnsafe = false) { - var blocks = new BlockInterpreter(); + var (tree, diagnostics) = BlockParser.Parse(commands); + if (diagnostics.Count > 0) + { + Log.Warning("Alias not run: {Count} syntax error(s), first: {Message}", diagnostics.Count, diagnostics[0].Message); + return; + } var sent = false; - foreach (var line in commands) + async Task RunNodes(IReadOnlyList nodes) { - var (kind, expression) = BlockInterpreter.Classify(line); - - if (kind == BlockKind.If) + foreach (var node in nodes) { - var met = blocks.Active && await _framework.RunOnFrameworkThread(() => EvaluateSafe(expression, args)); - blocks.EnterIf(met); - continue; + bool halt; + if (node is IfNode ifn) halt = await RunIf(ifn); + else if (node is LineNode line) halt = await RunLine(line.Text); + else halt = false; + if (halt) return true; } + return false; + } - if (kind == BlockKind.Else) { blocks.Else(); continue; } - if (kind == BlockKind.EndIf) { blocks.EndIf(); continue; } + async Task RunIf(IfNode ifn) + { + foreach (var branch in ifn.Branches) + { + var take = branch.Condition == null || await _framework.RunOnFrameworkThread(() => EvaluateSafe(branch.Condition!, args)); + if (take) return await RunNodes(branch.Body); + } + return false; + } + async Task RunLine(string text) + { + var (kind, expression) = BlockInterpreter.Classify(text); + if (kind == BlockKind.Return) return true; if (kind == BlockKind.Set) { - if (blocks.Active) + var (name, rawValue) = BlockInterpreter.ParseSet(expression); + await _framework.RunOnFrameworkThread(() => { - var (name, rawValue) = BlockInterpreter.ParseSet(expression); - await _framework.RunOnFrameworkThread(() => - { - if (!_setUserVariable(name, _resolver.Resolve(rawValue, args))) - Log.Warning("Unknown user variable in :set: {Name}", name); - }); - } - continue; + if (!_setUserVariable(name, _resolver.Resolve(rawValue, args))) + Log.Warning("Unknown user variable in set: {Name}", name); + }); + return false; } - if (kind == BlockKind.Wait) { - if (blocks.Active) - { - var resolved = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(expression, args)); - if (BlockInterpreter.TryParseDuration(resolved, out var ms)) - await Task.Delay(ms, cancellationToken); - else - Log.Warning("Invalid :wait duration: {Duration}", resolved); - } - continue; + var resolved = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(expression, args)); + if (BlockInterpreter.TryParseDuration(resolved, out var ms)) await Task.Delay(ms, cancellationToken); + else Log.Warning("Invalid wait duration: {Duration}", resolved); + return false; } - if (kind == BlockKind.Until) { - if (blocks.Active) - { - var (isUnsafe, condition) = BlockInterpreter.ParseUntil(expression); - await WaitUntilAsync(condition, args, isUnsafe && allowUnsafe, untilTimeoutMs, cancellationToken); - } - continue; + var (isUnsafe, condition) = BlockInterpreter.ParseUntil(expression); + await WaitUntilAsync(condition, args, isUnsafe && allowUnsafe, untilTimeoutMs, cancellationToken); + return false; } - - if (!blocks.Active) continue; - - var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args)); + var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(text, args)); if (shouldSkip != null && cmd.StartsWith("/")) { var parts = cmd.TrimStart('/').Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) continue; + if (parts.Length == 0) return false; if (shouldSkip(parts[0])) { Log.Warning("Skipping recursive command: {Command}", cmd); - continue; + return false; } } - if (sent) await Task.Delay(delayMs, cancellationToken); await _framework.RunOnFrameworkThread(() => Chat.SendMessage(cmd)); sent = true; - + return false; } + + await RunNodes(tree); } private bool EvaluateSafe(string expression, IReadOnlyList args) => _conditions.Evaluate(expression, args); diff --git a/Silkstring/Services/Conditions/BlockInterpreter.cs b/Silkstring/Services/Conditions/BlockInterpreter.cs index 785d70b..a4b2392 100644 --- a/Silkstring/Services/Conditions/BlockInterpreter.cs +++ b/Silkstring/Services/Conditions/BlockInterpreter.cs @@ -7,29 +7,22 @@ namespace Silkstring.Services.Conditions; internal enum BlockKind { Command, - If, - Else, - EndIf, Set, Wait, Until, - Comment + Comment, + Return } -internal sealed class BlockInterpreter +internal static 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("#")) return (BlockKind.Comment, 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, ""); - if (line.StartsWith(":set ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Set, line[5..]); - if (line.StartsWith(":wait ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Wait, line[6..]); - if (line.StartsWith(":until ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Until, line[7..]); + if (line.Equals(":return", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Return, ""); + if (IsStatement(line, ":set", out var set)) return (BlockKind.Set, set); + if (IsStatement(line, ":wait", out var wait)) return (BlockKind.Wait, wait); + if (IsStatement(line, ":until", out var until)) return (BlockKind.Until, until); return (BlockKind.Command, line); } @@ -56,28 +49,13 @@ public static bool TryParseDuration(string text, out int milliseconds) return true; } - 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() + private static bool IsStatement(string line, string keyword, out string rest) { - if (_stack.Count > 0) _stack.Pop(); + rest = ""; + if (line.Equals(keyword, StringComparison.OrdinalIgnoreCase)) return true; + if (!line.StartsWith(keyword + " ", StringComparison.OrdinalIgnoreCase)) return false; + rest = line[(keyword.Length + 1)..]; + return true; } - private sealed class Block - { - public bool ParentActive; - public bool ConditionTrue; - public bool Active; - } } diff --git a/Silkstring/Services/Conditions/BlockNode.cs b/Silkstring/Services/Conditions/BlockNode.cs new file mode 100644 index 0000000..b94e3c5 --- /dev/null +++ b/Silkstring/Services/Conditions/BlockNode.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Silkstring.Services.Conditions; + +internal abstract record BlockNode; +internal sealed record LineNode(string Text) : BlockNode; +internal sealed record IfNode(IReadOnlyList Branches) : BlockNode; +internal sealed record Branch(string? Condition, IReadOnlyList Body); diff --git a/Silkstring/Services/Conditions/BlockParser.cs b/Silkstring/Services/Conditions/BlockParser.cs new file mode 100644 index 0000000..15df42a --- /dev/null +++ b/Silkstring/Services/Conditions/BlockParser.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; + +namespace Silkstring.Services.Conditions; + +internal sealed class BlockParser +{ + private readonly IReadOnlyList _lines; + private readonly List _diagnostics = new(); + private int _pos; + + private BlockParser(IReadOnlyList lines) => _lines = lines; + + public static (IReadOnlyList Nodes, IReadOnlyList Diagnostics) Parse( + IReadOnlyList lines) + { + var p = new BlockParser(lines); + var nodes = p.ParseBlock(true); + return (nodes, p._diagnostics); + } + + private List ParseBlock(bool topLevel) + { + var nodes = new List(); + while (SkipToMeaningful()) + { + var line = _lines[_pos].Trim(); + if (line == "}") + { + if (!topLevel) return nodes; + _diagnostics.Add(new("Unexpected '}'", _pos)); + _pos++; + continue; + } + + if (TryIfHead(line, "if", out _)) { nodes.Add(ParseIf()); continue; } + + if (IsElseIfHead(line, out _) || IsElseHead(line)) + { + _diagnostics.Add(new("'else' without a matching 'if'", _pos)); + _pos++; + continue; + } + nodes.Add(new LineNode(line)); + _pos++; + } + return nodes; + } + + private IfNode ParseIf() + { + var branches = new List(); + AddBranch(branches, "if"); + while (SkipToMeaningful()) + { + var line = _lines[_pos].Trim(); + if (IsElseIfHead(line, out _)) { AddBranch(branches, "else if"); continue; } + if (IsElseHead(line)) { AddElseBranch(branches); break; } + break; + } + return new IfNode(branches); + } + + private void AddBranch(List branches, string keyword) + { + var openLine = _pos; + TryIfHead(_lines[_pos].Trim(), keyword, out var cond); + if (!Condition.TryParse(cond, out _, out var err)) _diagnostics.Add(new($"Invalid condition: {err}", openLine)); + _pos++; + var body = ParseBlock(false); + ExpectClose(openLine); + branches.Add(new Branch(cond, body)); + } + + private void AddElseBranch(List branches) + { + var openLine = _pos; + _pos++; + var body = ParseBlock(false); + ExpectClose(openLine); + branches.Add(new Branch(null, body)); + } + + private void ExpectClose(int openLine) + { + if (_pos < _lines.Count && _lines[_pos].Trim() == "}") { _pos++; return; } + _diagnostics.Add(new("Missing '}' to close this block", openLine)); + } + + private bool SkipToMeaningful() + { + while (_pos < _lines.Count) + { + var line = _lines[_pos].Trim(); + if (line.Length == 0 || line.StartsWith("#")) {_pos++; continue;} + return true; + } + return false; + } + + private static bool TryIfHead(string line, string keyword, out string condition) + { + condition = ""; + if (!line.StartsWith(keyword)) return false; + var rest = line[keyword.Length..].TrimStart(); + if (rest.Length == 0 || rest[0] != '(') return false; + var close = MatchParen(rest); + if (close < 0 || rest[(close + 1)..].Trim() != "{") return false; + condition = rest[1..close]; + return true; + } + + private static bool IsElseIfHead(string line, out string condition) => TryIfHead(line, "else if", out condition); + private static bool IsElseHead(string line) => line.StartsWith("else") && line[4..].TrimStart() == "{"; + + private static int MatchParen(string s) + { + var depth = 0; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '(') depth++; + else if (s[i] == ')' && --depth == 0) return i; + } + return -1; + } +} diff --git a/Silkstring/Services/ConfigMigrator.cs b/Silkstring/Services/ConfigMigrator.cs index 9e7236a..b2f8586 100644 --- a/Silkstring/Services/ConfigMigrator.cs +++ b/Silkstring/Services/ConfigMigrator.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Dalamud.Plugin; using Serilog; +using Silkstring.Models; namespace Silkstring.Services; @@ -18,6 +20,7 @@ public sealed record MigrationResult(IReadOnlyList Messages, string? Bac var messages = new List(); if (config.Version < 2) messages.Add(MigrateToV2(config)); + if (config.Version < 3) messages.Add(MigrateToV3(config)); config.Version = Configuration.CurrentVersion; config.Save(); @@ -43,6 +46,59 @@ private static string MigrateToV2(Configuration config) return $"Prefixed {count} command line(s) with '/' for the new slash rules."; } + private static string MigrateToV3(Configuration config) + { + var count = 0; + foreach (var alias in config.GetAliases()) + { + if (!alias.Output.Any(c => IsOldConditional(c.Command))) continue; + MigrateAliasToV3(alias); + count++; + } + return $"Updated {count} alias(es) to the new if and else block format."; + } + + private static bool IsOldConditional(string line) + { + var t = line.Trim(); + return t.StartsWith(":if ", StringComparison.OrdinalIgnoreCase) + || t.Equals(":else", StringComparison.OrdinalIgnoreCase) + || t.Equals(":endif", StringComparison.OrdinalIgnoreCase); + } + + internal static void MigrateAliasToV3(AliasEntry alias) + { + var output = new List(); + var depth = 0; + foreach (var cmd in alias.Output) + { + var trimmed = cmd.Command.Trim(); + if (trimmed.StartsWith(":if ", StringComparison.OrdinalIgnoreCase)) + { + output.Add(Line(depth, $"if ({trimmed[4..].Trim()}) {{")); + depth++; + } + else if (trimmed.Equals(":else", StringComparison.OrdinalIgnoreCase)) + { + depth = Math.Max(0, depth - 1); + output.Add(Line(depth, "}")); + output.Add(Line(depth, "else {")); + depth++; + } + else if (trimmed.Equals(":endif", StringComparison.OrdinalIgnoreCase)) + { + depth = Math.Max(0, depth - 1); + output.Add(Line(depth, "}")); + } + else + output.Add(Line(depth, trimmed)); + } + alias.Output = output; + } + + private static CommandEntry Line(int depth, string text) + => new() { Command = new string(' ', depth * 4) + text }; + private static string? Backup(IDalamudPluginInterface pi, int fromVersion) { try diff --git a/Silkstring/UI/Panels/AliasEditPanel.cs b/Silkstring/UI/Panels/AliasEditPanel.cs index 3cd6025..116c5b6 100644 --- a/Silkstring/UI/Panels/AliasEditPanel.cs +++ b/Silkstring/UI/Panels/AliasEditPanel.cs @@ -3,8 +3,6 @@ using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Components; using ImGuiColorTextEditNet; using Silkstring.Models; using Silkstring.Services; @@ -51,12 +49,12 @@ public void Draw() ImGui.Separator(); foreach (var d in _diagnostics) { + if (d.Line is not null) continue; var color = d.Severity == Severity.Error ? Palette.Error : Palette.Warning; - var prefix = d.Line is { } line ? $"Line {line + 1}: " : ""; - ImGui.TextColored(color, prefix + d.Message); + ImGui.TextColored(color, d.Message); } if (_diagnostics.Count > 0) ImGui.Spacing(); - DrawCommandList(alias); + DrawCommands(alias); } private void DrawEmptyState() @@ -84,19 +82,7 @@ private void DrawAliasHeader(AliasEntry alias) ImGuiUtil.Tooltip("Separate multiple aliases with | e.g. mew|meow|mreow"); } - private void DrawCommandList(AliasEntry alias) - { - if (_configuration.MultilineCommands) - { - DrawMultilineView(alias); - } - else - { - DrawListView(alias); - } - } - - private void DrawMultilineView(AliasEntry alias) + private void DrawCommands(AliasEntry alias) { if (_editorAliasId != alias.UniqueId) { @@ -117,48 +103,15 @@ private void DrawMultilineView(AliasEntry alias) } } - private void DrawListView(AliasEntry alias) - { - ImGui.Text("Commands:"); - ImGui.BeginChild("###commandList"); - foreach (var command in alias.Output) - { - DrawCommandRow(command); - } - - alias.Output.RemoveAll(c => c.Delete); - - if (ImGuiComponents.IconButton((int)FontAwesomeIcon.Plus, FontAwesomeIcon.Plus)) - { - alias.Output.Add(new CommandEntry()); - _configuration.Save(); - } - ImGuiUtil.Tooltip("Add Command"); - ImGui.EndChild(); - } - - private void DrawCommandRow(CommandEntry command) - { - ImGui.SetNextItemWidth(-60); - if (ImGui.InputText($"###cmd{command.UniqueId}", ref command.Command, 200)) - { - _configuration.MarkDirty(); - RefreshDiagnostics(); - } - ImGui.SameLine(); - - var canDelete = ImGui.GetIO().KeyShift && ImGui.GetIO().KeyCtrl; - ImGui.BeginDisabled(!canDelete); - if (ImGuiComponents.IconButton(command.UniqueId, FontAwesomeIcon.Trash)) command.Delete = true; - ImGui.EndDisabled(); - ImGuiUtil.Tooltip("Hold Shift + Ctrl to delete", true); - } - private void RefreshDiagnostics() { - if (_selectedAlias == null) { _diagnostics.Clear(); return; } + if (_selectedAlias == null) { _diagnostics.Clear(); _editor.ErrorMarkers.SetErrorMarkers(new()); return; } var defined = new HashSet(_configuration.UserVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); _diagnostics = AliasValidator.Validate(_selectedAlias, defined, _configuration.AllowUnsafeWaits, _configuration.GetAliases()); + var markers = new Dictionary(); + foreach (var d in _diagnostics) + if (d.Line is { } ln) markers[ln + 1] = d.Message; + _editor.ErrorMarkers.SetErrorMarkers(markers); } private static void ApplyMultiline(AliasEntry alias, string text) diff --git a/Silkstring/UI/SilkstringHighlighter.cs b/Silkstring/UI/SilkstringHighlighter.cs index cfcaf31..cb34363 100644 --- a/Silkstring/UI/SilkstringHighlighter.cs +++ b/Silkstring/UI/SilkstringHighlighter.cs @@ -40,21 +40,27 @@ public object Colorize(Span line, object? state) var indent = text.Length - text.TrimStart().Length; var body = text.TrimStart(); + if (IsHeadKeyword(body, out var kwLen)) + { + Paint(line, indent, indent + kwLen, PaletteIndex.Keyword); + if (TryBraceHead(body, out var condRel, out var condLen)) + { + var condStart = indent + condRel; + var condEnd = condStart + condLen; + if (TryParseCondition(text.Substring(condStart, condLen))) PaintContent(line, text, condStart, condEnd); + else Paint(line, condStart, condEnd, Error); + } + else if (body[kwLen..].TrimStart() != "{") + { + Paint(line, indent + kwLen, text.Length, Error); + } + return Empty; + } + var (kind, expression) = BlockInterpreter.Classify(body); var exprStart = indent + body.Length - expression.Length; switch (kind) { - case BlockKind.If: - Paint(line, indent, exprStart, PaletteIndex.Keyword); - if (TryParseCondition(expression)) PaintContent(line, text, exprStart); - else Paint(line, exprStart, text.Length, Error); - break; - - case BlockKind.Else: - case BlockKind.EndIf: - Paint(line, indent, exprStart, PaletteIndex.Keyword); - break; - case BlockKind.Set: Paint(line, indent, exprStart, PaletteIndex.Keyword); var (name, _) = BlockInterpreter.ParseSet(expression); @@ -81,6 +87,10 @@ public object Colorize(Span line, object? state) Paint(line, indent, text.Length, PaletteIndex.Comment); break; + case BlockKind.Return: + Paint(line, indent, text.Length, PaletteIndex.Keyword); + break; + default: if (body.Length > 1 && body[0] == ':' && char.IsLetter(body[1])) { @@ -106,14 +116,51 @@ public object Colorize(Span line, object? state) private static bool TryParseCondition(string expression) => Condition.TryParse(expression, out _, out _); - private static void PaintContent(Span line, string text, int from) + private static bool TryBraceHead(string body, out int condStart, out int condLen) + { + condStart = 0; condLen = 0; + string kw; + if (body.StartsWith("else if")) kw = "else if"; + else if (body.StartsWith("if")) kw = "if"; + else return false; + var trimmed = body[kw.Length..].TrimStart(); + if (trimmed.Length == 0 || trimmed[0] != '(') return false; + var close = MatchParen(trimmed); + if (close < 0 || trimmed[(close + 1)..].Trim() != "{") return false; + condStart = body.Length - trimmed.Length + 1; + condLen = close - 1; + return true; + } + + private static bool IsHeadKeyword(string body, out int kwLen) + { + kwLen = 0; + if (body.StartsWith("else if")) { if (body[7..].TrimStart().StartsWith('(')) { kwLen = 7; return true; } return false; } + if (body.StartsWith("if")) { if (body[2..].TrimStart().StartsWith('(')) { kwLen = 2; return true; } return false; } + if (body.StartsWith("else")) { if (body[4..].TrimStart().StartsWith('{')) { kwLen = 4; return true; } return false; } + return false; + } + + private static int MatchParen(string s) + { + var depth = 0; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '(') depth++; + else if (s[i] == ')' && --depth == 0) return i; + } + return -1; + } + + private static void PaintContent(Span line, string text, int from) => PaintContent(line, text, from, line.Length); + + private static void PaintContent(Span line, string text, int from, int to) { if (from >= text.Length) return; - foreach (Match m in StringRegex.Matches(text, from)) - Paint(line, m.Index, m.Index + m.Length, PaletteIndex.String); + foreach (Match m in StringRegex.Matches(text, from)) Paint(line, m.Index, m.Index + m.Length, PaletteIndex.String); PaintFlags(line, text, from); PaintTokens(line, text, from); - PaintMalformedBraces(line, from); + PaintMalformedBraces(line, from, to); } private static void PaintFlags(Span line, string text, int from) @@ -123,9 +170,9 @@ private static void PaintFlags(Span line, string text, int from) Paint(line, m.Index, m.Index + m.Length, Flag); } - private static void PaintMalformedBraces(Span line, int from) + private static void PaintMalformedBraces(Span line, int from, int to) { - for (var i = from; i < line.Length; i++) + for (var i = from; i < to; i++) { if (line[i].Char != '{' || line[i].ColorIndex != PaletteIndex.Default) continue; var end = i + 1; @@ -167,6 +214,7 @@ public static void ApplyPalette(TextEditor editor) r.SetColor(PaletteIndex.LineNumber, U32(Palette.LineNumber)); r.SetColor(PaletteIndex.Preprocessor, U32(Palette.Flag)); r.SetColor(PaletteIndex.Comment, U32(Palette.Comment)); + r.SetColor(PaletteIndex.ErrorMarker, U32(new Vector4(Palette.Error.X, Palette.Error.Y, Palette.Error.Z, 0.25f))); } private static uint U32(Vector4 c) => ImGui.ColorConvertFloat4ToU32(c); diff --git a/Silkstring/Windows/ConfigWindow.cs b/Silkstring/Windows/ConfigWindow.cs index 3c63b8e..1cd2543 100644 --- a/Silkstring/Windows/ConfigWindow.cs +++ b/Silkstring/Windows/ConfigWindow.cs @@ -69,13 +69,6 @@ public override void Draw() configuration.Save(); } - var multiline = configuration.MultilineCommands; - if (ImGui.Checkbox("Multiline command entry", ref multiline)) - { - configuration.MultilineCommands = multiline; - configuration.Save(); - } - var showLineNumbers = configuration.ShowLineNumbers; if (ImGui.Checkbox("Line numbers in the editor", ref showLineNumbers)) { diff --git a/Silkstring/Windows/HelpWindow.cs b/Silkstring/Windows/HelpWindow.cs index 605266f..c776cc1 100644 --- a/Silkstring/Windows/HelpWindow.cs +++ b/Silkstring/Windows/HelpWindow.cs @@ -179,7 +179,7 @@ private void DrawCommandsHelp() ImGui.TextColored(Palette.Heading, "Conditionals"); ImGui.Separator(); ImGui.Spacing(); - ImGui.TextWrapped("Run commands only when a condition is true with :if / :else / :endif blocks, pause with :wait, and hold until something happens with :until. See the Conditionals tab."); + ImGui.TextWrapped("Run commands only when a condition is true with if / else blocks, pause with :wait, hold until something happens with :until, and stop early with :return. See the Conditionals tab."); ImGui.Spacing(); ImGui.TextColored(Palette.Heading, "Macros"); @@ -330,20 +330,22 @@ private void DrawConditionalsHelp() ImGui.TextColored(Palette.Heading, "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.TextWrapped("Conditionals let an alias run some commands only when a condition is true. They use braces: everything inside the braces after if (...) 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.TextDisabled("if ({hpp} < 50) {"); + ImGui.TextDisabled(" /ac Cure"); + ImGui.TextDisabled("}"); + ImGui.TextDisabled("else {"); + ImGui.TextDisabled(" /say all good"); + ImGui.TextDisabled("}"); 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.BulletText("Add an else { } block for the other case."); + ImGui.BulletText("Chain checks with else if (...), which runs the first branch that is true."); + ImGui.BulletText("Blocks can be nested. Indent with Tab and remove indentation with Shift + Tab."); + ImGui.BulletText("A :return line stops the rest of the alias, handy as an early exit."); ImGui.Spacing(); ImGui.TextColored(Palette.Heading, "Conditions"); @@ -352,9 +354,9 @@ private void DrawConditionalsHelp() 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.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."); @@ -362,7 +364,7 @@ private void DrawConditionalsHelp() 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.TextDisabled("if ({0} == on) {"); ImGui.Unindent(); ImGui.Spacing(); @@ -406,7 +408,7 @@ private void DrawEditorHelp() ImGui.TableSetupColumn("Example", ImGuiTableColumnFlags.WidthFixed, 200); ImGui.TableSetupColumn("Meaning", ImGuiTableColumnFlags.WidthStretch); DrawColorRow(Palette.Command, "Command", "/emote waves", "A line that runs as a game command"); - DrawColorRow(Palette.Keyword, "Keyword", ":if :until :wait", "Control words that shape how an alias runs"); + DrawColorRow(Palette.Keyword, "Keyword", "if else :wait", "Control words that shape how an alias runs"); DrawColorRow(Palette.Variable, "Variable", "{character}", "A value filled in when the alias runs"); DrawColorRow(Palette.Parameter, "Parameter", "{0} {1..}", "An argument typed after the trigger"); DrawColorRow(Palette.String, "Quoted text", "\"Jane Doe\"", "A quoted value kept as one piece"); diff --git a/Tests/AliasValidatorTests.cs b/Tests/AliasValidatorTests.cs index 6613c1f..63c2e2c 100644 --- a/Tests/AliasValidatorTests.cs +++ b/Tests/AliasValidatorTests.cs @@ -46,15 +46,13 @@ public void NonSlashLineIsNotADependency() Assert.Empty(AliasValidator.FindCycle(a, new[] { a })); } - [Fact] public void ValidIf() => Assert.Empty(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/echo", ":endif"))); - [Fact] public void ValidIfElse() => Assert.Empty(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/a", ":else", "/b", ":endif"))); - [Fact] public void NestedValid() => Assert.Empty(AliasValidator.ValidateBlocks(Alias("a", ":if {a} == 1", ":if {b} == 2", "/c", ":endif", ":endif"))); - [Fact] public void NoBlocks() => Assert.Empty(AliasValidator.ValidateBlocks(Alias("a", "/say hi"))); - [Fact] public void Unclosed() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} < 50", "/a"))); - [Fact] public void OrphanElse() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", ":else"))); - [Fact] public void OrphanEndIf() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", ":endif"))); - [Fact] public void DuplicateElse() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", ":if {a} == 1", ":else", ":else", ":endif"))); - [Fact] public void BadExpression() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} <=", ":endif"))); + [Fact] public void ValidBraceBlock() => Assert.Empty(AliasValidator.ValidateBlocks(Alias("a", "if (1 == 1) {", "/echo", "}"))); + [Fact] public void UnclosedBraceBlock() => Assert.NotEmpty(AliasValidator.ValidateBlocks(Alias("a", "if (1 == 1) {", "/echo"))); + + [Fact] public void TriggerMissing() => Assert.NotEmpty(AliasValidator.ValidateTrigger(Alias(""))); + [Fact] public void TriggerValid() => Assert.Empty(AliasValidator.ValidateTrigger(Alias("greet"))); + [Fact] public void TriggerWithSpace() => Assert.NotEmpty(AliasValidator.ValidateTrigger(Alias("hello world"))); + [Fact] public void TriggerReserved() => Assert.NotEmpty(AliasValidator.ValidateTrigger(Alias("silkstring"))); [Fact] public void SetKnown() => Assert.Empty(AliasValidator.ValidateSets(Alias("a", ":set foo bar"), Defined("foo"))); [Fact] public void SetCaseInsensitive() => Assert.Empty(AliasValidator.ValidateSets(Alias("a", ":set FOO bar"), Defined("foo"))); diff --git a/Tests/BlockInterpreterTests.cs b/Tests/BlockInterpreterTests.cs index 072feea..5f6422d 100644 --- a/Tests/BlockInterpreterTests.cs +++ b/Tests/BlockInterpreterTests.cs @@ -3,18 +3,16 @@ 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")] [InlineData(":set foo bar", "Set", "foo bar")] [InlineData(":SET foo bar", "Set", "foo bar")] - [InlineData(":set", "Command", ":set")] + [InlineData(":set", "Set", "")] [InlineData(":wait 2", "Wait", "2")] [InlineData(":WAIT 1.5", "Wait", "1.5")] - [InlineData(":wait", "Command", ":wait")] + [InlineData(":wait", "Wait", "")] + [InlineData(":until {x}", "Until", "{x}")] + [InlineData(":return", "Return", "")] public void Classifies(string line, string kind, string expression) { var (k, e) = BlockInterpreter.Classify(line); @@ -49,83 +47,4 @@ public void ParsesDuration(string text, bool expected, int milliseconds) Assert.Equal(expected, BlockInterpreter.TryParseDuration(text, out var ms)); Assert.Equal(milliseconds, ms); } - - [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/BlockParserTests.cs b/Tests/BlockParserTests.cs new file mode 100644 index 0000000..0f21e5a --- /dev/null +++ b/Tests/BlockParserTests.cs @@ -0,0 +1,138 @@ +using Silkstring.Services; +using Silkstring.Services.Conditions; + +public class BlockParserTests +{ + private static (IReadOnlyList Nodes, IReadOnlyList Diags) Parse(params string[] lines) + => BlockParser.Parse(lines); + + private static string Line(BlockNode node) => Assert.IsType(node).Text; + + [Fact] + public void SingleIf() + { + var (nodes, diags) = Parse("if (a == 1) {", "/x", "}"); + Assert.Empty(diags); + var ifn = Assert.IsType(Assert.Single(nodes)); + var branch = Assert.Single(ifn.Branches); + Assert.Equal("a == 1", branch.Condition); + Assert.Equal("/x", Line(Assert.Single(branch.Body))); + } + + [Fact] + public void IfElse() + { + var (nodes, diags) = Parse("if (a) {", "/x", "}", "else {", "/y", "}"); + Assert.Empty(diags); + var ifn = Assert.IsType(Assert.Single(nodes)); + Assert.Equal(2, ifn.Branches.Count); + Assert.Equal("a", ifn.Branches[0].Condition); + Assert.Null(ifn.Branches[1].Condition); + Assert.Equal("/x", Line(Assert.Single(ifn.Branches[0].Body))); + Assert.Equal("/y", Line(Assert.Single(ifn.Branches[1].Body))); + } + + [Fact] + public void IfElseIfElse() + { + var (nodes, diags) = Parse("if (a) {", "/a", "}", "else if (b) {", "/b", "}", "else {", "/c", "}"); + Assert.Empty(diags); + var ifn = Assert.IsType(Assert.Single(nodes)); + Assert.Equal(3, ifn.Branches.Count); + Assert.Equal("a", ifn.Branches[0].Condition); + Assert.Equal("b", ifn.Branches[1].Condition); + Assert.Null(ifn.Branches[2].Condition); + } + + [Fact] + public void NestedIf() + { + var (nodes, diags) = Parse("if (a) {", "/x", "if (b) {", "/y", "}", "}"); + Assert.Empty(diags); + var outer = Assert.IsType(Assert.Single(nodes)); + var body = outer.Branches[0].Body; + Assert.Equal(2, body.Count); + Assert.Equal("/x", Line(body[0])); + var inner = Assert.IsType(body[1]); + Assert.Equal("b", inner.Branches[0].Condition); + Assert.Equal("/y", Line(Assert.Single(inner.Branches[0].Body))); + } + + [Fact] + public void LeafLinesInOrder() + { + var (nodes, diags) = Parse("/a", ":wait 2", "hello there", ":set x 1"); + Assert.Empty(diags); + Assert.Collection(nodes, + n => Assert.Equal("/a", Line(n)), + n => Assert.Equal(":wait 2", Line(n)), + n => Assert.Equal("hello there", Line(n)), + n => Assert.Equal(":set x 1", Line(n))); + } + + [Fact] + public void BlanksAndCommentsSkipped() + { + var (nodes, diags) = Parse("if (a) {", "", "# note", "/x", "}"); + Assert.Empty(diags); + var ifn = Assert.IsType(Assert.Single(nodes)); + Assert.Equal("/x", Line(Assert.Single(ifn.Branches[0].Body))); + } + + [Fact] + public void IndentationTolerated() + { + var (nodes, diags) = Parse("if (a) {", " /x", "}"); + Assert.Empty(diags); + var ifn = Assert.IsType(Assert.Single(nodes)); + Assert.Equal("/x", Line(Assert.Single(ifn.Branches[0].Body))); + } + + [Fact] + public void MissingBraceIsPlainLine() + { + var (nodes, diags) = Parse("if (a) matters"); + Assert.Empty(diags); + Assert.Equal("if (a) matters", Line(Assert.Single(nodes))); + } + + [Fact] + public void StrayClose() + { + var (_, diags) = Parse("/x", "}"); + Assert.Equal(1, Assert.Single(diags).Line); + } + + [Fact] + public void OrphanElse() + { + var (_, diags) = Parse("else {", "}"); + Assert.NotEmpty(diags); + Assert.Equal(0, diags[0].Line); + } + + [Fact] + public void OrphanElseIf() + { + var (_, diags) = Parse("else if (a) {", "}"); + Assert.NotEmpty(diags); + } + + [Fact] + public void UnclosedPointsAtOpener() + { + var (_, diags) = Parse("if (a) {", "/x"); + var d = Assert.Single(diags); + Assert.Equal(0, d.Line); + Assert.Contains("Missing", d.Message); + } + + [Fact] + public void InvalidCondition() + { + var (_, diags) = Parse("if (a <) {", "}"); + var d = Assert.Single(diags); + Assert.Equal(0, d.Line); + Assert.Contains("Invalid condition", d.Message); + } +} diff --git a/Tests/ConfigMigratorTests.cs b/Tests/ConfigMigratorTests.cs new file mode 100644 index 0000000..a76f643 --- /dev/null +++ b/Tests/ConfigMigratorTests.cs @@ -0,0 +1,54 @@ +using Silkstring.Models; +using Silkstring.Services; + +public class ConfigMigratorTests +{ + [Fact] + public void MigratesIfElseEndifToBraces() + { + var alias = new AliasEntry { Output = new() + { + new() { Command = ":if {hp} < 50" }, + new() { Command = "/a" }, + new() { Command = ":else" }, + new() { Command = "/b" }, + new() { Command = ":endif" }, + }}; + ConfigMigrator.MigrateAliasToV3(alias); + Assert.Equal( + new[] { "if ({hp} < 50) {", " /a", "}", "else {", " /b", "}" }, + alias.Output.Select(c => c.Command)); + } + + [Fact] + public void MigratesNestedIf() + { + var alias = new AliasEntry { Output = new() + { + new() { Command = ":if {a} == 1" }, + new() { Command = ":if {b} == 2" }, + new() { Command = "/c" }, + new() { Command = ":endif" }, + new() { Command = ":endif" }, + }}; + ConfigMigrator.MigrateAliasToV3(alias); + Assert.Equal( + new[] { "if ({a} == 1) {", " if ({b} == 2) {", " /c", " }", "}" }, + alias.Output.Select(c => c.Command)); + } + + [Fact] + public void LeavesNonConditionalLinesAlone() + { + var alias = new AliasEntry { Output = new() + { + new() { Command = "/say hi" }, + new() { Command = ":wait 2" }, + new() { Command = "good luck" }, + }}; + ConfigMigrator.MigrateAliasToV3(alias); + Assert.Equal( + new[] { "/say hi", ":wait 2", "good luck" }, + alias.Output.Select(c => c.Command)); + } +} diff --git a/extern/DalamudTextEdit b/extern/DalamudTextEdit index 80691f4..917cc9b 160000 --- a/extern/DalamudTextEdit +++ b/extern/DalamudTextEdit @@ -1 +1 @@ -Subproject commit 80691f4a50ced89e733d245d264404edbeb0a206 +Subproject commit 917cc9b4f5ec47d4ed98f387761755c962ad60a2