1
0
Fork 0
mirror of https://github.com/Reuh/anselme.git synced 2025-10-28 00:59:31 +00:00

Anselme v2.0.0-alpha rewrite

Woke up and felt like changing a couple things. It's actually been worked on for a while, little at a time...

The goal was to make the language and implementation much simpler. Well I don't know if it really ended up being simpler but it sure is more robust.

Main changes:
* proper first class functions and closures supports! proper scoping rules! no more namespace shenanigans!
* everything is an expression, no more statements! make the implementation both simpler and more complex, but it's much more consistent now! the syntax has massively changed as a result though.
* much more organized and easy to modify codebase: one file for each AST node, no more random fields or behavior set by some random node exceptionally, everything should now follow the same API defined in ast.abstract.Node

Every foundational feature should be implemented right now. The vast majority of things that were possible in v2 are possible now; some things aren't, but that's usually because v2 is a bit more sane.
The main missing things before a proper release are tests and documentation. There's a few other things that might be implemented later, see the ideas.md file.
This commit is contained in:
Étienne Fildadut 2023-12-17 17:15:16 +01:00
parent 2ff494d108
commit fe351b5ca4
484 changed files with 7099 additions and 18084 deletions

View file

@ -1,794 +1,80 @@
--- anselme main module
--- The main module.
--- Anselme Lua API reference
--
-- We actively support LuaJIT and Lua 5.4. Lua 5.1, 5.2 and 5.3 *should* work but I don't always test against them.
--
-- This documentation is generated from the main module file `anselme.lua` using `ldoc --ext md anselme.lua`.
--
-- Example usage:
-- Naming conventions:
-- * Classes
-- * everything_else
-- * (note: "classes" that are not meat to be instancied and are just here to benefit from inheritance fall into everything_else, e.g. parsing classes)
--- Usage:
-- ```lua
-- local anselme = require("anselme") -- load main module
-- local anselme = require("anselme")
--
-- local vm = anselme() -- create new VM
-- vm:loadgame("game") -- load some scripts, etc.
-- local interpreter = vm:rungame() -- create a new interpreter using what was loaded with :loadgame
-- -- create a new state
-- local state = anselme.new()
-- state:load_stdlib()
--
-- -- simple function to convert text event data into a string
-- -- in your game you may want to handle tags, here we ignore them for simplicity
-- local function format_text(text)
-- local r = ""
-- for _, l in ipairs(t) do
-- r = r .. l.text
-- end
-- return r
-- -- read an anselme script file
-- local f = assert(io.open("script.ans"))
-- local script = anselme.parse(f:read("*a"), "script.ans")
-- f:close()
--
-- -- load the script in a new branch
-- local run_state = state:branch()
-- run_state:run(script)
--
-- -- run the script
-- while run_state:active() do
-- local e, data = run_state:step()
-- if e == "text" then
-- for _, l in ipairs(data) do
-- print(l:format(run_state))
-- end
-- elseif e == "choice" then
-- for i, l in ipairs(data) do
-- print(("%s> %s"):format(i, l:format(run_state)))
-- end
-- local choice = tonumber(io.read("*l"))
-- data:choose(choice)
-- elseif e == "return" then
-- run_state:merge()
-- elseif e == "error" then
-- error(data)
-- end
-- end
--
-- -- event loop
-- repeat
-- local event, data = interpreter:step() -- progress script until next event
-- if event == "text" then
-- print(format_text(d))
-- elseif event == "choice" then
-- for j, choice in ipairs(d) do
-- print(j.."> "..format_text(choice))
-- end
-- interpreter:choose(io.read())
-- elseif event == "error" then
-- error(data)
-- end
-- until t == "return" or t == "error"
-- ```
--
-- Calling the Anselme main module will create a return a new [VM](#vms).
--
-- The main module also contain a few fields:
--
-- @type anselme
local anselme = {
--- Anselme version information table.
--
-- Contains version informations as number (higher means more recent) of Anselme divied in a few categories:
--
-- * `save`, which is incremented at each update which may break save compatibility
-- * `language`, which is incremented at each update which may break script file compatibility
-- * `api`, which is incremented at each update which may break Lua API compatibility
local parser = require("parser")
local State = require("state.State")
require("ast.abstract.Node"):_i_hate_cycles()
return {
--- Global version string. Follow semver.
version = "2.0.0-alpha",
--- Table containing per-category version numbers. Incremented by one for any change that may break compatibility.
versions = {
save = 2,
language = 25,
api = 6
--- Version number for languages and standard library changes.
language = 27,
--- Version number for save/AST format changes.
save = 4,
--- Version number for Lua API changes.
api = 8
},
--- General version number.
--
-- It is incremented at each update.
version = 26,
--- Currently running [interpreter](#interpreters).
-- `nil` if no interpreter running.
running = nil
}
package.loaded[...] = anselme
-- load libs
local anselme_root = (...):gsub("anselme$", "")
local preparse = require(anselme_root.."parser.preparser")
local postparse = require(anselme_root.."parser.postparser")
local expression = require(anselme_root.."parser.expression")
local eval = require(anselme_root.."interpreter.expression")
local injections = require(anselme_root.."parser.common").injections
local run_line = require(anselme_root.."interpreter.interpreter").run_line
local run = require(anselme_root.."interpreter.interpreter").run
local to_lua = require(anselme_root.."interpreter.common").to_lua
local merge_state = require(anselme_root.."interpreter.common").merge_state
local stdfuncs = require(anselme_root.."stdlib.functions")
local bootscript = require(anselme_root.."stdlib.bootscript")
local copy = require(anselme_root.."common").copy
local should_be_persisted = require(anselme_root.."interpreter.common").should_be_persisted
local check_persistable = require(anselme_root.."interpreter.common").check_persistable
-- wrappers for love.filesystem / luafilesystem
local function list_directory(path)
local t = {}
if love then
t = love.filesystem.getDirectoryItems(path)
else
local lfs = require("lfs")
for item in lfs.dir(path) do
table.insert(t, item)
end
end
return t
end
local function is_directory(path)
if love then
return not not love.filesystem.getInfo(path, "directory")
else
local lfs = require("lfs")
return lfs.attributes(path, "mode") == "directory"
end
end
local function is_file(path)
if love then
return not not love.filesystem.getInfo(path, "file")
else
local lfs = require("lfs")
return lfs.attributes(path, "mode") == "file"
end
end
--- Interpreters
--
-- An interpreter is in charge of running Anselme code and is spawned from a [VM](#vms).
-- Several interpreters from the same VM can run at the same time.
--
-- Typically, you would have a interpreter for each script that need at the same time, for example one for every NPC
-- that is currently talking.
--
-- Each interpreter can only run one script at a time, and will run it sequentially.
-- You can advance in the script by calling the `:step` method, which will run the script until an event is sent (for example some text needs to be displayed),
-- which will pause the whole interpreter until `:step` is called again.
--
-- @type interpreter
local interpreter_methods = {
--- interpreter state
-- for internal use, you shouldn't touch this
-- @local
state = nil,
--- [VM](#vms) this interpreter belongs to.
vm = nil,
--- String, type of the event that stopped the interpreter (`nil` if interpreter is still running).
end_event = nil,
--- Run the interpreter until the next event.
-- Returns event type (string), data (any).
--- Parse a `code` string and return the generated AST.
--
-- Will merge changed variables on successful script end.
-- `source` is an optional string; it will be used as the code source name in error messages.
--
-- If event is `"return"` or `"error"`, the interpreter can not be stepped further and should be discarded.
--
-- Default event types and their associated data:
-- * `text`: text to display, data is a list of text elements, each with a `text` field, containing the text contents, and a `tags` field, containing the tags associated with this text
-- * `choice`: choices to choose from, data is a list of choices Each of these choice is a list of text elements like for the `text` event
-- * `return`: when the script ends, data is the returned value (`nil` if nothing returned)
-- * `error`: when there is an error, data is the error message.
--
-- See [LANGUAGE.md](LANGUAGE.md) for more details on events.
step = function(self)
-- check status
if self.end_event then
return "error", ("interpreter can't be restarted after receiving a %s event"):format(self.end_event)
end
if coroutine.status(self.state.interpreter.coroutine) ~= "suspended" then
return "error", ("can't step interpreter because it has already finished or is already running (coroutine status: %s)"):format(coroutine.status(self.state.interpreter.coroutine))
end
-- handle interrupt
if self.state.interpreter.interrupt then
local expr = self.state.interpreter.interrupt
if expr == true then
return "return", "" -- nothing to do after interrupt
else
local line = self.state.interpreter.running_line
local namespace = self:current_namespace()
-- replace state with interrupted state
local exp, err = expression(expr, self.state.interpreter.global_state, namespace or "", "interpreter:interrupt")
if not exp then return "error", ("%s; during interrupt %q at %s"):format(err, expr, line and line.source or "unknown") end
local r, e = self.vm:run(exp)
if not r then return "error", e end
self.state = r.state
end
end
-- run
local previous = anselme.running
anselme.running = self
local success, event, data = coroutine.resume(self.state.interpreter.coroutine)
anselme.running = previous
if not success then event, data = "error", event end
if event == "return" then merge_state(self.state) end
if event == "return" or event == "error" then self.end_event = event end
return event, data
-- Usage:
-- ```lua
-- local ast = anselme.parse("1 + 2", "test")
-- ast:eval()
-- ```
parse = function(code, source)
return parser(code, source)
end,
--- Select a choice.
-- `i` is the index (number) of the choice in the choice list (from the choice event's data).
--
-- The choice will be selected on the next interpreter step.
--
-- Returns this interpreter.
choose = function(self, i)
self.state.interpreter.choice_selected = tonumber(i)
return self
end,
--- Interrupt (abort the currently running script) the interpreter on the next step, executing an expression (string, if specified) in the current namespace instead.
--
-- Returns this interpreter.
interrupt = function(self, expr)
self.state.interpreter.interrupt = expr or true
return self
end,
--- Returns the namespace (string) the last ran line belongs to.
current_namespace = function(self)
local line = self.state.interpreter.running_line
local namespace = ""
if line then
local cur_line = line
namespace = cur_line.namespace
while not namespace do
local block = cur_line.parent_block
if not block.parent_line then break end -- reached root
cur_line = block.parent_line
namespace = cur_line.namespace
end
end
return namespace
end,
--- Run an expression (string) or block, optionally in a specific namespace (string, will use root namespace if not specified).
-- This may trigger events and must be called from within the interpreter coroutine (i.e. from a function called from a running script).
--
-- No automatic merge if this change the interpreter state, merge is done once we reach end of script in a call to `:step` as usual.
--
-- Returns the returned value (nil if nothing returned).
run = function(self, expr, namespace)
-- check status
if coroutine.status(self.state.interpreter.coroutine) ~= "running" then
error("run must be called from whithin the interpreter coroutine")
end
-- parse
local err
if type(expr) ~= "table" then expr, err = expression(tostring(expr), self.state.interpreter.global_state, namespace or "", "interpreter:run") end
if not expr then coroutine.yield("error", err) end
-- run
local r, e
if expr.type == "block" then
r, e = run(self.state, expr)
else
r, e = eval(self.state, expr)
end
if not r then coroutine.yield("error", e) end
if self.state.interpreter.current_event then -- flush final events
local rf, re = run_line(self.state, { type = "flush events" })
if re then coroutine.yield("error", re) end
if rf then r = rf end
end
return to_lua(r, self.state)
end,
--- Evaluate an expression (string) or block, optionally in a specific namespace (string, will use root namespace if not specified).
-- The expression can't yield events.
-- Can be called from outside the interpreter coroutine. Will create a new coroutine that operate on this interpreter state.
--
-- No automatic merge if this change the interpreter state, merge is done once we reach end of script in a call to `:step` as usual.
--
-- Returns the returned value in case of success (nil if nothing returned).
--
-- Returns nil, error message in case of error.
eval = function(self, expr, namespace)
if self.end_event then
return "error", ("interpreter can't be restarted after receiving a %s event"):format(self.end_event)
end
-- parse
local err
if type(expr) ~= "table" then expr, err = expression(tostring(expr), self.state.interpreter.global_state, namespace or "", "interpreter:eval") end
if not expr then return nil, err end
-- run
local co = coroutine.create(function()
local r, e
if expr.type == "block" then
r, e = run(self.state, expr)
else
r, e = eval(self.state, expr)
end
if not r then return "error", e end
return "return", r
end)
local previous = anselme.running
anselme.running = self
local success, event, data = coroutine.resume(co)
anselme.running = previous
if not success then
return nil, event
elseif event == "error" then
self.end_event = "error"
return nil, data
elseif event ~= "return" then
return nil, ("evaluated expression generated an %q event; at %s"):format(event, self.state.interpreter.running_line.source)
else
return to_lua(data, self.state)
end
--- Return a new [State](#state).
new = function()
return State:new()
end,
}
interpreter_methods.__index = interpreter_methods
--- VMs
--
-- A VM stores the state required to run Anselme scripts. Each VM is completely independant from each other.
--
-- @type vm
local vm_mt = {
--- anselme state
-- for internal use, you shouldn't touch this
-- @local
state = nil,
--- loaded game state
-- for internal use, you shouldn't touch this
-- @local
game = nil,
--- Wrapper for loading a whole set of scripts (a "game").
-- Should be preferred to other loading functions if possible as this sets all the common options on its own.
--
-- Requires LÖVE or LuaFileSystem.
--
-- Will load from the directory given by `path` (string), in order:
-- * `config.ans`, which will be executed in the "config" namespace and may contains various optional configuration options:
-- * `anselme version`: number, version of the anselme language this game was made for
-- * `game version`: any, version information of the game. Can be used to perform eventual migration of save with an old version in the main file.
-- Always included in saved variables.
-- * `language`: string, built-in language file to load
-- * `inject directory`: string, directory that may contain "function start.ans", "checkpoint end.ans", etc. which content will be used to setup
-- the custom code injection methods (see vm:setinjection)
-- * `global directory`: string, path of global script directory. Every script file and subdirectory in the path will be loaded in the global namespace.
-- * `start expression`: string, expression that will be ran when starting the game
-- * files in the global directory, if defined in config.ans
-- * every other file in the path and subdirectories, using their path as namespace (i.e., contents of path/world1/john.ans will be defined in a function world1.john)
--
-- Returns this VM in case of success.
--
-- Returns nil, error message in case of error.
loadgame = function(self, path)
if self.game then error("game already loaded") end
-- load config
if is_file(path.."/config.ans") then
local s, e = self:loadfile(path.."/config.ans", "config")
if not s then return s, e end
s, e = self:eval("config")
if e then return s, e end
end
-- get config
self.game = {
anselme_version = self:eval("config.anselme version"),
game_version = self:eval("config.game version"),
language = self:eval("config.language"),
inject_directory = self:eval("config.inject directory"),
global_directory = self:eval("config.global directory"),
start_expression = self:eval("config.start expression")
}
-- check language version
if self.game.anselme_version and self.game.anselme_version ~= anselme.versions.language then
return nil, ("trying to load game made for Anselme language %s, but currently using version %s"):format(self.game.anselme_version, anselme.versions.language)
end
-- load language
if self.game.language then
local s, e = self:loadlanguage(self.game.language)
if not s then return s, e end
end
-- load injections
if self.game.inject_directory then
for inject, ninject in pairs(injections) do
local f = io.open(path.."/"..self.game.inject_directory.."/"..inject..".ans", "r")
if f then
self.state.inject[ninject] = f:read("*a")
f:close()
end
end
end
-- load global scripts
for _, item in ipairs(list_directory(path.."/"..self.game.global_directory)) do
if item:match("[^%.]") then
local p = path.."/"..self.game.global_directory.."/"..item
local s, e
if is_directory(p) then
s, e = self:loaddirectory(p)
elseif item:match("%.ans$") then
s, e = self:loadfile(p)
end
if not s then return s, e end
end
end
-- load other files
for _, item in ipairs(list_directory(path)) do
if item:match("[^%.]") and
item ~= "config.ans" and
item ~= self.game.global_directory and
item ~= self.game.inject_directory
then
local p = path.."/"..item
local s, e
if is_directory(p) then
s, e = self:loaddirectory(p, item)
elseif item:match("%.ans$") then
s, e = self:loadfile(p, item:gsub("%.ans$", ""))
end
if not s then return s, e end
end
end
return self
end,
--- Return a interpreter which runs the game start expression (if given).
--
-- Returns interpreter in case of success.
--
-- Returns nil, error message in case of error.
rungame = function(self)
if not self.game then error("no game loaded") end
if self.game.start_expression then
return self:run(self.game.start_expression)
else
return self:run("()")
end
end,
--- Load code from a string.
-- Similar to Lua's code loading functions.
--
-- Compared to their Lua equivalents, these also take an optional `name` argument (default="") that set the namespace to load the code in. Will define a new function is specified; otherwise, code will be parsed but not executable from an expression (as it is not named).
--
-- Returns parsed block in case of success.
--
-- Returns nil, error message in case of error.
loadstring = function(self, str, name, source)
local s, e = preparse(self.state, str, name or "", source)
if not s then return s, e end
return s
end,
--- Load code from a file.
-- See `vm:loadstring`.
loadfile = function(self, path, name)
local content
if love then
local e
content, e = love.filesystem.read(path)
if not content then return content, e end
else
local f, e = io.open(path, "r")
if not f then return f, e end
content = f:read("*a")
f:close()
end
local s, err = self:loadstring(content, name, path)
if not s then return s, err end
return s
end,
-- Load every file in a directory, using filename (without .ans extension) as its namespace.
--
-- Requires LÖVE or LuaFileSystem.
--
-- Returns this VM in case of success.
--
-- Returns nil, error message in case of error.
loaddirectory = function(self, path, name)
if not name then name = "" end
name = name == "" and "" or name.."."
for _, item in ipairs(list_directory(path)) do
if item:match("[^%.]") then
local p = path.."/"..item
local s, e
if is_directory(p) then
s, e = self:loaddirectory(p, name..item)
elseif item:match("%.ans$") then
s, e = self:loadfile(p, name..item:gsub("%.ans$", ""))
end
if not s then return s, e end
end
end
return self
end,
--- Set aliases for built-in variables 👁️, 🔖 and 🏁 that will be defined on every new checkpoint and function.
-- This does not affect variables that were defined before this function was called.
-- Set to nil for no alias.
--
-- Returns this VM.
setaliases = function(self, seen, checkpoint, reached)
self.state.builtin_aliases["👁️"] = seen
self.state.builtin_aliases["🔖"] = checkpoint
self.state.builtin_aliases["🏁"] = reached
return self
end,
--- Set some code that will be injected at specific places in all code loaded after this is called.
-- Can typically be used to define variables for every function like 👁️, setting some value on every function resume, etc.
--
-- Possible inject types:
-- * `"function start"`: injected at the start of every non-scoped function
-- * `"function end"`: injected at the end of every non-scoped function
-- * `"function return"`: injected at the end of each return's children that is contained in a non-scoped function
-- * `"checkpoint start"`: injected at the start of every checkpoint
-- * `"checkpoint end"`: injected at the end of every checkpoint
-- * `"class start"`: injected at the start of every class
-- * `"class end"`: injected at the end of every class
-- * `"scoped function start"`: injected at the start of every scoped function
-- * `"scoped function end"`: injected at the end of every scoped function
-- * `"scoped function return"`: injected at the end of each return's children that is contained in a scoped function
--
-- Set `code` to nil to disable the inject.
--
-- Returns this VM.
setinjection = function(self, inject, code)
assert(injections[inject], ("unknown injection type %q"):format(inject))
self.state.inject[injections[inject]] = code
return self
end,
--- Load and execute a built-in language file.
--
-- The language file may optionally contain the special variables:
-- * alias 👁️: string, default alias for 👁️
-- * alias 🏁: string, default alias for 🏁
-- * alias 🔖: string, default alias for 🔖
--
-- Returns this VM in case of success.
--
-- Returns nil, error message in case of error.
loadlanguage = function(self, lang)
local namespace = "anselme.languages."..lang
-- execute language file
local code = require(anselme_root.."stdlib.languages."..lang)
local s, e = self:loadstring(code, namespace, lang)
if not s then return s, e end
s, e = self:eval(namespace)
if e then return s, e end
-- set aliases for built-in variables
local seen_alias = self:eval(namespace..".alias 👁️")
local checkpoint_alias = self:eval(namespace..".alias 🔖")
local reached_alias = self:eval(namespace..".alias 🏁")
self:setaliases(seen_alias, checkpoint_alias, reached_alias)
return self
end,
--- Define functions from Lua.
--
-- * `signature`: string, full signature of the function
-- * `fn`: function (Lua function or table, see examples in `stdlib/functions.lua`)
--
-- Alternatively, can also take a table as a sole argument to load several functions: { ["signature"] = fn, ... }
--
-- Returns this VM.
loadfunction = function(self, signature, fn)
if type(signature) == "table" then
for k, v in pairs(signature) do
local s, e = self:loadfunction(k, v)
if not s then return nil, e end
end
else
if type(fn) == "function" then fn = { value = fn } end
self.state.link_next_function_definition_to_lua_function = fn
local s, e = self:loadstring(":$"..signature, "", "lua")
if not s then return nil, e end
assert(self.state.link_next_function_definition_to_lua_function == nil, "unexpected error while defining lua function")
return self
end
return self
end,
--- Save/load script state
--
-- Only saves persistent variables' full names and values.
-- Make sure to not change persistent variables names, class name, class attribute names, checkpoint names and functions names between a
-- save and a load (alias can of course be changed), as Anselme will not be able to match them to the old names stored in the save file.
--
-- If a variable is stored in the save file but is not marked as persistent in the current scripts (e.g. if you updated the Anselme scripts to
-- remove the persistence), it will not be loaded.
--
-- Loading should be done after loading all the game scripts (otherwise you will get "variable already defined" errors).
--
-- Returns this VM.
load = function(self, data)
assert(anselme.versions.save == data.anselme.versions.save, ("trying to load data from an incompatible version of Anselme; save was done using save version %s but current version is %s"):format(data.anselme.versions.save, anselme.versions.save))
for k, v in pairs(data.variables) do
if self.state.variable_metadata[k] then
if self.state.variable_metadata[k].persistent then
self.state.variables[k] = v
end
else
self.state.variables[k] = v -- non-existent variable: keep it in case there was a mistake, it's not going to affect anything anyway
end
end
return self
end,
--- Save script state.
-- See `vm:load`.
--
-- Returns save data in case of success.
--
-- Returns nil, error message in case of error.
save = function(self)
local vars = {}
for k, v in pairs(self.state.variables) do
if should_be_persisted(self.state, k, v) then
local s, e = check_persistable(v)
if not s then return nil, ("%s; while saving variable %s"):format(e, k) end
vars[k] = v
end
end
return {
anselme = {
versions = anselme.versions,
version = anselme.version
},
variables = vars
}
end,
--- Perform parsing that needs to be done after loading code.
-- This is automatically ran before starting an interpreter, but you may want to execute it before if you want to check for parsing error manually.
--
-- Returns self in case of success.
--
-- Returns nil, error message in case of error.
postload = function(self)
if #self.state.queued_lines > 0 then
local r, e = postparse(self.state)
if not r then return nil, e end
end
return self
end,
--- Enable feature flags.
-- Available flags:
-- * `"strip trailing spaces"`: remove trailing spaces from choice and text events (enabled by default)
-- * `"strip duplicate spaces"`: remove duplicated spaces between text elements from choice and text events (enabled by default)
--
-- Returns this VM.
enable = function(self, ...)
for _, flag in ipairs{...} do
self.state.feature_flags[flag] = true
end
return self
end,
--- Disable features flags.
-- Returns this VM.
disable = function(self, ...)
for _, flag in ipairs{...} do
self.state.feature_flags[flag] = nil
end
return self
end,
--- Run code.
-- Will merge state after successful execution
--
-- * `expr`: expression to evaluate (string or parsed expression), or a block to run
-- * `namespace`(default=""): namespace to evaluate the expression in
-- * `tags`(default={}): defaults tags when evaluating the expression (Lua value)
--
-- Return interpreter in case of success.
--
-- Returns nil, error message in case of error.
run = function(self, expr, namespace, tags)
local s, e = self:postload()
if not s then return s, e end
--
local err
if type(expr) ~= "table" then expr, err = expression(tostring(expr), self.state, namespace or "", "vm:run") end
if not expr then return expr, err end
-- interpreter state
local interpreter
interpreter = {
state = {
inject = self.state.inject,
feature_flags = self.state.feature_flags,
builtin_aliases = self.state.builtin_aliases,
aliases = setmetatable({}, { __index = self.state.aliases }),
functions = self.state.functions, -- no need for a cache as we can't define or modify any function from the interpreter for now
variable_metadata = self.state.variable_metadata, -- no cache as metadata are expected to be constant
variables = setmetatable({}, {
__index = function(variables, k)
local cache = getmetatable(variables).cache
if cache[k] == nil then
cache[k] = copy(self.state.variables[k], getmetatable(variables).copy_cache)
end
return cache[k]
end,
-- variables that keep current state and should be cleared at each checkpoint
cache = {}, -- cache of previously read values (copies), to get repeatable reads & handle mutable types without changing global state
modified_tables = {}, -- list of modified tables (copies) that should be merged with global state on next checkpoint
copy_cache = {}, -- table of [original table] = copied table. Automatically filled by copy().
-- keep track of scoped variables in scoped functions [fn line] = {{scoped variables}, next scope, ...}
-- (scoped variables aren't merged on checkpoint, shouldn't be cleared at checkpoints)
-- (only stores scoped variables that have been reassigned at some point (i.e. every accessed one since they start as undefined))
scoped = {}
}),
interpreter = {
-- constant
global_state = self.state,
coroutine = coroutine.create(function() return "return", interpreter:run(expr, namespace) end),
-- status
running_line = nil,
-- choice event
choice_selected = nil,
-- skip next choices until next event change (to skip currently running choice block when resuming from a checkpoint)
skip_choices_until_flush = nil,
-- active event buffer stack
event_buffer_stack = {},
-- current event waiting to be sent
current_event = nil,
-- interrupt
interrupt = nil,
-- tag stack
tags = {},
-- default tags for everything in this interpreter (Lua values)
base_lua_tags = tags,
},
},
vm = self
}
return setmetatable(interpreter, interpreter_methods)
end,
--- Evaluate code.
-- Behave like `:run`, except the expression can not emit events and will return the result of the expression directly.
-- Merge state after sucessful execution automatically like `:run`.
--
-- * `expr`: expression to evaluate (string or parsed expression), or a block to evaluate
-- * `namespace`(default=""): namespace to evaluate the expression in
-- * `tags`(default={}): defaults tags when evaluating the expression (Lua value)
--
-- Return value in case of success (nil if nothing returned).
--
-- Returns nil, error message in case of error.
eval = function(self, expr, namespace, tags)
local interpreter, err = self:run("()", namespace, tags)
if not interpreter then return interpreter, err end
local r, e = interpreter:eval(expr, namespace)
if e then return r, e end
assert(interpreter:step() == "return", "evaluated expression can not emit events") -- trigger merge / end-of-script things
return r
end
}
vm_mt.__index = vm_mt
-- return anselme module
return setmetatable(anselme, {
__call = function()
-- global state
local state = {
inject = {
-- function_start = "code block...", ...
},
feature_flags = {
["strip trailing spaces"] = true,
["strip duplicate spaces"] = true
},
builtin_aliases = {
-- ["👁️"] = "seen",
-- ["🔖"] = "checkpoint",
-- ["🏁"] = "reached"
},
aliases = {
-- ["bonjour.salutation"] = "hello.greeting", ...
},
functions = {
-- ["script.fn"] = {
-- {
-- function or checkpoint table
-- }, ...
-- }, ...
},
variable_metadata = {
-- foo = { constant = true, persistent = true, constraint = constraint, ... }, ...
},
variables = {
-- foo = {
-- type = "number",
-- value = 42
-- }, ...
},
queued_lines = {
-- { line = line, namespace = "foo" }, ...
},
link_next_function_definition_to_lua_function = nil -- temporarly set to tell the preparser to link a anselme function definition with a lua function
}
local vm = setmetatable({ state = state }, vm_mt)
-- bootscript
local boot = assert(vm:loadstring(bootscript, "", "boot script"))
local _, e = vm:eval(boot)
if e then error(e) end
-- lua-defined functions
assert(vm:loadfunction(stdfuncs.lua))
-- anselme-defined functions
local ansfunc = assert(vm:loadstring(stdfuncs.anselme, "", "built-in functions"))
_, e = vm:eval(ansfunc)
if e then return error(e) end
return vm
end
})