-- Copyright 2007-2016 Mitchell mitchell.att.foicica.com. See LICENSE. --[[ This comment is for LuaDoc. --- -- Extends Lua's `io` library with Textadept functions for working with files. -- @field _G.events.FILE_OPENED (string) -- Emitted after opening a file in a new buffer. -- Emitted by [`io.open_file()`](). -- Arguments: -- -- * _`filename`_: The opened file's filename. -- @field _G.events.FILE_BEFORE_SAVE (string) -- Emitted right before saving a file to disk. -- Emitted by [`io.save_file()`](). -- Arguments: -- -- * _`filename`_: The filename of the file being saved. -- @field _G.events.FILE_AFTER_SAVE (string) -- Emitted right after saving a file to disk. -- Emitted by [`io.save_file()`]() and [`io.save_file_as()`](). -- Arguments: -- -- * _`filename`_: The filename of the file being saved. -- * _`saved_as`_: Whether or not the file was saved under a different -- filename. -- @field _G.events.FILE_CHANGED (string) -- Emitted when Textadept detects that an open file was modified externally. -- When connecting to this event, connect with an index of 1 to override the -- default prompt to reload the file. -- Arguments: -- -- * _`filename`_: The filename externally modified. -- @field SNAPOPEN_MAX (number) -- The maximum number of files listed in the snapopen dialog. -- The default value is `1000`. module('io')]] -- Events. local events, events_connect = events, events.connect events.FILE_OPENED = 'file_opened' events.FILE_BEFORE_SAVE = 'file_before_save' events.FILE_AFTER_SAVE = 'file_after_save' events.FILE_CHANGED = 'file_changed' io.SNAPOPEN_MAX = 1000 --- -- List of recently opened files, the most recent being towards the top. -- @class table -- @name recent_files io.recent_files = {} --- -- List of encodings to attempt to decode files as. -- You should add to this list if you get a "Conversion failed" error when -- trying to open a file whose encoding is not recognized. Valid encodings are -- [GNU iconv's encodings][] and include: -- -- * European: ASCII, ISO-8859-{1,2,3,4,5,7,9,10,13,14,15,16}, KOI8-R, KOI8-U, -- KOI8-RU, CP{1250,1251,1252,1253,1254,1257}, CP{850,866,1131}, -- Mac{Roman,CentralEurope,Iceland,Croatian,Romania}, -- Mac{Cyrillic,Ukraine,Greek,Turkish}, Macintosh. -- * Semitic: ISO-8859-{6,8}, CP{1255,1256}, CP862, Mac{Hebrew,Arabic}. -- * Japanese: EUC-JP, SHIFT_JIS, CP932, ISO-2022-JP, ISO-2022-JP-2, -- ISO-2022-JP-1. -- * Chinese: EUC-CN, HZ, GBK, CP936, GB18030, EUC-TW, BIG5, CP950, -- BIG5-HKSCS, BIG5-HKSCS:2004, BIG5-HKSCS:2001, BIG5-HKSCS:1999, -- ISO-2022-CN, ISO-2022-CN-EXT. -- * Korean: EUC-KR, CP949, ISO-2022-KR, JOHAB. -- * Armenian: ARMSCII-8. -- * Georgian: Georgian-Academy, Georgian-PS. -- * Tajik: KOI8-T. -- * Kazakh: PT154, RK1048. -- * Thai: ISO-8859-11, TIS-620, CP874, MacThai. -- * Laotian: MuleLao-1, CP1133. -- * Vietnamese: VISCII, TCVN, CP1258. -- * Unicode: UTF-8, UCS-2, UCS-2BE, UCS-2LE, UCS-4, UCS-4BE, UCS-4LE, UTF-16, -- UTF-16BE, UTF-16LE, UTF-32, UTF-32BE, UTF-32LE, UTF-7, C99, JAVA. -- -- [GNU iconv's encodings]: http://www.gnu.org/software/libiconv/ -- @usage io.encodings[#io.encodings + 1] = 'UTF-16' -- @class table -- @name encodings io.encodings = {'UTF-8', 'ASCII', 'ISO-8859-1', 'MacRoman'} local BOMs = { ['UTF-8'] = '\239\187\191', ['UTF-16BE'] = '\254\255', ['UTF-16LE'] = '\255\254', ['UTF-32BE'] = '\0\0\254\255', ['UTF-32LE'] = '\255\254\0\0' } local c = _SCINTILLA.constants local EOLs = {['\r\n'] = c.EOL_CRLF, ['\r'] = c.EOL_CR, ['\n'] = c.EOL_LF} --- -- Opens *filenames*, a string filename or list of filenames, or the -- user-selected filenames. -- Emits a `FILE_OPENED` event. -- @param filenames Optional string filename or table of filenames to open. If -- `nil`, the user is prompted with a fileselect dialog. -- @see _G.events -- @name open_file function io.open_file(filenames) if type(filenames) == 'string' then filenames = {filenames} end filenames = filenames or ui.dialogs.fileselect{ title = _L['Open'], select_multiple = true, with_directory = (buffer.filename or ''):match('^.+[/\\]') or lfs.currentdir(), width = CURSES and ui.size[1] - 2 or nil } if not filenames then return end for i = 1, #filenames do local filename = lfs.abspath((filenames[i]:gsub('^file://', ''))) for j, buffer in ipairs(_BUFFERS) do if filename == buffer.filename then view:goto_buffer(j) goto continue end end local text = '' local f, err = io.open(filename, 'rb') if f then text = f:read('*a') f:close() if not text then return end -- filename exists, but cannot read it elseif lfs.attributes(filename) then error(err) end local buffer = buffer.new() buffer.encoding, buffer.encoding_bom = nil, nil -- Try to detect character encoding and convert to UTF-8. for encoding, bom in pairs(BOMs) do if text:sub(1, #bom) == bom then buffer.encoding, buffer.encoding_bom = encoding, bom text = text:sub(#bom + 1, -1):iconv('UTF-8', encoding) break end end if not buffer.encoding and not text:sub(1, 65536):find('\0') then for j = 1, #io.encodings do local ok, conv = pcall(string.iconv, text, 'UTF-8', io.encodings[j]) if ok then buffer.encoding, text = io.encodings[j], conv break end end assert(buffer.encoding, _L['Encoding conversion failed.']) end buffer.code_page = buffer.encoding and buffer.CP_UTF8 or 0 -- Detect EOL mode. buffer.eol_mode = EOLs[text:match('\r\n?')] or buffer.EOL_LF -- Insert buffer text and set properties. buffer:add_text(text, #text) buffer:goto_pos(0) buffer:empty_undo_buffer() buffer.mod_time = lfs.attributes(filename, 'modification') or os.time() buffer.filename = filename buffer:set_save_point() events.emit(events.FILE_OPENED, filename) -- Add file to recent files list, eliminating duplicates. for j, file in ipairs(io.recent_files) do if file == filename then table.remove(io.recent_files, j) break end end table.insert(io.recent_files, 1, filename) ::continue:: end end --- -- Reloads the current buffer's file contents, discarding any changes. -- @name reload_file function io.reload_file() if not buffer.filename then return end local pos, first_visible_line = buffer.current_pos, buffer.first_visible_line local f = assert(io.open(buffer.filename, 'rb')) local text = f:read('*a') f:close() local encoding, encoding_bom = buffer.encoding, buffer.encoding_bom if encoding_bom then text = text:sub(#encoding_bom + 1, -1) end if encoding then text = text:iconv('UTF-8', encoding) end buffer:clear_all() buffer:add_text(text, #text) buffer:line_scroll(0, first_visible_line) buffer:goto_pos(pos) buffer:set_save_point() buffer.mod_time = lfs.attributes(buffer.filename, 'modification') end -- LuaDoc is in core/.buffer.luadoc. local function set_encoding(buffer, encoding) assert(buffer.encoding, _L['Cannot change binary file encoding']) local pos, first_visible_line = buffer.current_pos, buffer.first_visible_line local text = buffer:get_text() text = text:iconv(buffer.encoding, 'UTF-8') text = text:iconv(encoding, buffer.encoding) text = text:iconv('UTF-8', encoding) buffer:clear_all() buffer:add_text(text, #text) buffer:line_scroll(0, first_visible_line) buffer:goto_pos(pos) buffer.encoding, buffer.encoding_bom = encoding, BOMs[encoding] end -- Sets the default buffer encoding. events_connect(events.BUFFER_NEW, function() buffer.set_encoding, buffer.encoding = set_encoding, 'UTF-8' end) --- -- Saves the current buffer to its file. -- Emits `FILE_BEFORE_SAVE` and `FILE_AFTER_SAVE` events. -- @name save_file function io.save_file() if not buffer.filename then io.save_file_as() return end events.emit(events.FILE_BEFORE_SAVE, buffer.filename) local text = buffer:get_text() if buffer.encoding then text = (buffer.encoding_bom or '')..text:iconv(buffer.encoding, 'UTF-8') end local f = assert(io.open(buffer.filename, 'wb')) f:write(text) f:close() buffer:set_save_point() buffer.mod_time = lfs.attributes(buffer.filename, 'modification') if buffer._type then buffer._type = nil end events.emit(events.FILE_AFTER_SAVE, buffer.filename) end --- -- Saves the current buffer to file *filename* or the user-specified filename. -- Emits a `FILE_AFTER_SAVE` event. -- @param filename Optional new filepath to save the buffer to. If `nil`, the -- user is prompted for one. -- @name save_file_as function io.save_file_as(filename) local dir, name = (buffer.filename or ''):match('^(.-[/\\]?)([^/\\]*)$') filename = filename or ui.dialogs.filesave{ title = _L['Save'], with_directory = dir, with_file = name:iconv('UTF-8', _CHARSET), width = CURSES and ui.size[1] - 2 or nil } if not filename then return end buffer.filename = filename io.save_file() events.emit(events.FILE_AFTER_SAVE, filename, true) end --- -- Saves all unsaved buffers to their respective files. -- @see io.save_file -- @name save_all_files function io.save_all_files() local current_buffer = _BUFFERS[buffer] for i, buffer in ipairs(_BUFFERS) do if buffer.filename and buffer.modify then view:goto_buffer(i) io.save_file() end end view:goto_buffer(current_buffer) end --- -- Closes the current buffer, prompting the user to continue if there are -- unsaved changes, and returns `true` if the buffer was closed. -- @return `true` if the buffer was closed; `nil` otherwise. -- @name close_buffer function io.close_buffer() local filename = buffer.filename or buffer._type or _L['Untitled'] local confirm = not buffer.modify or ui.dialogs.msgbox{ title = _L['Close without saving?'], text = _L['There are unsaved changes in'], informative_text = filename:iconv('UTF-8', _CHARSET), icon = 'gtk-dialog-question', button1 = _L['_Cancel'], button2 = _L['Close _without saving'] } == 2 if not confirm then return nil end -- nil return won't propagate a key command buffer:delete() return true end --- -- Closes all open buffers, prompting the user to continue if there are unsaved -- buffers, and returns `true` if the user did not cancel. -- No buffers are saved automatically. They must be saved manually. -- @return `true` if user did not cancel. -- @see io.close_buffer -- @name close_all_buffers function io.close_all_buffers() while #_BUFFERS > 1 do view:goto_buffer(#_BUFFERS) if not io.close_buffer() then return false end end return io.close_buffer() -- the last one end -- Detects if the current file has been externally modified and, if so, emits a -- `FILE_CHANGED` event. local function update_modified_file() if not buffer.filename then return end local mod_time = lfs.attributes(buffer.filename, 'modification') if not mod_time or not buffer.mod_time then return end if buffer.mod_time < mod_time then buffer.mod_time = mod_time events.emit(events.FILE_CHANGED) end end events_connect(events.BUFFER_AFTER_SWITCH, update_modified_file) events_connect(events.VIEW_AFTER_SWITCH, update_modified_file) events_connect(events.FOCUS, update_modified_file) events_connect(events.RESUME, update_modified_file) -- Prompts the user to reload the current file if it has been externally -- modified. events_connect(events.FILE_CHANGED, function() local msg = ('"%s"\n%s'):format(buffer.filename:iconv('UTF-8', _CHARSET), _L['has been modified. Reload it?']) local button = ui.dialogs.msgbox{ title = _L['Reload?'], text = _L['Reload modified file?'], informative_text = msg, icon = 'gtk-dialog-question', button1 = _L['_Yes'], button2 = _L['_No'] } if button == 1 then io.reload_file() end end) -- Closes the initial "Untitled" buffer. events_connect(events.FILE_OPENED, function() local buf = _BUFFERS[1] if #_BUFFERS == 2 and not (buf.filename or buf._type or buf.modify) then view:goto_buffer(1) io.close_buffer() end end) --- -- Prompts the user to select a recently opened file to be reopened. -- @see recent_files -- @name open_recent_file function io.open_recent_file() local utf8_filenames = {} for _, filename in ipairs(io.recent_files) do utf8_filenames[#utf8_filenames + 1] = filename:iconv('UTF-8', _CHARSET) end local button, i = ui.dialogs.filteredlist{ title = _L['Open'], columns = _L['File'], items = utf8_filenames, width = CURSES and ui.size[1] - 2 or nil } if button == 1 and i then io.open_file(io.recent_files[i]) end end -- List of version control directories. local vcs = {'.bzr', '.git', '.hg', '.svn', 'CVS'} --- -- Returns the root directory of the project that contains filesystem path -- *path*. -- In order to be recognized, projects must be under version control. Recognized -- VCSes are Bazaar, Git, Mercurial, SVN, and CVS. -- @param path Optional filesystem path to a project or a file contained within -- a project. The default value is the buffer's filename or the current -- working directory. -- @return string root or nil -- @name get_project_root function io.get_project_root(path) local root local lfs_attributes = lfs.attributes local dir = path or (buffer.filename or lfs.currentdir()):match('^(.+)[/\\]') while dir do for i = 1, #vcs do if lfs_attributes(dir..'/'..vcs[i], 'mode') == 'directory' then if vcs[i] ~= '.svn' and vcs[i] ~= 'CVS' then return dir end root = dir break end end dir = dir:match('^(.+)[/\\]') end return root end --- -- Map of file paths to filters used by `io.snapopen()`. -- @class table -- @name snapopen_filters -- @see snapopen io.snapopen_filters = {} --- -- Prompts the user to select files to be opened from *paths*, a string -- directory path or list of directory paths, using a filtered list dialog. -- If *paths* is `nil`, uses the current project's root directory, which is -- obtained from `io.get_project_root()`. -- Files shown in the dialog do not match any pattern in either string or table -- *filter* or, unless *exclude_FILTER* is `true`, in `lfs.FILTER`. A filter -- table contains: -- -- + Lua patterns that match filenames to exclude. -- + Optional `folders` sub-table that contains patterns matching directories -- to exclude. -- + Optional `extensions` sub-table that contains raw file extensions to -- exclude. -- + Optional `symlink` flag that when `true`, excludes symlinked files (but -- not symlinked directories). -- + Optional `folders.symlink` flag that when `true`, excludes symlinked -- directories. -- -- Any filter patterns starting with '!' exclude files and directories that do -- not match the pattern that follows. The number of files in the list is capped -- at `SNAPOPEN_MAX`. If *filter* is `nil` and *paths* is ultimately a string, -- the filter from the `io.snapopen_filters` table is used. In that case, unless -- explicitly specified, *exclude_FILTER* becomes `true`. -- *opts* is an optional table of additional options for -- `ui.dialogs.filteredlist()`. -- @param paths Optional string directory path or table of directory paths to -- search. The default value is the current project's root directory, if -- available. -- @param filter Optional filter for files and directories to exclude. The -- default value comes from `io.snapopen_filters` if *paths* is a string. -- @param exclude_FILTER Optional flag indicating whether or not to exclude the -- default filter `lfs.FILTER` in the search. If `false`, adds `lfs.FILTER` to -- *filter*. -- Normally, the default value is `false` to include the default filter. -- However, in the instances where *filter* comes from `io.snapopen_filters`, -- the default value is `true`. -- @param opts Optional table of additional options for -- `ui.dialogs.filteredlist()`. -- @usage io.snapopen(buffer.filename:match('^.+/')) -- list all files in the -- current file's directory, subject to the default filter -- @usage io.snapopen('/project', '!%.lua$') -- list all Lua files in a project -- directory -- @usage io.snapopen('/project', {folders = {'build'}}) -- list all source -- files in a project directory -- @see io.snapopen_filters -- @see lfs.FILTER -- @see SNAPOPEN_MAX -- @see ui.dialogs.filteredlist -- @name snapopen function io.snapopen(paths, filter, exclude_FILTER, opts) if not paths then paths = io.get_project_root() end if not paths then return end if type(paths) == 'string' then if not filter then filter = io.snapopen_filters[paths] if filter and exclude_FILTER == nil then exclude_FILTER = filter ~= lfs.FILTER end end paths = {paths} end local utf8_list = {} for i = 1, #paths do lfs.dir_foreach(paths[i], function(file) if #utf8_list >= io.SNAPOPEN_MAX then return false end file = file:gsub('^%.[/\\]', ''):iconv('UTF-8', _CHARSET) utf8_list[#utf8_list + 1] = file end, filter, exclude_FILTER) end if #utf8_list >= io.SNAPOPEN_MAX then local msg = string.format('%d %s %d', io.SNAPOPEN_MAX, _L['files or more were found. Showing the first'], io.SNAPOPEN_MAX) ui.dialogs.msgbox{ title = _L['File Limit Exceeded'], text = msg, icon = 'gtk-dialog-info' } end local options = { title = _L['Open'], columns = _L['File'], items = utf8_list, button1 = _L['_OK'], button2 = _L['_Cancel'], select_multiple = true, string_output = true, width = CURSES and ui.size[1] - 2 or nil } if opts then for k, v in pairs(opts) do options[k] = v end end local button, files = ui.dialogs.filteredlist(options) if button ~= _L['_OK'] or not files then return end for i = 1, #files do files[i] = files[i]:iconv(_CHARSET, 'UTF-8') end io.open_file(files) end