From 100a23edec81695b589d8dce5460fc8eeddbcb76 Mon Sep 17 00:00:00 2001 From: Reuh Date: Wed, 25 Dec 2019 15:53:32 +0100 Subject: [PATCH] First commit --- README.md | 156 ++++++ anselme.can | 1299 ++++++++++++++++++++++++++++++++++++++++++++++++++ run.lua | 37 ++ test.ans | 5 + test/rho.ans | 3 + test/yep.ans | 1 + 6 files changed, 1501 insertions(+) create mode 100644 README.md create mode 100644 anselme.can create mode 100644 run.lua create mode 100644 test.ans create mode 100644 test/rho.ans create mode 100644 test/yep.ans diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fdd5f2 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +Anselme quick reference +======================= + +Anselme will read script files line per line, starting from the start of the file. +Every line can have children: a new line prefixed with a tabulation, or more if it's a children of a children, and so on. +Anselme will automatically read the top-level lines. Children reading will be decided by their parents. + +Lines types and their properties +-------------------------------- +* Lines starting with a character which isn't listed below are text. They will be said out loud. Text formatting apply. If the line ends with a \, the text will not be immediately sent to the engine (it will be sent along with the next text line encountered, concatenated). + Example: Hello world! + No children. + No variables. +* Lines starting with ( are comments. + Example: (Important comment) + Their children are never read nor parsed, so it can be used for multiline comments. + No variables. +* Lines starting with § are paragraphs. A paragraph can have parameters, between parantheses and seperated by commas. Parantheses can be ommited if there are no parameters. Missing parent paragraphs will be created. + Example: § the start of the adventure (hero name, size of socks collection) + Their children are only read after a redirection to this paragraph. + 👁️: number of times the paragraph definition line has been encoutered before + 🗨️: number of times the paragraph's children have been executed before +* Lines starting with > are choices. The play can choose between this choice and every immediately following choice line. Text formatting apply. If a choice ends with a \, the choice will not immediately be sent to the engine (it will be send along with the next choice encoutered, with all choices available). + Example: > Yes. + Neat. + > No. + I'm sad now. + Its children will be read if the player select this choice. + No variables. +* Lines starting with : are variable definition. They will define and set to a specific value a currently undefined variable, which is searched in the closest paragraph only. Missing paragraphs will be created. They will always be run at compile time. + Example: :(variable*2) variableSquared + No children. + No variables. +* Lines starting with =, +, -, *, /, %, ^, !, &, | are variable assignements. They will change the value of a variable, searched as described in Variables. When asked to change the value of a paragraph, special behaviour may occur; see Aliases. + Example: +1 life point + No children. + No variables. +* Lines starting with ~ are redirections. They usually instruct the game to go to a specific paragraph (see Paragraph selection) and resume reading, but they will in practive evaluate any expression given to them. If the expression returns a paragraph, it will automatically be called (unless you redefine the ? operator). Redirections that immediately follow this one will only be read if this redirection failed (like a elseif). Expression default to true if not specified. + Example: ~ the start of the adventure ("John Pizzapone", 9821) + ~ life point > 5 + Life is good + ~ + NOT GOOD ENOUGH + Their children will be run only if the paragraph returns a truthy value. + No variables. +* Lines starting with @ are value return statements. They set the return value of the current paragraph. + Example: @1+1 + No children. + No variables. +* Lines starting with # are tags marker. They will define tags for all text sent from their children. Name and value are expressions. + Example: # "colour": "red", "big" + Hey. + "Hey" will be sent along with the tag table { colour = "red", "big" }. + Their children are always run. + No variables. + +Line decorators +--------------- +Every line can be suffixed with a ~ and a following condition; the line will only be run when the condition is verified. + +Similarly, every line can be suffixed with a # and a list of tags that will be set for this line (won't affect its children). Tag decorators must be placed before condition decorators. + +Lines can also be suffixed with a § and a name to behave like a paragraph (they will have variables, and can be redirected to). + +Text formatting +--------------- +Stuff inside braces { } will be replaced with the associated expression content. If the expression returns a paragraph, it will automatically be called. + +Tags +---- +Tags can be specified using the # line or decorator. If the expression returns a list, all of its elements will be recursively extracted and the final list will be provided to the engine. Paragraphs in the list will be automatically evaluated. If pairs are present, they will be used as key-value pairs in the tags table. + +Expressions +----------- +A formula. Available operators: ? (thruth test), &, | (boolean and, or), ! (boolean not), +, -, *, /, //, %, ^ (arithmetic), >, <, >=, <= (comparaison), =, != (value (in)equality), : (pair), , (list). + +Unusual operators: + ?paragraph will recursively evaluate the paragraph until a non-paragraph is found, and returns a boolean + -string will reverse the string + string + string/number will concatenate + string - number will returns everything before/after the last/first number characters + string - string will remove every string from the string + string * number will repeat the string + string / number will returns the last/first number characters + string/number % string/number will returns the position of string in string if found, no if not found + string/number ^ boolean will uppercase/lowercase the string + +Paragraph can have custom binary operator behaviour by having a sub paragraph named like _operator_ (eg, _+_ for the + operator). The function will receive (left, right) as parameters. This does not apply to lazy operators (&, |), you can only change their behaviour by changing the behaviour of the truth test (var is true if and only if ?var = 1), i.e., via redefining the ? operator). +Similarly, unary operators can be redefined by using the name -_. +Assignement operators can be redefined using their name (eg, = for direct assignement or + for addition). + +Parantheses can be used for priority management. + +Anselme test the falsity of value by comparing it with 0. Everything else is true, including the string "0". + +Variables can be used by writing their name. Straigthforward. + +Variables +--------- +Variables names can contain every character except . { } § > < ( ) ~ + - * / % ^ = ! & | : , and space. + +Value type: + * number: + 0, 1, ... + * string: + "Text". Text formatting applies. + * pair: + name: value + * list: + value1, value2, ... + * paragraph: + a reference to a paragraph + * luafunction: + function defined by the engine + +Variables need to be defined before use. Their type cannot be changed after definition. +The same rules as in Paragraph selection apply. + +Functions +--------- +Paragraphs can be used like functions. Use (var1, var2) to specify parameters in the paragraph definition. Theses variables will be set in the paragraph when it is called. Parantheses are not needed for functions without parameters. +When called in an expression, the paragraph will return a value that can be redefined using a @ line. By default, the return value is the empty string. + +Addresses +--------- +The path to a paragraph, subparagraph or any variable is called an address. +Anselme will search for variables from the current indentation level up to the top-level. +You can select sub-variables using a space between the parent paragraph name and its children, and so on. +You can select sub-variables using expression by putting them between braces (will automatically evaluate paragraph). For example, + + ~ foo {"bar"} + +will select foo bar. +When a sub-variables is not found directly, it will be searched in the parent's return values. + +Engine defined functions +------------------------ +Functions (same as paragraphs) can be defined by the game engine. These always will be searched first. See Anselme's public API on how to add them (at the end of this file). + +Built-in functions: + * ↩️(destiation name, source name) + will set up an alias so when the name "source name" is used but not found, it will be replaced with "destination name" + +Anselme's public interface is definied at the end of anselme.can. + +TODO: test/check redirections consistency/coverage +TODO: merge new scripts with an old state +TODO: translation thing. Linked with script merging. Simplest solution (which does not imply adding uuids to every text line in every file) would be to use a mapping file, which maps every save-relevant variable to its name in a translation. + +(TODO changer anselme pour les sauvegardes - j'ai une feuille dessus, mais iirc la bonne solution c'était de changer les variables pour référerer au dernier checkpoint (paragraph / choix / if) et de commit les données qu'aux checkponts (autorise changements de texte, mais à voir comment identifier uniquement les choix et ifs...)) +(TODO: autoriser type de variables custom (par ex list): définir type et actions avec les opérateurs) +(genre ici un type inventory: :inventory() raquettes / +"raquette sans fil" raquettes) (utiliser probablement les opérateurs custom) +(TODO: méthodes ? genre string:gsub(truc) signifie gsub(string, truc) idk ou juste des méthodes comme Lua (mais engine-defined)) + +TODO: functions with default value for arguments / named parameters. Use : as name-value delimiter (like with tags) +TODO: list methods \ No newline at end of file diff --git a/anselme.can b/anselme.can new file mode 100644 index 0000000..b3158d5 --- /dev/null +++ b/anselme.can @@ -0,0 +1,1299 @@ +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. +sendEvent = (root) + let e = root.event + root.event = nil + if e[1] == "text" then + coroutine.yield("text", e[2]) + elseif 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) + 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 + -- 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 = :() + 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, + + --- 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 +} +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. + 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 +}) diff --git a/run.lua b/run.lua new file mode 100644 index 0000000..95ecec3 --- /dev/null +++ b/run.lua @@ -0,0 +1,37 @@ +require("candran").setup() + +local vm = require("anselme")() + +vm:loaddirectory(".") +vm:loadfile("test.ans") + +print(require("inspect")(vm.state)) + +while true do + local e, d = vm:step() + if e == "text" then + for _, t in ipairs(d) do + print(t.text) + for k,v in pairs(t.tags) do + print("> "..tostring(k)..": "..tostring(v)) + end + end + print("-----") + elseif e == "choice" then + for i, c in ipairs(d) do + print(tostring(i)..": "..c.text) + for k,v in pairs(c.tags) do + print("> "..tostring(k)..": "..tostring(v)) + end + end + local choice + repeat + choice = tonumber(io.read("*l")) + until choice ~= nil and choice > 0 and choice <= #d + vm:choose(choice) + elseif e == "end" then + break + else + error("unknown event ("..tostring(e)..")") + end +end diff --git a/test.ans b/test.ans new file mode 100644 index 0000000..abaf86e --- /dev/null +++ b/test.ans @@ -0,0 +1,5 @@ +~ + hey + ~ test yep +~ + lol diff --git a/test/rho.ans b/test/rho.ans new file mode 100644 index 0000000..e43d504 --- /dev/null +++ b/test/rho.ans @@ -0,0 +1,3 @@ +LOOL + +~ test yep \ No newline at end of file diff --git a/test/yep.ans b/test/yep.ans new file mode 100644 index 0000000..d7d9f1e --- /dev/null +++ b/test/yep.ans @@ -0,0 +1 @@ +Yep. \ No newline at end of file