summaryrefslogtreecommitdiff
path: root/lua/clever_f/direct_preview_planner.lua
blob: 6735924c42d165ae4abb3c91b6678c9904e72415 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
local case_policy = require("clever_f.case_policy")
local domain = require("clever_f.domain")
local text_topology = require("clever_f.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