aboutsummaryrefslogtreecommitdiff
path: root/plugins/regexreplacepreview.lua
blob: 7675d54fe435051d29a4fb9f4857ed026171ce2e (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
-- mod-version:3
local core = require "core"
local keymap = require "core.keymap"
local command = require "core.command"

-- Compatibility with latest lite-xl regex changes.
local regex_match = regex.find_offsets or regex.match

-- Will iterate back through any UTF-8 bytes so that we don't replace bits
-- mid character.
local function previous_character(str, index)
  local byte
  repeat
    index = index - 1
    byte = string.byte(str, index)
  until byte < 128 or byte >= 192
  return index
end

-- Moves to the end of the identified character.
local function end_character(str, index)
  local byte = string.byte(str, index + 1)
  while byte and byte >= 128 and byte < 192 do
    index = index + 1
    byte = string.byte(str, index + 1)
  end
  return index
end

-- Build off matching. For now, only support basic replacements, but capture
-- groupings should be doable. We can even have custom group replacements and
-- transformations and stuff in lua. Currently, this takes group replacements
-- as \1 - \9.
-- Should work on UTF-8 text.
local function substitute(pattern_string, str, replacement)
  local pattern = type(pattern_string) == "table" and
    pattern_string or regex.compile(pattern_string)
  local result, indices = {}
  local matches, replacements = {}, {}
  local offset = 0
  repeat
    indices = { regex.cmatch(pattern, str, offset) }
    if #indices > 0 then
      table.insert(matches, indices)
      local currentReplacement = replacement
      if #indices > 2 then
        for i = 1, (#indices/2 - 1) do
          currentReplacement = string.gsub(
            currentReplacement,
            "\\" .. i,
            str:sub(indices[i*2+1], end_character(str,indices[i*2+2]-1))
          )
        end
      end
      currentReplacement = string.gsub(currentReplacement, "\\%d", "")
      table.insert(replacements, { indices[1], #currentReplacement+indices[1] })
      if indices[1] > 1 then
        table.insert(result, str:sub(offset, previous_character(str, indices[1])) .. currentReplacement)
      else
        table.insert(result, currentReplacement)
      end
      offset = indices[2]
    end
  until #indices == 0 or indices[1] == indices[2]
  return table.concat(result) .. str:sub(offset), matches, replacements
end

-- Workaround for bug in Lite XL 2.1
-- Remove this when b029f5993edb7dee5ccd2ba55faac1ec22e24609 is in a release
local function get_selection(doc, sort)
  local line1, col1, line2, col2 = doc:get_selection_idx(doc.last_selection)
  if line1 then
    return doc:get_selection_idx(doc.last_selection, sort)
  else
    return doc:get_selection_idx(1, sort)
  end
end


-- Takes the following pattern: /pattern/replace/
-- Capture groupings can be replaced using \1 through \9
local function regex_replace_file(view, pattern, old_lines, raw, start_line, end_line)
  local doc = view.doc
  local start_pattern, end_pattern, end_replacement, start_replacement = 2, 2;
  repeat
    end_pattern = string.find(pattern, "/", end_pattern)
  until end_pattern == nil or pattern[end_pattern-1] ~= "\\"
  if end_pattern == nil then
    end_pattern = #pattern + 1
  else
    end_pattern = end_pattern - 1
    start_replacement = end_pattern+2;
    end_replacement = end_pattern+2;
    repeat
      end_replacement = string.find(pattern, "/", end_replacement)
    until end_replacement == nil or pattern[end_replacement-1] ~= "\\"
  end
  end_replacement = end_replacement and (end_replacement - 1)

  local re = start_pattern ~= end_pattern
    and regex.compile(pattern:sub(start_pattern, end_pattern))

  local replacement = end_replacement and pattern:sub(
    start_replacement, end_replacement
  )
  local replace_line = raw and function(line, new_text)
    if line == #doc.lines then
      doc:raw_remove(line, 1, line, #doc.lines[line], { idx = 1 }, 0)
    else
      doc:raw_remove(line, 1, line+1, 1, { idx = 1 }, 0)
    end
    doc:raw_insert(line, 1, new_text, { idx = 1 }, 0)
  end or function(line, new_text)
    if line == #doc.lines then
      doc:remove(line, 1, line, #doc.lines[line])
    else
      doc:remove(line, 1, line+1, 1)
    end
    doc:insert(line, 1, new_text)
  end

  local line_scroll = nil
  if re then
    for i = (start_line or 1), (end_line or #doc.lines) do
      local new_text, matches, rmatches
      local old_text = old_lines[i] or doc.lines[i]
      local old_length = #old_text
      if replacement then
        new_text, matches, rmatches = substitute(re, old_text, replacement)
      end
      if matches and #matches > 0 then
        old_lines[i] = old_text
        replace_line(i, new_text)
        if line_scroll == nil then
          line_scroll = i
          doc:set_selection(i, rmatches[1][1], i, rmatches[1][2])
        end
      elseif old_lines[i] then
        replace_line(i, old_lines[i])
        old_lines[i] = nil
      end
      if not replacement then
        local s,e = regex_match(re, old_text)
        if s then
          line_scroll = i
          doc:set_selection(i, s, i, e)
          break
        end
      end
    end
    if line_scroll then
      view:scroll_to_line(line_scroll, true)
    end
  end
  if replacement == nil then
    for k,v in pairs(old_lines) do
      replace_line(k, v)
    end
    old_lines = {}
  end
  return old_lines, line_scroll ~= nil
end

command.add("core.docview!", {
  ["regex-replace-preview:find-replace-regex"] = function(view)
    local old_lines = {}
    local doc = view.doc
    local original_selection = { get_selection(doc, true) }
    local selection = doc:has_selection() and { get_selection(doc, true) } or {}
    core.command_view:enter("Regex Replace (enter pattern as /old/new/)", {
      text = "/",
      submit = function(pattern)
        regex_replace_file(view, pattern, {}, false, selection[1], selection[3])
      end,
      suggest = function(pattern)
        local incremental, has_replacement = regex_replace_file(
          view, pattern, old_lines, true, selection[1], selection[3]
        )
        if incremental then
          old_lines = incremental
        end
        if not has_replacement then
          doc:set_selection(table.unpack(original_selection))
        end
      end,
      cancel = function(pattern)
        for k,v in pairs(old_lines) do
          if v then
            if k == #doc.lines then
              doc:raw_remove(k, 1, k, #doc.lines[k], { idx = 1 }, 0)
            else
              doc:raw_remove(k, 1, k+1, 1, { idx = 1 }, 0)
            end
            doc:raw_insert(k, 1, v, { idx = 1 }, 0)
          end
        end
        doc:set_selection(table.unpack(original_selection))
      end
    })
  end
})

keymap.add { ["ctrl+shift+r"] = "regex-replace-preview:find-replace-regex" }