From 80f3025ed00f8c35ae0c83f4ed754a45d108ea1b Mon Sep 17 00:00:00 2001 From: Jackson Moore Date: Fri, 4 Sep 2026 14:29:26 +0200 Subject: Connect encoding and case conversion --- lua/clever_f/capabilities.lua | 1 + lua/clever_f/host_adapter.lua | 19 ++++++++++++ lua/clever_f/sequence_coordinator.lua | 19 +++++++++++- lua/clever_f/testing/memory_host.lua | 13 ++++++++ tests/run.lua | 56 +++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/lua/clever_f/capabilities.lua b/lua/clever_f/capabilities.lua index ff92f8f..325d015 100644 --- a/lua/clever_f/capabilities.lua +++ b/lua/clever_f/capabilities.lua @@ -9,6 +9,7 @@ M.read_methods = { count = { "read_count" }, configuration = { "read_configuration", "configuration_present" }, encoding = { "read_encoding" }, + case_conversion = { "lowercase" }, macro_state = { "read_macro_state" }, fold_state = { "read_fold_state" }, time = { "read_time_ms" }, diff --git a/lua/clever_f/host_adapter.lua b/lua/clever_f/host_adapter.lua index 51591a9..7a71fb8 100644 --- a/lua/clever_f/host_adapter.lua +++ b/lua/clever_f/host_adapter.lua @@ -65,6 +65,25 @@ function HostAdapter:runtime() return adapter_records[self].runtime end +function HostAdapter:read_encoding() + local runtime = self:runtime() + return runtime.api.nvim_get_option_value( + "encoding", + { scope = "global" } + ) +end + +function HostAdapter:lowercase(value) + if type(value) ~= "string" then + fail("case conversion value must be a string", 2) + end + local result = self:runtime().fn.tolower(value) + if type(result) ~= "string" then + fail("Nvim case conversion must return a string", 2) + end + return result +end + local function next_identity(adapter, prefix) local record = adapter_records[adapter] local identity = prefix .. "-" .. tostring(record.next_identity) diff --git a/lua/clever_f/sequence_coordinator.lua b/lua/clever_f/sequence_coordinator.lua index 0f233cf..6d2d31b 100644 --- a/lua/clever_f/sequence_coordinator.lua +++ b/lua/clever_f/sequence_coordinator.lua @@ -1,4 +1,6 @@ local acquisition_service_factory = require("clever_f.acquisition_service") +local case_policy = require("clever_f.case_policy") +local direct_preview_planner = require("clever_f.direct_preview_planner") local domain = require("clever_f.domain") local feedback_service_factory = require("clever_f.feedback_service") local motion_executor_factory = require("clever_f.motion_executor") @@ -73,9 +75,20 @@ function SequenceCoordinator.new(options) transitions = transitions, policy = policy_service, }) + local lowercase = options.lowercase + if lowercase == nil and type(host.lowercase) == "function" then + lowercase = function(value) + return host:lowercase(value) + end + end + local case_resolver = options.case_resolver + or case_policy.new({ lowercase = lowercase }) local target_factory = options.target_factory or options.target_plan_factory - or target_plan_factory.new({ policy = policy_service }) + or target_plan_factory.new({ + policy = policy_service, + case_resolver = case_resolver, + }) if type(target_factory) ~= "table" or type(target_factory.build) ~= "function" then fail("SequenceCoordinator target factory must provide build", 2) end @@ -87,6 +100,9 @@ function SequenceCoordinator.new(options) then fail("SequenceCoordinator motion factory must build contextual plans", 2) end + local direct_planner = options.direct_planner + or options.direct_preview_planner + or direct_preview_planner.new({ case_resolver = case_resolver }) local acquisition = options.acquisition or options.acquisition_service or acquisition_service_factory.new({ @@ -95,6 +111,7 @@ function SequenceCoordinator.new(options) transitions = transitions, policy = policy_service, feedback = feedback, + direct_planner = direct_planner, target_factory = target_factory, motion_factory = motion_factory, }) diff --git a/lua/clever_f/testing/memory_host.lua b/lua/clever_f/testing/memory_host.lua index 4c95721..6147d2c 100644 --- a/lua/clever_f/testing/memory_host.lua +++ b/lua/clever_f/testing/memory_host.lua @@ -146,6 +146,7 @@ function MemoryHost.new(options) _count = domain.Count.new(options.count), _configuration = copy(options.configuration or {}), _encoding = options.encoding or options.effective_encoding or "utf-8", + _lowercase = options.lowercase or vim.fn.tolower, _macro_state = macro_state(options.macro_state or options.macro_register), _fold_state = fold_state(options), _pending_operator = options.pending_operator, @@ -295,6 +296,18 @@ function MemoryHost:read_encoding() return self._encoding end +function MemoryHost:lowercase(value) + if type(value) ~= "string" then + error("case conversion value must be a string", 2) + end + local result = self._lowercase(value) + if type(result) ~= "string" then + error("case converter must return a string", 2) + end + self:_record("lowercase", { value = value, result = result }) + return result +end + function MemoryHost:read_macro_state() self:_record("read_macro_state", { executing = self._macro_state.executing }) return self._macro_state diff --git a/tests/run.lua b/tests/run.lua index 5ca4f34..871d977 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -361,6 +361,30 @@ test("ActionOutcome and DotPayload retain resolved result data", function() same("problem", domain.ActionOutcome.error(destination, "problem").diagnostic) end) +test("Host adapter reads encoding and uses Nvim case conversion", function() + local lowered = {} + local runtime = { + api = { + nvim_get_option_value = function(name, options) + same("encoding", name) + same("global", options.scope) + return "utf-8" + end, + }, + fn = { + tolower = function(value) + lowered[#lowered + 1] = value + return string.lower(value) + end, + }, + } + local adapter = host_adapter.new({ runtime = runtime }) + + same("utf-8", adapter:read_encoding()) + same("abc", adapter:lowercase("AbC")) + list_same({ "AbC" }, lowered) +end) + test("Host adapter restores exact cursor presentation values", function() local options = { guicursor = "n:block,i:ver37-blinkon123", @@ -7382,6 +7406,38 @@ test("Eager cleanup preserves history and finalizer registrations", function() same(nil, state.highlight_timer) end) +test("Core composition injects host case conversion into matching", function() + fresh_sequence_state() + local conversions = {} + local host = MemoryHost.new({ + buffer_lines = { "xA" }, + cursor = { line = 1, byte_column = 1 }, + lowercase = function(value) + conversions[#conversions + 1] = value + if value == "a" or value == "A" then + return "folded-a" + end + return value + end, + input_packets = { { kind = "text", text = "a" } }, + configuration = { + ignore_case = true, + mark_cursor = false, + mark_char = false, + }, + emit_movement_events = false, + }) + + local outcome = composition_root.new({ host = host }) + :invoke_descriptor("f") + + same(domain.ActionKind.MOVEMENT, outcome.kind) + same(domain.Position.new(1, 2), outcome.position) + truthy(#conversions >= 2) + same("a", conversions[1]) + same("A", conversions[#conversions]) +end) + test("Core activation owns the plugin-global sequence state", function() local state = fresh_sequence_state() local host = MemoryHost.new() -- cgit v1.2.3