little tool for building Wardley maps
-- 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
      local numeric_prefix, root = filename:match('^(%d+)-(.+)')
      if numeric_prefix then
        local buf = love.filesystem.read(filename)
        print('copying', filename)
        love.filesystem.write(filename, buf)
      end
    end
  end
  -- list files to load in save dir
  -- (ignore the repo because we might have deleted definitions)
  for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
    if io.open(love.filesystem.getSaveDirectory()..'/'..filename) then
      local numeric_prefix, root = filename:match('^(%d+)-(.+)')
      if numeric_prefix and 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)
  -- load files from save dir
  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
      error(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)
      Mode = 'run'
      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.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')  -- ignore errors which will likely be from keywords like `function = nil`
      love.filesystem.remove(Live.filename[definition_name])
      Live.filename[definition_name] = nil
    end
    live.send_to_driver('{}')
  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 = live.get_definition_name_from_buffer(buf)
    if definition_name == nil then
      -- contents are all Lua comments; we don't currently have a plan for them
      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)
    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 the first word
  return buf:match('^%s*(%S+)')
end

function live.get_definition_name_from_buffer(buf)
  return first_noncomment_word(buf)
end

-- return the first word (separated by whitespace) that's not in a Lua comment
-- or empty string if there's nothing
-- ignore strings; we don't expect them to be the first word in a program
function first_noncomment_word(str)
  local pos = 1
  while pos <= #str do  -- not Unicode-aware; hopefully it doesn't need to be
    if str:sub(pos,pos) == '-' then
      -- skip any comments
      if str:sub(pos+1,pos+1) == '-' then
        -- definitely start of a comment
        local long_comment_header = str:match('^%[=*%[', pos+2)
        if long_comment_header then
          -- long comment
          local long_comment_trailer = long_comment_header:gsub('%[', ']')
          pos = str:find(long_comment_trailer, pos, --[[plain]]true)
          if pos == nil then return '' end  -- incomplete comment; no first word
          pos = pos + #long_comment_trailer
        else
          -- line comment
          pos = str:find('\n', pos)
          if pos == nil then return '' end  -- incomplete comment; no first word
        end
      end
    end
    -- any non-whitespace that's not a comment is the first word
    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

-- 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)
  Mode = 'error'
  local stack_trace = debug.traceback('Error: ' .. tostring(err), --[[stack frame]]2):gsub('\n[^\n]+$', '')
  live.send_run_time_error_to_driver(stack_trace)
  Error_message = 'Something is wrong. Sorry!\n\n'..stack_trace..'\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