diff options
| author | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 18:22:01 +0200 |
|---|---|---|
| committer | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 21:31:43 +0200 |
| commit | 66d5e304d5c543a264bffac6d72655c46a6155e1 (patch) | |
| tree | 186992c5439e9c7e9efa61c6e3d73e88f5f920d7 /lua/clever_f/domain.lua | |
| parent | 48a5fb1d753c252780e0a267e1cba37485037fc8 (diff) | |
Defines motion domain and host capabilities
Diffstat (limited to 'lua/clever_f/domain.lua')
| -rw-r--r-- | lua/clever_f/domain.lua | 1285 |
1 files changed, 1285 insertions, 0 deletions
diff --git a/lua/clever_f/domain.lua b/lua/clever_f/domain.lua new file mode 100644 index 0000000..a5b8d39 --- /dev/null +++ b/lua/clever_f/domain.lua @@ -0,0 +1,1285 @@ +local M = {} + +local records = setmetatable({}, { __mode = "k" }) +local record_types = setmetatable({}, { __mode = "k" }) +local methods = {} +local formatters = {} +local equalities = {} +local metatables = {} + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function is_integer(value) + return type(value) == "number" + and value > -math.huge + and value < math.huge + and value == math.floor(value) +end + +local function register_type(type_name, type_methods, formatter, equality) + methods[type_name] = type_methods or {} + formatters[type_name] = formatter + equalities[type_name] = equality + + local mt = { + __index = function(value, key) + local field = records[value][key] + if field ~= nil then + return field + end + return methods[type_name][key] + end, + __newindex = function() + fail(type_name .. " values are immutable", 2) + end, + __tostring = function(value) + local format = formatters[type_name] + if format then + return format(records[value]) + end + return type_name + end, + __eq = function(left, right) + if record_types[left] ~= type_name or record_types[right] ~= type_name then + return false + end + local equal = equalities[type_name] + if equal then + return equal(records[left], records[right]) + end + return rawequal(left, right) + end, + __metatable = "clever_f.domain." .. type_name, + } + metatables[type_name] = mt +end + +local function new_record(type_name, fields) + local value = {} + records[value] = fields + record_types[value] = type_name + return setmetatable(value, metatables[type_name]) +end + +local function is_record(value, type_name) + return record_types[value] == type_name +end + +local function require_record(value, type_name, name) + if not is_record(value, type_name) then + fail((name or "value") .. " must be a " .. type_name, 2) + end + return value +end + +local function require_string(value, name, allow_empty) + if type(value) ~= "string" or (not allow_empty and value == "") then + fail(name .. " must be " .. (allow_empty and "a string" or "a nonempty string"), 2) + end + return value +end + +local function require_boolean(value, name) + if type(value) ~= "boolean" then + fail(name .. " must be a Boolean", 2) + end + return value +end + +local function require_nonnegative_integer(value, name) + if not is_integer(value) or value < 0 then + fail(name .. " must be a nonnegative integer", 2) + end + return value +end + +function M.type_of(value) + return record_types[value] +end + +local function define_enum(type_name, entries) + local enum_methods = {} + local namespace = {} + local by_value = {} + + register_type(type_name, enum_methods, function(data) + return data.value + end) + + for constant, serialized in pairs(entries) do + local value = new_record(type_name, { + name = constant, + value = serialized, + }) + namespace[constant] = value + by_value[serialized] = value + end + + function namespace.from_string(value) + if is_record(value, type_name) then + return value + end + local result = by_value[value] + if result == nil then + fail("value must be a valid " .. type_name, 2) + end + return result + end + + function namespace.is(value) + return is_record(value, type_name) + end + + function enum_methods:to_string() + return records[self].value + end + + return namespace +end + +M.Family = define_enum("Family", { + FIND = "FIND", + TILL = "TILL", +}) + +M.Direction = define_enum("Direction", { + FORWARD = "forward", + BACKWARD = "backward", +}) + +M.SelectionKind = define_enum("SelectionKind", { + NONE = "none", + CHARACTER = "character", + LINE = "line", + BLOCK = "block", +}) + +M.SelectionOption = define_enum("SelectionOption", { + INCLUSIVE = "inclusive", + EXCLUSIVE = "exclusive", +}) + +M.TargetKind = define_enum("TargetKind", { + CHARACTER = "character", + SPECIAL_KEY = "special_key", + CODE_FALLBACK = "code_fallback", +}) + +M.CaseMode = define_enum("CaseMode", { + SENSITIVE = "sensitive", + INSENSITIVE = "insensitive", +}) + +M.TargetPlanKind = define_enum("TargetPlanKind", { + EMPTY = "empty", + LITERAL = "literal", + BACKSLASH = "backslash", + SYMBOL = "symbol", + MIGEMO = "migemo", +}) + +M.SearchScope = define_enum("SearchScope", { + BUFFER = "buffer", + CURRENT_LINE = "current_line", +}) + +M.EndpointPolicy = define_enum("EndpointPolicy", { + REGULAR = "regular", + VISUAL_EXCLUSIVE = "visual_exclusive", +}) + +M.SearchStatus = define_enum("SearchStatus", { + COMPLETE = "complete", + BOUNDARY_AFTER_PARTIAL = "boundary_after_partial", + BOUNDARY_BEFORE_ANY = "boundary_before_any", +}) + +M.ActionKind = define_enum("ActionKind", { + MOVEMENT = "movement", + NEUTRAL = "neutral", + ESCAPE = "escape", + FAILED_SEARCH = "failed_search", + ERROR = "error", + EMPTY = "empty", +}) + +local Position = {} +M.Position = Position + +register_type("Position", Position, function(data) + return string.format("(%d,%d)", data.line, data.byte_column) +end, function(left, right) + return left.line == right.line and left.byte_column == right.byte_column +end) + +function Position.new(line, byte_column) + if not is_integer(line) or line < 1 then + fail("line must be a positive one-based integer", 2) + end + if not is_integer(byte_column) or byte_column < 1 then + fail("byte_column must be a positive one-based integer", 2) + end + return new_record("Position", { + line = line, + byte_column = byte_column, + }) +end + +function Position.coerce(value) + if Position.is(value) then + return value + end + if type(value) ~= "table" then + fail("position must be a Position or position table", 2) + end + return Position.new(value.line, value.byte_column) +end + +function Position.is(value) + return is_record(value, "Position") +end + +function Position.compare(left, right) + require_record(left, "Position", "left") + require_record(right, "Position", "right") + if left.line < right.line then + return -1 + end + if left.line > right.line then + return 1 + end + if left.byte_column < right.byte_column then + return -1 + end + if left.byte_column > right.byte_column then + return 1 + end + return 0 +end + +function Position.equal(left, right) + return Position.compare(left, right) == 0 +end + +function Position.stationary(left, right) + return Position.equal(left, right) +end + +function Position.is_forward(candidate, origin) + return Position.compare(candidate, origin) > 0 +end + +function Position.is_backward(candidate, origin) + return Position.compare(candidate, origin) < 0 +end + +function Position:to_table() + return { + line = self.line, + byte_column = self.byte_column, + } +end + +local Descriptor = {} +M.Descriptor = Descriptor + +register_type("Descriptor", Descriptor, function(data) + return data.value +end) + +local descriptors_by_string = {} +local descriptors_by_parts = {} + +local function descriptor_key(family, direction) + return family.value .. ":" .. direction.value +end + +local function define_descriptor(name, serialized, family, direction) + local descriptor = new_record("Descriptor", { + name = name, + value = serialized, + family = family, + direction = direction, + uppercase = serialized:match("%u") ~= nil, + }) + descriptors_by_string[serialized] = descriptor + descriptors_by_parts[descriptor_key(family, direction)] = descriptor + Descriptor[name] = descriptor + Descriptor[serialized] = descriptor + return descriptor +end + +Descriptor.FIND_FORWARD = define_descriptor( + "FIND_FORWARD", + "f", + M.Family.FIND, + M.Direction.FORWARD +) +Descriptor.FIND_BACKWARD = define_descriptor( + "FIND_BACKWARD", + "F", + M.Family.FIND, + M.Direction.BACKWARD +) +Descriptor.TILL_FORWARD = define_descriptor( + "TILL_FORWARD", + "t", + M.Family.TILL, + M.Direction.FORWARD +) +Descriptor.TILL_BACKWARD = define_descriptor( + "TILL_BACKWARD", + "T", + M.Family.TILL, + M.Direction.BACKWARD +) + +function Descriptor.is(value) + return is_record(value, "Descriptor") +end + +function Descriptor.is_valid(value) + return Descriptor.is(value) or descriptors_by_string[value] ~= nil +end + +function Descriptor.from_string(value) + if Descriptor.is(value) then + return value + end + local descriptor = descriptors_by_string[value] + if descriptor == nil then + fail("descriptor must be one of f, F, t, or T", 2) + end + return descriptor +end + +function Descriptor.try_from_string(value) + if Descriptor.is(value) then + return value + end + return descriptors_by_string[value] +end + +function Descriptor.from_parts(family, direction) + family = M.Family.from_string(family) + direction = M.Direction.from_string(direction) + return descriptors_by_parts[descriptor_key(family, direction)] +end + +function Descriptor.to_string(value) + return Descriptor.from_string(value).value +end + +function Descriptor.is_uppercase(value) + return Descriptor.from_string(value).uppercase +end + +function Descriptor.is_lowercase(value) + return not Descriptor.is_uppercase(value) +end + +function Descriptor.swap(value) + local descriptor = Descriptor.from_string(value) + local direction = descriptor.direction == M.Direction.FORWARD + and M.Direction.BACKWARD + or M.Direction.FORWARD + return Descriptor.from_parts(descriptor.family, direction) +end + +function Descriptor.lowercase(value) + local descriptor = Descriptor.from_string(value) + return Descriptor.from_parts(descriptor.family, M.Direction.FORWARD) +end + +function Descriptor.uppercase(value) + local descriptor = Descriptor.from_string(value) + return Descriptor.from_parts(descriptor.family, M.Direction.BACKWARD) +end + +local Count = {} +M.Count = Count + +register_type("Count", Count, function(data) + return tostring(data.value) +end, function(left, right) + return left.value == right.value +end) + +local count_one + +function Count.new(value) + if Count.is(value) then + return value + end + if value == nil then + value = 1 + end + if not is_integer(value) or value < 1 then + fail("count must be a positive integer", 2) + end + if value == 1 and count_one ~= nil then + return count_one + end + local count = new_record("Count", { value = value }) + if value == 1 then + count_one = count + end + return count +end + +function Count.is(value) + return is_record(value, "Count") +end + +function Count.to_number(value) + return Count.new(value).value +end + +Count.ONE = Count.new(1) + +local ModeContext = {} +M.ModeContext = ModeContext + +register_type("ModeContext", ModeContext, function(data) + return data.key +end) + +local mode_contexts = {} +local CTRL_V = string.char(0x16) +local CTRL_S = string.char(0x13) + +local operator_modes = { + no = true, + nov = true, + noV = true, + ["no" .. CTRL_V] = true, +} + +local function mode_traits(full_mode) + local operator = operator_modes[full_mode] == true + local visual_kind + local select_kind + + if not operator then + local lead = full_mode:sub(1, 1) + if lead == "v" then + visual_kind = M.SelectionKind.CHARACTER + elseif lead == "V" then + visual_kind = M.SelectionKind.LINE + elseif lead == CTRL_V then + visual_kind = M.SelectionKind.BLOCK + elseif lead == "s" then + select_kind = M.SelectionKind.CHARACTER + elseif lead == "S" then + select_kind = M.SelectionKind.LINE + elseif lead == CTRL_S then + select_kind = M.SelectionKind.BLOCK + end + end + + return operator, visual_kind, select_kind +end + +function ModeContext.from_full_mode(full_mode) + if ModeContext.is(full_mode) then + return full_mode + end + require_string(full_mode, "full_mode", false) + + local operator, visual_kind, select_kind = mode_traits(full_mode) + local key = operator and "no" or full_mode + local context = mode_contexts[key] + if context ~= nil then + return context + end + + context = new_record("ModeContext", { + key = key, + full_mode = key, + operator = operator, + visual_kind = visual_kind, + select_kind = select_kind, + visual = visual_kind ~= nil, + select = select_kind ~= nil, + command_path = visual_kind == nil, + }) + mode_contexts[key] = context + return context +end + +function ModeContext.is(value) + return is_record(value, "ModeContext") +end + +function ModeContext.equal(left, right) + require_record(left, "ModeContext", "left") + require_record(right, "ModeContext", "right") + return left.key == right.key +end + +function ModeContext:to_key() + return self.key +end + +local Selection = {} +M.Selection = Selection + +register_type("Selection", Selection, function(data) + return data.active and ("selection:" .. data.kind.value) or "selection:none" +end, function(left, right) + return left.active == right.active + and left.kind == right.kind + and left.anchor == right.anchor + and left.focus == right.focus + and left.option == right.option +end) + +function Selection.new(options) + if Selection.is(options) then + return options + end + if type(options) ~= "table" then + fail("selection options must be a table", 2) + end + + local active = require_boolean(options.active, "selection.active") + local kind = M.SelectionKind.from_string(options.kind) + local option = M.SelectionOption.from_string(options.option or "inclusive") + local anchor = options.anchor + local focus = options.focus + + if active then + if kind == M.SelectionKind.NONE then + fail("an active selection must have a selection kind", 2) + end + anchor = Position.coerce(anchor) + focus = Position.coerce(focus) + else + if kind ~= M.SelectionKind.NONE then + fail("an inactive selection must use the none kind", 2) + end + if anchor ~= nil or focus ~= nil then + fail("an inactive selection must have empty endpoints", 2) + end + end + + return new_record("Selection", { + active = active, + kind = kind, + anchor = anchor, + focus = focus, + option = option, + }) +end + +function Selection.inactive(option) + return Selection.new({ + active = false, + kind = M.SelectionKind.NONE, + option = option or M.SelectionOption.INCLUSIVE, + }) +end + +function Selection.active(kind, anchor, focus, option) + return Selection.new({ + active = true, + kind = kind, + anchor = anchor, + focus = focus, + option = option or M.SelectionOption.INCLUSIVE, + }) +end + +function Selection.is(value) + return is_record(value, "Selection") +end + +function Selection:with_focus(focus, kind) + if not self.active then + fail("selection must be active", 2) + end + return Selection.active(kind or self.kind, self.anchor, focus, self.option) +end + +function Selection:to_table() + return { + active = self.active, + kind = self.kind.value, + anchor = self.anchor and self.anchor:to_table() or nil, + focus = self.focus and self.focus:to_table() or nil, + option = self.option.value, + } +end + +local TextSnapshot = {} +M.TextSnapshot = TextSnapshot +local text_lines = setmetatable({}, { __mode = "k" }) + +register_type("TextSnapshot", TextSnapshot, function(data) + return "text:" .. tostring(data.line_count) .. " lines" +end, function(left, right) + if left.line_count ~= right.line_count then + return false + end + local left_lines = text_lines[left.identity] + local right_lines = text_lines[right.identity] + for index = 1, left.line_count do + if left_lines[index] ~= right_lines[index] then + return false + end + end + return true +end) + +function TextSnapshot.new(lines) + if TextSnapshot.is(lines) then + return lines + end + if type(lines) ~= "table" or #lines < 1 then + fail("text lines must be a nonempty list", 2) + end + local copy = {} + for index = 1, #lines do + if type(lines[index]) ~= "string" then + fail("each text line must be a string", 2) + end + copy[index] = lines[index] + end + local identity = {} + text_lines[identity] = copy + return new_record("TextSnapshot", { + identity = identity, + line_count = #copy, + }) +end + +function TextSnapshot.is(value) + return is_record(value, "TextSnapshot") +end + +function TextSnapshot:line(line_number) + if not is_integer(line_number) or line_number < 1 or line_number > self.line_count then + fail("line_number must identify a line in the text snapshot", 2) + end + return text_lines[self.identity][line_number] +end + +function TextSnapshot:lines() + local result = {} + local source = text_lines[self.identity] + for index = 1, self.line_count do + result[index] = source[index] + end + return result +end + +function TextSnapshot:to_table() + return { lines = self:lines() } +end + +local MacroState = {} +M.MacroState = MacroState + +register_type("MacroState", MacroState, function(data) + return data.executing and ("macro:" .. data.register) or "macro:inactive" +end, function(left, right) + return left.register == right.register +end) + +function MacroState.new(register) + if MacroState.is(register) then + return register + end + if register == "" then + register = nil + end + if register ~= nil then + require_string(register, "macro register", false) + end + return new_record("MacroState", { + register = register, + executing = register ~= nil, + }) +end + +function MacroState.is(value) + return is_record(value, "MacroState") +end + +local FoldState = {} +M.FoldState = FoldState +local fold_policies = setmetatable({}, { __mode = "k" }) + +register_type("FoldState", FoldState, function(data) + return "folds:" .. tostring(data.closed_levels) +end) + +function FoldState.new(open_policy, closed_levels) + if FoldState.is(open_policy) and closed_levels == nil then + return open_policy + end + if type(open_policy) ~= "table" then + fail("fold open policy must be a list", 2) + end + require_nonnegative_integer(closed_levels, "closed fold levels") + + local identity = {} + local policies = {} + local seen = {} + for index = 1, #open_policy do + local policy = require_string(open_policy[index], "fold policy item", false) + if not seen[policy] then + seen[policy] = true + policies[#policies + 1] = policy + end + end + fold_policies[identity] = { + list = policies, + set = seen, + } + return new_record("FoldState", { + identity = identity, + closed_levels = closed_levels, + }) +end + +function FoldState.is(value) + return is_record(value, "FoldState") +end + +function FoldState:opens(policy) + require_string(policy, "fold policy", false) + return fold_policies[self.identity].set[policy] == true +end + +function FoldState:policies() + local result = {} + local source = fold_policies[self.identity].list + for index = 1, #source do + result[index] = source[index] + end + return result +end + +local InputPacket = {} +M.InputPacket = InputPacket +local packet_bytes = setmetatable({}, { __mode = "k" }) + +M.InputPacketKind = define_enum("InputPacketKind", { + TEXT = "text", + RAW_BYTES = "raw_bytes", + SPECIAL_KEY = "special_key", + ERROR = "error", +}) + +register_type("InputPacket", InputPacket, function(data) + return "input:" .. data.kind.value +end) + +function InputPacket.text(text) + require_string(text, "input text", false) + return new_record("InputPacket", { + kind = M.InputPacketKind.TEXT, + text = text, + }) +end + +function InputPacket.raw_bytes(bytes) + if type(bytes) ~= "table" or #bytes < 1 then + fail("raw input bytes must be a nonempty list", 2) + end + local copy = {} + for index = 1, #bytes do + local byte = bytes[index] + if not is_integer(byte) or byte < 0 or byte > 255 then + fail("raw input bytes must contain byte values", 2) + end + copy[index] = byte + end + local identity = {} + packet_bytes[identity] = copy + return new_record("InputPacket", { + kind = M.InputPacketKind.RAW_BYTES, + identity = identity, + }) +end + +function InputPacket.special_key(name, encoded) + require_string(name, "special key name", false) + if encoded ~= nil then + require_string(encoded, "encoded special key", false) + end + return new_record("InputPacket", { + kind = M.InputPacketKind.SPECIAL_KEY, + name = name, + encoded = encoded, + }) +end + +function InputPacket.error(message) + require_string(message, "input error message", false) + return new_record("InputPacket", { + kind = M.InputPacketKind.ERROR, + message = message, + }) +end + +function InputPacket.from_table(packet) + if InputPacket.is(packet) then + return packet + end + if type(packet) ~= "table" then + fail("input packet must be an InputPacket or packet table", 2) + end + local kind = M.InputPacketKind.from_string(packet.kind) + if kind == M.InputPacketKind.TEXT then + return InputPacket.text(packet.text) + end + if kind == M.InputPacketKind.RAW_BYTES then + return InputPacket.raw_bytes(packet.bytes) + end + if kind == M.InputPacketKind.SPECIAL_KEY then + return InputPacket.special_key(packet.name, packet.encoded) + end + return InputPacket.error(packet.message) +end + +function InputPacket.is(value) + return is_record(value, "InputPacket") +end + +function InputPacket:bytes() + if self.kind ~= M.InputPacketKind.RAW_BYTES then + return nil + end + local source = packet_bytes[self.identity] + local result = {} + for index = 1, #source do + result[index] = source[index] + end + return result +end + +function InputPacket:to_table() + local result = { kind = self.kind.value } + if self.kind == M.InputPacketKind.TEXT then + result.text = self.text + elseif self.kind == M.InputPacketKind.RAW_BYTES then + result.bytes = self:bytes() + elseif self.kind == M.InputPacketKind.SPECIAL_KEY then + result.name = self.name + result.encoded = self.encoded + else + result.message = self.message + end + return result +end + +local TargetValue = {} +M.TargetValue = TargetValue + +register_type("TargetValue", TargetValue, function(data) + return "target:" .. data.kind.value .. ":" .. tostring(data.first_code) +end, function(left, right) + return left.kind == right.kind + and left.value == right.value + and left.first_code == right.first_code +end) + +function TargetValue.character(value, first_code) + require_string(value, "target character", false) + require_nonnegative_integer(first_code, "target first code") + return new_record("TargetValue", { + kind = M.TargetKind.CHARACTER, + value = value, + first_code = first_code, + }) +end + +function TargetValue.special_key(value, first_code) + require_string(value, "encoded special key", false) + first_code = first_code or string.byte(value, 1) + require_nonnegative_integer(first_code, "target first code") + if first_code ~= 0x80 then + fail("an encoded special key must start with hexadecimal 80", 2) + end + return new_record("TargetValue", { + kind = M.TargetKind.SPECIAL_KEY, + value = value, + first_code = first_code, + }) +end + +function TargetValue.code_fallback(first_code) + first_code = first_code or 0 + require_nonnegative_integer(first_code, "fallback character code") + return new_record("TargetValue", { + kind = M.TargetKind.CODE_FALLBACK, + value = "", + first_code = first_code, + }) +end + +function TargetValue.from_table(target) + if TargetValue.is(target) then + return target + end + if type(target) ~= "table" then + fail("target must be a TargetValue or target table", 2) + end + local kind = M.TargetKind.from_string(target.kind) + if kind == M.TargetKind.CHARACTER then + return TargetValue.character(target.value, target.first_code) + end + if kind == M.TargetKind.SPECIAL_KEY then + return TargetValue.special_key(target.value, target.first_code) + end + return TargetValue.code_fallback(target.first_code) +end + +function TargetValue.is(value) + return is_record(value, "TargetValue") +end + +function TargetValue:to_table() + return { + kind = self.kind.value, + value = self.value, + first_code = self.first_code, + } +end + +local TargetPlan = {} +M.TargetPlan = TargetPlan + +register_type("TargetPlan", TargetPlan, function(data) + return "target-plan:" .. data.kind.value +end) + +function TargetPlan.new(options) + if TargetPlan.is(options) then + return options + end + if type(options) ~= "table" then + fail("target plan options must be a table", 2) + end + local target = require_record(options.target, "TargetValue", "target plan target") + local kind = M.TargetPlanKind.from_string(options.kind) + local case_mode = M.CaseMode.from_string(options.case_mode) + if type(options.matcher) ~= "function" then + fail("target plan matcher must be a function", 2) + end + return new_record("TargetPlan", { + target = target, + kind = kind, + case_mode = case_mode, + matcher = options.matcher, + }) +end + +function TargetPlan.is(value) + return is_record(value, "TargetPlan") +end + +function TargetPlan:matches(...) + return self.matcher(...) +end + +function TargetPlan:to_table() + return { + target = self.target:to_table(), + kind = self.kind.value, + case_mode = self.case_mode.value, + } +end + +local ResolvedMotionPlan = {} +M.ResolvedMotionPlan = ResolvedMotionPlan + +register_type("ResolvedMotionPlan", ResolvedMotionPlan, function(data) + return "motion-plan:" .. data.descriptor.value +end) + +function ResolvedMotionPlan.new(options) + if ResolvedMotionPlan.is(options) then + return options + end + if type(options) ~= "table" then + fail("resolved motion plan options must be a table", 2) + end + return new_record("ResolvedMotionPlan", { + target_plan = require_record(options.target_plan, "TargetPlan", "target plan"), + descriptor = Descriptor.from_string(options.descriptor), + search_scope = M.SearchScope.from_string(options.search_scope), + endpoint_policy = M.EndpointPolicy.from_string(options.endpoint_policy), + }) +end + +function ResolvedMotionPlan.is(value) + return is_record(value, "ResolvedMotionPlan") +end + +function ResolvedMotionPlan:to_table() + return { + target_plan = self.target_plan:to_table(), + descriptor = self.descriptor.value, + search_scope = self.search_scope.value, + endpoint_policy = self.endpoint_policy.value, + } +end + +local MotionRequest = {} +M.MotionRequest = MotionRequest + +register_type("MotionRequest", MotionRequest, function(data) + return "motion-request:" .. data.descriptor.value +end) + +function MotionRequest.new(options) + if MotionRequest.is(options) then + return options + end + if type(options) ~= "table" then + fail("motion request options must be a table", 2) + end + if options.policy == nil then + fail("motion request policy is required", 2) + end + return new_record("MotionRequest", { + context = require_record(options.context, "ModeContext", "motion context"), + origin = Position.coerce(options.origin), + descriptor = Descriptor.from_string(options.descriptor), + target = require_record(options.target, "TargetValue", "motion target"), + count = Count.new(options.count), + policy = options.policy, + first_move = require_boolean(options.first_move, "first_move"), + }) +end + +function MotionRequest.is(value) + return is_record(value, "MotionRequest") +end + +local SearchOutcome = {} +M.SearchOutcome = SearchOutcome + +register_type("SearchOutcome", SearchOutcome, function(data) + return "search:" .. data.status.value +end) + +local function new_search_outcome(status, endpoint, successful_steps) + status = M.SearchStatus.from_string(status) + endpoint = Position.coerce(endpoint) + require_nonnegative_integer(successful_steps, "successful_steps") + + if status == M.SearchStatus.COMPLETE and successful_steps < 1 then + fail("a complete search must contain a successful step", 3) + end + if status == M.SearchStatus.BOUNDARY_AFTER_PARTIAL and successful_steps < 1 then + fail("a partial search must contain a successful step", 3) + end + if status == M.SearchStatus.BOUNDARY_BEFORE_ANY and successful_steps ~= 0 then + fail("a boundary-before-any search must contain zero successful steps", 3) + end + + return new_record("SearchOutcome", { + status = status, + endpoint = endpoint, + successful_steps = successful_steps, + complete = status == M.SearchStatus.COMPLETE, + }) +end + +function SearchOutcome.new(options) + if SearchOutcome.is(options) then + return options + end + if type(options) ~= "table" then + fail("search outcome options must be a table", 2) + end + return new_search_outcome(options.status, options.endpoint, options.successful_steps) +end + +function SearchOutcome.complete(endpoint, successful_steps) + return new_search_outcome(M.SearchStatus.COMPLETE, endpoint, successful_steps) +end + +function SearchOutcome.boundary_after_partial(endpoint, successful_steps) + return new_search_outcome( + M.SearchStatus.BOUNDARY_AFTER_PARTIAL, + endpoint, + successful_steps + ) +end + +function SearchOutcome.boundary_before_any(origin) + return new_search_outcome(M.SearchStatus.BOUNDARY_BEFORE_ANY, origin, 0) +end + +function SearchOutcome.is(value) + return is_record(value, "SearchOutcome") +end + +function SearchOutcome:to_table() + return { + status = self.status.value, + endpoint = self.endpoint:to_table(), + successful_steps = self.successful_steps, + complete = self.complete, + } +end + +local DotPayload = {} +M.DotPayload = DotPayload + +register_type("DotPayload", DotPayload, function(data) + return "dot:" .. data.descriptor.value +end, function(left, right) + return left.descriptor == right.descriptor and left.target == right.target +end) + +function DotPayload.new(descriptor, target) + if DotPayload.is(descriptor) and target == nil then + return descriptor + end + return new_record("DotPayload", { + descriptor = Descriptor.from_string(descriptor), + target = require_record(target, "TargetValue", "dot target"), + }) +end + +function DotPayload.is(value) + return is_record(value, "DotPayload") +end + +function DotPayload:to_table() + return { + descriptor = self.descriptor.value, + target = self.target:to_table(), + } +end + +local ActionOutcome = {} +M.ActionOutcome = ActionOutcome + +register_type("ActionOutcome", ActionOutcome, function(data) + return "action:" .. data.kind.value +end) + +local function new_action_outcome(options) + local kind = M.ActionKind.from_string(options.kind) + local position = Position.coerce(options.position) + local search_outcome = options.search_outcome + local descriptor = options.effective_descriptor + local dot_payload = options.dot_payload + + if search_outcome ~= nil then + require_record(search_outcome, "SearchOutcome", "search outcome") + end + if descriptor ~= nil then + descriptor = Descriptor.from_string(descriptor) + end + if dot_payload ~= nil then + require_record(dot_payload, "DotPayload", "dot payload") + end + if options.diagnostic ~= nil then + require_string(options.diagnostic, "diagnostic", false) + end + + if kind == M.ActionKind.MOVEMENT then + if search_outcome == nil or not search_outcome.complete then + fail("a movement action requires a complete search outcome", 3) + end + elseif kind == M.ActionKind.FAILED_SEARCH then + if search_outcome == nil or search_outcome.complete then + fail("a failed-search action requires an incomplete search outcome", 3) + end + elseif search_outcome ~= nil then + fail("only movement and failed-search actions can contain a search outcome", 3) + end + + if kind == M.ActionKind.ERROR and options.diagnostic == nil then + fail("an error action requires a diagnostic", 3) + end + + local complete + if search_outcome ~= nil then + complete = search_outcome.complete + end + + return new_record("ActionOutcome", { + kind = kind, + position = position, + search_outcome = search_outcome, + complete = complete, + successful_steps = search_outcome and search_outcome.successful_steps or 0, + effective_descriptor = descriptor, + dot_payload = dot_payload, + diagnostic = options.diagnostic, + }) +end + +function ActionOutcome.new(options) + if ActionOutcome.is(options) then + return options + end + if type(options) ~= "table" then + fail("action outcome options must be a table", 2) + end + return new_action_outcome(options) +end + +function ActionOutcome.from_search(search_outcome, descriptor, dot_payload) + require_record(search_outcome, "SearchOutcome", "search outcome") + return new_action_outcome({ + kind = search_outcome.complete and M.ActionKind.MOVEMENT or M.ActionKind.FAILED_SEARCH, + position = search_outcome.endpoint, + search_outcome = search_outcome, + effective_descriptor = descriptor, + dot_payload = dot_payload, + }) +end + +local function simple_action(kind, position, diagnostic) + return new_action_outcome({ + kind = kind, + position = position, + diagnostic = diagnostic, + }) +end + +function ActionOutcome.neutral(position) + return simple_action(M.ActionKind.NEUTRAL, position) +end + +function ActionOutcome.escape(position) + return simple_action(M.ActionKind.ESCAPE, position) +end + +function ActionOutcome.empty(position) + return simple_action(M.ActionKind.EMPTY, position) +end + +function ActionOutcome.error(position, diagnostic) + return simple_action(M.ActionKind.ERROR, position, diagnostic) +end + +function ActionOutcome.is(value) + return is_record(value, "ActionOutcome") +end + +function ActionOutcome:to_table() + return { + kind = self.kind.value, + position = self.position:to_table(), + complete = self.complete, + successful_steps = self.successful_steps, + effective_descriptor = self.effective_descriptor and self.effective_descriptor.value or nil, + dot_payload = self.dot_payload and self.dot_payload:to_table() or nil, + diagnostic = self.diagnostic, + } +end + +return M |
