From 03f0a9a23ac0878c49a221e45e900d6fb692d56c Mon Sep 17 00:00:00 2001 From: Tyler Miller Date: Wed, 9 Oct 2024 05:06:41 -0700 Subject: [PATCH] updates --- Makefile | 7 +- README.md | 175 +++++----- lua/sos/autocmds.lua | 2 +- lua/sos/commands.lua | 2 +- lua/sos/config.lua | 507 ++++++++++++++++++++++++++-- lua/sos/impl.lua | 172 ++++++---- lua/sos/init.lua | 78 ++--- lua/sos/type.lua | 680 ++++++++++++++++++++++++++++++++++++++ lua/sos/util.lua | 4 +- scripts/types.lua | 13 + tests/sos/config_spec.lua | 89 +++++ 11 files changed, 1500 insertions(+), 229 deletions(-) create mode 100644 lua/sos/type.lua create mode 100644 scripts/types.lua create mode 100644 tests/sos/config_spec.lua diff --git a/Makefile b/Makefile index a776f0f..c790926 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Variables: -# DIR: path to dir or file to test (default: all of target's files/tests) -# SEQ/SYNC: if set, run tests sequentially (default: true for benchmarks, otherwise false) +# DIR: path to dir or file to test (default: all of target's files/tests) +# SEQ/SYNC: if set, run tests sequentially (default: true for benchmarks, otherwise false) .PHONY: test t fmt format perf bench checkfmt @@ -39,3 +39,6 @@ perf bench: DIR ::= $(or $(DIR),perf) perf bench: override SEQ ::= true perf bench: @$(run-test) + +types: + @nvim -l scripts/types.lua diff --git a/README.md b/README.md index 3734a77..fde8c82 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# 🆘 sos.nvim 🆘 GitHub release (latest SemVer)FormatCI +# 🆘 sos.nvim 🆘 +GitHub release (latest SemVer)FormatCI Never manually save/write a buffer again! @@ -12,7 +13,7 @@ This plugin is an autosaver for Neovim that automatically saves all of your chan ### Additional Features - Has its own independent timer, distinct from `'updatetime'`, which may be set to any value in ms -- Timer is only started/reset on buffer changes, not cursor movements or other irrelevant events +- Timer is only started/reset on savable buffer changes, not cursor movements or other irrelevant events - Keeps buffers in sync with the filesystem by frequently running `:checktime` in the background for you (e.g. on `CTRL-Z` or suspend, resume, command, etc.) - Intelligently ignores `'readonly'` and other such unwritable buffers/files (i.e. the writing of files with insufficient permissions must be attempted manually with `:w`) @@ -34,99 +35,88 @@ After doing this, you will need to then either restart Neovim or execute `:Plug ## Setup/Options -Listed below are all of the possible options that can be configured along with their default values. Missing options will retain their current value (which will be their default value if never previously set, or if this is the first time calling `setup()` in this Neovim session). This means that `setup()` can be used later on to change just a single option while not touching/changing/resetting to default any of the other options. You can also pass `true` as a 2nd argument to `setup()` (i.e. `setup(opts, true)`) to reset all options to their default values before applying `opts`. If the plugin is started during Neovim's startup/init phase, the plugin will wait until Neovim has finished initializing before setting up its buffer and option observers (autocmds, buffer callbacks, etc.). +Listed below are all of the possible options that can be configured along with their default values. Missing options will use their default value. If the plugin is started during Neovim's startup/init phase, the plugin will wait until Neovim has finished initializing before setting up its buffer and option observers (autocmds, buffer callbacks, etc.). ```lua require("sos").setup { - -- Whether to enable the plugin - enabled = true, - - -- Time in ms after which `on_timer()` will be called. By default, `on_timer()` - -- is called 10 seconds after the last buffer change. Whenever an observed - -- buffer changes, the global timer is started (or reset, if it was already - -- started), and a countdown of `timeout` milliseconds begins. Further buffer - -- changes will then debounce the timer. After firing, the timer is not - -- started again until the next buffer change. - timeout = 10000, - - -- Set, and manage, Vim's 'autowrite' option (see :h 'autowrite'). Allowing - -- sos to "manage" the option makes it so that all autosaving functionality - -- can be enabled or disabled altogether in a synchronized fashion as - -- otherwise it is possible for autosaving to still occur even after sos has - -- been explicitly disabled (via :SosDisable for example). There are 3 - -- possible values: - -- - -- "all": set and manage 'autowriteall' - -- - -- true: set and manage 'autowrite' - -- - -- false: don't set, touch, or manage any of Vim's 'autowwrite' options - autowrite = true, - - -- Automatically write all modified buffers before executing a command on - -- the cmdline. Aborting the cmdline (e.g. via ``) also aborts the - -- write. The point of this is so that you don't have to manually write a - -- buffer before running commands such as `:luafile`, `:soruce`, or a `:!` - -- shell command which reads files (such as git or a code formatter). - -- Autocmds will be executed as a result of the writing (i.e. `nested = true`). - -- - -- false: don't write changed buffers prior to executing a command - -- - -- "all": write on any `:` command that gets executed (but not `` - -- mappings) - -- - -- "some": write only if certain commands (source/luafile etc.) appear - -- in the cmdline (not perfect, but may lead to fewer unneeded - -- file writes; implementation still needs some work, see - -- lua/sos/impl.lua) - -- - -- table: table that specifies which commands should trigger - -- a write - -- keys: the full/long names of commands that should - -- trigger write - -- values: true - save_on_cmd = "some", - - -- Save/write a changed buffer before leaving it (i.e. on the `BufLeave` - -- autocmd event). This will lead to fewer buffers having to be written - -- at once when the global/shared timer fires. Another reason for this is - -- the fact that neither `'autowrite'` nor `'autowriteall'` cover this case, - -- so it combines well with those options too. - save_on_bufleave = true, - - -- Save all buffers when Neovim loses focus. This is provided because - -- 'autowriteall' does not cover this case. It is particularly useful when - -- swapfiles have been disabled and you (knowingly or unknowingly) start - -- editing the same file in another Neovim instance while having unsaved - -- changes. It helps keep the file/version on the filesystem synchronized - -- with your latest changes when switching applications so that another - -- application won't accidentally open old versions of files that you are - -- still currently editing. Con: it could be that you actually intended to - -- open an older version of a file in another application/Neovim instance, - -- although in that case you're probably better off disabling autosaving - -- altogether (or keep it enabled but utilize a VCS to get the version you - -- need - that is, if you commit frequently enough). This option also enables - -- saving on suspend. - save_on_focuslost = true, - - -- Predicate fn which receives a buf number and should return true if it - -- should be observed for changes (i.e. whether the buffer should debounce - -- the shared/global timer). You probably don't want to change this unless - -- you absolutely need to and know what you're doing. Setting this option - -- will replace the default fn/behavior which is to observe buffers which - -- have: a normal 'buftype', 'ma', 'noro'. See lua/sos/impl.lua for the - -- default behavior/fn. - ---@type fun(bufnr: integer): boolean - -- should_observe_buf = require("sos.impl").should_observe_buf, - - -- The function that is called when the shared/global timer fires. You - -- probably don't want to change this unless you absolutely need to and know - -- what you're doing. Setting this option will replace the default - -- fn/behavior, which is simply to write all modified (i.e. 'mod' option is - -- set) buffers. See lua/sos/impl.lua for the default behavior/fn. Any value - -- returned by this function is ignored. `vim.api.*` can be used inside this - -- fn (this fn will be called with `vim.schedule()`). - -- on_timer = require("sos.impl").on_timer, + ---Whether to enable or disable the plugin. + enabled = true, + + ---Timeout in milliseconds for the global timer. Buffer changes debounce the + ---timer. + timeout = 10000, + + ---Automatically create missing parent directories when writing/autosaving a + ---buffer. + create_parent_dirs = true, + + ---Set and manage Vim's 'autowrite' option. + autowrite = true, + + ---Save all buffers before executing a command on the cmdline + save_on_cmd = 'some', + + ---Save all buffers when Neovim loses focus or is suspended. + save_on_focuslost = true, + + ---Save current buffer on `BufLeave` (see `:h BufLeave`) + save_on_bufleave = true, + + should_save = { + ---Whether to autosave buffers which aren't modifiable. + ---See `:help 'modifiable'`. + unmodifiable = true, + + ---How to handle `acwrite` type buffers (i.e. where `vim.bo.buftype == "acwrite"` + ---or the buffer's name is a URI). These buffers use an autocmd to perform + ---special actions and side-effects when saved/written. + acwrite = { + ---Whether to autosave buffers which process the file on save/write. + ---E.g. `tar`, `zip`, `gzip` + compress = true, + + ---Whether to autosave buffers which perform network actions (such as + ---sending a request) on save/write. E.g. `scp`, `http` + net = true, + + ---Whether to autosave buffers which perform git actions (such as staging + ---buffer content) on save/write. E.g. `fugitive`, `diffview`, `gitsigns` + git = true, + + ---Whether to autosave `acwrite` buffers which don't match any of the other + ---acwrite criteria/filters. + other = true, + + ---URI schemes to allow/disallow autosaving for. If a scheme is set to `false`, + ---any buffer whose name begins with that scheme will not be autosaved. Provided + ---schemes should be lowercase and will be matched case-insensitively. Schemes + ---take precedence over other `acwrite` filters. + --- + ---Example: + --- + ---```lua + ---schemes = { http = false, octo = false, file = true } + ---``` + schemes = { + ---Octo buffers are disabled by default as they can create new issues, + ---PR's, and comments on write/save. + octo = false, + term = false, + file = true, + }, + }, + }, + + hooks = { + ---A function – or any other callable value – which is called just before + ---writing/autosaving a buffer. If `false` is returned, the buffer will not + ---be written. + buf_autosave_pre = function(bufnr, bufname) end, + + ---A function – or any other callable value – which is called just after + ---writing/autosaving a buffer (even if the write failed). + buf_autosave_post = function(bufnr, bufname, errmsg) end, + }, } ``` @@ -137,6 +127,7 @@ All of the available commands are defined [here](/lua/sos/commands.lua). ## Tips - Decrease the `timeout` value if you'd like more frequent/responsive autosaving behavior (e.g. `10000` for 10 seconds, or `5000` for 5 seconds). It's probably best not to go below 5 seconds however. +- Disable swapfiles. ## FAQ diff --git a/lua/sos/autocmds.lua b/lua/sos/autocmds.lua index e5830d5..fd050fe 100644 --- a/lua/sos/autocmds.lua +++ b/lua/sos/autocmds.lua @@ -7,7 +7,7 @@ local augroup = 'sos-autosaver' function M.clear() api.nvim_create_augroup(augroup, { clear = true }) end ---Update defined autocmds according to `cfg` ----@param cfg sos.Config +---@param cfg sos.config.opts ---@return nil function M.refresh(cfg) api.nvim_create_augroup(augroup, { clear = true }) diff --git a/lua/sos/commands.lua b/lua/sos/commands.lua index 6faa709..7b7dc9e 100644 --- a/lua/sos/commands.lua +++ b/lua/sos/commands.lua @@ -101,7 +101,7 @@ return setmetatable( nargs = 0, force = true, function() - if require('sos.config').enabled then + if require('sos.config').opts.enabled then require('sos').disable(true) else require('sos').enable(true) diff --git a/lua/sos/config.lua b/lua/sos/config.lua index 221f173..a1688cf 100644 --- a/lua/sos/config.lua +++ b/lua/sos/config.lua @@ -1,22 +1,489 @@ ----@class sos.Config # Plugin options passed to `setup()`. ----@field enabled? boolean # Whether to enable or disable the plugin. ----@field timeout? integer # Timeout in ms. Buffer changes debounce the timer. ----@field autowrite? boolean | "all" # Set and manage Vim's 'autowrite' option. ----@field save_on_cmd? "all" | "some" | table | false # Save all buffers before executing a command on cmdline ----@field save_on_bufleave? boolean # Save current buffer on `BufLeave` (see `:h BufLeave`) ----@field save_on_focuslost? boolean # Save all bufs when Neovim loses focus or is suspended. ----@field should_observe_buf? fun(buf: integer): boolean # Return true to observe/attach to buf. ----@field on_timer? function # The function to call when the timer fires. - -local defaults = { - enabled = true, - timeout = 10000, - autowrite = true, - save_on_cmd = 'some', - save_on_bufleave = true, - save_on_focuslost = true, - should_observe_buf = require('sos.impl').should_observe_buf, - on_timer = require('sos.impl').on_timer, +local Type = require 'sos.type' +local util = require 'sos.util' + +---@class sos.config +local M = {} + +---@alias sos.Callable table + +-- BEGIN TYPES + +---@class (exact) sos.config.opts +---Timeout in milliseconds for the global timer. Buffer changes debounce the +---timer. +---@field timeout? integer +---Save all buffers when Neovim loses focus or is suspended. +---@field save_on_focuslost? boolean +---Automatically create missing parent directories when writing/autosaving a +---buffer. +---@field create_parent_dirs? boolean +---Set and manage Vim's 'autowrite' option. +---@field autowrite? boolean|"all" +---Save current buffer on `BufLeave` (see `:h BufLeave`) +---@field save_on_bufleave? boolean +---Whether to enable or disable the plugin. +---@field enabled? boolean +---Save all buffers before executing a command on the cmdline +---@field save_on_cmd? "all"|"some"|table|false +---@field should_save? sos.config.opts.should_save +---@field hooks? sos.config.opts.hooks + +---@class (exact) sos.config.opts.hooks +---A function – or any other callable value – which is called just before +---writing/autosaving a buffer. If `false` is returned, the buffer will not be +---written. +---@field buf_autosave_pre? sos.Callable|fun(bufnr: integer, bufname: string): boolean? +---A function – or any other callable value – which is called just after +---writing/autosaving a buffer (even if the write failed). +---@field buf_autosave_post? sos.Callable|fun(bufnr: integer, bufname: string, errmsg?: string) + +---@class (exact) sos.config.opts.should_save +---Whether to autosave buffers which aren't modifiable. See `:help 'modifiable'`. +---@field unmodifiable? boolean +---How to handle `acwrite` type buffers (i.e. where `vim.bo.buftype == "acwrite"` +---or the buffer's name is a URI). These buffers use an autocmd to perform +---special actions and side-effects when saved/written. +---@field acwrite? sos.config.opts.should_save.acwrite + +---How to handle `acwrite` type buffers (i.e. where `vim.bo.buftype == "acwrite"` +---or the buffer's name is a URI). These buffers use an autocmd to perform +---special actions and side-effects when saved/written. +---@class (exact) sos.config.opts.should_save.acwrite +---Whether to autosave buffers which process the file on save/write. E.g. `tar`, +---`zip`, `gzip` +---@field compress? boolean +---Whether to autosave buffers which perform network actions (such as sending a +---request) on save/write. E.g. `scp`, `http` +---@field net? boolean +---Whether to autosave buffers which perform git actions (such as staging buffer +---content) on save/write. E.g. `fugitive`, `diffview`, `gitsigns` +---@field git? boolean +---Whether to autosave `acwrite` buffers which don't match any of the other +---acwrite criteria/filters. +---@field other? boolean +---URI schemes to allow/disallow autosaving for. If a scheme is set to `false`, +---any buffer whose name begins with that scheme will not be autosaved. Provided +---schemes should be lowercase and will be matched case-insensitively. Schemes +---take precedence over other `acwrite` filters. +--- +---Example: +--- +---```lua +---schemes = { http = false, octo = false, file = true } +---``` +---@field schemes? table + +-- END TYPES + +-- {{{ +-- local defaults = { +-- enabled = true, +-- timeout = 10000, +-- autowrite = true, +-- save_on_cmd = 'some', +-- save_on_bufleave = true, +-- save_on_focuslost = true, +-- should_observe_buf = require('sos.impl').should_observe_buf, +-- on_timer = require('sos.impl').on_timer, +-- create_parent_dirs = true, +-- +-- include = {}, +-- exclude = {}, +-- +-- should_save_buf = { +-- ---Whether to allow/disallow autosaving for buffers where `vim.bo.modifiable +-- ---== false`. See `:help 'modifiable'`. +-- unmodifiable = true, +-- +-- ---How to handle `acwrite` type buffers (i.e. where `vim.bo.buftype == +-- ---"acwrite"` or the buffer's name is a URI). These buffers use an autocmd +-- ---to perform special actions and side-effects when saved/written. +-- acwrite = { +-- ---Whether to allow/disallow autosaving for `acwrite` buffers which don't +-- ---match any of the other criteria/filters. +-- other = true, +-- +-- ---Buffers which perform network actions (such as sending a request) on +-- ---save/write. E.g. `scp`, `ssh`, `http` +-- net = true, +-- +-- ---Buffers which perform git actions (such as staging buffer content) on +-- ---save/write. E.g. `fugitive`, `diffview`, `gitsigns` +-- git = true, +-- +-- ---Buffers which automatically compress the file on save/write. E.g. +-- ---`tar`, `zip`, `gzip` +-- compress = true, +-- +-- ---Particular URI schemes to allow/disallow autosaving for. The scheme is +-- ---parsed and obtained from the buffer's name. All provided schemes should +-- ---be lowercase and will be matched case-insensitively. Schemes take +-- ---precedence over other `acwrite` filters. +-- ---@type table +-- scheme = { +-- ---Octo buffers are disabled by default as they can create new +-- ---issues, PR's, and comments on write/save. +-- octo = false, +-- term = false, +-- file = true, +-- }, +-- }, +-- }, +-- } +-- }}} + +local should_save = Type.Table { + unmodifiable = Type.Boolean { + default = true, + desc = [[ + Whether to autosave buffers which aren't modifiable. See `:help 'modifiable'`. +]], + }, + + acwrite = Type.Table({ + desc = [[ + How to handle `acwrite` type buffers (i.e. where `vim.bo.buftype == "acwrite"` + or the buffer's name is a URI). These buffers use an autocmd to perform + special actions and side-effects when saved/written. +]], + }, { + other = Type.Boolean { + default = true, + desc = [[ + Whether to autosave `acwrite` buffers which don't match any of the other + acwrite criteria/filters. +]], + }, + + net = Type.Boolean { + default = true, + desc = [[ + Whether to autosave buffers which perform network actions (such as sending a + request) on save/write. E.g. `scp`, `http` +]], + }, + + git = Type.Boolean { + default = true, + desc = [[ + Whether to autosave buffers which perform git actions (such as staging buffer + content) on save/write. E.g. `fugitive`, `diffview`, `gitsigns` +]], + }, + + compress = Type.Boolean { + default = true, + desc = [[ + Whether to autosave buffers which process the file on save/write. E.g. `tar`, + `zip`, `gzip` +]], + }, + + schemes = Type.Map { + keys = Type.String, + values = Type.Boolean, + desc = [[ + URI schemes to allow/disallow autosaving for. If a scheme is set to `false`, + any buffer whose name begins with that scheme will not be autosaved. Provided + schemes should be lowercase and will be matched case-insensitively. Schemes + take precedence over other `acwrite` filters. + + Example: + + ```lua + schemes = { http = false, octo = false, file = true } + ``` +]], + default = { + ---Octo buffers are disabled by default as they can create new issues, + ---PR's, and comments on write/save. + octo = false, + term = false, + file = true, + }, + }, + }), } -return setmetatable({}, { __index = defaults }) +M.def = Type.Table({ luadoc_type_prefix = 'sos.config.opts' }, { + enabled = Type.Boolean { + default = true, + desc = [[Whether to enable or disable the plugin.]], + }, + + timeout = Type.Integer { + default = 10000, + desc = [[ + Timeout in milliseconds for the global timer. Buffer changes debounce the + timer. +]], + }, + + create_parent_dirs = Type.Boolean { + default = true, + desc = [[ + Automatically create missing parent directories when writing/autosaving a + buffer. +]], + }, + + autowrite = Type.Or { + Type.Boolean, + Type.Literal 'all', + default = true, + desc = [[Set and manage Vim's 'autowrite' option.]], + }, + + save_on_cmd = Type.Or { + Type.Literal 'all', + Type.Literal 'some', + Type.Map { + keys = Type.String, + values = Type.Boolean, + -- values = Type.Literal(true), + }, + Type.Literal(false), + default = 'some', + desc = [[Save all buffers before executing a command on the cmdline]], + }, + + save_on_bufleave = Type.Boolean { + default = true, + desc = [[Save current buffer on `BufLeave` (see `:h BufLeave`)]], + }, + + save_on_focuslost = Type.Boolean { + default = true, + desc = [[Save all buffers when Neovim loses focus or is suspended.]], + }, + + should_observe_buf = Type.Function { + deprecated = { + message = '`should_observe_buf` is deprecated, please remove it from your config', + }, + luadoc_type = 'fun(buf: integer): boolean', + desc = [[Return true to observe/attach to buf.]], + }, + + on_timer = Type.Function { + internal = true, + default = require('sos.impl').on_timer, + desc = [[The function to call when the timer fires.]], + }, + + should_save = should_save, + + hooks = Type.Table { + buf_autosave_pre = Type.Callable { + luadoc_type = 'sos.Callable|fun(bufnr: integer, bufname: string): boolean?', + default_text = 'function(bufnr, bufname) end', + default = util.no_op, + desc = [[ + A function – or any other callable value – which is called just before + writing/autosaving a buffer. If `false` is returned, the buffer will not be + written. +]], + }, + + buf_autosave_post = Type.Callable { + luadoc_type = 'sos.Callable|fun(bufnr: integer, bufname: string, errmsg?: string)', + default_text = 'function(bufnr, bufname, errmsg) end', + default = util.no_op, + desc = [[ + A function – or any other callable value – which is called just after + writing/autosaving a buffer (even if the write failed). +]], + }, + }, +}) + +-- vim.print(config:visit({ autowrit = 9 }, 'config')) + +-- local res = {} {{{ +-- for _, a in ipairs(vim.api.nvim_get_autocmds {}) do +-- if a.event:lower():find 'cmd$' then +-- table.insert(res, a.event .. ' ' .. a.pattern) +-- end +-- end +-- vim.fn.setreg('+', table.concat(res, '\n')) +-- +-- BufReadCmd *.shada +-- BufReadCmd *.shada.tmp.[a-z] +-- BufReadCmd *.tar.gz +-- BufReadCmd *.tar +-- BufReadCmd *.lrp +-- BufReadCmd *.tar.bz2 +-- BufReadCmd *.tar.Z +-- BufReadCmd *.tbz +-- BufReadCmd *.tgz +-- BufReadCmd *.tar.lzma +-- BufReadCmd *.tar.xz +-- BufReadCmd *.txz +-- BufReadCmd *.tar.zst +-- BufReadCmd *.tzst +-- BufReadCmd *.aar +-- BufReadCmd *.apk +-- BufReadCmd *.celzip +-- BufReadCmd *.crtx +-- BufReadCmd *.docm +-- BufReadCmd *.docx +-- BufReadCmd *.dotm +-- BufReadCmd *.dotx +-- BufReadCmd *.ear +-- BufReadCmd *.epub +-- BufReadCmd *.gcsx +-- BufReadCmd *.glox +-- BufReadCmd *.gqsx +-- BufReadCmd *.ja +-- BufReadCmd *.jar +-- BufReadCmd *.kmz +-- BufReadCmd *.odb +-- BufReadCmd *.odc +-- BufReadCmd *.odf +-- BufReadCmd *.odg +-- BufReadCmd *.odi +-- BufReadCmd *.odm +-- BufReadCmd *.odp +-- BufReadCmd *.ods +-- BufReadCmd *.odt +-- BufReadCmd *.otc +-- BufReadCmd *.otf +-- BufReadCmd *.otg +-- BufReadCmd *.oth +-- BufReadCmd *.oti +-- BufReadCmd *.otp +-- BufReadCmd *.ots +-- BufReadCmd *.ott +-- BufReadCmd *.oxt +-- BufReadCmd *.potm +-- BufReadCmd *.potx +-- BufReadCmd *.ppam +-- BufReadCmd *.ppsm +-- BufReadCmd *.ppsx +-- BufReadCmd *.pptm +-- BufReadCmd *.pptx +-- BufReadCmd *.sldx +-- BufReadCmd *.thmx +-- BufReadCmd *.vdw +-- BufReadCmd *.war +-- BufReadCmd *.wsz +-- BufReadCmd *.xap +-- BufReadCmd *.xlam +-- BufReadCmd *.xlsb +-- BufReadCmd *.xlsm +-- BufReadCmd *.xlsx +-- BufReadCmd *.xltm +-- BufReadCmd *.xltx +-- BufReadCmd *.xpi +-- BufReadCmd *.zip +-- BufReadCmd man://* +-- BufReadCmd dap-eval://* +-- BufReadCmd index{,.lock} +-- +-- BufWriteCmd *.shada +-- BufWriteCmd *.shada.tmp.[a-z] +-- FileAppendCmd *.shada +-- FileAppendCmd *.shada.tmp.[a-z] +-- +-- FileReadCmd *.shada +-- FileReadCmd *.shada.tmp.[a-z] +-- +-- +-- FileWriteCmd *.shada +-- FileWriteCmd *.shada.tmp.[a-z] +-- SourceCmd *.shada +-- SourceCmd *.shada.tmp.[a-z] }}} + +---@param opts? sos.config.opts +function M.apply(opts) + local preset = { + net = { + schemes = { + dav = true, + davs = true, + dns = true, + ftp = true, + http = true, + https = true, + imap = true, + ldap = true, + mail = true, + mailto = true, + mqtt = true, + pop = true, + rcp = true, + rsync = true, + scp = true, + sftp = true, + smtp = true, + ssh = true, + sshfs = true, + tcp = true, + udp = true, + wss = true, + }, + }, + + compress = { + schemes = { + gz = true, + gzip = true, + tar = true, + tarfile = true, + zipfile = true, + }, + }, + + git = { + schemes = { + fugitive = true, + diffview = true, + gitsigns = true, + }, + }, + } + + M.opts = M.def:eval(opts, { message_prefix = '[sos.nvim]: ' }) --[[@as sos.config.opts]] + + local pred = M.opts.should_save + assert(pred, '[sos.nvim]: internal error: issue with config resolution') + + local schemes_final = pred.acwrite.schemes + assert( + schemes_final, + '[sos.nvim]: internal error: issue with config resolution' + ) + + function M.predicate(bufnr, _bufname, acwrite_buftype, scheme) + if vim.bo[bufnr].ma == false then + if pred.unmodifiable == false then return false end + end + + if scheme then + local should = schemes_final[scheme] + if should ~= nil then return should ~= false end + return pred.acwrite.other + end + + if acwrite_buftype then return pred.acwrite.other end + end + + local function apply_schemes(schemes, enable) + for sch in pairs(schemes) do + if schemes_final[sch] == nil then schemes_final[sch] = enable end + end + end + + apply_schemes(preset.net.schemes, pred.acwrite.net) + apply_schemes(preset.git.schemes, pred.acwrite.git) + apply_schemes(preset.compress.schemes, pred.acwrite.compress) + + for _, k in ipairs(vim.tbl_keys(schemes_final)) do + local lower = k:lower() + if schemes_final[lower] == nil then + schemes_final[lower] = schemes_final[k] + end + end +end + +return M diff --git a/lua/sos/impl.lua b/lua/sos/impl.lua index 8d7ec68..ae3d631 100644 --- a/lua/sos/impl.lua +++ b/lua/sos/impl.lua @@ -30,11 +30,10 @@ local recognized_buftypes = { prompt = false, } ----@param buf integer ----@nodiscard +---@param buftype string ---@return boolean -local function wanted_buftype(buf) - local buftype = vim.bo[buf].bt +---@nodiscard +local function wanted_buftype(buftype) local wanted = recognized_buftypes[buftype] if wanted == nil then @@ -42,13 +41,13 @@ local function wanted_buftype(buf) ('[sos.nvim]: ignoring buf with unknown buftype "%s"'):format(buftype), vim.log.levels.WARN ) + + return false end - return wanted or false + return wanted end -local err - -- TODO: -- * Consider adding :confirm here (e.g. will error otherwise if user didn't set -- :confirm manually/globally) @@ -58,25 +57,48 @@ local err -- :h not-edited. -- * Use ++p flag to auto create parent dirs? Or prompt? ----@return nil -local function write_current_buf() - err = nil +-- local function write_current_buf() +-- err = nil +-- if require('sos.config').opts.hook.buf_autosave_pre(buf) == false then +-- return +-- end +-- +-- local ok, res = pcall( +-- api.nvim_cmd, +-- { cmd = 'write', mods = { silent = true } }, +-- { output = false } +-- ) +-- +-- -- require('sos.config').opts.hooks.buf_autosave_post() +-- if not ok then err = res end +-- end + +---@param bufnr integer +---@param bufname string +---@return boolean success +---@return string? errmsg +---@nodiscard +local function write_buf(bufnr, bufname) + local ok, err + + api.nvim_buf_call(bufnr, function() + if + require('sos.config').opts.hooks.buf_autosave_pre(bufnr, bufname) == false + then + return + end - local ok, res = pcall( - api.nvim_cmd, - { cmd = 'write', mods = { silent = true } }, - { output = false } - ) + ok, err = pcall( + api.nvim_cmd, + { cmd = 'write', mods = { silent = true } }, + { output = false } + ) - if not ok then err = res end -end + if ok then err = nil end + require('sos.config').opts.hooks.buf_autosave_post(bufnr, bufname, err) + end) ----@param buf integer ----@nodiscard ----@return boolean, string? -local function write_buf(buf) - api.nvim_buf_call(buf, write_current_buf) - return not err, err + return ok, err end ---@param buf integer @@ -84,75 +106,82 @@ end ---@return string? errmsg ---@nodiscard function M.write_buf_if_needed(buf) - -- TODO: bufloaded, ignore nomodifiable, acwrite pattern - -- TODO: consider the case where it's not allowed to modify file but is -- allowed to create a file/dirent? + if not vim.o.write then return true end + -- Using values directly from `getbufinfo()` table (from caller) might be a -- little bit faster here, but those values potentially become outdated due to - -- `BufWrite` autocmds? So, we'll just check everything again below and not + -- `BufWrite*` autocmds? So, we'll just check everything again below and not -- assume anything. local bufinfo = vim.fn.getbufinfo(buf)[1] - - -- Invalid buf (wiped by autocmd?) - if not bufinfo then return true end + if not bufinfo then return true end -- Invalid buf (wiped by autocmd?) local name = bufinfo.name if bufinfo.changed == 0 - or not vim.o.write - or vim.bo[buf].ro + or #name == 0 or bufinfo.loaded == 0 - or not wanted_buftype(buf) or bufinfo.variables.sos_ignore - or #name == 0 then return true end + if vim.bo[buf].ro then return true end + local buftype = vim.bo[buf].bt + if not wanted_buftype(buftype) then return true end + + local acwrite_buftype = buftype == 'acwrite' local scheme = util.uri_scheme(name) - if scheme then return write_buf(buf) end - local buftype = vim.bo[buf].bt - if buftype == 'acwrite' then - return write_buf(buf) - elseif buftype == '' then - local stat, _errmsg, errname = uv.fs_stat(name) - - if stat then - -- File exists: only write if it's writeable and not a dir. - if vim.fn.filewritable(name) == 1 and not stat.type:find '^dir' then - return write_buf(buf) - end + if + require('sos.config').predicate(buf, name, acwrite_buftype, scheme) == false + then + return true + end - return true - elseif errname == 'ENOENT' then - -- TODO: Try stat again on error (or certain errors, like if - -- EINTR is possible to observe in lua)? - if name:find '[\\/]$' then - -- Unsure what the user would want here, so just return - -- success and don't write anything. - return true - end + if acwrite_buftype or scheme then return write_buf(buf, name) end + + local stat, _errmsg, errname = uv.fs_stat(name) - local dir = vim.fn.fnamemodify(name, ':h') - local dir_stat, _dir_errmsg, dir_errname = uv.fs_stat(dir) + if stat then + -- File exists: only write if it's writeable and not a dir. + if vim.fn.filewritable(name) == 1 and not stat.type:find '^dir' then + return write_buf(buf, name) + end + + return true + elseif errname == 'ENOENT' then + -- TODO: Try stat again on error (or certain errors, like if EINTR is + -- possible to observe in lua)? + if name:find '[\\/]$' then + -- Unsure what the user would want here, so just return success and don't + -- write anything. + return true + end - if dir_stat then - if vim.fn.filewritable(dir) == 2 then - -- Parent is writeable dir - return write_buf(buf) - end + local dir = vim.fn.fnamemodify(name, ':h') + local dir_stat, _dir_errmsg, dir_errname = uv.fs_stat(dir) - -- Parent dir exists, but isn't writeable. - return true - elseif dir_errname == 'ENOENT' then - if util.to_bool(vim.fn.mkdir(dir, 'p')) then return write_buf(buf) end + if dir_stat then + if vim.fn.filewritable(dir) == 2 then + -- Parent is writeable dir + return write_buf(buf, name) + end - -- Parent dir doesn't exist, failed to create it (e.g. perms). - return true + -- Parent dir exists, but isn't writeable. + return true + elseif dir_errname == 'ENOENT' then + if + require('sos.config').opts.create_parent_dirs + and util.to_bool(vim.fn.mkdir(dir, 'p')) + then + return write_buf(buf, name) end + + -- Parent dir doesn't exist, failed to create it (e.g. perms). + return true end end @@ -167,7 +196,14 @@ function M.should_observe_buf(buf) -- won't fire when unnamed buf becomes named, and even when buf is renamed -- and `BufNew` fires the name will still be the old name (even if using -- vim.api to get the name). - return wanted_buftype(buf) and vim.bo[buf].ma and not vim.bo[buf].ro + + if not wanted_buftype(vim.bo[buf].bt) or vim.bo[buf].ro then return false end + + if not vim.bo[buf].ma then + return require('sos.config').opts.should_save.unmodifiable + end + + return true end ---@return nil diff --git a/lua/sos/init.lua b/lua/sos/init.lua index 172ea15..e7a8c43 100644 --- a/lua/sos/init.lua +++ b/lua/sos/init.lua @@ -51,11 +51,12 @@ TODO: Command/Fn/Opt to enable/disable locally (per buf) local MultiBufObserver = require 'sos.observer' local autocmds = require 'sos.autocmds' -local cfg = require 'sos.config' +local config = require 'sos.config' local util = require 'sos.util' local errmsg = util.errmsg local api = vim.api local augroup_init = 'sos-autosaver.init' +local did_setup = false ---@class sos local mt = { buf_observer = MultiBufObserver:new() } @@ -71,14 +72,14 @@ local function was_reloaded(unset_ok) return m ~= M and m or nil end -local function redirect_call() - local current = was_reloaded() - if current then setmetatable(M, getmetatable(current)) end - return current -end +-- local function redirect_call() +-- local current = was_reloaded() +-- if current then setmetatable(M, getmetatable(current)) end +-- return current +-- end -local function manage_vim_opts(config, plug_enabled) - local aw = config.autowrite +local function manage_vim_opts(plug_enabled) + local aw = config.opts.autowrite if aw == 'all' then vim.o.autowrite = false @@ -109,7 +110,13 @@ local function defer_init() pattern = '*', desc = 'Initialize sos.nvim', once = true, - callback = function() M.setup() end, + callback = function() + if config.opts.enabled then + M.enable(false) + else + M.disable(false) + end + end, }) return true @@ -120,56 +127,39 @@ end ---@param verbose? boolean function mt.enable(verbose) - cfg.enabled = true assert(not was_reloaded()) + if not did_setup then return M.setup { enabled = true } end + config.opts.enabled = true if defer_init() then return end - manage_vim_opts(cfg, true) - autocmds.refresh(cfg) - M.buf_observer:start(cfg) + manage_vim_opts(true) + autocmds.refresh(config.opts) + M.buf_observer:start { + should_observe_buf = require('sos.impl').should_observe_buf, + timeout = config.opts.timeout, + on_timer = config.opts.on_timer, + } if verbose then util.notify 'enabled' end end ---@param verbose? boolean function mt.disable(verbose) - cfg.enabled = false assert(not was_reloaded()) + if not did_setup then return M.setup { enabled = false } end + config.opts.enabled = false if defer_init() then return end - manage_vim_opts(cfg, false) + manage_vim_opts(false) autocmds.clear() M.buf_observer:stop() if verbose then util.notify 'disabled' end end ----Missing keys in `opts` are left untouched and will continue to use their ----current value, or will fallback to their default value if never previously ----set. ----@param opts? sos.Config ----@param reset? boolean Reset all options to their defaults before applying `opts` ----@return nil -function mt.setup(opts, reset) - vim.validate { opts = { opts, 'table', true } } - - if reset then - for _, k in ipairs(vim.tbl_keys(cfg)) do - if rawget(cfg, k) ~= nil then rawset(cfg, k, nil) end - end - end - - if opts then - for k, v in pairs(opts) do - if cfg[k] == nil then - vim.notify( - string.format('[sos.nvim]: unrecognized key in options: %s', k), - vim.log.levels.WARN - ) - else - cfg[k] = vim.deepcopy(v) - end - end - end +---@param opts? sos.config.opts +function mt.setup(opts) + config.apply(opts) + did_setup = true if not defer_init() then - if cfg.enabled then + if config.opts.enabled then M.enable(false) else M.disable(false) @@ -259,7 +249,7 @@ do if old then -- Plugin was reloaded somehow - rawset(cfg, 'enabled', nil) + rawset(config.opts, 'enabled', nil) -- TODO: Forcefully detach buf callbacks? Emit a warning? old.stop() diff --git a/lua/sos/type.lua b/lua/sos/type.lua new file mode 100644 index 0000000..d6a7466 --- /dev/null +++ b/lua/sos/type.lua @@ -0,0 +1,680 @@ +---@class sos.Type +---@field luatype? "number"|"string"|"boolean"|"table"|"function"|"thread"|"userdata" +---@field luadoc_type_prefix? string prefix for generated type/class names +---@field luadoc_type? string|fun(self, ...): string literal type or type name +---@field luadoc_decl? fun(self, ...): string? +---@field desc? string +---@field message_prefix? string +---Value to use when the provided value is missing or `nil`. Defaults to `nil`. +---@field default? any +---@field default_text? string +---When `true`, it is an error for values of this `Type` to be missing or `nil`. +---@field required? boolean +---@field deprecated? unknown +---@field internal? boolean +local Type = { util = {} } +Type.super = Type + +local _overrides = {} + +function Type:__index(k) + local o = _overrides[k] + if o ~= nil then return o end + local super = rawget(self, 'super') + if super == nil then super = Type end + return rawget(super, k) +end + +setmetatable(Type, { + __call = function(self, def) return self:new(def) end, +}) + +-- TODO: cleanup desc, deprecations, error func, check for recursion +-- * Example/defaults generator +-- * Doc generator +-- * LSP type generator +-- * Table type where unknown keys are allowed and use index signature +-- * Fn type with strict typechecking of params? +-- * Ordered keys +-- * A generic method for formatting messages? + +---@diagnostic disable: unused-vararg + +local function extend(...) + local res = {} + for i = 1, select('#', ...) do + for k, v in pairs(select(i, ...)) do + res[k] = v + end + end + + return res +end + +function Type:eval(value, overrides) + if overrides then _overrides = overrides end + local ok, res = pcall(self.visit, self, value) + _overrides = {} + assert(ok, res) + return res +end + +-- Default Implementations + +function Type:__tostring() return self:display() end + +---How to display or describe the type (e.g. in error messages). Should be +---overriden if `self.luatype` is not defined or descriptive enough. +---@return string +function Type:display() return self.luatype end + +function Type:__call(...) return self:call(...) end +function Type:call(...) return self:extend(...) end + +---Caller handles description. +---@return string +function Type:print_default() + if self.default_text then return self.util.trim(self.default_text) end + -- TODO: Better table serialization + return vim.inspect(self:get_default()) + -- return tostring(self:get_default()) +end + +---Visits all types recursively, calling the provided `callback` on each type. +---@param callback fun(self, ...): any? +---@param ... unknown reversed keypath to the type +---@return unknown? +function Type:walk_type(callback, ...) return callback(self, ...) end + +function Type:on_error(msg) vim.api.nvim_err_writeln(msg) end + +---Retrieves or makes the default value for this `Type`. By default, this method +---is called when the provided value is: of the wrong type, missing, or `nil`. +---The default implementation simply returns `self.default`. +---@param ... string|number|nil reversed keypath +function Type:get_default(...) return self.default end + +---Called when the corresponding value is missing or `nil`. Should emit an error +---message if needed. The returned value (if any) will be used instead. The +---default implementation emits an error if `self.required` is truthy and then +---returns the result of `self:get_default(...)`. +---@param ... string|number|nil reversed keypath +---@return unknown? default +function Type:on_nil(...) + if self.required then + self:on_error( + (self.message_prefix or '') + .. self:fmt_keypath(('missing required field: %s'):format((...)), ...) + ) + end + + return self:get_default(...) +end + +---Called when type-checking fails. Should emit an error message if needed. The +---returned value (if any) will be used in-place of the original `value`. +---@param value any the original value received +---@return unknown? final the value to use instead +---@return string? errmsg +function Type:on_mismatch(value, ...) + self:on_error( + (self.message_prefix or '') + .. self:fmt_keypath( + ('type mismatch: got %s (%s), expected %s'):format( + type(value), + type(value) == 'string' and ('%q'):format(value) or value, + self + ), + ... + ) + ) + + return self:get_default(...) +end + +function Type:extend(def) + return Type:new(extend(self, type(def) == 'table' and def or { def })) +end + +---Checks whether the provided `value` is of the expected type. +--- +---Currently, and by default, this returns `false` only when the `value` is +---entirely unusable and should be thrown away completely (i.e. the validity of +---child types is irrelevant and does not propagate to parent types). +---@param value unknown? the value to be checked by this `Type` +---@param ... string|number|nil the reversed keypath to `value` +---@return boolean valid: whether `value` is of the expected type (not including children) +function Type:check(value, ...) return type(value) == self.luatype end + +---The entry point and initial/top-level method called on each `Type` instance +---which in-turn delegates to other methods depending upon the provided, +---corresponding `value`. +---@param value unknown? the value to be checked and resolved by this `Type` +---@param ... string|number|nil the reversed keypath to `value` +---@return unknown? final the final value to be used (after checks and post-processing) +function Type:visit(value, ...) + if value == nil then return self:on_nil(...) end + if self.deprecated then return self:on_deprecation(value, ...) end + if self:check(value, ...) then return self:resolve(value, ...) end + return self:on_mismatch(value, ...) +end + +---@param value unknown +---@param ... string|number|nil the reversed keypath to `value` +---@return unknown? final: the final value to be used +function Type:on_deprecation(value, ...) + if type(self.deprecated) == 'table' and self.deprecated.message then + vim.notify_once( + (self.message_prefix or '') .. self.deprecated.message, + vim.log.levels.WARN + ) + elseif select('#', ...) > 0 then + vim.notify_once( + (self.message_prefix or '') + .. self:fmt_keypath(... .. ' is deprecated', ...), + vim.log.levels.WARN + ) + end +end + +---Called for non-nil values of this type which type-check successfully. This +---method may do additional checks and post-processing on the `value`. For +---example, this method is responsible for type-checking and resolving child +---types/values of aggregate types like `Type.Map` and `Type.Table`. +---@param value unknown +---@param ... string|number|nil the reversed keypath to `value` +---@return unknown? final the final value to be used +function Type:resolve(value, ...) return value end + +---@param def sos.Type +---@return sos.Type +function Type:new(def) + vim.validate { + def = { def, 'table' }, + } + + vim.validate { + ['def.luatype'] = { def.luatype, 'string', true }, + ['def.luadoc_decl'] = { def.luadoc_decl, 'function', true }, + ['def.required'] = { def.required, 'boolean', true }, + -- ['opts.check'] = { def.check, 'function', true }, + -- ['opts.resolve'] = { def.resolve, 'function', true }, + -- ['opts.on_nil'] = { def.on_nil, 'function', true }, + -- ['opts.on_mismatch'] = { def.on_mismatch, 'function', true }, + } + + if not (def.check or def.luatype) then + error 'either `check()` or `luatype` must be passed/defined' + end + + if not (def.display or def.luatype) then + error 'either `display()` or `luatype` must be passed/defined' + end + + if self.deprecated then + if self.required then error 'a deprecated Type cannot be required' end + end + + local luadoc_type = def.luadoc_type + if not luadoc_type then + error '`luadoc_type` must be passed/defined' + elseif not vim.is_callable(luadoc_type) then + vim.validate { ['def.luadoc_type'] = { luadoc_type, 'string' } } + ---@cast luadoc_type string + def.luadoc_type = function() return luadoc_type end + end + + return setmetatable(def, self.super) +end + +-- Types + +---@class sos.Type.Or: sos.Type +---@field [1] sos.Type +---@field [integer] sos.Type +---@field default_from? integer + +---@param def sos.Type.Or +---@return sos.Type.Or +function Type.Or(def) + if def.check == nil then + function def:check(value) + for _, ty in ipairs(self) do + if ty:check(value) then + self._match = ty + return true + end + end + + return false + end + + function def:resolve(value) return self._match:resolve(value) end + end + + if def.luadoc_type == nil then + function def:luadoc_type(...) + local res = {} + for _, ty in ipairs(self) do + table.insert(res, (ty:luadoc_type(...):gsub('fun%b():%s*%S+', '(%0)'))) + end + + return table.concat(res, '|') + end + + function def:display() + local res = {} + for _, ty in ipairs(self) do + table.insert(res, (ty:display():gsub('fun%b():%s*%S+', '(%0)'))) + end + + return table.concat(res, '|') + end + end + + function def:get_default(...) + if self.default then return self.default end + if self.default_from then + return self[self.default_from]:get_default(...) + end + end + + function def:walk_type(callback, ...) + if callback(self, ...) then return end + + for _, v in ipairs(self) do + v:walk_type(callback, ...) + end + end + + return Type:new(def) --[[@as sos.Type.Or]] +end + +-- TODO: how to handle errors/warnings/deprecations emitted by child types? +-- especially key type? + +---@class sos.Type.Map: sos.Type +---@field keys sos.Type +---@field values sos.Type +---@field inherit_metatable? boolean +---@field inherit_defaults? boolean extend the provided value with mappings from the default +---@overload fun(def: sos.Type.Map): sos.Type.Map +Type.Map = Type:new { + inherit_metatable = true, + inherit_defaults = true, + luatype = 'table', + luadoc_type = function(self, ...) + return ('table<%s, %s>'):format( + self.keys:luadoc_type(...), + self.values:luadoc_type(...) + ) + end, + + get_default = function(self, ...) + if self.default ~= nil then + -- TODO: do this in ctor instead? + -- if not self:check(self.default, 'default', ...) then + -- self:on_error( + -- self:fmt_keypath( + -- ('type mismatch: got %s, expected %s'):format( + -- type(self.default), + -- self.luatype + -- ), + -- 'default', + -- ... + -- ) + -- ) + -- + -- return {} + -- end + + local res = {} + for k, v in pairs(self.default) do + k = self.keys:visit(k, self:fmt_key(k) .. '(key)', ...) + if k ~= nil then res[k] = self.values:visit(v, k, ...) end + end + + return res + end + end, + + resolve = function(self, value, ...) + local res = {} + + for k, v in pairs(value) do + k = self.keys:visit(k, self:fmt_key(k) .. '(key)', ...) + if k ~= nil then res[k] = self.values:visit(v, k, ...) end + + -- -- TODO: this skips hook, on_mismatch, etc. + -- if not self.keys:check(k, k, ...) then + -- self.keys:on_error( + -- self:fmt_keypath( + -- ('invalid key type: got %s, expected %s'):format(type(k), self.keys), + -- k, + -- ... + -- ) + -- ) + -- else + -- + -- -- if not self.values:check(v) then + -- -- res[k] = self.values:on_mismatch(v, k, ...) + -- -- else + -- -- res[k] = self.values:resolve(v, k, ...) + -- -- end + -- end + end + + if self.inherit_metatable then setmetatable(res, getmetatable(value)) end + + if self.inherit_defaults then + local default = self:get_default(...) + + if default ~= nil then + for k, v in pairs(default) do + if res[k] == nil then res[k] = v end + end + end + end + + return res + end, + + walk_type = function(self, callback, ...) + if callback(self, ...) then return end + callback(self.keys, ...) + callback(self.values, ...) + end, +} --[[@as sos.Type.Map]] + +---@overload fun(def: sos.Type): sos.Type +Type.Boolean = Type:new { + luatype = 'boolean', + luadoc_type = function() return 'boolean' end, +} + +---@overload fun(def: sos.Type): sos.Type +Type.String = Type:new { + luatype = 'string', + luadoc_type = function() return 'string' end, + -- print_default = function(self) + -- if self.default_text then return self.super.print_default(self) end + -- local default = self:get_default() + -- if default == nil then return self.super.print_default(self) end + -- return ('%q'):format(default) + -- end, +} + +---@overload fun(def: sos.Type): sos.Type +Type.Function = Type:new { + luatype = 'function', + luadoc_type = function() return 'function' end, +} + +---@overload fun(def: sos.Type): sos.Type +Type.Number = + Type:new { luatype = 'number', luadoc_type = function() return 'number' end } + +---@overload fun(def: sos.Type): sos.Type +Type.Integer = Type.Number:extend { + display = function() return 'integer' end, + luadoc_type = function() return 'integer' end, + check = function(self, value) + return Type.check(self, value) and value % 1 == 0 + end, +} + +---@overload fun(def: sos.Type): sos.Type +Type.Callable = Type:new { + display = function() return 'Callable' end, + luadoc_type = function() return 'Callable' end, + check = function(_self, value) return vim.is_callable(value) end, +} + +---@overload fun(def: sos.Type): sos.Type +Type.Literal = Type:new { + display = function(self) + return (type(self[1]) == 'string' and '%q' or '%s'):format(self[1]) + end, + + luadoc_type = function(self) + return (type(self[1]) == 'string' and '%q' or '%s'):format(self[1]) + end, + + check = function(self, value, ...) return value == self[1] end, +} + +---@class sos.Type.Table: sos.Type +---@field ignore_extra_keys? boolean whether unknown keys are allowed +---@field fields? table +---@overload fun(def: sos.Type.Table, fields: table): sos.Type.Table +---@overload fun(fields: table): sos.Type.Table +Type.Table = Type:new { + luatype = 'table', + ignore_extra_keys = false, + luadoc_type = function(self, ...) + if not self.fields then return 'table' end + return table.concat(self.util.tbl_reverse { ... }, '.') + end, + + luadoc_decl = function(self, ...) + if not self.fields then return end + local res = {} + + do + local desc = self:fmt_desc() + if desc then + table.insert(res, desc) + table.insert(res, '\n') + end + end + + table.insert(res, '---@class (exact) ') + table.insert(res, (self:luadoc_type(...))) + table.insert(res, '\n') + + for k, v in pairs(self.fields) do + if not v.deprecated and not v.internal then + local desc = v:fmt_desc() + if desc then + table.insert(res, desc) + table.insert(res, '\n') + end + + table.insert(res, '---@field ') + -- TODO: What if k isn't a string|number? + table.insert(res, k) + if not v.required then table.insert(res, '?') end + table.insert(res, ' ') + table.insert(res, v:luadoc_type(k, ...)) + table.insert(res, '\n') + end + end + + return table.concat(res) + end, + + call = function(self, ...) + local a = select(1, ...) + + if select('#', ...) > 1 then + a.fields = select(2, ...) + else + a = { fields = a } + end + + return self:extend(a) + end, + + get_default = function(self, ...) + if self.default ~= nil then + return vim.deepcopy(self.default, true) + elseif self.fields ~= nil then + local res = {} + + for k, v in pairs(self.fields) do + -- NOTE: Must avoid deprecation check here + -- v:hook(nil, k, ...) + res[k] = v:on_nil(k, ...) + end + + return res + end + end, + + resolve = function(self, value, ...) + if self.fields ~= nil then + local res = {} + + for k, v in pairs(self.fields) do + res[k] = v:visit(value[k], k, ...) + end + + if not self.ignore_extra_keys then + for k in pairs(value) do + if self.fields[k] == nil then + self:on_error( + (self.message_prefix or '') + .. self:fmt_keypath(('unexpected key: %s'):format(k), k, ...) + ) + end + end + end + + return res + end + + return vim.deepcopy(value, true) + end, + + walk_type = function(self, callback, ...) + if callback(self, ...) then return end + + for k, v in pairs(self.fields) do + v:walk_type(callback, k, ...) + end + end, + + print_default = function(self) + if self.default_text or self.default or not self.fields then + return self.super.print_default(self) + end + + local res = { '{' } + for k, v in pairs(self.fields) do + if not v.deprecated and not v.internal then + table.insert(res, '\n') + + local desc = v:fmt_desc() + if desc then + table.insert(res, desc) + table.insert(res, '\n') + end + + -- TODO: What if k isn't a string|number? + local fmt + if type(k) == 'string' then + fmt = '%s = %s,\n' + else + fmt = '[%s] = %s,\n' + end + + table.insert(res, (fmt):format(k, v:print_default())) + end + end + + table.insert(res, '}') + return table.concat(res) + end, +} --[[@as sos.Type.Table]] + +-- Extras + +---@return string luadoc +function Type:to_luadoc() + local declarations, seen = {}, {} + + local function callback(self, ...) + if self.deprecated then return true end + + if self.luadoc_decl and not seen[self] then + seen[self] = true + local decl = self:luadoc_decl(...) + if decl then table.insert(declarations, decl) end + end + end + + if self.luadoc_type_prefix then + self:walk_type(callback, self.luadoc_type_prefix) + else + self:walk_type(callback) + end + + -- local res = {} + -- for k, v in pairs(classes) do + -- table.sort( + -- v, + -- function(a, b) return a:match '---@field.*' < b:match '---@field.*' end + -- ) + -- + -- table.insert( + -- res, + -- ('---@class (exact) %s%s\n%s\n'):format( + -- luadoc_type_prefix, + -- k:gsub('^.', '.%0'), + -- table.concat(v, '\n') + -- ) + -- ) + -- end + + return table.concat(declarations, '\n') +end + +--[[ Formatting/Strings ]] + +function Type:fmt_key(k) + if type(k) == 'string' then + return k + elseif type(k) == 'number' then + -- k = ('[%s]'):format(k) + return k + else + return ('<%s>'):format(k) + end +end + +function Type:fmt_keypath(s, ...) + if select('#', ...) == 0 then return s end + local res = {} + + for i = select('#', ...), 1, -1 do + table.insert(res, self:fmt_key((select(i, ...)))) + end + + return table.concat(res, '.') .. ': ' .. s +end + +---@return string|nil +function Type:fmt_desc() + return self.desc + and (self.desc + :gsub('^%s*', '---') + :gsub('%s+$', '') + :gsub('\n[^%S\n]*', '\n---')) + or nil +end + +--[[ Utils ]] + +function Type.util.trim(s) return (s:gsub('^%s+', ''):gsub('%s+$', '')) end + +function Type.util.tbl_reverse(tbl) + local res = {} + for i = #tbl, 1, -1 do + table.insert(res, tbl[i]) + end + + return res +end + +---@diagnostic enable: unused-vararg +return Type diff --git a/lua/sos/util.lua b/lua/sos/util.lua index 45b68fa..4adab62 100644 --- a/lua/sos/util.lua +++ b/lua/sos/util.lua @@ -1,6 +1,8 @@ local api = vim.api local M = {} +function M.no_op() end + ---Displays an error message. ---@param fmt string ---@param ... unknown fmt arguments @@ -25,7 +27,7 @@ function M.bufnr_to_name(buf) end ---Converts vim boolean to Lua boolean. ----@param val any +---@param val integer|boolean ---@return boolean function M.to_bool(val) return val == 1 or val == true end diff --git a/scripts/types.lua b/scripts/types.lua new file mode 100644 index 0000000..f99c09f --- /dev/null +++ b/scripts/types.lua @@ -0,0 +1,13 @@ +vim.opt.rtp:prepend '.' +local f = assert(io.open('lua/sos/config.lua', 'rb')) + +local s = f:read('*a'):gsub( + '(\n%-%-+%s*BEGIN TYPES[^\r\n]*\r?\n).-(\r?\n%-%-+%s*END TYPES)', + function(a, b) return a .. '\n' .. require('sos.config').def:to_luadoc() .. b end, + 1 +) + +assert(f:close()) + +f = assert(io.open('lua/sos/config.lua', 'wb')) +assert(f:write(s)) diff --git a/tests/sos/config_spec.lua b/tests/sos/config_spec.lua new file mode 100644 index 0000000..efa5d95 --- /dev/null +++ b/tests/sos/config_spec.lua @@ -0,0 +1,89 @@ +local config = require 'sos.config' + +local default_enabled = true +local default_unmodifiable = true +local default_timeout = 1e4 + +local function ifnil(val, default) + if val == nil then + return default + else + return val + end +end + +local function assert_shape(expect) + expect = expect or {} + assert.is_table(config.opts) + assert.is_table(config.opts.should_save) + assert.is_table(config.opts.hooks) + assert.is_not_nil(next(config.opts.hooks)) + + assert.are_equal(ifnil(expect.enabled, default_enabled), config.opts.enabled) + + assert.are_equal( + ifnil(ifnil(expect.should_save, {}).unmodifiable, default_unmodifiable), + config.opts.should_save.unmodifiable + ) + + assert.are_equal(ifnil(expect.timeout, default_timeout), config.opts.timeout) +end + +describe('sos', function() + describe('config', function() + describe('opts', function() + it('should not require any opts to be set (uses defaults)', function() + require('sos').setup() + assert_shape() + + require('sos').setup {} + assert_shape() + end) + + it('should use defaults for missing values', function() + require('sos').setup { timeout = 123456789, should_save = {} } + assert_shape { timeout = 123456789 } + end) + + describe('(if invalid value/type is passed)', function() + it('should emit error msg and then resolve rest of config', function() + vim.v.errmsg = '' + require('sos').setup { + ---@diagnostic disable-next-line: assign-type-mismatch + timeout = 'bad', + should_save = { unmodifiable = false }, + } + + print('vim.v.errmsg:', vim.v.errmsg) + assert.matches('expected %p?integer%p?', vim.v.errmsg) + assert.matches('got %p?string%p?', vim.v.errmsg) + assert_shape { should_save = { unmodifiable = false } } + end) + end) + + describe('(if deprecated value/type is passed)', function() + it('should emit deprecation msg', function() + vim.v.statusmsg = '' + vim.v.warningmsg = '' + vim.v.errmsg = '' + require('sos').setup { + should_observe_buf = true, + should_save = { unmodifiable = not default_unmodifiable }, + } + + print('vim.v.statusmsg:', vim.v.statusmsg) + assert.matches( + '%p?should_observe_buf%p? is deprecated', + vim.v.warningmsg + ) + assert.equals('', vim.v.errmsg) + assert.equals('', vim.v.warningmsg) + assert.is_nil('', config.opts.should_observe_buf) + assert_shape { + should_save = { unmodifiable = not default_unmodifiable }, + } + end) + end) + end) + end) +end)