From 5176a07a729860d0c0cdefe96252fc7ff9e16d43 Mon Sep 17 00:00:00 2001 From: ksyasuda Date: Wed, 19 Feb 2025 02:20:39 -0800 Subject: [PATCH] initial commit --- README.md | 3 + lua/odis/config.lua | 80 ++++++++++++++++ lua/odis/health.lua | 31 ++++++ lua/odis/init.lua | 51 ++++++++++ lua/odis/sources.lua | 221 ++++++++++++++++++++++++++++++++++++++++++ lua/odis/utils.lua | 54 +++++++++++ lua/odis/window.lua | 223 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 663 insertions(+) create mode 100644 README.md create mode 100644 lua/odis/config.lua create mode 100644 lua/odis/health.lua create mode 100644 lua/odis/init.lua create mode 100644 lua/odis/sources.lua create mode 100644 lua/odis/utils.lua create mode 100644 lua/odis/window.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..78f69d1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +
+

ODIS - Open Docs in Split

+
diff --git a/lua/odis/config.lua b/lua/odis/config.lua new file mode 100644 index 0000000..2eaa0b4 --- /dev/null +++ b/lua/odis/config.lua @@ -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 = "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 diff --git a/lua/odis/health.lua b/lua/odis/health.lua new file mode 100644 index 0000000..b42a92a --- /dev/null +++ b/lua/odis/health.lua @@ -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 diff --git a/lua/odis/init.lua b/lua/odis/init.lua new file mode 100644 index 0000000..7b3944a --- /dev/null +++ b/lua/odis/init.lua @@ -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("") + 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 diff --git a/lua/odis/sources.lua b/lua/odis/sources.lua new file mode 100644 index 0000000..bd3e697 --- /dev/null +++ b/lua/odis/sources.lua @@ -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 diff --git a/lua/odis/utils.lua b/lua/odis/utils.lua new file mode 100644 index 0000000..c17f7e3 --- /dev/null +++ b/lua/odis/utils.lua @@ -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 diff --git a/lua/odis/window.lua b/lua/odis/window.lua new file mode 100644 index 0000000..a73c63b --- /dev/null +++ b/lua/odis/window.lua @@ -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