Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a way to cache state between restarts of Neovim #624

Merged
merged 27 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7106e2e
feat: add a way to cache state between restarts of Neovim
PriceHiller Oct 31, 2023
afdd6a7
refactor: use `or` pattern to set default
PriceHiller Nov 2, 2023
3e0c1bf
fix: get deep copy of `State:load` error
PriceHiller Nov 2, 2023
a08cd5a
refactor: use `:catch` & `:finally` in `State:load`
PriceHiller Nov 2, 2023
6161297
refactor: use more idiomatic empty `Promise` creation in `State:load`
PriceHiller Nov 2, 2023
281925c
refactor: make the `State` module more ergonomic for initialization
PriceHiller Nov 2, 2023
4ebb84b
test: add initial tests for the `State` module
PriceHiller Nov 2, 2023
c594f2b
test: validate self-healing capability of `State` module
PriceHiller Nov 2, 2023
0320f93
refactor: return new instance of state from state module
PriceHiller Nov 6, 2023
c299d57
refactor: use `catch` for more consistency with other error handlers
PriceHiller Nov 6, 2023
312d519
refactor: early return to better raise file error in state load
PriceHiller Nov 6, 2023
2e01ff1
style: format state module
PriceHiller Nov 6, 2023
e073ca4
fix: ensure State:load properly manages cache healing
PriceHiller Nov 7, 2023
37b5274
feat: track State:save context in State._ctx
PriceHiller Nov 7, 2023
4130207
test: ensure needed vim stdpaths are created on startup
PriceHiller Nov 7, 2023
76beae3
test: correctly handle async testing of the State module
PriceHiller Nov 7, 2023
d5c7f8f
refactor: use echo_warning for notifications in the State module
PriceHiller Nov 7, 2023
056191f
fix: make chaining off State:save correctly wait until file save
PriceHiller Nov 8, 2023
de1ad28
feat: add synchronous wrappers for State:load & State:sync
PriceHiller Nov 8, 2023
d533595
docs: update annotation for State.new
PriceHiller Nov 8, 2023
389ba27
fix: ensure save state is correctly tracked
PriceHiller Nov 8, 2023
751b5dd
feat: add State:wipe function to State module
PriceHiller Nov 8, 2023
d74f13b
test: update state_spec to use new State functions
PriceHiller Nov 8, 2023
4f5d9c2
test: wait for cache to be fully saved in self-healing test
PriceHiller Nov 8, 2023
a0e8d3e
refactor: rename `State` -> `OrgState` with annotation
PriceHiller Nov 9, 2023
6407b0e
test: remove unnecessary logic in `State` testing
PriceHiller Nov 9, 2023
54bbf88
test: add annotation for `state` in state_spec
PriceHiller Nov 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions lua/orgmode/state/state.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
local utils = require('orgmode.utils')
local Promise = require('orgmode.utils.promise')

local State = { data = {}, _ctx = { loaded = false, curr_loader = nil } }

local cache_path = vim.fs.normalize(vim.fn.stdpath('cache') .. '/org-cache.json', { expand_env = false })
--- Returns the current State singleton
function State:new()
-- This is done so we can later iterate the 'data'
-- subtable cleanly and shove it into a cache
setmetatable(State, {
__index = function(tbl, key)
return tbl.data[key]
end,
__newindex = function(tbl, key, value)
tbl.data[key] = value
end,
})
-- Start trying to load the state from cache as part of initializing the state
self:load()
return self
end

---Save the current state to cache
---@return Promise
function State:save()
--- We want to ensure the state was loaded before saving.
return self:load():next(function(_)
utils.writefile(cache_path, vim.json.encode(State.data)):next(function(_) end, function(err_msg)
PriceHiller marked this conversation as resolved.
Show resolved Hide resolved
vim.schedule_wrap(function()
vim.notify('Failed to save current state! Error: ' .. err_msg, vim.log.levels.WARN, { title = 'Orgmode' })
end)
end)
end, function(_) end)
end

---Load the state cache into the current state
---@return Promise
function State:load()
--- If we currently have a loading operation already running, return that
--- promise. This avoids a race condition of sorts as without this there's
--- potential to have two State:load operations occuring and whichever
--- finishes last sets the state. Not desirable.
if self._ctx.curr_loader ~= nil then
return self._ctx.curr_loader
end

--- If we've already loaded the state from cache we don't need to do so again
if self._ctx.loaded then
return Promise.new(function(resolve, _)
PriceHiller marked this conversation as resolved.
Show resolved Hide resolved
return resolve()
end)
end

self._ctx.curr_loader = utils.readfile(cache_path, { raw = true }):next(
function(data)
local success, decoded = pcall(vim.json.decode, data, {
luanil = { object = true, array = true },
})
self._ctx.curr_loader = nil
if not success then
vim.schedule(function()
vim.notify('State cache load failure, error: ' .. decoded, vim.log.levels.WARN, {
title = 'Orgmode',
})
-- Try to 'repair' the cache by saving the current state
self:save()
end)
end
-- Because the state cache repair happens potentially after the data has
-- been added to the cache, we need to ensure the decoded table is set to
-- empty if we got an error back on the json decode operation.
if type(decoded) ~= 'table' then
decoded = {}
end

self._ctx.loaded = true
-- It is possible that while the state was loading from cache values
-- were saved into the state. We want to preference the newer values in
-- the state and still get whatever values may not have been set in the
-- interim of the load operation.
self.data = vim.tbl_deep_extend('force', decoded, self.data)
return self
end, ---@param err string
function(err)
PriceHiller marked this conversation as resolved.
Show resolved Hide resolved
-- If the file didn't exist then go ahead and save
-- our current cache and as a side effect create the file
if type(err) == 'string' and err:match([[^ENOENT.*]]) then
self:save()
PriceHiller marked this conversation as resolved.
Show resolved Hide resolved
else
-- If the file did exist, something is wrong. Kick this to the top
error(err)
end
end
)

return self._ctx.curr_loader
end

return State:new()
32 changes: 31 additions & 1 deletion lua/orgmode/utils/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ local debounce_timers = {}
local query_cache = {}
local tmp_window_augroup = vim.api.nvim_create_augroup('OrgTmpWindow', { clear = true })

function utils.readfile(file)
function utils.readfile(file, opts)
opts = vim.tbl_deep_extend('keep', opts or {}, { raw = false })
PriceHiller marked this conversation as resolved.
Show resolved Hide resolved
return Promise.new(function(resolve, reject)
uv.fs_open(file, 'r', 438, function(err1, fd)
if err1 then
Expand All @@ -24,6 +25,9 @@ function utils.readfile(file)
if err4 then
return reject(err4)
end
if opts.raw then
return resolve(data)
end
local lines = vim.split(data, '\n')
table.remove(lines, #lines)
return resolve(lines)
Expand All @@ -34,6 +38,32 @@ function utils.readfile(file)
end)
end

function utils.writefile(file, data)
return Promise.new(function(resolve, reject)
uv.fs_open(file, 'w', 438, function(err1, fd)
if err1 then
return reject(err1)
end
uv.fs_fstat(fd, function(err2, stat)
if err2 then
return reject(err2)
end
uv.fs_write(fd, data, nil, function(err3, bytes)
if err3 then
return reject(err3)
end
uv.fs_close(fd, function(err4)
if err4 then
return reject(err4)
end
return resolve(bytes)
end)
end)
end)
end)
end)
end

function utils.open(target)
if vim.fn.executable('xdg-open') == 1 then
return vim.fn.system(string.format('xdg-open %s', target))
Expand Down