mirror of
https://github.com/brues-code/SuperCleveRoidMacros.git
synced 2026-09-16 03:38:00 +00:00
1de455f96e
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.
1213 lines
38 KiB
Lua
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
|