summaryrefslogtreecommitdiff
path: root/lua/clever_tee/direct_preview_planner.lua
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
commitc7b14ffb6d14969c9c41864e827f33f8e80fc24e (patch)
treec7c0ac23dc93a4a8b75449d2be4d0e05c4f05f9e /lua/clever_tee/direct_preview_planner.lua
parent9013636a57144e1f57c9339e7888d588430aae6a (diff)
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/direct_preview_planner.lua')
-rw-r--r--lua/clever_tee/direct_preview_planner.lua250
1 files changed, 250 insertions, 0 deletions
diff --git a/lua/clever_tee/direct_preview_planner.lua b/lua/clever_tee/direct_preview_planner.lua
new file mode 100644
index 0000000..699fcf4
--- /dev/null
+++ b/lua/clever_tee/direct_preview_planner.lua
@@ -0,0 +1,250 @@
+local case_policy = require("clever_tee.case_policy")
+local domain = require("clever_tee.domain")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local DirectPreviewPlanner = {}
+DirectPreviewPlanner.__index = DirectPreviewPlanner
+M.DirectPreviewPlanner = DirectPreviewPlanner
+
+local planner_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function require_view(view)
+ if not text_topology.TextView.is(view) then
+ fail("direct preview text must be a TextView", 3)
+ end
+ return view
+end
+
+local function require_case_resolver(options)
+ options = options or {}
+ if type(options) == "function" then
+ options = { lowercase = options }
+ end
+ if type(options) ~= "table" then
+ fail("DirectPreviewPlanner options must be a table", 3)
+ end
+ local resolver = options.case_resolver
+ if resolver == nil then
+ resolver = case_policy.new({ lowercase = options.lowercase })
+ end
+ if type(resolver) ~= "table" or type(resolver.lowercase) ~= "function" then
+ fail("DirectPreviewPlanner case resolver must provide lowercase", 3)
+ end
+ return resolver
+end
+
+function DirectPreviewPlanner.new(options)
+ if DirectPreviewPlanner.is(options) then
+ return options
+ end
+ local planner = setmetatable({}, DirectPreviewPlanner)
+ planner_records[planner] = {
+ case_resolver = require_case_resolver(options),
+ }
+ return planner
+end
+
+function DirectPreviewPlanner.is(value)
+ return type(value) == "table" and planner_records[value] ~= nil
+end
+
+function DirectPreviewPlanner:scan_current_line(view, origin, direction)
+ require_view(view)
+ origin = domain.Position.coerce(origin)
+ direction = domain.Direction.from_string(direction)
+ local records = {}
+ for position, character, span in view:iter_strict(
+ origin,
+ direction,
+ domain.SearchScope.CURRENT_LINE
+ ) do
+ records[#records + 1] = {
+ position = position,
+ character = character,
+ span = span,
+ }
+ end
+ return records
+end
+
+function M.marker_position(descriptor, target_position)
+ domain.Descriptor.from_string(descriptor)
+ return domain.Position.coerce(target_position)
+end
+
+function M.normalize_count(count)
+ return domain.Count.to_number(count)
+end
+
+function DirectPreviewPlanner:normalize_count(count)
+ return M.normalize_count(count)
+end
+
+function M.direction_for_descriptor(descriptor)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ if domain.Descriptor.is_uppercase(descriptor) then
+ return domain.Direction.BACKWARD
+ end
+ return domain.Direction.FORWARD
+end
+
+function DirectPreviewPlanner:scan_for_descriptor(view, origin, descriptor)
+ return self:scan_current_line(
+ view,
+ origin,
+ M.direction_for_descriptor(descriptor)
+ )
+end
+
+local function increment_counter(counters, character)
+ local value = (counters[character] or 0) + 1
+ counters[character] = value
+ return value
+end
+
+local function is_upper_ascii(character)
+ if type(character) ~= "string" or #character ~= 1 then
+ return false
+ end
+ local code = character:byte(1)
+ return code >= string.byte("A") and code <= string.byte("Z")
+end
+
+local function increment_smart_counters(counters, character)
+ local exact_count = increment_counter(counters, character)
+ local lowercase_count
+ if is_upper_ascii(character) then
+ local lowercase = string.char(character:byte(1) + 32)
+ lowercase_count = increment_counter(counters, lowercase)
+ end
+ return exact_count, lowercase_count
+end
+
+M.is_upper_ascii = is_upper_ascii
+
+local function require_case_setting(settings, name)
+ local value = settings[name]
+ if value == nil then
+ return false
+ end
+ if type(value) ~= "boolean" then
+ fail("direct preview " .. name .. " must be a Boolean", 3)
+ end
+ return value
+end
+
+function M.case_grouping_settings(settings)
+ settings = settings or {}
+ if type(settings) ~= "table" then
+ fail("direct preview case settings must be a table", 2)
+ end
+ return {
+ ignore_case = require_case_setting(settings, "ignore_case"),
+ smart_case = require_case_setting(settings, "smart_case"),
+ }
+end
+
+function M.validate_marker_positions(view, positions)
+ require_view(view)
+ if type(positions) ~= "table" then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+
+ local result = {}
+ local seen = {}
+ local item_count = 0
+ for key, position in pairs(positions) do
+ if type(key) ~= "number"
+ or key ~= math.floor(key)
+ or key < 1
+ or key > #positions
+ then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+ position = domain.Position.coerce(position)
+ if not view:is_character_start(position) then
+ fail("direct preview marker must start an editor character", 2)
+ end
+ local identity = tostring(position.line) .. ":" .. tostring(position.byte_column)
+ if seen[identity] then
+ fail("direct preview marker positions must be unique", 2)
+ end
+ seen[identity] = true
+ result[key] = position
+ item_count = item_count + 1
+ end
+ if item_count ~= #positions then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+ return result
+end
+
+function DirectPreviewPlanner:plan(view, origin, descriptor, count, settings)
+ local grouping = M.case_grouping_settings(settings)
+ local ignore_case = grouping.ignore_case
+ local smart_case = grouping.smart_case
+ local selected_occurrence = self:normalize_count(count)
+ local counters = {}
+ local positions = {}
+ local resolver = planner_records[self].case_resolver
+ for _, record in ipairs(self:scan_for_descriptor(view, origin, descriptor)) do
+ local selected
+ if ignore_case then
+ selected = increment_counter(
+ counters,
+ resolver:lowercase(record.character)
+ ) == selected_occurrence
+ elseif smart_case then
+ local exact_count, lowercase_count = increment_smart_counters(
+ counters,
+ record.character
+ )
+ selected = exact_count == selected_occurrence
+ or lowercase_count == selected_occurrence
+ else
+ selected = increment_counter(
+ counters,
+ record.character
+ ) == selected_occurrence
+ end
+ if selected then
+ positions[#positions + 1] = M.marker_position(descriptor, record.position)
+ end
+ end
+ return M.validate_marker_positions(view, positions)
+end
+
+function M.new(options)
+ return DirectPreviewPlanner.new(options)
+end
+
+function M.scan_current_line(view, origin, direction)
+ return DirectPreviewPlanner.new():scan_current_line(view, origin, direction)
+end
+
+function M.scan_for_descriptor(view, origin, descriptor)
+ return DirectPreviewPlanner.new():scan_for_descriptor(view, origin, descriptor)
+end
+
+function M.plan(view, origin, descriptor, count, settings, options)
+ return DirectPreviewPlanner.new(options):plan(
+ view,
+ origin,
+ descriptor,
+ count,
+ settings
+ )
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return DirectPreviewPlanner.new(options)
+ end,
+})
+
+return M