snap.love/live.lua

322 lines
10 KiB
Lua

-- A general architecture for free-wheeling, live programs:
-- on startup:
-- scan both the app directory and the save directory for files with numeric prefixes
-- load files in order
--
-- 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, the app must:
-- determine the definition name from the first word
-- execute the value, returning any errors
-- look up the filename for the definition or define a new filename for it
-- save the message's value to the filename
--
-- 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 all files with numeric prefix
function live.initialize(arg)
live.freeze_all_existing_definitions()
-- version control
Live.filenames_to_load = {} -- filenames in order of numeric prefix
Live.filename = {} -- map from definition name to filename (including numeric prefix)
Live.final_prefix = 0
live.load_files_so_far()
-- some hysteresis
Live.previous_read = 0
if on.load then on.load() end
end
function live.load_files_so_far()
print('new edits will go to ' .. love.filesystem.getSaveDirectory())
-- if necessary, copy files from repo to save dir
if io.open(love.filesystem.getSaveDirectory()..'/0000-freewheeling-start') == nil then
print('copying all definitions from repo to save dir')
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do
-- only runs once
local buf = love.filesystem.read(filename)
print('copying', filename)
love.filesystem.write(filename, buf)
end
end
end
-- load files from save dir
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do
-- only runs once
if tonumber(numeric_prefix) > 0 then -- skip 0000
Live.filename[root] = filename
table.insert(Live.filenames_to_load, filename)
Live.final_prefix = math.max(Live.final_prefix, tonumber(numeric_prefix))
end
end
end
table.sort(Live.filenames_to_load)
live.load_all(files)
end
function live.load_all()
for _,filename in ipairs(Live.filenames_to_load) do
--? print('loading', filename)
local buf = love.filesystem.read(filename)
assert(buf and buf ~= '')
local status, err = live.eval(buf)
if not status then
return err
end
end
end
APP = 'fw_app'
-- === 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.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.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') -- ignore errors which will likely be from keywords like `function = nil`
love.filesystem.remove(Live.filename[definition_name])
Live.filename[definition_name] = nil
end
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
-- 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 status, err = live.eval(buf)
if not status then
-- throw an error
live.send_to_driver('ERROR '..tostring(err))
return
end
-- eval succeeded without errors; persist the definition
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
love.filesystem.write(filename, buf)
-- run all tests
Test_errors = {}
App.run_tests(record_error_by_test)
live.send_to_driver(json.encode(Test_errors))
end
end
function live.get_cmd_from_buffer(buf)
return buf:match('^%s*(%S+)')
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
-- 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 (pcall) 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
-- === infrastructure for performing safety checks on any new definition
-- 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
-- === 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