Files
SuperCleveRoidMacros/MacroErrorChecker.lua
Brues 1de455f96e Support Blizzard-style bracket groups: [a][b] Spell and [] fall-through
A clause may now carry a leading run of [group] blocks sharing one action;
groups are OR'd, first pass wins, and [] always passes. The existing
;-separated single-block form is unchanged and mixes freely.

The OR loop lives in the two single-clause evaluators, DoWithConditionals
and TestAction, which expand a multi-group clause into one-group variants
and recurse. That covers every ;-split site, /castsequence (where ; is not
a separator and the sequence is keyed by the full args), /cancelaura,
/stopmacro and the editor highlight without rewriting strings, so
action.args, the ParsedMsg/Sequences caches and highlight offsets keep
their identity.

ParseMsg scans the leading group run (quote-aware) instead of the greedy
%[(.+)%], which previously turned [a][b] into the bogus key a][b and made
such clauses fail silently on both the cast and icon paths. The flag
pattern still runs on the whole clause when there are no groups, so !Spell
is byte-identical.

TestAction reports which variant passed and TestForActiveAction stores its
conditionals, so range/usable colouring follows the passing group's @unit
rather than group 1's. /target gains ; and group support; /pfcast injects
the resolved unit into every group, not just the first. The macro checker
validates every leading block, drops the EMPTY_CONDITIONAL error, and
catches a missing action after the last group.
2026-09-10 17:17:08 -05:00

1213 lines
38 KiB
Lua

--[[
Macro Syntax Error Checker
Author: Mewtiny
License: MIT License
Validates macro syntax and reports errors to help users debug their macros
]]
local _G = _G or getfenv(0)
local CleveRoids = _G.CleveRoids or {}
-- Known valid conditionals
-- Minimal static entries for special cases not in CleveRoids.Keywords
-- The bulk of valid conditionals are auto-populated from Keywords below
local VALID_CONDITIONALS = {
-- multiscan is processed in Core.lua before Keywords loop (target resolution)
-- It's in ignoreKeywords, not Keywords, but users write it in macros
multiscan = true,
}
-- Auto-populate from CleveRoids.Keywords (all registered conditionals)
-- MacroErrorChecker.lua loads after Conditionals.lua so Keywords is populated
if CleveRoids.Keywords then
for keyword, _ in pairs(CleveRoids.Keywords) do
VALID_CONDITIONALS[keyword] = true
end
end
-- Also add user-facing entries from ignoreKeywords (multiscan already added above)
if CleveRoids.ignoreKeywords then
for keyword, _ in pairs(CleveRoids.ignoreKeywords) do
-- Skip internal metadata keys that users never type in macros
if keyword ~= "_operators" and keyword ~= "_groups" and keyword ~= "action" then
VALID_CONDITIONALS[keyword] = true
end
end
end
-- Deprecated names still valid in macros; ParseMsg rewrites them to the current
-- keyword, so they never reach Keywords/ignoreKeywords under their old name.
if CleveRoids.conditionalAliases then
for alias, _ in pairs(CleveRoids.conditionalAliases) do
VALID_CONDITIONALS[alias] = true
end
end
-- Known valid commands
local VALID_COMMANDS = {
-- Core commands NOT registered via SlashCmdList (so not auto-discoverable):
-- Blizzard builtins (/cast, /target, /run, ...) handled by FrameXML/C, and
-- /focus (a pfUI command, only discoverable when pfUI is loaded).
["/cast"] = true,
["/focus"] = true,
["/print"] = true,
["/run"] = true,
["/script"] = true,
["/target"] = true,
-- NOTE: This addon's own commands (/cast pets, /equip*, /quickheal, /cleveroid,
-- /macrocheck, ...) and third-party addon commands (/aux, /rinse, /cursive, ...)
-- are no longer hardcoded here. They self-register via SlashCmdList and are
-- picked up automatically by CleveRoids.IsRegisteredCommand (see discovery below).
--------------------------------------------------------------------------
-- Standard WoW 1.12.1 Commands
--------------------------------------------------------------------------
-- Chat
["/say"] = true,
["/s"] = true,
["/yell"] = true,
["/y"] = true,
["/shout"] = true,
["/whisper"] = true,
["/w"] = true,
["/tell"] = true,
["/reply"] = true,
["/r"] = true,
["/party"] = true,
["/p"] = true,
["/guild"] = true,
["/g"] = true,
["/officer"] = true,
["/o"] = true,
["/raid"] = true,
["/ra"] = true,
["/battleground"] = true,
["/bg"] = true,
["/emote"] = true,
["/e"] = true,
["/em"] = true,
["/me"] = true,
["/rw"] = true,
["/announce"] = true,
-- Channel
["/join"] = true,
["/leave"] = true,
["/channel"] = true,
["/chatlist"] = true,
["/chatwho"] = true,
["/chatinvite"] = true,
["/ckick"] = true,
-- Targeting
["/tar"] = true,
["/assist"] = true,
["/a"] = true,
["/targetenemy"] = true,
["/targetfriend"] = true,
["/targetnearestenemy"] = true,
["/targetnearestfriend"] = true,
["/targetlasttarget"] = true,
["/targetlastenemy"] = true,
["/targetlastfriend"] = true,
-- Group / Raid
["/invite"] = true,
["/inv"] = true,
["/uninvite"] = true,
["/kick"] = true,
["/promote"] = true,
["/leader"] = true,
["/disband"] = true,
["/trade"] = true,
["/roll"] = true,
["/random"] = true,
["/raidinfo"] = true,
["/readycheck"] = true,
["/lfg"] = true,
["/lfm"] = true,
-- Loot
["/masterloot"] = true,
["/ffa"] = true,
["/roundrobin"] = true,
["/needbeforegreed"] = true,
["/grouploot"] = true,
-- Guild
["/ginvite"] = true,
["/guildinvite"] = true,
["/gremove"] = true,
["/guildremove"] = true,
["/gpromote"] = true,
["/guildpromote"] = true,
["/gdemote"] = true,
["/guilddemote"] = true,
["/gquit"] = true,
["/guildquit"] = true,
["/gdisband"] = true,
["/guilddisband"] = true,
["/gmotd"] = true,
["/guildmotd"] = true,
["/ginfo"] = true,
["/guildinfo"] = true,
["/groster"] = true,
["/guildroster"] = true,
["/guildleader"] = true,
-- System
["/logout"] = true,
["/camp"] = true,
["/quit"] = true,
["/exit"] = true,
["/reload"] = true,
["/reloadui"] = true,
["/console"] = true,
["/macro"] = true,
["/played"] = true,
["/time"] = true,
["/who"] = true,
["/afk"] = true,
["/away"] = true,
["/dnd"] = true,
["/busy"] = true,
["/help"] = true,
["/pvp"] = true,
["/combatlog"] = true,
["/chatlog"] = true,
["/clear"] = true,
["/ignore"] = true,
["/unignore"] = true,
["/friend"] = true,
["/friends"] = true,
["/removefriend"] = true,
["/gm"] = true,
["/bug"] = true,
["/suggest"] = true,
-- Movement / Stance
["/follow"] = true,
["/f"] = true,
["/dismount"] = true,
["/cancelform"] = true,
-- Pet (standard WoW extras)
["/petabandon"] = true,
["/petstay"] = true,
-- Emotes
["/agree"] = true,
["/amaze"] = true,
["/angry"] = true,
["/apologize"] = true,
["/applaud"] = true,
["/attacktarget"] = true,
["/bark"] = true,
["/bashful"] = true,
["/beckon"] = true,
["/beg"] = true,
["/bite"] = true,
["/bleed"] = true,
["/blink"] = true,
["/blush"] = true,
["/boggle"] = true,
["/bonk"] = true,
["/bored"] = true,
["/bounce"] = true,
["/bow"] = true,
["/bravo"] = true,
["/burp"] = true,
["/bye"] = true,
["/cackle"] = true,
["/calm"] = true,
["/cat"] = true,
["/charge"] = true,
["/cheer"] = true,
["/chicken"] = true,
["/chuckle"] = true,
["/clap"] = true,
["/cold"] = true,
["/comfort"] = true,
["/commend"] = true,
["/confused"] = true,
["/congratulate"] = true,
["/congrats"] = true,
["/cough"] = true,
["/cower"] = true,
["/crack"] = true,
["/cringe"] = true,
["/cry"] = true,
["/cuddle"] = true,
["/curious"] = true,
["/curtsey"] = true,
["/dance"] = true,
["/disappointed"] = true,
["/doom"] = true,
["/drink"] = true,
["/drool"] = true,
["/duck"] = true,
["/eat"] = true,
["/embarrass"] = true,
["/encourage"] = true,
["/enemy"] = true,
["/eye"] = true,
["/fart"] = true,
["/feast"] = true,
["/fidget"] = true,
["/flap"] = true,
["/flee"] = true,
["/flex"] = true,
["/flirt"] = true,
["/flop"] = true,
["/gasp"] = true,
["/gaze"] = true,
["/giggle"] = true,
["/glad"] = true,
["/gloat"] = true,
["/glare"] = true,
["/golfclap"] = true,
["/goodbye"] = true,
["/greet"] = true,
["/grin"] = true,
["/groan"] = true,
["/grovel"] = true,
["/growl"] = true,
["/guffaw"] = true,
["/hail"] = true,
["/happy"] = true,
["/healme"] = true,
["/hello"] = true,
["/helpme"] = true,
["/hug"] = true,
["/hungry"] = true,
["/impatient"] = true,
["/incoming"] = true,
["/insult"] = true,
["/introduce"] = true,
["/jk"] = true,
["/kiss"] = true,
["/kneel"] = true,
["/laugh"] = true,
["/laydown"] = true,
["/lick"] = true,
["/listen"] = true,
["/lost"] = true,
["/love"] = true,
["/massage"] = true,
["/moan"] = true,
["/mock"] = true,
["/moo"] = true,
["/moon"] = true,
["/mourn"] = true,
["/no"] = true,
["/nod"] = true,
["/nosepick"] = true,
["/oom"] = true,
["/openfire"] = true,
["/panic"] = true,
["/pat"] = true,
["/peek"] = true,
["/peer"] = true,
["/peon"] = true,
["/pest"] = true,
["/pick"] = true,
["/pinch"] = true,
["/pity"] = true,
["/plead"] = true,
["/point"] = true,
["/poke"] = true,
["/ponder"] = true,
["/pounce"] = true,
["/praise"] = true,
["/pray"] = true,
["/purr"] = true,
["/puzzle"] = true,
["/question"] = true,
["/raise"] = true,
["/rasp"] = true,
["/ready"] = true,
["/regret"] = true,
["/roar"] = true,
["/rofl"] = true,
["/rude"] = true,
["/ruffle"] = true,
["/sad"] = true,
["/salute"] = true,
["/scared"] = true,
["/scratch"] = true,
["/sexy"] = true,
["/shake"] = true,
["/shimmy"] = true,
["/shiver"] = true,
["/shoo"] = true,
["/shrug"] = true,
["/shy"] = true,
["/sigh"] = true,
["/silly"] = true,
["/sit"] = true,
["/slap"] = true,
["/sleep"] = true,
["/smile"] = true,
["/smirk"] = true,
["/snarl"] = true,
["/snicker"] = true,
["/sniff"] = true,
["/sob"] = true,
["/soothe"] = true,
["/sorry"] = true,
["/spit"] = true,
["/stand"] = true,
["/stare"] = true,
["/stink"] = true,
["/strong"] = true,
["/surprised"] = true,
["/surrender"] = true,
["/tap"] = true,
["/tease"] = true,
["/thank"] = true,
["/thirsty"] = true,
["/threaten"] = true,
["/tickle"] = true,
["/tired"] = true,
["/train"] = true,
["/truce"] = true,
["/twiddle"] = true,
["/veto"] = true,
["/victory"] = true,
["/violin"] = true,
["/volunteer"] = true,
["/wait"] = true,
["/wave"] = true,
["/welcome"] = true,
["/whine"] = true,
["/whistle"] = true,
["/wink"] = true,
["/work"] = true,
["/yawn"] = true,
["/yes"] = true,
}
-- ============================================================================
-- Command Whitelist API (for third-party addon commands)
-- ============================================================================
local function GetWhitelistedCommands()
if not CleveRoidMacros then CleveRoidMacros = {} end
if not CleveRoidMacros.whitelistedCommands then
CleveRoidMacros.whitelistedCommands = {}
end
return CleveRoidMacros.whitelistedCommands
end
function CleveRoids.AddWhitelistedCommand(cmd)
if not cmd or cmd == "" then return end
cmd = string.lower(cmd)
-- Auto-prepend "/" if missing
if string.sub(cmd, 1, 1) ~= "/" then
cmd = "/" .. cmd
end
local whitelist = GetWhitelistedCommands()
whitelist[cmd] = true
end
function CleveRoids.RemoveWhitelistedCommand(cmd)
if not cmd or cmd == "" then return end
cmd = string.lower(cmd)
if string.sub(cmd, 1, 1) ~= "/" then
cmd = "/" .. cmd
end
local whitelist = GetWhitelistedCommands()
whitelist[cmd] = nil
end
function CleveRoids.IsWhitelistedCommand(cmd)
if not cmd or cmd == "" then return false end
cmd = string.lower(cmd)
if string.sub(cmd, 1, 1) ~= "/" then
cmd = "/" .. cmd
end
local whitelist = GetWhitelistedCommands()
return whitelist[cmd] == true
end
function CleveRoids.GetWhitelistedCommandsList()
local whitelist = GetWhitelistedCommands()
local list = {}
for cmd, _ in pairs(whitelist) do
table.insert(list, cmd)
end
table.sort(list)
return list
end
-- ============================================================================
-- Registered Slash Command Discovery (auto-covers addon & client commands)
-- ============================================================================
-- Builtin chat/social/emote commands and macro pseudo-commands are NOT in
-- SlashCmdList (FrameXML handles them internally), so VALID_COMMANDS above
-- still covers those. This scan covers everything registered the addon way:
-- any "/foo" exposed via SLASH_<KEY>N globals, including this addon's own
-- commands, third-party addons (/aux, /rinse, ...), and client commands like
-- /dump. Rebuilt on PLAYER_ENTERING_WORLD so addons that load or lazily
-- register after us are still picked up.
local registeredCommands = nil
local function BuildRegisteredCommandCache()
local cache = {}
if type(SlashCmdList) == "table" then
for key, _ in pairs(SlashCmdList) do
-- Each handler key maps to one or more SLASH_<KEY>N "/string" globals
local i = 1
while true do
local slash = _G["SLASH_" .. key .. i]
if not slash then break end
if type(slash) == "string" and slash ~= "" then
cache[string.lower(slash)] = true
end
i = i + 1
end
end
end
registeredCommands = cache
return cache
end
function CleveRoids.IsRegisteredCommand(cmd)
if not cmd or cmd == "" then return false end
local cache = registeredCommands or BuildRegisteredCommandCache()
return cache[string.lower(cmd)] == true
end
-- Invalidate the cache when the world loads so post-load registrations count.
local registeredCmdFrame = CreateFrame("Frame")
registeredCmdFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
registeredCmdFrame:SetScript("OnEvent", function()
BuildRegisteredCommandCache()
end)
-- Commands that can have conditionals without actions
-- e.g., /petattack [harm] or /target [exists,hp:<=20]
local COMMANDS_NO_ACTION_NEEDED = {
["/petattack"] = true,
["/petfollow"] = true,
["/petwait"] = true,
["/petpassive"] = true,
["/petaggressive"] = true,
["/petdefensive"] = true,
["/target"] = true,
["/cleartarget"] = true,
["/focus"] = true,
["/follow"] = true,
["/f"] = true,
["/dismount"] = true,
["/cancelform"] = true,
["/startattack"] = true,
["/stopattack"] = true,
["/stopcasting"] = true,
["/stopchanneling"] = true,
["/unqueue"] = true,
["/retarget"] = true,
["/stopmacro"] = true,
["/skipmacro"] = true,
["/unshift"] = true,
["/firstaction"] = true,
["/nofirstaction"] = true,
["/clearequipqueue"] = true,
["/equipqueuestatus"] = true,
["/quickheal"] = true,
["/qh"] = true,
["/rl"] = true,
["/combotrack"] = true,
["/cleveroid"] = true,
["/cleveroidmacros"] = true,
["/print"] = true,
["/run"] = true,
["/script"] = true,
}
-- Safe string operations to prevent addon errors from malformed macros
local function safeStringSub(str, startPos, endPos)
if not str or type(str) ~= "string" then return "" end
local len = string.len(str)
if startPos < 1 then startPos = 1 end
if endPos and endPos > len then endPos = len end
return string.sub(str, startPos, endPos)
end
local function safeStringFind(str, pattern, init)
if not str or type(str) ~= "string" then return nil end
local success, result1, result2, result3 = pcall(string.find, str, pattern, init)
if success then
return result1, result2, result3
end
return nil
end
local function safeStringLen(str)
if not str or type(str) ~= "string" then return 0 end
return string.len(str)
end
local function safeTrim(str)
if not str or type(str) ~= "string" then return "" end
local success, result = pcall(CleveRoids.Trim, str)
if success then return result end
return str
end
-- Error types
local ERROR_TYPES = {
INVALID_CONDITIONAL = "Invalid conditional",
MISMATCHED_BRACKETS = "Mismatched brackets",
INVALID_OPERATOR = "Invalid operator",
MISSING_ARGUMENT = "Missing argument",
INVALID_COMMAND = "Unknown command",
INVALID_TARGET = "Invalid target format",
MALFORMED_QUOTES = "Malformed quotes",
EMPTY_ACTION = "Empty action",
INVALID_SYNTAX = "Invalid syntax",
}
CleveRoids.MacroErrors = {}
-- Check if a string has balanced brackets
local function checkBrackets(text)
if not text or type(text) ~= "string" then return true end
local openCount = 0
local inQuotes = false
local len = safeStringLen(text)
for i = 1, len do
local char = safeStringSub(text, i, i)
if not char or char == "" then break end
if char == '"' then
inQuotes = not inQuotes
elseif not inQuotes then
if char == "[" then
openCount = openCount + 1
elseif char == "]" then
openCount = openCount - 1
if openCount < 0 then
return false, "Extra closing bracket"
end
end
end
end
if openCount > 0 then
return false, "Missing closing bracket"
elseif openCount < 0 then
return false, "Extra closing bracket"
end
return true
end
-- Check if quotes are balanced
local function checkQuotes(text)
if not text or type(text) ~= "string" then return true end
local quoteCount = 0
local escaped = false
local len = safeStringLen(text)
for i = 1, len do
local char = safeStringSub(text, i, i)
if not char or char == "" then break end
if escaped then
escaped = false
elseif char == "\\" then
escaped = true
elseif char == '"' then
quoteCount = quoteCount + 1
end
end
local quotient = math.floor(quoteCount / 2)
if (quoteCount - (quotient * 2)) ~= 0 then
return false, "Unmatched quotes"
end
return true
end
-- Validate conditional syntax
local function validateConditional(conditional, args, action)
local errors = {}
if not conditional or conditional == "" then
return errors
end
-- Check if conditional is valid
local baseCond = string.lower(safeTrim(conditional))
if not VALID_CONDITIONALS[baseCond] then
table.insert(errors, {
type = ERROR_TYPES.INVALID_CONDITIONAL,
conditional = conditional,
message = "Unknown conditional: " .. conditional
})
end
-- Check for required arguments
local needsArgs = {
combo = true,
hp = true, myhp = true, rawhp = true, myrawhp = true,
power = true, mypower = true, rawpower = true, myrawpower = true,
mana = true, mymana = true, rage = true, myrage = true,
energy = true, myenergy = true,
hplost = true, myhplost = true,
powerlost = true, mypowerlost = true,
stat = true,
talent = true,
actionbar = true,
button = true,
form = true, stance = true,
level = true, mylevel = true,
myspellhaste = true,
distance = true, nodistance = true,
swingtimer = true, stimer = true,
rangedtimer = true, rtimer = true,
threat = true,
ttk = true, tte = true,
spellcasttime = true, nospellcasttime = true,
}
if needsArgs[baseCond] and (not args or args == "") and (not action or action == "") then
table.insert(errors, {
type = ERROR_TYPES.MISSING_ARGUMENT,
conditional = conditional,
message = conditional .. " requires an argument"
})
end
-- Check operator syntax for numeric comparisons
if args and type(args) == "string" then
local hasHpOrPower = safeStringFind(baseCond, "hp") or safeStringFind(baseCond, "power") or
safeStringFind(baseCond, "mana") or safeStringFind(baseCond, "energy") or
safeStringFind(baseCond, "rage") or safeStringFind(baseCond, "haste") or
safeStringFind(baseCond, "combo") or baseCond == "stat"
if hasHpOrPower then
local hasOperator = safeStringFind(args, "[<>=~]+")
if args ~= "" and not hasOperator and not safeStringFind(args, "^%d+$") then
-- Might be missing operator
if not safeStringFind(args, "[a-zA-Z]") then
table.insert(errors, {
type = ERROR_TYPES.INVALID_OPERATOR,
conditional = conditional,
message = conditional .. " may need an operator (>, <, =, >=, <=)"
})
end
end
end
end
return errors
end
-- Parse and validate a single line
local function validateLine(line, lineNum)
local errors = {}
-- Skip comments and empty lines
if not line or type(line) ~= "string" then
return errors
end
line = safeTrim(line)
if line == "" or safeStringSub(line, 1, 2) == "--" then
return errors
end
-- Check for # directives - only #showtooltip is valid
if safeStringSub(line, 1, 1) == "#" then
local _, _, directive = safeStringFind(line, "^(#[a-z]+)")
if directive then
local lowerDirective = string.lower(directive)
if lowerDirective ~= "#showtooltip" then
table.insert(errors, {
type = ERROR_TYPES.INVALID_COMMAND,
line = lineNum,
command = directive,
message = "Unknown directive: " .. directive .. " (did you mean #showtooltip?)"
})
end
end
-- Valid #showtooltip or other # lines are skipped from further validation
return errors
end
-- Wrap the entire validation in pcall to catch any unexpected errors
local success, result = pcall(function()
local localErrors = {}
-- Check for valid command
local _, _, cmd = safeStringFind(line, "^(/[a-z]+%d*)")
if cmd then
local lowerCmd = string.lower(cmd)
if not VALID_COMMANDS[lowerCmd]
and not CleveRoids.IsRegisteredCommand(lowerCmd)
and not CleveRoids.IsWhitelistedCommand(lowerCmd) then
table.insert(localErrors, {
type = ERROR_TYPES.INVALID_COMMAND,
line = lineNum,
command = cmd,
message = "Unknown command: " .. cmd
})
end
end
-- Check brackets
local bracketsOk, bracketError = checkBrackets(line)
if not bracketsOk then
table.insert(localErrors, {
type = ERROR_TYPES.MISMATCHED_BRACKETS,
line = lineNum,
message = bracketError or "Bracket mismatch"
})
end
-- Check quotes
local quotesOk, quoteError = checkQuotes(line)
if not quotesOk then
table.insert(localErrors, {
type = ERROR_TYPES.MALFORMED_QUOTES,
line = lineNum,
message = quoteError or "Quote mismatch"
})
end
-- Check for semicolons inside brackets (must check full line before semicolon split)
-- e.g., [nomybuff;battleshout] is wrong - semicolons separate actions, not conditionals
if bracketsOk then
local depth = 0
local inQuotes = false
local lineLen = safeStringLen(line)
for i = 1, lineLen do
local ch = safeStringSub(line, i, i)
if ch == '"' then
inQuotes = not inQuotes
elseif not inQuotes then
if ch == "[" then
depth = depth + 1
elseif ch == "]" then
depth = depth - 1
elseif ch == ";" and depth > 0 then
table.insert(localErrors, {
type = ERROR_TYPES.INVALID_SYNTAX,
line = lineNum,
message = "';' inside brackets is invalid - use spaces to separate conditionals, ':' for arguments"
})
break
end
end
end
end
-- Check for missing semicolons between bracket groups
-- e.g., /cast [cond]Backstab[cond2]Garrote should use ; between actions
if bracketsOk then
local pos = 1
while true do
local closePos = safeStringFind(line, "%]", pos)
if not closePos then break end
local nextOpenPos = safeStringFind(line, "%[", closePos + 1)
if not nextOpenPos then break end
local between = safeStringSub(line, closePos + 1, nextOpenPos - 1)
if not safeStringFind(between, ";") then
local trimmed = safeTrim(between)
if trimmed ~= "" then
table.insert(localErrors, {
type = ERROR_TYPES.INVALID_SYNTAX,
line = lineNum,
message = "Missing ';' before '[' - use '" .. trimmed .. ";' to separate actions"
})
end
end
pos = nextOpenPos + 1
end
end
-- Split by semicolons to handle multiple actions per line
local actions = CleveRoids.splitStringIgnoringQuotes(line, ";")
if not actions then
return localErrors
end
for _, actionPart in ipairs(actions) do
actionPart = safeTrim(actionPart)
if actionPart ~= "" and safeStringSub(actionPart, 1, 1) ~= "/" then
actionPart = "/" .. actionPart -- Add leading slash if missing after split
end
-- Parse the leading run of [conditional] blocks, if any. `[a][b] X` is
-- Blizzard-style OR chaining: every block is validated, and the action
-- is what follows the last one.
local blocks = {}
local condEnd = nil
local condStart = safeStringFind(actionPart, "%[")
local len = safeStringLen(actionPart)
while condStart do
-- Find matching closing bracket
local depth = 0
local inQuotes = false
local closePos = nil
for i = condStart, len do
local char = safeStringSub(actionPart, i, i)
if not char or char == "" then break end
if char == '"' then
inQuotes = not inQuotes
elseif not inQuotes then
if char == "[" then
depth = depth + 1
elseif char == "]" then
depth = depth - 1
if depth == 0 then
closePos = i
break
end
end
end
end
if not closePos then break end
table.insert(blocks, safeStringSub(actionPart, condStart + 1, closePos - 1))
condEnd = closePos
-- Another block directly after this one (whitespace allowed)?
local _, wsEnd = safeStringFind(actionPart, "^%s*", closePos + 1)
local nextPos = (wsEnd or closePos) + 1
if safeStringSub(actionPart, nextPos, nextPos) == "[" then
condStart = nextPos
else
condStart = nil
end
end
for _, conditionBlock in ipairs(blocks) do
-- `[]` is the always-true group; nothing to validate
if safeTrim(conditionBlock) ~= "" then
-- Check for invalid @ target syntax
local _, _, target = safeStringFind(conditionBlock, "(@[^%s,]+)")
if target and not safeStringFind(target, "^@[a-z]+%d*") then
table.insert(localErrors, {
type = ERROR_TYPES.INVALID_TARGET,
line = lineNum,
message = "Invalid target: " .. target
})
end
-- Parse individual conditionals
local condGroups = CleveRoids.splitStringIgnoringQuotes(conditionBlock, {",", " "})
if condGroups then
for _, condGroup in condGroups do
if condGroup ~= "" and condGroup ~= target then
local parts = CleveRoids.splitStringIgnoringQuotes(condGroup, ":")
if parts then
local cond = string.lower(safeTrim(parts[1] or ""))
local args = safeTrim(parts[2] or "")
-- Check for missing ':' between conditional and its argument
-- e.g., "combo>0" should be "combo:>0", "hp50" should be "hp:50"
if cond ~= "" and (not parts[2] or args == "") and not safeStringFind(cond, "^@") then
local _, _, condPrefix, valueSuffix = safeStringFind(cond, "^([a-z]+)([<>=~%d].+)$")
if condPrefix and valueSuffix and VALID_CONDITIONALS[condPrefix] then
table.insert(localErrors, {
type = ERROR_TYPES.INVALID_SYNTAX,
line = lineNum,
message = "Missing ':' after " .. condPrefix .. " (use " .. condPrefix .. ":" .. valueSuffix .. ")"
})
cond = nil -- Skip further validation, we identified the issue
end
end
if cond and cond ~= "" then
-- Validate the conditional
local condErrors = validateConditional(cond, args, nil)
for _, err in condErrors do
err.line = lineNum
table.insert(localErrors, err)
end
end
end
end
end
end
end
end
if condEnd then
-- Check for action after conditionals
-- Extract the command from this action part
local _, _, cmdFromAction = safeStringFind(actionPart, "^(/[a-z]+%d*)")
local needsAction = true
if cmdFromAction then
local lowerCmdFromAction = string.lower(cmdFromAction)
if COMMANDS_NO_ACTION_NEEDED[lowerCmdFromAction] then
needsAction = false
end
end
if needsAction then
local afterCond = safeStringSub(actionPart, (condEnd or 0) + 1)
local _, _, action = safeStringFind(afterCond, "^%s*[!~?]?(.+)")
if not action or safeTrim(action) == "" then
table.insert(localErrors, {
type = ERROR_TYPES.EMPTY_ACTION,
line = lineNum,
message = "Conditional has no action"
})
end
end
end
end
return localErrors
end)
if success and result then
return result
elseif not success then
-- An error occurred during validation
return {{
type = "VALIDATION_ERROR",
line = lineNum,
message = "Internal error validating line: " .. tostring(result)
}}
end
return errors
end
-- Validate an entire macro
function CleveRoids.ValidateMacro(macroName)
-- Wrap entire function in pcall for safety
local success, result = pcall(function()
local errors = {}
if not macroName or macroName == "" then
return {{
type = "ERROR",
message = "No macro name provided"
}}
end
local macroID = GetMacroIndexByName(macroName)
if not macroID or macroID == 0 then
return {{
type = "ERROR",
message = "Macro not found: " .. tostring(macroName)
}}
end
local name, texture, body = GetMacroInfo(macroID)
if not body or body == "" then
return {{
type = "ERROR",
message = "Macro is empty"
}}
end
-- Split into lines
local lines = CleveRoids.splitString(body, "\n")
if not lines then
return {{
type = "ERROR",
message = "Failed to parse macro body"
}}
end
for lineNum, line in ipairs(lines) do
local lineErrors = validateLine(line, lineNum)
if lineErrors then
for _, err in lineErrors do
table.insert(errors, err)
end
end
end
return errors
end)
if success then
return result
else
-- Return a safe error message if validation itself fails
return {{
type = "CRITICAL_ERROR",
message = "Critical error validating macro: " .. tostring(result)
}}
end
end
-- Validate raw macro body text (for live editing in the macro frame)
-- bodyText: The raw text from the EditBox (not yet saved)
-- Returns: Array of error tables with .type, .line, .message fields
function CleveRoids.ValidateMacroBody(bodyText)
local success, result = pcall(function()
local errors = {}
if not bodyText or bodyText == "" then
return errors
end
local lines = CleveRoids.splitString(bodyText, "\n")
if not lines then
return errors
end
for lineNum, line in ipairs(lines) do
local lineErrors = validateLine(line, lineNum)
if lineErrors then
for _, err in lineErrors do
table.insert(errors, err)
end
end
end
return errors
end)
if success then
return result
else
return {{
type = "CRITICAL_ERROR",
message = "Critical error validating macro body: " .. tostring(result)
}}
end
end
-- Validate all macros
function CleveRoids.ValidateAllMacros()
local results = {}
local totalErrors = 0
-- Account-wide macros are indexed from 1 up to GetNumMacros().
-- Character-specific macros occupy the slots immediately following the account-wide ones.
-- In Classic clients, the macro UI has 18 General (Account) slots and 18 Character-Specific slots.
local numAccountMacros = GetNumMacros()
-- The WoW API GetMacroInfo(index) supports indexing up to 36 (1-18 for General, 19-36 for Character)
-- in Classic clients, even though the total is GetNumMacros() + GetNumCharacterMacros() in Retail.
-- To ensure we check all 36 possible slots:
local totalSlots = 36
for i = 1, totalSlots do
local nameSuccess, name = pcall(GetMacroInfo, i)
-- Check if GetMacroInfo returned a name (i.e., the slot is used)
if nameSuccess and name and name ~= "" then
-- Wrap each macro validation in pcall so one bad macro doesn't stop all validation
local errorsSuccess, errors = pcall(CleveRoids.ValidateMacro, name)
if errorsSuccess and errors and table.getn(errors) > 0 then
results[name] = errors
totalErrors = totalErrors + table.getn(errors)
end
end
end
return results, totalErrors
end
-- Print errors for a macro
function CleveRoids.PrintMacroErrors(macroName)
local success, errors = pcall(CleveRoids.ValidateMacro, macroName)
if not success then
CleveRoids.Print("|cffff0000Error|r: Failed to validate macro '" .. tostring(macroName) .. "': " .. tostring(errors))
return
end
if not errors or table.getn(errors) == 0 then
CleveRoids.Print("|cff00ff00[OK]|r Macro '" .. macroName .. "' has no syntax errors")
return
end
CleveRoids.Print("|cffff0000[ERRORS]|r Macro '" .. macroName .. "' has " .. table.getn(errors) .. " error(s):")
for _, err in errors do
if err and err.message then
local line = err.line and ("Line " .. err.line .. ": ") or ""
local msg = "|cffffaa00" .. line .. "|r" .. err.message
pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, " " .. msg)
end
end
end
-- Print all macro errors
function CleveRoids.PrintAllMacroErrors()
local success, results, totalErrors = pcall(CleveRoids.ValidateAllMacros)
if not success then
CleveRoids.Print("|cffff0000Error|r: Failed to validate macros: " .. tostring(results))
return
end
if totalErrors == 0 then
CleveRoids.Print("|cff00ff00[OK]|r All macros are error-free!")
return
end
local macroCount = 0
for _ in pairs(results) do macroCount = macroCount + 1 end
CleveRoids.Print("|cffff0000Found " .. totalErrors .. " error(s) in " .. macroCount .. " macro(s):|r")
for macroName, errors in pairs(results) do
if macroName and errors then
pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, " ")
pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, "|cffff8800" .. macroName .. "|r (" .. table.getn(errors) .. " error(s)):")
for _, err in errors do
if err and err.message then
local line = err.line and ("Line " .. err.line .. ": ") or ""
local msg = " |cffffaa00" .. line .. "|r" .. err.message
pcall(DEFAULT_CHAT_FRAME.AddMessage, DEFAULT_CHAT_FRAME, msg)
end
end
end
end
end
-- Slash command
SLASH_MACROCHECK1 = "/macrocheck"
SlashCmdList.MACROCHECK = function(msg)
if CleveRoidMacros and CleveRoidMacros.macrocheck == 0 then
CleveRoids.Print("Macro syntax checker is disabled. Enable with /cleveroid macrocheck 1")
return
end
local success, result = pcall(function()
msg = safeTrim(msg or "")
if msg == "" or msg == "all" then
CleveRoids.PrintAllMacroErrors()
else
CleveRoids.PrintMacroErrors(msg)
end
end)
if not success then
CleveRoids.Print("|cffff0000Error|r: Macro check failed: " .. tostring(result))
end
end
if not CleveRoidMacros or CleveRoidMacros.macrocheck ~= 0 then
CleveRoids.Print("Macro syntax checker loaded. Use /macrocheck [macroname] or /macrocheck all")
end