aboutsummaryrefslogtreecommitdiff
path: root/dot_config/herdr/plugins/github/herdr-splits-d880c4924f2b/lua/herdr-splits/conf.lua
blob: d17b920f681623037f9c4d83442aae19716c0744 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
---herdr-splits shared config file (generated artifact) + notation translator.
---Owns everything about `herdr-splits.conf`: path resolution, reading existing
---managed values, translating Neovim key notation to Herdr chord notation,
---and writing the resolved config back (atomically). Requires nothing else
---from this plugin (avoiding a require-cycle with config.lua).
---@class HerdrSplitsConf
local M = {}

-- Neovim modifier prefix -> Herdr modifier name.
local MOD = { C = 'ctrl', M = 'alt', A = 'alt', S = 'shift', D = 'cmd' }
-- Herdr's documented specials: lowercase the terminal key when present.
local SPECIAL = {
  left = 'left', right = 'right', up = 'up', down = 'down',
  enter = 'enter', ['return'] = 'enter', tab = 'tab',
  esc = 'esc', escape = 'esc', space = 'space', backspace = 'backspace',
  del = 'delete', delete = 'delete', home = 'home',
  ['end'] = 'end', pageup = 'pageup', pagedown = 'pagedown',
}

-- Marker line stamped into every generated conf. Its presence means the file
-- is the plugin's own output: setup() is authoritative, so read_managed() does
-- NOT adopt it back (removing an opt reverts to the default on the next start).
local MARKER = '# herdr-splits-generated-v1'

local uv = vim.uv or vim.loop

-- Lazily-resolved cache of the shared config path, so setup() shells out to
-- `herdr plugin config-dir` at most once per process.
local resolved_path

---Resolve the herdr binary: a config.herdr_bin override (when the config
---module is loaded), then HERDR_BIN_PATH, then 'herdr'. pcall-required so
---conf.lua never hard-requires config.lua at load time (cycle-safe).
---@return string
local function resolve_herdr_bin()
  local ok, cfg = pcall(require, 'herdr-splits.config')
  if ok and cfg.herdr_bin then return cfg.herdr_bin end
  return vim.env.HERDR_BIN_PATH or 'herdr'
end

---Best-effort `herdr plugin config-dir herdr-splits` query. Returns the
---directory printed by herdr (trimmed), or nil on any failure / empty output.
---@return string|nil
local function query_config_dir()
  local ok, obj = pcall(vim.system, { resolve_herdr_bin(), 'plugin', 'config-dir', 'herdr-splits' }, { text = true })
  if not ok or not obj then return nil end
  local wok, res = pcall(obj.wait, obj)
  if not wok or not res or res.code ~= 0 then return nil end
  local out = (res.stdout or ''):gsub('^%s+', ''):gsub('%s+$', '')
  return out ~= '' and out or nil
end

---Path to the shared herdr-splits config file (same file the Herdr-side
---scripts read). Precedence: (1) HERDR_SPLITS_CONFIG; (2) HERDR_PLUGIN_CONFIG_DIR
---+ `/herdr-splits.conf`; (3) `herdr plugin config-dir herdr-splits` (via the
---herdr binary) + `/herdr-splits.conf`; (4) XDG_CONFIG_HOME / ~/.config
---fallback. Resolved once and cached, so setup() shells out at most once.
---@return string
function M.path()
  if resolved_path then return resolved_path end
  if vim.env.HERDR_SPLITS_CONFIG and vim.env.HERDR_SPLITS_CONFIG ~= '' then
    resolved_path = vim.env.HERDR_SPLITS_CONFIG
    return resolved_path
  end
  if vim.env.HERDR_PLUGIN_CONFIG_DIR and vim.env.HERDR_PLUGIN_CONFIG_DIR ~= '' then
    resolved_path = vim.env.HERDR_PLUGIN_CONFIG_DIR .. '/herdr-splits.conf'
    return resolved_path
  end
  local dir = query_config_dir()
  if dir then
    resolved_path = dir .. '/herdr-splits.conf'
    return resolved_path
  end
  local xdg = vim.env.XDG_CONFIG_HOME
  local base = (xdg and xdg:sub(1, 1) == '/') and xdg
      or ((vim.env.HOME or '~') .. '/.config')
  resolved_path = base .. '/herdr/plugins/config/herdr-splits/herdr-splits.conf'
  return resolved_path
end

---Translate a Neovim key notation (e.g. `<C-h>`, `<M-Left>`, `h`) into the
---Herdr chord notation the scripts and `herdr pane send-keys` expect
---(e.g. `ctrl+h`, `alt+left`, `h`). Returns nil for modifier-only / empty
---input so callers can fall back to a per-direction default.
---@param nvim_key string
---@return string|nil
function M.to_herdr(nvim_key)
  if type(nvim_key) ~= 'string' or nvim_key == '' then return nil end
  if not nvim_key:match('^<.+>$') then return nvim_key:lower() end -- plain "h" -> "h"
  local inner = nvim_key:sub(2, -2)
  local mods, key = {}, nil
  for part in inner:gmatch('[^-]+') do
    local m = MOD[part:upper()]
    if m then
      mods[#mods + 1] = m
    else
      key = SPECIAL[part:lower()] or part:lower()
    end
  end
  if not key then return nil end
  mods[#mods + 1] = key
  return table.concat(mods, '+')
end

---Parse the shared config file for the managed keys only. When the file
---carries the generated MARKER it is the plugin's own output and is NOT read
---back (setup() is authoritative — removing an opt reverts to default). When
---the MARKER is absent (a legacy/hand-edited conf) the managed values are
---parsed and adopted once; the second return value is true only when at
---least one managed value was found, so the caller can emit a one-time
---migration notice. Returns `({}, false)` for a missing/marker-tagged file;
---last occurrence wins (matches the scripts' `tail -n 1`).
---@return table values, boolean adopted
function M.read_managed()
  local f = io.open(M.path(), 'r')
  if not f then return {}, false end
  local out = { nav_keys = {}, resize_keys = {} }
  for line in f:lines() do
    -- Generated marker anywhere => our own output; do not adopt it back.
    if line:match('^%s*' .. vim.pesc(MARKER) .. '%s*$') then
      f:close()
      return {}, false
    end
    local k, v = line:match('^%s*([%w_]+)%s*=%s*([^%s#]+)')
    if k and v then
      local dir = k:match('^nav_key_(%a+)$')
      if dir then
        out.nav_keys[dir] = v
      else
        dir = k:match('^resize_key_(%a+)$')
        if dir then
          out.resize_keys[dir] = v
        elseif k == 'unzoom_on_nav' then
          out.unzoom_on_nav = (v ~= 'false')
        elseif k == 'nav_at_edge' then
          out.nav_at_edge = (v == 'stop' and 'stop' or 'wrap')
        end
      end
    end
  end
  f:close()
  local adopted = false
  for _, t in pairs({ out.nav_keys, out.resize_keys }) do
    for _ in pairs(t) do adopted = true; break end
  end
  if out.unzoom_on_nav ~= nil or out.nav_at_edge ~= nil then adopted = true end
  return out, adopted
end

---Write the full managed key set to the shared config with a generated-file
---header (including the MARKER), atomically via a unique same-dir temp +
---os.rename so a script never observes a partial write and two concurrent
---Neovim instances don't share a temp inode. Skips the write entirely when the
---content is unchanged (preserves mtime, reduces multi-instance contention).
---Returns (true, nil) on success or (false, err) on failure; callers wrap in
---pcall so a write failure never crashes startup.
---@param resolved table resolved managed config (nav_keys, resize_keys, unzoom_on_nav, nav_at_edge)
---@return boolean ok, string|nil err
function M.write(resolved)
  local path = M.path()
  local lines = {
    MARKER,
    '# herdr-splits plugin config — GENERATED by herdr-splits.nvim setup().',
    '# Regenerated from defaults + setup() opts on every setup(); do not edit by hand.',
    '# A headerless/legacy conf is adopted once on migration (see the startup',
    '# notice), then overwritten thereafter — remove an opt in setup() to revert.',
    'nav_key_left='     .. resolved.nav_keys.left,
    'nav_key_down='     .. resolved.nav_keys.down,
    'nav_key_up='       .. resolved.nav_keys.up,
    'nav_key_right='    .. resolved.nav_keys.right,
    'resize_key_left='  .. resolved.resize_keys.left,
    'resize_key_down='  .. resolved.resize_keys.down,
    'resize_key_up='    .. resolved.resize_keys.up,
    'resize_key_right=' .. resolved.resize_keys.right,
    'unzoom_on_nav='    .. (resolved.unzoom_on_nav == false and 'false' or 'true'),
    'nav_at_edge='      .. (resolved.nav_at_edge == 'stop' and 'stop' or 'wrap'),
  }
  local content = table.concat(lines, '\n') .. '\n'

  -- Skip when unchanged: only when the existing file's bytes match exactly.
  local existing = io.open(path, 'r')
  if existing then
    local prev = existing:read('*a')
    existing:close()
    if prev == content then return true end
  end

  vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p')
  -- Unique same-dir temp so os.rename stays same-filesystem (atomic) and two
  -- Neovim instances don't collide on a shared temp inode.
  local tmp = path .. '.tmp.' .. vim.fn.getpid() .. '.' .. tostring(uv.hrtime())
  local f, err = io.open(tmp, 'w')
  if not f then
    pcall(os.remove, tmp)
    vim.notify('herdr-splits: failed to open conf temp file: ' .. tostring(err), vim.log.levels.WARN)
    return false, err
  end
  local wok, werr = f:write(content)
  -- file:close() flushes buffered output and can itself fail with a delayed
  -- write error (disk full, NFS, quota). Its result MUST be checked before
  -- os.rename: otherwise an incomplete temp replaces the live config and the
  -- atomic-write guarantee is lost. LuaJIT returns true on success or
  -- (nil, err) on a flush failure — same form as file:write, checked here too.
  local cok, cerr = f:close()
  if (not wok) or (not cok) then
    local why = (not wok) and werr or cerr
    pcall(os.remove, tmp)
    vim.notify('herdr-splits: failed to write conf temp file: ' .. tostring(why), vim.log.levels.WARN)
    return false, why
  end
  local ok, rerr = os.rename(tmp, path)
  if not ok then
    pcall(os.remove, tmp)
    vim.notify('herdr-splits: failed to write conf: ' .. tostring(rerr), vim.log.levels.WARN)
  end
  return ok, rerr
end

---Runnable self-check for the notation translator. Asserts on the documented
---mapping table; returns true on success. Intended for headless validation:
---`nvim --headless -c "lua assert(require('herdr-splits.conf')._selfcheck())" -c "qa"`.
---@return boolean
function M._selfcheck()
  local cases = {
    { '<C-h>', 'ctrl+h' }, { '<C-j>', 'ctrl+j' }, { '<C-k>', 'ctrl+k' }, { '<C-l>', 'ctrl+l' },
    { '<M-h>', 'alt+h' }, { '<M-Left>', 'alt+left' }, { '<S-Left>', 'shift+left' },
    { '<D-x>', 'cmd+x' }, { '<C-M-h>', 'ctrl+alt+h' }, { 'h', 'h' }, { '<Esc>', 'esc' },
  }
  for _, c in ipairs(cases) do
    local got = M.to_herdr(c[1])
    assert(got == c[2], ('to_herdr(%q)=%q want %q'):format(c[1], tostring(got), c[2]))
  end
  return true
end

return M