diff --git a/README.md b/README.md index f3555c1..7c4560c 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,37 @@ Using **lazy.nvim**: - Execute a command in the running container. If `cmd` is omitted, you’ll be prompted. Common patterns: build, test, codegen. - `:DevcontainerDown` - Stop and remove the container (see `remove_existing_container` if you want to always start clean). +- `:DevcontainerSelectConfig` + - On the host, pick which `devcontainer.json` subsequent plugin commands should use. It does not switch the container of an existing Neovim process. - `:DevContainerToggle` - Toggle the last devcontainer terminal window. --- +## Multiple dev container configs + +A project may define more than one config, for example: + +```text +.devcontainer/devcontainer.json +.devcontainer/gpu/devcontainer.json +.devcontainer/cpu/devcontainer.json +``` + +The first command you run on the host (`:DevcontainerUp`, `:DevcontainerExec`, …) asks you to pick one, listing each config's `name` alongside its path. That choice is reused for the rest of that host Neovim session; use `:DevcontainerSelectConfig` to change the config used by subsequent commands, or set `reuse_fixed_path = false` to be asked every time. + +Selecting a config inside a devcontainer cannot move the current Neovim process to another container. To switch, return to the host Neovim, run `:DevcontainerSelectConfig`, then `:DevcontainerUp` and `:DevcontainerConnect`. + +To skip the prompt altogether, pin a config (path relative to the workspace folder, or absolute): + +```lua +require("devcontainer-cli").setup({ + fixed_devcontainer_json_path = ".devcontainer/gpu/devcontainer.json", +}) +``` + +--- + ## Configuration Call `require("devcontainer-cli").setup({ ... })` with any of the following options (defaults shown): @@ -121,6 +147,13 @@ Call `require("devcontainer-cli").setup({ ... })` with any of the following opti -- Search upwards and use the nearest `.devcontainer/` folder toplevel = true, + -- Pin a specific devcontainer.json (relative to the workspace folder, or + -- absolute). When nil, you are asked to pick one if the project has several + fixed_devcontainer_json_path = nil, + + -- Reuse the config you picked instead of asking again on every command + reuse_fixed_path = true, + -- Start from scratch each `DevcontainerUp` (slower but clean) remove_existing_container = true, diff --git a/doc/devcontainer-cli.nvim.txt b/doc/devcontainer-cli.nvim.txt index df4e350..4feb83b 100644 --- a/doc/devcontainer-cli.nvim.txt +++ b/doc/devcontainer-cli.nvim.txt @@ -29,4 +29,19 @@ DevcontainerConnect *DevcontainerConnect* DevcontainerDown *DevcontainerDown* Stops and removes the devcontainer associated with the current project. + +DevcontainerSelectConfig *DevcontainerSelectConfig* + On the host, choose which devcontainer.json subsequent DevcontainerUp, + DevcontainerExec, DevcontainerConnect and DevcontainerDown commands should + use. This does not change the container in which nvim is currently running. + To switch containers, return to the host nvim, select another config, run + DevcontainerUp and then DevcontainerConnect. + + When a project contains several configs (`.devcontainer/devcontainer.json`, + `.devcontainer.json` and/or `.devcontainer//devcontainer.json`) the + other commands prompt for one the first time they are used and remember that + choice in the current host nvim session. + + Set `fixed_devcontainer_json_path` to pin a config and skip the prompt, or + `reuse_fixed_path = false` to be asked on every command. ================================================================================ diff --git a/doc/tags b/doc/tags index dd254dd..53912f6 100644 --- a/doc/tags +++ b/doc/tags @@ -1,6 +1,7 @@ DevcontainerConnect devcontainer-cli.nvim.txt /*DevcontainerConnect* DevcontainerDown devcontainer-cli.nvim.txt /*DevcontainerDown* DevcontainerExec devcontainer-cli.nvim.txt /*DevcontainerExec* +DevcontainerSelectConfig devcontainer-cli.nvim.txt /*DevcontainerSelectConfig* DevcontainerToggle devcontainer-cli.nvim.txt /*DevcontainerToggle* DevcontainerUp devcontainer-cli.nvim.txt /*DevcontainerUp* devcontainer-cli.nvim devcontainer-cli.nvim.txt /*devcontainer-cli.nvim* diff --git a/lua/devcontainer-cli/config.lua b/lua/devcontainer-cli/config.lua index 8ce8f8f..0b1cce7 100644 --- a/lua/devcontainer-cli/config.lua +++ b/lua/devcontainer-cli/config.lua @@ -24,6 +24,13 @@ local default_config = { interactive = false, -- use the .devcontainer directory closest to root in the directory tree toplevel = true, + -- path of the devcontainer.json to use, either absolute or relative to the + -- workspace folder, e.g. ".devcontainer/gpu/devcontainer.json". When unset + -- and the workspace holds more than one, you are asked to pick one + fixed_devcontainer_json_path = nil, + -- reuse the devcontainer.json you picked for a workspace for the rest of the + -- nvim session instead of asking again on every command + reuse_fixed_path = true, -- Folder where the nvim-devcontainer-cli is installed nvim_plugin_folder = file_path:gsub("init.lua", "") .. "../../../", -- Remove existing container each time DevcontainerUp is executed diff --git a/lua/devcontainer-cli/devcontainer_cli.lua b/lua/devcontainer-cli/devcontainer_cli.lua index c93c130..0e18915 100644 --- a/lua/devcontainer-cli/devcontainer_cli.lua +++ b/lua/devcontainer-cli/devcontainer_cli.lua @@ -19,7 +19,6 @@ -- SOFTWARE. local utils = require("devcontainer-cli.devcontainer_utils") local terminal = require("devcontainer-cli.terminal") -local log = require("devcontainer-cli.log") local M = {} @@ -61,15 +60,23 @@ function M.up() utils.bringup() end +-- choose which devcontainer config to use when the project has several +function M.select_config() + utils.select_config() +end + -- Thanks to the autocommand executed after leaving the UI, after closing the -- neovim window the devcontainer will be automatically open in a new terminal function M.connect() - if not utils.create_connect_cmd() then - log.error("Failed to create autocommand") - return - end - - vim.cmd("wqa") + utils.create_connect_cmd( + function() + vim.schedule( + function() + vim.cmd("wqa") + end + ) + end + ) end -- kill the current running docker container associated with the current project diff --git a/lua/devcontainer-cli/devcontainer_utils.lua b/lua/devcontainer-cli/devcontainer_utils.lua index 85696d9..f6b2f78 100644 --- a/lua/devcontainer-cli/devcontainer_utils.lua +++ b/lua/devcontainer-cli/devcontainer_utils.lua @@ -89,32 +89,113 @@ function M.parse(args) return result end --- build the initial part of a devcontainer command ----@param action (string) the action for the devcontainer to perform --- (see man devcontainer) ----@return (string|nil) nil if no devcontainer_parent could be found otherwise --- the basic devcontainer command for the given type -local function _devcontainer_command(action) - local devcontainer_root = folder_utils.get_root(config.toplevel) - if devcontainer_root == nil then +---@class Workspace +---@field root string the workspace folder handed to the devcontainer cli +---@field config string the absolute path of the devcontainer.json to use + +-- the devcontainer config that was last selected, keyed by workspace folder +local _selected_configs = {} + +-- determine which devcontainer config should be used, asking the user to pick +-- one whenever the workspace contains more than a single config +---@param callback (function) called with the resolved Workspace, and not called +-- at all when nothing could be resolved +---@param force_select (boolean|nil) prompt even if a config was already selected +local function _resolve_workspace(callback, force_select) + local root = folder_utils.get_root(config.toplevel) + if root == nil then log.error("unable to find devcontainer directory...") - return nil + return + end + + local fixed_devcontainer_json_path = config.fixed_devcontainer_json_path + if fixed_devcontainer_json_path ~= nil and fixed_devcontainer_json_path ~= "" then + local path = vim.startswith(fixed_devcontainer_json_path, "/") and fixed_devcontainer_json_path or + (root .. "/" .. fixed_devcontainer_json_path) + if vim.fn.filereadable(path) ~= 1 then + log.error("configured devcontainer config '" .. path .. "' does not exist...") + return + end + + callback({ root = root, config = path }) + return end + local configs = folder_utils.get_configs(root) + if #configs == 0 then + log.error("unable to find a devcontainer.json in '" .. root .. "'...") + return + end + + if not force_select then + local selected = _selected_configs[root] + if config.reuse_fixed_path and selected ~= nil and vim.tbl_contains(configs, selected) then + callback({ root = root, config = selected }) + return + end + + if #configs == 1 then + _selected_configs[root] = configs[1] + callback({ root = root, config = configs[1] }) + return + end + end + + vim.ui.select( + configs, + { + prompt = "Select a devcontainer config:", + format_item = function(item) + local relative = item:sub(#root + 2) + local name = folder_utils.get_config_name(item) + if name == nil then + return relative + end + + return name .. " (" .. relative .. ")" + end, + }, + function(choice) + if choice == nil then + log.info("no devcontainer config selected, ignoring.") + return + end + + _selected_configs[root] = choice + callback({ root = root, config = choice }) + end + ) +end + +-- build the initial part of a devcontainer command +---@param action (string) the action for the devcontainer to perform +-- (see man devcontainer) +---@param workspace (Workspace) the workspace folder and config to act on +---@return (string) the basic devcontainer command for the given action +local function _devcontainer_command(action, workspace) local command = "devcontainer " .. action - command = command .. " --workspace-folder '" .. devcontainer_root .. "'" + command = command .. " --workspace-folder " .. vim.fn.shellescape(workspace.root) + command = command .. " --config " .. vim.fn.shellescape(workspace.config) + command = command .. " --id-label " .. vim.fn.shellescape("devcontainer.config_file=" .. workspace.config) return command end +-- prompt the user for the devcontainer config to use from now on +function M.select_config() + _resolve_workspace( + function(workspace) + log.info("using devcontainer config: " .. workspace.config) + end, + true + ) +end + -- helper function to generate devcontainer bringup command ----@return (string|nil) nil if no devcontainer_parent could be found otherwise the --- devcontainer bringup command -local function _get_devcontainer_up_cmd() - local command = _devcontainer_command("up") - if command == nil then - return command - end +---@param workspace (Workspace) the workspace folder and config to bring up +---@return (string) the devcontainer bringup command +local function _get_devcontainer_up_cmd(workspace) + local command = _devcontainer_command("up", workspace) if config.remove_existing_container then command = command .. " --remove-existing-container" @@ -145,31 +226,31 @@ end -- issues command to bringup devcontainer function M.bringup() - local command = _get_devcontainer_up_cmd() - - if command == nil then - return - end - - if config.interactive then - vim.ui.input( - { - prompt = _wrap_text( - "Spawning devcontainer with command: " .. command - ) .. "\n\n" .. "Press q to cancel or any other key to continue\n" - }, - function(input) - if (input == "q" or input == "Q") then - log.info("\nUser cancelled bringing up devcontainer") - else - terminal.spawn(command) - end + _resolve_workspace( + function(workspace) + local command = _get_devcontainer_up_cmd(workspace) + + if config.interactive then + vim.ui.input( + { + prompt = _wrap_text( + "Spawning devcontainer with command: " .. command + ) .. "\n\n" .. "Press q to cancel or any other key to continue\n" + }, + function(input) + if (input == "q" or input == "Q") then + log.info("\nUser cancelled bringing up devcontainer") + else + terminal.spawn(command) + end + end + ) + return end - ) - return - end - terminal.spawn(command) + terminal.spawn(command) + end + ) end -- execute the given cmd within the given devcontainer_parent @@ -177,14 +258,14 @@ end ---@param direction (string|nil) the placement of the window to be created -- (left, right, bottom, float) function M._exec_cmd(cmd, direction, size) - local command = _devcontainer_command("exec") - if command == nil then - return - end - - command = command .. " " .. config.shell .. " -c '" .. cmd .. "'" - log.info(command) - terminal.spawn(command, direction, size) + _resolve_workspace( + function(workspace) + local command = _devcontainer_command("exec", workspace) + command = command .. " " .. config.shell .. " -c '" .. cmd .. "'" + log.info(command) + terminal.spawn(command, direction, size) + end + ) end -- execute a given cmd within the given devcontainer_parent @@ -215,23 +296,22 @@ function M.exec(cmd, direction, size) end -- create the necessary functions needed to connect to nvim in a devcontainer -function M.create_connect_cmd() - local au_id = vim.api.nvim_create_augroup("devcontainer-cli.connect", {}) - local dev_command = _devcontainer_command("exec") - if dev_command == nil then - return false - end - dev_command = dev_command .. " " .. config.nvim_binary - - vim.api.nvim_create_autocmd( - "UILeave", - { - group = au_id, - callback = - function() +---@param on_created (function) called once the autocommand has been created +function M.create_connect_cmd(on_created) + _resolve_workspace( + function(workspace) + local au_id = vim.api.nvim_create_augroup("devcontainer-cli.connect", {}) + local dev_command = _devcontainer_command("exec", workspace) .. " " .. config.nvim_binary + + vim.api.nvim_create_autocmd( + "UILeave", + { + group = au_id, + callback = function() local connect_command = {} if vim.env.TMUX ~= nil then connect_command = { "tmux split-window -h -t \"$TMUX_PANE\"" } + dev_command = vim.fn.shellescape(dev_command) elseif vim.fn.executable("wezterm") == 1 then connect_command = { "wezterm cli split-pane --right --cwd . -- bash -c" } dev_command = "\"" .. dev_command .. "\"" @@ -245,7 +325,7 @@ function M.create_connect_cmd() connect_command = { "Terminal.app" } else log.error("no supported terminal emulator found.") - return false + return end table.insert(connect_command, dev_command) @@ -256,34 +336,34 @@ function M.create_connect_cmd() end ) end - } - ) + } + ) - return true + on_created() + end + ) end -- issues command to down devcontainer function M.down() - local workspace = folder_utils.get_root(config.toplevel) - if workspace == nil then - log.error("Couldn't determine project root") - return - end - - local tag = workspace .. "/.devcontainer/devcontainer.json" - local command = "docker ps -q -a --filter label=devcontainer.config_file=" .. tag - log.debug("Attempting to get pid of devcontainer using command: " .. command) - local result = vim.fn.systemlist(command) - - if #result == 0 then - log.warn("Couldn't find devcontainer to kill") - return - end + _resolve_workspace( + function(workspace) + local tag = vim.fn.shellescape("label=devcontainer.config_file=" .. workspace.config) + local command = "docker ps -q -a --filter " .. tag + log.debug("Attempting to get pid of devcontainer using command: " .. command) + local result = vim.fn.systemlist(command) + + if #result == 0 then + log.warn("Couldn't find devcontainer to kill") + return + end - local pid = result[1] - command = "docker kill " .. pid - log.info("Killing docker container with pid: " .. pid) - terminal.spawn(command) + local pid = result[1] + command = "docker kill " .. pid + log.info("Killing docker container with pid: " .. pid) + terminal.spawn(command) + end + ) end return M diff --git a/lua/devcontainer-cli/folder_utils.lua b/lua/devcontainer-cli/folder_utils.lua index b783c81..ec41333 100644 --- a/lua/devcontainer-cli/folder_utils.lua +++ b/lua/devcontainer-cli/folder_utils.lua @@ -25,6 +25,11 @@ local function _directory_exists(target_folder) return (vim.fn.isdirectory(target_folder) == 1) end +-- return true if file exists and is readable +local function _file_exists(target_file) + return (vim.fn.filereadable(target_file) == 1) +end + -- get the devcontainer path for the given directory ---@param directory (string) the directory containing .devcontainer ---@return (string|nil) directory if a devcontainer exists within it or nil otherwise @@ -35,6 +40,10 @@ local function _get_devcontainer_parent(directory) return directory end + if _file_exists(directory .. '/.devcontainer.json') then + return directory + end + return nil end @@ -78,4 +87,129 @@ function M.get_root(toplevel) return _get_root_directory(current_directory, toplevel) end +-- collect every devcontainer config reachable from the given workspace folder +-- the layouts recognized are the ones understood by the devcontainer cli: +-- /.devcontainer/devcontainer.json, /.devcontainer.json and +-- /.devcontainer//devcontainer.json +---@param root (string) the workspace folder to search in +---@return (string[]) the absolute paths of the configs found, possibly empty +function M.get_configs(root) + local configs = {} + + for _, candidate in ipairs({ + root .. '/.devcontainer/devcontainer.json', + root .. '/.devcontainer.json', + }) do + if _file_exists(candidate) then + table.insert(configs, candidate) + end + end + + local nested = vim.fn.glob(root .. '/.devcontainer/*/devcontainer.json', true, true) + table.sort(nested) + vim.list_extend(configs, nested) + + return configs +end + +-- strip the comments and trailing commas that devcontainer.json allows but +-- that are not valid json +---@param text (string) the raw contents of a devcontainer.json +---@return (string) the equivalent valid json +local function _strip_jsonc(text) + local out = {} + local index = 1 + local length = #text + local in_string = false + + while index <= length do + local char = text:sub(index, index) + local next_char = text:sub(index + 1, index + 1) + + if in_string then + if char == '\\' then + table.insert(out, text:sub(index, index + 1)) + index = index + 2 + else + in_string = (char ~= '"') + table.insert(out, char) + index = index + 1 + end + elseif char == '"' then + in_string = true + table.insert(out, char) + index = index + 1 + elseif char == '/' and next_char == '/' then + index = text:find('\n', index) or (length + 1) + elseif char == '/' and next_char == '*' then + local _, stop = text:find('*/', index + 2, true) + index = (stop or length) + 1 + else + table.insert(out, char) + index = index + 1 + end + end + + text = table.concat(out) + out = {} + index = 1 + length = #text + in_string = false + + while index <= length do + local char = text:sub(index, index) + + if in_string then + table.insert(out, char) + if char == '\\' then + index = index + 1 + if index <= length then + table.insert(out, text:sub(index, index)) + end + elseif char == '"' then + in_string = false + end + elseif char == '"' then + in_string = true + table.insert(out, char) + elseif char == ',' then + local next_index = index + 1 + while next_index <= length and text:sub(next_index, next_index):match('%s') do + next_index = next_index + 1 + end + local next_char = text:sub(next_index, next_index) + if next_char ~= '}' and next_char ~= ']' then + table.insert(out, char) + end + else + table.insert(out, char) + end + + index = index + 1 + end + + return table.concat(out) +end + +-- read the name declared by a devcontainer config +---@param path (string) the absolute path of a devcontainer.json +---@return (string|nil) the declared name, or nil when absent or unparseable +function M.get_config_name(path) + if not _file_exists(path) then + return nil + end + + local ok, decoded = pcall( + function() + return vim.json.decode(_strip_jsonc(table.concat(vim.fn.readfile(path), '\n'))) + end + ) + + if not ok or type(decoded) ~= 'table' or type(decoded.name) ~= 'string' or decoded.name == '' then + return nil + end + + return decoded.name +end + return M diff --git a/lua/devcontainer-cli/init.lua b/lua/devcontainer-cli/init.lua index 2ae207a..b4ec67f 100644 --- a/lua/devcontainer-cli/init.lua +++ b/lua/devcontainer-cli/init.lua @@ -78,6 +78,15 @@ function M.setup(opts) } ) + vim.api.nvim_create_user_command( + "DevcontainerSelectConfig", + devcontainer_cli.select_config, + { + nargs = 0, + desc = "Select which devcontainer config to use.", + } + ) + log.debug("Finished setting up devcontainer-cli") end diff --git a/tests/devcontainer-cli/unit_tests.lua b/tests/devcontainer-cli/unit_tests.lua index fe352a9..e28aa99 100644 --- a/tests/devcontainer-cli/unit_tests.lua +++ b/tests/devcontainer-cli/unit_tests.lua @@ -70,3 +70,97 @@ describe("folder_utils.get_root:", function() end ) end) + +describe("folder_utils.get_configs:", function() + it( + "finds the root, nested and top level devcontainer configs", + function() + local root = vim.fn.tempname() + vim.fn.mkdir(root .. "/.devcontainer/b", "p") + vim.fn.mkdir(root .. "/.devcontainer/a", "p") + vim.fn.writefile({ "{}" }, root .. "/.devcontainer/devcontainer.json") + vim.fn.writefile({ "{}" }, root .. "/.devcontainer/a/devcontainer.json") + vim.fn.writefile({ "{}" }, root .. "/.devcontainer/b/devcontainer.json") + vim.fn.writefile({ "{}" }, root .. "/.devcontainer.json") + + local configs = folder_utils.get_configs(root) + + assert(#configs == 4) + assert(configs[1] == root .. "/.devcontainer/devcontainer.json") + assert(configs[2] == root .. "/.devcontainer.json") + assert(configs[3] == root .. "/.devcontainer/a/devcontainer.json") + assert(configs[4] == root .. "/.devcontainer/b/devcontainer.json") + + vim.fn.delete(root, "rf") + end + ) + + it( + "returns an empty list when there is no config", + function() + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + + assert(#folder_utils.get_configs(root) == 0) + + vim.fn.delete(root, "rf") + end + ) +end) + +describe("folder_utils.get_config_name:", function() + local function write_config(lines) + local path = vim.fn.tempname() + vim.fn.writefile(lines, path) + return path + end + + it( + "reads the name through comments and trailing commas", + function() + local path = write_config({ + "// a line comment with a } brace", + "{", + " /* a block", + " comment */", + " \"name\": \"GPU // dev\",", + " \"image\": \"debian\",", + "}", + }) + + assert(folder_utils.get_config_name(path) == "GPU // dev") + + vim.fn.delete(path) + end + ) + + it( + "preserves comma-bracket sequences inside name strings", + function() + local path = write_config({ + "{", + " \"name\": \"quoted \\\",} and ,]\",", + "}", + }) + + assert(folder_utils.get_config_name(path) == "quoted \",} and ,]") + + vim.fn.delete(path) + end + ) + + it( + "returns nil when the name is missing or the config is invalid", + function() + local without_name = write_config({ "{ \"image\": \"debian\" }" }) + local invalid = write_config({ "not json" }) + + assert(folder_utils.get_config_name(without_name) == nil) + assert(folder_utils.get_config_name(invalid) == nil) + assert(folder_utils.get_config_name(vim.fn.tempname()) == nil) + + vim.fn.delete(without_name) + vim.fn.delete(invalid) + end + ) +end)