initial commit

This commit is contained in:
ksyasuda 2025-02-19 02:20:39 -08:00
commit 5176a07a72
No known key found for this signature in database
7 changed files with 663 additions and 0 deletions

3
README.md Normal file
View File

@ -0,0 +1,3 @@
<div align="center">
<h1 align="center">ODIS - Open Docs in Split</h1>
</div>

80
lua/odis/config.lua Normal file
View File

@ -0,0 +1,80 @@
-- config.lua
local M = {}
-- Display modes
M.modes = {
float = "float",
buffer = "buffer",
hsplit = "hsplit",
vsplit = "vsplit",
tab = "tab",
}
local defaults = {
display = {
default_mode = M.modes.float,
picker = true,
float = {
maxwidth = 80,
maxheight = 40,
border = "rounded",
title = true,
style = "minimal",
auto_focus = true,
anchor = "bottom_right",
offset = { row = -2, col = -2 },
},
window = {
width = 0.4,
height = 0.25,
position = "bottom|right",
floating = false,
border = "none",
},
},
integrations = {
treesitter = {
enabled = true, -- Enable Treesitter integration
highlight = true, -- Enable syntax highlighting
langs = { -- Language mapping for different doc types
lsp = "markdown",
help = "vimdoc",
-- Remove man from default treesitter langs since it's not commonly available
}
}
},
sources = {
lsp = { enabled = true },
vim = { enabled = true },
man = { enabled = true },
},
priority = { "LSP", "Vim", "Man" },
mappings = {
close = "<leader>dc",
},
}
local valid_positions = {
horizontal = { top = true, bottom = true },
vertical = { left = true, right = true }
}
function M.setup(opts)
-- Validate split positions if provided
if opts and opts.display and opts.display.window and opts.display.window.position then
local pos = opts.display.window.position
if pos:find("|") then
local vsplit, hsplit = pos:match("([^|]+)|([^|]+)")
if not (valid_positions.vertical[vsplit] and valid_positions.horizontal[hsplit]) and
not (valid_positions.horizontal[vsplit] and valid_positions.vertical[hsplit]) then
error("Invalid position combination: " .. pos)
end
end
end
M.options = vim.tbl_deep_extend("force", defaults, opts or {})
end
M.setup()
return M

31
lua/odis/health.lua Normal file
View File

@ -0,0 +1,31 @@
local M = {}
local health = vim.health or require("health")
function M.check()
health.report_start("odis.nvim")
-- Check for required dependencies
if vim.fn.executable("man") == 1 then
health.report_ok("man command is available")
else
health.report_warn("man command not found, man page documentation will be disabled")
end
-- Check LSP
if #vim.lsp.get_active_clients() > 0 then
health.report_ok("LSP clients are active")
else
health.report_info("No active LSP clients found")
end
-- Check UI requirements
local has_notify, _ = pcall(require, "notify")
if has_notify then
health.report_ok("nvim-notify is available")
else
health.report_info("nvim-notify not found, falling back to vim.notify")
end
end
return M

51
lua/odis/init.lua Normal file
View File

@ -0,0 +1,51 @@
local M = {}
local config = require("odis.config")
local window = require("odis.window")
local sources = require("odis.sources")
function M.setup(opts)
config.setup(opts)
if not M._maps_setup then
vim.keymap.set('n', config.options.mappings.close, window.close_window, { silent = true })
M._maps_setup = true
end
M.options = config.options
end
-- Re-export modes
M.modes = config.modes
function M.show_documentation(display_mode, override_word)
local word = override_word or vim.fn.expand("<cword>")
if word == "" then return end
display_mode = display_mode or config.options.display.default_mode
sources.get_documentation(word, function(found_sources)
if #found_sources == 1 or not config.options.display.picker then
local source = found_sources[1]
local handlers = {
LSP = function() sources.display_documentation(source.doc, display_mode, "LSP") end,
Man = function() sources.display_man_page(word, display_mode) end,
Vim = function() sources.display_vim_help(word, display_mode) end,
}
if handlers[source.name] then handlers[source.name]() end
else
vim.ui.select(found_sources, {
prompt = "Select documentation source:",
format_item = function(item) return item.name end,
kind = "documentation_source"
}, function(choice)
if not choice then return end
local handlers = {
LSP = function() sources.display_documentation(choice.doc, display_mode, "LSP") end,
Man = function() sources.display_man_page(word, display_mode) end,
Vim = function() sources.display_vim_help(word, display_mode) end,
}
if handlers[choice.name] then handlers[choice.name]() end
end)
end
end)
end
return M

221
lua/odis/sources.lua Normal file
View File

@ -0,0 +1,221 @@
local M = {}
local config = require("odis.config")
local window = require("odis.window")
local utils = require("odis.utils")
function M.display_documentation(doc_lines, display_mode, source_type)
if not doc_lines or #doc_lines == 0 then return false end
if display_mode == config.modes.float then
return window.display_float(doc_lines, {
title = string.format(" %s Documentation ", source_type),
filetype = source_type:lower(),
})
end
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, doc_lines)
for opt, value in pairs(window.buf_opts) do
vim.api.nvim_buf_set_option(buf, opt, value)
end
vim.api.nvim_buf_set_option(buf, "filetype", source_type:lower())
utils.setup_treesitter(buf, source_type)
return window.create_documentation_window(buf, display_mode, source_type or "generic")
end
function M.display_vim_help(word, display_mode)
if display_mode == config.modes.float then
local ok, _ = pcall(vim.cmd, "help " .. word)
if not ok then return false end
local help_buf = vim.api.nvim_get_current_buf()
local cursor_pos = vim.api.nvim_win_get_cursor(0)
local lines = vim.api.nvim_buf_get_lines(help_buf, 0, -1, false)
vim.cmd("bdelete " .. help_buf)
local win, buf = window.display_float(lines, {
title = " Vim Help: " .. word .. " ",
filetype = "help",
})
if win then
vim.api.nvim_win_set_cursor(win, cursor_pos)
vim.api.nvim_win_call(win, function()
vim.cmd("normal! gg")
vim.cmd("/" .. vim.fn.escape(word, "/\\"))
vim.cmd("normal! zz")
end)
end
return win, buf
end
local buf = vim.api.nvim_create_buf(false, true)
local ok, _ = pcall(vim.cmd, "help " .. word)
if not ok then return false end
local help_buf = vim.api.nvim_get_current_buf()
local cursor_pos = vim.api.nvim_win_get_cursor(0)
local lines = vim.api.nvim_buf_get_lines(help_buf, 0, -1, false)
vim.cmd("bdelete " .. help_buf)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
for opt, value in pairs(window.buf_opts) do
vim.api.nvim_buf_set_option(buf, opt, value)
end
vim.api.nvim_buf_set_option(buf, "filetype", "help")
vim.api.nvim_buf_set_option(buf, "iskeyword", "!-~,^*,^|,^\"")
vim.api.nvim_buf_set_option(buf, "tabstop", 8)
local win = window.create_documentation_window(buf, display_mode, "vim")
if win then
vim.api.nvim_win_set_option(win, "conceallevel", 2)
vim.api.nvim_win_set_option(win, "concealcursor", "nc")
vim.api.nvim_win_set_option(win, "spell", false)
vim.api.nvim_win_set_cursor(win, cursor_pos)
vim.api.nvim_win_call(win, function()
vim.cmd("normal! gg")
vim.cmd("/" .. vim.fn.escape(word, "/\\"))
vim.cmd("normal! zz")
end)
end
return win
end
function M.display_man_page(word, display_mode)
if display_mode == config.modes.float then
vim.cmd("Man " .. word)
local man_buf = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(man_buf, 0, -1, false)
vim.cmd("bdelete")
return window.display_float(lines, {
title = " Man: " .. word .. " ",
filetype = "man",
})
end
local buf = vim.api.nvim_create_buf(false, true)
vim.cmd("Man " .. word)
local man_buf = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(man_buf, 0, -1, false)
vim.cmd("bdelete")
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
for opt, value in pairs(window.buf_opts) do
vim.api.nvim_buf_set_option(buf, opt, value)
end
vim.api.nvim_buf_set_option(buf, "filetype", "man")
return window.create_documentation_window(buf, display_mode, "man")
end
function M.get_documentation(word, callback)
local sources = {}
local opts = config.options.sources
local pending = 0
local completed = false
-- Track enabled sources
local enabled_count = 0
if opts.vim.enabled then enabled_count = enabled_count + 1 end
if opts.man.enabled then enabled_count = enabled_count + 1 end
if opts.lsp.enabled then enabled_count = enabled_count + 1 end
-- Early return if no sources are enabled
if enabled_count == 0 then
vim.notify("No documentation sources are enabled", vim.log.levels.WARN)
return
end
local function process_sources()
if completed and pending == 0 then
if #sources > 0 then
local priority_map = {}
for i, p in ipairs(config.options.priority) do
priority_map[p:lower()] = i
end
table.sort(sources, function(a, b)
return (priority_map[a.name:lower()] or 99) < (priority_map[b.name:lower()] or 99)
end)
callback(sources)
else
vim.notify("No documentation available for '" .. word .. "'", vim.log.levels.INFO)
end
end
end
-- Vim help check
if opts.vim.enabled then
pending = pending + 1
vim.schedule(function()
local temp_buf = vim.api.nvim_create_buf(false, true)
local ok = pcall(function()
vim.api.nvim_set_current_buf(temp_buf)
vim.cmd("help " .. word)
local help_buf = vim.api.nvim_get_current_buf()
if help_buf ~= temp_buf then
local lines = vim.api.nvim_buf_get_lines(help_buf, 0, -1, false)
if #lines > 0 then
table.insert(sources, { name = "Vim", doc = lines })
end
vim.cmd(help_buf .. "bdelete!")
end
end)
vim.cmd(temp_buf .. "bdelete!")
pending = pending - 1
process_sources()
end)
end
-- Man page check
if opts.man.enabled then
pending = pending + 1
vim.fn.jobstart({"man", "-P", "cat", word}, {
stdout_buffered = true,
on_stdout = function(_, data)
if data and #data > 1 then
table.insert(sources, { name = "Man", doc = data })
end
end,
on_exit = function(_, code)
vim.schedule(function()
pending = pending - 1
process_sources()
end)
end
})
end
-- LSP documentation
if opts.lsp.enabled then
local clients = vim.lsp.get_active_clients({ bufnr = 0 })
for _, client in ipairs(clients) do
if client.server_capabilities.hoverProvider then
pending = pending + 1
local params = vim.lsp.util.make_position_params()
vim.lsp.buf_request(0, "textDocument/hover", params, function(_, result)
if result and result.contents then
local doc_lines = vim.lsp.util.convert_input_to_markdown_lines(result.contents)
if doc_lines and #doc_lines > 0 then
table.insert(sources, { name = "LSP", doc = doc_lines })
end
end
pending = pending - 1
process_sources()
end)
break
end
end
end
completed = true
process_sources()
end
return M

54
lua/odis/utils.lua Normal file
View File

@ -0,0 +1,54 @@
local M = {}
local config = require("odis.config")
function M.setup_treesitter(buf, source_type)
if not config.options.integrations.treesitter.enabled then
return
end
-- Skip treesitter for source types that don't need it
if source_type:lower() == "man" then
return
end
local has_ts, ts = pcall(require, "nvim-treesitter")
if not has_ts then
return
end
local lang = config.options.integrations.treesitter.langs[source_type:lower()]
if not lang then
return
end
local ok, parser_ok = pcall(vim.treesitter.language.inspect, lang)
if ok and parser_ok and config.options.integrations.treesitter.highlight then
vim.api.nvim_buf_set_option(buf, "syntax", "") -- Disable regular syntax
pcall(vim.treesitter.start, buf, lang) -- Enable treesitter highlighting
end
end
-- Escape special pattern characters in a string
function M.escape_pattern(text)
return text:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
end
-- Safe string split with fallback
function M.split(str, sep)
if str == nil then return {} end
if sep == nil then sep = "%s" end
local t = {}
for s in string.gmatch(str, "([^"..sep.."]+)") do
table.insert(t, s)
end
return t
end
-- Check if a source type needs syntax highlighting
function M.needs_highlighting(source_type)
return source_type:lower() ~= "man" and
config.options.integrations.treesitter.enabled and
config.options.integrations.treesitter.highlight
end
return M

223
lua/odis/window.lua Normal file
View File

@ -0,0 +1,223 @@
local M = {}
local config = require("odis.config")
local utils = require("odis.utils")
M.state = {
windows = {}, -- Stack of {win = win_id, buf = buf_id, mode = display_mode}
}
M.buf_opts = {
modified = false,
modifiable = false,
buftype = "nofile",
bufhidden = "wipe",
swapfile = false,
buflisted = false,
}
M.win_opts = {
wrap = true,
number = false,
relativenumber = false,
cursorline = false,
signcolumn = "no",
}
function M.track_window(win, buf, display_mode)
if win then
table.insert(M.state.windows, {
win = win,
buf = buf,
mode = display_mode
})
end
end
function M.close_window()
local last = M.state.windows[#M.state.windows]
if not last or not vim.api.nvim_win_is_valid(last.win) then
table.remove(M.state.windows)
return
end
if last.mode == config.modes.buffer then
vim.cmd("buffer #")
else
vim.api.nvim_win_close(last.win, true)
end
table.remove(M.state.windows)
end
function M.calculate_float_position(width, height, cfg)
local editor_height = vim.api.nvim_get_option("lines")
local editor_width = vim.api.nvim_get_option("columns")
local pos = {}
local border_space = cfg.border ~= "none" and 2 or 0
width = width + border_space
height = height + border_space
if cfg.anchor == "cursor" then
pos = {
relative = "cursor",
row = cfg.offset.row,
col = cfg.offset.col,
}
else
pos = {
relative = "editor",
row = 0,
col = 0,
}
if cfg.anchor == "bottom_right" then
pos.row = editor_height - height - 2 + cfg.offset.row
pos.col = editor_width - width - 2 + cfg.offset.col
elseif cfg.anchor == "bottom_left" then
pos.row = editor_height - height - 2 + cfg.offset.row
pos.col = 1 + cfg.offset.col
elseif cfg.anchor == "top_right" then
pos.row = 1 + cfg.offset.row
pos.col = editor_width - width - 2 + cfg.offset.col
elseif cfg.anchor == "top_left" then
pos.row = 1 + cfg.offset.row
pos.col = 1 + cfg.offset.col
end
pos.row = math.max(0, math.min(pos.row, editor_height - height - 2))
pos.col = math.max(0, math.min(pos.col, editor_width - width - 2))
end
return pos
end
function M.display_float(content, opts)
local float_config = config.options.display.float
local buf = vim.api.nvim_create_buf(false, true)
content = type(content) == "string" and vim.split(content, "\n") or content
vim.api.nvim_buf_set_lines(buf, 0, -1, false, content)
for k, v in pairs(M.buf_opts) do
vim.api.nvim_buf_set_option(buf, k, v)
end
local max_content_width = 0
for _, line in ipairs(content) do
max_content_width = math.max(max_content_width, vim.fn.strdisplaywidth(line))
end
local width = math.min(max_content_width + 2, float_config.maxwidth)
local height = math.min(#content, float_config.maxheight)
local pos = M.calculate_float_position(width, height, float_config)
local win = vim.api.nvim_open_win(buf, float_config.auto_focus, {
relative = pos.relative,
width = width,
height = height,
row = pos.row,
col = pos.col,
style = float_config.style,
border = float_config.border,
zindex = 50,
title = (float_config.title and opts and opts.title) and opts.title or nil,
title_pos = "center",
focusable = true,
})
for k, v in pairs(M.win_opts) do
pcall(vim.api.nvim_win_set_option, win, k, v)
end
vim.api.nvim_win_set_option(win, "winhighlight", "NormalFloat:Normal")
if opts and opts.filetype then
vim.api.nvim_buf_set_option(buf, "filetype", opts.filetype)
utils.setup_treesitter(buf, opts.filetype)
end
M.track_window(win, buf, config.modes.float)
return win, buf
end
function M.setup_window_options(win, source_type, buf)
for k, v in pairs(M.win_opts) do
pcall(vim.api.nvim_win_set_option, win, k, v)
end
if config.options.mappings.close then
vim.keymap.set("n", config.options.mappings.close, M.close_window, { buffer = buf, silent = true })
end
M.track_window(win, buf, source_type)
end
function M.get_split_position(display_mode, window_config)
local position = window_config.position
if position:find("|") then
local pos1, pos2 = position:match("([^|]+)|([^|]+)")
if display_mode == config.modes.vsplit then
return pos1 == "left" or pos1 == "right" and pos1 or pos2
else
return pos1 == "top" or pos1 == "bottom" and pos1 or pos2
end
end
return position
end
function M.create_documentation_window(buf, display_mode, source_type)
local window_config = config.options.display.window
local cur_win = vim.api.nvim_get_current_win()
local win
if display_mode == config.modes.buffer then
M.state.origin_buf = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(cur_win, buf)
win = cur_win
elseif display_mode == config.modes.tab then
vim.cmd("tabnew")
win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, buf)
else
local dimensions = {
width = math.floor(vim.api.nvim_win_get_width(cur_win) * window_config.width),
height = math.floor(vim.api.nvim_win_get_height(cur_win) * window_config.height)
}
if window_config.floating and window_config.border and window_config.border ~= "none" then
win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = dimensions.width,
height = dimensions.height,
border = window_config.border,
style = 'minimal',
title = " Documentation ",
title_pos = "center",
})
else
local position = M.get_split_position(display_mode, window_config)
local split_cmd = display_mode == config.modes.vsplit and
(position == "right" and "rightbelow vsplit" or "leftabove vsplit") or
(position == "bottom" and "rightbelow split" or "leftabove split")
vim.cmd(split_cmd)
win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, buf)
local resize_cmd = display_mode == config.modes.vsplit and
"vertical resize " .. dimensions.width or
"resize " .. dimensions.height
vim.cmd(resize_cmd)
for k, v in pairs(M.win_opts) do
pcall(vim.api.nvim_win_set_option, win, k, v)
end
end
end
M.setup_window_options(win, display_mode, buf)
return win
end
return M