abortive experiment: keep definitions independent

Functions can refer to each other, but global variable initializers
shouldn't.

But this doesn't work. That comment keeps growing to capture more corner
cases.

Step back. What am I trying to achieve?

I'm not trying to create a better abstraction for programming with. I'm
trying to use an existing abstraction (LÖVE) without needing additional
tools.

I'm not supporting end-user programming, only end-programmer
programming. What happens in a regular LÖVE program if you use a global
before it's defined? You get an error, and you're on the hook to fix it.
But it's obvious what's going on because a file has an obvious sequence
of definitions. But what if you have multiple files? It's easy to lose
track of order and we mostly don't care.

The important property existing dev environments care about: merely
editing a definition doesn't _change_ the order of top-level
definitions. Let's just provide this guarantee.

We'll no longer load definitions in order of their version. Just load
definitions in the order they were created. Editing a definition doesn't
change this order. Deleting and recreating a definition puts it at the
end.
This commit is contained in:
Kartik K. Agaram 2023-04-15 17:22:37 -07:00
parent 6274322412
commit 02cdf25c1d
1 changed files with 93 additions and 0 deletions

View File

@ -36,6 +36,7 @@ function live.initialize(arg)
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.initial_env = table.copy(_G)
live.load_files_so_far()
Live.previous_read = 0
@ -257,10 +258,12 @@ function live.run(buf)
-- other commands go here
else
local definition_name = cmd
-- pre-check
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
-- perform the real evaluation and send back any real errors
local next_filename = live.versioned_filename(Live.next_version, definition_name)
love.filesystem.write(next_filename, buf)
table.insert(Live.history, definition_name)
@ -273,6 +276,67 @@ function live.run(buf)
live.send_to_driver('ERROR '..tostring(err))
return
end
-- Try running in bare environment to catch any order-dependence in
-- definitions. The basic intent is to avoid this situation:
-- define X = 3
-- define Y = X+1
-- edit X = 4
-- Now the definition of Y runs before X (because Freewheeling apps load
-- top-level definitions in version order) when X is nil, and Y starts
-- raising a confusing error. And all because we modified X. Action at a
-- distance.
--
-- To avoid this we make sure each definition is always self-contained.
--
-- This feels highly experimental. Concerns:
--
-- 1. Any side-effects in top-level definitions will run twice. Ugh.
-- But then, they will also run any time you restart the app.
-- Putting side-effects in the top-level seems like a more unnatural thing
-- for a programmer to do than defining one global in terms of another.
--
-- 2. We still won't catch situations where we depended on a global
-- variable and it used to be non-nil but now it's nil:
-- define X = 3
-- define Y = X
-- edit X = 4
-- Now Y is nil when it didn't used to be. Which can be confusing. And it
-- won't even raise any errors.
--
-- 3. I'm going to create only a shallow copy of globals. It's hard to
-- think about what real, really humongous global variables we might end
-- up accidentally copying and turning everything sluggish. We might still
-- miss some situation like:
-- define io.bar
-- define Y = io.bar -- allowed because io.bar has leaked in to the bare environment
-- So this isn't perfect.
--
-- 4. Lua's setfenv is confusing to me. I wonder if there's some situation
-- like:
-- X = foo()
-- that might cause trouble because foo() will continue to use the
-- non-initial environment.
--
-- So far it seems fine. My intent is only to catch order-dependence in
-- top-level definitions. The initial environment will have a subset of
-- definitions; we prevent the live app from mutating any of them. It seems
-- reasonable to assume that any pre-existing functions will not rely on any
-- globals some random app is going to introduce.
--
-- 5. I could probably move the setfenv into eval_in_initial_env, but that
-- feels confusing to think about given the table of bar envs is itself
-- stored in a global variable.
--
-- 6. I could probably avoid duplicating eval as eval_in_initial_env, but
-- it feels less error-prone to keep the two isolated. We never mess with
-- the env of the real eval.
setfenv(live.eval_in_initial_env, table.copy(Live.initial_env))
local status, err = live.eval_in_initial_env(buf)
if not status then
live.roll_back()
live.send_to_driver('ERROR this definition depends on other globals which can lead to hard-to-debug errors. Please keep top-level definitions order-independent. Define functions for more complex initialization.')
return
end
-- run all tests
Test_errors = {}
App.run_tests(record_error_by_test)
@ -363,6 +427,35 @@ function live.full_name(scopes, name)
return ns..'.'..name
end
-- exact copy of live.eval that has had setfenv applied to it
function live.eval_in_initial_env(buf)
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
function table.copy(h)
local result = {}
for k,v in pairs(h) do
result[k] = v
end
return result
end
function table.length(h)
local result = 0
for _ in pairs(h) do
result = result+1
end
return result
end
-- === on error, pause the app and wait for messages