Files
OpenRA/src/editor/debugger.lua

1186 lines
44 KiB
Lua

-- Integration with MobDebug
-- Copyright 2011-12 Paul Kulchenko, ZeroBrane LLC
-- Original authors: Lomtik Software (J. Winwood & John Labenski)
-- Luxinia Dev (Eike Decker & Christoph Kubisch)
local copas = require "copas"
local socket = require "socket"
local mobdebug = require "mobdebug"
local ide = ide
local debugger = ide.debugger
debugger.server = nil -- DebuggerServer object when debugging, else nil
debugger.running = false -- true when the debuggee is running
debugger.listening = false -- true when the debugger is listening for a client
debugger.portnumber = ide.config.debugger.port or mobdebug.port -- the port # to use for debugging
debugger.watchCtrl = nil -- the watch ctrl that shows watch information
debugger.stackCtrl = nil -- the stack ctrl that shows stack information
debugger.toggleview = { stackview = false, watchview = false }
debugger.hostname = ide.config.debugger.hostname or (function()
local addr = wx.wxIPV4address() -- check what address is resolvable
for _, host in ipairs({wx.wxGetHostName(), wx.wxGetFullHostName()}) do
if host and #host > 0 and addr:Hostname(host) then return host end
end
return "localhost" -- last resort; no known good hostname
end)()
local notebook = ide.frame.notebook
local CURRENT_LINE_MARKER = StylesGetMarker("currentline")
local CURRENT_LINE_MARKER_VALUE = 2^CURRENT_LINE_MARKER
local BREAKPOINT_MARKER = StylesGetMarker("breakpoint")
local BREAKPOINT_MARKER_VALUE = 2^BREAKPOINT_MARKER
local function q(s) return s:gsub('([%(%)%.%%%+%-%*%?%[%^%$%]])','%%%1') end
local function updateWatchesSync(num)
local watchCtrl = debugger.watchCtrl
if watchCtrl and debugger.server and not debugger.running
and not debugger.scratchpad and not (debugger.options or {}).noeval then
for idx = 0, watchCtrl:GetItemCount() - 1 do
if not num or idx == num then
local expression = watchCtrl:GetItemText(idx)
local _, values, error = debugger.evaluate(expression)
if error then error = error:gsub("%[.-%]:%d+:%s+","")
elseif #values == 0 then values = {'nil'} end
local newval = error and ('error: '..error) or values[1]
-- get the current value from a list item
do local litem = wx.wxListItem()
litem:SetMask(wx.wxLIST_MASK_TEXT)
litem:SetId(idx)
litem:SetColumn(1)
watchCtrl:GetItem(litem)
watchCtrl:SetItemBackgroundColour(idx,
watchCtrl:GetItem(litem) and newval ~= litem:GetText()
and ide.config.styles.caretlinebg
and wx.wxColour(unpack(ide.config.styles.caretlinebg.bg))
or watchCtrl:GetBackgroundColour())
end
watchCtrl:SetItem(idx, 1, newval)
end
end
end
end
local simpleType = {['nil'] = true, ['string'] = true, ['number'] = true, ['boolean'] = true}
local stackItemValue = {}
local function checkIfExpandable(value, item)
local expandable = type(value) == 'table' and next(value) ~= nil
and not stackItemValue[value] -- only expand first time
if expandable then -- cache table value to expand when requested
stackItemValue[item:GetValue()] = value
stackItemValue[value] = item:GetValue() -- to avoid circular refs
end
return expandable
end
local function updateStackSync()
local stackCtrl = debugger.stackCtrl
if stackCtrl and debugger.server
and not debugger.running and not debugger.scratchpad then
local stack, _, err = debugger.stack()
if not stack or #stack == 0 then
stackCtrl:DeleteAllItems()
if err then -- report an error if any
stackCtrl:AppendItem(stackCtrl:AddRoot("Stack"), "Error: " .. err, 0)
end
return
end
stackCtrl:Freeze()
stackCtrl:DeleteAllItems()
local params = {comment = false, nocode = true}
local root = stackCtrl:AddRoot("Stack")
stackItemValue = {} -- reset cache of items in the stack
for _,frame in ipairs(stack) do
-- "main chunk at line 24"
-- "foo() at line 13 (defined at foobar.lua:11)"
-- call = { source.name, source.source, source.linedefined,
-- source.currentline, source.what, source.namewhat, source.short_src }
local call = frame[1]
local func = call[5] == "main" and "main chunk"
or call[5] == "C" and (call[1] or "C function")
or call[5] == "tail" and "tail call"
or (call[1] or "anonymous function")
local text = func ..
(call[4] == -1 and '' or " at line "..call[4]) ..
(call[5] ~= "main" and call[5] ~= "Lua" and ''
or (call[3] > 0 and " (defined at "..call[2]..":"..call[3]..")"
or " (defined in "..call[2]..")"))
local callitem = stackCtrl:AppendItem(root, text, 0)
for name,val in pairs(frame[2]) do
-- comment can be not necessarily a string for tables with metatables
-- that provide its own __tostring method
local value, comment = val[1], tostring(val[2])
local text = ("%s = %s%s"):
format(name, mobdebug.line(value, params),
simpleType[type(value)] and "" or (" --[["..comment.."]]"))
local item = stackCtrl:AppendItem(callitem, text, 1)
if checkIfExpandable(value, item) then
stackCtrl:SetItemHasChildren(item, true)
end
end
for name,val in pairs(frame[3]) do
local value, comment = val[1], val[2]
local text = ("%s = %s%s"):
format(name, mobdebug.line(value, params),
simpleType[type(value)] and "" or (" --[["..comment.."]]"))
local item = stackCtrl:AppendItem(callitem, text, 2)
if checkIfExpandable(value, item) then
stackCtrl:SetItemHasChildren(item, true)
end
end
stackCtrl:SortChildren(callitem)
stackCtrl:Expand(callitem)
end
stackCtrl:EnsureVisible(stackCtrl:GetFirstChild(root))
stackCtrl:Thaw()
end
end
local function updateStackAndWatches()
-- check if the debugger is running and may be waiting for a response.
-- allow that request to finish, otherwise updateWatchesSync() does nothing.
if debugger.running then debugger.update() end
if debugger.server and not debugger.running then
copas.addthread(function() updateStackSync() updateWatchesSync() end)
end
end
local function updateWatches(num)
-- check if the debugger is running and may be waiting for a response.
-- allow that request to finish, otherwise updateWatchesSync() does nothing.
if debugger.running then debugger.update() end
if debugger.server and not debugger.running then
copas.addthread(function() updateWatchesSync(num) end)
end
end
local function debuggerToggleViews(show)
local mgr = ide.frame.uimgr
local refresh = false
for view, needed in pairs(debugger.toggleview) do
local pane = mgr:GetPane(view)
if show then -- starting debugging and pane is not shown
debugger.toggleview[view] = not pane:IsShown()
if debugger.toggleview[view] and needed then
pane:Show()
refresh = true
end
else -- completing debugging and pane is shown
debugger.toggleview[view] = pane:IsShown() and needed
if debugger.toggleview[view] then
pane:Hide()
refresh = true
end
end
end
if refresh then mgr:Update() end
end
local function killClient()
if (debugger.pid) then
-- using SIGTERM for some reason kills not only the debugee process,
-- but also some system processes, which leads to a blue screen crash
-- (at least on Windows Vista SP2)
local ret = wx.wxProcess.Kill(debugger.pid, wx.wxSIGKILL, wx.wxKILL_CHILDREN)
if ret == wx.wxKILL_OK then
DisplayOutputLn(TR("Program stopped (pid: %d)."):format(debugger.pid))
elseif ret ~= wx.wxKILL_NO_PROCESS then
DisplayOutputLn(TR("Unable to stop program (pid: %d), code %d.")
:format(debugger.pid, ret))
end
debugger.pid = nil
end
end
local function activateDocument(file, line, skipauto)
if not file then return end
if not wx.wxIsAbsolutePath(file) and debugger.basedir then
file = debugger.basedir .. file
end
local activated
local indebugger = file:find('mobdebug%.lua$')
local fileName = wx.wxFileName(file)
for _, document in pairs(ide.openDocuments) do
-- skip those tabs that may have file without names (untitled.lua)
if document.filePath and fileName:SameAs(wx.wxFileName(document.filePath)) then
local editor = document.editor
local selection = document.index
RequestAttention()
notebook:SetSelection(selection)
SetEditorSelection(selection)
ClearAllCurrentLineMarkers()
if line then
editor:MarkerAdd(line-1, CURRENT_LINE_MARKER)
editor:EnsureVisibleEnforcePolicy(line-1)
end
activated = editor
break
end
end
if not (activated or indebugger or debugger.loop or skipauto)
and ide.config.editor.autoactivate then
-- found file, but can't activate yet (because this part may be executed
-- in a different co-routine), so schedule pending activation.
if wx.wxFileName(file):FileExists() then
debugger.activate = {file, line}
return true -- report successful activation, even though it's pending
end
if not debugger.missing[file] then -- only report files once per session
debugger.missing[file] = true
DisplayOutputLn(TR("Couldn't activate file '%s' for debugging; continuing without it.")
:format(file))
end
end
return activated ~= nil
end
local function reSetBreakpoints()
-- remove all breakpoints that may still be present from the last session
-- this only matters for those remote clients that reload scripts
-- without resetting their breakpoints
debugger.handle("delallb")
-- go over all windows and find all breakpoints
if (not debugger.scratchpad) then
for _, document in pairs(ide.openDocuments) do
local editor = document.editor
local filePath = document.filePath
local line = editor:MarkerNext(0, BREAKPOINT_MARKER_VALUE)
while line ~= -1 do
debugger.handle("setb " .. filePath .. " " .. (line+1))
line = editor:MarkerNext(line + 1, BREAKPOINT_MARKER_VALUE)
end
end
end
end
debugger.shell = function(expression, isstatement)
-- check if the debugger is running and may be waiting for a response.
-- allow that request to finish, otherwise updateWatchesSync() does nothing.
if debugger.running then debugger.update() end
if debugger.server and not debugger.running
and (not debugger.scratchpad or debugger.scratchpad.paused) then
copas.addthread(function ()
-- exec command is not expected to return anything.
-- eval command returns 0 or more results.
-- 'values' has a list of serialized results returned.
-- as it is not possible to distinguish between 0 results and one
-- 'nil' value returned, 'nil' is always returned in this case.
-- the first value returned by eval command is not used;
-- this may need to be taken into account by other debuggers.
local addedret, forceexpression = true, expression:match("^%s*=%s*")
expression = expression:gsub("^%s*=%s*","")
local _, values, err = debugger.evaluate(expression)
if not forceexpression and err and
(err:find("'?<eof>'? expected near '") or
err:find("'%(' expected near") or
err:find("unexpected symbol near '")) then
_, values, err = debugger.execute(expression)
addedret = false
end
if err then
if addedret then err = err:gsub('^%[string "return ', '[string "') end
DisplayShellErr(err)
elseif addedret or #values > 0 then
if forceexpression then -- display elements as multi-line
local mobdebug = require "mobdebug"
for i,v in pairs(values) do -- stringify each of the returned values
local func = loadstring('return '..v) -- deserialize the value first
if func then -- if it's deserialized correctly
values[i] = (forceexpression and i > 1 and '\n' or '') ..
mobdebug.line(func(), {nocode = true, comment = 0,
-- if '=' is used, then use multi-line serialized output
indent = forceexpression and ' ' or nil})
end
end
end
-- if empty table is returned, then show nil if this was an expression
if #values == 0 and (forceexpression or not isstatement) then
values = {'nil'}
end
DisplayShell((table.unpack or unpack)(values))
end
-- refresh Stack and Watch windows if executed a statement (and no err)
if isstatement and not err and not addedret and #values == 0 then
updateStackSync() updateWatchesSync() end
end)
end
end
debugger.listen = function()
local server = socket.bind("*", debugger.portnumber)
DisplayOutputLn(TR("Debugger server started at %s:%d.")
:format(debugger.hostname, debugger.portnumber))
copas.autoclose = false
copas.addserver(server, function (skt)
if debugger.server then
DisplayOutputLn(TR("Refused a request to start a new debugging session as there is one in progress already."))
return
end
copas.setErrorHandler(function(error)
DisplayOutputLn(TR("Can't start debugging session due to internal error '%s'."):format(error))
debugger.terminate()
end)
local options = debugger.options or {}
if not debugger.scratchpad then SetAllEditorsReadOnly(true) end
debugger.server = copas.wrap(skt)
debugger.socket = skt
debugger.loop = false
debugger.scratchable = false
debugger.stats = {line = 0}
debugger.missing = {}
local wxfilepath = GetEditorFileAndCurInfo()
local startfile = options.startfile or options.startwith
or (wxfilepath and wxfilepath:GetFullPath())
if not startfile then
DisplayOutputLn(TR("Can't start debugging without an opened file or with the current file not being saved ('%s').")
:format(ide.config.default.fullname))
return debugger.terminate()
end
local startpath = wx.wxFileName(startfile):GetPath(wx.wxPATH_GET_VOLUME + wx.wxPATH_GET_SEPARATOR)
local basedir = options.basedir or FileTreeGetDir() or startpath
-- guarantee that the path has a trailing separator
debugger.basedir = wx.wxFileName.DirName(basedir):GetFullPath()
-- load the remote file into the debugger
-- set basedir first, before loading to make sure that the path is correct
debugger.handle("basedir " .. debugger.basedir)
reSetBreakpoints()
if options.redirect then
debugger.handle("output stdout " .. options.redirect, nil,
{ handler = function(m)
if not debugger.server then return end
-- if it's an error returned, then handle the error
if m and m:find("stack traceback:", 1, true) then
-- this is an error message sent remotely
local func = loadstring("return "..m)
if func then
DisplayOutputLn(func())
debugger.terminate()
return
end
end
if ide.config.debugger.outputfilter then
m = ide.config.debugger.outputfilter(m)
elseif m then
local max = 240
m = #m < max+4 and m or m:sub(1,max) .. "...\n"
end
if m then DisplayOutputNoMarker(m) end
end})
end
if (options.startwith) then
local file, line, err = debugger.loadfile(options.startwith)
if err then
DisplayOutputLn(TR("Can't run the entry point script ('%s').")
:format(options.startwith)
.." "..TR("Compilation error")
..":\n"..err)
return debugger.terminate()
end
elseif not (options.run or debugger.scratchpad) then
local file, line, err = debugger.loadfile(startfile)
-- "load" can work in two ways: (1) it can load the requested file
-- OR (2) it can "refuse" to load it if the client was started
-- with start() method, which can't load new files
-- if file and line are set, this indicates option #2
if err then
DisplayOutputLn(TR("Can't debug the script in the active editor window.")
.." "..TR("Compilation error")
..":\n"..err)
return debugger.terminate()
elseif options.runstart then
-- do nothing as no activation is required; the script will be run
elseif file and line then
local activated = activateDocument(file, line, true)
-- if not found, check using full file path and reset basedir
if not activated and not wx.wxIsAbsolutePath(file) then
activated = activateDocument(startpath..file, line, true)
if activated then
debugger.basedir = startpath
debugger.handle("basedir " .. debugger.basedir)
-- reset breakpoints again as basedir has changed
reSetBreakpoints()
end
end
-- if not found and the files doesn't exist, it may be
-- a remote call; try to map it to the project folder
if not activated and not wx.wxFileName(file):FileExists() then
-- file is /foo/bar/my.lua; basedir is d:\local\path\
-- check for d:\local\path\my.lua, d:\local\path\bar\my.lua, ...
-- wxwidgets on Windows handles \\ and / as separators, but on OSX
-- and Linux it only handles 'native' separator;
-- need to translate for GetDirs to work.
local file = file:gsub("\\", "/")
local parts = wx.wxFileName(file):GetDirs()
local name = wx.wxFileName(file):GetFullName()
-- find the longest remote path that can be mapped locally
local longestpath, remotedir
while true do
local mapped = GetFullPathIfExists(basedir, name)
if mapped then
longestpath = mapped
remotedir = file:gsub(q(name):gsub("/", ".").."$", "")
end
if #parts == 0 then break end
name = table.remove(parts, #parts) .. "/" .. name
end
-- if found a local mapping under basedir
activated = longestpath and activateDocument(longestpath, line, true)
if activated then
-- find remote basedir by removing the tail from remote file
debugger.handle("basedir " .. debugger.basedir .. "\t" .. remotedir)
-- reset breakpoints again as remote basedir has changed
reSetBreakpoints()
DisplayOutputLn(TR("Mapped remote request for '%s' to '%s'.")
:format(remotedir, debugger.basedir))
end
end
if not activated then
DisplayOutputLn(TR("Can't find file '%s' in the current project to activate for debugging. Update the project or open the file in the editor before debugging.")
:format(file))
return debugger.terminate()
end
-- debugger may still be available for scratchpad,
-- if the interpreter signals scratchpad support, so enable it.
debugger.scratchable = ide.interpreter.scratchextloop ~= nil
else
debugger.scratchable = true
activateDocument(startfile, 1)
end
end
if (not options.noshell and not debugger.scratchpad) then
ShellSupportRemote(debugger.shell)
end
debuggerToggleViews(true)
updateStackSync()
updateWatchesSync()
DisplayOutputLn(TR("Debugging session started in '%s'."):format(debugger.basedir))
if (debugger.scratchpad) then
debugger.scratchpad.updated = true
else
if (options.runstart) then
ClearAllCurrentLineMarkers()
debugger.run()
end
if (options.run) then
local file, line = debugger.handle("run")
activateDocument(file, line)
end
end
end)
debugger.listening = true
end
debugger.handle = function(command, server, options)
local verbose = ide.config.debugger.verbose
local osexit, gprint
osexit, os.exit = os.exit, function () end
if (verbose) then
gprint, _G.print = _G.print, function (...) DisplayOutputLn(...) end
end
debugger.running = true
if verbose then DisplayOutputLn("Debugger sent (command):", command) end
local file, line, err = mobdebug.handle(command, server or debugger.server, options)
if verbose then DisplayOutputLn("Debugger received (file, line, err):", file, line, err) end
debugger.running = false
os.exit = osexit
if (verbose) then _G.print = gprint end
return file, line, err
end
debugger.exec = function(command)
if debugger.server and not debugger.running then
copas.addthread(function ()
local out
local attempts = 0
while true do
-- clear markers before running the command
-- don't clear if running trace as the marker is then invisible,
-- and it needs to be visible during tracing
if not debugger.loop then ClearAllCurrentLineMarkers() end
debugger.breaking = false
local file, line, err = debugger.handle(out or command)
if out then out = nil end
if line == nil then
if err then DisplayOutputLn(err) end
DebuggerStop()
return
elseif not debugger.server then
-- it is possible that while debugger.handle call was executing
-- the debugging was terminated; simply return in this case.
return
else
if activateDocument(file, line) then
debugger.stats.line = debugger.stats.line + 1
if debugger.loop then
updateStackSync()
updateWatchesSync()
else
updateStackAndWatches()
return
end
else
-- clear the marker as it wasn't cleared earlier
if debugger.loop then ClearAllCurrentLineMarkers() end
-- we may be in some unknown location at this point;
-- If this happens, stop and report allowing users to set
-- breakpoints and step through.
if debugger.breaking then
DisplayOutputLn(TR("Debugging suspended at %s:%s (couldn't activate the file).")
:format(file, line))
return
end
-- redo now; if the call is from the debugger, then repeat
-- the same command, except when it was "run" (switch to 'step');
-- this is needed to "break" execution that happens in on() call.
-- in all other cases get out of this file.
-- don't get out of "mobdebug", because it may happen with
-- start() or on() call, which will get us out of the current
-- file, which is not what we want.
-- Some engines (Corona SDK) report =?:0 as the current location.
-- repeat the same command, but check if this has been tried
-- too many times already; if so, get "out"
out = ((tonumber(line) == 0 and attempts < 10) and command
or (file:find('mobdebug%.lua$')
and (command == 'run' and 'step' or command) or "out"))
attempts = attempts + 1
end
end
end
end)
end
end
debugger.handleAsync = function(command)
if debugger.server and not debugger.running then
copas.addthread(function () debugger.handle(command) end)
end
end
debugger.loadfile = function(file)
return debugger.handle("load " .. file)
end
debugger.loadstring = function(file, string)
return debugger.handle("loadstring '" .. file .. "' " .. string)
end
debugger.update = function()
copas.step(0)
-- if there are any pending activations
if debugger.activate then
local file, line = (table.unpack or unpack)(debugger.activate)
if LoadFile(file) then activateDocument(file, line) end
debugger.activate = nil
end
end
debugger.terminate = function()
if debugger.server then
if debugger.pid then -- if there is PID, try local kill
killClient()
else -- otherwise, try graceful exit for the remote process
debugger.breaknow("exit")
end
DebuggerStop()
end
end
debugger.step = function() debugger.exec("step") end
debugger.trace = function()
debugger.loop = true
debugger.exec("step")
end
debugger.over = function() debugger.exec("over") end
debugger.out = function() debugger.exec("out") end
debugger.run = function() debugger.exec("run") end
debugger.evaluate = function(expression) return debugger.handle('eval ' .. expression) end
debugger.execute = function(expression) return debugger.handle('exec ' .. expression) end
debugger.stack = function() return debugger.handle('stack') end
debugger.breaknow = function(command)
-- stop if we're running a "trace" command
debugger.loop = false
-- force suspend command; don't use copas interface as it checks
-- for the other side "reading" and the other side is not reading anything.
-- use the "original" socket to send "suspend" command.
-- this will only break on the next Lua command.
if debugger.socket then
local running = debugger.running
-- this needs to be short as it will block the UI
debugger.socket:settimeout(0.25)
local file, line, err = debugger.handle(command or "suspend", debugger.socket)
debugger.socket:settimeout(0)
-- restore running status
debugger.running = running
debugger.breaking = true
-- don't need to do anything else as the earlier call (run, step, etc.)
-- will get the results (file, line) back and will update the UI
return file, line, err
end
end
debugger.breakpoint = function(file, line, state)
debugger.handleAsync((state and "setb " or "delb ") .. file .. " " .. line)
end
debugger.quickeval = function(var, callback)
if debugger.server and not debugger.running
and not debugger.scratchpad and not (debugger.options or {}).noeval then
copas.addthread(function ()
local _, values, err = debugger.evaluate(var)
local val = err
and err:gsub("%[.-%]:%d+:%s*","error: ")
or (var .. " = " .. (#values > 0 and values[1] or 'nil'))
if callback then callback(val) end
end)
end
end
-- need imglist to be a file local variable as SetImageList takes ownership
-- of it and if done inside a function, icons do not work as expected
local imglist = wx.wxImageList(16,16)
do
local getBitmap = (ide.app.createbitmap or wx.wxArtProvider.GetBitmap)
local size = wx.wxSize(16,16)
-- 0 = stack call
imglist:Add(getBitmap(wx.wxART_GO_FORWARD, wx.wxART_OTHER, size))
-- 1 = local variables
imglist:Add(getBitmap(wx.wxART_LIST_VIEW, wx.wxART_OTHER, size))
-- 2 = upvalues
imglist:Add(getBitmap(wx.wxART_REPORT_VIEW, wx.wxART_OTHER, size))
end
local width, height = 360, 200
function debuggerCreateStackWindow()
local stackCtrl = wx.wxTreeCtrl(ide.frame, wx.wxID_ANY,
wx.wxDefaultPosition, wx.wxSize(width, height),
wx.wxTR_LINES_AT_ROOT + wx.wxTR_HAS_BUTTONS + wx.wxTR_SINGLE + wx.wxTR_HIDE_ROOT)
debugger.stackCtrl = stackCtrl
stackCtrl:SetImageList(imglist)
stackCtrl:Connect( wx.wxEVT_COMMAND_TREE_ITEM_EXPANDING,
function (event)
local item_id = event:GetItem()
local count = stackCtrl:GetChildrenCount(item_id, false)
if count > 0 then return true end
local image = stackCtrl:GetItemImage(item_id)
local num = 1
for name,value in pairs(stackItemValue[item_id:GetValue()]) do
local strval = mobdebug.line(value, {comment = false, nocode = true})
local text = type(name) == "number"
and (num == name and strval or ("[%s] = %s"):format(name, strval))
or ("%s = %s"):format(tostring(name), strval)
local item = stackCtrl:AppendItem(item_id, text, image)
if checkIfExpandable(value, item) then
stackCtrl:SetItemHasChildren(item, true)
end
num = num + 1
end
stackCtrl:SortChildren(item_id)
return true
end)
stackCtrl:Connect( wx.wxEVT_COMMAND_TREE_ITEM_COLLAPSED,
function() return true end)
local notebook = wxaui.wxAuiNotebook(frame, wx.wxID_ANY,
wx.wxDefaultPosition, wx.wxDefaultSize,
wxaui.wxAUI_NB_DEFAULT_STYLE + wxaui.wxAUI_NB_TAB_EXTERNAL_MOVE
+ wx.wxNO_BORDER)
notebook:AddPage(stackCtrl, TR("Stack"), true)
local mgr = ide.frame.uimgr
mgr:AddPane(notebook, wxaui.wxAuiPaneInfo():
Name("stackview"):Float():
MinSize(height,height):FloatingSize(width,height):
PinButton(true):Hide())
mgr:Update()
end
local function debuggerCreateWatchWindow()
local watchCtrl = wx.wxListCtrl(frame, wx.wxID_ANY,
wx.wxDefaultPosition, wx.wxDefaultSize,
wx.wxLC_REPORT + wx.wxLC_EDIT_LABELS)
debugger.watchCtrl = watchCtrl
local info = wx.wxListItem()
info:SetMask(wx.wxLIST_MASK_TEXT + wx.wxLIST_MASK_WIDTH)
info:SetText(TR("Expression"))
info:SetWidth(width * 0.32)
watchCtrl:InsertColumn(0, info)
info:SetText(TR("Value"))
info:SetWidth(width * 0.56)
watchCtrl:InsertColumn(1, info)
local watchMenu = wx.wxMenu{
{ ID_ADDWATCH, TR("&Add Watch")..KSC(ID_ADDWATCH) },
{ ID_EDITWATCH, TR("&Edit Watch")..KSC(ID_EDITWATCH) },
{ ID_DELETEWATCH, TR("&Delete Watch")..KSC(ID_DELETEWATCH) }}
local function findSelectedWatchItem()
local count = watchCtrl:GetSelectedItemCount()
if count > 0 then
for idx = 0, watchCtrl:GetItemCount() - 1 do
if watchCtrl:GetItemState(idx, wx.wxLIST_STATE_FOCUSED) ~= 0 then
return idx
end
end
end
return -1
end
local defaultExpr = ""
local function addWatch()
local row = watchCtrl:InsertItem(watchCtrl:GetItemCount(), TR("Expr"))
watchCtrl:SetItem(row, 0, defaultExpr)
watchCtrl:SetItem(row, 1, TR("Value"))
watchCtrl:EditLabel(row)
end
local function editWatch()
local row = findSelectedWatchItem()
if row >= 0 then watchCtrl:EditLabel(row) end
end
local function deleteWatch()
local row = findSelectedWatchItem()
if row >= 0 then watchCtrl:DeleteItem(row) end
end
watchCtrl:Connect(wx.wxEVT_CONTEXT_MENU,
function (event)
watchCtrl:PopupMenu(watchMenu)
end)
watchCtrl:Connect(wx.wxEVT_KEY_DOWN,
function (event)
local keycode = event:GetKeyCode()
if (keycode == wx.WXK_DELETE) then return deleteWatch()
elseif (keycode == wx.WXK_INSERT) then return addWatch()
elseif (keycode == wx.WXK_F2) then return editWatch()
end
event:Skip()
end)
watchCtrl:Connect(ID_ADDWATCH, wx.wxEVT_COMMAND_MENU_SELECTED, addWatch)
watchCtrl:Connect(ID_EDITWATCH, wx.wxEVT_COMMAND_MENU_SELECTED, editWatch)
watchCtrl:Connect(ID_EDITWATCH, wx.wxEVT_UPDATE_UI,
function (event) event:Enable(watchCtrl:GetSelectedItemCount() > 0) end)
watchCtrl:Connect(ID_DELETEWATCH, wx.wxEVT_COMMAND_MENU_SELECTED, deleteWatch)
watchCtrl:Connect(ID_DELETEWATCH, wx.wxEVT_UPDATE_UI,
function (event) event:Enable(watchCtrl:GetSelectedItemCount() > 0) end)
watchCtrl:Connect(wx.wxEVT_COMMAND_LIST_END_LABEL_EDIT,
function (event)
local row = event:GetIndex()
if event:IsEditCancelled() then
if watchCtrl:GetItemText(row) == defaultExpr then
watchCtrl:DeleteItem(row)
end
else
watchCtrl:SetItem(row, 0, event:GetText())
updateWatches(row)
end
event:Skip()
end)
local notebook = wxaui.wxAuiNotebook(frame, wx.wxID_ANY,
wx.wxDefaultPosition, wx.wxDefaultSize,
wxaui.wxAUI_NB_DEFAULT_STYLE + wxaui.wxAUI_NB_TAB_EXTERNAL_MOVE
+ wx.wxNO_BORDER)
notebook:AddPage(watchCtrl, TR("Watch"), true)
local mgr = ide.frame.uimgr
mgr:AddPane(notebook, wxaui.wxAuiPaneInfo():
Name("watchview"):Float():
MinSize(height,height):FloatingSize(width,height):
PinButton(true):Hide())
mgr:Update()
end
debuggerCreateStackWindow()
debuggerCreateWatchWindow()
----------------------------------------------
-- public api
function DebuggerShowStackWindow()
local mgr = ide.frame.uimgr
if (not mgr:GetPane("stackview"):IsShown()) then
mgr:GetPane("stackview"):Show()
mgr:Update()
end
updateStackAndWatches()
end
function DebuggerShowWatchWindow()
local mgr = ide.frame.uimgr
if (not mgr:GetPane("watchview"):IsShown()) then
mgr:GetPane("watchview"):Show()
mgr:Update()
end
updateStackAndWatches()
end
function DebuggerAddWatch(watch)
DebuggerShowWatchWindow()
local watchCtrl = debugger.watchCtrl
-- check if this expression is already on the list
for idx = 0, watchCtrl:GetItemCount() - 1 do
if watchCtrl:GetItemText(idx) == watch then return end
end
local row = watchCtrl:InsertItem(watchCtrl:GetItemCount(), TR("Expr"))
watchCtrl:SetItem(row, 0, watch)
watchCtrl:SetItem(row, 1, TR("Value"))
updateWatches(row)
end
function DebuggerAttachDefault(options)
debugger.options = options
if (debugger.listening) then return end
debugger.listen()
end
function DebuggerShutdown()
if debugger.server then debugger.terminate() end
if debugger.pid then killClient() end
end
function DebuggerStop()
if (debugger.server) then
debugger.server = nil
debugger.pid = nil
SetAllEditorsReadOnly(false)
ShellSupportRemote(nil)
ClearAllCurrentLineMarkers()
DebuggerScratchpadOff()
debuggerToggleViews(false)
local lines = TR("traced %d instruction", debugger.stats.line):format(debugger.stats.line)
DisplayOutputLn(TR("Debugging session completed (%s)."):format(lines))
else
-- it's possible that the application couldn't start, or that the
-- debugger in the application didn't start, which means there is
-- no debugger.server, but scratchpad may still be on. Turn it off.
DebuggerScratchpadOff()
end
end
function DebuggerMakeFileName(editor, filePath)
return filePath or ide.config.default.fullname
end
function DebuggerToggleBreakpoint(editor, line)
-- ignore requests to toggle when the debugger is running
if debugger.server and debugger.running then return end
local markers = editor:MarkerGet(line)
if markers >= CURRENT_LINE_MARKER_VALUE then
markers = markers - CURRENT_LINE_MARKER_VALUE
end
local id = editor:GetId()
local filePath = DebuggerMakeFileName(editor, ide.openDocuments[id].filePath)
if markers >= BREAKPOINT_MARKER_VALUE then
editor:MarkerDelete(line, BREAKPOINT_MARKER)
if debugger.server then
debugger.breakpoint(filePath, line+1, false)
end
else
editor:MarkerAdd(line, BREAKPOINT_MARKER)
if debugger.server then
debugger.breakpoint(filePath, line+1, true)
end
end
end
-- scratchpad functions
function DebuggerRefreshScratchpad()
if debugger.scratchpad and debugger.scratchpad.updated and not debugger.scratchpad.paused then
local scratchpadEditor = debugger.scratchpad.editor
local compiled, code = CompileProgram(scratchpadEditor, true)
if not compiled then return end
if debugger.scratchpad.running then
-- break the current execution first
-- don't try too frequently to avoid overwhelming the debugger
local now = TimeGet()
if now - debugger.scratchpad.running > 0.250 then
debugger.breaknow()
debugger.scratchpad.running = now
end
else
local clear = ide.frame.menuBar:IsChecked(ID_CLEAROUTPUT)
local filePath = DebuggerMakeFileName(scratchpadEditor,
ide.openDocuments[scratchpadEditor:GetId()].filePath)
-- wrap into a function call to make "return" to work with scratchpad
code = "(function()"..code.."\nend)()"
-- this is a special error message that is generated at the very end
-- of each script to avoid exiting the (debugee) scratchpad process.
-- these errors are handled and not reported to the user
local errormsg = 'execution suspended at ' .. TimeGet()
local stopper = "error('" .. errormsg .. "')"
-- store if interpreter requires a special handling for external loop
local extloop = ide.interpreter.scratchextloop
local function reloadScratchpadCode()
debugger.scratchpad.running = TimeGet()
debugger.scratchpad.updated = false
debugger.scratchpad.runs = (debugger.scratchpad.runs or 0) + 1
if clear then ClearOutput() end
-- the code can be running in two ways under scratchpad:
-- 1. controlled by the application, requires stopper (most apps)
-- 2. controlled by some external loop (for example, love2d).
-- in the first case we need to reload the app after each change
-- in the second case, we need to load the app once and then
-- "execute" new code to reflect the changes (with some limitations).
local _, _, err
if extloop then -- if the execution is controlled by an external loop
if debugger.scratchpad.runs == 1
then _, _, err = debugger.loadstring(filePath, code)
else _, _, err = debugger.execute(code) end
else _, _, err = debugger.loadstring(filePath, code .. stopper) end
-- when execute() is used, it's not possible to distinguish between
-- compilation and run-time error, so just report as "Scratchpad error"
local prefix = extloop and TR("Scratchpad error") or TR("Compilation error")
if not err then
_, _, err = debugger.handle("run")
prefix = TR("Execution error")
end
if err and not err:find(errormsg) then
local fragment, line = err:match('.-%[string "([^\010\013]+)"%]:(%d+)%s*:')
-- make the code shorter to better see the error message
if prefix == TR("Scratchpad error") and fragment and #fragment > 30 then
err = err:gsub(q(fragment), function(s) return s:sub(1,30)..'...' end)
end
DisplayOutputLn(prefix
..(line and (" "..TR("on line %d"):format(line)) or "")
..":\n"..err:gsub('stack traceback:.+', ''):gsub('\n+$', ''))
end
debugger.scratchpad.running = false
end
copas.addthread(reloadScratchpadCode)
end
end
end
local numberStyle = wxstc.wxSTC_LUA_NUMBER
function DebuggerScratchpadOn(editor)
-- first check if there is already scratchpad editor.
-- this may happen when more than one editor is being added...
if debugger.scratchpad and debugger.scratchpad.editors then
debugger.scratchpad.editors[editor] = true
else
debugger.scratchpad = {editor = editor, editors = {[editor] = true}}
-- check if the debugger is already running; this happens when
-- scratchpad is turned on after external script has connected
if debugger.server then
debugger.scratchpad.updated = true
ClearAllCurrentLineMarkers()
SetAllEditorsReadOnly(false)
ShellSupportRemote(nil) -- disable remote shell
DebuggerRefreshScratchpad()
elseif not ProjectDebug(true, "scratchpad") then
debugger.scratchpad = nil
return
end
end
local scratchpadEditor = editor
scratchpadEditor:StyleSetUnderline(numberStyle, true)
debugger.scratchpad.margin = scratchpadEditor:GetMarginWidth(0) +
scratchpadEditor:GetMarginWidth(1) + scratchpadEditor:GetMarginWidth(2)
scratchpadEditor:Connect(wxstc.wxEVT_STC_MODIFIED, function(event)
local evtype = event:GetModificationType()
if (bit.band(evtype,wxstc.wxSTC_MOD_INSERTTEXT) ~= 0 or
bit.band(evtype,wxstc.wxSTC_MOD_DELETETEXT) ~= 0 or
bit.band(evtype,wxstc.wxSTC_PERFORMED_UNDO) ~= 0 or
bit.band(evtype,wxstc.wxSTC_PERFORMED_REDO) ~= 0) then
debugger.scratchpad.updated = true
debugger.scratchpad.editor = scratchpadEditor
end
event:Skip()
end)
scratchpadEditor:Connect(wx.wxEVT_LEFT_DOWN, function(event)
local scratchpad = debugger.scratchpad
local point = event:GetPosition()
local pos = scratchpadEditor:PositionFromPoint(point)
-- are we over a number in the scratchpad? if not, it's not our event
if ((not scratchpad) or
(bit.band(scratchpadEditor:GetStyleAt(pos),31) ~= numberStyle)) then
event:Skip()
return
end
-- find start position and length of the number
local text = scratchpadEditor:GetText()
local nstart = pos
while nstart >= 0
and (bit.band(scratchpadEditor:GetStyleAt(nstart),31) == numberStyle)
do nstart = nstart - 1 end
local nend = pos
while nend < string.len(text)
and (bit.band(scratchpadEditor:GetStyleAt(nend),31) == numberStyle)
do nend = nend + 1 end
-- check if there is minus sign right before the number and include it
if nstart >= 0 and scratchpadEditor:GetTextRange(nstart,nstart+1) == '-' then
nstart = nstart - 1
end
scratchpad.start = nstart + 1
scratchpad.length = nend - nstart - 1
scratchpad.origin = scratchpadEditor:GetTextRange(nstart+1,nend)
if tonumber(scratchpad.origin) then
scratchpad.point = point
scratchpadEditor:CaptureMouse()
end
end)
scratchpadEditor:Connect(wx.wxEVT_LEFT_UP, function(event)
if debugger.scratchpad and debugger.scratchpad.point then
debugger.scratchpad.point = nil
scratchpadEditor:ReleaseMouse()
wx.wxSetCursor(wx.wxNullCursor) -- restore cursor
else event:Skip() end
end)
scratchpadEditor:Connect(wx.wxEVT_MOTION, function(event)
local point = event:GetPosition()
local pos = scratchpadEditor:PositionFromPoint(point)
local scratchpad = debugger.scratchpad
local ipoint = scratchpad and scratchpad.point
-- record the fact that we are over a number or dragging slider
scratchpad.over = scratchpad and
(ipoint ~= nil or (bit.band(scratchpadEditor:GetStyleAt(pos),31) == numberStyle))
if ipoint then
local startpos = scratchpad.start
local endpos = scratchpad.start+scratchpad.length
-- calculate difference in point position
local dx = point.x - ipoint.x
-- calculate the number of decimal digits after the decimal point
local origin = scratchpad.origin
local decdigits = #(origin:match('%.(%d+)') or '')
-- calculate new value
local value = tonumber(origin) + dx * 10^-decdigits
-- convert new value back to string to check the number of decimal points
-- this is needed because the rate of change is determined by the
-- current value. For example, for number 1, the next value is 2,
-- but for number 1.1, the next is 1.2 and for 1.01 it is 1.02.
-- But if 1.01 becomes 1.00, the both zeros after the decimal point
-- need to be preserved to keep the increment ratio the same when
-- the user wants to release the slider and start again.
origin = tostring(value)
local newdigits = #(origin:match('%.(%d+)') or '')
if decdigits ~= newdigits then
origin = origin .. (origin:find('%.') and '' or '.') .. ("0"):rep(decdigits-newdigits)
end
-- update length
scratchpad.length = #origin
-- update the value in the document
scratchpadEditor:SetTargetStart(startpos)
scratchpadEditor:SetTargetEnd(endpos)
scratchpadEditor:ReplaceTarget(origin)
else event:Skip() end
end)
scratchpadEditor:Connect(wx.wxEVT_SET_CURSOR, function(event)
if (debugger.scratchpad and debugger.scratchpad.over) then
event:SetCursor(wx.wxCursor(wx.wxCURSOR_SIZEWE))
elseif debugger.scratchpad and ide.osname == 'Unix' then
-- restore the cursor manually on Linux since event:Skip() doesn't reset it
local ibeam = event:GetX() > debugger.scratchpad.margin
event:SetCursor(wx.wxCursor(ibeam and wx.wxCURSOR_IBEAM or wx.wxCURSOR_RIGHT_ARROW))
else event:Skip() end
end)
return true
end
function DebuggerScratchpadOff()
if not debugger.scratchpad then return end
for scratchpadEditor in pairs(debugger.scratchpad.editors) do
scratchpadEditor:StyleSetUnderline(numberStyle, false)
scratchpadEditor:Disconnect(wx.wxID_ANY, wx.wxID_ANY, wxstc.wxEVT_STC_MODIFIED)
scratchpadEditor:Disconnect(wx.wxID_ANY, wx.wxID_ANY, wx.wxEVT_MOTION)
scratchpadEditor:Disconnect(wx.wxID_ANY, wx.wxID_ANY, wx.wxEVT_LEFT_DOWN)
scratchpadEditor:Disconnect(wx.wxID_ANY, wx.wxID_ANY, wx.wxEVT_LEFT_UP)
scratchpadEditor:Disconnect(wx.wxID_ANY, wx.wxID_ANY, wx.wxEVT_SET_CURSOR)
end
wx.wxSetCursor(wx.wxNullCursor) -- restore cursor
debugger.scratchpad = nil
debugger.terminate()
-- disable menu if it is still enabled
-- (as this may be called when the debugger is being shut down)
local menuBar = ide.frame.menuBar
if menuBar:IsChecked(ID_RUNNOW) then menuBar:Check(ID_RUNNOW, false) end
return true
end