live = {}
Live = {}
on = {}
function live.load()
if Live.frozen_definitions == nil then live.freeze_all_existing_definitions()
end
Live.filenames_to_load = {} Live.filename = {} Live.final_prefix = 0
live.load_files_so_far()
Live.previous_read = 0
end
function live.load_files_so_far()
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
local numeric_prefix, root = filename:match('^(%d+)-(.+)')
if numeric_prefix and tonumber(numeric_prefix) > 0 then Live.filename[root] = filename
table.insert(Live.filenames_to_load, filename)
Live.final_prefix = math.max(Live.final_prefix, tonumber(numeric_prefix))
end
end
table.sort(Live.filenames_to_load)
for _,filename in ipairs(Live.filenames_to_load) do
local buf = love.filesystem.read(filename)
assert(buf and buf ~= '')
local _, definition_name = filename:match('^(%d+)-(.+)')
local status, err = live.eval(buf, definition_name)
if not status then
error(err)
end
end
end
APP = 'fw_app'
function live.update(dt)
if Current_time - Live.previous_read > 0.1 then
local buf = live.receive_from_driver()
if buf then
local possibly_mutated = live.run(buf)
if possibly_mutated then
Mode = 'run'
if Redo_initialization then
Redo_initialization = nil
love.run() end
end
if on.code_change then on.code_change() end
end
Live.previous_read = Current_time
end
end
function live.receive_from_driver()
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
if f == nil then return nil end
local result = f:read('*a')
f:close()
if result == '' then return nil end print('<='..color_escape(1, 4))
print(result)
print(reset_terminal())
os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
return result
end
function live.send_to_driver(msg)
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w')
if f == nil then return end
f:write(msg)
f:close()
print('=>'..color_escape(0, 2))
print(msg)
print(reset_terminal())
end
function live.send_run_time_error_to_driver(msg)
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w')
if f == nil then return end
f:write(msg)
f:close()
print('=>'..color_escape(0, 1))
print(msg)
print(reset_terminal())
end
function color_escape(format, color)
return ('\027[%d;%dm'):format(format, 30+color)
end
function reset_terminal()
return '\027[m'
end
function live.run(buf)
local cmd = live.get_cmd_from_buffer(buf)
assert(cmd)
print('command is '..cmd)
if cmd == 'QUIT' then
love.event.quit(1)
elseif cmd == 'RESTART' then
restart()
elseif cmd == 'MANIFEST' then
Live.filename[APP] = love.filesystem.getIdentity()
live.send_to_driver(json.encode(Live.filename))
elseif cmd == 'DELETE' then
local definition_name = buf:match('^%s*%S+%s+(%S+)')
if Live.frozen_definitions[definition_name] then
live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be deleted.')
return
end
if Live.filename[definition_name] then
local index = table.find(Live.filenames_to_load, Live.filename[definition_name])
table.remove(Live.filenames_to_load, index)
live.eval(definition_name..' = nil', 'driver') nativefs.remove(App.source_dir..Live.filename[definition_name])
love.filesystem.remove(Live.filename[definition_name])
Live.filename[definition_name] = nil
end
live.send_to_driver('{}')
return true
elseif cmd == 'GET' then
local definition_name = buf:match('^%s*%S+%s+(%S+)')
local val, _ = live.get_binding(definition_name)
if val then
live.send_to_driver(val)
else
live.send_to_driver('ERROR no such value')
end
elseif cmd == 'GET*' then
local result = {}
for definition_name in buf:gmatch('%s+(%S+)') do
print(definition_name)
local val, _ = live.get_binding(definition_name)
if val then
table.insert(result, val)
end
end
local delimiter = '\n==fw: definition boundary==\n'
live.send_to_driver(table.concat(result, delimiter)..delimiter) elseif cmd == 'DEFAULT_MAP' then
local contents = love.filesystem.read('default_map')
if contents == nil then contents = '{}' end
live.send_to_driver(contents)
else
local definition_name = live.get_definition_name_from_buffer(buf)
if definition_name == nil then
live.send_to_driver('ERROR empty definition')
return
end
print('definition name is '..definition_name)
if Live.frozen_definitions[definition_name] then
live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.')
return
end
local status, err = live.eval(buf, definition_name)
if not status then
live.send_to_driver('ERROR '..cleaned_up_frame(tostring(err)))
return
end
local filename = Live.filename[definition_name]
if filename == nil then
Live.final_prefix = Live.final_prefix+1
filename = ('%04d-%s'):format(Live.final_prefix, definition_name)
table.insert(Live.filenames_to_load, filename)
Live.filename[definition_name] = filename
end
local status, err = nativefs.write(App.source_dir..filename, buf)
if err then
local status, err2 = love.filesystem.write(filename, buf)
if err2 then
live.send_to_driver('ERROR '..tostring(err..'\n\n'..err2))
return true
end
end
Test_errors = {}
App.run_tests(record_error_by_test)
live.send_to_driver(json.encode(Test_errors))
return true
end
end
function live.get_cmd_from_buffer(buf)
return buf:match('^%s*(%S+)')
end
function live.get_definition_name_from_buffer(buf)
return first_noncomment_word(buf)
end
function first_noncomment_word(str)
local pos = 1
while pos <= #str do if str:sub(pos,pos) == '-' then
if str:sub(pos+1,pos+1) == '-' then
local long_comment_header = str:match('^%[=*%[', pos+2)
if long_comment_header then
local long_comment_trailer = long_comment_header:gsub('%[', ']')
pos = str:find(long_comment_trailer, pos, true)
if pos == nil then return '' end pos = pos + #long_comment_trailer
else
pos = str:find('\n', pos)
if pos == nil then return '' end end
end
end
if str:sub(pos,pos):match('%s') then
pos = pos+1
else
return str:match('^%S*', pos)
end
end
return ''
end
function test_first_noncomment_word()
check_eq(first_noncomment_word(''), '', 'empty string')
check_eq(first_noncomment_word('abc'), 'abc', 'single word')
check_eq(first_noncomment_word('abc def'), 'abc', 'stop at space')
check_eq(first_noncomment_word('abc\tdef'), 'abc', 'stop at tab')
check_eq(first_noncomment_word('abc\ndef'), 'abc', 'stop at newline')
check_eq(first_noncomment_word('-- abc\ndef'), 'def', 'ignore line comment')
check_eq(first_noncomment_word('--[[abc]] def'), 'def', 'ignore block comment')
check_eq(first_noncomment_word('--[[abc\n]] def'), 'def', 'ignore multi-line block comment')
check_eq(first_noncomment_word('--[[abc\n--]] def'), 'def', 'ignore comment leader before block comment trailer')
check_eq(first_noncomment_word('--[=[abc]=] def'), 'def', 'ignore long comment')
check_eq(first_noncomment_word('--[=[abc]] def ]=] ghi'), 'ghi', 'ignore long comment containing block comment trailer')
check_eq(first_noncomment_word('--[===[abc\n\ndef ghi\njkl]===]mno\npqr'), 'mno', 'ignore long comment containing block comment trailer')
check_eq(first_noncomment_word('-'), '-', 'incomplete comment token')
check_eq(first_noncomment_word('--abc'), '', 'incomplete line comment')
check_eq(first_noncomment_word('--abc\n'), '', 'just a line comment')
check_eq(first_noncomment_word('--abc\n '), '', 'just a line comment 2')
check_eq(first_noncomment_word('--[ab\n'), '', 'incomplete block comment token is a line comment')
check_eq(first_noncomment_word('--[[ab'), '', 'incomplete block comment')
check_eq(first_noncomment_word('--[[ab\n]'), '', 'incomplete block comment 2')
check_eq(first_noncomment_word('--[=[ab\n]] ]='), '', 'incomplete block comment 3')
check_eq(first_noncomment_word('--[=[ab\n]] ]=]'), '', 'just a block comment')
check_eq(first_noncomment_word('--[=[ab\n]] ]=] \n \n '), '', 'just a block comment 2')
end
function live.get_binding(name)
if Live.filename[name] then
return love.filesystem.read(Live.filename[name])
end
end
function table.find(h, x)
for k,v in pairs(h) do
if v == x then
return k
end
end
end
function live.eval(buf, filename)
local f = load('return '..buf, filename or 'REPL')
if f then
return pcall(f)
end
local f, err = load(buf, filename or 'REPL')
if f then
return pcall(f)
else
return nil, err
end
end
function live.freeze_all_existing_definitions()
Live.frozen_definitions = {on=true} local done = {}
done[Live.frozen_definitions]=true
live.freeze_all_existing_definitions_in(_G, {}, done)
end
function live.freeze_all_existing_definitions_in(tab, scopes, done)
if done[tab] then return end
done[tab] = true
for name,binding in pairs(tab) do
local full_name = live.full_name(scopes, name)
Live.frozen_definitions[full_name] = true
if type(binding) == 'table' and full_name ~= 'package' then table.insert(scopes, name)
live.freeze_all_existing_definitions_in(binding, scopes, done)
table.remove(scopes)
end
end
end
function live.full_name(scopes, name)
local ns = table.concat(scopes, '.')
if #ns == 0 then return name end
return ns..'.'..name
end
function live.handle_error(err)
love.graphics.setCanvas() Mode = 'error'
local callstack = debug.traceback('', 2)
local cleaned_up_error = 'Error: ' .. cleaned_up_frame(tostring(err))..'\n'..cleaned_up_callstack(callstack)
live.send_run_time_error_to_driver(cleaned_up_error)
love.graphics.setFont(love.graphics.newFont(20))
Error_message = 'Something is wrong. Sorry!\n\n'..cleaned_up_error..'\n\n'..
"(Note: function names above don't include outer tables. So functions like on.draw might show up as just 'draw', etc.)\n\n"..
'Options:\n'..
'- press "ctrl+c" (without the quotes) to copy this message to your clipboard to send to me: ak@akkartik.com\n'..
'- press any other key to retry, see if things start working again\n'..
'- run driver.love to try to fix it yourself. As you do, feel free to ask me questions: ak@akkartik.com\n'
Error_count = Error_count+1
if Error_count > 1 then
Error_message = Error_message..('\n\nThis is error #%d in this session; things will probably not improve in this session. Please copy the message and send it to me: ak@akkartik.com.'):format(Error_count)
end
print(Error_message)
end
function live.handle_initialization_error(err)
Redo_initialization = true
live.handle_error(err)
end
function cleaned_up_callstack(callstack)
local frames = {}
for frame in string.gmatch(callstack, '[^\n]+\n*') do
table.insert(frames, cleaned_up_frame(frame))
end
return table.concat(frames, '\n\t')
end
function cleaned_up_frame(frame)
local line = frame:gsub('^%s*(.-)\n?$', '%1')
local filename, rest = line:match('([^:]*):(.*)')
return cleaned_up_filename(filename)..':'..rest
end
function cleaned_up_filename(filename)
local core_filename = filename:match('^%[string "(.*)"%]$')
if core_filename == nil then return filename end
local _, core_filename2 = core_filename:match('^(%d+)-(.+)')
return core_filename2 or core_filename
end