fix backspace on ;; jumping to offset 0
[lisp-parkour.git] / edit.lua
blob598bd9b3bf8b1128ce8e643f66f52d4dafb9d86c
1 local M = {}
3 -- XXX: in Lua 5.2 unpack() was moved into table
4 local unpack = unpack or table.unpack
6 local function startof(node) return node.start end
7 local function finishof(node) return node.finish end
8 local function is_comment(node) return node.is_comment end
10 function M.new(parser, walker, fmt, insert, delete)
12 local function normalize_spacing(start, delta, list)
13 if delta > 0 then
14 table.insert(list, {start + delta, -delta})
15 elseif delta < 0 then
16 table.insert(list, {start, -delta})
17 end
18 return delta
19 end
21 local function reindent_list(parent, base_indent, deltas, keep_electric_space)
22 local adj = 0
23 local pstart = parent.start + (parent.p and #parent.p or 0) + #parent.d
24 if parent.is_empty then
25 normalize_spacing(pstart, parent.finish - pstart, deltas)
26 return deltas
27 end
28 for i, s in ipairs(parent) do
29 local delta
30 adj = s.indent and 0 or adj
31 local prev = parent[i - 1]
32 if not s.indent and not (prev and (s.d == "|" or prev.d == "|")) then
33 -- remove/add leading spaces
34 local off = prev and prev.finish + 1 or pstart
35 adj = adj + normalize_spacing(off, s.start - off - (prev and 1 or 0), deltas)
36 end
37 local nxt = parent[i + 1]
38 local is_last = not nxt
39 if (is_last and not keep_electric_space) or nxt and nxt.indent then
40 -- clean up trailing spaces
41 local has_eol = s.is_comment and parser.opposite[s.d]:find("\n") -- grep -n XYZZ\Y *.lua
42 local off = is_last and parent.finish - (has_eol and 1 or 0) or nxt.start - nxt.indent - 1
43 local finish = s.finish + (has_eol and 0 or 1)
44 normalize_spacing(finish, off - finish, deltas)
45 end
46 if s.indent then
47 delta = base_indent - s.indent
48 local last_distinguished = fmt:last_distinguished(parent)
49 if last_distinguished then
50 delta = delta + 1
51 if i - 1 <= last_distinguished then
52 delta = delta + 2
53 end
54 elseif -- align further arguments below the second one
55 parent.d == "(" -- [] and {} contain data, so no arguments
56 and not parent[1].is_string -- a string can't have arguments
57 --and not parent[1].is_list -- wrong, GNU Emacs compatible behaviour
58 then
59 if i > 2 and not parent[2].indent then
60 delta = base_indent + parent[2].start - pstart - s.indent
61 end
62 end
63 if delta < 0 then delta = math.max(-s.indent, delta) end
64 if delta ~= 0 then
65 table.insert(deltas, {s.start, delta})
66 end
67 adj = adj - delta
68 end
69 if s.is_list then
70 local nearest_indented = s.indent and s or walker.indented_before(s)
71 local parent_column = nearest_indented.indent + (s.start + (s.p and #s.p or 0) + #s.d) - nearest_indented.start
72 reindent_list(s, parent_column - adj, deltas, keep_electric_space)
73 end
74 end
75 return deltas
76 end
78 -- FIXME: when called at the end of another operation, some nested lists do not get reindented
79 local function reindent_at(scope, range, keep_electric_space)
80 if not range or scope.is_root then return end
81 local parent = walker.sexp_at(scope, true)
82 if not (parent and parent.is_list) then return end
83 local _, parent_at, m = walker.sexp_at(range, true)
84 if not m then
85 _, m = parent_at.after(range.start, startof)
86 if not m then
87 m = #parent_at
88 end
89 end
90 local base = parent_at[m] or parent_at
91 local path = walker.sexp_path(base)
92 local after_start, at_pfinish
93 if parent_at[m] then
94 local s = parent_at[#parent_at]
95 local has_eol = s.is_comment and parser.opposite[s.d]:find("\n") -- grep -n XYZZ\Y *.lua
96 if range.start >= s.finish + 1 + (has_eol and 1 or 0) then
97 at_pfinish = true
98 else
99 after_start = range.start < base.start and 0 or range.start - base.start
101 else
102 after_start = (parent_at.p and #parent_at.p or 0) + #parent_at.d
104 local indented_parent = parent.indent and parent or parent.is_list and walker.indented_before(parent)
105 local parent_column = indented_parent and (indented_parent.indent + parent.start - indented_parent.start +
106 (parent.p and #parent.p or 0) + #parent.d) or
108 local deltas = reindent_list(parent, parent_column, {}, keep_electric_space)
109 table.sort(deltas, function(d1, d2) return d1[1] > d2[1] end)
110 for _, pair in ipairs(deltas) do
111 local offset, delta = unpack(pair)
112 if delta > 0 then
113 insert(offset, string.rep(" ", delta))
114 elseif delta < 0 then
115 delete(offset + delta, -delta)
118 parser.tree.rewind(parent.start or 0)
119 if path then
120 local sexp, parentng = walker.goto_path(path)
121 if sexp then
122 if at_pfinish then
123 return parentng.finish
124 else
125 return sexp.start + after_start
129 return range.start
132 local function splice(pos, sexps, skip, backwards, action)
133 local spliced
134 local sexp = walker.sexp_at(skip, true)
135 local start = sexp.start + (sexp.p and #sexp.p or 0)
136 local tosplice = action.splice or action.wrap or sexp.is_empty
137 local opening = (sexp.p or '')..sexp.d
138 local closing = parser.opposite[sexp.d]
139 local real_start = tosplice and sexp.start or start + #sexp.d
140 local splice_closing = not closing:find("\n") or sexp.is_empty
141 local real_finish = sexp.finish + 1 - (tosplice and splice_closing and 0 or #closing)
142 local first = backwards and
143 {start = real_start, finish = math.max(pos, start + #sexp.d)} or
144 {start = real_start, finish = sexp.is_empty and pos or start + #sexp.d}
145 local second = backwards and
146 {start = sexp.is_empty and pos or sexp.finish + 1 - #closing, finish = real_finish} or
147 {start = math.min(pos, sexp.finish + 1 - #closing), finish = real_finish}
148 action.func(second)
149 action.func(first)
150 spliced = tosplice
151 if action.kill then
152 local ndeleted = first.finish - first.start + second.finish - second.start
153 if ndeleted > 0 then
154 sexps.rewind(sexp.start)
157 return first.finish - first.start, spliced, opening, closing
160 local function pick_out(range, pos, action)
161 local sexps = parser.tree
162 local skips = sexps.unbalanced_delimiters(range)
163 -- handle splice and kill-splice of forms and strings:
164 if #skips == 1 then
165 local sexp = walker.sexp_at(skips[1], true)
166 local backward_splice = skips[1].opening and pos >= sexp.start + (sexp.p and #sexp.p or 0) + #sexp.d
167 and range.start >= sexp.start
168 local forward_splice = skips[1].closing and pos <= sexp.finish + 1 - #parser.opposite[sexp.d]
169 and range.finish <= sexp.finish + 1
170 if backward_splice or forward_splice then
171 return splice(backward_splice and range.finish or range.start, sexps, sexp, backward_splice, action)
174 local node, parent = walker.sexp_at({start = range.finish, finish = range.finish})
175 local drop_eol = node and node.finish + 1 == range.finish and node.is_comment and parser.opposite[node.d]:find("\n")
176 table.sort(skips, function(a, b) return a.start < b.start end)
177 table.insert(skips, {start = range.finish - (drop_eol and 1 or 0)}) -- grep -n XYZZ\Y *.lua
178 table.insert(skips, 1, {finish = range.start})
179 local ndeleted = drop_eol and 1 or 0
180 for i = #skips - 1, 1, -1 do
181 local region = {start = skips[i].finish, finish = skips[i + 1].start}
182 if skips[i].closing and skips[i + 1].opening then
183 -- leave out some of the space between adjacent lists
184 local _, rparent = walker.sexp_at(region)
185 local nxt = rparent.after(region.start, startof)
186 region.start = nxt and nxt.start or region.start
188 if action then
189 action.func(region)
190 ndeleted = ndeleted + (region.finish - region.start)
193 -- if parent[#parent + 1] is nil, we are at EOF
194 if ndeleted > 0 and (not parent.is_root or parent[#parent + 1]) then
195 sexps.rewind(range.start)
197 return ndeleted
200 local function raise_sexp(range, pos)
201 local sexp, parent = walker.sexp_at(range, true)
202 if sexp and parent and parent.is_list then
203 delete(sexp.finish + 1, parent.finish - sexp.finish)
204 delete(parent.start, sexp.start - parent.start)
205 parser.tree.rewind(parent.start)
206 range.start = parent.start + pos - sexp.start
207 range.finish = range.start
208 local _, parentng = walker.sexp_at(range, true)
209 local _, grandparent = walker.sexp_at(parentng, true)
210 return grandparent and reindent_at(grandparent, range) or range.start
214 local function slurp_sexp(range, forward)
215 local _, parent = walker.sexp_at(range, true)
216 local seeker = forward and walker.finish_after or walker.start_before
217 if not parent or not parent.is_list then return range.start end
218 local r = {start = parent.start, finish = parent.finish + 1}
219 local newpos = seeker(r, is_comment)
220 if not newpos then return range.start end
221 local opening = (parent.p or '')..parent.d
222 local closing = parser.opposite[parent.d]
223 local delimiter = forward and closing or opening
224 if forward then
225 insert(newpos, delimiter)
227 delete(forward and parent.finish or parent.start, #delimiter)
228 if not forward then
229 insert(newpos, delimiter)
231 parser.tree.rewind(math.min(parent.start, newpos))
232 local _, parentng = walker.sexp_at(range, true)
233 return reindent_at(parentng, range)
236 local function barf_sexp(range, forward)
237 local _, parent, m = walker.sexp_at(range, true)
238 local seeker = forward and walker.finish_before or walker.start_after
239 -- TODO: barfing out of strings requires calling the parser on them
240 if not parent or not parent.is_list then return range.start end
241 local opening = (parent.p or '')..parent.d
242 local pstart = parent.start + #opening
243 local r = {start = forward and parent.finish - 1 or pstart, finish = forward and parent.finish or pstart + 1}
244 local newpos = seeker(r, is_comment) or forward and pstart or parent.finish
245 if not newpos then return range.start end
246 local closing = parser.opposite[parent.d]
247 local delimiter = forward and closing or opening
248 if not forward then
249 insert(newpos, delimiter)
251 delete(forward and parent.finish or parent.start, #delimiter)
252 if forward then
253 insert(newpos, delimiter)
255 parser.tree.rewind(math.min(parent.start, newpos))
256 range.start = range.start + #delimiter *
257 (m == 1 and not forward and -1 or m == #parent and forward and 1 or 0)
258 local _, parentng = walker.sexp_at(range, true)
259 local _, grandparent = walker.sexp_at(parentng, true)
260 return reindent_at(grandparent, range) or range.start
263 local function splice_sexp(range, _)
264 local _, parent = walker.sexp_at(range)
265 if not parent or not parent.d then return end
266 local opening = (parent.p or '')..parent.d
267 local closing = parser.opposite[parent.d]
268 local finish = parent.finish + 1 - #closing
269 if not closing:find("\n") then
270 delete(finish, #closing)
272 -- TODO: (un)escape special characters, if necessary
273 delete(parent.start, parent.is_empty and (finish - parent.start) or #opening)
274 parser.tree.rewind(parent.start)
275 range.start = range.start - #opening
276 range.finish = range.start
277 local _, parentng = walker.sexp_at(range, true)
278 return reindent_at(parentng, range)
281 local function cycle_wrap(range, pos)
282 local _, parent = walker.sexp_at(range)
283 if not parent or not parent.is_list then return end
284 local pstart = parent.start + #((parent.p or '')..parent.d) - 1
285 local next_kind = {["("] = "[", ["["] = "{", ["{"] = "("}
286 delete(parent.finish, 1)
287 insert(parent.finish, parser.opposite[next_kind[parent.d]])
288 delete(pstart, #parent.d)
289 insert(pstart, next_kind[parent.d])
290 parser.tree.rewind(parent.start)
291 return pos
294 local function split_sexp(range)
295 local _, parent = walker.sexp_at(range)
296 if not (parent and parent.d) then return end
297 local new_finish, new_start
298 if parent.is_list then
299 local prev = parent.before(range.start, finishof, is_comment)
300 new_finish = prev and prev.finish + 1
301 -- XXX: do not skip comments here, so they end up in the second list
302 -- and are not separated from their target expression:
303 local nxt = new_finish and parent.after(new_finish, startof)
304 new_start = nxt and nxt.start
305 else
306 new_start = range.start
307 new_finish = range.start
309 if not (new_start and new_finish) then return end
310 local opening = (parent.p or '')..parent.d
311 local closing = parser.opposite[parent.d]
312 insert(new_start, opening)
313 local sep = parser.opposite[parent.d]:find("\n") and "" -- line comments already have a separator
314 or new_finish == new_start and " " -- only add a separator if there was none before
315 or ""
316 insert(new_finish, closing..sep)
317 parser.tree.rewind(parent.start)
318 range.start = new_start + (parent.is_list and 0 or #opening + #closing)
319 range.finish = range.start
320 local _, parentng = walker.sexp_at(range, true)
321 local _, grandparent = walker.sexp_at(parentng, true)
322 return reindent_at(grandparent, range) or range.start
325 local function join_sexps(range)
326 local node, parent = walker.sexp_at(range, true)
327 local first = node and node.finish + 1 == range.start and node or parent.before(range.start, finishof)
328 local second = first ~= node and node or parent.after(range.start, startof)
329 if not (first and second and first.d and
330 (first.d == second.d or
331 -- join line comments even when their delimiters differ slightly
332 -- (different number of ;'s, existence/lack of a space after them)
333 first.d:find("^;") and second.d:find("^;"))) then
334 return
336 local opening = (second.p or '')..second.d
337 local closing = parser.opposite[first.d]
338 local pos
339 if not first.is_list then
340 pos = first.finish + 1 - #closing
341 delete(pos, second.start + #opening - pos)
342 else
343 delete(second.start, #opening)
344 delete(first.finish, #closing)
345 pos = second.start - #closing
347 parser.tree.rewind(first.start)
348 range.start = pos
349 range.finish = range.start
350 local _, parentng = walker.sexp_at(range, true)
351 local _, grandparent = walker.sexp_at(parentng, true)
352 return reindent_at(grandparent, range) or range.start
355 local function delete_splicing(range, pos, splicing, delete_and_yank)
356 local action = {kill = true, wrap = splicing, splice = splicing, func = delete_and_yank}
357 local sexp, parent, n = walker.sexp_at(range, true)
358 local ndeleted, spliced = pick_out(range, pos, action)
359 local closing = spliced and parser.opposite[sexp.d]
360 local inner_list_len = spliced and sexp.finish - sexp.start + 1 - #sexp.d - #closing
361 local range_len = range.finish - range.start
362 local backwards = pos == range.finish
363 local whole_object = sexp and
364 (sexp.start == range.start and sexp.finish + 1 == range.finish
365 or spliced and (inner_list_len <= (backwards and range_len + ndeleted or range_len)))
366 local in_head_atom = sexp and not sexp.d and n == 1 and #parent > 1
367 local in_whitespace = not sexp
368 if whole_object or in_whitespace or in_head_atom then
369 local cur = whole_object and sexp.start or range.start
370 parser.tree.rewind(cur)
371 local r = {start = cur, finish = cur}
372 local _, parentng = walker.sexp_at(r, true)
373 local newpos = reindent_at(parentng, r)
374 return newpos or cur
376 return not backwards and ndeleted <= 0 and range.finish - ndeleted or range.start
379 local function transpose(range, first, second)
380 if not (first and second) then return end
381 local copy1 = first.text
382 local copy2 = second.text
383 delete(second.start, second.finish + 1 - second.start)
384 insert(second.start, copy1)
385 delete(first.start, first.finish + 1 - first.start)
386 insert(first.start, copy2)
387 parser.tree.rewind(first.start)
388 range.start = second.finish + 1
389 range.finish = range.start
390 local _, parentng = walker.sexp_at(range, true)
391 return reindent_at(parentng, range) or range.start
394 local function transpose_sexps(range)
395 local node, parent = walker.sexp_at(range, true)
396 local first = node and node.finish + 1 == range.start and node or parent.before(range.start, finishof)
397 local second = first ~= node and node or parent.after(range.start, startof)
398 return transpose(range, first, second)
401 local function transpose_words(range)
402 local _, first = walker.start_float_before(range)
403 local _, second = walker.finish_float_after(range)
404 if first and second and first.start == second.start then
405 _, first = walker.start_float_before(first)
407 return transpose(range, first, second)
410 local function transpose_chars(range)
411 local node, parent, i = walker.sexp_at(range)
412 local nxt = i and parent[i + 1]
413 local pstart = not parent.is_root and parent.start + (parent.p and #parent.p or 0) + #parent.d
414 local pfinish = not parent.is_root and parent.finish
415 local npref = node and node.p and node.start + #node.p
416 -- only allow transposing while inside atoms/words and prefixes
417 if node and ((npref and (range.start <= npref and range.start > node.start) or
418 node.d and range.start > node.finish + 1) or not node.d and (not pstart or range.start > pstart)) then
419 local start = range.start -
420 ((range.start == pfinish or range.start == npref or
421 range.start == node.finish + 1 and (parent.is_list or parent.is_root) and (not nxt or nxt.indent)) and 1 or 0)
422 local str_start = start - node.start + 1
423 local char = node.text:sub(str_start, str_start)
424 delete(start, 1)
425 insert(start - 1, #char > 0 and char or " ")
426 parser.tree.rewind(start)
427 local in_head_atom = i == 1 and #parent > 1
428 return parent.is_list and in_head_atom
429 and reindent_at(parent, {start = start + 1, finish = start + 1})
430 or start + 1
434 local function delete_nonsplicing(range, pos, delete_maybe_yank)
435 local action = {kill = true, wrap = false, splice = false, func = delete_maybe_yank}
436 local ndeleted, spliced, opening = pick_out(range, pos, action)
437 local backwards = pos == range.finish
438 if opening then
439 if spliced then
440 return pos - ndeleted
441 else
442 if ndeleted == 0 then
443 local sexp = walker.sexp_at(range)
444 if sexp and sexp.d and sexp.d:match("^;") then
445 if pos == sexp.start + #sexp.d then
446 return join_sexps(range) or pos
447 elseif pos == sexp.finish then
448 local closing = parser.opposite[sexp.d]
449 local r = {start = pos + #closing, finish = pos + #closing}
450 return join_sexps(r) or pos + #closing
454 return backwards and (range.finish - ndeleted) or range.start
456 else
457 return backwards and range.start or (range.finish - ndeleted)
461 local function make_wrap(kind)
462 return function(range, pos, round_to_square)
463 local opening = kind
464 if kind == ";" then
465 local node, parent = walker.sexp_at(range, true)
466 if parent.is_root then
467 -- FIXME: don't do that at the end of a defun
468 opening = ";;; "
469 else
470 local nxt = parent.after(range.finish, startof)
471 if not (nxt and nxt.indent and node and (node.finish + 1 <= range.start)) then
472 opening = ";; "
476 local function _wrap(r)
477 local indices, nodes = walker.sexp_path(r)
478 local delimiter, closing = opening, parser.opposite[opening]
479 local squarewords = fmt.squarewords
480 if closing ~= "}" and squarewords then
481 if closing == ")" and (round_to_square or squarewords.not_optional)
482 and not fmt:rewrite_bracket_p(indices, nodes, range) then
483 delimiter, closing = "[", "]"
486 insert(r.finish, closing)
487 insert(r.start, delimiter)
489 local action = {kill = false, wrap = false, splice = false, func = _wrap}
490 pick_out(range, pos, action)
491 parser.tree.rewind(range.start)
492 local _, parentng = walker.sexp_at(range, true)
493 range.start = range.start + #opening
494 return reindent_at(parentng, range) or range.start
498 return {
499 delete_splicing = delete_splicing,
500 delete_nonsplicing = delete_nonsplicing,
501 reindent_at = reindent_at,
502 pick_out = pick_out,
503 raise_sexp = raise_sexp,
504 slurp_sexp = slurp_sexp,
505 barf_sexp = barf_sexp,
506 splice_sexp = splice_sexp,
507 wrap_round = make_wrap("("),
508 meta_doublequote = make_wrap('"'),
509 comment_dwim = make_wrap(";"),
510 cycle_wrap = cycle_wrap,
511 split_sexp = split_sexp,
512 join_sexps = join_sexps,
513 transpose_sexps = transpose_sexps,
514 transpose_words = transpose_words,
515 transpose_chars = transpose_chars,
519 return M