summaryrefslogtreecommitdiff
path: root/lua
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-03 21:48:30 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-03 21:48:30 +0200
commit163a72c31512cd95725859c332ed65f7d3558c54 (patch)
tree1a12ecda7fbf4282e56311d0b11cae6ba9149558 /lua
parent66d5e304d5c543a264bffac6d72655c46a6155e1 (diff)
Implement configuration policy sampling
Diffstat (limited to 'lua')
-rw-r--r--lua/clever_f/capabilities.lua1
-rw-r--r--lua/clever_f/policy.lua494
-rw-r--r--lua/clever_f/testing/memory_host.lua8
3 files changed, 503 insertions, 0 deletions
diff --git a/lua/clever_f/capabilities.lua b/lua/clever_f/capabilities.lua
index 77ce130..23da397 100644
--- a/lua/clever_f/capabilities.lua
+++ b/lua/clever_f/capabilities.lua
@@ -15,6 +15,7 @@ M.read_methods = {
M.effect_methods = {
movement = { "apply_cursor", "apply_selection", "set_operator_inclusive" },
+ configuration = { "write_configuration" },
input = { "read_input" },
folds = { "open_fold" },
prompt = { "show_prompt" },
diff --git a/lua/clever_f/policy.lua b/lua/clever_f/policy.lua
new file mode 100644
index 0000000..b56487f
--- /dev/null
+++ b/lua/clever_f/policy.lua
@@ -0,0 +1,494 @@
+local domain = require("clever_f.domain")
+
+local M = {}
+local PolicyService = {}
+PolicyService.__index = PolicyService
+M.PolicyService = PolicyService
+
+M.ValueType = {
+ BOOLEAN = "boolean",
+ STRING = "string",
+ STRING_LIST = "string_list",
+ OPTIONAL_GROUP_NAME = "optional_group_name",
+ NONNEGATIVE_INTEGER = "nonnegative_integer",
+ PRESENCE = "presence",
+}
+
+M.Sampling = {
+ LIVE = "live",
+ ACTIVATION = "activation",
+ LINK_EVALUATION = "link_evaluation",
+}
+
+M.DEFAULT_MAP_SUPPRESSION_SENTINEL = "suppress_default_mappings"
+
+local NO_VALUE = {}
+local SCHEMA = {
+ search_current_line_only = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ ignore_case = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ smart_case = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ use_migemo = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ fix_key_direction = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ show_prompt = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ chars_match_any_signs = {
+ value_type = M.ValueType.STRING,
+ default = "",
+ sampling = M.Sampling.LIVE,
+ },
+ mark_cursor = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_cursor_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "Cursor",
+ feature_setting = "mark_cursor",
+ highlight_group = "CleverFCursor",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ hide_cursor_on_cmdline = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ repeat_timeout_ms = {
+ value_type = M.ValueType.NONNEGATIVE_INTEGER,
+ default = 0,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_char = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_char_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "CleverFDefaultLabel",
+ feature_setting = "mark_char",
+ highlight_group = "CleverFChar",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ highlight_timeout_ms = {
+ value_type = M.ValueType.NONNEGATIVE_INTEGER,
+ default = 0,
+ sampling = M.Sampling.LIVE,
+ },
+ repeat_last_char_inputs = {
+ value_type = M.ValueType.STRING_LIST,
+ default = { "\r" },
+ sampling = M.Sampling.LIVE,
+ },
+ mark_direct = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_direct_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "CleverFDefaultLabel",
+ feature_setting = "mark_direct",
+ highlight_group = "CleverFDirect",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ clean_labels_eagerly = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.ACTIVATION,
+ },
+ [M.DEFAULT_MAP_SUPPRESSION_SENTINEL] = {
+ value_type = M.ValueType.PRESENCE,
+ default = false,
+ sampling = M.Sampling.ACTIVATION,
+ },
+}
+
+local COLOR_SETTINGS = {
+ "mark_cursor_color",
+ "mark_char_color",
+ "mark_direct_color",
+}
+
+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 copy_list(values)
+ local result = {}
+ for index = 1, #values do
+ result[index] = values[index]
+ end
+ return result
+end
+
+local function copy_table(value)
+ local result = {}
+ for key, item in pairs(value) do
+ if type(item) == "table" and domain.type_of(item) == nil then
+ result[key] = copy_table(item)
+ else
+ result[key] = item
+ end
+ end
+ return result
+end
+
+local function schema_entry(name)
+ local entry = SCHEMA[name]
+ if entry == nil then
+ fail("unknown policy setting '" .. tostring(name) .. "'", 2)
+ end
+ return entry
+end
+
+local function default_value(entry)
+ if entry.default == NO_VALUE then
+ return nil
+ end
+ if type(entry.default) == "table" then
+ return copy_table(entry.default)
+ end
+ return entry.default
+end
+
+local function validate_boolean(value, name)
+ if type(value) ~= "boolean" then
+ fail("policy setting '" .. name .. "' must be a Boolean", 3)
+ end
+ return value
+end
+
+local function validate_string(value, name)
+ if type(value) ~= "string" then
+ fail("policy setting '" .. name .. "' must be a string", 3)
+ end
+ return value
+end
+
+local function validate_string_list(value, name)
+ if type(value) ~= "table" then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+
+ local length = #value
+ local item_count = 0
+ for key, item in pairs(value) do
+ if not is_integer(key) or key < 1 or key > length then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ if type(item) ~= "string" then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ item_count = item_count + 1
+ end
+ if item_count ~= length then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ return copy_list(value)
+end
+
+local function validate_optional_group_name(value, name)
+ if value ~= nil and (type(value) ~= "string" or value == "") then
+ fail("policy setting '" .. name .. "' must be an optional group name", 3)
+ end
+ return value
+end
+
+local function validate_nonnegative_integer(value, name)
+ if not is_integer(value) or value < 0 then
+ fail("policy setting '" .. name .. "' must be a nonnegative integer", 3)
+ end
+ return value
+end
+
+local VALIDATORS = {
+ [M.ValueType.BOOLEAN] = validate_boolean,
+ [M.ValueType.STRING] = validate_string,
+ [M.ValueType.STRING_LIST] = validate_string_list,
+ [M.ValueType.OPTIONAL_GROUP_NAME] = validate_optional_group_name,
+ [M.ValueType.NONNEGATIVE_INTEGER] = validate_nonnegative_integer,
+}
+
+local function require_provider(provider)
+ if type(provider) ~= "table" then
+ fail("policy configuration provider must be a table", 2)
+ end
+ local required = {
+ "configuration_present",
+ "read_configuration",
+ "write_configuration",
+ }
+ for _, method_name in ipairs(required) do
+ if type(provider[method_name]) ~= "function" then
+ fail("policy configuration provider is missing " .. method_name, 2)
+ end
+ end
+ return provider
+end
+
+function M.setting_names()
+ local names = {}
+ for name in pairs(SCHEMA) do
+ names[#names + 1] = name
+ end
+ table.sort(names)
+ return names
+end
+
+function M.schema()
+ local result = {}
+ for name, entry in pairs(SCHEMA) do
+ local public_entry = {
+ value_type = entry.value_type,
+ sampling = entry.sampling,
+ has_default = true,
+ }
+ local value = default_value(entry)
+ if value ~= nil then
+ public_entry.default = value
+ end
+ if entry.default_target ~= nil then
+ public_entry.default_target = entry.default_target
+ public_entry.feature_setting = entry.feature_setting
+ public_entry.highlight_group = entry.highlight_group
+ end
+ result[name] = public_entry
+ end
+ return result
+end
+
+function M.default(name)
+ return default_value(schema_entry(name))
+end
+
+function M.defaults()
+ local result = {}
+ for name, entry in pairs(SCHEMA) do
+ local value = default_value(entry)
+ if value ~= nil then
+ result[name] = value
+ end
+ end
+ return result
+end
+
+function PolicyService.new(provider)
+ return setmetatable({
+ _provider = require_provider(provider),
+ _activation = nil,
+ }, PolicyService)
+end
+
+function M.new(provider)
+ return PolicyService.new(provider)
+end
+
+setmetatable(M, {
+ __call = function(_, provider)
+ return PolicyService.new(provider)
+ end,
+})
+
+function PolicyService:get(name)
+ local entry = schema_entry(name)
+ if entry.value_type == M.ValueType.PRESENCE then
+ return self._provider:configuration_present(name)
+ end
+
+ local value
+ if self._provider:configuration_present(name) then
+ value = self._provider:read_configuration(name)
+ else
+ value = default_value(entry)
+ end
+ return VALIDATORS[entry.value_type](value, name)
+end
+
+function PolicyService:_get_typed(name, expected_type)
+ local entry = schema_entry(name)
+ if entry.value_type ~= expected_type then
+ fail(
+ "policy setting '" .. name .. "' does not have type " .. expected_type,
+ 2
+ )
+ end
+ return self:get(name)
+end
+
+function PolicyService:get_boolean(name)
+ return self:_get_typed(name, M.ValueType.BOOLEAN)
+end
+
+function PolicyService:get_string(name)
+ return self:_get_typed(name, M.ValueType.STRING)
+end
+
+function PolicyService:get_string_list(name)
+ return self:_get_typed(name, M.ValueType.STRING_LIST)
+end
+
+function PolicyService:get_optional_group_name(name)
+ return self:_get_typed(name, M.ValueType.OPTIONAL_GROUP_NAME)
+end
+
+function PolicyService:get_nonnegative_integer(name)
+ return self:_get_typed(name, M.ValueType.NONNEGATIVE_INTEGER)
+end
+
+function PolicyService:get_presence(name)
+ return self:_get_typed(name, M.ValueType.PRESENCE)
+end
+
+function PolicyService:default_maps_suppressed()
+ return self:get_presence(M.DEFAULT_MAP_SUPPRESSION_SENTINEL)
+end
+
+function PolicyService:capture_activation()
+ if self._activation == nil then
+ self._activation = {
+ install_default_mappings = not self:default_maps_suppressed(),
+ clean_labels_eagerly = self:get_boolean("clean_labels_eagerly"),
+ }
+ end
+ return copy_table(self._activation)
+end
+
+function PolicyService:evaluate_highlight_links()
+ local result = {}
+ for _, color_setting in ipairs(COLOR_SETTINGS) do
+ local entry = SCHEMA[color_setting]
+ local configured_target = self:get_optional_group_name(color_setting)
+ result[entry.highlight_group] = {
+ enabled = self:get_boolean(entry.feature_setting),
+ feature_setting = entry.feature_setting,
+ color_setting = color_setting,
+ configured_target = configured_target,
+ target = configured_target or entry.default_target,
+ }
+ end
+ return result
+end
+
+local function require_target(target)
+ if not domain.TargetValue.is(target) then
+ fail("case policy target must be a TargetValue", 2)
+ end
+ return target
+end
+
+function M.resolve_case_mode(target, ignore_case, smart_case)
+ target = require_target(target)
+ validate_boolean(ignore_case, "ignore_case")
+ validate_boolean(smart_case, "smart_case")
+
+ if ignore_case or (smart_case and target.value:match("^[a-z]$") ~= nil) then
+ return domain.CaseMode.INSENSITIVE
+ end
+ return domain.CaseMode.SENSITIVE
+end
+
+function PolicyService:case_mode(target)
+ return M.resolve_case_mode(
+ target,
+ self:get_boolean("ignore_case"),
+ self:get_boolean("smart_case")
+ )
+end
+
+function PolicyService:sample_search()
+ local current_line_only = self:get_boolean("search_current_line_only")
+ return {
+ search_current_line_only = current_line_only,
+ search_scope = current_line_only
+ and domain.SearchScope.CURRENT_LINE
+ or domain.SearchScope.BUFFER,
+ }
+end
+
+function PolicyService:sample_match(target)
+ local ignore_case = self:get_boolean("ignore_case")
+ local smart_case = self:get_boolean("smart_case")
+ return {
+ ignore_case = ignore_case,
+ smart_case = smart_case,
+ use_migemo = self:get_boolean("use_migemo"),
+ chars_match_any_signs = self:get_string("chars_match_any_signs"),
+ case_mode = M.resolve_case_mode(target, ignore_case, smart_case),
+ }
+end
+
+function PolicyService:sample_direction()
+ return {
+ fix_key_direction = self:get_boolean("fix_key_direction"),
+ }
+end
+
+function PolicyService:sample_acquisition()
+ return {
+ show_prompt = self:get_boolean("show_prompt"),
+ mark_cursor = self:get_boolean("mark_cursor"),
+ hide_cursor_on_cmdline = self:get_boolean("hide_cursor_on_cmdline"),
+ mark_direct = self:get_boolean("mark_direct"),
+ }
+end
+
+function PolicyService:sample_markers()
+ return {
+ mark_cursor = self:get_boolean("mark_cursor"),
+ mark_char = self:get_boolean("mark_char"),
+ mark_direct = self:get_boolean("mark_direct"),
+ }
+end
+
+function PolicyService:sample_timeouts()
+ return {
+ repeat_timeout_ms = self:get_nonnegative_integer("repeat_timeout_ms"),
+ highlight_timeout_ms = self:get_nonnegative_integer("highlight_timeout_ms"),
+ }
+end
+
+function PolicyService:sample_previous_input()
+ return {
+ repeat_last_char_inputs = self:get_string_list("repeat_last_char_inputs"),
+ }
+end
+
+function PolicyService:disable_migemo_for_unsupported_encoding()
+ self._provider:write_configuration("use_migemo", false)
+end
+
+return M
diff --git a/lua/clever_f/testing/memory_host.lua b/lua/clever_f/testing/memory_host.lua
index 726d244..57af1f1 100644
--- a/lua/clever_f/testing/memory_host.lua
+++ b/lua/clever_f/testing/memory_host.lua
@@ -268,6 +268,14 @@ function MemoryHost:read_configuration(name)
return value
end
+function MemoryHost:write_configuration(name, value)
+ if type(name) ~= "string" or name == "" then
+ error("configuration name must be a nonempty string", 2)
+ end
+ self._configuration[name] = copy(value)
+ self:_record("write_configuration", { name = name, value = value })
+end
+
function MemoryHost:read_encoding()
self:_record("read_encoding", { encoding = self._encoding })
return self._encoding