aboutsummaryrefslogtreecommitdiffhomepage
path: root/modules/textadept/editing.lua
blob: 19d3587ce9fcc8d3b8f3929641a918d3f09f330a (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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
-- Copyright 2007-2013 Mitchell mitchell.att.foicica.com. See LICENSE.

local M = {}

--[[ This comment is for LuaDoc.
---
-- Editing features for Textadept.
-- @field AUTOPAIR (bool)
--   Automatically close opening '(', '[', '{', '"', or '''
--   characters.
--   The default value is `true`.
--   Auto-paired characters are defined in the [`char_matches`](#char_matches)
--   table.
-- @field HIGHLIGHT_BRACES (bool)
--   Highlight matching brace characters like "()[]{}".
--   The default value is `true`.
--   Matching braces are defined in the [`braces`](#braces) table.
-- @field TYPEOVER_CHARS (bool)
--   Move over the typeover character under the caret when typing it instead of
--   inserting it.
--   The default value is `true`.
--   Typeover characters are defined in the [`typeover_chars`](#typeover_chars)
--   table.
-- @field AUTOINDENT (bool)
--   Match the indentation level of the previous line when inserting a new line.
--   The default value is `true`.
-- @field STRIP_TRAILING_SPACES (bool)
--   Strip trailing whitespace on file save.
--   The default value is `true`.
-- @field HIGHLIGHT_COLOR (string)
--   The name of the color in the current theme to
--   [highlight words](#highlight_word) with.
module('_M.textadept.editing')]]

M.AUTOPAIR = true
M.HIGHLIGHT_BRACES = true
M.TYPEOVER_CHARS = true
M.AUTOINDENT = true
M.STRIP_TRAILING_SPACES = true
M.HIGHLIGHT_COLOR = not CURSES and 'color.orange' or 'color.yellow'

---
-- Map of lexer names to line comment strings for programming languages, used by
-- the `block_comment()` function.
-- Keys are lexer names and values are either the language's line comment
-- prefixes or block comment delimiters separated by a '|'.
-- @class table
-- @name comment_string
-- @see block_comment
M.comment_string = {actionscript='//',ada='--',antlr='//',adpl='!',applescript='--',asp='\'',awk='#',b_lang='//',bash='#',batch=':',bibtex='%',boo='#',chuck='//',cmake='#',coffeescript='#',context='%',cpp='//',csharp='//',css='/*|*/',cuda='//',desktop='#',django='{#|#}',dmd='//',dot='//',eiffel='--',erlang='%',forth='|\\',fortran='!',fsharp='//',gap='#',gettext='#',glsl='//',gnuplot='#',go='//',groovy='//',gtkrc='#',haskell='--',hypertext='<!--|-->',idl='//',inform='!',ini='#',Io='#',java='//',javascript='//',json='/*|*/',jsp='//',latex='%',less='//',lilypond='%',lisp=';',lua='--',makefile='#',matlab='#',nemerle='//',nsis='#',objective_c='//',pascal='//',perl='#',php='//',pike='//',pkgbuild='#',prolog='%',props='#',ps='%',python='#',rails='#',rebol=';',rexx='--',rhtml='<!--|-->',rstats='#',ruby='#',sass='//',scala='//',scheme=';',smalltalk='"|"',sql='#',tcl='#',tex='%',vala='//',vb='\'',vbscript='\'',verilog='//',vhdl='--',xml='<!--|-->'}

---
-- Map of auto-paired characters like parentheses, brackets, braces, and quotes,
-- with language-specific auto-paired character maps assigned to a lexer name
-- key.
-- The ASCII values of opening characters are assigned to strings containing
-- complement characters. The default auto-paired characters are "()", "[]",
-- "{}", "&apos;&apos;", and "&quot;&quot;".
-- @class table
-- @name char_matches
-- @usage _M.textadept.editing.char_matches.hypertext = {..., [60] = '>'}
-- @see AUTOPAIR
M.char_matches = {[40] = ')', [91] = ']', [123] = '}', [39] = "'", [34] = '"'}

---
-- Table of brace characters to highlight, with language-specific brace
-- character tables assigned to a lexer name key.
-- The ASCII values of brace characters are keys and are assigned non-`nil`
-- values. The default brace characters are '(', ')', '[', ']', '{', and '}'.
-- @class table
-- @name braces
-- @usage _M.textadept.editing.braces.hypertext = {..., [60] = 1, [62] = 1}
-- @see HIGHLIGHT_BRACES
M.braces = {[40] = 1, [41] = 1, [91] = 1, [93] = 1, [123] = 1, [125] = 1}

---
-- Table of characters to move over when typed, with language-specific typeover
-- character tables assigned to a lexer name key.
-- The ASCII values of characters are keys and are assigned non-`nil` values.
-- The default characters are ')', ']', '}', '&apos;', and '&quot;'.
-- @class table
-- @name typeover_chars
-- @usage _M.textadept.editing.typeover_chars.hypertext = {..., [62] = 1}
-- @see TYPEOVER_CHARS
M.typeover_chars = {[41] = 1, [93] = 1, [125] = 1, [39] = 1, [34] = 1}

-- Matches characters specified in char_matches.
events.connect(events.CHAR_ADDED, function(c)
  if not M.AUTOPAIR then return end
  local match = (M.char_matches[buffer:get_lexer(true)] or M.char_matches)[c]
  if match and buffer.selections == 1 then buffer:insert_text(-1, match) end
end)

-- Removes matched chars on backspace.
events.connect(events.KEYPRESS, function(code)
  if not M.AUTOPAIR or keys.KEYSYMS[code] ~= '\b' or buffer.selections ~= 1 then
    return
  end
  local pos, char = buffer.current_pos, buffer.char_at[buffer.current_pos - 1]
  local match = (M.char_matches[buffer:get_lexer(true)] or M.char_matches)[char]
  if match and buffer.char_at[pos] == string.byte(match) then buffer:clear() end
end)

-- Highlights matching braces.
events.connect(events.UPDATE_UI, function()
  if not M.HIGHLIGHT_BRACES then return end
  local pos = buffer.current_pos
  if (M.braces[buffer:get_lexer(true)] or M.braces)[buffer.char_at[pos]] then
    local match = buffer:brace_match(pos)
    if match ~= -1 then
      buffer:brace_highlight(pos, match)
    else
      buffer:brace_bad_light(pos)
    end
  else
    buffer:brace_bad_light(-1)
  end
end)

-- Moves over typeover characters when typed.
events.connect(events.KEYPRESS, function(code)
  if not M.TYPEOVER_CHARS then return end
  if M.typeover_chars[code] and buffer.char_at[buffer.current_pos] == code then
    buffer:char_right()
    return true
  end
end)

-- Auto-indent on return.
events.connect(events.CHAR_ADDED, function(char)
  if not M.AUTOINDENT or char ~= 10 then return end
  local buffer = buffer
  local pos = buffer.current_pos
  local line = buffer:line_from_position(pos)
  local i = line - 1
  while i >= 0 and buffer:get_line(i):find('^[\r\n]+$') do i = i - 1 end
  if i >= 0 then
    buffer.line_indentation[line] = buffer.line_indentation[i]
    buffer:goto_pos(buffer.line_indent_position[line])
  end
end)

-- Autocomplete multiple selections.
events.connect(events.AUTO_C_SELECTION, function(text, position)
  local buffer = buffer
  local pos = buffer.selection_n_caret[buffer.main_selection]
  buffer:begin_undo_action()
  for i = 0, buffer.selections - 1 do
    buffer.target_start = buffer.selection_n_anchor[i] - (pos - position)
    buffer.target_end = buffer.selection_n_caret[i]
    buffer:replace_target(text)
    buffer.selection_n_anchor[i] = buffer.selection_n_anchor[i] + #text
    buffer.selection_n_caret[i] = buffer.selection_n_caret[i] + #text
  end
  buffer:end_undo_action()
  buffer:auto_c_cancel() -- tell Scintilla not to handle autocompletion normally
end)

-- Prepares the buffer for saving to a file.
events.connect(events.FILE_BEFORE_SAVE, function()
  if not M.STRIP_TRAILING_SPACES then return end
  local buffer = buffer
  buffer:begin_undo_action()
  -- Strip trailing whitespace.
  local line_end_position, char_at = buffer.line_end_position, buffer.char_at
  local lines = buffer.line_count
  for line = 0, lines - 1 do
    local s, e = buffer:position_from_line(line), line_end_position[line]
    local i, c = e - 1, char_at[e - 1]
    while i >= s and c == 9 or c == 32 do i, c = i - 1, char_at[i - 1] end
    if i < e - 1 then
      buffer.target_start, buffer.target_end = i + 1, e
      buffer:replace_target('')
    end
  end
  -- Ensure ending newline.
  local e = buffer:position_from_line(lines)
  if lines == 1 or e > buffer:position_from_line(lines - 1) then
    buffer:insert_text(e, '\n')
  end
  -- Convert non-consistent EOLs
  buffer:convert_eo_ls(buffer.eol_mode)
  buffer:end_undo_action()
end)

---
-- Goes to the current character's matching brace, selecting the text in-between
-- if *select* is `true`.
-- @param select Optional flag indicating whether or not to select the text
--   between matching braces. The default value is `false`.
-- @name match_brace
function M.match_brace(select)
  local pos = buffer.current_pos
  local match_pos = buffer:brace_match(pos)
  if match_pos == -1 then return end
  if not select then
    buffer:goto_pos(match_pos)
  elseif match_pos > pos then
    buffer:set_sel(pos, match_pos + 1)
  else
    buffer:set_sel(pos + 1, match_pos)
  end
end

---
-- Displays an autocompletion list, built from the set of *default_words* and
-- existing words in the buffer, for the word behind the caret, returning `true`
-- if completions were found.
-- @param default_words Optional list of words considered to be in the buffer,
--   even if they are not. Words may contain [registered images][].
--
-- [registered images]: buffer.html#register_image
-- @return `true` if there were completions to show; `false` otherwise.
-- @see buffer.word_chars
-- @name autocomplete_word
function M.autocomplete_word(default_words)
  local buffer = buffer
  local pos, length = buffer.current_pos, buffer.length
  local completions, c_list = {}, {}
  local buffer_text = buffer:get_text()
  local root = buffer_text:sub(1, pos):match('['..buffer.word_chars..']+$')
  if not root or root == '' then return end
  for _, word in ipairs(default_words or {}) do
    if word:match('^'..root) then
      c_list[#c_list + 1], completions[word:match('^(.-)%??%d*$')] = word, true
    end
  end
  local patt = '^['..buffer.word_chars..']+'
  buffer.target_start, buffer.target_end = 0, buffer.length
  buffer.search_flags = buffer.SCFIND_WORDSTART
  if not buffer.auto_c_ignore_case then
    buffer.search_flags = buffer.search_flags + buffer.SCFIND_MATCHCASE
  end
  local match_pos = buffer:search_in_target(root)
  while match_pos ~= -1 do
    local s, e = buffer_text:find(patt, match_pos + 1)
    local match = buffer_text:sub(s, e)
    if not completions[match] and #match > #root then
      c_list[#c_list + 1], completions[match] = match, true
    end
    buffer.target_start, buffer.target_end = match_pos + 1, buffer.length
    match_pos = buffer:search_in_target(root)
  end
  if not buffer.auto_c_ignore_case then
    table.sort(c_list)
  else
    table.sort(c_list, function(a, b) return a:upper() < b:upper() end)
  end
  if #c_list > 0 then
    if not buffer.auto_c_choose_single or #c_list ~= 1 then
      buffer.auto_c_order = 0 -- pre-sorted
      buffer:auto_c_show(#root, table.concat(c_list, ' '))
    else
      -- Scintilla does not emit AUTO_C_SELECTION in this case. This is
      -- necessary for autocompletion with multiple selections.
      local text = c_list[1]:match('^(.-)%??%d*$')
      events.emit(events.AUTO_C_SELECTION, text, pos - #root)
    end
    return true
  end
end

---
-- Comments or uncomments the selected lines with line comment string *comment*
-- or the comment from the `comment_string` table for the current lexer.
-- As long as any part of a line is selected, the entire line is eligible for
-- commenting/uncommenting.
-- @param comment Optional comment string inserted or removed from each line in
--   the selection. Comment delimiters are separated by a '|'. The default value
--   is the comment in the `comment_string` table for the current lexer.
-- @see comment_string
-- @name block_comment
function M.block_comment(comment)
  local buffer = buffer
  comment = comment or M.comment_string[buffer:get_lexer(true)]
  local prefix, suffix = comment:match('^([^|]+)|?([^|]*)$')
  if not prefix then return end
  local anchor, pos = buffer.selection_start, buffer.selection_end
  local s, e = buffer:line_from_position(anchor), buffer:line_from_position(pos)
  local ignore_last_line = s ~= e and pos == buffer:position_from_line(e)
  anchor, pos = buffer.line_end_position[s] - anchor, buffer.length - pos
  buffer:begin_undo_action()
  for line = s, not ignore_last_line and e or e - 1 do
    local p = buffer:position_from_line(line)
    if buffer:text_range(p, p + #prefix) == prefix then
      buffer:set_sel(p, p + #prefix)
      buffer:replace_sel('')
      if suffix ~= '' then
        p = buffer.line_end_position[line]
        buffer:set_sel(p - #suffix, p)
        buffer:replace_sel('')
        if line == s then anchor = anchor - #suffix end
        if line == e then pos = pos - #suffix end
      end
    else
      buffer:insert_text(p, prefix)
      if suffix ~= '' then
        buffer:insert_text(buffer.line_end_position[line], suffix)
        if line == s then anchor = anchor + #suffix end
        if line == e then pos = pos + #suffix end
      end
    end
  end
  buffer:end_undo_action()
  anchor, pos = buffer.line_end_position[s] - anchor, buffer.length - pos
  anchor = math.max(anchor, buffer:position_from_line(s)) -- stay on first line
  if s ~= e then buffer:set_sel(anchor, pos) else buffer:goto_pos(pos) end
end

---
-- Goes to line number *line* or the user-specified line in the buffer.
-- @param line Optional line number to go to. If `nil`, the user is prompted for
--   one.
-- @name goto_line
function M.goto_line(line)
  if not line then
    line = tonumber(gui.dialog('inputbox',
                               '--title', _L['Go To'],
                               '--text', _L['Line Number:'],
                               '--button1', _L['_OK'],
                               '--button2', _L['_Cancel'],
                               '--no-newline'):match('%-?%d+$'))
    if not line or line < 0 then return end
  end
  buffer:ensure_visible_enforce_policy(line - 1)
  buffer:goto_line(line - 1)
end

---
-- Transposes characters intelligently.
-- If the caret is at the end of a line, the two characters before the caret are
-- transposed. Otherwise, the characters to the left and right are.
-- @name transpose_chars
function M.transpose_chars()
  if buffer.length == 0 then return end
  local pos, char = buffer.current_pos, buffer.char_at[buffer.current_pos]
  if char == 10 or char == 13 or pos == buffer.length then pos = pos - 1 end
  buffer.target_start, buffer.target_end = pos - 1, pos + 1
  buffer:replace_target(buffer:text_range(pos - 1, pos + 1):reverse())
  buffer:goto_pos(pos + 1)
end

---
-- Joins the currently selected lines or the current line with the line below
-- it.
-- As long as any part of a line is selected, the entire line is eligible for
-- joining.
-- @name join_lines
function M.join_lines()
  buffer:target_from_selection()
  buffer:line_end()
  local line = buffer:line_from_position(buffer.target_start)
  if line == buffer:line_from_position(buffer.target_end) then
    buffer.target_end = buffer:position_from_line(line + 1)
  end
  buffer:lines_join()
end

---
-- Encloses the selected text or the word behind the caret within strings *left*
-- and *right*.
-- @param left The left part of the enclosure.
-- @param right The right part of the enclosure.
-- @name enclose
function M.enclose(left, right)
  buffer:target_from_selection()
  local s, e = buffer.target_start, buffer.target_end
  if s == e then buffer.target_start = buffer:word_start_position(s, true) end
  buffer:replace_target(left..buffer:text_range(buffer.target_start, e)..right)
  buffer:goto_pos(buffer.target_end)
end

---
-- Selects the text in-between strings *left* and *right* containing the caret.
-- If already selected, toggles between selecting the *left* and *right*
-- enclosures too.
-- @param left The left part of the enclosure.
-- @param right The right part of the enclosure.
-- @name select_enclosed
function M.select_enclosed(left, right)
  local anchor, pos = buffer.anchor, buffer.current_pos
  if anchor ~= pos then buffer:goto_pos(pos - #right) end
  buffer:search_anchor()
  local s, e = buffer:search_prev(0, left), buffer:search_next(0, right)
  if s >= 0 and e >= 0 then
    if s + #left == anchor and e == pos then s, e = s - #left, e + #right end
    buffer:set_sel(s + #left, e)
  end
end

---
-- Selects the current word.
-- @see buffer.word_chars
-- @name select_word
function M.select_word()
  buffer:set_sel(buffer:word_start_position(buffer.current_pos, true),
                 buffer:word_end_position(buffer.current_pos, true))
end

---
-- Selects the current line.
-- @name select_line
function M.select_line()
  buffer:home()
  buffer:line_end_extend()
end

---
-- Selects the current paragraph.
-- Paragraphs are surrounded by one or more blank lines.
-- @name select_paragraph
function M.select_paragraph()
  buffer:para_up()
  buffer:para_down_extend()
end

---
-- Selects indented text blocks intelligently.
-- If no block of text is selected, all text with the current level of
-- indentation is selected. If a block of text is selected and the lines
-- immediately above and below it are one indentation level lower, they are
-- added to the selection. In all other cases, the behavior is the same as if no
-- text is selected.
-- @name select_indented_block
function M.select_indented_block()
  local buffer = buffer
  local s = buffer:line_from_position(buffer.selection_start)
  local e = buffer:line_from_position(buffer.selection_end)
  local indent = buffer.line_indentation[s] - buffer.tab_width
  if indent < 0 then return end
  if buffer:get_sel_text() ~= '' and
     buffer.line_indentation[s - 1] == indent and
     buffer.line_indentation[e + 1] == indent then
    s, e, indent = s - 1, e + 1, indent + buffer.tab_width
  end
  while buffer.line_indentation[s - 1] > indent do s = s - 1 end
  while buffer.line_indentation[e + 1] > indent do e = e + 1 end
  s, e = buffer:position_from_line(s), buffer.line_end_position[e]
  buffer:set_sel(s, e)
end

---
-- Converts indentation between tabs and spaces depending on the buffer's
-- indentation settings.
-- If `buffer.use_tabs` is `true`, `buffer.tab_width` indenting spaces are
-- converted to tabs. Otherwise, all indenting tabs are converted to
-- `buffer.tab_width` spaces.
-- @see buffer.use_tabs
-- @name convert_indentation
function M.convert_indentation()
  local buffer = buffer
  local line_indentation = buffer.line_indentation
  local line_indent_position = buffer.line_indent_position
  buffer:begin_undo_action()
  for line = 0, buffer.line_count do
    local s = buffer:position_from_line(line)
    local indent = line_indentation[line]
    local indent_pos = line_indent_position[line]
    current_indentation = buffer:text_range(s, indent_pos)
    if buffer.use_tabs then
      new_indentation = ('\t'):rep(indent / buffer.tab_width)
    else
      new_indentation = (' '):rep(indent)
    end
    if current_indentation ~= new_indentation then
      buffer.target_start, buffer.target_end = s, indent_pos
      buffer:replace_target(new_indentation)
    end
  end
  buffer:end_undo_action()
end

local INDIC_HIGHLIGHT = _SCINTILLA.next_indic_number()

-- Clears highlighted word indicators and markers.
local function clear_highlighted_words()
  buffer.indicator_current = INDIC_HIGHLIGHT
  buffer:indicator_clear_range(0, buffer.length)
end
events.connect(events.KEYPRESS, function(code)
  if keys.KEYSYMS[code] == 'esc' then clear_highlighted_words() end
end)

---
-- Highlights all occurrences of the selected text or the current word.
-- @see buffer.word_chars
-- @name highlight_word
function M.highlight_word()
  clear_highlighted_words()
  local buffer = buffer
  local s, e = buffer.selection_start, buffer.selection_end
  if s == e then
    s, e = buffer:word_start_position(s, true), buffer:word_end_position(s)
  end
  if s == e then return end
  local word = buffer:text_range(s, e)
  buffer.search_flags = buffer.SCFIND_WHOLEWORD + buffer.SCFIND_MATCHCASE
  buffer.target_start, buffer.target_end = 0, buffer.length
  while buffer:search_in_target(word) > -1 do
    local len = buffer.target_end - buffer.target_start
    buffer:indicator_fill_range(buffer.target_start, len)
    buffer.target_start, buffer.target_end = buffer.target_end, buffer.length
  end
  buffer:set_sel(s, e)
end

-- Sets view properties for highlighted word indicators and markers.
local function set_highlight_properties()
  buffer.indic_fore[INDIC_HIGHLIGHT] = buffer.property_int[M.HIGHLIGHT_COLOR]
  buffer.indic_style[INDIC_HIGHLIGHT] = buffer.INDIC_ROUNDBOX
  buffer.indic_alpha[INDIC_HIGHLIGHT] = 255
  if not CURSES then buffer.indic_under[INDIC_HIGHLIGHT] = true end
end
if buffer then set_highlight_properties() end
events.connect(events.VIEW_NEW, set_highlight_properties)

---
-- Passes selected or all buffer text to string shell command *cmd* as standard
-- input (stdin) and replaces the input text with the command's standard output
-- (stdout).
-- Standard input is as follows:
--
-- 1. If text is selected and spans multiple lines, all text on the lines
-- containing the selection is used. However, if the end of the selection is at
-- the beginning of a line, only the EOL (end of line) characters from the
-- previous line are included as input. The rest of the line is excluded.
-- 2. If text is selected and spans a single line, only the selected text is
-- used.
-- 3. If no text is selected, the entire buffer is used.
-- @param cmd The Linux, BSD, Mac OSX, or Windows shell command to filter text
--   through.
-- @name filter_through
function M.filter_through(cmd)
  local s, e = buffer.selection_start, buffer.selection_end
  local input
  if s ~= e then -- use selected lines as input
    local i, j = buffer:line_from_position(s), buffer:line_from_position(e)
    if i < j then
      s = buffer:position_from_line(i)
      if buffer.column[e] > 0 then e = buffer:position_from_line(j + 1) end
    end
    input = buffer:text_range(s, e)
  else -- use whole buffer as input
    input = buffer:get_text()
  end
  local tmpfile = _USERHOME..'/.ft'
  local f = io.open(tmpfile, 'wb')
  f:write(input)
  f:close()
  local cmd = (not WIN32 and 'cat' or 'type')..' "'..tmpfile..'" | '..cmd
  if WIN32 then cmd = cmd:gsub('/', '\\') end
  local p = io.popen(cmd)
  if s ~= e then
    buffer.target_start, buffer.target_end = s, e
    buffer:replace_target(p:read('*all'))
    buffer:set_sel(buffer.target_start, buffer.target_end)
  else
    buffer:set_text(p:read('*all'))
    buffer:goto_pos(s)
  end
  p:close()
  os.remove(tmpfile)
end

return M