-- A general architecture for free-wheeling, live programs: -- on startup: -- scan both the app directory and the save directory for files with numeric prefixes -- from the numeric prefix in file 'head', obtain a manifest -- load all files (which must start with a numeric prefix) from the manifest -- -- then start drawing frames on screen and reacting to events -- -- events from keyboard and mouse are handled as the app desires -- -- on incoming messages to a specific file, however, the app must: -- save the message's value to a new, smallest unused numeric prefix -- execute the value -- if there's an error, go back to the previous value of the same -- definition if one exists -- -- if a game encounters a run-time error, send it to the driver and await -- further instructions. The app will go unresponsive in the meantime, that -- is expected. To shut it down cleanly, type C-q in the driver. -- namespace for these functions live = {} -- state for these functions Live = {} -- a namespace of frameworky callbacks -- these will be modified live on = {} -- ========= on startup, load the version at head function live.initialize(arg) -- version control Live.head = 0 Live.next_version = 1 Live.history = {} -- array of filename roots corresponding to each numeric prefix Live.manifest = {} -- mapping from roots to numeric prefixes as of version Live.head live.freeze_all_existing_definitions() live.load_files_so_far() Live.previous_read = 0 if on.load then on.load() end end -- Everything that exists before we start loading the live files is frozen and -- can't be edited live. function live.freeze_all_existing_definitions() Live.frozen_definitions = {on=true} -- special case for version 1 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) -- track duplicates to avoid cycles like _G._G, _G._G._G, etc. if done[tab] then return end done[tab] = true for name,binding in pairs(tab) do local full_name = live.full_name(scopes, name) --? print(full_name) Live.frozen_definitions[full_name] = true if type(binding) == 'table' and full_name ~= 'package' then -- var 'package' contains copies of all modules, but not the best name; rely on people to not modify package.loaded.io.open, etc. 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.load_files_so_far() print('new edits will go to ' .. love.filesystem.getSaveDirectory()) local files = {} live.append_files_with_numeric_prefix('', files) table.sort(files) live.check_integrity(files) live.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files) table.sort(files) live.check_integrity(files) Live.history = live.load_history(files) Live.next_version = #Live.history + 1 local head_string = love.filesystem.read('head') Live.head = tonumber(head_string) if Live.head > 0 then Live.manifest = json.decode(love.filesystem.read(live.versioned_manifest(Live.head))) end live.load_everything_in_manifest() end function live.append_files_with_numeric_prefix(dir, files) for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do if file:match('^%d') then table.insert(files, file) end end end function live.check_integrity(files) local manifest_found, file_found = false, false local expected_index = 1 for _,file in ipairs(files) do for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do -- only runs once local index = tonumber(numeric_prefix) -- skip files without numeric prefixes if index ~= nil then if index < expected_index then print(index, expected_index) end assert(index >= expected_index) if index > expected_index then assert(index == expected_index+1) assert(manifest_found and file_found) expected_index = index manifest_found, file_found = false, false end assert(index == expected_index) if root == 'fwmanifest' then assert(not manifest_found) manifest_found = true else assert(not file_found) file_found = true end end end end end function live.load_history(files) local result = {} for _,file in ipairs(files) do for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do -- only runs once local index = tonumber(numeric_prefix) -- skip if index ~= nil then if root ~= 'fwmanifest' then assert(index == #result+1) table.insert(result, root) end end end end return result end function live.load_everything_in_manifest() local files_to_load = {} for k,v in pairs(Live.manifest) do -- Most keys in the manifest are definitions. If we need to store any -- metadata we'll do it in keys starting with a specific prefix. if not starts_with(k, 'fw_') then local root, index = k, v local filename = live.versioned_filename(index, root) table.insert(files_to_load, filename) end end table.sort(files_to_load) for _,filename in ipairs(files_to_load) do local buf = love.filesystem.read(filename) assert(buf and buf ~= '') local status, err = live.eval(buf) if status == nil then error(('error loading %s from manifest: %s'):format(filename, err)) end end end PARENT = 'fw_parent' APP = 'fw_app' function live.versioned_filename(index, root) return ('%04d-%s'):format(index, root) end function live.versioned_manifest(index) return ('%04d-fwmanifest'):format(index) end -- ========= on each frame, check for messages and alter the app as needed function live.update(dt) if Current_time - Live.previous_read > 0.1 then local buf = live.receive_from_driver() if buf then live.run(buf) if on.code_change then on.code_change() end end Live.previous_read = Current_time end end -- look for a message from outside, and return nil if there's nothing 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 -- empty file == no message print('<='..color(--[[bold]]1, --[[blue]]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(0, --[[green]]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(0, --[[red]]1)) print(msg) print(reset_terminal()) end -- args: -- format: 0 for normal, 1 for bold -- color: 0-15 function color(format, color) return ('\027[%d;%dm'):format(format, 30+color) end function reset_terminal() return '\027[m' end -- define or undefine top-level bindings 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.manifest[APP] = love.filesystem.getIdentity() -- doesn't need to be persisted, but no harm if it does.. live.send_to_driver(json.encode(Live.manifest)) elseif cmd == 'DELETE' then local definition_name = buf:match('^%S+%s+(%S+)') Live.manifest[definition_name] = nil live.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil` local next_filename = live.versioned_filename(Live.next_version, definition_name) love.filesystem.write(next_filename, '') table.insert(Live.history, definition_name) live.roll_forward() elseif cmd == 'GET' then local definition_name = buf:match('^%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 -- batch version of GET 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) -- send a final delimiter to simplify the driver's task elseif cmd == 'DEFAULT_MAP' then local contents = love.filesystem.read('default_map') if contents == nil then contents = '{}' end live.send_to_driver(contents) -- other commands go here else local definition_name = cmd 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 next_filename = live.versioned_filename(Live.next_version, definition_name) love.filesystem.write(next_filename, buf) table.insert(Live.history, definition_name) Live.manifest[definition_name] = Live.next_version live.roll_forward() local status, err = live.eval(buf) if not status then live.roll_back() -- throw an error live.send_to_driver('ERROR '..tostring(err)) return end -- run all tests Test_errors = {} App.run_tests(record_error_by_test) live.send_to_driver(json.encode(Test_errors)) end end -- update Live.Head and record the new Live.Manifest (which caller has already modified) function live.roll_forward() Live.manifest[PARENT] = Live.head local manifest_filename = live.versioned_manifest(Live.next_version) love.filesystem.write(manifest_filename, json.encode(Live.manifest)) Live.head = Live.next_version love.filesystem.write('head', tostring(Live.head)) Live.next_version = Live.next_version + 1 end -- update app.Head and reload app.Manifest appropriately function live.roll_back() Live.head = Live.manifest[PARENT] love.filesystem.write('head', tostring(Live.head)) local previous_manifest_filename = live.versioned_manifest(Live.head) Live.manifest = json.decode(love.filesystem.read(previous_manifest_filename)) end function live.get_cmd_from_buffer(buf) return buf:match('^%s*(%S+)') end function live.get_binding(name) if Live.manifest[name] then return love.filesystem.read(live.versioned_filename(Live.manifest[name], name)) end end -- Wrapper for Lua's weird evaluation model. -- Lua is persnickety about expressions vs statements, so we need to do some -- extra work to get the result of an evaluation. -- return values: -- all well -> true, ... -- load failed -> nil, error message -- run failed -> false, error message function live.eval(buf) -- We assume a program is either correct with 'return' prefixed xor not. -- Is this correct? Who knows! But the Lua REPL does this as well. local f = load('return '..buf, 'REPL') if f then return pcall(f) end local f, err = load(buf, 'REPL') if f then return pcall(f) else return nil, err end end -- ========= on error, pause the app and wait for messages -- return nil to continue the event loop, non-nil to quit function live.handle_error(err) local msg = tostring(err) -- draw a pause indicator on screen love.graphics.setColor(1,0,0) love.graphics.rectangle('fill', 10,10, 3,10) love.graphics.rectangle('fill', 16,10, 3,10) love.graphics.present() -- print stack trace here just in case we ran the app through a terminal local stack_trace = debug.traceback('Error: '..msg, --[[stack frame]]2):gsub('\n[^\n]+$', '') print(stack_trace) print('Look in the driver for options to investigate further.') print("(You probably can't close the app window at this point. If you don't have the driver set up, you might need to force-quit.)") -- send stack trace to driver and wait for a response live.send_run_time_error_to_driver(stack_trace) local buf repeat buf = live.receive_from_driver() love.timer.sleep(0.001) until buf if buf == 'QUIT' then return true end live.run(buf) end