diff --git a/README.md b/README.md index 9c0792b..a7eb13a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This plugin helps you to set up: - **LSP support** for GDScript and Godot shaders (`.gdshader` files) - **Godot class docs** in Neovim, rendered from the official docs source as Markdown - **Debugging** via `nvim-dap` for GDScript +- **Optional live run output** for `:GodotRun*` commands in a Neovim buffer or float - **Treesitter syntax highlighting** for Godot shader files - **Automatic formatting** of `.gd` files using `gdscript-formatter` - **Optional C# support** (user-managed LSP, plus debugging and tooling checks) @@ -169,6 +170,21 @@ require("godotdev").setup({ inline_hints = { enabled = false, -- enable Neovim inlay hints when the attached server supports them }, + run = { + console = { + enabled = false, -- capture :GodotRun* output in Neovim; these runs are no longer detached + renderer = "buffer", -- "buffer" | "float" + buffer = { + position = "bottom", -- "right" | "bottom" | "current" + size = 0.3, + }, + float = { + width = 0.8, + height = 0.25, + border = "rounded", + }, + }, + }, editor_server = { address = nil, -- nil uses the current server or the platform default remove_stale_socket = true, @@ -232,6 +248,7 @@ treesitter = { Default notes: - `autostart_editor_server = false` is the safer default because starting a Neovim server is an external-editor concern and should be opt-in. - `inline_hints.enabled = false` is the safer default because Godot's LSP support for inlay hints may vary by version and filetype. +- `run.console.enabled = false` is the safer default because live console capture changes `:GodotRun*` from detached launches to attached subprocesses managed by Neovim. - `treesitter.auto_setup = true` stays enabled by default for convenience, but it is safe to turn off if you already configure `nvim-treesitter` yourself. - `docs.fallback_renderer = "browser"` remains the default because browser fallback is the only option that can recover when rendered `.rst` docs cannot be fetched. - The plugin uses Neovim's built-in LSP APIs; `nvim-lspconfig` is not required unless you want it for other servers in your own config. @@ -357,6 +374,13 @@ Notes: - These commands shell out to `godot` on your `PATH`. - `:GodotRunScenePicker` requires Telescope to be installed; the rest do not. +Optional console capture: +- Set `run.console.enabled = true` to capture stdout/stderr from `:GodotRun*` inside Neovim. +- Choose `run.console.renderer = "buffer"` for a split buffer or `"float"` for a floating window. +- Use `:GodotShowConsole` to reopen the most recent captured console window. +- While console capture is enabled, the launched Godot process is managed by Neovim instead of using the plugin's detached launch path. +- This first implementation captures one active Godot run at a time; starting another captured run while one is still active shows a warning. + ## Godot class docs Open the official Godot class reference from Neovim: diff --git a/doc/godotdev.txt b/doc/godotdev.txt index 0f7d845..fcac03d 100644 --- a/doc/godotdev.txt +++ b/doc/godotdev.txt @@ -9,6 +9,7 @@ game development using Neovim as an external editor. It provides: - LSP support for GDScript and .gdshader files - Optional inline hints via Neovim's built-in inlay hint API when supported by the attached Godot LSP client - Debugging via nvim-dap +- Optional live run output for `:GodotRun*` commands in a Neovim buffer or float - Treesitter syntax highlighting - Optional C# support (user-managed LSP) with dotnet, csharp-ls/OmniSharp, and netcoredbg - Autoformatting `.gd` files with `gdscript-formatter` @@ -49,6 +50,21 @@ Example setup: inline_hints = { enabled = false, -- enable Neovim inlay hints when the attached server supports them }, + run = { + console = { + enabled = false, -- capture :GodotRun* output in Neovim; these runs are no longer detached + renderer = "buffer", -- "buffer" | "float" + buffer = { + position = "bottom", -- "right" | "bottom" | "current" + size = 0.3, + }, + float = { + width = 0.8, + height = 0.25, + border = "rounded", + }, + }, + }, editor_server = { address = nil, -- nil uses the current server or the platform default remove_stale_socket = true, @@ -104,6 +120,7 @@ Default notes: - `autostart_editor_server = false` is the safer default because starting a Neovim server is an external-editor concern and should be opt-in. - `inline_hints.enabled = false` is the safer default because Godot's LSP support for inlay hints may vary by version and filetype. +- `run.console.enabled = false` is the safer default because live console capture changes `:GodotRun*` from detached launches to attached subprocesses managed by Neovim. - `treesitter.auto_setup = true` stays enabled by default for convenience, but it is safe to turn off if you already configure `nvim-treesitter` yourself. - `docs.fallback_renderer = "browser"` remains the default because browser fallback is the only option that can recover when rendered `.rst` docs cannot be fetched. - The plugin uses Neovim's built-in LSP APIs; `nvim-lspconfig` is optional and not required for Godot LSP support. @@ -201,6 +218,13 @@ Notes: - These commands shell out to `godot` on your `PATH`. - `:GodotRunScenePicker` requires Telescope to be installed; the rest do not. +Optional console capture: +- Set `run.console.enabled = true` to capture stdout/stderr from `:GodotRun*` inside Neovim. +- Choose `run.console.renderer = "buffer"` for a split buffer or `"float"` for a floating window. +- Use `:GodotShowConsole` to reopen the most recent captured console window. +- While console capture is enabled, the launched Godot process is managed by Neovim instead of using the plugin's detached launch path. +- This first implementation captures one active Godot run at a time; starting another captured run while one is still active shows a warning. + ============================================================================== Godot docs *godotdev-docs* diff --git a/lua/godotdev/run.lua b/lua/godotdev/run.lua index cb0e1a8..c18fd08 100644 --- a/lua/godotdev/run.lua +++ b/lua/godotdev/run.lua @@ -160,6 +160,11 @@ local function run_godot(args) local cmd = { "godot", "--path", root } vim.list_extend(cmd, args or {}) + local run_console = require("godotdev.run_console") + if run_console.is_enabled() then + return run_console.start(cmd, root) + end + vim.system(cmd, { detach = true, text = true }, function(result) if result.code == 0 then return diff --git a/lua/godotdev/run_console.lua b/lua/godotdev/run_console.lua new file mode 100644 index 0000000..611ac67 --- /dev/null +++ b/lua/godotdev/run_console.lua @@ -0,0 +1,330 @@ +local M = {} + +M.opts = { + enabled = false, + renderer = "buffer", -- "buffer" | "float" + buffer = { + position = "bottom", -- "right" | "bottom" | "current" + size = 0.3, + }, + float = { + width = 0.8, + height = 0.25, + border = "rounded", + }, +} + +local state = { + buffer = nil, + window = nil, + process = nil, + partial = { + stdout = "", + stderr = "", + }, +} + +local function sanitize_size(size, fallback, max) + if type(size) ~= "number" or size <= 0 then + return fallback + end + + return math.min(size, max) +end + +local function set_window_options(win) + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + vim.wo[win].wrap = false + vim.wo[win].number = false + vim.wo[win].relativenumber = false + vim.wo[win].signcolumn = "no" + vim.wo[win].cursorline = false + vim.wo[win].winfixwidth = false + vim.wo[win].winfixheight = false +end + +local function ensure_buffer() + local buf = state.buffer + if buf and vim.api.nvim_buf_is_valid(buf) then + return buf + end + + for _, existing in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(existing) and vim.api.nvim_buf_get_name(existing) == "godotdev://console" then + state.buffer = existing + return existing + end + end + + buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "hide" + vim.bo[buf].swapfile = false + vim.bo[buf].buflisted = false + vim.bo[buf].filetype = "log" + vim.bo[buf].modifiable = false + vim.bo[buf].readonly = false + vim.api.nvim_buf_set_name(buf, "godotdev://console") + vim.keymap.set("n", "q", "close", { buffer = buf, silent = true }) + + state.buffer = buf + return buf +end + +local function open_buffer_window(buf) + local buffer_config = M.opts.buffer or {} + local position = buffer_config.position or "bottom" + local size = sanitize_size(buffer_config.size, 0.3, 0.9) + + if position == "current" then + vim.api.nvim_set_current_buf(buf) + state.window = vim.api.nvim_get_current_win() + set_window_options(state.window) + return state.window + end + + local width = math.max(math.floor(vim.o.columns * size), 40) + local height = math.max(math.floor(vim.o.lines * size), 10) + + if position == "right" then + vim.cmd(("botright %dvsplit"):format(width)) + else + vim.cmd(("botright %dsplit"):format(height)) + end + + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, buf) + state.window = win + set_window_options(win) + return win +end + +local function open_float_window(buf) + local float = M.opts.float or {} + local width_ratio = sanitize_size(float.width, 0.8, 1) + local height_ratio = sanitize_size(float.height, 0.25, 1) + local width = math.min(math.max(math.floor(vim.o.columns * width_ratio), 60), vim.o.columns) + local height = math.min(math.max(math.floor(vim.o.lines * height_ratio), 10), vim.o.lines - 2) + local row = math.max(math.floor((vim.o.lines - height) / 2 - 1), 0) + local col = math.max(math.floor((vim.o.columns - width) / 2), 0) + + local win = state.window + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_set_buf(win, buf) + vim.api.nvim_win_set_config(win, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = float.border or "rounded", + title = " Godot Console ", + title_pos = "center", + }) + state.window = win + set_window_options(win) + return win + end + + win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = float.border or "rounded", + title = " Godot Console ", + title_pos = "center", + }) + state.window = win + set_window_options(win) + return win +end + +local function focus_or_open() + local buf = ensure_buffer() + local win = state.window + + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_set_current_win(win) + if vim.api.nvim_win_get_buf(win) ~= buf then + vim.api.nvim_win_set_buf(win, buf) + end + set_window_options(win) + return buf, win + end + + if M.opts.renderer == "float" then + return buf, open_float_window(buf) + end + + return buf, open_buffer_window(buf) +end + +local function replace_buffer_lines(lines) + local buf = ensure_buffer() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].modified = false +end + +local function append_lines(lines) + if not lines or #lines == 0 then + return + end + + local buf = ensure_buffer() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].modified = false +end + +local function flush_partial(stream) + local pending = state.partial[stream] + if pending == "" then + return + end + + state.partial[stream] = "" + if stream == "stderr" then + append_lines({ "[stderr] " .. pending }) + return + end + + append_lines({ pending }) +end + +local function append_stream_chunk(stream, data) + if not data or data == "" then + return + end + + local pending = state.partial[stream] .. data + local complete = pending:sub(-1) == "\n" + local lines = vim.split(pending, "\n", { plain = true }) + + if complete then + state.partial[stream] = "" + if lines[#lines] == "" then + table.remove(lines) + end + else + state.partial[stream] = table.remove(lines) or "" + end + + if stream == "stderr" then + for i, line in ipairs(lines) do + lines[i] = "[stderr] " .. line + end + end + + append_lines(lines) +end + +function M.is_enabled() + return M.opts.enabled == true +end + +function M.show() + local buf = state.buffer + if not buf or not vim.api.nvim_buf_is_valid(buf) then + vim.notify("godotdev.nvim: no Godot console output has been captured yet", vim.log.levels.INFO) + return false + end + + local _, win = focus_or_open() + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_set_cursor(win, { vim.api.nvim_buf_line_count(buf), 0 }) + end + return true +end + +function M.start(cmd, root) + if state.process then + vim.notify("godotdev.nvim: Godot console capture already has an active process", vim.log.levels.WARN) + return false + end + + local buf, win = focus_or_open() + local header = { + "# Godot Console", + "", + "Command: " .. table.concat(cmd, " "), + "Project: " .. root, + "", + } + replace_buffer_lines(header) + state.partial.stdout = "" + state.partial.stderr = "" + + local exited = false + local ok, process_or_err = pcall(vim.system, cmd, { + cwd = root, + text = true, + stdout = function(err, data) + if err then + vim.schedule(function() + append_lines({ "[stdout error] " .. tostring(err) }) + end) + return + end + + vim.schedule(function() + append_stream_chunk("stdout", data) + end) + end, + stderr = function(err, data) + if err then + vim.schedule(function() + append_lines({ "[stderr error] " .. tostring(err) }) + end) + return + end + + vim.schedule(function() + append_stream_chunk("stderr", data) + end) + end, + }, function(result) + vim.schedule(function() + flush_partial("stdout") + flush_partial("stderr") + append_lines({ + "", + ("[Process exited] code=%d signal=%d"):format(result.code or 0, result.signal or 0), + }) + exited = true + state.process = nil + end) + end) + + if not ok then + append_lines({ "[spawn error] " .. tostring(process_or_err) }) + return false + end + + state.process = exited and nil or process_or_err + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_set_cursor(win, { vim.api.nvim_buf_line_count(buf), 0 }) + end + return true +end + +function M.setup(opts) + M.opts = vim.tbl_deep_extend("force", M.opts, opts or {}) + + if vim.fn.exists(":GodotShowConsole") ~= 2 then + vim.api.nvim_create_user_command("GodotShowConsole", function() + M.show() + end, { desc = "Show the Godot run console buffer" }) + end +end + +return M diff --git a/lua/godotdev/setup.lua b/lua/godotdev/setup.lua index 4229b60..637aeed 100644 --- a/lua/godotdev/setup.lua +++ b/lua/godotdev/setup.lua @@ -18,6 +18,21 @@ M.opts = { inline_hints = { enabled = false, -- uses Neovim's built-in inlay hints when the attached Godot LSP supports them }, + run = { + console = { + enabled = false, -- capture :GodotRun* output in Neovim; when enabled these runs are no longer detached + renderer = "buffer", -- "buffer" | "float" + buffer = { + position = "bottom", -- "right" | "bottom" | "current" + size = 0.3, + }, + float = { + width = 0.8, + height = 0.25, + border = "rounded", + }, + }, + }, docs = { renderer = "float", -- "float" | "browser" | "buffer" fallback_renderer = "browser", -- nil | "browser" | "buffer"; browser is the only fetch-recovery fallback @@ -103,6 +118,7 @@ function M.setup(opts) M.opts = vim.tbl_deep_extend("force", M.opts, opts or {}) require("godotdev.inline_hints").setup(M.opts.inline_hints) + require("godotdev.run_console").setup(M.opts.run and M.opts.run.console or {}) require("godotdev.lsp").setup({ editor_host = M.opts.editor_host, editor_port = M.opts.editor_port, diff --git a/tests/run.lua b/tests/run.lua index ad6bf56..fd4dd18 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11,6 +11,7 @@ local specs = { "tests.spec_tree_sitter", "tests.spec_setup", "tests.spec_inline_hints", + "tests.spec_run_console", "tests.spec_docs", "tests.spec_docs_render", "tests.spec_formatting", diff --git a/tests/spec_run_console.lua b/tests/spec_run_console.lua new file mode 100644 index 0000000..6581a02 --- /dev/null +++ b/tests/spec_run_console.lua @@ -0,0 +1,114 @@ +local h = require("tests.helpers") + +local function delete_command(name) + if vim.fn.exists(":" .. name) == 2 then + vim.api.nvim_del_user_command(name) + end +end + +return { + { + name = "run console setup registers show command once", + run = function() + delete_command("GodotShowConsole") + + h.clear_module("godotdev.run_console") + local mod = require("godotdev.run_console") + mod.setup() + mod.setup() + + h.assert_equal(vim.fn.exists(":GodotShowConsole"), 2) + end, + }, + { + name = "run console start appends stdout stderr and exit status", + run = function() + h.clear_module("godotdev.run_console") + local mod = require("godotdev.run_console") + mod.setup({ + enabled = true, + renderer = "buffer", + buffer = { + position = "bottom", + size = 0.3, + }, + }) + + local ok, err = pcall(function() + h.with_field(vim, "schedule", function(fn) + fn() + end, function() + h.with_field(vim, "system", function(_cmd, _opts, on_exit) + _opts.stdout(nil, "hello\n") + _opts.stderr(nil, "oops\n") + on_exit({ code = 0, signal = 0 }) + return {} + end, function() + h.assert_truthy(mod.start({ "godot", "--path", "/tmp/project" }, "/tmp/project")) + end) + end) + + local buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + h.assert_truthy(lines[1]:match("Godot Console") ~= nil) + h.assert_equal(lines[6], "hello") + h.assert_equal(lines[7], "[stderr] oops") + h.assert_truthy(lines[#lines]:match("%[Process exited%] code=0 signal=0") ~= nil) + end) + + if not ok then + error(err) + end + end, + }, + { + name = "run_project uses run console when enabled", + run = function() + local called = {} + + h.clear_module("godotdev.run_console") + h.clear_module("godotdev.run") + local run = require("godotdev.run") + local run_console = require("godotdev.run_console") + run_console.setup({ enabled = true }) + + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + vim.fn.writefile({ "; Engine configuration file." }, root .. "/project.godot") + local scene = root .. "/scenes/Main.tscn" + vim.fn.mkdir(vim.fs.dirname(scene), "p") + vim.fn.writefile({ "[gd_scene format=3]" }, scene) + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, scene) + vim.api.nvim_set_current_buf(buf) + + local ok, err = pcall(function() + h.with_field(vim.fn, "executable", function(name) + return name == "godot" and 1 or 0 + end, function() + h.with_field(run_console, "start", function(cmd, root_arg) + called.cmd = cmd + called.root = root_arg + return true + end, function() + h.assert_truthy(run.run_project()) + end) + end) + end) + + run_console.setup({ enabled = false }) + + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + pcall(vim.fn.delete, root, "rf") + + if not ok then + error(err) + end + + h.assert_equal(called.cmd[1], "godot") + h.assert_equal(called.cmd[2], "--path") + h.assert_equal(vim.uv.fs_realpath(called.root), vim.uv.fs_realpath(root)) + end, + }, +} diff --git a/tests/spec_setup.lua b/tests/spec_setup.lua index 6a491a6..036ed72 100644 --- a/tests/spec_setup.lua +++ b/tests/spec_setup.lua @@ -127,6 +127,7 @@ return { ) h.assert_equal(vim.fn.exists(":GodotDocs"), 2) h.assert_equal(vim.fn.exists(":GodotToggleInlineHints"), 2) + h.assert_equal(vim.fn.exists(":GodotShowConsole"), 2) h.assert_equal(vim.fn.exists(":GodotStartEditorServer"), 2) h.assert_equal(vim.fn.exists(":GodotRunProject"), 2) end,