mirror of
https://github.com/brues-code/SuperCleveRoidMacros.git
synced 2026-09-16 03:38:00 +00:00
Merge pull request #111 from jrc13245/macro-error-checker
Macro error checker
This commit is contained in:
@@ -3068,6 +3068,27 @@ function CleveRoids.DoUse(msg)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Find an item in bags by name (ignoring equipped items)
|
||||
-- Used to prefer bag copies over swapping from paired equipped slots
|
||||
-- (e.g., dual-wielding the same weapon with /equipmh + /equipoh)
|
||||
local function FindItemInBagsByName(itemName)
|
||||
if not itemName then return nil, nil end
|
||||
local lowerName = string_lower(itemName)
|
||||
for bag = 0, 4 do
|
||||
local numSlots = GetContainerNumSlots(bag) or 0
|
||||
for slot = 1, numSlots do
|
||||
local link = GetContainerItemLink(bag, slot)
|
||||
if link then
|
||||
local _, _, name = string_find(link, "|h%[(.-)%]|h")
|
||||
if name and string_lower(name) == lowerName then
|
||||
return bag, slot
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function CleveRoids.EquipBagItem(msg, slotOrOffhand)
|
||||
if CleveRoids.equipDebugLog then
|
||||
CleveRoids.Print("|cff00ffff[EquipLog] EquipBagItem called: '" .. tostring(msg) .. "' slot=" .. tostring(slotOrOffhand) .. "|r")
|
||||
@@ -3122,7 +3143,22 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand)
|
||||
if itemInfo.inventoryID == invslot then
|
||||
return true -- Already in correct slot
|
||||
end
|
||||
-- Pick up from current slot and equip to target
|
||||
-- Before swapping from another equipped slot, check bags for another copy
|
||||
-- This handles dual-wielding the same weapon (e.g., /equipmh Scimitar + /equipoh Scimitar)
|
||||
local bagCopyBag, bagCopySlot = FindItemInBagsByName(itemInfo.name or searchTerm)
|
||||
if bagCopyBag then
|
||||
ClearCursor()
|
||||
PickupContainerItem(bagCopyBag, bagCopySlot)
|
||||
if CursorHasItem and CursorHasItem() then
|
||||
EquipCursorItem(invslot)
|
||||
ClearCursor()
|
||||
if CleveRoids.equipDebugLog then
|
||||
CleveRoids.Print("|cff00ff00[EquipLog] Equipped bag copy of '" .. tostring(msg) .. "' from bag " .. bagCopyBag .. " slot " .. bagCopySlot .. " (preferred over swap)|r")
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
-- No bag copy found - swap from equipped slot
|
||||
ClearCursor()
|
||||
PickupInventoryItem(itemInfo.inventoryID)
|
||||
if CursorHasItem and CursorHasItem() then
|
||||
@@ -3202,6 +3238,7 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand)
|
||||
-- Check if item is already equipped in the paired slot (swap case)
|
||||
-- EquipItemByName doesn't handle swapping equipped items, so we must do it manually
|
||||
-- Paired slots: trinkets (13<->14), weapons (16<->17), rings (11<->12)
|
||||
-- BUT: check bags first for another copy (e.g., dual-wielding same weapon)
|
||||
local pairedSlots = {[13] = 14, [14] = 13, [16] = 17, [17] = 16, [11] = 12, [12] = 11}
|
||||
local checkSlot = pairedSlots[invslot]
|
||||
if checkSlot then
|
||||
@@ -3209,23 +3246,45 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand)
|
||||
if link then
|
||||
local _, _, slotItemName = string_find(link, "|h%[(.-)%]|h")
|
||||
if slotItemName and string_lower(slotItemName) == string_lower(msg) then
|
||||
-- Found item in paired slot - swap it manually
|
||||
if CleveRoids.equipDebugLog then
|
||||
CleveRoids.Print("|cff00ffff[EquipLog] Swapping from slot " .. checkSlot .. " to slot " .. invslot .. "|r")
|
||||
end
|
||||
ClearCursor()
|
||||
PickupInventoryItem(checkSlot)
|
||||
if CursorHasItem and CursorHasItem() then
|
||||
EquipCursorItem(invslot)
|
||||
ClearCursor()
|
||||
if CleveRoids.Items then
|
||||
CleveRoids.Items[msg] = nil
|
||||
CleveRoids.Items[string_lower(msg)] = nil
|
||||
-- Item found in paired slot - but prefer a bag copy if one exists
|
||||
local bagCopyBag, bagCopySlot = FindItemInBagsByName(msg)
|
||||
if bagCopyBag then
|
||||
-- Bag copy available - equip from bag instead of swapping
|
||||
if CleveRoids.equipDebugLog then
|
||||
CleveRoids.Print("|cff00ffff[EquipLog] Found bag copy, equipping from bag " .. bagCopyBag .. " slot " .. bagCopySlot .. " instead of swapping from slot " .. checkSlot .. "|r")
|
||||
end
|
||||
InvalidateDisplacedItem()
|
||||
return true
|
||||
ClearCursor()
|
||||
PickupContainerItem(bagCopyBag, bagCopySlot)
|
||||
if CursorHasItem and CursorHasItem() then
|
||||
EquipCursorItem(invslot)
|
||||
ClearCursor()
|
||||
if CleveRoids.Items then
|
||||
CleveRoids.Items[msg] = nil
|
||||
CleveRoids.Items[string_lower(msg)] = nil
|
||||
end
|
||||
InvalidateDisplacedItem()
|
||||
return true
|
||||
end
|
||||
ClearCursor()
|
||||
else
|
||||
-- No bag copy - swap from paired slot
|
||||
if CleveRoids.equipDebugLog then
|
||||
CleveRoids.Print("|cff00ffff[EquipLog] Swapping from slot " .. checkSlot .. " to slot " .. invslot .. "|r")
|
||||
end
|
||||
ClearCursor()
|
||||
PickupInventoryItem(checkSlot)
|
||||
if CursorHasItem and CursorHasItem() then
|
||||
EquipCursorItem(invslot)
|
||||
ClearCursor()
|
||||
if CleveRoids.Items then
|
||||
CleveRoids.Items[msg] = nil
|
||||
CleveRoids.Items[string_lower(msg)] = nil
|
||||
end
|
||||
InvalidateDisplacedItem()
|
||||
return true
|
||||
end
|
||||
ClearCursor()
|
||||
end
|
||||
ClearCursor()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
--[[
|
||||
Macro Error UI - Real-time visual error feedback in the macro editor
|
||||
Author: Mewtiny
|
||||
License: MIT License
|
||||
|
||||
Provides:
|
||||
- Error summary panel anchored below MacroFrame
|
||||
- Red semi-transparent backdrop on error lines in the EditBox
|
||||
]]
|
||||
local _G = _G or getfenv(0)
|
||||
local CleveRoids = _G.CleveRoids or {}
|
||||
|
||||
local Extension = CleveRoids.RegisterExtension("MacroErrorUI")
|
||||
|
||||
-- Constants
|
||||
local DEBOUNCE_DELAY = 0.3
|
||||
local MAX_PANEL_HEIGHT = 120
|
||||
local MAX_DISPLAY_ERRORS = 5
|
||||
local HIGHLIGHT_POOL_SIZE = 20
|
||||
local ERROR_FONT_POOL_SIZE = 6 -- header + max errors
|
||||
|
||||
-- State
|
||||
local errorPanel = nil
|
||||
local headerText = nil
|
||||
local errorFontStrings = {}
|
||||
local lineHighlights = {}
|
||||
local lastKeystroke = 0
|
||||
local pendingValidation = false
|
||||
local lastSelectedMacro = nil
|
||||
local updateFrame = nil
|
||||
local hooked = false
|
||||
|
||||
-- Cache for current errors (avoids re-validation on every frame)
|
||||
local currentErrors = nil
|
||||
local nameHighlight = nil -- Yellow backdrop behind macro name when name has errors
|
||||
|
||||
-- ============================================================================
|
||||
-- Error Panel (Option A)
|
||||
-- ============================================================================
|
||||
|
||||
local function CreateErrorPanel()
|
||||
if errorPanel then return end
|
||||
|
||||
local panel = CreateFrame("Frame", "CleveRoidsErrorPanel", MacroFrame)
|
||||
-- Anchor to the scroll frame (text edit area) instead of the full MacroFrame,
|
||||
-- which can extend far below the visible UI on extended macro clients
|
||||
local scrollRef = MacroFrameScrollFrame or MacroFrame
|
||||
panel:SetPoint("TOPLEFT", scrollRef, "BOTTOMLEFT", 0, -27)
|
||||
panel:SetPoint("TOPRIGHT", scrollRef, "BOTTOMRIGHT", 0, -27)
|
||||
panel:SetHeight(1)
|
||||
panel:SetFrameStrata("DIALOG")
|
||||
panel:SetBackdrop({
|
||||
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
||||
tile = true, tileSize = 16, edgeSize = 12,
|
||||
insets = { left = 3, right = 3, top = 3, bottom = 3 },
|
||||
})
|
||||
panel:SetBackdropColor(0.1, 0.05, 0.05, 0.92)
|
||||
panel:SetBackdropBorderColor(0.6, 0.1, 0.1, 0.8)
|
||||
panel:Hide()
|
||||
|
||||
-- Header: "N error(s) found"
|
||||
headerText = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
||||
headerText:SetPoint("TOPLEFT", panel, "TOPLEFT", 8, -8)
|
||||
headerText:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -8, -8)
|
||||
headerText:SetTextColor(1, 0.3, 0.3, 1)
|
||||
|
||||
-- Pre-allocate error message FontStrings
|
||||
for i = 1, MAX_DISPLAY_ERRORS do
|
||||
local fs = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
|
||||
fs:SetPoint("TOPLEFT", headerText, "BOTTOMLEFT", 0, -2 - (i - 1) * 12)
|
||||
fs:SetPoint("TOPRIGHT", headerText, "BOTTOMRIGHT", 0, -2 - (i - 1) * 12)
|
||||
fs:SetJustifyH("LEFT")
|
||||
fs:Hide()
|
||||
errorFontStrings[i] = fs
|
||||
end
|
||||
|
||||
errorPanel = panel
|
||||
end
|
||||
|
||||
local function UpdateErrorPanel(errors)
|
||||
if not errorPanel then return end
|
||||
|
||||
local count = errors and table.getn(errors) or 0
|
||||
|
||||
if count == 0 then
|
||||
errorPanel:Hide()
|
||||
return
|
||||
end
|
||||
|
||||
-- Header
|
||||
if count == 1 then
|
||||
headerText:SetText("1 error found")
|
||||
else
|
||||
headerText:SetText(count .. " errors found")
|
||||
end
|
||||
|
||||
-- Populate error lines
|
||||
local displayed = 0
|
||||
for i = 1, MAX_DISPLAY_ERRORS do
|
||||
local fs = errorFontStrings[i]
|
||||
if i <= count then
|
||||
local err = errors[i]
|
||||
local linePrefix = err.line and ("Line " .. err.line .. ": ") or ""
|
||||
local msg = linePrefix .. (err.message or "Unknown error")
|
||||
-- Truncate long messages
|
||||
if string.len(msg) > 80 then
|
||||
msg = string.sub(msg, 1, 77) .. "..."
|
||||
end
|
||||
-- Name errors in yellow, syntax errors in red
|
||||
local color = err.type == "NAME_ERROR" and "|cffffcc60" or "|cffffa0a0"
|
||||
fs:SetText(color .. msg .. "|r")
|
||||
fs:Show()
|
||||
displayed = displayed + 1
|
||||
else
|
||||
fs:SetText("")
|
||||
fs:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show overflow indicator
|
||||
if count > MAX_DISPLAY_ERRORS then
|
||||
local lastFs = errorFontStrings[MAX_DISPLAY_ERRORS]
|
||||
lastFs:SetText("|cff888888... and " .. (count - MAX_DISPLAY_ERRORS + 1) .. " more|r")
|
||||
lastFs:Show()
|
||||
end
|
||||
|
||||
-- Dynamic height: header(16) + padding(8+6) + lines(12 each)
|
||||
local linesShown = displayed
|
||||
if linesShown > MAX_DISPLAY_ERRORS then linesShown = MAX_DISPLAY_ERRORS end
|
||||
local height = 8 + 16 + 2 + (linesShown * 12) + 6
|
||||
if height > MAX_PANEL_HEIGHT then height = MAX_PANEL_HEIGHT end
|
||||
errorPanel:SetHeight(height)
|
||||
errorPanel:Show()
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Line Highlights (Option B)
|
||||
-- ============================================================================
|
||||
|
||||
local function GetLineHeight()
|
||||
if not MacroFrameText then return 13 end
|
||||
local _, fontSize = MacroFrameText:GetFont()
|
||||
-- Use raw font size - this matches the EditBox's actual line spacing
|
||||
return fontSize or 13
|
||||
end
|
||||
|
||||
local function GetTopTextInset()
|
||||
if not MacroFrameText then return 0 end
|
||||
-- GetTextInsets returns left, right, top, bottom padding inside the EditBox
|
||||
local success, l, r, t, b = pcall(MacroFrameText.GetTextInsets, MacroFrameText)
|
||||
if success and t then
|
||||
return t
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function EnsureHighlightPool()
|
||||
if table.getn(lineHighlights) >= HIGHLIGHT_POOL_SIZE then return end
|
||||
|
||||
for i = table.getn(lineHighlights) + 1, HIGHLIGHT_POOL_SIZE do
|
||||
local tex = MacroFrameText:CreateTexture(nil, "BACKGROUND")
|
||||
tex:SetTexture(0.6, 0.08, 0.08, 0.25)
|
||||
tex:Hide()
|
||||
lineHighlights[i] = tex
|
||||
end
|
||||
end
|
||||
|
||||
local function UpdateLineHighlights(errors)
|
||||
if not MacroFrameText then return end
|
||||
|
||||
EnsureHighlightPool()
|
||||
|
||||
-- Collect which lines have errors (deduplicate)
|
||||
local errorLines = {}
|
||||
if errors then
|
||||
for _, err in ipairs(errors) do
|
||||
if err.line then
|
||||
errorLines[err.line] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local lineHeight = GetLineHeight()
|
||||
local topInset = GetTopTextInset()
|
||||
local editWidth = MacroFrameText:GetWidth()
|
||||
if editWidth < 10 then editWidth = 260 end -- fallback
|
||||
|
||||
local highlightIdx = 1
|
||||
for lineNum, _ in pairs(errorLines) do
|
||||
if highlightIdx > HIGHLIGHT_POOL_SIZE then break end
|
||||
|
||||
local tex = lineHighlights[highlightIdx]
|
||||
-- Position: top text inset + (lineNum-1) lines down
|
||||
local yOffset = -topInset - ((lineNum - 1) * lineHeight)
|
||||
|
||||
tex:ClearAllPoints()
|
||||
tex:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", -2, yOffset)
|
||||
tex:SetWidth(editWidth + 4)
|
||||
tex:SetHeight(lineHeight)
|
||||
tex:Show()
|
||||
|
||||
highlightIdx = highlightIdx + 1
|
||||
end
|
||||
|
||||
-- Hide unused highlights
|
||||
for i = highlightIdx, HIGHLIGHT_POOL_SIZE do
|
||||
if lineHighlights[i] then
|
||||
lineHighlights[i]:Hide()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Name Highlight (yellow backdrop on macro name)
|
||||
-- ============================================================================
|
||||
|
||||
local function CreateNameHighlight()
|
||||
if nameHighlight then return end
|
||||
-- Find the macro name display element
|
||||
-- Standard Blizzard_MacroUI uses MacroFrameSelectedMacroName (FontString)
|
||||
-- and MacroFrameSelectedMacroButton (icon)
|
||||
local nameFrame = getglobal("MacroFrameSelectedMacroName")
|
||||
if not nameFrame then return end
|
||||
|
||||
-- FontStrings can't own textures, so create on their parent
|
||||
local parent = nameFrame:GetParent() or MacroFrame
|
||||
local tex = parent:CreateTexture(nil, "BACKGROUND")
|
||||
tex:SetTexture(0.7, 0.6, 0.1, 0.3)
|
||||
tex:SetPoint("TOPLEFT", nameFrame, "TOPLEFT", -3, 3)
|
||||
tex:SetPoint("BOTTOMRIGHT", nameFrame, "BOTTOMRIGHT", 3, -3)
|
||||
tex:Hide()
|
||||
nameHighlight = tex
|
||||
end
|
||||
|
||||
local function UpdateNameHighlight(hasNameErrors)
|
||||
if not nameHighlight then
|
||||
CreateNameHighlight()
|
||||
end
|
||||
if nameHighlight then
|
||||
if hasNameErrors then
|
||||
nameHighlight:Show()
|
||||
else
|
||||
nameHighlight:Hide()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Validation & Debounce
|
||||
-- ============================================================================
|
||||
|
||||
-- Build a set of player spell names (lowercase) for name conflict checks
|
||||
local function GetPlayerSpellNames()
|
||||
local spellNames = {}
|
||||
-- Primary source: CleveRoids.Spells indexed table
|
||||
if CleveRoids.Spells then
|
||||
for bookType, spells in CleveRoids.Spells do
|
||||
if type(spells) == "table" then
|
||||
for spellName, _ in pairs(spells) do
|
||||
if type(spellName) == "string" then
|
||||
spellNames[string.lower(spellName)] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Fallback: iterate spellbook directly
|
||||
if not next(spellNames) then
|
||||
local i = 1
|
||||
while true do
|
||||
local name, rank = GetSpellName(i, "spell")
|
||||
if not name then break end
|
||||
spellNames[string.lower(name)] = true
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return spellNames
|
||||
end
|
||||
|
||||
-- Build a set of player item names (lowercase) for name conflict checks
|
||||
local function GetPlayerItemNames()
|
||||
local itemNames = {}
|
||||
if CleveRoids.Items then
|
||||
for key, value in pairs(CleveRoids.Items) do
|
||||
if type(key) == "string" and type(value) == "table" and value.name then
|
||||
itemNames[string.lower(value.name)] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return itemNames
|
||||
end
|
||||
|
||||
-- Validate the current macro's name and return any name errors
|
||||
local function ValidateMacroName()
|
||||
local nameErrors = {}
|
||||
if not MacroFrame or not MacroFrame.selectedMacro then return nameErrors end
|
||||
|
||||
local selectedSlot = MacroFrame.selectedMacro
|
||||
local nameOk, name = pcall(GetMacroInfo, selectedSlot)
|
||||
if not nameOk or not name then return nameErrors end
|
||||
|
||||
-- Blank/whitespace name
|
||||
local trimmedName = CleveRoids.Trim and CleveRoids.Trim(name) or name
|
||||
if trimmedName == "" then
|
||||
table.insert(nameErrors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Macro name is blank or only spaces"
|
||||
})
|
||||
end
|
||||
|
||||
-- Spell conflict
|
||||
local lowerName = string.lower(name)
|
||||
local spellNames = GetPlayerSpellNames()
|
||||
if spellNames[lowerName] then
|
||||
table.insert(nameErrors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Name '" .. name .. "' conflicts with a spell/ability"
|
||||
})
|
||||
end
|
||||
|
||||
-- Item conflict
|
||||
local itemNames = GetPlayerItemNames()
|
||||
if itemNames[lowerName] then
|
||||
table.insert(nameErrors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Name '" .. name .. "' conflicts with an item"
|
||||
})
|
||||
end
|
||||
|
||||
-- Duplicate name check
|
||||
local dupeCount = 0
|
||||
for i = 1, 36 do
|
||||
local ok, otherName = pcall(GetMacroInfo, i)
|
||||
if ok and otherName and string.lower(otherName) == lowerName then
|
||||
dupeCount = dupeCount + 1
|
||||
end
|
||||
end
|
||||
if dupeCount > 1 then
|
||||
table.insert(nameErrors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Duplicate name '" .. name .. "' (used " .. dupeCount .. " times)"
|
||||
})
|
||||
end
|
||||
|
||||
return nameErrors
|
||||
end
|
||||
|
||||
local function RunValidation()
|
||||
if not MacroFrameText then return end
|
||||
|
||||
local bodyText = MacroFrameText:GetText()
|
||||
if not bodyText or bodyText == "" then
|
||||
currentErrors = nil
|
||||
UpdateErrorPanel(nil)
|
||||
UpdateLineHighlights(nil)
|
||||
UpdateNameHighlight(false)
|
||||
return
|
||||
end
|
||||
|
||||
-- Combine name errors + body errors
|
||||
local errors = {}
|
||||
|
||||
local nameErrors = ValidateMacroName()
|
||||
local hasNameErrors = table.getn(nameErrors) > 0
|
||||
for _, err in ipairs(nameErrors) do
|
||||
table.insert(errors, err)
|
||||
end
|
||||
|
||||
local bodyErrors = CleveRoids.ValidateMacroBody(bodyText)
|
||||
if bodyErrors then
|
||||
for _, err in ipairs(bodyErrors) do
|
||||
table.insert(errors, err)
|
||||
end
|
||||
end
|
||||
|
||||
if table.getn(errors) == 0 then errors = nil end
|
||||
currentErrors = errors
|
||||
|
||||
UpdateErrorPanel(errors)
|
||||
UpdateLineHighlights(errors)
|
||||
UpdateNameHighlight(hasNameErrors)
|
||||
end
|
||||
|
||||
local function RequestValidation()
|
||||
lastKeystroke = GetTime()
|
||||
pendingValidation = true
|
||||
end
|
||||
|
||||
local function OnUpdateTick()
|
||||
if not MacroFrame or not MacroFrame:IsVisible() then return end
|
||||
|
||||
-- Debounced keystroke validation
|
||||
if pendingValidation and (GetTime() - lastKeystroke) >= DEBOUNCE_DELAY then
|
||||
pendingValidation = false
|
||||
RunValidation()
|
||||
end
|
||||
|
||||
-- Detect macro selection change (poll-based, safer than hooking unknown functions)
|
||||
if MacroFrame.selectedMacro ~= lastSelectedMacro then
|
||||
lastSelectedMacro = MacroFrame.selectedMacro
|
||||
-- Immediate validation on selection change
|
||||
pendingValidation = false
|
||||
RunValidation()
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Cleanup
|
||||
-- ============================================================================
|
||||
|
||||
-- Report all macro errors to chat on frame close
|
||||
local function ReportAllMacroErrors()
|
||||
local accountEntries = {} -- { {name=, slot=, errors=}, ... }
|
||||
local characterEntries = {}
|
||||
local nameCount = {} -- lowercase name -> count (for duplicate detection)
|
||||
local nameSlots = {} -- lowercase name -> { slot1, slot2, ... }
|
||||
local spellNames = GetPlayerSpellNames()
|
||||
local itemNames = GetPlayerItemNames()
|
||||
|
||||
-- First pass: collect all macro names and body errors
|
||||
for i = 1, 36 do
|
||||
local nameOk, name = pcall(GetMacroInfo, i)
|
||||
if nameOk and name and name ~= "" then
|
||||
local errors = {}
|
||||
|
||||
-- Validate body
|
||||
local _, _, body = GetMacroInfo(i)
|
||||
if body and body ~= "" then
|
||||
local bodyErrors = CleveRoids.ValidateMacroBody(body)
|
||||
if bodyErrors then
|
||||
for _, err in ipairs(bodyErrors) do
|
||||
table.insert(errors, err)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Check blank/whitespace name
|
||||
local trimmedName = CleveRoids.Trim and CleveRoids.Trim(name) or name
|
||||
if trimmedName == "" then
|
||||
table.insert(errors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Macro name is blank or only spaces"
|
||||
})
|
||||
end
|
||||
|
||||
-- Check if name matches a player spell
|
||||
local lowerNameCheck = string.lower(name)
|
||||
if spellNames[lowerNameCheck] then
|
||||
table.insert(errors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Name '" .. name .. "' conflicts with a known spell/ability"
|
||||
})
|
||||
end
|
||||
|
||||
-- Check if name matches a player item
|
||||
if itemNames[lowerNameCheck] then
|
||||
table.insert(errors, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Name '" .. name .. "' conflicts with an inventory item"
|
||||
})
|
||||
end
|
||||
|
||||
-- Track name for duplicate detection
|
||||
local lowerName = string.lower(name)
|
||||
nameCount[lowerName] = (nameCount[lowerName] or 0) + 1
|
||||
if not nameSlots[lowerName] then nameSlots[lowerName] = {} end
|
||||
table.insert(nameSlots[lowerName], i)
|
||||
|
||||
local entry = { name = name, slot = i, errors = errors }
|
||||
if i <= 18 then
|
||||
table.insert(accountEntries, entry)
|
||||
else
|
||||
table.insert(characterEntries, entry)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Second pass: inject duplicate name errors
|
||||
for lowerName, count in pairs(nameCount) do
|
||||
if count > 1 then
|
||||
local slots = nameSlots[lowerName]
|
||||
for _, slot in ipairs(slots) do
|
||||
-- Find the entry for this slot and add the error
|
||||
local list = slot <= 18 and accountEntries or characterEntries
|
||||
for _, entry in ipairs(list) do
|
||||
if entry.slot == slot then
|
||||
table.insert(entry.errors, 1, {
|
||||
type = "NAME_ERROR",
|
||||
message = "Duplicate name '" .. entry.name .. "' (used " .. count .. " times)"
|
||||
})
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Output: filter to only entries with errors
|
||||
local accountErrors = {}
|
||||
local characterErrors = {}
|
||||
for _, entry in ipairs(accountEntries) do
|
||||
if table.getn(entry.errors) > 0 then
|
||||
table.insert(accountErrors, entry)
|
||||
end
|
||||
end
|
||||
for _, entry in ipairs(characterEntries) do
|
||||
if table.getn(entry.errors) > 0 then
|
||||
table.insert(characterErrors, entry)
|
||||
end
|
||||
end
|
||||
|
||||
local totalMacros = table.getn(accountErrors) + table.getn(characterErrors)
|
||||
if totalMacros == 0 then return end
|
||||
|
||||
DEFAULT_CHAT_FRAME:AddMessage("|cffff6060[MacroErrorChecker]|r Found errors in " .. totalMacros .. " macro(s):", 1, 0.8, 0.4)
|
||||
|
||||
local function PrintSection(label, entries)
|
||||
if table.getn(entries) == 0 then return end
|
||||
DEFAULT_CHAT_FRAME:AddMessage(" |cff88aaff--- " .. label .. " ---|r")
|
||||
for _, entry in ipairs(entries) do
|
||||
local count = table.getn(entry.errors)
|
||||
DEFAULT_CHAT_FRAME:AddMessage(" |cffffffff" .. entry.name .. "|r - " .. count .. " error(s)")
|
||||
for _, err in ipairs(entry.errors) do
|
||||
local linePrefix = err.line and ("L" .. err.line .. ": ") or ""
|
||||
local msg = linePrefix .. (err.message or "Unknown error")
|
||||
if string.len(msg) > 90 then
|
||||
msg = string.sub(msg, 1, 87) .. "..."
|
||||
end
|
||||
-- Name errors in yellow, syntax errors in red
|
||||
local color = err.type == "NAME_ERROR" and "|cffffcc60" or "|cffffa0a0"
|
||||
DEFAULT_CHAT_FRAME:AddMessage(" " .. color .. msg .. "|r")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
PrintSection("General Macros", accountErrors)
|
||||
PrintSection("Character Macros", characterErrors)
|
||||
end
|
||||
|
||||
local function ClearAll()
|
||||
currentErrors = nil
|
||||
pendingValidation = false
|
||||
lastSelectedMacro = nil
|
||||
|
||||
if errorPanel then
|
||||
errorPanel:Hide()
|
||||
end
|
||||
|
||||
for i = 1, MAX_DISPLAY_ERRORS do
|
||||
if errorFontStrings[i] then
|
||||
errorFontStrings[i]:SetText("")
|
||||
errorFontStrings[i]:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, table.getn(lineHighlights) do
|
||||
if lineHighlights[i] then
|
||||
lineHighlights[i]:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
if nameHighlight then
|
||||
nameHighlight:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Hook Installation
|
||||
-- ============================================================================
|
||||
|
||||
local function InstallHooks()
|
||||
if hooked then return end
|
||||
if not MacroFrameText or not MacroFrame then return end
|
||||
|
||||
-- Skip if SuperMacro is active (it replaces the macro editor entirely)
|
||||
if SuperMacroFrame ~= nil then return end
|
||||
|
||||
-- OnTextChanged: trigger debounced validation on every keystroke
|
||||
local origOnTextChanged = MacroFrameText:GetScript("OnTextChanged")
|
||||
MacroFrameText:SetScript("OnTextChanged", function()
|
||||
if origOnTextChanged then
|
||||
origOnTextChanged()
|
||||
end
|
||||
RequestValidation()
|
||||
end)
|
||||
|
||||
-- OnShow: validate immediately when macro frame opens
|
||||
local origOnShow = MacroFrame:GetScript("OnShow")
|
||||
MacroFrame:SetScript("OnShow", function()
|
||||
if origOnShow then
|
||||
origOnShow()
|
||||
end
|
||||
-- Create panel lazily on first show
|
||||
CreateErrorPanel()
|
||||
-- Reset state and validate
|
||||
lastSelectedMacro = MacroFrame.selectedMacro
|
||||
RunValidation()
|
||||
end)
|
||||
|
||||
-- OnHide: clean up everything
|
||||
local origOnHide = MacroFrame:GetScript("OnHide")
|
||||
MacroFrame:SetScript("OnHide", function()
|
||||
if origOnHide then
|
||||
origOnHide()
|
||||
end
|
||||
ClearAll()
|
||||
-- Report all macro errors to chat when closing the editor
|
||||
pcall(ReportAllMacroErrors)
|
||||
end)
|
||||
|
||||
-- OnUpdate for debounce timer and selection change polling
|
||||
updateFrame = CreateFrame("Frame")
|
||||
updateFrame:SetScript("OnUpdate", function()
|
||||
local success, err = pcall(OnUpdateTick)
|
||||
if not success then
|
||||
-- Silently fail - don't spam errors every frame
|
||||
pendingValidation = false
|
||||
end
|
||||
end)
|
||||
|
||||
hooked = true
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Extension Entry Points
|
||||
-- ============================================================================
|
||||
|
||||
function Extension.OnAddonLoaded()
|
||||
if arg1 == "Blizzard_MacroUI" then
|
||||
InstallHooks()
|
||||
end
|
||||
end
|
||||
|
||||
function Extension.OnLoad()
|
||||
-- Skip if SuperMacro is loaded (detected at load time)
|
||||
if SuperMacroFrame ~= nil then return end
|
||||
|
||||
-- Listen for macro UI loading
|
||||
Extension.RegisterEvent("ADDON_LOADED", "OnAddonLoaded")
|
||||
|
||||
-- If MacroFrame already exists (unlikely but safe), hook immediately
|
||||
if MacroFrame and MacroFrameText then
|
||||
InstallHooks()
|
||||
end
|
||||
end
|
||||
|
||||
_G["CleveRoids"] = CleveRoids
|
||||
@@ -0,0 +1,804 @@
|
||||
--[[
|
||||
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
|
||||
|
||||
-- Known valid commands
|
||||
local VALID_COMMANDS = {
|
||||
-- Core addon commands
|
||||
["/cast"] = true,
|
||||
["/castpet"] = true,
|
||||
["/cancelaura"] = true,
|
||||
["/castsequence"] = true,
|
||||
["/equip"] = true,
|
||||
["/equipmh"] = true,
|
||||
["/equipoh"] = true,
|
||||
["/equip11"] = true,
|
||||
["/equip12"] = true,
|
||||
["/equip13"] = true,
|
||||
["/equip14"] = true,
|
||||
["/focus"] = true,
|
||||
["/petattack"] = true,
|
||||
["/petfollow"] = true,
|
||||
["/petwait"] = true,
|
||||
["/petpassive"] = true,
|
||||
["/petaggressive"] = true,
|
||||
["/petdefensive"] = true,
|
||||
["/print"] = true,
|
||||
["/run"] = true,
|
||||
["/runmacro"] = true,
|
||||
["/script"] = true,
|
||||
["/startattack"] = true,
|
||||
["/stopattack"] = true,
|
||||
["/stopcasting"] = true,
|
||||
["/stopmacro"] = true,
|
||||
["/skipmacro"] = true,
|
||||
["/target"] = true,
|
||||
["/cleartarget"] = true,
|
||||
["/unqueue"] = true,
|
||||
["/use"] = true,
|
||||
["/unbuff"] = true,
|
||||
["/unshift"] = true,
|
||||
["/retarget"] = true,
|
||||
["/firstaction"] = true,
|
||||
["/nofirstaction"] = true,
|
||||
["/applymain"] = true,
|
||||
["/applyoff"] = true,
|
||||
["/clearequipqueue"] = true,
|
||||
["/equipqueuestatus"] = true,
|
||||
["/quickheal"] = true,
|
||||
["/qh"] = true,
|
||||
["/rl"] = true,
|
||||
["/combotrack"] = true,
|
||||
["/cleveroid"] = true,
|
||||
["/cleveroidmacros"] = true,
|
||||
["/macrocheck"] = true,
|
||||
-- Chat / emotes / common WoW commands
|
||||
["/s"] = true,
|
||||
["/y"] = true,
|
||||
["/r"] = true,
|
||||
["/bg"] = true,
|
||||
["/e"] = true,
|
||||
["/w"] = true,
|
||||
["/g"] = true,
|
||||
["/p"] = true,
|
||||
["/invite"] = true,
|
||||
["/trade"] = true,
|
||||
["/db"] = true,
|
||||
["/roll"] = true,
|
||||
["/bow"] = true,
|
||||
["/rinse"] = true,
|
||||
["/am"] = true,
|
||||
["/aux"] = true,
|
||||
["/instancetimers"] = true,
|
||||
["/umacro"] = true,
|
||||
["/camp"] = true,
|
||||
["/logout"] = true,
|
||||
["/exit"] = true,
|
||||
["/promote"] = true,
|
||||
}
|
||||
|
||||
-- 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,
|
||||
["/startattack"] = true,
|
||||
["/stopattack"] = true,
|
||||
["/stopcasting"] = 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",
|
||||
EMPTY_CONDITIONAL = "Empty conditional block",
|
||||
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,
|
||||
hplost = true, myhplost = true,
|
||||
powerlost = true, mypowerlost = true,
|
||||
stat = true,
|
||||
talent = true,
|
||||
actionbar = true,
|
||||
button = true,
|
||||
form = true, stance = true,
|
||||
level = true, mylevel = true,
|
||||
distance = true, nodistance = true,
|
||||
swingtimer = true, stimer = 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, "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] 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 conditionals if present - use non-greedy match
|
||||
local condStart = safeStringFind(actionPart, "%[")
|
||||
local condEnd = nil
|
||||
local conditionBlock = nil
|
||||
|
||||
if condStart then
|
||||
-- Find matching closing bracket
|
||||
local depth = 0
|
||||
local inQuotes = false
|
||||
local len = safeStringLen(actionPart)
|
||||
|
||||
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
|
||||
condEnd = i
|
||||
conditionBlock = safeStringSub(actionPart, condStart + 1, i - 1)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if conditionBlock then
|
||||
if safeTrim(conditionBlock) == "" then
|
||||
table.insert(localErrors, {
|
||||
type = ERROR_TYPES.EMPTY_CONDITIONAL,
|
||||
line = lineNum,
|
||||
message = "Empty conditional block []"
|
||||
})
|
||||
else
|
||||
-- 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
|
||||
|
||||
-- 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✓|r Macro '" .. macroName .. "' has no syntax errors")
|
||||
return
|
||||
end
|
||||
|
||||
CleveRoids.Print("|cffff0000✗|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✓|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)
|
||||
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
|
||||
|
||||
CleveRoids.Print("Macro syntax checker loaded. Use /macrocheck [macroname] or /macrocheck all")
|
||||
@@ -14,6 +14,7 @@ Conditionals.lua
|
||||
Console.lua
|
||||
ExtensionsManager.lua
|
||||
ComboPointTracker.lua
|
||||
MacroErrorChecker.lua
|
||||
|
||||
Compatibility\SuperMacro.lua
|
||||
Compatibility\pfUI.lua
|
||||
@@ -21,6 +22,7 @@ Compatibility\Bongos.lua
|
||||
Compatibility\UltimaMacros.lua
|
||||
|
||||
Extensions\MacroLengthWarn.lua
|
||||
Extensions\MacroErrorUI.lua
|
||||
Extensions\Mouseover\GameTooltip.lua
|
||||
Extensions\Mouseover\Blizzard.lua
|
||||
Extensions\Mouseover\CT_RaidAssist.lua
|
||||
|
||||
Reference in New Issue
Block a user