-- Copyright 2007-2022 Mitchell. 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 [`buffer:save()`](). -- 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 [`buffer:save()`]() and [`buffer:save_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 in order to override the default -- prompt to reload the file. -- Arguments: -- -- * _`filename`_: The filename externally modified. -- @field quick_open_max (number) -- The maximum number of files listed in the quick open dialog. -- The default value is `1000`. module('io')]] -- Events. -- LuaFormatter off local file_io_events = {'file_opened','file_before_save','file_after_save','file_changed'} -- LuaFormatter on for _, v in ipairs(file_io_events) do events[v:upper()] = v end io.quick_open_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. -- * 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]: https://www.gnu.org/software/libiconv/ -- @usage io.encodings[#io.encodings + 1] = 'UTF-32' -- @class table -- @name encodings io.encodings = {'UTF-8', 'ASCII', 'CP1252', 'UTF-16'} --- -- Opens *filenames*, a string filename or list of filenames, or the user-selected filename(s). -- 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. -- @param encodings Optional string encoding or table of encodings file contents are in (one -- encoding per file). If `nil`, encoding auto-detection is attempted via `io.encodings`. -- @see _G.events -- @name open_file function io.open_file(filenames, encodings) assert_type(encodings, 'string/table/nil', 2) if not assert_type(filenames, 'string/table/nil', 1) then filenames = ui.dialogs.fileselect{ title = _L['Open File'], 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 end if type(filenames) == 'string' then filenames = {filenames} end if type(encodings) ~= 'table' then encodings = {encodings} end for i = 1, #filenames do local filename = lfs.abspath((filenames[i]:gsub('^file://', ''))) for _, buf in ipairs(_BUFFERS) do if filename == buf.filename then view:goto_buffer(buf) goto continue end end local text = '' if lfs.attributes(filename) then local f, errmsg = io.open(filename, 'rb') if not f then error(string.format('cannot open %s', errmsg), 2) end text = f:read('a') f:close() if not text then goto continue end -- filename exists, but cannot read it end local buffer = buffer.new() if encodings[i] then buffer.encoding, text = encodings[i], text:iconv('UTF-8', encodings[i]) else -- Try to detect character encoding and convert to UTF-8. local has_zeroes = text:sub(1, 65535):find('\0') for _, encoding in ipairs(io.encodings) do if not has_zeroes or encoding:find('^UTF%-[13][62]') then local ok, conv = pcall(string.iconv, text, 'UTF-8', encoding) if ok then buffer.encoding, text = encoding, conv goto encoding_detected end end end assert(has_zeroes, _L['Encoding conversion failed.']) buffer.encoding = nil -- binary (default was 'UTF-8') end ::encoding_detected:: buffer.code_page = buffer.encoding and buffer.CP_UTF8 or 0 -- Detect EOL mode. local s, e = text:find('\r?\n') if s then buffer.eol_mode = buffer[s ~= e and 'EOL_CRLF' or 'EOL_LF'] end -- Insert buffer text and set properties. buffer:append_text(text) 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. table.insert(io.recent_files, 1, filename) for j = 2, #io.recent_files do if io.recent_files[j] == filename then table.remove(io.recent_files, j) break end end ::continue:: end end -- LuaDoc is in core/.buffer.luadoc. local function reload(buffer) if not buffer then buffer = _G.buffer end if not buffer.filename then return end local f = assert(io.open(buffer.filename, 'rb')) local text = f:read('a') f:close() if buffer.encoding then text = text:iconv('UTF-8', buffer.encoding) end buffer:set_text(text) 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_type(encoding, 'string/nil', 1) local pos, first_visible_line = buffer.current_pos, view.first_visible_line local text = buffer:get_text() if buffer.encoding then text = text:iconv(buffer.encoding, 'UTF-8') if encoding then text = text:iconv(encoding, buffer.encoding) end end if encoding then text = text:iconv('UTF-8', encoding) end buffer:set_text(text) buffer:goto_pos(pos) view.first_visible_line = first_visible_line buffer.encoding = encoding buffer.code_page = buffer.encoding and buffer.CP_UTF8 or 0 end -- LuaDoc is in core/.buffer.luadoc. local function save(buffer) if not buffer then buffer = _G.buffer end if not buffer.filename then buffer:save_as() return end events.emit(events.FILE_BEFORE_SAVE, buffer.filename) local text = buffer:get_text() if buffer.encoding then text = text:iconv(buffer.encoding, 'UTF-8') end assert(io.open(buffer.filename, 'wb')):write(text):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 -- LuaDoc is in core/.buffer.luadoc. local function save_as(buffer, filename) if not buffer then buffer = _G.buffer end local dir, name = (buffer.filename or ''):match('^(.-[/\\]?)([^/\\]*)$') if not assert_type(filename, 'string/nil', 1) then filename = ui.dialogs.filesave{ title = _L['Save File'], with_directory = dir, with_file = name, width = CURSES and ui.size[1] - 2 or nil } if not filename then return end end buffer.filename = filename buffer:save() events.emit(events.FILE_AFTER_SAVE, filename, true) end --- -- Saves all unsaved buffers to their respective files. -- @see buffer.save -- @name save_all_files function io.save_all_files() for _, buffer in ipairs(_BUFFERS) do if buffer.filename and buffer.modify then buffer:save() end end end -- LuaDoc is in core/.buffer.luadoc. local function close(buffer, force) if not buffer then buffer = _G.buffer end if buffer.modify and not force then local filename = buffer.filename or buffer._type or _L['Untitled'] if buffer.filename then filename = filename:iconv('UTF-8', _CHARSET) end local button = ui.dialogs.msgbox{ title = _L['Close without saving?'], text = _L['There are unsaved changes in'], informative_text = filename, icon = 'gtk-dialog-question', button1 = _L['Cancel'], button2 = _L['Close without saving'], width = CURSES and #filename > 40 and ui.size[1] - 2 or nil } if button ~= 2 then return nil end -- do not propagate key command end buffer:delete() return true 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 mod_time and buffer.mod_time and buffer.mod_time < mod_time then buffer.mod_time = mod_time events.emit(events.FILE_CHANGED, buffer.filename) 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) --- -- 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; `nil` otherwise. -- @see buffer.close -- @name close_all_buffers function io.close_all_buffers() events.disconnect(events.BUFFER_AFTER_SWITCH, update_modified_file) while #_BUFFERS > 1 do if not buffer:close() then return nil end end events.connect(events.BUFFER_AFTER_SWITCH, update_modified_file) return buffer:close() -- the last one end -- Sets buffer io methods and the default buffer encoding. events.connect(events.BUFFER_NEW, function() buffer.reload = reload buffer.set_encoding, buffer.encoding = set_encoding, 'UTF-8' buffer.save, buffer.save_as, buffer.close = save, save_as, close end) -- Export for later storage into the first buffer, which does not exist yet. -- Cannot rely on `events.BUFFER_NEW` because init scripts (e.g. menus and key bindings) can -- access buffer functions before the first `events.BUFFER_NEW` is emitted. io._reload, io._save, io._save_as, io._close = reload, save, save_as, close -- Prompts the user to reload the current file if it has been externally modified. events.connect(events.FILE_CHANGED, function(filename) local button = ui.dialogs.msgbox{ title = _L['Reload?'], text = _L['Reload modified file?'], informative_text = string.format('"%s"\n%s', filename:iconv('UTF-8', _CHARSET), _L['has been modified. Reload it?']), -- LuaFormatter icon = 'gtk-dialog-question', button1 = _L['Yes'], button2 = _L['No'], width = CURSES and #filename > 40 and ui.size[1] - 2 or nil } if button == 1 then buffer:reload() end end) -- Closes the initial "Untitled" buffer when another buffer is opened. events.connect(events.FILE_OPENED, function() if #_BUFFERS > 2 then return end local buf = _BUFFERS[1] if not (buf.filename or buf._type or buf.modify) then buf:close() 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_list, i = {}, 1 while i <= #io.recent_files do local filename = io.recent_files[i] if lfs.attributes(filename) then utf8_list[#utf8_list + 1] = io.recent_files[i]:iconv('UTF-8', _CHARSET) i = i + 1 else table.remove(io.recent_files, i) end end local button button, i = ui.dialogs.filteredlist{ title = _L['Open File'], columns = _L['Filename'], items = utf8_list } 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', '_FOSSIL_'} --- -- 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, Fossil, Git, Mercurial, and SVN. -- @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. This parameter -- may be omitted. -- @param submodule Optional flag that indicates whether or not to return the root of the -- current submodule (if applicable). The default value is `false`. -- @return string root or nil -- @name get_project_root function io.get_project_root(path, submodule) if type(path) == 'boolean' then path, submodule = nil, path end if not assert_type(path, 'string/nil', 1) then path = buffer.filename or lfs.currentdir() end local dir = path:match('^(.+)[/\\]?') while dir do for i = 1, #vcs do local mode = lfs.attributes(dir .. '/' .. vcs[i], 'mode') if mode and (submodule or mode == 'directory') then return dir end end dir = dir:match('^(.+)[/\\]') end return nil end --- -- Map of directory paths to filters used by `io.quick_open()`. -- @class table -- @name quick_open_filters -- @see quick_open io.quick_open_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()`. -- String or list *filter* determines which files to show in the dialog, with the default -- filter being `io.quick_open_filters[path]` (if it exists) or `lfs.default_filter`. A filter -- consists of Lua patterns that match file and directory paths to include or exclude. Patterns -- are inclusive by default. Exclusive patterns begin with a '!'. If no inclusive patterns are -- given, any path is initially considered. As a convenience, file extensions can be specified -- literally instead of as a Lua pattern (e.g. '.lua' vs. '%.lua$'), and '/' also matches the -- Windows directory separator ('[/\\]' is not needed). -- The number of files in the list is capped at `quick_open_max`. -- If *filter* is `nil` and *paths* is ultimately a string, the filter from the -- `io.quick_open_filters` table is used. If that filter does not exist, `lfs.default_filter` -- is used. -- *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 include and/or exclude. The -- default value is `lfs.default_filter` unless a filter for *paths* is defined in -- `io.quick_open_filters`. -- @param opts Optional table of additional options for `ui.dialogs.filteredlist()`. -- @usage io.quick_open(buffer.filename:match('^(.+)[/\\]')) -- list all files in the current -- file's directory, subject to the default filter -- @usage io.quick_open(io.get_current_project(), '.lua') -- list all Lua files in the current -- project -- @usage io.quick_open(io.get_current_project(), '!/build') -- list all files in the current -- project except those in the build directory -- @see io.quick_open_filters -- @see lfs.default_filter -- @see quick_open_max -- @see ui.dialogs.filteredlist -- @name quick_open function io.quick_open(paths, filter, opts) if not assert_type(paths, 'string/table/nil', 1) then paths = io.get_project_root() if not paths then return end end if not assert_type(filter, 'string/table/nil', 2) then filter = io.quick_open_filters[paths] or lfs.default_filter end assert_type(opts, 'table/nil', 3) local utf8_list = {} for _, path in ipairs(type(paths) == 'table' and paths or {paths}) do for filename in lfs.walk(path, filter) do if #utf8_list >= io.quick_open_max then break end utf8_list[#utf8_list + 1] = filename:iconv('UTF-8', _CHARSET) end end if #utf8_list >= io.quick_open_max then ui.dialogs.msgbox{ title = _L['File Limit Exceeded'], text = string.format('%d %s %d', io.quick_open_max, _L['files or more were found. Showing the first'], io.quick_open_max), icon = 'gtk-dialog-info' } end local options = { title = _L['Open File'], columns = _L['Filename'], 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, utf8_filenames = ui.dialogs.filteredlist(options) if button ~= _L['OK'] or not utf8_filenames then return end local filenames = {} for i = 1, #utf8_filenames do filenames[i] = utf8_filenames[i]:iconv(_CHARSET, 'UTF-8') end io.open_file(filenames) end