let VERSION = "0.11.0" --## Amazing constants ##-- --- Recurring paterns: full variable name let dissallowedVariable = "%{%}%§%>%<%(%)%~%+%-%*%/%%%=%?%!%&%|%:%^%,%@%s%.%#\"" let pvariable = "[^%s%%d][^%s]*":format(dissallowedVariable, dissallowedVariable) --- Operators priority, higher means first let binopPriority = { [","] = 0, [":"] = 1, ["|"] = 2, ["&"] = 3, ["<"] = 4, [">"] = 4, ["<="] = 4, [">="] = 4, ["!="] = 4, ["="] = 4, ["+"] = 5, ["-"] = 5, ["*"] = 6, ["/"] = 6, ["//"] = 6, ["%"] = 6, ["^"] = 8 } let unopPriority = { ["!"] = 7, ["-"] = 7, ["?"] = 9 } -- +inf priority: parantheses, function calls --## Runtime functions ##-- let expression, eval, evalBool, evalAddress, luaToAns, findVariable, lookupVariable, runFunction, formatText, sendEvent, parse, run, tryPotentialFunction, pushTags, readable, evalList, evalNoParagraph, defineVariable, runChildren, step, evalFlatListNoParagraph --- Parse code. parse = (context, code, origin="a unnamed chunk", temporary) let root = context.root let parent = context -- current parent element let parentParagraph = (parent.paragraph or parent.type == "root") and parent or parent.parentParagraph -- closest parent paragraph let lastParsed = nil -- last parsed element let lastLineEmpty = false -- if the last line was empty let indent = 0 -- current indentation level let lineno = 0 -- line number let skipChildren = false -- true to not parse this line's children for l in (code.."\n"):gmatch("([^\n]*)\n") do lineno += 1 -- Indentation parse let tabs, line = l:match("^(\t*)(.*)$") let level = #tabs if line == "" or (skipChildren and level > indent) then lastLineEmpty = true continue end if skipChildren and level <= indent then skipChildren = false end -- Children. Childrenize! if level == indent+1 then indent += 1 parent = lastParsed if parent.type == "paragraph" then parentParagraph = parent end if not parent.children then error("a %s line doesn't want children but was given some; at line %s in %s":format(parent.type, lineno, origin)) end -- Uh... Unchildrenization ? elseif level < indent then while level < indent do indent -= 1 if parent.type == "paragraph" then parentParagraph = parentParagraph.parent end parent = parent.parent end -- Invalid childrenization. elseif level ~= indent then error("invalid indentation; at line %s in %s":format(lineno, origin)) end -- Staaaaaart parsing let parsed = { -- element type = "unknown", -- element type parent = parent, -- parent element. Should be nil only for root. children = nil, -- nil if the element shouldn't have children parentParagraph = parentParagraph, -- closest parent paragraph. Should be nil only for root. root = root, -- ast root variables = nil, -- variable map. -- condition decorator condition = nil, -- condition expression if the line has been decorated with a condition. nil if no condition. -- tags decorator tags = nil, -- paragraph, paragraph decorator paragraph = nil, parameters = nil, ["return"] = nil, value = nil, -- choice, text text = nil, -- definition, assignement, paragraph, paragraph decorator address = nil, -- assignement operator = nil, -- redirection, tag, return, definition, assignement expression = nil, -- redirection, choice continue = nil, -- other temporary = temporary, -- true if the line should be removed immediately after it was run. line = lineno, -- line number. For debug/error message information. origin = origin -- origin name. For debug/error message information. } -- Decorators if not line:match("^%(") then -- Condition decorator if line:match("^%s*[^%~].*%~[^%~]+$") then let s, c, e = line:match("^(%s*[^%~].*)%~([^%~%#%§]+)(.-)$") let exp, rem = expression(parsed, c) if rem:match("[^%s]") then error("invalid condition decorator expression near %q; at line %s in %s":format(rem, lineno, origin)) else line = s..e parsed.condition = exp end end -- Tag decorator if line:match("^%s*[^%#].*%#[^%#]+$") then let s, c, e = line:match("^(%s*[^%#].*)%#([^%~%#%§]+)(.-)$") let exp, rem = expression(parsed, c) if rem:match("[^%s]") then error("invalid tag decorator expression near %q; at line %s in %s":format(rem, lineno, origin)) else line = s..e parsed.tags = exp end end -- Paragraph decorator if line:match("^%s*[^%§].*%§[^%§]+$") then let s, c, e = line:match("^(%s*[^%§].*)%§%s*([^%~%#%§]+)(.-)$") line = s..e parsed.paragraph = c end end -- Comment if line:match("^%(") then skipChildren = true continue -- Paragraph elseif line:match("^§") then parsed.type = "paragraph" parsed.paragraph = line:match("^§%s*(.-)%s*$") parsed.children = {} -- Choice elseif line:match("^>") then parsed.type = "choice" parsed.children = {} let lastLine = parsed.parent.children[#parsed.parent.children] if not lastLineEmpty and lastLine and (lastLine.type == "choice" or lastLine.type == "elsechoice") then parsed.continue = true end parsed.text = line:match("^>%s*(.-)%s*$") -- Variable definition elseif line:match("^:") then parsed.type = "definition" parsed.expression, parsed.address = expression(parsed, line:match("^:%s*(.-)%s*$")) let var, rem = expression(parsed, parsed.address) if rem:match("[^%s]") or var.type ~= "variable" then error("unreasonably invalid variable name (%s); at line %s in %s":format(parsed.address, lineno, origin)) else parsed.address = evalAddress(parsed, var.address) end -- Define at compile time defineVariable(parsed, parsed.address, eval(parsed, parsed.expression)) -- Variable assignements elseif line:match("^[%=%+%-%*%/%%%?%!%^%&%|]") then parsed.type = "assignement" parsed.operator = line:match("^([%=%+%-%*%/%%%?%!%^%&%|])") parsed.expression, parsed.address = expression(parsed, line:match("^[%=%+%-%*%/%%%?%!%^%&%|]%s*(.-)%s*$")) let var, rem = expression(parsed, parsed.address) if rem:match("[^%s]") or var.type ~= "variable" then error("unreasonably invalid variable name (%s); at line %s in %s":format(parsed.address, lineno, origin)) else parsed.address = var.address end -- Redirection elseif line:match("^%~") then parsed.type = "redirection" parsed.children = {} let lastLine = parsed.parent.children[#parsed.parent.children] if not lastLineEmpty and lastLine and lastLine.type == "redirection" then parsed.continue = true end let cond = line:match("^%~%s*(.-)%s*$") if cond == "" then cond = "1" end let exp, rem = expression(parsed, cond) if rem:match("[^%s]") then error("invalid redirection expression near %q; at line %s in %s":format(rem, lineno, origin)) else parsed.expression = exp end -- Value return elseif line:match("^%@") then parsed.type = "return" let exp, rem = expression(parsed, line:match("^%@%s*(.-)%s*$")) if rem:match("[^%s]") then error("invalid return expression near %q; at line %s in %s":format(rem, lineno, origin)) else parsed.expression = exp end -- Tag elseif line:match("^%#") then parsed.type = "tag" parsed.children = {} let exp, rem = expression(parsed, line:match("^%#%s*(.-)%s*$")) if rem:match("[^%s]") then error("invalid tag expression near %q; at line %s in %s":format(rem, lineno, origin)) else parsed.expression = exp end -- Presumption of text else parsed.type = "text" parsed.text = line:match("^%s*(.-)%s*$") end -- Setup paragraph if parsed.paragraph then let name, parameters = parsed.paragraph:match("^%s*([^%(]+)(.-)%s*$") -- Parse address if name:match("%s") or name:match("^%{") then let var, rem = expression(parsed, name) if rem:match("[^%s]") or var.type ~= "variable" then error("unreasonably invalid paragraph name (%s); at line %s in %s":format(parsed.address, lineno, origin)) else parsed.address = evalAddress(parsed, var.address) end else -- allow special caracters for simple addresses (for _+_ methods, etc.) parsed.address = { name } end -- Define at compile time let pvar = defineVariable(parent, parsed.address, parsed) -- Parse parameters parsed.parameters = {} parsed.variables = pvar if parameters:match("^%s*%(.-%)%s*$") then let content = parameters:match("^%s*%((.-)%)%s*$") for par in content:gmatch("[^,]+") do let var = par:match("^%s*(.-)%s*$") table.insert(parsed.parameters, var) defineVariable(parsed, { var }, eval("0")) end end parsed.value = parsed -- paragraphs are variables themselves defineVariable(parsed, {"👁️"}, eval("0")) defineVariable(parsed, {"🗨️"}, eval("0")) parsed["return"] = { type = "string", value = "" } end -- Insert parent = parsed.parent table.insert(parent.children, parsed) lastParsed = parsed lastLineEmpty = false end end --- Read an expression at the start of a string, returns the expression AST and the remaining string expression = (context, str, operatingOn, minPriority=0) -- Sweep sweep sweep str = str:match("^%s*(.-)$") -- VALUES -- -- (requiredly) -- if not operatingOn then -- Litteraly nothing if str == "" then error("unexpected empty expression; at line %s in %s":format(context.line, context.origin)) -- String elseif str:match("^\"") then let string, remaining = str:match("^\"([^\"]*)\"(.*)$") return expression(context, remaining, { type = "string", value = string }, minPriority) -- Float elseif str:match("^%d*%.%d+") then let number, remaining = str:match("^(%d*%.%d+)(.*)$") return expression(context, remaining, { type = "number", value = tonumber(number) }, minPriority) -- Integer elseif str:match("^%d+") then let number, remaining = str:match("^(%d+)(.*)$") return expression(context, remaining, { type = "number", value = tonumber(number) }, minPriority) -- Unary operators elseif str:match("^[%?%!%-]") then let op, rem = str:match("^([%?%!%-])(.*)$") -- Copy pasted from the binary operators code. Should not be needed since unary operators are supposed to have the highest priority, but you never know! -- UPDATE: added exponentiation operator with higher priority. Thanks, past self. let exp, remaining, lowerPriorityOp = expression(context, rem, nil, unopPriority[op]) let unop = { type = "u"..op, expression = exp } if lowerPriorityOp then -- lower priority op handling return expression(context, remaining, unop, minPriority) else return unop, remaining end -- Parantheses elseif str:match("^%(") then let content, remaining = str:match("^(%b())(.*)$") let exp, premaining = expression(context, content:match("^%((.*)%)$"), nil) if premaining:match("[^%s]") then error("something in parantheses can't be read as an expression; at line %s in %s":format(context.line, context.origin)) end return expression(context, remaining, { type = "parantheses", expression = exp }, minPriority) -- Everything with letters in it, ie variable elseif str:match("^"..pvariable) then let var, remaining = str:match("^("..pvariable..")(.*)$") -- Build address let address = { { type = "string", value = var } } while remaining:match("^%s%s-"..pvariable) or remaining:match("^%s-%b{}") do if remaining:match("^%s-%{") then -- expression var, remaining = remaining:match("^%s-(%b{})(.*)$") var = var:gsub("^%{", ""):gsub("%}$", "") let exp, rem = expression(context, var) if rem:match("[^%s]") then error("something in parantheses can't be read as an expression; in variable address at line %s in %s":format(context.line, context.origin)) else table.insert(address, exp) end else -- identifier var, remaining = remaining:match("^%s%s-("..pvariable..")(.*)$") table.insert(address, { type = "string", value = var }) end end return expression(context, remaining, { type = "variable", address = address }, minPriority) end -- OPERATORS -- -- (possibly) -- else -- This is the point where I stopped writing code and pondered for a minute what the fuck was I doing. Heh. if str:match("^<=") or str:match("^>=") or str:match("^!=") or str:match("^//") or str:match("^[%&%|%+%-%*%/%%%<%>%=%^%:%,]") then let op, rem if str:match("^<=") or str:match("^>=") or str:match("^!=") then op, rem = str:match("^([<>!]=)(.*)$") elseif str:match("^//") then op, rem = str:match("^(//)(.*)$") else op, rem = str:match("^([%&%|%+%-%*%/%%%<%>%=%^%:%,])(.*)$") end -- Higher priority, ie need to be deeper in the AST if binopPriority[op] >= minPriority then let rightVal, remaining, lowerPriorityOp = expression(context, rem, nil, binopPriority[op]) let binop = { type = "b"..op, left = operatingOn, right = rightVal } if lowerPriorityOp then -- lower priority op handling return expression(context, remaining, binop, minPriority) else return binop, remaining end -- Return but notice there is a lower priority operator remaining (so it's handled higher) else return operatingOn, str, true end -- Function call elseif str:match("^%(") then let content, remaining = str:match("^(%b())(.*)$") content = content:match("^%((.*)%)$") let args = nil if content:match("[^%s]") then let exp, rem = expression(context, content) if rem:match("[^%s]") then error("invalid function parameter expression near %q; at line %s in %s":format(rem, context.line, context.origin)) end args = exp end return expression(context, remaining, { type = "call", expression = operatingOn, arguments = args }, minPriority) -- Function call without parathesis (single number or string litteral) elseif str:match("^[%d%.\"]") and minPriority < math.huge then let exp, rem = expression(context, str, nil, math.huge) assert(exp.type == "number" or exp.type == "string", "expected string or number but got a %s; in function call at line %s in %s":format(exp.type, context.line, context.origin)) return expression(context, rem, { type = "call", expression = operatingOn, arguments = exp }, minPriority) else return operatingOn, str -- no operator apparently end end -- Yay, we shouldn't be here. error("the expression parser just gave up; at line %s in %s":format(context.line, context.origin)) end --- Define a variable in the closest scope. -- Returns the variable parent table. defineVariable = (context, address, value) let pvar = context.variables or context.parentParagraph.variables for _, part in ipairs(address) do if not pvar[part] then pvar[part] = {} end pvar = pvar[part] end if pvar[1] then error("variable %q already defined; at line %s in %s":format(table.concat(address, " "), context.line, context.origin)) end pvar[1] = value return pvar end --- Find a variable in any line. Won't try looking in a higher scope/paragraph. -- Return nil if the variable wasn't found. -- Return var, variable parent table if it was found. findVariable = (context, address) let root = context.root -- get variable table let pvar = context.variables if not pvar then return nil end -- Reach last depth for _, part in ipairs(address) do while true do if pvar[part] then pvar = pvar[part] break end -- Aliases if root.aliases[part] then let alias = root.aliases[part] if pvar[alias] then pvar = pvar[alias] break end end -- Try using parent paragraph return value let pval = pvar[1] if pval and context ~= pval and pval.type == "paragraph" then let r = runFunction(context, pval) if type(r.value) == "table" and r.value.variables then pvar = r.value.variables continue end end -- We failed you. return nil end end -- Last depth handling if pvar[1] then return pvar[1], pvar end -- We failed. A bit later this time. return nil end --- Find a variable in any line, and will search higher in the AST until it either find it or reach root. -- If source directories are defined (see vm:loaddirectory), this function will load scripts from theses directories as needed. -- Return nil if the variable parent paragraph could not be found. -- Return var, variable parent table if it was found. lookupVariable = (context, address) -- Search from current level to top level. let parentParagraph = context while parentParagraph do let v, par = findVariable(parentParagraph, address) if v then return v, par end parentParagraph = parentParagraph.parentParagraph end -- Source directories let root = context.root for _, dir in ipairs(root.directories) do for j=#address, 1, -1 do let filename = table.concat(address, "/", 1, j) let f = io.open("%s/%s.ans":format(dir, filename), "r") if f then let code = "§ %s\n":format(table.concat(address, " ", 1, j)) for l in f:lines("*l") do code ..= "\t%s\n":format(l) end f:close() parse(root, code, filename) return findVariable(root, address) end end end -- We failed you. Sorry. return nil end -- Run a function, and returns its return value. runFunction = (context, fn, args={}) if fn.type == "paragraph" then -- Checks parameters let p = fn.value if #args ~= #p.parameters then error("paragraph (%s; at line %s in %s) expected %s parameters but received %s; at line %s in %s":format(table.concat(p.address, " "), p.line, p.origin, #p.parameters, #args, context.line, context.origin)) end for i, arg in ipairs(args) do p.variables[p.parameters[i]][1] = arg end -- Run function p["return"] = { type = "string", value = "" } runChildren(p) return p["return"] elseif fn.type == "luafunction" then let luaArgs = {} for i, arg in ipairs(args) do table.insert(luaArgs, arg.value) end let ret = fn.value(unpack(luaArgs)) let val = luaToAns(ret) if val then return val else error("invalid return type (%s) for luafunction in expression; at line %s in %s":format(ret, context.line, context.origin)) end else error("tried to call a %s variable in an expression; at line %s in %s":format(fn.type, context.line, context.origin)) end end --- Search for a function in a list of potential source variables. -- If found, returns its return value. -- Returns nil otherwise tryPotentialFunction = (context, potentialParentVariables, name, arguments) let fn -- search function for _, p in ipairs(potentialParentVariables) do if type(p.value) == "table" and p.value.variables then let v = findVariable(p.value, { name }) if v then fn = v break end end end -- run if fn then return runFunction(context, fn, arguments) end end --- Evaluate an expression -- exp can be an expression AST or a string -- If no context is specified, will create a temporary empty context. eval = (context, exp) let remain = "" if type(context) == "string" and exp == nil then context, exp = {}, context end if type(exp) == "string" then exp, remain = expression(context, exp) end if remain:match("[^%s]") then error("unexpected text in expression near %q; at line %s in %s":format(remain, context.line, context.origin)) end assert(exp.type, "undefined variable type; at line %s in %s":format(context.line, context.origin)) -- Litterals if exp.type == "number" then return { type = "number", value = exp.value } elseif exp.type == "string" then return { type = "string", value = formatText(context, exp.value) } elseif exp.type == "parantheses" then return eval(context, exp.expression) -- Variables elseif exp.type == "variable" then let addr = evalAddress(context, exp.address) let v = lookupVariable(context, addr) if v then if type(v.value) == "table" and v.value.paragraph then return { type = "paragraph", value = v.value } else return { type = v.type, value = v.value } end end error("can't find the variable (%s); at line %s in %s":format(table.concat(addr, " "), context.line, context.origin)) -- Arithmetic elseif exp.type:match("^b[%+%-%*%/%%%^%>%<%=%!%:%,][%/%=]?$") then let op = exp.type:match("^b(.*)$") let left, right = eval(context, exp.left), eval(context, exp.right) let customBinop = tryPotentialFunction(context, { left, right }, "_%s_":format(op), { left, right }) if customBinop then return customBinop end if op == "+" then if (left.type == "string" and (right.type == "string" or right.type == "number")) or ((left.type == "number" or left.type == "string") and right.type == "string") then return { type = "string", value = tostring(left.value) .. tostring(right.value) } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) + tonumber(right.value) } end elseif op == "-" then if left.type == "string" and right.type == "number" then return { type = "string", value = tostring(left.value):sub(1, utf8.offset(left.value, utf8.len(left.value)-tonumber(right.value))) } elseif left.type == "number" and right.type == "string" then return { type = "string", value = tostring(right.value):sub(utf8.offset(right.value, tonumber(left.value)+1)) } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) - tonumber(right.value) } elseif left.type == "string" and right.type == "string" then return { type = "string", value = tostring(left.value):gsub(tostring(right.value), "") } end elseif op == "*" then if left.type == "string" and right.type == "number" then return { type = "string", value = tostring(left.value):rep(tonumber(right.value)) } elseif left.type == "number" and right.type == "string" then return { type = "string", value = tostring(right.value):rep(tonumber(left.value)) } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) * tonumber(right.value) } end elseif op == "/" then if left.type == "string" and right.type == "number" then return { type = "string", value = tostring(left.value):sub(utf8.offset(left.value, utf8.len(left.value)-tonumber(right.value)+1)) } elseif left.type == "number" and right.type == "string" then return { type = "string", value = tostring(right.value):sub(1, utf8.offset(right.value, tonumber(left.value))) } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) / tonumber(right.value) } end elseif op == "//" then if left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) // tonumber(right.value) } end elseif op == "%" then if (left.type == "string" and right.type == "number") or (left.type == "number" and right.type == "string") then return { type = "number", value = tostring(left.value):find(tostring(right.value)) or 0 } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) % tonumber(right.value) } end elseif op == "^" then if left.type == "string" then let s = tostring(left.value) if evalBool(context, exp.right).value == 0 then s = s:lower() else s = s:upper() end return { type = "string", value = s } elseif left.type == "number" and right.type == "number" then return { type = "number", value = tonumber(left.value) ^ tonumber(right.value) } end elseif op == ">" then if (left.type == "number" or left.type == "string") and (right.type == "number" or right.type == "string") then return { type = "number", value = (left.value > right.value) and 1 or 0 } end elseif op == "<" then if (left.type == "number" or left.type == "string") and (right.type == "number" or right.type == "string") then return { type = "number", value = (left.value < right.value) and 1 or 0 } end elseif op == ">=" then if (left.type == "number" or left.type == "string") and (right.type == "number" or right.type == "string") then return { type = "number", value = (left.value >= right.value) and 1 or 0 } end elseif op == "<=" then if (left.type == "number" or left.type == "string") and (right.type == "number" or right.type == "string") then return { type = "number", value = (left.value <= right.value) and 1 or 0 } end elseif op == "=" then return { type = "number", value = (left.value == right.value) and 1 or 0 } elseif op == "!=" then return { type = "number", value = (left.value ~= right.value) and 1 or 0 } elseif op == ":" then return { type = "pair", value = { name = left, value = right } } elseif op == "," then return { type = "list", value = { head = left, tail = right } } end error("invalid value types for %s operator: %s %s %s; at line %s in %s":format(op, left.type, op, right.type, context.line, context.origin)) elseif exp.type:match("^u[%-%?%!]$") then let op = exp.type:match("^u(.*)$") let value = eval(context, exp.expression) let customUnop = tryPotentialFunction(context, { value }, "%s_":format(op), { value }) if customUnop then return customUnop end if op == "-" then if value.type == "number" then return { type = "number", value = -tonumber(value.value) } elseif value.type == "string" then return { type = "string", value = value.value:reverse() } end elseif op == "?" then if value.type == "paragraph" then return evalBool(context, { type = "call", expression = exp.expression }) else return { type = "number", value = (value.value == 0) and 0 or 1 } end elseif op == "!" then return { type = "number", value = evalBool(context, exp.expression).value == 0 and 1 or 0 } end error("invalid value types for %s operator: %s; at line %s in %s":format(op, value.type, context.line, context.origin)) -- Lazy operators elseif exp.type == "b&" then if evalBool(exp.left).value == 0 then return left else return evalBool(context, exp.right) end elseif exp.type == "b|" then let left = evalBool(context, exp.left) if left.value == 0 then return evalBool(context, exp.right) else return left end -- Call elseif exp.type == "call" then let fn = eval(context, exp.expression) if exp.arguments then return runFunction(context, fn, evalList(context, exp.arguments)) else return runFunction(context, fn) end else error("unkown expression (%s) to evaluate; at line %s in %s":format(exp.type, context.line, context.origin)) end end --- Same as eval, but return a Anselme boolean. evalBool = (context, expression) return eval(context, { type = "u?", expression = expression }) end --- Same as eval, but run paragraphs. evalNoParagraph = (context, exp) let t = eval(context, exp) while t.type == "paragraph" do -- run paragraph/function t = runFunction(context, t) end return t end --- Same as eval, but returns a Lua list of expression. evalList = (context, exp) let l = {} let e = eval(context, exp) while e.type == "list" do table.insert(l, e.value.head) e = e.value.tail end table.insert(l, e) return l end --- Same as evalNoParagraph, but returns a Lua list of expression in which nested lists are merged with the full list. evalFlatListNoParagraph = (context, exp) let l = {} let extract = (e) while e.type == "list" or e.type == "paragraph" do if e.type == "paragraph" then e = runFunction(context, e) else let hd = e.value.head if hd.type == "list" or hd.type == "paragraph" then extract(hd) else table.insert(l, hd) end e = e.value.tail end end table.insert(l, e) end extract(eval(context, exp)) return l end --- Evaluate a variable address. Returns a list of strings (paragraphs name). evalAddress = (context, address) return [ for _, a in ipairs(address) do push tostring(evalNoParagraph(context, a).value) end ] end --- Convert a Lua variable to a Anselme value. -- Returns nil in case of error. luaToAns = (var) if var == nil or var == false then return eval("0") elseif var == true then return eval("1") elseif type(var) == "number" then return { type = "number", value = var } elseif type(var) == "string" then return { type = "number", value = var } elseif type(var) == "function" then return { type = "luafunction", value = var } elseif type(var) == "table" and var.type then return { type = var.type, value = var.value } else error("don't know how to convert a %s to a Anselme value":format(type(var))) end end --- Returns a readable representation of a variable. readable = (var) if var.type == "pair" then return "%s:%s":format(readable(var.value.name), readable(var.value.value)) elseif var.type == "list" then return "%s,%s":format(readable(var.value.head), readable(var.value.tail)) else return tostring(var.value) end end --- Format text. formatText = (context, text, yield) let r = text:gsub("%b{}", (exp) return readable(evalNoParagraph(context, exp:match("^{(.*)}$"))) end) return r end --- Send event to the engine. -- root is the root node or an event sendEvent = (root) let e = root.event or root root.event = nil if e[1] == "choice" then let vm = coroutine.yield("choice", e[2]) if not vm.state.chosen then error("no choice has been made by the engine, I don't know what to doooooo") end let c = assert(e[3][vm.state.chosen], "invalid choice %s, expected something in [1,%s]":format(vm.state.chosen, #e[2])) vm.state.chosen = nil runChildren(c) else coroutine.yield(e[1], e[2]) end end --- Add a list of tag expressions to the current tag state, and returns the previous state. pushTags = (context, tags) let root = context.root let oldTags = root.tags let newTags = [ for k, v in pairs(root.tags) do @[k]=v end ] let l = evalFlatListNoParagraph(context, tags) for _, t in ipairs(l) do if t.type == "pair" then let name, value = t.value.name, t.value.value while name.type == "paragraph" do name = runFunction(context, name) end while value.type == "paragraph" do value = runFunction(context, value) end newTags[name.value] = value.value else table.insert(newTags, t.value) end end root.tags = newTags return oldTags end --- Run a line's children. runChildren = (line) run(line.children) if line.paragraph then line.variables["🗨️"][1].value += 1 end end --- Run a list of elements. Must be used in a couroutine. -- i is the starting line indice. -- Returns the indice of the last line ran. run = (lines, i=1) -- trivial case if #lines == 0 then return i end -- get root let root = lines[1].root -- run while i <= #lines do let line = lines[i] i += 1 -- Interrupt events if #root.interrupts > 0 then for _, e in ipairs(root.interrupts) do sendEvent(e) end root.interrupts = {} end -- Condition decorator if line.condition then if evalBool(line, line.condition).value == 0 then continue end end -- Tag decorator let oldTags if line.tags then oldTags = pushTags(line, line.tags) end -- Run line if line.type == "paragraph" or line.type == "definition" then -- pass elseif line.type == "choice" then -- send other event on their way if root.event and root.event[1] ~= "choice" then sendEvent(root) end -- let us do our own thing now. please if not root.event then root.event = { "choice", {}, {} } end -- complete the choice list let isBuffered = false let t = formatText(line, line.text) if t:match("\\$") then t = t:gsub("\\$", "") isBuffered = true end table.insert(root.event[2], { text = t, tags = root.tags }) table.insert(root.event[3], line) -- choices remain if i <= #lines and lines[i].type == "choice" and lines[i].continue then isBuffered = true end -- send to whatever when done if not isBuffered then sendEvent(root) end elseif line.type == "assignement" then -- Evaluate address let addr = evalAddress(line, line.address) -- Search from current to top level let v, pvar = lookupVariable(line, addr) if not v then error("can't find the variable (%s); at line %s in %s":format(table.concat(addr, " "), line.line, line.origin)) end -- Eval expression let exp = eval(line, line.expression) -- Custom operators let cv = tryPotentialFunction(line, { v }, line.operator, { v, exp }) if cv then -- good else -- Normal operation if line.operator ~= "=" then exp = eval(line, { type = "b"..line.operator, left = { type = "variable", address = line.address }, right = exp }) end -- Assign if pvar[1] then if v.type ~= exp.type then error("tried to replace the variable %q of type %s with a %s; at line %s in %s":format(table.concat(addr, " "), v.type, exp.type, line.line, line.origin)) else pvar[1] = { type = exp.type, value = exp.value } end else error("found the variable (%s) to assign, but it's inaccessible; at line %s in %s":format(table.concat(addr, " "), line.line, line.origin)) end end elseif line.type == "redirection" then if evalBool(line, line.expression).value ~= 0 then runChildren(line) while i <= #lines and lines[i].type == "redirection" and lines[i].continue do i += 1 end end elseif line.type == "return" then line.parentParagraph["return"] = eval(line, line.expression) elseif line.type == "tag" then let oldTags = pushTags(line, line.expression) run(line.children) root.tags = oldTags elseif line.type == "text" then -- send other event on their way if root.event and root.event[1] ~= "text" then sendEvent(root) end -- do our own thing if not root.event then root.event = { "text", {} } end let t = formatText(line, line.text) if t:match("\\$") then table.insert(root.event[2], { text = t:gsub("\\$", ""), tags = root.tags }) else table.insert(root.event[2], { text = t, tags = root.tags }) sendEvent(root) end else error("element unknown to the runtime (%s); at line %s in %s":format(line.type, line.line, line.origin)) end -- paragraph decorator if line.paragraph then line.variables["👁️"][1].value += 1 end -- tag decorator, part two if line.tags then root.tags = oldTags end -- Temporary line if line.temporary then i -= 1 table.remove(lines, i) end end return i end --- Step the VM. Use in a coroutine. step = :() @_coroutine = coroutine.running() while true do @state.lastLine = run(@state.children, @state.lastLine) if @state.event then sendEvent(@state) end coroutine.yield("end") end end --## Public interface ##-- --- Anselme VM method and properties. let vm_mt = { --- The root AST, A.K.A. the state. This is what you want to save and load in your game. -- Also, it's probably not a wise idea to edit this variable yourself, but you do you. state = nil, --- Add an engine-defined variable, or a table of engine-defined variables. -- The variable will be defined from the root of the document. -- Name can be any valid variable name, and can contains . (paragraphs will be created as needed). -- The variable can be a Lua function. It will be run inside Anselme's coroutine, so you may yield if you want. -- Lua | Anselme type casting: -- string <-> string -- number <-> number -- false -> 0 -- true -> 1 -- table -> paragraph -- table(type) -> custom type -- AST <- paragraph -- function <-> luafunction -- So if you want to check the falseness of a Anselme's value from Lua, variable == 0. -- You can define a paragraph behaviour when called by defining a function as the first element of the table (index 1). The function receives the paragraph as its first argument. -- You can also define a cutom type by returning a { type = "type name", value = data } table if you really want to. -- The AST structure is documented a few lines above, in the root element definition. register = :(name, var) if type(name) == "table" then for k, v in pairs(name) do @register(k, v) end return @ elseif type(var) == "table" and not var.type then for k, v in pairs(var) do @register("%s %s":format(name, k), v) end if var[1] then @register(name, var[1]) end return @ else defineVariable(@state, [ for p in name:gmatch("[^%s]+") do p end ], luaToAns(var)) return @ end end, --- Load a file content into the VM and append it at the end of the root element. loadfile = :(path) let f = io.open(path, "r") if not f then error("can't open file %q":format(path)) end parse(@state, f:read("*a"), path) f:close() return @ end, --- Load a directory. -- When a variable is not found, it will be searched in the source directories. loaddirectory = :(path) table.insert(@state.directories, path) return @ end, --- Load some text into the VM and append it at the end of the root element. load = :(code, origin) parse(@state, code, origin) return @ end, --- Load a file content into the VM and append it at the end of the root element. -- Unlike loadfile, code loaded using executefile will be deleted from the VM's state as soon as it is run, -- so it won't be saved and restored with the VM. Typically used to initiate scripts (with a redirection). -- In pratice: the code will be run as soon as possible, only once. execfile = :(path) let f = io.open(path, "r") if not f then error("can't open file \""..tostring(path).."\"") end parse(@state, f:read("*a"), path, true) f:close() return @ end, --- Load some text into the VM and append it at the end of the root element. -- Unlike loadfile, code loaded using executefile will be deleted from the VM's state as soon as it is run, -- so it won't be saved and restored with the VM. Typically used to initiate scripts (with a redirection). -- In pratice: the code will be run as soon as possible, only once. exec = :(code, origin) parse(@state, code, origin, true) return @ end, --- Evaluate an expression in the VM, from the root element. -- Returns the associated Lua value. Casting rules are described in the register method. eval = :(exp) let v = eval(@state, exp) return v.value end, --- Choose an answer by specifying its option indice (starting at 1). -- Should only be called after receiving a "choice" event. See the step method. choose = :(i) @state.chosen = assert(i, "expected a choice but none was given") return @ end, --- Throw an interrupt event; i.e., pause the VM and send the event as soon as possible. -- This can be used to trigger custom events that need to be handled outside of Anselme. -- Can be called from an luafunction, or from outside. interrupt = :(name, data) if coroutine.running() == @_coroutine then coroutine.yield(name, data) else table.insert(@state.interrupts, { name, data }) end end, --- Wrapped coroutine that returns event, data each time it is called. -- Will run all the code at the root element, and then wait for more. It never die. -- "text", message: text to display -- "choice", {message1, message2, ...}: a choice. Anselme will expect an answer to be chosen using the choose method before the next call to step. -- "end", nil: end of the script -- Messages are tables: { text = "string", tags = { tagName = tagValue, ... } } step = nil, --- The VM coroutine. Do not use this directly, use :step. _coroutine = nil } vm_mt.__index = vm_mt --- Create a new Anselme VM. Load an existing state if specified. let newVM = (state) -- VM state/root element. Also the full element AST structure specification. let root = state or { -- Stuff which will still be useful later. See the comments in parse() for a description of the fields. type = "root", children = {}, variables = {}, line = 0, origin = "root", -- Useful stuff that's only on root chosen = nil, -- chosen answer. See the choose method. event = nil, -- event buffer - contains { event(str), data, other } if an event is waiting to be sent. interrupts = {}, -- list of interrupt events; they will be sent as soon as possible, regardless of the current event buffer lastLine = 1, -- The last line run in the root element. Used to always resume at the exact right spot™ in the step method. tags = {}, -- Currently active tags. aliases = {}, -- Name aliases directories = {} -- Source directories } root.root = root -- Create and return VM. let vm = setmetatable({ state = root, step = coroutine.wrap(step) }, vm_mt) -- Defaults functions vm:register("↩️", function(dest, source) root.aliases[source] = dest end) return vm end return setmetatable({ --- Anselme's version string. VERSION = VERSION, --- Create a new VM. new = newVM }, { --- Create a new VM. __call = () return newVM() end })