From c8bdcccf277452002346abfa244eb62484f41864 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 4 Feb 2025 10:03:22 -0700 Subject: [PATCH] feat(hsm): first version of hsm library functional --- ' | 104 ++++ lib/hsm.moon | 334 ++++++++++ lib/lume.lua | 780 ++++++++++++++++++++++++ {src => lib}/util/debug.moon | 0 lib/util/list.moon | 5 + {src => lib}/util/parse_transition.moon | 18 +- {src => lib}/util/string.moon | 0 {src => lib}/util/validators.moon | 8 +- spec/parse_transition_spec.moon | 32 +- spec/string_spec.moon | 2 +- spec/validators_spec.moon | 25 +- src/stoplight.moon | 109 ++++ 12 files changed, 1371 insertions(+), 46 deletions(-) create mode 100644 ' create mode 100644 lib/hsm.moon create mode 100644 lib/lume.lua rename {src => lib}/util/debug.moon (100%) create mode 100644 lib/util/list.moon rename {src => lib}/util/parse_transition.moon (64%) rename {src => lib}/util/string.moon (100%) rename {src => lib}/util/validators.moon (77%) create mode 100644 src/stoplight.moon diff --git a/' b/' new file mode 100644 index 0000000..fa4fb87 --- /dev/null +++ b/' @@ -0,0 +1,104 @@ +require "src.util.validators" + +describe "validators", -> + describe "is_valid_timer", -> + it "returns falsy when passed nil", -> + result = is_valid_timer nil + -- nil is falsy in Lua. + assert.falsy result + + it "returns false when passed false", -> + result = is_valid_timer false + assert.is_false result + + it "returns false when passed 0", -> + result = is_valid_timer 0 + assert.is_false result + + it "returns false when passed a negative number", -> + result = is_valid_timer -1 + assert.is_false result + + it "returns true when passed a positive integer", -> + result = is_valid_timer 1 + assert.is_true result + + it "returns true when passed a positive decimal", -> + result = is_valid_timer 3.14 + assert.is_true result + + it "returns false when passed a non-number (string)", -> + assert.is_false => is_valid_timer "10" + + it "returns false when passed a non-number (boolean true)", -> + assert.has_false => is_valid_timer true + + it "returns false when passed a non-number (table)", -> + assert.has_false => is_valid_timer { foo: "bar" } + + describe "is_valid_symbol", -> + -- When the input is not a string, it should immediately return false. + it "returns false when passed nil", -> + result = is_valid_symbol nil + assert.is_false result + + it "returns false when passed a number", -> + result = is_valid_symbol 123 + assert.is_false result + + it "returns false when passed a table", -> + result = is_valid_symbol { } + assert.is_false result + + it "returns false when passed a boolean", -> + result = is_valid_symbol false + assert.is_false result + + -- When the input is a string: + it "returns false for an empty string", -> + result = is_valid_symbol "" + assert.is_false result + + it "returns true for a single lowercase letter", -> + result = is_valid_symbol "a" + assert.is_true result + + it "returns true for a single uppercase letter", -> + result = is_valid_symbol "Z" + assert.is_true result + + it "returns true for a string of only letters", -> + result = is_valid_symbol "HelloWorld" + assert.is_true result + + it "returns true for a string with mixed case letters", -> + result = is_valid_symbol "AbCdEf" + assert.is_true result + + it "returns false for a string with numbers", -> + result = is_valid_symbol "abc123" + assert.is_false result + + it "returns false for a string with punctuation", -> + result = is_valid_symbol "hello!" + assert.is_false result + + it "returns false for a string with an underscore", -> + result = is_valid_symbol "a_b" + assert.is_false result + + it "returns false for a string containing whitespace", -> + result = is_valid_symbol "abc def" + assert.is_false result + + it "returns false for a string with leading/trailing whitespace", -> + result = is_valid_symbol " abc " + assert.is_false result + + it "returns false for a string with non-ASCII letters", -> + result = is_valid_symbol "éclair" + assert.is_false result + + it "returns false for a string with a hyphen", -> + result = is_valid_symbol "A-B" + assert.is_false result diff --git a/lib/hsm.moon b/lib/hsm.moon new file mode 100644 index 0000000..884658f --- /dev/null +++ b/lib/hsm.moon @@ -0,0 +1,334 @@ +-- Require external modules and libraries +require "lib.util.debug" +require "lib.util.list" +require "lib.util.parse_transition" +require "lib.util.validators" + +lume = require "lib.lume" + +-- Global constants +ROOT_NAME = "HSM" +TIMER_NAME = "_HSM_TIMER_" +TERMINAL_STATE = "ExitHSM" + +-- Build the path from a given state up to the root. +-- @param state (table) The starting state node. +-- @return (table) Array of nodes from the starting state up to the root. +path_to_root = (state) -> + path = {} + while state + table.insert(path, state) + state = state.parent + path + +-- Recursively construct a state node. +-- +-- Each node is augmented with a reference to its parent, transitions, timers, +-- and, if it is the root, a reference to the full state_map. +-- +-- @param name (string) The name of the state. +-- @param def (table) The state definition table. +-- @param parent (table or nil) The parent state node. +-- @param state_map (table) Global mapping of state names to nodes. +-- @return (table) The constructed state node. +build_node = (name, def, parent, state_map) -> + -- Validate state name (except for the ROOT) + unless name == ROOT_NAME or is_valid_symbol name + error "Invalid state name: #{name}" + + -- Initialize node with default properties. + node = + name: name + parent: parent + transitions: {} + timers: {} + default: def.default or nil + + -- Ensure state is defined only once. + if state_map[name] + error "State #{name} defined more than once" + state_map[name] = node + + -- Process transitions defined as array entries. + for index, transition_str in ipairs def + transition = parse_transition(transition_str) -- parse_transition does validation + if transition.type == "on" + node.transitions[transition.event] = transition.target + else + -- For "every" or "after" transitions, generate a unique timer event name. + event_name = "#{TIMER_NAME}#{name}__#{#node.timers + 1}" + once = transition.type == "after" + duration = transition.duration + node.transitions[event_name] = transition.target + table.insert(node.timers, {once: once, duration: duration, event: event_name}) + + -- Process nested states (ignore keys that are not strings or are "default"). + for key, value in pairs def + if type(key) == "string" and key != "default" + build_node(key, value, node, state_map) + + -- If this is the root node, attach the full state_map. + unless parent + node.state_map = state_map + + node + +-- Build the hierarchical state machine (HSM) graph. +-- +-- This function takes a machine definition and recursively constructs the HSM +-- by creating nodes and then converting transition targets (state names) into +-- actual node references. +-- +-- @param def (table) The machine definition. +-- @return (table) The root state node of the HSM. +build_hsm = (def) -> + -- Prepopulate state_map with a terminal state. + root = build_node(ROOT_NAME, def, nil, { + [TERMINAL_STATE]: { name: TERMINAL_STATE, transitions: {}, timers: {} } + }) + + -- Grab a reference to the state map + states = root.state_map + + -- Convert default and normal transitions from state names to node references. + for name, state in pairs states + -- update default transitions + if state.default + state.default = states[state.default] + -- update normal transitions + if state.transitions + for event, target in pairs state.transitions + unless states[target] + error "State #{target} referenced, but never defined" + state.transitions[event] = states[target] -- change string to node reference here + + root + +-- Plan a transition from a source state to a destination state. +-- +-- This function follows any default transitions from the destination state, +-- then computes the exit and entry paths by finding the least common ancestor +-- (LCA) of the source and final destination. +-- +-- @param src (table) The current state node. +-- @param dst (table) The target state node. +-- @return (table) A table with `exit` (states to exit) and `entry` (states to enter). +plan_transition = lume.memoize (src, dst) -> + -- Follow default transitions to get the final destination. + seen = {} + final_dst = dst + while final_dst.default + if seen[final_dst] + error "Cycle detected in default transitions (in state #{final_dst.name})!" + seen[final_dst] = true + final_dst = final_dst.default + + -- Build paths from both source and destination to the root. + src_path = path_to_root(src) + dst_path = path_to_root(final_dst) + + -- Remove the common tail to determine the least common ancestor. + while #src_path > 0 and #dst_path > 0 and src_path[#src_path] == dst_path[#dst_path] + table.remove(src_path, #src_path) + table.remove(dst_path, #dst_path) + + plan = + exit: src_path + entry: reverse(dst_path) + + -- If transitioning to the terminal state, no entry actions are needed. + if final_dst.name == TERMINAL_STATE + plan.entry = {} + + plan + +-- Create an HSM runtime class from a machine definition. +-- +-- The returned class provides methods for state transitions, updating timers, +-- handling events, and invoking state entry/update/exit actions. +-- +-- @param machine_def (table) The machine definition. +-- @return (class) The HSM runtime class. +export create_hsm = (machine_def) -> + + root = build_hsm(machine_def) + + class HSMRuntime + -- Constructor: initializes the runtime state and binds state action methods. + new: => + @root = root + @cur_state = nil + @timers = {} -- Map: state node -> list of timer objects + @event_queue = {} + @is_handling = false + @in_transition = false + @MAX_EVENTS_PER_UPDATE = 10 + + -- Bind action methods (entry, update, exit) for each state. + default_msg = (action) -> print "*** WARNING *** Using default #{action} HSM action function. Override to suppress this message." + for name, state in pairs @root.state_map + state.entry = @["on#{name}Entry"] or (=> default_msg "on#{name}Entry") + state.update = @["on#{name}Update"] or ((dt) -> default_msg "on#{name}Update") + state.exit = @["on#{name}Exit"] or (=> default_msg "on#{name}Exit") + + -- Transition into the initial state and return self. + @transition(@root) + @ + + -- Initialize timers for a given state. + -- + -- @param state (table) The state node whose timers should be created. + init_timers: (state) => + if state.timers + @timers[state] = lume.map state.timers, (t) -> + { + once: t.once + duration: t.duration + event: t.event -- TODO: change this from event to target state + elapsed: 0 + } + + -- Destroy timers associated with a state. + -- + -- @param state (table) The state node whose timers should be removed. + destroy_timers: (state) => + @timers[state] = nil + + -- Update timers across all active states. + -- + -- Called during each update cycle; increments timer elapsed times, + -- checks for timer firing (including overshoot), and triggers events. + -- + -- @param dt (number) The delta time since the last update. + -- @return (boolean) false if there is no current state, otherwise true. + update_timers: (dt) => + return false unless @cur_state + max_timer = nil + + -- Iterate over timers for each state. + for state, timers in pairs @timers + for timer in *timers + continue unless timer.duration + timer.elapsed += dt + timer.overshoot = timer.elapsed - timer.duration + if timer.overshoot > 0 and (not max_timer or timer.overshoot > max_timer.overshoot) + max_timer = timer + + -- If a timer fired, trigger its event. + if max_timer + if max_timer.once + max_timer.duration = nil + max_timer.elapsed = nil + else + max_timer.elapsed = max_timer.elapsed % max_timer.duration + @trigger(max_timer.event) + + true + + -- Transition from the current state to a destination state. + -- + -- This method computes the exit/entry plan, calls exit actions (and destroys timers) + -- for states being exited, then calls entry actions (and initializes timers) for states + -- being entered. + -- + -- @param dst (table) The target state node. + -- @return (boolean) true if no actual state change occurred. + transition: (dst) => + @in_transition = true + starting_state = @cur_state + plan = plan_transition(@cur_state, dst) + + -- Exit states: destroy timers and invoke exit actions. + for state in *plan.exit + @destroy_timers(state) + @cur_state = state + if state.exit then state.exit(self) + if state == @root + @cur_state = nil -- HSM terminated + @in_transition = false + return + + -- Enter new states: initialize timers and invoke entry actions. + for state in *plan.entry + @init_timers(state) + if state.entry then state.entry(self) + @cur_state = state + + @in_transition = false + + -- process events that we might've accumulated during transition + @drain_events() + + starting_state == @cur_state + + -- Update the HSM. + -- + -- Processes timers and then calls update actions from the current state upward. + -- + -- @param dt (number) Delta time. + -- @return (boolean) true if there is a current state; false otherwise. + update: (dt) => + -- exit if the HSM is terminated + return false unless @cur_state + + -- update our timers + @update_timers(dt) + + -- Propagate update actions from current state up through ancestors. + s = @cur_state + while s + if s.update then s.update(self, dt) + s = s.parent + + -- Process any events in our queue, some of which might've just happened + @drain_events() + + true + + -- Process an event. + -- + -- Processes an event by checking to see if the current state or any of its + -- parents handles it. If none, the event is just discarded. Once an event + -- is handled by any state, it's discarded. + -- + -- @param event (string) Event. + process_event: (event) => + -- search from current state to root looking for one to handle this + s = @cur_state + while s + if s.transitions[event] + -- found a state that handles event, transition + @transition(s.transitions[event]) + return + s = s.parent + + -- Drain events in the @event_queue. + -- + -- Processes events in the queue up to @MAX_EVENTS_PER_UPDATE. + drain_events: () => + num = math.min(@MAX_EVENTS_PER_UPDATE, #@event_queue) + count = 0 + while count < num and #@event_queue > 0 + @process_event(table.remove(@event_queue, 1)) + + -- Handle an event by checking the current state and its ancestors. + -- + -- If a transition for the event is found, the HSM will transition accordingly. + -- + -- @param event (string) The event to trigger. + -- @return (boolean) false if there is no current state; true otherwise. + trigger: (event) => + return false unless @cur_state + + if @is_handling or @in_transition + -- if we're processing an event now, just queue it up + table.insert(@event_queue, event) + else + -- if we're not processing anything, do it immediately + @is_handling = true + @process_event(event) + @is_handling = false + + true + + HSMRuntime diff --git a/lib/lume.lua b/lib/lume.lua new file mode 100644 index 0000000..2157891 --- /dev/null +++ b/lib/lume.lua @@ -0,0 +1,780 @@ +-- +-- lume +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local lume = { _version = "2.3.0" } + +local pairs, ipairs = pairs, ipairs +local type, assert, unpack = type, assert, unpack or table.unpack +local tostring, tonumber = tostring, tonumber +local math_floor = math.floor +local math_ceil = math.ceil +local math_atan2 = math.atan2 or math.atan +local math_sqrt = math.sqrt +local math_abs = math.abs + +local noop = function() +end + +local identity = function(x) + return x +end + +local patternescape = function(str) + return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1") +end + +local absindex = function(len, i) + return i < 0 and (len + i + 1) or i +end + +local iscallable = function(x) + if type(x) == "function" then return true end + local mt = getmetatable(x) + return mt and mt.__call ~= nil +end + +local getiter = function(x) + if lume.isarray(x) then + return ipairs + elseif type(x) == "table" then + return pairs + end + error("expected table", 3) +end + +local iteratee = function(x) + if x == nil then return identity end + if iscallable(x) then return x end + if type(x) == "table" then + return function(z) + for k, v in pairs(x) do + if z[k] ~= v then return false end + end + return true + end + end + return function(z) return z[x] end +end + + + +function lume.clamp(x, min, max) + return x < min and min or (x > max and max or x) +end + + +function lume.round(x, increment) + if increment then return lume.round(x / increment) * increment end + return x >= 0 and math_floor(x + .5) or math_ceil(x - .5) +end + + +function lume.sign(x) + return x < 0 and -1 or 1 +end + + +function lume.lerp(a, b, amount) + return a + (b - a) * lume.clamp(amount, 0, 1) +end + + +function lume.smooth(a, b, amount) + local t = lume.clamp(amount, 0, 1) + local m = t * t * (3 - 2 * t) + return a + (b - a) * m +end + + +function lume.pingpong(x) + return 1 - math_abs(1 - x % 2) +end + + +function lume.distance(x1, y1, x2, y2, squared) + local dx = x1 - x2 + local dy = y1 - y2 + local s = dx * dx + dy * dy + return squared and s or math_sqrt(s) +end + + +function lume.angle(x1, y1, x2, y2) + return math_atan2(y2 - y1, x2 - x1) +end + + +function lume.vector(angle, magnitude) + return math.cos(angle) * magnitude, math.sin(angle) * magnitude +end + + +function lume.random(a, b) + if not a then a, b = 0, 1 end + if not b then b = 0 end + return a + math.random() * (b - a) +end + + +function lume.randomchoice(t) + return t[math.random(#t)] +end + + +function lume.weightedchoice(t) + local sum = 0 + for _, v in pairs(t) do + assert(v >= 0, "weight value less than zero") + sum = sum + v + end + assert(sum ~= 0, "all weights are zero") + local rnd = lume.random(sum) + for k, v in pairs(t) do + if rnd < v then return k end + rnd = rnd - v + end +end + + +function lume.isarray(x) + return type(x) == "table" and x[1] ~= nil +end + + +function lume.push(t, ...) + local n = select("#", ...) + for i = 1, n do + t[#t + 1] = select(i, ...) + end + return ... +end + + +function lume.remove(t, x) + local iter = getiter(t) + for i, v in iter(t) do + if v == x then + if lume.isarray(t) then + table.remove(t, i) + break + else + t[i] = nil + break + end + end + end + return x +end + + +function lume.clear(t) + local iter = getiter(t) + for k in iter(t) do + t[k] = nil + end + return t +end + + +function lume.extend(t, ...) + for i = 1, select("#", ...) do + local x = select(i, ...) + if x then + for k, v in pairs(x) do + t[k] = v + end + end + end + return t +end + + +function lume.shuffle(t) + local rtn = {} + for i = 1, #t do + local r = math.random(i) + if r ~= i then + rtn[i] = rtn[r] + end + rtn[r] = t[i] + end + return rtn +end + + +function lume.sort(t, comp) + local rtn = lume.clone(t) + if comp then + if type(comp) == "string" then + table.sort(rtn, function(a, b) return a[comp] < b[comp] end) + else + table.sort(rtn, comp) + end + else + table.sort(rtn) + end + return rtn +end + + +function lume.array(...) + local t = {} + for x in ... do t[#t + 1] = x end + return t +end + + +function lume.each(t, fn, ...) + local iter = getiter(t) + if type(fn) == "string" then + for _, v in iter(t) do v[fn](v, ...) end + else + for _, v in iter(t) do fn(v, ...) end + end + return t +end + + +function lume.map(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + for k, v in iter(t) do rtn[k] = fn(v) end + return rtn +end + + +function lume.all(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if not fn(v) then return false end + end + return true +end + + +function lume.any(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if fn(v) then return true end + end + return false +end + + +function lume.reduce(t, fn, first) + local started = first ~= nil + local acc = first + local iter = getiter(t) + for _, v in iter(t) do + if started then + acc = fn(acc, v) + else + acc = v + started = true + end + end + assert(started, "reduce of an empty table with no first value") + return acc +end + + +function lume.unique(t) + local rtn = {} + for k in pairs(lume.invert(t)) do + rtn[#rtn + 1] = k + end + return rtn +end + + +function lume.filter(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.reject(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if not fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if not fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.merge(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + local iter = getiter(t) + for k, v in iter(t) do + rtn[k] = v + end + end + return rtn +end + + +function lume.concat(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + if t ~= nil then + local iter = getiter(t) + for _, v in iter(t) do + rtn[#rtn + 1] = v + end + end + end + return rtn +end + + +function lume.find(t, value) + local iter = getiter(t) + for k, v in iter(t) do + if v == value then return k end + end + return nil +end + + +function lume.match(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for k, v in iter(t) do + if fn(v) then return v, k end + end + return nil +end + + +function lume.count(t, fn) + local count = 0 + local iter = getiter(t) + if fn then + fn = iteratee(fn) + for _, v in iter(t) do + if fn(v) then count = count + 1 end + end + else + if lume.isarray(t) then + return #t + end + for _ in iter(t) do count = count + 1 end + end + return count +end + + +function lume.slice(t, i, j) + i = i and absindex(#t, i) or 1 + j = j and absindex(#t, j) or #t + local rtn = {} + for x = i < 1 and 1 or i, j > #t and #t or j do + rtn[#rtn + 1] = t[x] + end + return rtn +end + + +function lume.first(t, n) + if not n then return t[1] end + return lume.slice(t, 1, n) +end + + +function lume.last(t, n) + if not n then return t[#t] end + return lume.slice(t, -n, -1) +end + + +function lume.invert(t) + local rtn = {} + for k, v in pairs(t) do rtn[v] = k end + return rtn +end + + +function lume.pick(t, ...) + local rtn = {} + for i = 1, select("#", ...) do + local k = select(i, ...) + rtn[k] = t[k] + end + return rtn +end + + +function lume.keys(t) + local rtn = {} + local iter = getiter(t) + for k in iter(t) do rtn[#rtn + 1] = k end + return rtn +end + + +function lume.clone(t) + local rtn = {} + for k, v in pairs(t) do rtn[k] = v end + return rtn +end + + +function lume.fn(fn, ...) + assert(iscallable(fn), "expected a function as the first argument") + local args = { ... } + return function(...) + local a = lume.concat(args, { ... }) + return fn(unpack(a)) + end +end + + +function lume.once(fn, ...) + local f = lume.fn(fn, ...) + local done = false + return function(...) + if done then return end + done = true + return f(...) + end +end + + +local memoize_fnkey = {} +local memoize_nil = {} + +function lume.memoize(fn) + local cache = {} + return function(...) + local c = cache + for i = 1, select("#", ...) do + local a = select(i, ...) or memoize_nil + c[a] = c[a] or {} + c = c[a] + end + c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)} + return unpack(c[memoize_fnkey]) + end +end + + +function lume.combine(...) + local n = select('#', ...) + if n == 0 then return noop end + if n == 1 then + local fn = select(1, ...) + if not fn then return noop end + assert(iscallable(fn), "expected a function or nil") + return fn + end + local funcs = {} + for i = 1, n do + local fn = select(i, ...) + if fn ~= nil then + assert(iscallable(fn), "expected a function or nil") + funcs[#funcs + 1] = fn + end + end + return function(...) + for _, f in ipairs(funcs) do f(...) end + end +end + + +function lume.call(fn, ...) + if fn then + return fn(...) + end +end + + +function lume.time(fn, ...) + local start = os.clock() + local rtn = {fn(...)} + return (os.clock() - start), unpack(rtn) +end + + +local lambda_cache = {} + +function lume.lambda(str) + if not lambda_cache[str] then + local args, body = str:match([[^([%w,_ ]-)%->(.-)$]]) + assert(args and body, "bad string lambda") + local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend" + lambda_cache[str] = lume.dostring(s) + end + return lambda_cache[str] +end + + +local serialize + +local serialize_map = { + [ "boolean" ] = tostring, + [ "nil" ] = tostring, + [ "string" ] = function(v) return string.format("%q", v) end, + [ "number" ] = function(v) + if v ~= v then return "0/0" -- nan + elseif v == 1 / 0 then return "1/0" -- inf + elseif v == -1 / 0 then return "-1/0" end -- -inf + return tostring(v) + end, + [ "table" ] = function(t, stk) + stk = stk or {} + if stk[t] then error("circular reference") end + local rtn = {} + stk[t] = true + for k, v in pairs(t) do + rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk) + end + stk[t] = nil + return "{" .. table.concat(rtn, ",") .. "}" + end +} + +setmetatable(serialize_map, { + __index = function(_, k) error("unsupported serialize type: " .. k) end +}) + +serialize = function(x, stk) + return serialize_map[type(x)](x, stk) +end + +function lume.serialize(x) + return serialize(x) +end + + +function lume.deserialize(str) + return lume.dostring("return " .. str) +end + + +function lume.split(str, sep) + if not sep then + return lume.array(str:gmatch("([%S]+)")) + else + assert(sep ~= "", "empty separator") + local psep = patternescape(sep) + return lume.array((str..sep):gmatch("(.-)("..psep..")")) + end +end + + +function lume.trim(str, chars) + if not chars then return str:match("^[%s]*(.-)[%s]*$") end + chars = patternescape(chars) + return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$") +end + + +function lume.wordwrap(str, limit) + limit = limit or 72 + local check + if type(limit) == "number" then + check = function(s) return #s >= limit end + else + check = limit + end + local rtn = {} + local line = "" + for word, spaces in str:gmatch("(%S+)(%s*)") do + local s = line .. word + if check(s) then + table.insert(rtn, line .. "\n") + line = word + else + line = s + end + for c in spaces:gmatch(".") do + if c == "\n" then + table.insert(rtn, line .. "\n") + line = "" + else + line = line .. c + end + end + end + table.insert(rtn, line) + return table.concat(rtn) +end + + +function lume.format(str, vars) + if not vars then return str end + local f = function(x) + return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}") + end + return (str:gsub("{(.-)}", f)) +end + + +function lume.trace(...) + local info = debug.getinfo(2, "Sl") + local t = { info.short_src .. ":" .. info.currentline .. ":" } + for i = 1, select("#", ...) do + local x = select(i, ...) + if type(x) == "number" then + x = string.format("%g", lume.round(x, .01)) + end + t[#t + 1] = tostring(x) + end + print(table.concat(t, " ")) +end + + +function lume.dostring(str) + return assert((loadstring or load)(str))() +end + + +function lume.uuid() + local fn = function(x) + local r = math.random(16) - 1 + r = (x == "x") and (r + 1) or (r % 4) + 9 + return ("0123456789abcdef"):sub(r, r) + end + return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) +end + + +function lume.hotswap(modname) + local oldglobal = lume.clone(_G) + local updated = {} + local function update(old, new) + if updated[old] then return end + updated[old] = true + local oldmt, newmt = getmetatable(old), getmetatable(new) + if oldmt and newmt then update(oldmt, newmt) end + for k, v in pairs(new) do + if type(v) == "table" then update(old[k], v) else old[k] = v end + end + end + local err = nil + local function onerror(e) + for k in pairs(_G) do _G[k] = oldglobal[k] end + err = lume.trim(e) + end + local ok, oldmod = pcall(require, modname) + oldmod = ok and oldmod or nil + xpcall(function() + package.loaded[modname] = nil + local newmod = require(modname) + if type(oldmod) == "table" then update(oldmod, newmod) end + for k, v in pairs(oldglobal) do + if v ~= _G[k] and type(v) == "table" then + update(v, _G[k]) + _G[k] = v + end + end + end, onerror) + package.loaded[modname] = oldmod + if err then return nil, err end + return oldmod +end + + +local ripairs_iter = function(t, i) + i = i - 1 + local v = t[i] + if v ~= nil then + return i, v + end +end + +function lume.ripairs(t) + return ripairs_iter, t, (#t + 1) +end + + +function lume.color(str, mul) + mul = mul or 1 + local r, g, b, a + r, g, b = str:match("#(%x%x)(%x%x)(%x%x)") + if r then + r = tonumber(r, 16) / 0xff + g = tonumber(g, 16) / 0xff + b = tonumber(b, 16) / 0xff + a = 1 + elseif str:match("rgba?%s*%([%d%s%.,]+%)") then + local f = str:gmatch("[%d.]+") + r = (f() or 0) / 0xff + g = (f() or 0) / 0xff + b = (f() or 0) / 0xff + a = f() or 1 + else + error(("bad color string '%s'"):format(str)) + end + return r * mul, g * mul, b * mul, a * mul +end + + +local chain_mt = {} +chain_mt.__index = lume.map(lume.filter(lume, iscallable, true), + function(fn) + return function(self, ...) + self._value = fn(self._value, ...) + return self + end + end) +chain_mt.__index.result = function(x) return x._value end + +function lume.chain(value) + return setmetatable({ _value = value }, chain_mt) +end + +setmetatable(lume, { + __call = function(_, ...) + return lume.chain(...) + end +}) + + +return lume diff --git a/src/util/debug.moon b/lib/util/debug.moon similarity index 100% rename from src/util/debug.moon rename to lib/util/debug.moon diff --git a/lib/util/list.moon b/lib/util/list.moon new file mode 100644 index 0000000..e3ff242 --- /dev/null +++ b/lib/util/list.moon @@ -0,0 +1,5 @@ +-- Reverse a list. +-- @param list (table) The array to reverse. +-- @return (table) A new array with the elements in reverse order. +export reverse = (list) -> + [list[i] for i = #list, 1, -1] diff --git a/src/util/parse_transition.moon b/lib/util/parse_transition.moon similarity index 64% rename from src/util/parse_transition.moon rename to lib/util/parse_transition.moon index f6a75d1..b0a8663 100644 --- a/src/util/parse_transition.moon +++ b/lib/util/parse_transition.moon @@ -1,5 +1,5 @@ -require "src.util.string" -require "src.util.validators" +require "lib.util.string" +require "lib.util.validators" --- -- parse_transition @@ -23,19 +23,13 @@ export parse_transition = (trans) -> when "on" unless is_valid_symbol words[3] error "Invalid event name" - {target: words[1], event: words[3]} + {target: words[1], type: words[2], event: words[3]} - when "after" + when "after", "every" num = tonumber words[3] unless is_valid_timer num - error "Invalid time value in 'after' transition type" - {target: words[1], after: num} - - when "every" - num = tonumber words[3] - unless is_valid_timer num - error "Invalid time value in 'every' transition type" - {target: words[1], every: num} + error "Invalid time value in 'after/every' transition type" + {target: words[1], type: words[2], duration: num} else error "Unknown transition type" diff --git a/src/util/string.moon b/lib/util/string.moon similarity index 100% rename from src/util/string.moon rename to lib/util/string.moon diff --git a/src/util/validators.moon b/lib/util/validators.moon similarity index 77% rename from src/util/validators.moon rename to lib/util/validators.moon index d4855df..d23b64b 100644 --- a/src/util/validators.moon +++ b/lib/util/validators.moon @@ -6,7 +6,7 @@ -- @param x number: The timer value to validate. -- @return boolean: Returns `true` if `x` is a positive number; otherwise, returns `false` or `nil`. export is_valid_timer = (x) -> - x and x > 0 + type(x) == "number" and x > 0 --- -- Checks whether the given string is a valid symbol. @@ -18,11 +18,7 @@ export is_valid_timer = (x) -> -- @param s string: The string to be validated as a symbol. -- @return boolean: Returns `true` if `s` contains only letters; otherwise, returns `false`. export is_valid_symbol = (s) -> - -- Return false immediately if s is not a string. - unless type(s) == "string" - return false - -- Use Lua's pattern matching to ensure the string only contains letters. - if string.match s, "^[A-Za-z]+$" + if type(s) == "string" and string.match s, "^[A-Za-z][A-Za-z0-9_]*$" true else false diff --git a/spec/parse_transition_spec.moon b/spec/parse_transition_spec.moon index 19d1599..9df5581 100644 --- a/spec/parse_transition_spec.moon +++ b/spec/parse_transition_spec.moon @@ -1,19 +1,19 @@ -require "src.util.parse_transition" +require "lib.util.parse_transition" describe "parse_transition", -> -- ### Valid Cases ### it "parses a valid 'on' transition", -> result = parse_transition "stateA on eventX" - assert.same {target: "stateA", event: "eventX"}, result + assert.same {target: "stateA", type: "on", event: "eventX"}, result it "parses a valid 'after' transition", -> result = parse_transition "stateB after 3.14" - assert.same {target: "stateB", after: 3.14}, result + assert.same {target: "stateB", type: "after", duration: 3.14}, result it "parses a valid 'every' transition", -> result = parse_transition "stateC every 42" - assert.same {target: "stateC", every: 42}, result + assert.same {target: "stateC", type: "every", duration: 42}, result -- ### Basic Error Cases ### it "errors when input is not a string", -> @@ -25,29 +25,29 @@ describe "parse_transition", -> it "errors when transition type is unknown", -> assert.has_error (-> parse_transition "stateD unknown value"), "Unknown transition type" - it "errors when number conversion fails for 'after' transition", -> - assert.has_error (-> parse_transition "stateE after not_a_number"), "Invalid time value in 'after' transition type" + it "errors when number conversion fails for 'after/every' transition", -> + assert.has_error (-> parse_transition "stateE after not_a_number"), "Invalid time value in 'after/every' transition type" - it "errors when number conversion fails for 'every' transition", -> - assert.has_error (-> parse_transition "stateF every not_a_number"), "Invalid time value in 'every' transition type" + it "errors when number conversion fails for 'after/every' transition", -> + assert.has_error (-> parse_transition "stateF every not_a_number"), "Invalid time value in 'after/every' transition type" -- ### Additional Tests for Nil and Bad Numbers ### it "errors when nil is passed as transition", -> assert.has_error (-> parse_transition nil), "Transition must be a string" - it "errors for a negative number in an 'after' transition", -> + it "errors for a negative number in an 'after/every' transition", -> -- Assuming negative times are invalid. - assert.has_error (-> parse_transition "stateNeg after -5"), "Invalid time value in 'after' transition type" + assert.has_error (-> parse_transition "stateNeg after -5"), "Invalid time value in 'after/every' transition type" - it "errors for a negative number in an 'every' transition", -> - assert.has_error (-> parse_transition "stateNeg every -5"), "Invalid time value in 'every' transition type" + it "errors for a negative number in an 'after/every' transition", -> + assert.has_error (-> parse_transition "stateNeg every -5"), "Invalid time value in 'after/every' transition type" - it "errors for zero in an 'after' transition", -> + it "errors for zero in an 'after/every' transition", -> -- Zero is considered invalid. - assert.has_error (-> parse_transition "stateZero after 0"), "Invalid time value in 'after' transition type" + assert.has_error (-> parse_transition "stateZero after 0"), "Invalid time value in 'after/every' transition type" - it "errors for zero in an 'every' transition", -> - assert.has_error (-> parse_transition "stateZero every 0"), "Invalid time value in 'every' transition type" + it "errors for zero in an 'after/every' transition", -> + assert.has_error (-> parse_transition "stateZero every 0"), "Invalid time value in 'after/every' transition type" it "errors when the state name contains a space", -> -- e.g. "state A" is not a valid Lua identifier. diff --git a/spec/string_spec.moon b/spec/string_spec.moon index 70706a1..ffa668a 100644 --- a/spec/string_spec.moon +++ b/spec/string_spec.moon @@ -1,4 +1,4 @@ -require "src.util.string" +require "lib.util.string" describe "string.split", -> it "returns an empty table when given an empty string", -> diff --git a/spec/validators_spec.moon b/spec/validators_spec.moon index c180ceb..f856b48 100644 --- a/spec/validators_spec.moon +++ b/spec/validators_spec.moon @@ -1,4 +1,4 @@ -require "src.util.validators" +require "lib.util.validators" describe "validators", -> describe "is_valid_timer", -> @@ -27,14 +27,17 @@ describe "validators", -> result = is_valid_timer 3.14 assert.is_true result - it "errors when passed a non-number (string)", -> - assert.has_error => is_valid_timer "10" + it "returns false when passed a non-number (string)", -> + result = is_valid_timer "10" + assert.is_false result - it "errors when passed a non-number (boolean true)", -> - assert.has_error => is_valid_timer true + it "returns false when passed a non-number (boolean true)", -> + result = is_valid_timer true + assert.has_false result - it "errors when passed a non-number (table)", -> - assert.has_error => is_valid_timer { foo: "bar" } + it "returns false when passed a non-number (table)", -> + result = is_valid_timer { foo: "bar" } + assert.has_false result describe "is_valid_symbol", -> -- When the input is not a string, it should immediately return false. @@ -75,17 +78,17 @@ describe "validators", -> result = is_valid_symbol "AbCdEf" assert.is_true result - it "returns false for a string with numbers", -> + it "returns true for a string with numbers", -> result = is_valid_symbol "abc123" - assert.is_false result + assert.is_true result it "returns false for a string with punctuation", -> result = is_valid_symbol "hello!" assert.is_false result - it "returns false for a string with an underscore", -> + it "returns true for a string with an underscore", -> result = is_valid_symbol "a_b" - assert.is_false result + assert.is_true result it "returns false for a string containing whitespace", -> result = is_valid_symbol "abc def" diff --git a/src/stoplight.moon b/src/stoplight.moon new file mode 100644 index 0000000..9db9ef9 --- /dev/null +++ b/src/stoplight.moon @@ -0,0 +1,109 @@ +require "lib.hsm" + +-- Example machine definition: Stoplight +stoplight_def = { + default: "Red" -- Initial substate is "Red" + + -- Global timer: every 15s, transition into FlashingRed + "FlashingRed every 15" + + -- Terminate the HSM after 25s + "ExitHSM after 25" + + -- keyboard events + "Green on G_PRESSED" + "Red on R_PRESSED" + "Yellow on Y_PRESSED" + "ExitHSM on Q_PRESSED" + "FlashingRed on F_PRESSED" + + Red: { + -- After 5s in Red, go to Green + "Green after 5" + } + + Green: { + -- After 5s in Green, go to Yellow + "Yellow after 5" + } + + Yellow: { + -- After 1s in Yellow, return to Red + "Red after 1" + } + + FlashingRed: { + -- On entering FlashingRed, immediately go to FlashingRedOn + default: "FlashingRedOn" + + -- After 5s in FlashingRed, transition back to Red + "Red after 5" + + FlashingRedOn: { + -- After 0.5s in FlashingRedOn, toggle to FlashingRedOff + "FlashingRedOff after 0.5" + } + + FlashingRedOff: { + -- After 0.5s in FlashingRedOff, toggle back to FlashingRedOn + "FlashingRedOn after 0.5" + } + } +} + +-- Define a Stoplight class that extends the HSM runtime. +class Stoplight extends create_hsm(stoplight_def) + -- HSM-level actions. + onHSMEntry: => print "stoplight started" + onHSMUpdate: (dt) => + onHSMExit: => print "stoplight stopped" + + -- Red state actions. + onRedEntry: => print "red light ON" + onRedUpdate: (dt) => + onRedExit: => print "red light OFF" + + -- FlashingRed state actions. + onFlashingRedEntry: => + onFlashingRedUpdate: (dt) => + onFlashingRedExit: => + + -- FlashingRedOn state actions. + onFlashingRedOnEntry: => print "red light ON" + onFlashingRedOnUpdate: (dt) => + onFlashingRedOnExit: => print "red light OFF" + + -- FlashingRedOff state actions. + onFlashingRedOffEntry: => + onFlashingRedOffUpdate: (dt) => + onFlashingRedOffExit: => + + -- Yellow state actions. + onYellowEntry: => print "yellow light ON" + onYellowUpdate: (dt) => + onYellowExit: => print "yellow light OFF" + + -- Green state actions. + onGreenEntry: => print "green light ON" + onGreenUpdate: (dt) => + onGreenExit: => print "green light OFF" + +-- Instantiate the stoplight HSM. +stoplight = Stoplight! + +-- Set up non-blocking input. +getch = require "lua-getch" +getch.set_raw_mode io.stdin +getch.set_nonblocking io.stdin, true + +-- Main loop parameters. +dt = 0.05 +while stoplight\update dt + key = getch.get_char io.stdin + switch key + when 102 then stoplight\trigger "F_PRESSED" + when 103 then stoplight\trigger "G_PRESSED" + when 113 then stoplight\trigger "Q_PRESSED" + when 114 then stoplight\trigger "R_PRESSED" + when 121 then stoplight\trigger "Y_PRESSED" + os.execute "sleep #{dt}"