Implement new AST: `assert`.

This is a new AST that will allow executing a
simple expression and conditionally pass/fail
and cleanup when it completes.
pull/974/head
Connor Rigby 2019-08-19 17:23:00 -07:00
parent a04ddf0159
commit 7e1ceaf7be
No known key found for this signature in database
GPG Key ID: 29A88B24B70456E0
12 changed files with 351 additions and 3 deletions

1
.gitignore vendored
View File

@ -56,3 +56,4 @@ nerves-hub
*.pem
*.db
*.db-journal
*.lua

View File

@ -0,0 +1,74 @@
# CeleryScript IF `expression` field.
The CeleryScript `if` block takes a possible left hand side value of
`expression` which allows an arbitrary string to be evaluated. This
expression is evaluated against a lua 5.2 interpreter.
## Lua API
The following functions are available for usage along with [Lua's
standard library](https://www.lua.org/manual/5.2/).
```lua
-- Comments are ignored by the interpreter
-- help(function_name)
-- Returns docs for a function
print(help("send_message"));
print(help("get_position"));
-- get_position()
-- Returns a table containing the current position data
position = get_position();
if position.x <= 20.55 then
return true;
else
print("current position: (", position.x, ",", position.y, "," position.z, ")");
return false;
end
-- get_pins()
-- Returns a table containing current pin data
pins = get_pins();
if pins[9] == 1.0 then
return true;
end
-- send_message(type, message, channels)
-- Sends a message to farmbot's logger
send_message("info", "hello, world", ["toast"])
```
## Expression contract
Expressions are expected to be evaluated in a certain way. The evaluation will fail
if this contract is not met. An expression should return one of the following values:
* `true`
* `false`
* `("error", "string reason signaling an error happened")`
### Examples
Check if the x position is within a range of 5 and 10
```lua
position = get_position();
return position.x >= 5 and position.x <= 10;
```
Check is a pin is a toggled, with error checking
```lua
-- All farmbot functions will return a tuple containing an error
-- if something bad happens
position, positionErr = get_position();
pins, pinErr = get_pins();
if positionErr or pinErr then
return "error", positionErr or pinErr;
else
return pins[9] == 1.0
end
```

View File

@ -166,8 +166,35 @@ defmodule FarmbotCeleryScript.Compiler do
end
end
# `Assert` is a internal node useful for self testing.
compile :assertion, %{lua: expression, op: op, _then: then_ast} do
quote location: :keep do
case FarmbotCeleryScript.SysCalls.eval_assertion(unquote(compile_ast(expression))) do
{:error, reason} ->
{:error, reason}
true ->
:ok
false when unquote(op) == "abort" ->
FarmbotCeleryScript.SysCalls.log("Assertion failed (aborting)")
{:error, "Assertion failed (aborting)"}
false when unquote(op) == "recover" ->
FarmbotCeleryScript.SysCalls.log("Assertion failed (recovering)")
unquote(compile_block(then_ast))
false when unquote(op) == "abort_recover" ->
FarmbotCeleryScript.SysCalls.log("Assertion failed (recovering then aborting)")
unquote(compile_block([then_ast, %AST{kind: :abort, args: %{}}]))
end
end
end
# Compiles an if statement.
compile :_if, %{_then: then_ast, _else: else_ast, lhs: lhs, op: op, rhs: rhs} do
rhs = compile_ast(rhs)
# Turns the left hand side arg into
# a number. x, y, z, and pin{number} are special that need to be
# evaluated before evaluating the if statement.
@ -188,6 +215,10 @@ defmodule FarmbotCeleryScript.Compiler do
quote [location: :keep],
do: FarmbotCeleryScript.SysCalls.read_pin(unquote(String.to_integer(pin)), nil)
"expression" ->
quote [location: :keep],
do: FarmbotCeleryScript.SysCalls.eval_assertion(rhs)
# Named pin has two intents here
# in this case we want to read the named pin.
%AST{kind: :named_pin} = ast ->
@ -198,8 +229,6 @@ defmodule FarmbotCeleryScript.Compiler do
compile_ast(ast)
end
rhs = compile_ast(rhs)
# Turn the `op` arg into Elixir code
if_eval =
case op do
@ -238,6 +267,11 @@ defmodule FarmbotCeleryScript.Compiler do
quote location: :keep do
unquote(lhs) > unquote(rhs)
end
_ ->
quote location: :keep do
unquote(lhs)
end
end
# Finally, compile the entire if statement.
@ -336,6 +370,12 @@ defmodule FarmbotCeleryScript.Compiler do
end
end
compile :abort do
quote location: :keep do
Macro.escape({:error, "aborted"})
end
end
# Compiles move_absolute
compile :move_absolute, %{location: location, offset: offset, speed: speed} do
quote location: :keep do

View File

@ -136,7 +136,7 @@ defmodule FarmbotCeleryScript.Scheduler do
end
def handle_info(:checkup, %{next: nil} = state) do
Logger.debug("Scheduling next checkup with no next")
# Logger.debug("Scheduling next checkup with no next")
state
|> schedule_next_checkup()

View File

@ -67,6 +67,20 @@ defmodule FarmbotCeleryScript.SysCalls do
@callback log(message :: String.t()) :: any()
@callback sequence_init_log(message :: String.t()) :: any()
@callback sequence_complete_log(message :: String.t()) :: any()
@callback eval_assertion(expression :: String.t()) :: true | false | error()
def eval_assertion(sys_calls \\ @sys_calls, expression) when is_binary(expression) do
case sys_calls.eval_assertion(expression) do
true ->
true
false ->
false
{:error, reason} when is_binary(reason) ->
or_error(sys_calls, :eval_assertion, [expression], reason)
end
end
def log(sys_calls \\ @sys_calls, message) when is_binary(message) do
apply(sys_calls, :log, [message])

View File

@ -129,6 +129,9 @@ defmodule FarmbotCeleryScript.SysCalls.Stubs do
@impl true
def zero(axis), do: error(:zero, [axis])
@impl true
def eval_assertion(expression), do: error(:eval_assertion, [expression])
defp error(fun, _args) do
msg = """
CeleryScript syscall stubbed: #{fun}

View File

@ -0,0 +1,56 @@
defmodule FarmbotOS.Lua do
@type t() :: tuple()
@type table() :: [{any, any}]
alias FarmbotOS.Lua.CeleryScript
@doc """
Evaluates some Lua code. The code should
return a boolean value.
"""
def eval_assertion(str) when is_binary(str) do
init()
|> set_table([:get_position], &CeleryScript.get_position/2)
|> set_table([:get_pins], &CeleryScript.get_pins/2)
|> set_table([:send_message], &CeleryScript.send_message/2)
|> set_table([:help], &CeleryScript.help/2)
|> set_table([:version], &CeleryScript.version/2)
|> eval(str)
|> case do
{:ok, [true | _]} ->
true
{:ok, [false | _]} ->
false
{:ok, [_, reason]} when is_binary(reason) ->
{:error, reason}
{:ok, _data} ->
{:error, "bad return value from expression evaluation"}
{:error, {:lua_error, _error, _lua}} ->
{:error, "lua runtime error evaluating expression"}
{:error, {:badmatch, {:error, [{line, :luerl_parse, parse_error}], _}}} ->
{:error, "failed to parse expression (line:#{line}): #{IO.iodata_to_binary(parse_error)}"}
error ->
error
end
end
@spec init() :: t()
def init do
:luerl.init()
end
@spec set_table(t(), Path.t(), any()) :: t()
def set_table(lua, path, value) do
:luerl.set_table(path, value, lua)
end
@spec eval(t(), String.t()) :: {:ok, any()} | {:error, any()}
def eval(lua, hook) when is_binary(hook) do
:luerl.eval(hook, lua)
end
end

View File

@ -0,0 +1,147 @@
defmodule FarmbotOS.Lua.CeleryScript do
alias FarmbotCeleryScript.SysCalls
@doc """
Returns a table containing position data
## Example
print("x", farmbot.get_position().x);
print("y", farmbot.get_position()["y"]);
position = farmbot.get_position();
print("z", position.z);
"""
def get_position(["x"], lua) do
case SysCalls.get_current_x() do
x when is_number(x) ->
{[x, nil], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
def get_position(["y"], lua) do
case SysCalls.get_current_y() do
y when is_number(y) ->
{[y, nil], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
def get_position(["z"], lua) do
case SysCalls.get_current_z() do
z when is_number(z) ->
{[z, nil], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
def get_position(_args, lua) do
with x when is_number(x) <- SysCalls.get_current_x(),
y when is_number(y) <- SysCalls.get_current_y(),
z when is_number(z) <- SysCalls.get_current_z() do
{[[{"x", x}, {"y", y}, {"z", z}], nil], lua}
else
{:error, reason} ->
{[nil, reason], lua}
end
end
@doc """
Returns a table with pins data
## Example
print("pin9", farmbot.get_pin()["9"]);
"""
def get_pins(_args, lua) do
case do_get_pins(Enum.to_list(0..69)) do
{:ok, contents} ->
{[contents, nil], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
@doc """
# Example Usage
## With channels
farmbot.send_message("info", "hello, world", ["email", "toast"])
## No channels
farmbot.send_message("info", "hello, world")
"""
def send_message([kind, message], lua) do
do_send_message(kind, message, [], lua)
end
def send_message([kind, message | channels], lua) do
channels = Enum.map(channels, &String.to_atom/1)
do_send_message(kind, message, channels, lua)
end
@doc """
Returns help docs about a function.
"""
def help([function_name], lua) do
function_name = String.to_atom(function_name)
case Code.fetch_docs(__MODULE__) do
{:docs_v1, _, _, _, _, _, docs} ->
docs =
Enum.find_value(docs, fn
{{:function, ^function_name, _arity}, _, _, %{"en" => docs}, _} ->
IO.iodata_to_binary(docs)
_other ->
false
end)
if docs,
do: {[docs, nil], lua},
else: {[nil, "docs not found"], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
@doc "Returns the current version of farmbot."
def version(_args, lua) do
{[FarmbotCore.Project.version(), nil], lua}
end
defp do_send_message(kind, message, channels, lua) do
case SysCalls.send_message(kind, message, channels) do
:ok ->
{[true, nil], lua}
{:error, reason} ->
{[nil, reason], lua}
end
end
defp do_get_pins(nums, acc \\ [])
defp do_get_pins([p | rest], acc) do
case FarmbotFirmware.request({:pin_read, [p: p]}) do
{:ok, {_, {:report_pin_value, [p: ^p, v: v]}}} ->
do_get_pins(rest, [{to_string(p), v} | acc])
er ->
er
end
end
defp do_get_pins([], acc), do: {:ok, Enum.reverse(acc)}
end

View File

@ -23,6 +23,8 @@ defmodule FarmbotOS.SysCalls do
SetPinIOMode
}
alias FarmbotOS.Lua
alias FarmbotCore.{Asset, Asset.Repo, Asset.Private, Asset.Sync, BotState, Leds}
alias FarmbotExt.{API, API.Reconciler, API.SyncGroup}
@ -58,6 +60,9 @@ defmodule FarmbotOS.SysCalls do
@impl true
defdelegate set_pin_io_mode(pin, mode), to: SetPinIOMode
@impl true
defdelegate eval_assertion(expression), to: Lua
@impl true
def log(message) do
if FarmbotCore.Asset.fbos_config(:sequence_body_log) do

View File

@ -82,6 +82,7 @@ defmodule FarmbotOS.MixProject do
{:nerves_hub_cli, "~> 0.7", runtime: false},
{:shoehorn, "~> 0.6"},
{:ring_logger, "~> 0.8"},
{:luerl, github: "rvirding/luerl"},
# Host/test only dependencies.
{:excoveralls, "~> 0.10", only: [:test], targets: [:host]},

View File

@ -33,6 +33,8 @@
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jsx": {:hex, :jsx, "2.9.0", "d2f6e5f069c00266cad52fb15d87c428579ea4d7d73a33669e12679e203329dd", [:mix, :rebar3], [], "hexpm"},
"lager": {:hex, :lager, "3.6.5", "831910109f3fcb503debf658ca0538836b348c58bfbf349a6d48228096ce9040", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm"},
"luer": {:git, "https://github.com/rvirding/luerl.git", "ce4e1b5a66a2a37efe2f8cd16e365ad9845b5015", []},
"luerl": {:git, "https://github.com/rvirding/luerl.git", "ce4e1b5a66a2a37efe2f8cd16e365ad9845b5015", []},
"makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"mdns_lite": {:hex, :mdns_lite, "0.1.0", "efad834847576ab7641d1016754ec6f512d765788ae3718e1ac6a648c85eab12", [:mix], [{:dns, "~> 2.1", [hex: :dns, repo: "hexpm", optional: false]}], "hexpm"},

View File

@ -241,6 +241,11 @@ defmodule Farmbot.TestSupport.CeleryScript.TestSysCalls do
call({:zero, [axis]})
end
@impl true
def eval_assertion(expression) do
call({:eval_assertion, [expression]})
end
defp call(data) do
{handler, kind, args} = GenServer.call(__MODULE__, data, :infinity)
handler.(kind, args)