Skip to content

Commit e9c08d5

Browse files
feat: add a way to cache state between restarts of Neovim (#624)
Co-authored-by: Kristijan Husak <[email protected]>
1 parent 0f06237 commit e9c08d5

File tree

4 files changed

+342
-1
lines changed

4 files changed

+342
-1
lines changed

lua/orgmode/state/state.lua

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
local utils = require('orgmode.utils')
2+
local Promise = require('orgmode.utils.promise')
3+
4+
---@class OrgState
5+
local OrgState = { data = {}, _ctx = { loaded = false, saved = false, curr_loader = nil, savers = 0 } }
6+
7+
local cache_path = vim.fs.normalize(vim.fn.stdpath('cache') .. '/org-cache.json', { expand_env = false })
8+
9+
---Returns the current OrgState singleton
10+
---@return OrgState
11+
function OrgState.new()
12+
-- This is done so we can later iterate the 'data'
13+
-- subtable cleanly and shove it into a cache
14+
setmetatable(OrgState, {
15+
__index = function(tbl, key)
16+
return tbl.data[key]
17+
end,
18+
__newindex = function(tbl, key, value)
19+
tbl.data[key] = value
20+
end,
21+
})
22+
local self = OrgState
23+
-- Start trying to load the state from cache as part of initializing the state
24+
self:load()
25+
return self
26+
end
27+
28+
---Save the current state to cache
29+
---@return Promise
30+
function OrgState:save()
31+
OrgState._ctx.saved = false
32+
--- We want to ensure the state was loaded before saving.
33+
self:load()
34+
self._ctx.savers = self._ctx.savers + 1
35+
return utils
36+
.writefile(cache_path, vim.json.encode(OrgState.data))
37+
:next(function()
38+
self._ctx.savers = self._ctx.savers - 1
39+
if self._ctx.savers == 0 then
40+
OrgState._ctx.saved = true
41+
end
42+
end)
43+
:catch(function(err_msg)
44+
self._ctx.savers = self._ctx.savers - 1
45+
vim.schedule_wrap(function()
46+
utils.echo_warning('Failed to save current state! Error: ' .. err_msg)
47+
end)
48+
end)
49+
end
50+
51+
---Synchronously save the state into cache
52+
---@param timeout? number How long to wait for the save operation
53+
function OrgState:save_sync(timeout)
54+
OrgState._ctx.saved = false
55+
self:save()
56+
vim.wait(timeout or 500, function()
57+
return OrgState._ctx.saved
58+
end, 20)
59+
end
60+
61+
---Load the state cache into the current state
62+
---@return Promise
63+
function OrgState:load()
64+
--- If we currently have a loading operation already running, return that
65+
--- promise. This avoids a race condition of sorts as without this there's
66+
--- potential to have two OrgState:load operations occuring and whichever
67+
--- finishes last sets the state. Not desirable.
68+
if self._ctx.curr_loader ~= nil then
69+
return self._ctx.curr_loader
70+
end
71+
72+
--- If we've already loaded the state from cache we don't need to do so again
73+
if self._ctx.loaded then
74+
return Promise.resolve(self)
75+
end
76+
77+
self._ctx.curr_loader = utils
78+
.readfile(cache_path, { raw = true })
79+
:next(function(data)
80+
local success, decoded = pcall(vim.json.decode, data, {
81+
luanil = { object = true, array = true },
82+
})
83+
self._ctx.curr_loader = nil
84+
if not success then
85+
local err_msg = vim.deepcopy(decoded)
86+
vim.schedule(function()
87+
utils.echo_warning('OrgState cache load failure, error: ' .. vim.inspect(err_msg))
88+
-- Try to 'repair' the cache by saving the current state
89+
self:save()
90+
end)
91+
end
92+
-- Because the state cache repair happens potentially after the data has
93+
-- been added to the cache, we need to ensure the decoded table is set to
94+
-- empty if we got an error back on the json decode operation.
95+
if type(decoded) ~= 'table' then
96+
decoded = {}
97+
end
98+
99+
-- It is possible that while the state was loading from cache values
100+
-- were saved into the state. We want to preference the newer values in
101+
-- the state and still get whatever values may not have been set in the
102+
-- interim of the load operation.
103+
self.data = vim.tbl_deep_extend('force', decoded, self.data)
104+
return self
105+
end)
106+
:catch(function(err)
107+
-- If the file didn't exist then go ahead and save
108+
-- our current cache and as a side effect create the file
109+
if type(err) == 'string' and err:match([[^ENOENT.*]]) then
110+
self:save()
111+
return self
112+
end
113+
-- If the file did exist, something is wrong. Kick this to the top
114+
error(err)
115+
end)
116+
:finally(function()
117+
self._ctx.loaded = true
118+
self._ctx.curr_loader = nil
119+
end)
120+
121+
return self._ctx.curr_loader
122+
end
123+
124+
---Synchronously load the state from cache if it hasn't been loaded
125+
---@param timeout? number How long to wait for the cache load before erroring
126+
---@return OrgState
127+
function OrgState:load_sync(timeout)
128+
local state
129+
local err
130+
self
131+
:load()
132+
:next(function(loaded_state)
133+
state = loaded_state
134+
end)
135+
:catch(function(reject)
136+
err = reject
137+
end)
138+
139+
vim.wait(timeout or 500, function()
140+
return state ~= nil or err ~= nil
141+
end, 20)
142+
143+
if err then
144+
error(err)
145+
end
146+
147+
if err == nil and state == nil then
148+
error('Did not load OrgState in time')
149+
end
150+
151+
return state
152+
end
153+
154+
---Reset the current state to empty
155+
---@param overwrite? boolean Whether or not the cache should also be wiped
156+
function OrgState:wipe(overwrite)
157+
overwrite = overwrite or false
158+
159+
self.data = {}
160+
self._ctx.curr_loader = nil
161+
self._ctx.loaded = false
162+
self._ctx.saved = false
163+
if overwrite then
164+
state:save_sync()
165+
end
166+
end
167+
168+
return OrgState.new()

lua/orgmode/utils/init.lua

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ local debounce_timers = {}
66
local query_cache = {}
77
local tmp_window_augroup = vim.api.nvim_create_augroup('OrgTmpWindow', { clear = true })
88

9-
function utils.readfile(file)
9+
function utils.readfile(file, opts)
10+
opts = opts or {}
1011
return Promise.new(function(resolve, reject)
1112
uv.fs_open(file, 'r', 438, function(err1, fd)
1213
if err1 then
@@ -24,6 +25,9 @@ function utils.readfile(file)
2425
if err4 then
2526
return reject(err4)
2627
end
28+
if opts.raw then
29+
return resolve(data)
30+
end
2731
local lines = vim.split(data, '\n')
2832
table.remove(lines, #lines)
2933
return resolve(lines)
@@ -34,6 +38,32 @@ function utils.readfile(file)
3438
end)
3539
end
3640

41+
function utils.writefile(file, data)
42+
return Promise.new(function(resolve, reject)
43+
uv.fs_open(file, 'w', 438, function(err1, fd)
44+
if err1 then
45+
return reject(err1)
46+
end
47+
uv.fs_fstat(fd, function(err2, stat)
48+
if err2 then
49+
return reject(err2)
50+
end
51+
uv.fs_write(fd, data, nil, function(err3, bytes)
52+
if err3 then
53+
return reject(err3)
54+
end
55+
uv.fs_close(fd, function(err4)
56+
if err4 then
57+
return reject(err4)
58+
end
59+
return resolve(bytes)
60+
end)
61+
end)
62+
end)
63+
end)
64+
end)
65+
end
66+
3767
function utils.open(target)
3868
if vim.fn.executable('xdg-open') == 1 then
3969
return vim.fn.system(string.format('xdg-open %s', target))

tests/minimal_init.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ function M.setup(plugins)
6565
vim.env.XDG_STATE_HOME = M.root('xdg/state')
6666
vim.env.XDG_CACHE_HOME = M.root('xdg/cache')
6767

68+
local std_paths = {
69+
'cache',
70+
'data',
71+
'config',
72+
}
73+
74+
for _, std_path in pairs(std_paths) do
75+
vim.fn.mkdir(vim.fn.stdpath(std_path), 'p')
76+
end
77+
6878
-- NOTE: Cleanup the xdg cache on exit so new runs of the minimal init doesn't share any previous state, e.g. shada
6979
vim.api.nvim_create_autocmd('VimLeave', {
7080
callback = function()

tests/plenary/state/state_spec.lua

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
local utils = require('orgmode.utils')
2+
---@type OrgState
3+
local state = nil
4+
local cache_path = vim.fs.normalize(vim.fn.stdpath('cache') .. '/org-cache.json', { expand_env = false })
5+
6+
describe('State', function()
7+
before_each(function()
8+
-- Ensure the cache file is removed before each run
9+
state = require('orgmode.state.state')
10+
state:wipe()
11+
vim.fn.delete(cache_path, 'rf')
12+
end)
13+
14+
it("should create a state file if it doesn't exist", function()
15+
local stat = vim.loop.fs_stat(cache_path)
16+
if stat then
17+
error('Cache file existed before it should! Ensure it is deleted before each test run!')
18+
end
19+
20+
-- This creates the cache file on new instances of `State`
21+
state:load()
22+
23+
-- wait until the state has been saved
24+
vim.wait(50, function()
25+
return state._ctx.saved
26+
end, 10)
27+
28+
local stat, err, _ = vim.loop.fs_stat(cache_path)
29+
if not stat then
30+
error(err)
31+
end
32+
end)
33+
34+
it('should save the cache file as valid json', function()
35+
local data = nil
36+
local read_f_err = nil
37+
state:save():next(function()
38+
utils
39+
.readfile(cache_path, { raw = true })
40+
:next(function(state_data)
41+
data = state_data
42+
end)
43+
:catch(function(err)
44+
read_f_err = err
45+
end)
46+
end)
47+
48+
-- wait until the newly saved state file has been read
49+
vim.wait(50, function()
50+
return data ~= nil or read_f_err ~= nil
51+
end, 10)
52+
if read_f_err then
53+
error(read_f_err)
54+
end
55+
56+
local success, decoded = pcall(vim.json.decode, data, {
57+
luanil = { object = true, array = true },
58+
})
59+
if not success then
60+
error('Cache file did not contain valid json after saving! Error: ' .. vim.inspect(decoded))
61+
end
62+
end)
63+
64+
it('should be able to save and load state data', function()
65+
-- Set a variable into the state object
66+
state.my_var = 'hello world'
67+
-- Save the state
68+
state:save_sync()
69+
-- Wipe the variable and "unload" the State
70+
state.my_var = nil
71+
state._ctx.loaded = false
72+
73+
-- Ensure the state can be loaded from the file now by ignoring the previous load
74+
state:load_sync()
75+
-- These should be the same after the wipe. We just loaded it back in from the state cache.
76+
assert.are.equal('hello world', state.my_var)
77+
end)
78+
79+
it('should be able to self-heal from an invalid state file', function()
80+
state:save_sync()
81+
82+
-- Mangle the cache
83+
vim.cmd.edit(cache_path)
84+
vim.api.nvim_buf_set_lines(0, 0, -1, false, { '[ invalid json!' })
85+
vim.cmd.write()
86+
87+
-- Ensure we reload the state from its cache file (this should also "heal" the cache)
88+
state._ctx.loaded = false
89+
state._ctx.saved = false
90+
state:load_sync()
91+
vim.wait(500, function()
92+
return state._ctx.saved
93+
end)
94+
-- Wait a little longer to ensure the data is flushed into the cache
95+
vim.wait(100)
96+
97+
-- Now attempt to read the file and check that it is, in fact, "healed"
98+
local cache_data = nil
99+
local read_f_err = nil
100+
utils
101+
.readfile(cache_path, { raw = true })
102+
:next(function(state_data)
103+
cache_data = state_data
104+
end)
105+
:catch(function(reject)
106+
read_f_err = reject
107+
end)
108+
:finally(function()
109+
read_file = true
110+
end)
111+
112+
vim.wait(500, function()
113+
return cache_data ~= nil or read_f_err ~= nil
114+
end, 20)
115+
116+
if read_f_err then
117+
error('Unable to read the healed state cache! Error: ' .. vim.inspect(read_f_err))
118+
end
119+
120+
local success, decoded = pcall(vim.json.decode, cache_data, {
121+
luanil = { object = true, array = true },
122+
})
123+
124+
if not success then
125+
error(
126+
'Unable to self-heal from an invalid state! Error: '
127+
.. vim.inspect(decoded)
128+
.. '\n\t-> Got cache content as '
129+
.. vim.inspect(cache_data)
130+
)
131+
end
132+
end)
133+
end)

0 commit comments

Comments
 (0)