├── .editorconfig ├── .forgejo ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── .luacheckrc ├── .luarc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── doc └── devcontainer.txt ├── lua └── devcontainer │ ├── commands.lua │ ├── compose.lua │ ├── config.lua │ ├── config_file │ ├── jsonc.lua │ └── parse.lua │ ├── container.lua │ ├── container_utils.lua │ ├── health.lua │ ├── init.lua │ ├── internal │ ├── cmdline.lua │ ├── container_executor.lua │ ├── executor.lua │ ├── log.lua │ ├── nvim.lua │ ├── runtimes │ │ ├── compose │ │ │ ├── devcontainer.lua │ │ │ ├── docker-compose.lua │ │ │ ├── docker.lua │ │ │ └── podman-compose.lua │ │ ├── container │ │ │ ├── devcontainer.lua │ │ │ ├── docker.lua │ │ │ └── podman.lua │ │ ├── helpers │ │ │ ├── common_compose.lua │ │ │ └── common_container.lua │ │ └── init.lua │ ├── utils.lua │ └── validation.lua │ └── status.lua ├── scripts ├── devsetup ├── docs-template.txt ├── gendoc ├── pre-commit └── test └── tests ├── configs ├── docker-compose │ └── .devcontainer │ │ ├── devcontainer.json │ │ └── docker-compose.yml ├── dockerfile │ └── .devcontainer │ │ ├── Dockerfile │ │ └── devcontainer.json ├── image │ └── .devcontainer │ │ └── devcontainer.json └── tickets │ └── 85 │ └── .devcontainer │ └── devcontainer.json ├── devcontainer ├── compose_spec.lua ├── config_file │ ├── jsonc_spec.lua │ ├── parse_async_spec.lua │ └── parse_spec.lua ├── container_spec.lua └── internal │ ├── executor_spec.lua │ ├── runtimes │ └── helpers │ │ └── common_compose_spec.lua │ └── utils_spec.lua └── init.vim /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.lua] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.forgejo/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Version** 11 | - Plugin version (or ref) 12 | - Platform info 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Provide logs if possible** 21 | Logs can be accessed with `DevcontainerLogs` command or with `require("devcontainer.commands").open_logs()` function. 22 | -------------------------------------------------------------------------------- /.forgejo/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project (check out discussions first) 4 | title: '' 5 | labels: enhancement, question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | -------------------------------------------------------------------------------- /.forgejo/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | STYLUA_VERSION: "v0.20.0" 11 | LUACHECK_VERSION: "v1.2.0" 12 | 13 | jobs: 14 | docs-test: 15 | steps: 16 | - name: Checkout nvim-dev-container 17 | uses: actions/checkout@v4 18 | - name: Setup neovim 19 | uses: https://github.com/rhysd/action-setup-vim@v1 20 | with: 21 | neovim: true 22 | version: stable 23 | - name: Test neovim docs 24 | run: nvim --headless --noplugin -V1 -es -c "helptags doc" 25 | 26 | stylua: 27 | steps: 28 | - name: Checkout nvim-dev-container 29 | uses: actions/checkout@v4 30 | - name: Cache stylua 31 | id: cache-stylua 32 | uses: actions/cache@v4 33 | with: 34 | path: stylua 35 | key: ${{ runner.os }}-stylua-${{ env.STYLUA_VERSION }} 36 | - name: Download stylua 37 | if: steps.cache-stylua.outputs.cache-hit != 'true' 38 | run: | 39 | wget "https://github.com/JohnnyMorganz/StyLua/releases/download/${{ env.STYLUA_VERSION }}/stylua-linux-x86_64.zip" -O stylua.zip 40 | unzip stylua.zip 41 | - name: Run stylua 42 | run: ./stylua --check . 43 | 44 | luacheck: 45 | container: 46 | image: ghcr.io/lunarmodules/luacheck:${{ env.LUACHECK_VERSION }} 47 | steps: 48 | - name: Install git 49 | run: apk add --no-cache git 50 | - name: Checkout nvim-dev-container 51 | run: git clone "${{ github.server_url }}/${{ github.repository }}" . 52 | - name: Run luacheck 53 | run: luacheck . 54 | 55 | test: 56 | steps: 57 | - name: Checkout nvim-dev-container 58 | uses: actions/checkout@v4 59 | - name: Setup neovim 60 | uses: https://github.com/rhysd/action-setup-vim@v1 61 | with: 62 | neovim: true 63 | version: stable 64 | - name: Checkout plenary 65 | run: git clone https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim 66 | - name: Checkout treesitter 67 | run: git clone https://github.com/nvim-treesitter/nvim-treesitter ~/.local/share/nvim/site/pack/vendor/start/nvim-treesitter 68 | - name: Link plenary and treesitter 69 | run: ln -s "$(pwd)" ~/.local/share/nvim/site/pack/vendor/start 70 | - name: Run tests 71 | run: scripts/test 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | 43 | build/ 44 | 45 | # local vimrc 46 | .vimrc 47 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = { 2 | "vim", 3 | } 4 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.checkThirdParty": false, 3 | "diagnostics.globals": [ 4 | "describe", 5 | "it" 6 | ] 7 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to nvim-dev-container 2 | 3 | First of all, thanks for taking the time to contribute to the project! 4 | 5 | Following is a basic set of guidelines for contributing to this repository and instructions to make it as easy as possible. 6 | 7 | > Parts of this guidelines are taken from https://github.com/atom/atom/blob/master/CONTRIBUTING.md 8 | 9 | If you do not have an account registered and do not want to register one, you can use mailing lists to report bugs, discuss the project and send patches: 10 | - [discussions mailing list](https://lists.sr.ht/~esensar/nvim-dev-container-discuss) 11 | - [development mailing list](https://lists.sr.ht/~esensar/nvim-dev-container-devel) 12 | 13 | #### Table of contents 14 | 15 | - [Asking questions](#asking-questions) 16 | - [Styleguides](#styleguides) 17 | - [Commit message](#commit-messages) 18 | - [Lint](#lint) 19 | - [Additional info](#additional-info) 20 | 21 | ## Reporting bugs 22 | 23 | Follow the issue template when reporting a new bug. Also try to provide log which can be found with `DevcontainerLogs` command or with `require("devcontainer.commands").open_logs()` function. Export env variable `NVIM_DEVCONTAINER_DEBUG=1` to produce more logs (e.g. start neovim with `NVIM_DEVCONTAINER_DEBUG=1 nvim`). 24 | 25 | ## Styleguides 26 | 27 | ### Commit messages 28 | - Use the present tense ("Add feature" not "Added feature") 29 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 30 | - Limit the first line to 72 characters or less 31 | - Reference issues and pull requests liberally after the first line 32 | - Project uses [Karma commit message format](http://karma-runner.github.io/6.0/dev/git-commit-msg.html) 33 | 34 | ### Lint 35 | 36 | This project uses [luacheck](https://github.com/mpeterv/luacheck) and [stylua](https://github.com/johnnymorganz/stylua). Script is provided to prepare pre-commit hook to check these tools and run tests (`scripts/devsetup`). 37 | 38 | ## Generating documentation 39 | 40 | Documentation is generated using [lemmy-help](https://github.com/numToStr/lemmy-help). To generate documentation run `scripts/gendoc`. 41 | 42 | ## Running tests 43 | 44 | Running tests requires [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) and [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) to be checked out in the parent directory of this repository. 45 | 46 | Tests can then be run with: 47 | ``` 48 | nvim --headless --noplugin -u tests/init.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/init.vim'}" 49 | ``` 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ensar Sarajčić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # devcontainer 2 | 3 | [![Dotfyle](https://dotfyle.com/plugins/esensar/nvim-dev-container/shield)](https://dotfyle.com/plugins/esensar/nvim-dev-container) 4 | [![License](https://img.shields.io/badge/license-MIT-brightgreen)](/LICENSE) 5 | [![status-badge](https://ci.codeberg.org/api/badges/8585/status.svg)](https://ci.codeberg.org/repos/8585) 6 | 7 | Goal of this plugin is to provide functionality similar to VSCode's [remote container development](https://code.visualstudio.com/docs/remote/containers) plugin and other functionality that enables development in docker container. This plugin is inspired by [jamestthompson3/nvim-remote-containers](https://github.com/jamestthompson3/nvim-remote-containers), but aims to enable having neovim embedded in docker container. 8 | 9 | **NOTE:** If you do not have an account registered and do not want to register one, you can use mailing lists to report bugs, discuss the project and send patches: 10 | - [discussions mailing list](https://lists.sr.ht/~esensar/nvim-dev-container-discuss) 11 | - [development mailing list](https://lists.sr.ht/~esensar/nvim-dev-container-devel) 12 | 13 | **WORK IN PROGRESS** 14 | 15 | [![asciicast](https://asciinema.org/a/JFwfoaBQwYoR7f5w0GuFPZDj8.svg)](https://asciinema.org/a/JFwfoaBQwYoR7f5w0GuFPZDj8) 16 | 17 | ## Requirements 18 | 19 | - [NeoVim](https://neovim.io) version 0.9.0+ (previous versions may be supported, but are not tested - commands and autocommands will definitely fail) 20 | - [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) with included `jsonc` parser (or manually installed jsonc parser) 21 | 22 | ## Installation 23 | 24 | Install using favourite plugin manager. 25 | 26 | e.g. Using [lazy.nvim](https://github.com/folke/lazy.nvim) 27 | 28 | ```lua 29 | { 30 | 'https://codeberg.org/esensar/nvim-dev-container', 31 | dependencies = 'nvim-treesitter/nvim-treesitter' 32 | } 33 | ``` 34 | 35 | or assuming `nvim-treesitter` is already available: 36 | 37 | ```lua 38 | { 'https://codeberg.org/esensar/nvim-dev-container' } 39 | ``` 40 | 41 | ## Usage 42 | 43 | To use the plugin with defaults just call the `setup` function: 44 | 45 | ```lua 46 | require("devcontainer").setup{} 47 | ``` 48 | 49 | It is possible to override some of the functionality of the plugin with options passed into `setup`. Everything passed to `setup` is optional. Following block represents default values: 50 | 51 | ```lua 52 | require("devcontainer").setup { 53 | config_search_start = function() 54 | -- By default this function uses vim.loop.cwd() 55 | -- This is used to find a starting point for .devcontainer.json file search 56 | -- Since by default, it is searched for recursively 57 | -- That behavior can also be disabled 58 | end, 59 | workspace_folder_provider = function() 60 | -- By default this function uses first workspace folder for integrated lsp if available and vim.loop.cwd() as a fallback 61 | -- This is used to replace `${localWorkspaceFolder}` in devcontainer.json 62 | -- Also used for creating default .devcontainer.json file 63 | end, 64 | terminal_handler = function(command) 65 | -- By default this function creates a terminal in a new tab using :terminal command 66 | -- It also removes statusline when that tab is active, to prevent double statusline 67 | -- It can be overridden to provide custom terminal handling 68 | end, 69 | nvim_installation_commands_provider = function(path_binaries, version_string) 70 | -- Returns table - list of commands to run when adding neovim to container 71 | -- Each command can either be a string or a table (list of command parts) 72 | -- Takes binaries available in path on current container and version_string passed to the command or current version of neovim 73 | end, 74 | devcontainer_json_template = function() 75 | -- Returns table - list of lines to set when creating new devcontainer.json files 76 | -- As a template 77 | -- Used only when using functions from commands module or created commands 78 | end, 79 | -- Can be set to false to prevent generating default commands 80 | -- Default commands are listed below 81 | generate_commands = true, 82 | -- By default no autocommands are generated 83 | -- This option can be used to configure automatic starting and cleaning of containers 84 | autocommands = { 85 | -- can be set to true to automatically start containers when devcontainer.json is available 86 | init = false, 87 | -- can be set to true to automatically remove any started containers and any built images when exiting vim 88 | clean = false, 89 | -- can be set to true to automatically restart containers when devcontainer.json file is updated 90 | update = false, 91 | }, 92 | -- can be changed to increase or decrease logging from library 93 | log_level = "info", 94 | -- can be set to true to disable recursive search 95 | -- in that case only .devcontainer.json and .devcontainer/devcontainer.json files will be checked relative 96 | -- to the directory provided by config_search_start 97 | disable_recursive_config_search = false, 98 | -- can be set to false to disable image caching when adding neovim 99 | -- by default it is set to true to make attaching to containers faster after first time 100 | cache_images = true, 101 | -- By default all mounts are added (config, data and state) 102 | -- This can be changed to disable mounts or change their options 103 | -- This can be useful to mount local configuration 104 | -- And any other mounts when attaching to containers with this plugin 105 | attach_mounts = { 106 | neovim_config = { 107 | -- enables mounting local config to /root/.config/nvim in container 108 | enabled = false, 109 | -- makes mount readonly in container 110 | options = { "readonly" } 111 | }, 112 | neovim_data = { 113 | -- enables mounting local data to /root/.local/share/nvim in container 114 | enabled = false, 115 | -- no options by default 116 | options = {} 117 | }, 118 | -- Only useful if using neovim 0.8.0+ 119 | neovim_state = { 120 | -- enables mounting local state to /root/.local/state/nvim in container 121 | enabled = false, 122 | -- no options by default 123 | options = {} 124 | }, 125 | }, 126 | -- This takes a list of mounts (strings) that should always be added to every run container 127 | -- This is passed directly as --mount option to docker command 128 | -- Or multiple --mount options if there are multiple values 129 | always_mount = {}, 130 | -- This takes a string (usually either "podman" or "docker") representing container runtime - "devcontainer-cli" is also partially supported 131 | -- That is the command that will be invoked for container operations 132 | -- If it is nil, plugin will use whatever is available (trying "podman" first) 133 | container_runtime = nil, 134 | -- Similar to container runtime, but will be used if main runtime does not support an action - useful for "devcontainer-cli" 135 | backup_runtime = nil, 136 | -- This takes a string (usually either "podman-compose" or "docker-compose") representing compose command - "devcontainer-cli" is also partially supported 137 | -- That is the command that will be invoked for compose operations 138 | -- If it is nil, plugin will use whatever is available (trying "podman-compose" first) 139 | compose_command = nil, 140 | -- Similar to compose command, but will be used if main command does not support an action - useful for "devcontainer-cli" 141 | backup_compose_command = nil, 142 | } 143 | ``` 144 | 145 | Check out [wiki](https://codeberg.org/esensar/nvim-dev-container/wiki) for more information. 146 | 147 | ### Commands 148 | 149 | If not disabled by using `generate_commands = false` in setup, this plugin provides the following commands: 150 | 151 | - `DevcontainerStart` - start whatever is defined in devcontainer.json 152 | - `DevcontainerAttach` - attach to whatever is defined in devcontainer.json 153 | - `DevcontainerExec` - execute a single command on container defined in devcontainer.json 154 | - `DevcontainerStop` - stop whatever was started based on devcontainer.json 155 | - `DevcontainerStopAll` - stop everything started with this plugin (in current session) 156 | - `DevcontainerRemoveAll` - remove everything started with this plugin (in current session) 157 | - `DevcontainerLogs` - open plugin log file 158 | - `DevcontainerEditNearestConfig` - opens nearest devcontainer.json file if it exists, or creates a new one if it does not 159 | 160 | ### Functions 161 | 162 | Check out [:h devcontainer](doc/devcontainer.txt) for full list of functions. 163 | 164 | ## Contributing 165 | 166 | Check out [contributing guidelines](CONTRIBUTING.md). 167 | 168 | ## License 169 | 170 | [MIT](LICENSE) 171 | -------------------------------------------------------------------------------- /lua/devcontainer/compose.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.compose Compose module 2 | ---@brief [[ 3 | ---Provides functions related to compose control 4 | ---@brief ]] 5 | local v = require("devcontainer.internal.validation") 6 | local log = require("devcontainer.internal.log") 7 | local runtimes = require("devcontainer.internal.runtimes") 8 | 9 | local M = {} 10 | 11 | ---@class ComposeUpOpts 12 | ---@field args? table list of additional arguments to up command 13 | ---@field on_success function() success callback 14 | ---@field on_fail function() failure callback 15 | 16 | ---Run docker-compose up with passed file 17 | ---@param compose_file string|table path to docker-compose.yml file or files 18 | ---@param opts ComposeUpOpts Additional options including callbacks 19 | ---@usage `require("devcontainer.compose").up("docker-compose.yml")` 20 | function M.up(compose_file, opts) 21 | vim.validate({ 22 | compose_file = { compose_file, { "string", "table" } }, 23 | }) 24 | opts = opts or {} 25 | v.validate_callbacks(opts) 26 | opts.on_success = opts.on_success 27 | or function() 28 | vim.notify("Successfully started services from " .. compose_file) 29 | end 30 | opts.on_fail = opts.on_fail 31 | or function() 32 | vim.notify("Starting services from " .. compose_file .. " failed!", vim.log.levels.ERROR) 33 | end 34 | 35 | runtimes.compose.up(compose_file, opts) 36 | end 37 | 38 | ---@class ComposeDownOpts 39 | ---@field on_success function() success callback 40 | ---@field on_fail function() failure callback 41 | 42 | ---Run docker-compose down with passed file 43 | ---@param compose_file string|table path to docker-compose.yml file or files 44 | ---@param opts ComposeDownOpts Additional options including callbacks 45 | ---@usage `require("devcontainer.compose").down("docker-compose.yml")` 46 | function M.down(compose_file, opts) 47 | vim.validate({ 48 | compose_file = { compose_file, { "string", "table" } }, 49 | }) 50 | opts = opts or {} 51 | v.validate_callbacks(opts) 52 | opts.on_success = opts.on_success 53 | or function() 54 | vim.notify("Successfully stopped services from " .. compose_file) 55 | end 56 | opts.on_fail = opts.on_fail 57 | or function() 58 | vim.notify("Stopping services from " .. compose_file .. " failed!", vim.log.levels.ERROR) 59 | end 60 | 61 | runtimes.compose.down(compose_file, opts) 62 | end 63 | 64 | ---@class ComposeGetContainerIdOpts 65 | ---@field on_success? function(container_id) success callback 66 | ---@field on_fail? function() failure callback 67 | 68 | ---Run docker-compose ps with passed file and service to get its container_id 69 | ---@param compose_file string|table path to docker-compose.yml file or files 70 | ---@param service string service name 71 | ---@param opts ComposeGetContainerIdOpts Additional options including callbacks 72 | ---@usage [[ 73 | ---require("devcontainer.compose").get_container_id( 74 | --- "docker-compose.yml", 75 | --- { on_success = function(container_id) end } 76 | ---) 77 | ---@usage ]] 78 | function M.get_container_id(compose_file, service, opts) 79 | vim.validate({ 80 | compose_file = { compose_file, { "string", "table" } }, 81 | service = { service, "string" }, 82 | }) 83 | opts = opts or {} 84 | v.validate_callbacks(opts) 85 | opts.on_success = opts.on_success 86 | or function(container_id) 87 | vim.notify("Container id of service " .. service .. " from " .. compose_file .. " is " .. container_id) 88 | end 89 | opts.on_fail = opts.on_fail 90 | or function() 91 | vim.notify( 92 | "Fetching container id for " .. service .. " from " .. compose_file .. " failed!", 93 | vim.log.levels.ERROR 94 | ) 95 | end 96 | 97 | runtimes.compose.get_container_id(compose_file, service, opts) 98 | end 99 | 100 | log.wrap(M) 101 | return M 102 | -------------------------------------------------------------------------------- /lua/devcontainer/config.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.config Devcontainer plugin config module 2 | ---@brief [[ 3 | ---Provides current devcontainer plugin configuration 4 | ---Don't change directly, use `devcontainer.setup{}` instead 5 | ---Can be used for read-only access 6 | ---@brief ]] 7 | 8 | local M = {} 9 | 10 | local function default_terminal_handler(command) 11 | local laststatus = vim.o.laststatus 12 | local lastheight = vim.o.cmdheight 13 | vim.cmd("tabnew") 14 | local bufnr = vim.api.nvim_get_current_buf() 15 | vim.o.laststatus = 0 16 | vim.o.cmdheight = 0 17 | local au_id = vim.api.nvim_create_augroup("devcontainer.container.terminal", {}) 18 | vim.api.nvim_create_autocmd("BufEnter", { 19 | buffer = bufnr, 20 | group = au_id, 21 | callback = function() 22 | vim.o.laststatus = 0 23 | vim.o.cmdheight = 0 24 | end, 25 | }) 26 | vim.api.nvim_create_autocmd("BufLeave", { 27 | buffer = bufnr, 28 | group = au_id, 29 | callback = function() 30 | vim.o.laststatus = laststatus 31 | vim.o.cmdheight = lastheight 32 | end, 33 | }) 34 | vim.api.nvim_create_autocmd("BufDelete", { 35 | buffer = bufnr, 36 | group = au_id, 37 | callback = function() 38 | vim.o.laststatus = laststatus 39 | vim.api.nvim_del_augroup_by_id(au_id) 40 | vim.o.cmdheight = lastheight 41 | end, 42 | }) 43 | vim.fn.termopen(command) 44 | end 45 | 46 | local function workspace_folder_provider() 47 | return vim.lsp.buf.list_workspace_folders()[1] or vim.loop.cwd() 48 | end 49 | 50 | local function default_config_search_start() 51 | return vim.loop.cwd() 52 | end 53 | 54 | local function default_nvim_installation_commands_provider(_, version_string) 55 | return { 56 | { 57 | "apt-get", 58 | "update", 59 | }, 60 | { 61 | "apt-get", 62 | "-y", 63 | "install", 64 | "curl", 65 | "fzf", 66 | "ripgrep", 67 | "tree", 68 | "git", 69 | "xclip", 70 | "python3", 71 | "python3-pip", 72 | "python3-pynvim", 73 | "nodejs", 74 | "npm", 75 | "tzdata", 76 | "ninja-build", 77 | "gettext", 78 | "libtool", 79 | "libtool-bin", 80 | "autoconf", 81 | "automake", 82 | "cmake", 83 | "g++", 84 | "pkg-config", 85 | "zip", 86 | "unzip", 87 | }, 88 | { "npm", "i", "-g", "neovim" }, 89 | { "mkdir", "-p", "/root/TMP" }, 90 | { "sh", "-c", "cd /root/TMP && git clone https://github.com/neovim/neovim" }, 91 | { 92 | "sh", 93 | "-c", 94 | "cd /root/TMP/neovim && (git checkout " .. version_string .. " || true) && make -j4 && make install", 95 | }, 96 | { "rm", "-rf", "/root/TMP" }, 97 | } 98 | end 99 | 100 | local function default_devcontainer_json_template() 101 | return { 102 | "{", 103 | [[ "name": "Your Definition Name Here (Community)",]], 104 | [[// Update the 'image' property with your Docker image name.]], 105 | [[// "image": "alpine",]], 106 | [[// Or define build if using Dockerfile.]], 107 | [[// "build": {]], 108 | [[// "dockerfile": "Dockerfile",]], 109 | [[// [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile]], 110 | [[// "args": { "VARIANT: "buster" },]], 111 | [[// }]], 112 | [[// Or use docker-compose]], 113 | [[// Update the 'dockerComposeFile' list if you have more compose files or use different names.]], 114 | [["dockerComposeFile": "docker-compose.yml",]], 115 | [[// Use 'forwardPorts' to make a list of ports inside the container available locally.]], 116 | [[// "forwardPorts": [],]], 117 | [[// Define mounts.]], 118 | [[// "mounts": [ "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename} ]] 119 | .. [[,type=bind,consistency=delegated" ],]], 120 | [[// Uncomment when using a ptrace-based debugger like C++, Go, and Rust]], 121 | [[// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],]], 122 | [[}]], 123 | } 124 | end 125 | 126 | ---Handles terminal requests (mainly used for attaching to container) 127 | ---By default it uses terminal command 128 | ---@type function 129 | M.terminal_handler = default_terminal_handler 130 | 131 | ---Provides docker build path 132 | ---By default uses first LSP workplace folder or vim.loop.cwd() 133 | ---@type function 134 | M.workspace_folder_provider = workspace_folder_provider 135 | 136 | ---Provides starting search path for .devcontainer.json 137 | ---After this search moves up until root 138 | ---By default it uses vim.loop.cwd() 139 | ---@type function 140 | M.config_search_start = default_config_search_start 141 | 142 | ---Flag to disable recursive search for .devcontainer config files 143 | ---By default plugin will move up to root looking for .devcontainer files 144 | ---This flag can be used to prevent it and only look in M.config_search_start 145 | ---@type boolean 146 | M.disable_recursive_config_search = false 147 | 148 | ---Flag to enable image caching after adding neovim - to make further attaching faster 149 | ---True by default 150 | ---@type boolean 151 | M.cache_images = true 152 | 153 | ---Provides commands for adding neovim to container 154 | ---This function should return a table listing commands to run - each command should eitehr be a table or a string 155 | ---It takes a list of executables available in the container, to decide 156 | ---which package manager to use and also version string with current neovim version 157 | ---@type function 158 | M.nvim_installation_commands_provider = default_nvim_installation_commands_provider 159 | 160 | ---Provides template for creating new .devcontainer.json files 161 | ---This function should return a table listing lines of the file 162 | ---@type function 163 | M.devcontainer_json_template = default_devcontainer_json_template 164 | 165 | ---Used to set current container runtime 166 | ---By default plugin will try to use "docker" or "podman" 167 | ---@type string? 168 | M.container_runtime = nil 169 | 170 | ---Used to set backup runtime when main runtime does not support a command 171 | ---By default plugin will try to use "docker" or "podman" 172 | ---@type string? 173 | M.backup_runtime = nil 174 | 175 | ---Used to set current compose command 176 | ---By default plugin will try to use "docker-compose" or "podman-compose" 177 | ---@type string? 178 | M.compose_command = nil 179 | 180 | ---Used to set backup command when main command does not support a command 181 | ---By default plugin will try to use "docker-compose" or "podman-compose" 182 | ---@type string? 183 | M.backup_compose_command = nil 184 | 185 | ---@class MountOpts 186 | ---@field enabled boolean if true this mount is enabled 187 | ---@field options table[string]|nil additional bind options, useful to define { "readonly" } 188 | 189 | ---@class AttachMountsOpts 190 | ---@field neovim_config? MountOpts if true attaches neovim local config to /root/.config/nvim in container 191 | ---@field neovim_data? MountOpts if true attaches neovim data to /root/.local/share/nvim in container 192 | ---@field neovim_state? MountOpts if true attaches neovim state to /root/.local/state/nvim in container 193 | 194 | ---Configuration for mounts when using attach command 195 | ---NOTE: when attaching in a separate command, it is useful to set 196 | ---always to true, since these have to be attached when starting 197 | ---Useful to mount neovim configuration into container 198 | ---Applicable only to `devcontainer.commands` functions! 199 | ---@type AttachMountsOpts 200 | M.attach_mounts = { 201 | neovim_config = { 202 | enabled = false, 203 | options = { "readonly" }, 204 | }, 205 | neovim_data = { 206 | enabled = false, 207 | options = {}, 208 | }, 209 | neovim_state = { 210 | enabled = false, 211 | options = {}, 212 | }, 213 | } 214 | 215 | ---List of mounts to always add to all containers 216 | ---Applicable only to `devcontainer.commands` functions! 217 | ---@type table[string] 218 | M.always_mount = {} 219 | 220 | ---@alias LogLevel 221 | ---| '"trace"' 222 | ---| '"debug"' 223 | ---| '"info"' 224 | ---| '"warn"' 225 | ---| '"error"' 226 | ---| '"fatal"' 227 | 228 | ---Current log level 229 | ---@type LogLevel 230 | M.log_level = "info" 231 | 232 | ---List of env variables to add to all containers started with this plugin 233 | ---Applicable only to `devcontainer.commands` functions! 234 | ---NOTE: This does not support "${localEnv:VAR_NAME}" syntax - use vim.env 235 | ---@type table[string, string] 236 | M.container_env = {} 237 | 238 | ---List of env variables to add to all containers when attaching 239 | ---Applicable only to `devcontainer.commands` functions! 240 | ---NOTE: This supports "${containerEnv:VAR_NAME}" syntax to use variables from container 241 | ---@type table[string, string] 242 | M.remote_env = {} 243 | 244 | return M 245 | -------------------------------------------------------------------------------- /lua/devcontainer/config_file/jsonc.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.config_file.jsonc Jsonc parsing module 2 | ---@brief [[ 3 | ---Vim supports Json parsing by default, but devcontainer config files are Jsonc. 4 | ---This module supports Jsonc parsing by removing comments and then parsing as Json. 5 | ---Treesitter is used for this and jsonc parser needs to be installed. 6 | ---@brief ]] 7 | local log = require("devcontainer.internal.log") 8 | local M = {} 9 | 10 | local function clean_jsonc(jsonc_content) 11 | local parser = vim.treesitter.get_string_parser(jsonc_content, "jsonc") 12 | local tree = parser:parse() 13 | local root = tree[1]:root() 14 | local query = vim.treesitter.query.parse("jsonc", "((comment)+ @c)") 15 | local lines = vim.split(jsonc_content, "\n") 16 | 17 | ---@diagnostic disable-next-line: missing-parameter 18 | for _, node, _ in query:iter_captures(root) do 19 | local row_start, col_start, row_end, col_end = node:range() 20 | local line = row_start + 1 21 | local start_part = string.sub(lines[line], 1, col_start) 22 | local end_part = string.sub(lines[line], col_end + 1) 23 | lines[line] = start_part .. end_part 24 | for l = line + 1, row_end, 1 do 25 | lines[l] = "" 26 | end 27 | if row_end + 1 ~= line then 28 | lines[row_end + 1] = string.sub(lines[line], col_end + 1) 29 | end 30 | end 31 | local result = vim.fn.join(lines, "\n") 32 | return vim.fn.substitute(result, ",\\_s*}", "}", "g") 33 | end 34 | 35 | ---Parse Json string into a Lua table 36 | ---Usually file should be read and content should be passed as a string into the function 37 | ---@param jsonc_content string 38 | ---@return table? 39 | ---@usage `require("devcontainer.config_file.jsonc").parse_jsonc([[{ "test": "value" }]])` 40 | function M.parse_jsonc(jsonc_content) 41 | vim.validate({ 42 | jsonc_content = { jsonc_content, "string" }, 43 | }) 44 | local clean_content = clean_jsonc(jsonc_content) 45 | return vim.json.decode(clean_content) 46 | end 47 | 48 | log.wrap(M) 49 | return M 50 | -------------------------------------------------------------------------------- /lua/devcontainer/config_file/parse.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.config_file.parse Devcontainer config file parsing module 2 | ---@brief [[ 3 | ---Provides support for parsing specific devcontainer.json files as well as 4 | ---automatic discovery and parsing of nearest file 5 | ---Ensures basic configuration required for the plugin to work is present in files 6 | ---@brief ]] 7 | local jsonc = require("devcontainer.config_file.jsonc") 8 | local config = require("devcontainer.config") 9 | local u = require("devcontainer.internal.utils") 10 | local log = require("devcontainer.internal.log") 11 | local uv = vim.loop 12 | 13 | local M = {} 14 | 15 | local function readFileAsync(path, callback) 16 | uv.fs_open(path, "r", 438, function(err_open, fd) 17 | if err_open or not fd then 18 | return callback(err_open, nil) 19 | end 20 | uv.fs_fstat(fd, function(err_stat, stat) 21 | if err_stat or not stat then 22 | return callback(err_stat, nil) 23 | end 24 | uv.fs_read(fd, stat.size, 0, function(err_read, data) 25 | if err_read then 26 | return callback(err_read, nil) 27 | end 28 | uv.fs_close(fd, function(err_close) 29 | if err_close then 30 | return callback(err_close, nil) 31 | end 32 | return callback(nil, data) 33 | end) 34 | end) 35 | end) 36 | end) 37 | end 38 | 39 | local function readFileSync(path) 40 | local fd = assert(uv.fs_open(path, "r", 438)) 41 | local stat = assert(uv.fs_fstat(fd)) 42 | local data = assert(uv.fs_read(fd, stat.size, 0)) 43 | assert(uv.fs_close(fd)) 44 | return data 45 | end 46 | 47 | local function invoke_callback(callback, success, data) 48 | if success then 49 | callback(nil, data) 50 | else 51 | callback(data, nil) 52 | end 53 | end 54 | 55 | local function parse_devcontainer_content(config_file_path, content) 56 | local parsed_config = vim.tbl_extend("keep", jsonc.parse_jsonc(content), { build = {}, hostRequirements = {} }) 57 | if 58 | parsed_config.image == nil 59 | and parsed_config.dockerFile == nil 60 | and (parsed_config.build.dockerfile == nil) 61 | and parsed_config.dockerComposeFile == nil 62 | then 63 | error("Either image, dockerFile or dockerComposeFile need to be present in the file") 64 | end 65 | return vim.tbl_deep_extend("force", parsed_config, { metadata = { file_path = config_file_path } }) 66 | end 67 | 68 | ---Parse specific devcontainer.json file into a Lua table 69 | ---Ensures that at least one of "image", "dockerFile" or "dockerComposeFile" keys is present 70 | ---@param config_file_path string 71 | ---@param callback? function if nil run sync, otherwise run async and pass result to the callback(err, data) 72 | ---@return table? result or nil if running async 73 | ---@usage `require("devcontainer.config_file.parse").parse_devcontainer_config([[{ "image": "test" }]])` 74 | function M.parse_devcontainer_config(config_file_path, callback) 75 | vim.validate({ 76 | config_file_path = { config_file_path, "string" }, 77 | callback = { callback, { "function", "nil" } }, 78 | }) 79 | if callback then 80 | readFileAsync( 81 | config_file_path, 82 | vim.schedule_wrap(function(err, content) 83 | if err then 84 | callback(err, nil) 85 | else 86 | local success, data = pcall(parse_devcontainer_content, config_file_path, content) 87 | invoke_callback(callback, success, data) 88 | end 89 | end) 90 | ) 91 | return nil 92 | end 93 | local content = readFileSync(config_file_path) 94 | return parse_devcontainer_content(config_file_path, content) 95 | end 96 | 97 | local function find_nearest_devcontainer_file_async(callback) 98 | local directory = config.config_search_start() 99 | local last_ino = nil 100 | 101 | local function recur_dir(err, data) 102 | if err or data == nil or data.ino == last_ino or config.disable_recursive_config_search then 103 | callback("No devcontainer files found!", nil) 104 | return 105 | end 106 | last_ino = data.ino 107 | local files = { ".devcontainer.json", ".devcontainer" .. u.path_sep .. "devcontainer.json", "devcontainer.json" } 108 | local index = 1 109 | local function file_callback(_, file_data) 110 | if file_data then 111 | local path = directory .. u.path_sep .. files[index] 112 | callback(nil, path) 113 | else 114 | index = index + 1 115 | if index > #files then 116 | directory = directory .. u.path_sep .. ".." 117 | uv.fs_stat(directory, recur_dir) 118 | else 119 | local path = directory .. u.path_sep .. files[index] 120 | uv.fs_stat(path, file_callback) 121 | end 122 | end 123 | end 124 | 125 | local path = directory .. u.path_sep .. files[index] 126 | uv.fs_stat(path, file_callback) 127 | end 128 | 129 | uv.fs_stat(directory, recur_dir) 130 | end 131 | 132 | local function find_nearest_devcontainer_file() 133 | local directory = config.config_search_start() 134 | local last_ino = nil 135 | 136 | local function recur_dir(err, data) 137 | if err or data == nil or data.ino == last_ino or config.disable_recursive_config_search then 138 | error("No devcontainer files found!") 139 | end 140 | last_ino = data.ino 141 | local files = { ".devcontainer.json", ".devcontainer" .. u.path_sep .. "devcontainer.json", "devcontainer.json" } 142 | 143 | for _, file in pairs(files) do 144 | local path = directory .. u.path_sep .. file 145 | local success, stat_data = pcall(uv.fs_stat, path) 146 | if success and stat_data ~= nil then 147 | return path 148 | end 149 | end 150 | directory = directory .. u.path_sep .. ".." 151 | local dir_exists, directory_info = pcall(uv.fs_stat, directory) 152 | local dir_err = nil 153 | local dir_data = nil 154 | if dir_exists then 155 | dir_data = directory_info 156 | else 157 | dir_err = directory_info or "Not found" 158 | end 159 | return recur_dir(dir_err, dir_data) 160 | end 161 | 162 | local dir_exists, directory_info = pcall(uv.fs_stat, directory) 163 | local dir_err = nil 164 | local dir_data = nil 165 | if dir_exists then 166 | dir_data = directory_info 167 | else 168 | dir_err = directory_info 169 | end 170 | return recur_dir(dir_err, dir_data) 171 | end 172 | 173 | ---Parse nearest devcontainer.json file into a Lua table 174 | ---Prefers .devcontainer.json over .devcontainer/devcontainer.json 175 | ---Looks in config.config_search_start first and then moves up all the way until root 176 | ---Fails if no devcontainer.json files were found, or if the first one found is invalid 177 | ---@param callback? function if nil run sync, otherwise run async and pass result to the callback(err, data) 178 | ---@return table? result or nil if running async 179 | ---@usage `require("devcontainer.config_file.parse").parse_nearest_devcontainer_config()` 180 | function M.parse_nearest_devcontainer_config(callback) 181 | vim.validate({ 182 | callback = { callback, { "function", "nil" } }, 183 | }) 184 | if callback then 185 | return find_nearest_devcontainer_file_async(function(err, data) 186 | if err then 187 | callback(err, nil) 188 | else 189 | M.parse_devcontainer_config(data, callback) 190 | end 191 | end) 192 | else 193 | return M.parse_devcontainer_config(find_nearest_devcontainer_file(), nil) 194 | end 195 | end 196 | 197 | local function sub_variables(config_string) 198 | local local_workspace_folder = config.workspace_folder_provider() 199 | local parts = vim.split(local_workspace_folder, u.path_sep) 200 | local local_workspace_folder_basename = parts[#parts] 201 | config_string = string.gsub(config_string, "${localWorkspaceFolder}", local_workspace_folder) 202 | config_string = string.gsub(config_string, "${localWorkspaceFolderBasename}", local_workspace_folder_basename) 203 | config_string = string.gsub( 204 | config_string, 205 | "${localEnv:[a-zA-Z_]+[a-zA-Z0-9_]*:?[a-zA-Z_]*[a-zA-Z0-9_]*}", 206 | function(part) 207 | part = string.gsub(part, "${localEnv:", "") 208 | part = string.sub(part, 1, #part - 1) 209 | part = vim.split(part, ":") 210 | local default = part[2] or "" 211 | part = part[1] 212 | return vim.env[part] or default 213 | end 214 | ) 215 | return config_string 216 | end 217 | 218 | local function sub_container_env(config_string, env_map) 219 | config_string = string.gsub( 220 | config_string, 221 | "${containerEnv:[a-zA-Z_]+[a-zA-Z0-9_]*:?[a-zA-Z_]*[a-zA-Z0-9_]*}", 222 | function(part) 223 | part = string.gsub(part, "${containerEnv:", "") 224 | part = string.sub(part, 1, #part - 1) 225 | part = vim.split(part, ":") 226 | local default = part[2] or "" 227 | part = part[1] 228 | return env_map[part] or default 229 | end 230 | ) 231 | return config_string 232 | end 233 | 234 | local function sub_variables_recursive(config_table) 235 | if vim.islist(config_table) then 236 | for i, v in ipairs(config_table) do 237 | if type(v) == "table" then 238 | config_table[i] = vim.tbl_deep_extend("force", config_table[i], sub_variables_recursive(v)) 239 | elseif type(v) == "string" then 240 | config_table[i] = sub_variables(v) 241 | end 242 | end 243 | elseif type(config_table) == "table" then 244 | for k, v in pairs(config_table) do 245 | if type(v) == "table" then 246 | config_table[k] = vim.tbl_deep_extend("force", config_table[k], sub_variables_recursive(v)) 247 | elseif type(v) == "string" then 248 | config_table[k] = sub_variables(v) 249 | end 250 | end 251 | end 252 | return config_table 253 | end 254 | 255 | ---Fills passed devcontainer config with defaults based on spec 256 | ---Expects a proper config file, parsed with functions from this module 257 | ---NOTE: This mutates passed config! 258 | ---@param config_file table parsed config 259 | ---@return table config with filled defaults and absolute paths 260 | function M.fill_defaults(config_file) 261 | vim.validate({ 262 | config_file = { config_file, "table" }, 263 | }) 264 | 265 | local file_path = config_file.metadata.file_path 266 | local components = vim.split(file_path, u.path_sep) 267 | table.remove(components, #components) 268 | local file_dir = table.concat(components, u.path_sep) 269 | 270 | local function to_absolute(relative_path) 271 | return file_dir .. u.path_sep .. relative_path 272 | end 273 | 274 | if config_file.build.dockerfile or config_file.dockerFile then 275 | config_file.build.dockerfile = config_file.build.dockerfile or config_file.dockerFile 276 | config_file.dockerFile = config_file.dockerFile or config_file.build.dockerfile 277 | config_file.context = config_file.context or config_file.build.context or "." 278 | config_file.build.context = config_file.build.context or config_file.context or "." 279 | 280 | config_file.build.dockerfile = to_absolute(config_file.build.dockerfile) 281 | config_file.dockerFile = to_absolute(config_file.dockerFile) 282 | config_file.context = to_absolute(config_file.context) 283 | config_file.build.context = to_absolute(config_file.build.context) 284 | 285 | config_file.build.args = config_file.build.args or {} 286 | config_file.runArgs = config_file.runArgs or {} 287 | if config_file.overrideCommand == nil then 288 | config_file.overrideCommand = true 289 | end 290 | end 291 | 292 | if config_file.image ~= nil and config_file.overrideCommand == nil then 293 | config_file.overrideCommand = true 294 | config_file.runArgs = config_file.runArgs or {} 295 | end 296 | 297 | if config_file.dockerComposeFile then 298 | if type(config_file.dockerComposeFile) == "table" then 299 | for i, val in ipairs(config_file.dockerComposeFile) do 300 | config_file.dockerComposeFile[i] = to_absolute(val) 301 | end 302 | elseif type(config_file.dockerComposeFile) == "string" then 303 | config_file.dockerComposeFile = to_absolute(config_file.dockerComposeFile) 304 | end 305 | config_file.overrideCommand = config_file.overrideCommand or false 306 | end 307 | 308 | config_file.workspaceFolder = config_file.workspaceFolder or "/workspace" 309 | 310 | config_file.forwardPorts = config_file.forwardPorts or {} 311 | config_file.remoteEnv = config_file.remoteEnv or {} 312 | 313 | return sub_variables_recursive(config_file) 314 | end 315 | 316 | ---Checks if remoteEnv property needs env values to be filled 317 | ---This can be used to prevent making needless calls to the container 318 | ---@param remote_env table remoteEnv property of parsed config 319 | ---@return boolean true if environment is required to fill out remoteEnv 320 | function M.remote_env_needs_fill(remote_env) 321 | vim.validate({ 322 | remote_env = { remote_env, "table" }, 323 | }) 324 | 325 | for _, v in pairs(remote_env) do 326 | if string.match(v, "${containerEnv:[a-zA-Z_]+[a-zA-Z0-9_]*:?[a-zA-Z_]*[a-zA-Z0-9_]*}") then 327 | return true 328 | end 329 | end 330 | return false 331 | end 332 | 333 | ---Fill passed remoteEnv table with values from env_map 334 | ---Env_map should usually be generated from environment of the running container 335 | ---NOTE: This mutates passed remoteEnv! 336 | ---@param remote_env table remoteEnv property of parsed config 337 | ---@param env_map table map of container environment 338 | ---@return table remoteEnv with replaced containerEnv values 339 | function M.fill_remote_env(remote_env, env_map) 340 | vim.validate({ 341 | remote_env = { remote_env, "table" }, 342 | env_map = { env_map, "table" }, 343 | }) 344 | 345 | for k, v in pairs(remote_env) do 346 | remote_env[k] = sub_container_env(v, env_map) 347 | end 348 | return remote_env 349 | end 350 | 351 | ---Checks if configuration needs to have ${containerWorkspaceFolder} 352 | ---and ${containerWorkspaceFolderBasename} values filled in 353 | ---This can be used to prevent making needless calls to the container 354 | ---@param config_table table parsed config 355 | ---@return boolean true if environment is required to fill out remoteEnv 356 | function M.container_workspace_folder_needs_fill(config_table) 357 | vim.validate({ 358 | config_table = { config_table, "table" }, 359 | }) 360 | 361 | for k, v in pairs(config_table) do 362 | if type(v) == "table" then 363 | if M.container_workspace_folder_needs_fill(config_table[k]) then 364 | return true 365 | end 366 | elseif type(v) == "string" then 367 | if string.match(v, "${containerWorkspaceFolder}") or string.match(v, "${containerWorkspaceFolderBasename}") then 368 | return true 369 | end 370 | end 371 | end 372 | return false 373 | end 374 | 375 | ---Checks if configuration needs to have ${containerWorkspaceFolder} 376 | ---and ${containerWorkspaceFolderBasename} values filled in 377 | ---This can be used to prevent making needless calls to the container 378 | ---@param config_table table parsed config 379 | ---@param container_workspace_folder string workspace folder fetched from container 380 | ---@return table new_config_table with filled in containerWorkspaceFolder 381 | function M.fill_container_workspace_folder(config_table, container_workspace_folder) 382 | vim.validate({ 383 | config_table = { config_table, "table" }, 384 | container_workspace_folder = { container_workspace_folder, "string" }, 385 | }) 386 | 387 | for k, v in pairs(config_table) do 388 | if type(v) == "table" then 389 | config_table[k] = 390 | vim.tbl_deep_extend("force", config_table[k], M.fill_container_workspace_folder(v, container_workspace_folder)) 391 | elseif type(v) == "string" then 392 | local parts = vim.split(container_workspace_folder, u.path_sep) 393 | local container_workspace_folder_basename = parts[#parts] 394 | v = string.gsub(v, "${containerWorkspaceFolder}", container_workspace_folder) 395 | v = string.gsub(v, "${containerWorkspaceFolderBasename}", container_workspace_folder_basename) 396 | config_table[k] = v 397 | end 398 | end 399 | return config_table 400 | end 401 | 402 | ---Return path of the nearest devcontainer.json file 403 | ---Prefers .devcontainer.json over .devcontainer/devcontainer.json 404 | ---Looks in config.config_search_start first and then moves up all the way until root 405 | ---Fails if no devcontainer.json files were found, or if the first one found is invalid 406 | ---@param callback? function if nil run sync, otherwise run async and pass result to the callback(err, data) 407 | ---@return string? result or nil if running async 408 | ---@usage `require("devcontainer.config_file.parse").find_nearest_devcontainer_config()` 409 | function M.find_nearest_devcontainer_config(callback) 410 | vim.validate({ 411 | callback = { callback, { "function", "nil" } }, 412 | }) 413 | if callback then 414 | find_nearest_devcontainer_file_async(callback) 415 | return nil 416 | else 417 | return find_nearest_devcontainer_file() 418 | end 419 | end 420 | 421 | log.wrap(M) 422 | return M 423 | -------------------------------------------------------------------------------- /lua/devcontainer/container.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.container Container module 2 | ---@brief [[ 3 | ---Provides functions related to container control: 4 | --- - building 5 | --- - attaching 6 | --- - running 7 | ---@brief ]] 8 | local v = require("devcontainer.internal.validation") 9 | local log = require("devcontainer.internal.log") 10 | local runtimes = require("devcontainer.internal.runtimes") 11 | 12 | local M = {} 13 | 14 | -- TODO: Move to utils 15 | local function command_to_repr(command) 16 | if type(command) == "string" then 17 | return command 18 | elseif type(command) == "table" then 19 | return table.concat(command, " ") 20 | end 21 | return "" 22 | end 23 | 24 | ---@class ContainerPullOpts 25 | ---@field on_success function() success callback 26 | ---@field on_fail function() failure callback 27 | 28 | ---Pull passed image using docker pull 29 | ---@param image string Docker image to pull 30 | ---@param opts ContainerPullOpts Additional options including callbacks 31 | ---@usage `require("devcontainer.container").pull("alpine", { on_success = function() end, on_fail = function() end})` 32 | function M.pull(image, opts) 33 | vim.validate({ 34 | image = { image, "string" }, 35 | opts = { opts, { "table", "nil" } }, 36 | }) 37 | opts = opts or {} 38 | v.validate_callbacks(opts) 39 | opts.on_success = opts.on_success or function() 40 | vim.notify("Successfully pulled image " .. image) 41 | end 42 | opts.on_fail = opts.on_fail 43 | or function() 44 | vim.notify("Pulling image " .. image .. " failed!", vim.log.levels.ERROR) 45 | end 46 | 47 | runtimes.container.pull(image, opts) 48 | end 49 | 50 | ---@class ContainerBuildOpts 51 | ---@field tag? string tag for the image built 52 | ---@field args? table list of additional arguments to build command 53 | ---@field on_success function(image_id) success callback taking the image_id of the built image 54 | ---@field on_progress? function(DevcontainerBuildStatus) callback taking build status object 55 | ---@field on_fail function() failure callback 56 | 57 | ---Build image from passed dockerfile using docker build 58 | ---@param file string Path to Dockerfile to build 59 | ---@param path? string Path to the workspace, vim.lsp.buf.list_workspace_folders()[1] by default 60 | ---@param opts ContainerBuildOpts Additional options including callbacks and tag 61 | ---@usage [[ 62 | ---require("devcontainer.container").build( 63 | --- "Dockerfile", 64 | --- { on_success = function(image_id) end, on_fail = function() end } 65 | ---) 66 | ---@usage ]] 67 | function M.build(file, path, opts) 68 | vim.validate({ 69 | file = { file, "string" }, 70 | path = { path, { "string", "nil" } }, 71 | opts = { opts, { "table", "nil" } }, 72 | }) 73 | path = path or vim.lsp.buf.list_workspace_folders()[1] 74 | opts = opts or {} 75 | v.validate_opts_with_callbacks(opts, { 76 | tag = "string", 77 | args = function(x) 78 | return x == nil or vim.tbl_islist(x) 79 | end, 80 | }) 81 | opts.on_success = opts.on_success 82 | or function(image_id) 83 | local message = "Successfully built image from " .. file 84 | if image_id then 85 | message = message .. " - image_id: " .. image_id 86 | end 87 | if opts.tag then 88 | message = message .. " - tag: " .. opts.tag 89 | end 90 | vim.notify(message) 91 | end 92 | local user_on_success = opts.on_success 93 | opts.on_success = function(image_id) 94 | vim.api.nvim_exec_autocmds( 95 | "User", 96 | { pattern = "DevcontainerImageBuilt", modeline = false, data = { image_id = image_id } } 97 | ) 98 | user_on_success(image_id) 99 | end 100 | opts.on_fail = opts.on_fail 101 | or function() 102 | vim.notify("Building image from file " .. file .. " failed!", vim.log.levels.ERROR) 103 | end 104 | local original_on_progress = opts.on_progress 105 | opts.on_progress = function(build_status) 106 | vim.api.nvim_exec_autocmds("User", { pattern = "DevcontainerBuildProgress", modeline = false }) 107 | if original_on_progress then 108 | original_on_progress(build_status) 109 | end 110 | end 111 | 112 | runtimes.container.build(file, path, opts) 113 | end 114 | 115 | ---@class ContainerRunOpts 116 | ---@field autoremove? boolean automatically remove container after stopping - false by default 117 | ---@field command string|table|nil command to run in container 118 | ---@field args? table list of additional arguments to run command 119 | ---@field on_success function(container_id) success callback taking the id of the started container - not invoked if tty 120 | ---@field on_fail function() failure callback 121 | 122 | ---Run passed image using docker run 123 | ---NOTE: If terminal_handler is passed, then it needs to start the process too - default termopen does just that 124 | ---@param image string Docker image to run 125 | ---@param opts ContainerRunOpts Additional options including callbacks 126 | ---@usage `require("devcontainer.container").run("alpine", { on_success = function(id) end, on_fail = function() end })` 127 | function M.run(image, opts) 128 | vim.validate({ 129 | image = { image, "string" }, 130 | opts = { opts, { "table", "nil" } }, 131 | }) 132 | opts = opts or {} 133 | v.validate_opts_with_callbacks(opts, { 134 | command = { "string", "table" }, 135 | autoremove = "boolean", 136 | args = function(x) 137 | return vim.tbl_islist(x) 138 | end, 139 | }) 140 | opts.on_success = opts.on_success or function(_) 141 | vim.notify("Successfully started image " .. image) 142 | end 143 | local user_on_success = opts.on_success 144 | opts.on_success = function(container_id) 145 | vim.api.nvim_exec_autocmds( 146 | "User", 147 | { pattern = "DevcontainerContainerStarted", modeline = false, data = { container_id = container_id } } 148 | ) 149 | user_on_success(container_id) 150 | end 151 | opts.on_fail = opts.on_fail 152 | or function() 153 | vim.notify("Starting image " .. image .. " failed!", vim.log.levels.ERROR) 154 | end 155 | 156 | runtimes.container.run(image, opts) 157 | end 158 | 159 | ---@class ContainerExecOpts 160 | ---@field tty? boolean attach to container TTY and display it in terminal buffer, using configured terminal handler 161 | ---@field terminal_handler? function override to open terminal in a different way, :tabnew + termopen by default 162 | ---@field capture_output? boolean if true captures output and passes it to success callback - incompatible with tty 163 | ---@field command string|table|nil command to run in container 164 | ---@field args? table list of additional arguments to exec command 165 | ---@field on_success? function(output?) success callback - not called if tty 166 | ---@field on_fail? function() failure callback - not called if tty 167 | 168 | ---Run command on a container using docker exec 169 | ---Useful for attaching to neovim 170 | ---NOTE: If terminal_handler is passed, then it needs to start the process too - default termopen does just that 171 | ---@param container_id string Docker container to exec on 172 | ---@param opts ContainerExecOpts Additional options including callbacks 173 | ---@usage[[ 174 | ---require("devcontainer.container").exec( 175 | --- "some_id", 176 | --- { command = "nvim", on_success = function() end, on_fail = function() end } 177 | ---) 178 | ---@usage]] 179 | function M.exec(container_id, opts) 180 | vim.validate({ 181 | container_id = { container_id, "string" }, 182 | opts = { opts, { "table", "nil" } }, 183 | }) 184 | opts = opts or {} 185 | v.validate_opts_with_callbacks(opts, { 186 | command = { "string", "table" }, 187 | tty = "boolean", 188 | capture_output = "boolean", 189 | terminal_handler = "function", 190 | args = function(x) 191 | return x == nil or vim.tbl_islist(x) 192 | end, 193 | }) 194 | opts.on_success = opts.on_success 195 | or function(_) 196 | vim.notify("Successfully executed command " .. command_to_repr(opts.command) .. " on container " .. container_id) 197 | end 198 | opts.on_fail = opts.on_fail 199 | or function() 200 | vim.notify( 201 | "Executing command " .. command_to_repr(opts.command) .. " on container " .. container_id .. " failed!", 202 | vim.log.levels.ERROR 203 | ) 204 | end 205 | 206 | return runtimes.container.exec(container_id, opts) 207 | end 208 | 209 | ---@class ContainerStopOpts 210 | ---@field on_success function() success callback 211 | ---@field on_fail function() failure callback 212 | 213 | ---Stop passed containers 214 | ---@param containers table[string] ids of containers to stop 215 | ---@param opts ContainerStopOpts Additional options including callbacks 216 | ---@usage [[ 217 | ---require("devcontainer.container").container_stop( 218 | --- { "some_id" }, 219 | --- { on_success = function() end, on_fail = function() end } 220 | ---) 221 | ---@usage ]] 222 | function M.container_stop(containers, opts) 223 | vim.validate({ 224 | containers = { containers, "table" }, 225 | }) 226 | opts = opts or {} 227 | v.validate_callbacks(opts) 228 | local user_on_success = opts.on_success or function() 229 | vim.notify("Successfully stopped containers!") 230 | end 231 | opts.on_success = function() 232 | for _, container_id in ipairs(containers) do 233 | vim.api.nvim_exec_autocmds( 234 | "User", 235 | { pattern = "DevcontainerContainerStopped", modeline = false, data = { container_id = container_id } } 236 | ) 237 | end 238 | user_on_success() 239 | end 240 | opts.on_fail = opts.on_fail or function() 241 | vim.notify("Stopping containers failed!", vim.log.levels.ERROR) 242 | end 243 | 244 | runtimes.container.container_stop(containers, opts) 245 | end 246 | 247 | ---@class ContainerCommitOpts 248 | ---@field tag string? image tag to use for commit 249 | ---@field on_success? function() success callback 250 | ---@field on_fail? function() failure callback 251 | 252 | ---Commits passed container 253 | ---@param container string id of containers to commit 254 | ---@param opts ContainerCommitOpts Additional options including callbacks 255 | ---@usage [[ 256 | ---require("devcontainer.container").container_commit( 257 | --- "some_id", 258 | --- { tag = "my_image", on_success = function() end, on_fail = function() end } 259 | ---) 260 | ---@usage ]] 261 | function M.container_commit(container, opts) 262 | vim.validate({ 263 | container = { container, "string" }, 264 | }) 265 | opts = opts or {} 266 | v.validate_callbacks(opts) 267 | v.validate_opts(opts, { tag = { "string", "nil" } }) 268 | local user_on_success = opts.on_success 269 | or function() 270 | vim.notify("Successfully committed container " .. container .. " to tag " .. opts.tag .. "!") 271 | end 272 | opts.on_success = function() 273 | vim.api.nvim_exec_autocmds("User", { 274 | pattern = "DevcontainerContainerCommitted", 275 | modeline = false, 276 | data = { container_id = container, tag = opts.tag }, 277 | }) 278 | user_on_success() 279 | end 280 | opts.on_fail = opts.on_fail 281 | or function() 282 | vim.notify("Committing container " .. container .. " failed!", vim.log.levels.ERROR) 283 | end 284 | 285 | runtimes.container.container_commit(container, opts) 286 | end 287 | 288 | ---@class ImageInspectOpts 289 | ---@field format? string format passed to image inspect command 290 | ---@field on_success? function() success callback 291 | ---@field on_fail? function() failure callback 292 | 293 | ---Inspect image status 294 | ---@param image string id of image 295 | ---@param opts ImageInspectOpts Additional options including callbacks 296 | ---@usage [[ 297 | ---require("devcontainer.container").image_inspect( 298 | --- "some_id", 299 | --- { on_success = function(response) end, on_fail = function() end } 300 | ---) 301 | ---@usage ]] 302 | function M.image_inspect(image, opts) 303 | vim.validate({ 304 | image = { image, "string" }, 305 | }) 306 | opts = opts or {} 307 | v.validate_opts_with_callbacks(opts, { 308 | format = "string", 309 | }) 310 | opts.on_success = opts.on_success 311 | or function(result) 312 | vim.notify("Result of image_inspect(" .. image .. ") is " .. result .. "!") 313 | end 314 | opts.on_fail = opts.on_fail 315 | or function() 316 | vim.notify("Inspecting image " .. image .. " has failed!", vim.log.levels.ERROR) 317 | end 318 | 319 | runtimes.container.image_inspect(image, opts) 320 | end 321 | 322 | ---@class ImageContainsOpts 323 | ---@field on_success? function() success callback 324 | ---@field on_fail? function() failure callback 325 | 326 | ---Checks if image contains another image 327 | ---@param parent_image string id of image that should contain other image 328 | ---@param child_image string id of image that should be contained in the parent image 329 | ---@param opts ImageContainsOpts Additional options including callbacks 330 | ---@usage [[ 331 | ---require("devcontainer.container").image_contains( 332 | --- "some_id", 333 | --- "some_other_id", 334 | --- { on_success = function() end, on_fail = function() end } 335 | ---) 336 | ---@usage ]] 337 | function M.image_contains(parent_image, child_image, opts) 338 | vim.validate({ 339 | parent_image = { parent_image, "string" }, 340 | child_image = { child_image, "string" }, 341 | }) 342 | opts = opts or {} 343 | v.validate_callbacks(opts) 344 | opts.on_success = opts.on_success 345 | or function(contains) 346 | vim.notify("Result of image_contains(" .. parent_image .. ", " .. child_image .. ") is " .. contains .. "!") 347 | end 348 | opts.on_fail = opts.on_fail 349 | or function() 350 | vim.notify( 351 | "Checking if image " .. parent_image .. " conains image " .. child_image .. " has failed!", 352 | vim.log.levels.ERROR 353 | ) 354 | end 355 | 356 | runtimes.container.image_contains(parent_image, child_image, opts) 357 | end 358 | 359 | ---@class ImageRmOpts 360 | ---@field force? boolean force deletion 361 | ---@field on_success function() success callback 362 | ---@field on_fail function() failure callback 363 | 364 | ---Removes passed images 365 | ---@param images table[string] ids of images to remove 366 | ---@param opts ImageRmOpts Additional options including callbacks 367 | ---@usage[[ 368 | ---require("devcontainer.container").image_rm( 369 | --- { "some_id" }, 370 | --- { on_success = function() end, on_fail = function() end } 371 | ---) 372 | ---@usage]] 373 | function M.image_rm(images, opts) 374 | vim.validate({ 375 | images = { images, "table" }, 376 | }) 377 | opts = opts or {} 378 | v.validate_callbacks(opts) 379 | opts.on_success = opts.on_success or function() 380 | vim.notify("Successfully removed images!") 381 | end 382 | local user_on_success = opts.on_success 383 | opts.on_success = function() 384 | for _, image_id in ipairs(images) do 385 | vim.api.nvim_exec_autocmds( 386 | "User", 387 | { pattern = "DevcontainerImageRemoved", modeline = false, data = { image_id = image_id } } 388 | ) 389 | end 390 | user_on_success() 391 | end 392 | opts.on_fail = opts.on_fail or function() 393 | vim.notify("Removing images failed!", vim.log.levels.ERROR) 394 | end 395 | 396 | runtimes.container.image_rm(images, opts) 397 | end 398 | 399 | ---@class ContainerRmOpts 400 | ---@field force? boolean force deletion 401 | ---@field on_success function() success callback 402 | ---@field on_fail function() failure callback 403 | 404 | ---Removes passed containers 405 | ---@param containers table[string] ids of containers to remove 406 | ---@param opts ContainerRmOpts Additional options including callbacks 407 | ---@usage[[ 408 | ---require("devcontainer.container").container_rm( 409 | --- { "some_id" }, 410 | --- { on_success = function() end, on_fail = function() end } 411 | ---) 412 | ---@usage]] 413 | function M.container_rm(containers, opts) 414 | vim.validate({ 415 | containers = { containers, "table" }, 416 | }) 417 | opts = opts or {} 418 | v.validate_callbacks(opts) 419 | opts.on_success = opts.on_success or function() 420 | vim.notify("Successfully removed containers!") 421 | end 422 | local user_on_success = opts.on_success 423 | opts.on_success = function() 424 | for _, container_id in ipairs(containers) do 425 | vim.api.nvim_exec_autocmds( 426 | "User", 427 | { pattern = "DevcontainerContainerRemoved", modeline = false, data = { container_id = container_id } } 428 | ) 429 | end 430 | user_on_success() 431 | end 432 | opts.on_fail = opts.on_fail or function() 433 | vim.notify("Removing containers failed!", vim.log.levels.ERROR) 434 | end 435 | 436 | runtimes.container.container_rm(containers, opts) 437 | end 438 | 439 | ---@class ContainerLsOpts 440 | ---@field all? boolean show all containers, not only running 441 | ---@field async? boolean run async - true by default 442 | ---@field on_success? function(containers_list) success callback 443 | ---@field on_fail? function() failure callback 444 | 445 | ---Lists containers 446 | ---@param opts ContainerLsOpts Additional options including callbacks 447 | ---@usage[[ 448 | ---require("devcontainer.container").container_ls( 449 | --- { on_success = function(containers) end, on_fail = function() end } 450 | ---) 451 | ---@usage]] 452 | function M.container_ls(opts) 453 | opts = opts or {} 454 | v.validate_callbacks(opts) 455 | v.validate_opts(opts, { all = { "boolean", "nil" }, async = { "boolean", "nil" } }) 456 | opts.on_success = opts.on_success 457 | or function(containers) 458 | vim.notify("Containers: " .. table.concat(containers, ", ")) 459 | end 460 | opts.on_fail = opts.on_fail or function() 461 | vim.notify("Loading containers failed!", vim.log.levels.ERROR) 462 | end 463 | 464 | return runtimes.container.container_ls(opts) 465 | end 466 | 467 | log.wrap(M) 468 | return M 469 | -------------------------------------------------------------------------------- /lua/devcontainer/container_utils.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.container-utils High level container utility functions 2 | ---@brief [[ 3 | ---Provides functions for interacting with containers 4 | ---High-level functions 5 | ---@brief ]] 6 | local container_runtime = require("devcontainer.container") 7 | local v = require("devcontainer.internal.validation") 8 | 9 | local M = {} 10 | 11 | ---@class ContainerUtilsGetContainerEnvOpts 12 | ---@field on_success function(table) success callback with env map parameter 13 | ---@field on_fail function() failure callback 14 | 15 | ---Returns env variables from passed container in success callback 16 | ---Env variables are retrieved using printenv 17 | ---@param container_id string 18 | ---@param opts? ContainerUtilsGetContainerEnvOpts 19 | function M.get_container_env(container_id, opts) 20 | vim.validate({ 21 | container_id = { container_id, "string" }, 22 | }) 23 | opts = opts or {} 24 | v.validate_callbacks(opts) 25 | 26 | local on_success = opts.on_success or function(_) end 27 | local on_fail = opts.on_fail or function() end 28 | 29 | container_runtime.exec(container_id, { 30 | capture_output = true, 31 | command = "printenv", 32 | on_success = function(output) 33 | local env_map = {} 34 | local lines = vim.split(output, "\n") 35 | for _, line in ipairs(lines) do 36 | local items = vim.split(line, "=") 37 | local key = table.remove(items, 1) 38 | local value = table.concat(items, "=") 39 | env_map[key] = value 40 | end 41 | on_success(env_map) 42 | end, 43 | on_fail = on_fail, 44 | }) 45 | end 46 | 47 | ---@class ContainerUtilsGetContainerWorkspaceFolderOpts 48 | ---@field on_success function(string) success callback with container workspace folder 49 | ---@field on_fail function() failure callback 50 | 51 | ---Returns workspace folder of passed image in success callback 52 | ---Retrieved using image inspect 53 | ---@param image_id string 54 | ---@param opts? ContainerUtilsGetContainerWorkspaceFolderOpts 55 | function M.get_image_workspace(image_id, opts) 56 | vim.validate({ 57 | image_id = { image_id, "string" }, 58 | }) 59 | opts = opts or {} 60 | v.validate_callbacks(opts) 61 | 62 | local on_success = opts.on_success or function(_) end 63 | local on_fail = opts.on_fail or function() end 64 | 65 | container_runtime.image_inspect(image_id, { 66 | format = "{{.Config.WorkingDir}}", 67 | on_success = on_success, 68 | on_fail = on_fail, 69 | }) 70 | end 71 | 72 | return M 73 | -------------------------------------------------------------------------------- /lua/devcontainer/health.lua: -------------------------------------------------------------------------------- 1 | local function vim_version_string() 2 | local v = vim.version() 3 | return v.major .. "." .. v.minor .. "." .. v.patch 4 | end 5 | 6 | local config = require("devcontainer.config") 7 | local executor = require("devcontainer.internal.executor") 8 | 9 | return { 10 | check = function() 11 | local start 12 | if vim.fn.has("nvim-0.10") == 1 then 13 | start = vim.health.start 14 | else 15 | start = vim.health.report_start 16 | end 17 | local warn 18 | if vim.fn.has("nvim-0.10") == 1 then 19 | warn = vim.health.warn 20 | else 21 | warn = vim.health.report_warn 22 | end 23 | local ok 24 | if vim.fn.has("nvim-0.10") == 1 then 25 | ok = vim.health.ok 26 | else 27 | ok = vim.health.report_ok 28 | end 29 | local error 30 | if vim.fn.has("nvim-0.10") == 1 then 31 | error = vim.health.error 32 | else 33 | error = vim.health.report_error 34 | end 35 | 36 | start("Neovim version") 37 | 38 | if vim.fn.has("nvim-0.10") == 0 then 39 | warn("Latest Neovim version is recommended for full feature set!") 40 | else 41 | ok("Neovim version tested and supported: " .. vim_version_string()) 42 | end 43 | 44 | start("Required plugins") 45 | 46 | local has_jsonc, jsonc_info = pcall(vim.treesitter.language.inspect, "jsonc") 47 | 48 | if not has_jsonc then 49 | error("Jsonc treesitter parser missing! devcontainer.json files parsing will fail!") 50 | else 51 | ok("Jsonc treesitter parser available. ABI version: " .. jsonc_info._abi_version) 52 | end 53 | 54 | start("External dependencies") 55 | 56 | if config.container_runtime ~= nil then 57 | if executor.is_executable(config.container_runtime) then 58 | local handle = io.popen(config.container_runtime .. " --version") 59 | if handle ~= nil then 60 | local version = handle:read("*a") 61 | handle:close() 62 | ok(version) 63 | end 64 | else 65 | error(config.container_runtime .. " is not executable. Make sure it is installed!") 66 | end 67 | else 68 | local runtimes = { "podman", "docker" } 69 | local has_any = false 70 | for _, executable in ipairs(runtimes) do 71 | if executor.is_executable(executable) then 72 | has_any = true 73 | local handle = io.popen(executable .. " --version") 74 | if handle ~= nil then 75 | local version = handle:read("*a") 76 | handle:close() 77 | ok("Found " .. executable .. ": " .. version) 78 | end 79 | end 80 | end 81 | if not has_any then 82 | error("No container runtime is available! Install either podman or docker!") 83 | end 84 | end 85 | 86 | if config.compose_command ~= nil then 87 | if executor.is_executable(config.compose_command) then 88 | local handle = io.popen(config.compose_command .. " --version") 89 | if handle ~= nil then 90 | local version = handle:read("*a") 91 | handle:close() 92 | ok(version) 93 | end 94 | else 95 | error(config.compose_command .. " is not executable! It is required for full functionality of this plugin!") 96 | end 97 | else 98 | local compose_runtimes = { "podman-compose", "docker-compose", "docker compose" } 99 | local has_any = false 100 | for _, executable in ipairs(compose_runtimes) do 101 | if executor.is_executable(executable) then 102 | has_any = true 103 | local handle = io.popen(executable .. " --version") 104 | if handle ~= nil then 105 | local version = handle:read("*a") 106 | handle:close() 107 | ok("Found " .. executable .. ": " .. version) 108 | end 109 | end 110 | end 111 | if not has_any then 112 | error("No compose tool is available! Install either podman-compose or docker-compose!") 113 | end 114 | end 115 | end, 116 | } 117 | -------------------------------------------------------------------------------- /lua/devcontainer/init.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer Main devcontainer module - used to setup the plugin 2 | ---@brief [[ 3 | ---Provides setup function 4 | ---@brief ]] 5 | local M = {} 6 | 7 | local config = require("devcontainer.config") 8 | local commands = require("devcontainer.commands") 9 | local log = require("devcontainer.internal.log") 10 | local parse = require("devcontainer.config_file.parse") 11 | local v = require("devcontainer.internal.validation") 12 | local executor = require("devcontainer.internal.executor") 13 | local runtime = require("devcontainer.internal.runtimes") 14 | local cmdline = require("devcontainer.internal.cmdline") 15 | 16 | local configured = false 17 | 18 | ---@class DevcontainerAutocommandOpts 19 | ---@field init? boolean|string set to true (or "ask" to prompt before stating) to enable automatic devcontainer start 20 | ---@field clean? boolean set to true to enable automatic devcontainer stop and clean 21 | ---@field update? boolean set to true to enable automatic devcontainer update when config file is changed 22 | 23 | ---@class DevcontainerSetupOpts 24 | ---@field config_search_start? function provides starting point for .devcontainer.json search 25 | ---@field workspace_folder_provider? function provides current workspace folder 26 | ---@field terminal_handler? function handles terminal command requests, useful for floating terminals and similar 27 | ---@field devcontainer_json_template? function provides template for new .devcontainer.json files - returns table 28 | ---@field nvim_installation_commands_provider? function provides table of commands for installing neovim in container 29 | ---@field generate_commands? boolean can be set to false to prevent plugin from creating commands (true by default) 30 | ---@field autocommands? DevcontainerAutocommandOpts can be set to enable autocommands, disabled by default 31 | ---@field log_level? LogLevel can be used to override library logging level 32 | ---@field container_env? table can be used to override containerEnv for all started containers 33 | ---@field remote_env? table can be used to override remoteEnv when attaching to containers 34 | ---@field disable_recursive_config_search? boolean can be used to disable recursive .devcontainer search 35 | ---@field cache_images? boolean can be used to cache images after adding neovim - true by default 36 | ---@field attach_mounts? AttachMountsOpts can be used to configure mounts when adding neovim to containers 37 | ---@field always_mount? table[table|string] list of mounts to add to every container 38 | ---@field container_runtime? string container runtime to use ("docker", "podman", "devcontainer-cli") 39 | ---@field backup_runtime? string container runtime to use when main does not support an action ("docker", "podman") 40 | ---@field compose_command? string command to use for compose 41 | ---@field backup_compose_command? string command to use for compose when main does not support an action 42 | 43 | ---Starts the plugin and sets it up with provided options 44 | ---@param opts? DevcontainerSetupOpts 45 | function M.setup(opts) 46 | if configured then 47 | log.info("Already configured, skipping!") 48 | return 49 | end 50 | 51 | vim.validate({ 52 | opts = { opts, "table" }, 53 | }) 54 | opts = opts or {} 55 | v.validate_opts(opts, { 56 | config_search_start = "function", 57 | workspace_folder_provider = "function", 58 | terminal_handler = "function", 59 | devcontainer_json_template = "function", 60 | nvim_installation_commands_provider = "function", 61 | generate_commands = "boolean", 62 | autocommands = "table", 63 | log_level = "string", 64 | container_env = "table", 65 | remote_env = "table", 66 | disable_recursive_config_search = "boolean", 67 | cache_images = "boolean", 68 | attach_mounts = "table", 69 | always_mount = function(t) 70 | return t == nil or vim.tbl_islist(t) 71 | end, 72 | }) 73 | if opts.autocommands then 74 | v.validate_deep(opts.autocommands, "opts.autocommands", { 75 | init = { "boolean", "string" }, 76 | clean = "boolean", 77 | update = "boolean", 78 | }) 79 | end 80 | local am = opts.attach_mounts 81 | if am then 82 | v.validate_deep(am, "opts.attach_mounts", { 83 | neovim_config = "table", 84 | neovim_data = "table", 85 | neovim_state = "table", 86 | }) 87 | 88 | local mount_opts_mapping = { 89 | enabled = "boolean", 90 | options = function(t) 91 | return t == nil or vim.tbl_islist(t) 92 | end, 93 | } 94 | 95 | if am.neovim_config then 96 | v.validate_deep(am.neovim_config, "opts.attach_mounts.neovim_config", mount_opts_mapping) 97 | end 98 | 99 | if am.neovim_data then 100 | v.validate_deep(am.neovim_data, "opts.attach_mounts.neovim_data", mount_opts_mapping) 101 | end 102 | 103 | if am.neovim_state then 104 | v.validate_deep(am.neovim_state, "opts.attach_mounts.neovim_state", mount_opts_mapping) 105 | end 106 | end 107 | 108 | configured = true 109 | 110 | config.terminal_handler = opts.terminal_handler or config.terminal_handler 111 | config.devcontainer_json_template = opts.devcontainer_json_template or config.devcontainer_json_template 112 | config.nvim_installation_commands_provider = opts.nvim_installation_commands_provider 113 | or config.nvim_installation_commands_provider 114 | config.workspace_folder_provider = opts.workspace_folder_provider or config.workspace_folder_provider 115 | config.config_search_start = opts.config_search_start or config.config_search_start 116 | config.always_mount = opts.always_mount or config.always_mount 117 | config.attach_mounts = opts.attach_mounts or config.attach_mounts 118 | config.disable_recursive_config_search = opts.disable_recursive_config_search 119 | or config.disable_recursive_config_search 120 | if opts.cache_images ~= nil then 121 | config.cache_images = opts.cache_images 122 | end 123 | if vim.env.NVIM_DEVCONTAINER_DEBUG then 124 | config.log_level = "trace" 125 | else 126 | config.log_level = opts.log_level or config.log_level 127 | end 128 | config.container_env = opts.container_env or config.container_env 129 | config.remote_env = opts.remote_env or config.remote_env 130 | config.container_runtime = opts.container_runtime or config.container_runtime 131 | config.backup_runtime = opts.backup_runtime or config.backup_runtime 132 | config.compose_command = opts.compose_command or config.compose_command 133 | config.backup_compose_command = opts.backup_compose_command or config.backup_compose_command 134 | 135 | if config.compose_command == nil then 136 | if executor.is_executable("podman-compose") then 137 | config.compose_command = "podman-compose" 138 | elseif executor.is_executable("docker-compose") then 139 | config.compose_command = "docker-compose" 140 | elseif executor.is_executable("docker compose") then 141 | config.compose_command = "docker compose" 142 | end 143 | end 144 | 145 | if config.backup_compose_command == nil then 146 | if executor.is_executable("podman-compose") then 147 | config.backup_compose_command = "podman-compose" 148 | elseif executor.is_executable("docker-compose") then 149 | config.backup_compose_command = "docker-compose" 150 | elseif executor.is_executable("docker compose") then 151 | config.backup_compose_command = "docker compose" 152 | end 153 | end 154 | 155 | if config.container_runtime == nil then 156 | if executor.is_executable("podman") then 157 | config.container_runtime = "podman" 158 | elseif executor.is_executable("docker") then 159 | config.container_runtime = "docker" 160 | end 161 | end 162 | 163 | if config.backup_runtime == nil then 164 | if executor.is_executable("podman") then 165 | config.backup_runtime = "podman" 166 | elseif executor.is_executable("docker") then 167 | config.backup_runtime = "docker" 168 | end 169 | end 170 | 171 | if opts.generate_commands ~= false then 172 | local container_command_complete = cmdline.complete_parse(function(cmdline_status) 173 | local command_suggestions = { "nvim", "sh" } 174 | -- Filling second arg 175 | if cmdline_status.current_arg == 2 then 176 | return command_suggestions 177 | elseif cmdline_status.current_arg == 1 then 178 | local options = { "devcontainer", "latest" } 179 | local containers = runtime.container.container_ls({ async = false }) 180 | vim.list_extend(options, containers) 181 | 182 | if cmdline_status.arg_count == 1 then 183 | vim.list_extend(options, command_suggestions) 184 | end 185 | return options 186 | end 187 | return {} 188 | end) 189 | 190 | -- Automatic 191 | vim.api.nvim_create_user_command("DevcontainerStart", function(_) 192 | commands.start_auto() 193 | end, { 194 | nargs = 0, 195 | desc = "Start either compose, dockerfile or image from .devcontainer.json", 196 | }) 197 | vim.api.nvim_create_user_command("DevcontainerAttach", function(args) 198 | local target = "devcontainer" 199 | local command = "nvim" 200 | if #args.fargs == 1 then 201 | command = args.fargs[1] 202 | elseif #args.fargs > 1 then 203 | target = args.fargs[1] 204 | command = args.fargs 205 | table.remove(command, 1) 206 | end 207 | commands.attach_auto(target, command) 208 | end, { 209 | nargs = "*", 210 | desc = "Attach to either compose, dockerfile or image from .devcontainer.json", 211 | complete = container_command_complete, 212 | }) 213 | vim.api.nvim_create_user_command("DevcontainerExec", function(args) 214 | local target = "devcontainer" 215 | local command = "nvim" 216 | if #args.fargs == 1 then 217 | command = args.fargs[1] 218 | elseif #args.fargs > 1 then 219 | target = args.fargs[1] 220 | command = args.fargs 221 | table.remove(command, 1) 222 | end 223 | commands.exec(target, command) 224 | end, { 225 | nargs = "*", 226 | desc = "Execute a command on running container", 227 | complete = container_command_complete, 228 | }) 229 | vim.api.nvim_create_user_command("DevcontainerStop", function(_) 230 | commands.stop_auto() 231 | end, { 232 | nargs = 0, 233 | desc = "Stop either compose, dockerfile or image from .devcontainer.json", 234 | }) 235 | 236 | -- Cleanup 237 | vim.api.nvim_create_user_command("DevcontainerStopAll", function(_) 238 | commands.stop_all() 239 | end, { 240 | nargs = 0, 241 | desc = "Stop everything started with devcontainer", 242 | }) 243 | vim.api.nvim_create_user_command("DevcontainerRemoveAll", function(_) 244 | commands.remove_all() 245 | end, { 246 | nargs = 0, 247 | desc = "Remove everything started with devcontainer", 248 | }) 249 | 250 | -- Util 251 | vim.api.nvim_create_user_command("DevcontainerLogs", function(_) 252 | commands.open_logs() 253 | end, { 254 | nargs = 0, 255 | desc = "Open devcontainer plugin logs in a new buffer", 256 | }) 257 | vim.api.nvim_create_user_command("DevcontainerEditNearestConfig", function(_) 258 | commands.edit_devcontainer_config() 259 | end, { 260 | nargs = 0, 261 | desc = "Opens nearest devcontainer.json file in a new buffer or creates one if it does not exist", 262 | }) 263 | end 264 | 265 | if opts.autocommands then 266 | local au_id = vim.api.nvim_create_augroup("devcontainer_autostart", {}) 267 | 268 | if opts.autocommands.init then 269 | local last_devcontainer_file = nil 270 | 271 | local function auto_start() 272 | parse.find_nearest_devcontainer_config(vim.schedule_wrap(function(err, data) 273 | if err == nil and data ~= nil then 274 | if vim.loop.fs_realpath(data) ~= last_devcontainer_file then 275 | if opts.autocommands.init == "ask" then 276 | vim.ui.select( 277 | { "Yes", "No" }, 278 | { prompt = "Devcontainer file found! Would you like to start the container?" }, 279 | function(choice) 280 | if choice == "Yes" then 281 | commands.start_auto() 282 | last_devcontainer_file = vim.loop.fs_realpath(data) 283 | end 284 | end 285 | ) 286 | else 287 | commands.start_auto() 288 | last_devcontainer_file = vim.loop.fs_realpath(data) 289 | end 290 | end 291 | end 292 | end)) 293 | end 294 | 295 | vim.api.nvim_create_autocmd("BufEnter", { 296 | pattern = "*", 297 | group = au_id, 298 | callback = function() 299 | auto_start() 300 | end, 301 | once = true, 302 | }) 303 | 304 | vim.api.nvim_create_autocmd("DirChanged", { 305 | pattern = "*", 306 | group = au_id, 307 | callback = function() 308 | auto_start() 309 | end, 310 | }) 311 | end 312 | 313 | if opts.autocommands.clean then 314 | vim.api.nvim_create_autocmd("VimLeavePre", { 315 | pattern = "*", 316 | group = au_id, 317 | callback = function() 318 | commands.remove_all() 319 | end, 320 | }) 321 | end 322 | 323 | if opts.autocommands.update then 324 | vim.api.nvim_create_autocmd({ "BufWritePost", "FileWritePost" }, { 325 | pattern = "*devcontainer.json", 326 | group = au_id, 327 | callback = function(event) 328 | parse.find_nearest_devcontainer_config(function(err, data) 329 | if err == nil and data ~= nil then 330 | if data == event.match then 331 | commands.stop_auto(function() 332 | commands.start_auto() 333 | end) 334 | end 335 | end 336 | end) 337 | end, 338 | }) 339 | end 340 | end 341 | 342 | log.info("Setup complete!") 343 | end 344 | 345 | return M 346 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/cmdline.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.cmdline Command line related helpers 2 | ---@brief [[ 3 | ---Provides helpers related to Neovim command line 4 | ---@brief ]] 5 | 6 | local M = {} 7 | 8 | ---@class CmdLineStatus 9 | ---@field arg_count integer total arguments count (not including the command) 10 | ---@field current_arg integer index of current argument (1 based index) 11 | ---@field current_arg_lead string? current arg lead 12 | 13 | ---Helper that can be passed into the `complete` 14 | ---extra in `nvim_create_user_command` to parse 15 | ---arguments and make it easier to decide on completion 16 | ---results 17 | ---@param callback function(CmdLineStatus) 18 | ---@return function 19 | function M.complete_parse(callback) 20 | return function(arg_lead, cmd_line, cursor_pos) 21 | -- Remove command part 22 | local replaced_length = string.match(cmd_line, "%w*%s"):len() 23 | 24 | if replaced_length > cursor_pos then 25 | return callback({ arg_count = 0, current_arg = 0, current_arg_lead = "" }) 26 | end 27 | 28 | local _, arg_count = string.gsub(cmd_line, "%w%s", "") 29 | local _, current_arg = string.gsub(string.sub(cmd_line, 1, cursor_pos), "%w%s", "") 30 | 31 | return callback({ arg_count = arg_count, current_arg = current_arg, arg_lead = arg_lead }) 32 | end 33 | end 34 | 35 | return M 36 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/container_executor.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.container_executor Utilities related to executing commands in containers 2 | ---@brief [[ 3 | ---Provides high level commands related to executing commands on container 4 | ---@brief ]] 5 | 6 | local M = {} 7 | 8 | local log = require("devcontainer.internal.log") 9 | local v = require("devcontainer.internal.validation") 10 | local runtimes = require("devcontainer.internal.runtimes") 11 | 12 | ---@class RunAllOpts 13 | ---@field on_success function() success callback 14 | ---@field on_step function(step) step success callback 15 | ---@field on_fail function() failure callback 16 | 17 | ---Run all passed commands sequentially on the container 18 | ---If any of them fail, on_fail callback will be called immediately 19 | ---@param container_id string of container to run commands on 20 | ---@param commands table[string] commands to run on the container 21 | ---@param opts? RunAllOpts Additional options including callbacks 22 | function M.run_all_seq(container_id, commands, opts) 23 | vim.validate({ 24 | container_id = { container_id, "string" }, 25 | commands = { commands, "table" }, 26 | opts = { opts, { "table", "nil" } }, 27 | }) 28 | opts = opts or {} 29 | v.validate_callbacks(opts) 30 | v.validate_opts(opts, { on_step = "function" }) 31 | opts.on_success = opts.on_success 32 | or function() 33 | vim.notify("Successfully ran commands (" .. vim.inspect(commands) .. ") on container (" .. container_id .. ")") 34 | end 35 | opts.on_fail = opts.on_fail 36 | or function() 37 | vim.notify( 38 | "Running commands (" .. vim.inspect(commands) .. ") on (" .. container_id .. ") has failed!", 39 | vim.log.levels.ERROR 40 | ) 41 | end 42 | opts.on_step = opts.on_step 43 | or function(step) 44 | vim.notify("Successfully ran command (" .. table.concat(step, " ") .. ") on (" .. container_id .. ")!") 45 | end 46 | 47 | -- Index starts at 1 - first iteration is fake 48 | local index = 0 49 | local on_success_step 50 | on_success_step = function() 51 | if index > 0 then 52 | opts.on_step(commands[index]) 53 | end 54 | if index == #commands then 55 | opts.on_success() 56 | else 57 | index = index + 1 58 | runtimes.container.exec(container_id, { 59 | command = commands[index], 60 | on_success = on_success_step, 61 | on_fail = opts.on_fail, 62 | }) 63 | end 64 | end 65 | -- Start looping 66 | on_success_step() 67 | end 68 | 69 | log.wrap(M) 70 | return M 71 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/executor.lua: -------------------------------------------------------------------------------- 1 | ---@brief [[ 2 | ---Internal library for managing and running executables 3 | ---@brief ]] 4 | local uv = vim.loop 5 | local log = require("devcontainer.internal.log") 6 | 7 | local M = {} 8 | 9 | ---Checks if passed command is executable - fails if not 10 | ---@param command string 11 | ---@return boolean true if executable, false otherwise 12 | function M.is_executable(command) 13 | if string.match(command, " ") then 14 | vim.fn.system(command) 15 | return vim.v.shell_error == 0 16 | end 17 | if vim.fn.has("win32") == 1 then 18 | command = command .. ".exe" 19 | end 20 | return vim.fn.executable(command) == 1 21 | end 22 | 23 | ---Ensures that passed command is executable - fails if not executable 24 | ---@param command string 25 | ---@usage `require("devcontainer.internal.executor").ensure_executable("docker")` 26 | function M.ensure_executable(command) 27 | if not M.is_executable(command) then 28 | error(command .. " is not executable. Ensure it is properly installed and available on PATH") 29 | end 30 | end 31 | 32 | local function handle_close(handle) 33 | if not uv.is_closing(handle) then 34 | uv.close(handle) 35 | end 36 | end 37 | 38 | ---@class RunCommandOpts 39 | ---@field stdout? function 40 | ---@field stderr? function 41 | ---@field args? string[] 42 | ---@field uv? table 43 | 44 | ---Runs passed command and passes arguments to vim.loop.spawn 45 | ---Passes all stdout and stderr data to opts.handler.stdout and opts.handler.stderr 46 | ---@param command string command to run 47 | ---@param opts RunCommandOpts contains stdio handlers as well as optionally options for vim.loop 48 | ---@param onexit? function function(code, signal) 49 | ---@see vim.loop opts.uv are passed to vim.loop.spawn 50 | ---@see https://github.com/luvit/luv/blob/master/docs.md 51 | ---@usage `require("devcontainer.internal.executor").run_command("docker", {}, function() end)` 52 | function M.run_command(command, opts, onexit) 53 | --TODO: test coverage 54 | local uv_opts = opts.uv or {} 55 | 56 | local stdout = uv.new_pipe(false) 57 | local stderr = uv.new_pipe(false) 58 | 59 | local handle, pid 60 | handle, pid = uv.spawn( 61 | command, 62 | vim.tbl_extend("force", uv_opts, { 63 | stdio = { nil, stdout, stderr }, 64 | args = opts.args, 65 | }), 66 | vim.schedule_wrap(function(code, signal) 67 | handle_close(stdout) 68 | handle_close(stderr) 69 | handle_close(handle) 70 | 71 | if code > 0 and onexit == nil then 72 | vim.notify( 73 | "Process (pid: " 74 | .. pid 75 | .. ") `" 76 | .. command 77 | .. " " 78 | .. table.concat(opts.args, " ") 79 | .. "` exited with code " 80 | .. code 81 | .. " - signal " 82 | .. signal, 83 | vim.log.levels.ERROR 84 | ) 85 | end 86 | 87 | if type(onexit) == "function" then 88 | onexit(code, signal) 89 | end 90 | end) 91 | ) 92 | if stdout then 93 | uv.read_start(stdout, opts.stdout or function() end) 94 | end 95 | if stderr then 96 | uv.read_start(stderr, opts.stderr or function() end) 97 | end 98 | end 99 | 100 | ---@param command string|table command to run 101 | ---@return integer status,string output status code (0 = success) and stdout of the command 102 | function M.run_command_sync(command) 103 | return vim.v.shell_error, vim.fn.system(command) 104 | end 105 | 106 | log.wrap(M) 107 | return M 108 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/log.lua: -------------------------------------------------------------------------------- 1 | ---@brief [[ 2 | ---Internal library for logging 3 | ---Parts of this are taken from https://github.com/nvim-lua/plenary.nvim/blob/master/lua/plenary/log.lua 4 | ---This was done to avoid dependency on plenary.nvim, but a lot of people probably already use it anyways, 5 | ---so maybe this can be removed at a later point 6 | ---@brief ]] 7 | local config = require("devcontainer.config") 8 | local M = {} 9 | 10 | M.logfile = string.format("%s/%s.log", vim.api.nvim_call_function("stdpath", { "cache" }), "devcontainer") 11 | 12 | -- Level configuration 13 | M.modes = { 14 | { name = "trace", hl = "Comment" }, 15 | { name = "debug", hl = "Comment" }, 16 | { name = "info", hl = "None" }, 17 | { name = "warn", hl = "WarningMsg" }, 18 | { name = "error", hl = "ErrorMsg" }, 19 | { name = "fatal", hl = "ErrorMsg" }, 20 | } 21 | 22 | M.levels = {} 23 | for i, v in ipairs(M.modes) do 24 | M.levels[v.name] = i 25 | end 26 | 27 | local make_string = function(...) 28 | local t = {} 29 | for i = 1, select("#", ...) do 30 | t[#t + 1] = vim.inspect(select(i, ...)) 31 | end 32 | return table.concat(t, " ") 33 | end 34 | 35 | function M.log_at_level(level, level_config, message_maker, ...) 36 | -- Return early if we're below the config.level 37 | if level < M.levels[config.log_level] then 38 | return 39 | end 40 | local nameupper = level_config.name:upper() 41 | 42 | local msg = message_maker(...) 43 | 44 | local fp = assert(io.open(M.logfile, "a")) 45 | local str = string.format("[%-6s%s]: %s\n", nameupper, os.date(), msg) 46 | fp:write(str) 47 | fp:close() 48 | end 49 | 50 | for i, x in ipairs(M.modes) do 51 | -- log.info("these", "are", "separated") 52 | M[x.name] = function(...) 53 | return M.log_at_level(i, x, make_string, ...) 54 | end 55 | 56 | -- log.fmt_info("These are %s strings", "formatted") 57 | M[("fmt_%s"):format(x.name)] = function(...) 58 | return M.log_at_level(i, x, function(...) 59 | local passed = { ... } 60 | local fmt = table.remove(passed, 1) 61 | local inspected = {} 62 | for _, v in ipairs(passed) do 63 | table.insert(inspected, vim.inspect(v)) 64 | end 65 | return string.format(fmt, unpack(inspected)) 66 | end, ...) 67 | end 68 | end 69 | 70 | function M.wrap(obj) 71 | if vim.env.NVIM_DEVCONTAINER_DEBUG then 72 | local old_obj = vim.deepcopy(obj) 73 | for k, v in pairs(obj) do 74 | if type(v) == "function" then 75 | obj[k] = function(...) 76 | local args = { ... } 77 | M.fmt_trace("Calling %s with (%s)", k, args) 78 | local result = old_obj[k](...) 79 | M.fmt_trace("Result of %s with (%s) = %s", k, args, result or "nil") 80 | return result 81 | end 82 | end 83 | end 84 | else 85 | return obj 86 | end 87 | end 88 | 89 | return M 90 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/nvim.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.nvim Neovim in container related commands 2 | ---@brief [[ 3 | ---Provides high level commands related to using neovim inside container 4 | ---@brief ]] 5 | 6 | local M = {} 7 | 8 | local log = require("devcontainer.internal.log") 9 | local v = require("devcontainer.internal.validation") 10 | local u = require("devcontainer.internal.utils") 11 | local status = require("devcontainer.status") 12 | local config = require("devcontainer.config") 13 | local container_executor = require("devcontainer.internal.container_executor") 14 | local container_runtime = require("devcontainer.container") 15 | 16 | ---@class AddNeovimOpts 17 | ---@field on_success? function() success callback 18 | ---@field on_step? function(step) step success callback 19 | ---@field on_fail? function() failure callback 20 | ---@field version? string version of neovim to use - current version by default 21 | 22 | ---Adds neovim to passed container using exec 23 | ---@param container_id string id of container to add neovim to 24 | ---@param opts? AddNeovimOpts Additional options including callbacks 25 | function M.add_neovim(container_id, opts) 26 | vim.validate({ 27 | container_id = { container_id, "string" }, 28 | opts = { opts, { "table", "nil" } }, 29 | }) 30 | opts = opts or {} 31 | v.validate_callbacks(opts) 32 | opts.on_success = opts.on_success 33 | or function() 34 | vim.notify("Successfully added neovim to container (" .. container_id .. ")") 35 | end 36 | opts.on_fail = opts.on_fail 37 | or function() 38 | vim.notify("Adding neovim to container (" .. container_id .. ") has failed!", vim.log.levels.ERROR) 39 | end 40 | opts.on_step = opts.on_step 41 | or function(step) 42 | vim.notify("Executed " .. table.concat(step, " ") .. " on container (" .. container_id .. ")!") 43 | end 44 | 45 | local function run_commands(commands) 46 | local build_status = { 47 | build_title = "Adding neovim to: " .. container_id, 48 | progress = 0, 49 | step_count = #commands, 50 | current_step = 0, 51 | image_id = nil, 52 | source_dockerfile = nil, 53 | build_command = "nvim.add_neovim", 54 | commands_run = {}, 55 | running = true, 56 | } 57 | local current_step = 0 58 | status.add_build(build_status) 59 | 60 | container_executor.run_all_seq(container_id, commands, { 61 | on_success = function() 62 | build_status.running = false 63 | vim.api.nvim_exec_autocmds("User", { pattern = "DevcontainerBuildProgress", modeline = false }) 64 | if config.cache_images then 65 | local tag = u.get_image_cache_tag() 66 | container_runtime.container_commit(container_id, { 67 | tag = tag, 68 | }) 69 | end 70 | opts.on_success() 71 | end, 72 | on_step = function(step) 73 | current_step = current_step + 1 74 | build_status.current_step = current_step 75 | build_status.progress = math.floor((build_status.current_step / build_status.step_count) * 100) 76 | vim.api.nvim_exec_autocmds("User", { pattern = "DevcontainerBuildProgress", modeline = false }) 77 | opts.on_step(step) 78 | end, 79 | on_fail = opts.on_fail, 80 | }) 81 | end 82 | 83 | local version_string = opts.version 84 | if not version_string then 85 | local version = vim.version() 86 | version_string = "v" .. version.major .. "." .. version.minor .. "." .. version.patch 87 | end 88 | container_runtime.exec(container_id, { 89 | command = { "compgen", "-c" }, 90 | on_success = function(result) 91 | local available_commands = {} 92 | if result then 93 | local result_lines = vim.split(result, "\n") 94 | for _, line in ipairs(result_lines) do 95 | if v then 96 | table.insert(available_commands, line) 97 | end 98 | end 99 | end 100 | local commands = config.nvim_installation_commands_provider(available_commands, version_string) 101 | run_commands(commands) 102 | end, 103 | on_fail = function() 104 | local commands = config.nvim_installation_commands_provider({}, version_string) 105 | run_commands(commands) 106 | end, 107 | }) 108 | end 109 | 110 | log.wrap(M) 111 | return M 112 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/compose/devcontainer.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.compose.devcontainer Devcontainer CLI compose runtime module 2 | ---@brief [[ 3 | ---Provides functions related to compose control 4 | ---@brief ]] 5 | local devcontainer_cli = require("devcontainer.internal.runtimes.container.devcontainer") 6 | local log = require("devcontainer.internal.log") 7 | 8 | local M = {} 9 | 10 | ---Run compose up with passed file 11 | ---@param _ string|table path to docker-compose.yml file or files - ignored 12 | ---@param opts ComposeUpOpts Additional options including callbacks 13 | function M:up(_, opts) 14 | local _ = self 15 | devcontainer_cli:run("", { on_success = opts.on_success, on_fail = opts.on_fail }) 16 | end 17 | 18 | log.wrap(M) 19 | return M 20 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/compose/docker-compose.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.compose.docker-compose Docker-compose compose runtime module 2 | ---@brief [[ 3 | ---Provides functions related to docker-compose control 4 | ---@brief ]] 5 | local log = require("devcontainer.internal.log") 6 | local common = require("devcontainer.internal.runtimes.helpers.common_compose") 7 | 8 | local M = common.new({ runtime = "docker-compose" }) 9 | 10 | log.wrap(M) 11 | return M 12 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/compose/docker.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.compose.docker Docker compose runtime module 2 | ---@brief [[ 3 | ---Provides functions related to docker compose control 4 | ---@brief ]] 5 | local log = require("devcontainer.internal.log") 6 | local common = require("devcontainer.internal.runtimes.helpers.common_compose") 7 | 8 | local M = common.new({ runtime = "docker compose" }) 9 | 10 | log.wrap(M) 11 | return M 12 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/compose/podman-compose.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.compose.podman-compose Podman-compose compose runtime module 2 | ---@brief [[ 3 | ---Provides functions related to podman-compose control 4 | ---@brief ]] 5 | local log = require("devcontainer.internal.log") 6 | local common = require("devcontainer.internal.runtimes.helpers.common_compose") 7 | 8 | local M = common.new({ runtime = "podman-compose" }) 9 | 10 | log.wrap(M) 11 | return M 12 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/container/devcontainer.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.container.devcontainer Devcontainer CLI container runtime module 2 | ---@brief [[ 3 | ---Provides functions related to devcontainer control: 4 | --- - building 5 | --- - attaching 6 | --- - running 7 | ---@brief ]] 8 | local log = require("devcontainer.internal.log") 9 | local exe = require("devcontainer.internal.executor") 10 | local config = require("devcontainer.config") 11 | 12 | local M = {} 13 | 14 | ---Runs command with passed arguments 15 | ---@param args string[] 16 | ---@param opts? RunCommandOpts 17 | ---@param onexit function(code, signal) 18 | local function run_with_current_runtime(args, opts, onexit) 19 | exe.ensure_executable("devcontainer") 20 | 21 | opts = opts or {} 22 | exe.run_command( 23 | "devcontainer", 24 | vim.tbl_extend("force", opts, { 25 | args = args, 26 | stderr = vim.schedule_wrap(function(err, data) 27 | if data then 28 | log.fmt_error("devcontainer command (%s): %s", args, data) 29 | end 30 | if opts.stderr then 31 | opts.stderr(err, data) 32 | end 33 | end), 34 | }), 35 | onexit 36 | ) 37 | end 38 | 39 | ---Build image for passed workspace 40 | ---@param _ string Path to Dockerfile to build 41 | ---@param path string Path to the workspace 42 | ---@param opts ContainerBuildOpts Additional options including callbacks and tag 43 | function M:build(_, path, opts) 44 | local _ = self 45 | local command = { "--workspace-folder", path, "build" } 46 | run_with_current_runtime(command, {}, function(code, _) 47 | if code == 0 then 48 | opts.on_success("") 49 | else 50 | opts.on_fail() 51 | end 52 | end) 53 | end 54 | 55 | ---Run passed image using devcontainer CLI 56 | ---@param _ string image to run - ignored - using workspace folder 57 | ---@param opts ContainerRunOpts Additional options including callbacks 58 | function M:run(_, opts) 59 | local _ = self 60 | local command = { "--workspace-folder", config.workspace_folder_provider(), "up" } 61 | run_with_current_runtime(command, {}, function(code, _) 62 | if code == 0 then 63 | opts.on_success("") 64 | else 65 | opts.on_fail() 66 | end 67 | end) 68 | end 69 | 70 | ---Run command on a container using devcontainer cli 71 | ---@param _ string container to exec on - ignored - using workspace folder 72 | ---@param opts ContainerExecOpts Additional options including callbacks 73 | function M:exec(_, opts) 74 | local _ = self 75 | local command = { "--workspace-folder", config.workspace_folder_provider(), "exec" } 76 | vim.list_extend(command, opts.args or {}) 77 | local run_opts = nil 78 | local captured = nil 79 | if opts.capture_output then 80 | run_opts = { 81 | stdout = function(_, data) 82 | if data then 83 | captured = data 84 | end 85 | end, 86 | } 87 | end 88 | run_with_current_runtime(command, run_opts, function(code, _) 89 | if code == 0 then 90 | if opts.capture_output then 91 | opts.on_success(captured) 92 | else 93 | opts.on_success(nil) 94 | end 95 | else 96 | opts.on_fail() 97 | end 98 | end) 99 | end 100 | 101 | log.wrap(M) 102 | return M 103 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/container/docker.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.container.docker Docker container runtime module 2 | ---@brief [[ 3 | ---Provides functions related to docker control: 4 | --- - building 5 | --- - attaching 6 | --- - running 7 | ---@brief ]] 8 | local log = require("devcontainer.internal.log") 9 | local common = require("devcontainer.internal.runtimes.helpers.common_container") 10 | 11 | local M = common.new({ runtime = "docker" }) 12 | 13 | log.wrap(M) 14 | return M 15 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/container/podman.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.container.podman Podman container runtime module 2 | ---@brief [[ 3 | ---Provides functions related to podman control: 4 | --- - building 5 | --- - attaching 6 | --- - running 7 | ---@brief ]] 8 | local log = require("devcontainer.internal.log") 9 | local common = require("devcontainer.internal.runtimes.helpers.common_container") 10 | 11 | local M = common.new({ runtime = "podman" }) 12 | 13 | ---Run passed image using podman run 14 | ---@param image string image to run 15 | ---@param opts ContainerRunOpts Additional options including callbacks 16 | function M:run(image, opts) 17 | local args = opts.args 18 | if args then 19 | local next = false 20 | for k, v in ipairs(args) do 21 | if next then 22 | next = false 23 | local mount = v 24 | if not string.match(mount, "z=true") then 25 | mount = mount .. ",z=true" 26 | args[k] = mount 27 | end 28 | end 29 | if v == "--mount" then 30 | next = true 31 | end 32 | end 33 | end 34 | return common.run(self, image, opts) 35 | end 36 | 37 | log.wrap(M) 38 | return M 39 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/helpers/common_compose.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.helpers.common_compose Common commands that should work with compose runtimes 2 | ---@brief [[ 3 | ---Provides functions related to compose control 4 | ---@brief ]] 5 | local exe = require("devcontainer.internal.executor") 6 | local log = require("devcontainer.internal.log") 7 | local config = require("devcontainer.config") 8 | local utils = require("devcontainer.internal.utils") 9 | 10 | local M = {} 11 | 12 | ---Runs compose command with passed arguments 13 | ---@param args string[] 14 | ---@param opts? RunCommandOpts 15 | ---@param onexit function(code, signal) 16 | local function run_current_compose_command(self, args, opts, onexit) 17 | local runtime = tostring(self.runtime) or config.compose_command 18 | exe.ensure_executable(runtime) 19 | 20 | if string.match(runtime, " ") then 21 | local parts = vim.split(runtime, " ") 22 | runtime = parts[1] 23 | table.remove(parts, 1) 24 | vim.list_extend(parts, args) 25 | args = parts 26 | end 27 | 28 | opts = opts or {} 29 | exe.run_command( 30 | runtime, 31 | vim.tbl_extend("force", opts, { 32 | args = args, 33 | stderr = vim.schedule_wrap(function(err, data) 34 | if data then 35 | log.fmt_error("%s command (%s): %s", runtime, args, data) 36 | end 37 | if opts.stderr then 38 | opts.stderr(err, data) 39 | end 40 | end), 41 | }), 42 | onexit 43 | ) 44 | end 45 | 46 | ---Prepare compose command arguments with file or files 47 | ---@param compose_file string|table 48 | local function get_compose_files_command(compose_file) 49 | local command = nil 50 | if type(compose_file) == "table" then 51 | command = {} 52 | for _, file in ipairs(compose_file) do 53 | table.insert(command, "-f") 54 | table.insert(command, file) 55 | end 56 | elseif type(compose_file) == "string" then 57 | command = { "-f", compose_file } 58 | end 59 | return command 60 | end 61 | 62 | ---Run compose up with passed file 63 | ---@param compose_file string|table path to docker-compose.yml file or files 64 | ---@param opts ComposeUpOpts Additional options including callbacks 65 | function M:up(compose_file, opts) 66 | local command = get_compose_files_command(compose_file) 67 | vim.list_extend(command, { "up", "-d" }) 68 | vim.list_extend(command, opts.args or {}) 69 | run_current_compose_command(self, command, nil, function(code, _) 70 | if code == 0 then 71 | opts.on_success() 72 | else 73 | opts.on_fail() 74 | end 75 | end) 76 | end 77 | 78 | ---Run compose down with passed file 79 | ---@param compose_file string|table path to docker-compose.yml file or files 80 | ---@param opts ComposeDownOpts Additional options including callbacks 81 | function M:down(compose_file, opts) 82 | local command = get_compose_files_command(compose_file) 83 | vim.list_extend(command, { "down" }) 84 | run_current_compose_command(self, command, nil, function(code, _) 85 | if code == 0 then 86 | opts.on_success() 87 | else 88 | opts.on_fail() 89 | end 90 | end) 91 | end 92 | 93 | ---Run compose ps with passed file and service to get its container_id 94 | ---@param compose_file string|table path to docker-compose.yml file or files 95 | ---@param service string service name 96 | ---@param opts ComposeGetContainerIdOpts Additional options including callbacks 97 | function M:get_container_id(compose_file, service, opts) 98 | local command = get_compose_files_command(compose_file) 99 | vim.list_extend(command, { "ps", "-q", service }) 100 | local container_id = nil 101 | run_current_compose_command(self, command, { 102 | stdout = function(_, data) 103 | if data then 104 | container_id = vim.split(data, "\n")[1] 105 | end 106 | end, 107 | }, function(code, _) 108 | if code == 0 then 109 | opts.on_success(container_id) 110 | else 111 | opts.on_fail() 112 | end 113 | end) 114 | end 115 | 116 | M = utils.add_constructor(M) 117 | log.wrap(M) 118 | return M 119 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/helpers/common_container.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes.helpers.common_container Common commands that should work for most runtimes 2 | ---@brief [[ 3 | ---Provides functions related to container control: 4 | --- - building 5 | --- - attaching 6 | --- - running 7 | ---@brief ]] 8 | local exe = require("devcontainer.internal.executor") 9 | local config = require("devcontainer.config") 10 | local status = require("devcontainer.status") 11 | local log = require("devcontainer.internal.log") 12 | local utils = require("devcontainer.internal.utils") 13 | 14 | local M = {} 15 | 16 | ---Runs command with passed arguments 17 | ---@param args string[] 18 | ---@param opts? RunCommandOpts 19 | ---@param onexit function(code, signal) 20 | local function run_with_current_runtime(self, args, opts, onexit) 21 | local runtime = tostring(self.runtime) or config.container_runtime 22 | exe.ensure_executable(runtime) 23 | 24 | opts = opts or {} 25 | exe.run_command( 26 | runtime, 27 | vim.tbl_extend("force", opts, { 28 | args = args, 29 | stderr = vim.schedule_wrap(function(err, data) 30 | if data then 31 | log.fmt_error("%s command (%s): %s", runtime, args, data) 32 | end 33 | if opts.stderr then 34 | opts.stderr(err, data) 35 | end 36 | end), 37 | }), 38 | onexit 39 | ) 40 | end 41 | 42 | ---Pull passed image using 43 | ---@param image string image to pull 44 | ---@param opts ContainerPullOpts Additional options including callbacks 45 | function M:pull(image, opts) 46 | run_with_current_runtime(self, { "pull", image }, nil, function(code, _) 47 | if code == 0 then 48 | opts.on_success() 49 | else 50 | opts.on_fail() 51 | end 52 | end) 53 | end 54 | 55 | ---Build image from passed dockerfile 56 | ---@param file string Path to Dockerfile to build 57 | ---@param path string Path to the workspace 58 | ---@param opts ContainerBuildOpts Additional options including callbacks and tag 59 | function M:build(file, path, opts) 60 | local command = { "build", "-f", file, path } 61 | if opts.tag then 62 | table.insert(command, "-t") 63 | table.insert(command, opts.tag) 64 | end 65 | 66 | local id_temp_file = os.tmpname() 67 | table.insert(command, "--iidfile") 68 | table.insert(command, id_temp_file) 69 | 70 | vim.list_extend(command, opts.args or {}) 71 | 72 | local build_status = { 73 | build_title = "Dockerfile: " .. file, 74 | progress = 0, 75 | step_count = 0, 76 | current_step = 0, 77 | image_id = nil, 78 | source_dockerfile = file, 79 | build_command = table.concat(vim.list_extend({ config.container_runtime }, command), " "), 80 | commands_run = {}, 81 | running = true, 82 | } 83 | status.add_build(build_status) 84 | 85 | local image_id = nil 86 | run_with_current_runtime(self, command, { 87 | stdout = vim.schedule_wrap(function(_, data) 88 | if data then 89 | local lines = vim.split(data, "\n") 90 | local step_regex = vim.regex("\\cStep [[:digit:]]*/[[:digit:]]* *: .*") 91 | for _, line in ipairs(lines) do 92 | ---@diagnostic disable-next-line: need-check-nil 93 | if step_regex:match_str(line) then 94 | local step_line = vim.split(line, ":") 95 | local step_numbers = vim.split(vim.split(step_line[1], " ")[2], "/") 96 | table.insert(build_status.commands_run, string.sub(step_line[2], 2)) 97 | build_status.current_step = tonumber(step_numbers[1]) 98 | build_status.step_count = tonumber(step_numbers[2]) 99 | build_status.progress = math.floor((build_status.current_step / build_status.step_count) * 100) 100 | opts.on_progress(vim.deepcopy(build_status)) 101 | end 102 | end 103 | end 104 | end), 105 | }, function(code, _) 106 | image_id = vim.fn.readfile(id_temp_file)[1] 107 | vim.fn.delete(id_temp_file) 108 | build_status.running = false 109 | opts.on_progress(vim.deepcopy(build_status)) 110 | if code == 0 then 111 | status.add_image({ 112 | image_id = image_id, 113 | source_dockerfile = file, 114 | }) 115 | opts.on_success(image_id) 116 | else 117 | opts.on_fail() 118 | end 119 | end) 120 | end 121 | 122 | ---Run passed image using 123 | ---@param image string image to run 124 | ---@param opts ContainerRunOpts Additional options including callbacks 125 | function M:run(image, opts) 126 | local command = { "run", "-i", "-d" } 127 | if opts.autoremove == true then 128 | table.insert(command, "--rm") 129 | end 130 | 131 | vim.list_extend(command, opts.args or {}) 132 | 133 | table.insert(command, image) 134 | if opts.command then 135 | if type(opts.command) == "string" then 136 | table.insert(command, opts.command) 137 | elseif type(opts.command) == "table" then 138 | ---@diagnostic disable-next-line: param-type-mismatch 139 | vim.list_extend(command, opts.command) 140 | end 141 | end 142 | 143 | local container_id = nil 144 | run_with_current_runtime(self, command, { 145 | stdout = function(_, data) 146 | if data then 147 | container_id = vim.split(data, "\n")[1] 148 | end 149 | end, 150 | }, function(code, _) 151 | if code == 0 then 152 | status.add_container({ 153 | image_id = image, 154 | container_id = container_id, 155 | autoremove = opts.autoremove, 156 | }) 157 | opts.on_success(container_id) 158 | else 159 | opts.on_fail() 160 | end 161 | end) 162 | end 163 | 164 | ---Run command on a container using 165 | ---@param container_id string container to exec on 166 | ---@param opts ContainerExecOpts Additional options including callbacks 167 | function M:exec(container_id, opts) 168 | local command = { "exec", "-i" } 169 | if opts.tty then 170 | table.insert(command, "-t") 171 | end 172 | 173 | vim.list_extend(command, opts.args or {}) 174 | 175 | table.insert(command, container_id) 176 | if opts.command then 177 | if type(opts.command) == "string" then 178 | table.insert(command, opts.command) 179 | elseif type(opts.command) == "table" then 180 | ---@diagnostic disable-next-line: param-type-mismatch 181 | vim.list_extend(command, opts.command) 182 | end 183 | end 184 | 185 | if opts.tty then 186 | (opts.terminal_handler or config.terminal_handler)(vim.list_extend({ config.container_runtime }, command)) 187 | else 188 | local run_opts = nil 189 | local captured = nil 190 | if opts.capture_output then 191 | run_opts = { 192 | stdout = function(_, data) 193 | if data then 194 | captured = data 195 | end 196 | end, 197 | } 198 | end 199 | run_with_current_runtime(self, command, run_opts, function(code, _) 200 | if code == 0 then 201 | if opts.capture_output then 202 | opts.on_success(captured) 203 | else 204 | opts.on_success(nil) 205 | end 206 | else 207 | opts.on_fail() 208 | end 209 | end) 210 | end 211 | end 212 | 213 | ---Stop passed containers 214 | ---@param containers table[string] ids of containers to stop 215 | ---@param opts ContainerStopOpts Additional options including callbacks 216 | function M:container_stop(containers, opts) 217 | local command = { "container", "stop" } 218 | 219 | vim.list_extend(command, containers) 220 | run_with_current_runtime(self, command, nil, function(code, _) 221 | if code == 0 then 222 | for _, container in ipairs(containers) do 223 | status.move_container_to_stopped(container) 224 | end 225 | opts.on_success() 226 | else 227 | opts.on_fail() 228 | end 229 | end) 230 | end 231 | 232 | ---Commit container into an image 233 | ---@param container string id of container to commit 234 | ---@param opts ContainerCommitOpts Additional options including callbacks 235 | function M:container_commit(container, opts) 236 | local command = { "commit", container, opts.tag } 237 | 238 | run_with_current_runtime(self, command, nil, function(code, _) 239 | if code == 0 then 240 | opts.on_success() 241 | else 242 | opts.on_fail() 243 | end 244 | end) 245 | end 246 | 247 | ---Removes passed images 248 | ---@param images table[string] ids of images to remove 249 | ---@param opts ImageRmOpts Additional options including callbacks 250 | function M:image_rm(images, opts) 251 | local command = { "image", "rm" } 252 | 253 | if opts.force then 254 | table.insert(command, "-f") 255 | end 256 | 257 | vim.list_extend(command, images) 258 | run_with_current_runtime(self, command, nil, function(code, _) 259 | if code == 0 then 260 | for _, image in ipairs(images) do 261 | status.remove_image(image) 262 | end 263 | opts.on_success() 264 | else 265 | opts.on_fail() 266 | end 267 | end) 268 | end 269 | 270 | ---Inspect image using image inspect command 271 | ---@param image string id of image 272 | ---@param opts ImageInspectOpts Additional options including callbacks 273 | function M:image_inspect(image, opts) 274 | local command = { "image", "inspect", image } 275 | if opts.format ~= nil then 276 | vim.list_extend(command, { "--format", opts.format }) 277 | end 278 | 279 | local response = nil 280 | run_with_current_runtime(self, command, { 281 | stdout = function(_, data) 282 | if data then 283 | if opts.format ~= nil then 284 | response = data 285 | else 286 | response = vim.json.decode(data) 287 | end 288 | end 289 | end, 290 | }, function(code, _) 291 | if code == 0 then 292 | opts.on_success(response) 293 | else 294 | opts.on_fail() 295 | end 296 | end) 297 | end 298 | 299 | ---Checks if image contains another image 300 | ---@param parent_image string id of image that should contain other image 301 | ---@param child_image string id of image that should be contained in the parent image 302 | ---@param opts ImageContainsOpts Additional options including callbacks 303 | function M:image_contains(parent_image, child_image, opts) 304 | local notified_error = false 305 | local notified_success = false 306 | local parent_done = false 307 | local parent_layers = {} 308 | local child_done = false 309 | local child_layers = {} 310 | 311 | local function parse_layers(data) 312 | if data then 313 | local cleaned = string.gsub(data, "[%[%]]", "") 314 | return vim.split(cleaned, " ") 315 | else 316 | return {} 317 | end 318 | end 319 | 320 | local function notify_error() 321 | if not notified_error then 322 | notified_error = true 323 | opts.on_fail() 324 | end 325 | end 326 | 327 | local function notify_success() 328 | if parent_done and child_done and not notified_success then 329 | notified_success = true 330 | for _, v in ipairs(child_layers) do 331 | local contains = false 332 | for _, pv in ipairs(parent_layers) do 333 | if v == pv then 334 | contains = true 335 | break 336 | end 337 | end 338 | if not contains then 339 | opts.on_success(false) 340 | return 341 | end 342 | end 343 | opts.on_success(true) 344 | end 345 | end 346 | 347 | local format = "{{.RootFS.Layers}}" 348 | 349 | self:image_inspect(parent_image, { 350 | format = format, 351 | on_success = function(response) 352 | parent_layers = parse_layers(response) 353 | parent_done = true 354 | notify_success() 355 | end, 356 | on_fail = notify_error, 357 | }) 358 | self:image_inspect(child_image, { 359 | format = format, 360 | on_success = function(response) 361 | child_layers = parse_layers(response) 362 | child_done = true 363 | notify_success() 364 | end, 365 | on_fail = notify_error, 366 | }) 367 | end 368 | 369 | ---Removes passed containers 370 | ---@param containers table[string] ids of containers to remove 371 | ---@param opts ContainerRmOpts Additional options including callbacks 372 | function M:container_rm(containers, opts) 373 | local command = { "container", "rm" } 374 | 375 | if opts.force then 376 | table.insert(command, "-f") 377 | end 378 | 379 | vim.list_extend(command, containers) 380 | run_with_current_runtime(self, command, nil, function(code, _) 381 | if code == 0 then 382 | for _, container in ipairs(containers) do 383 | status.remove_container(container) 384 | end 385 | opts.on_success() 386 | else 387 | opts.on_fail() 388 | end 389 | end) 390 | end 391 | 392 | ---Lists containers 393 | ---@param opts ContainerLsOpts Additional options including callbacks 394 | function M:container_ls(opts) 395 | local command = { "container", "ls", "--format", "{{.Names}}" } 396 | 397 | if opts.all then 398 | table.insert(command, "-a") 399 | end 400 | 401 | local containers = {} 402 | local parse_and_store_containers = function(data) 403 | if data then 404 | local new_containers = vim.split(data, "\n") 405 | for _, v in ipairs(new_containers) do 406 | if v then 407 | table.insert(containers, v) 408 | end 409 | end 410 | end 411 | end 412 | if opts.async ~= false then 413 | run_with_current_runtime(self, command, { 414 | stdout = function(_, data) 415 | parse_and_store_containers(data) 416 | end, 417 | }, function(code, _) 418 | if code == 0 then 419 | opts.on_success(containers) 420 | else 421 | opts.on_fail() 422 | end 423 | end) 424 | else 425 | table.insert(command, 1, config.container_runtime) 426 | local code, result = exe.run_command_sync(command) 427 | if code == 0 then 428 | parse_and_store_containers(result) 429 | return containers 430 | else 431 | error("Code: " .. code .. ". Message: " .. result) 432 | end 433 | end 434 | end 435 | 436 | M = utils.add_constructor(M) 437 | log.wrap(M) 438 | return M 439 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/runtimes/init.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.internal.runtimes Runtimes module 2 | ---@brief [[ 3 | ---Provides shared interface for runtimes 4 | ---Expects valid inputs and does not do any validation on its own 5 | ---@brief ]] 6 | 7 | local M = {} 8 | 9 | local config = require("devcontainer.config") 10 | 11 | local function get_current_container_runtime() 12 | if config.container_runtime == "docker" then 13 | return require("devcontainer.internal.runtimes.container.docker") 14 | elseif config.container_runtime == "podman" then 15 | return require("devcontainer.internal.runtimes.container.podman") 16 | elseif config.container_runtime == "devcontainer-cli" then 17 | return require("devcontainer.internal.runtimes.container.devcontainer") 18 | end 19 | -- Default 20 | return require("devcontainer.internal.runtimes.helpers.common_container").new({ runtime = config.container_runtime }) 21 | end 22 | 23 | local function get_backup_container_runtime() 24 | if config.backup_runtime == "docker" then 25 | return require("devcontainer.internal.runtimes.container.docker") 26 | elseif config.backup_runtime == "podman" then 27 | return require("devcontainer.internal.runtimes.container.podman") 28 | elseif config.backup_runtime == "devcontainer-cli" then 29 | return require("devcontainer.internal.runtimes.container.devcontainer") 30 | end 31 | -- Default 32 | return require("devcontainer.internal.runtimes.helpers.common_container").new({ runtime = config.backup_runtime }) 33 | end 34 | 35 | local function get_current_compose_runtime() 36 | if config.compose_command == "docker-compose" then 37 | return require("devcontainer.internal.runtimes.compose.docker-compose") 38 | elseif config.compose_command == "podman-compose" then 39 | return require("devcontainer.internal.runtimes.compose.podman-compose") 40 | elseif config.compose_command == "docker compose" then 41 | return require("devcontainer.internal.runtimes.compose.docker") 42 | elseif config.compose_command == "devcontainer-cli" then 43 | return require("devcontainer.internal.runtimes.compose.devcontainer") 44 | end 45 | -- Default 46 | return require("devcontainer.internal.runtimes.helpers.common_compose").new({ runtime = config.compose_command }) 47 | end 48 | 49 | local function get_backup_compose_runtime() 50 | if config.backup_compose_command == "docker-compose" then 51 | return require("devcontainer.internal.runtimes.compose.docker-compose") 52 | elseif config.backup_compose_command == "podman-compose" then 53 | return require("devcontainer.internal.runtimes.compose.podman-compose") 54 | elseif config.backup_compose_command == "docker compose" then 55 | return require("devcontainer.internal.runtimes.compose.docker") 56 | elseif config.backup_compose_command == "devcontainer-cli" then 57 | return require("devcontainer.internal.runtimes.compose.devcontainer") 58 | end 59 | -- Default 60 | return require("devcontainer.internal.runtimes.helpers.common_compose").new({ 61 | runtime = config.backup_compose_command, 62 | }) 63 | end 64 | 65 | local function run_with_container(required_func, callback) 66 | local current = get_current_container_runtime() 67 | if current[required_func] then 68 | return callback(current, current[required_func]) 69 | else 70 | local backup = get_backup_container_runtime() 71 | if backup[required_func] then 72 | return callback(backup, backup[required_func]) 73 | else 74 | vim.notify( 75 | "Function " 76 | .. required_func 77 | .. " is not supported on either " 78 | .. config.container_runtime 79 | .. " or " 80 | .. config.backup_runtime 81 | ) 82 | return nil 83 | end 84 | end 85 | end 86 | 87 | local function run_with_compose(required_func, callback) 88 | local current = get_current_compose_runtime() 89 | if current[required_func] then 90 | return callback(current, current[required_func]) 91 | else 92 | local backup = get_backup_compose_runtime() 93 | if backup[required_func] then 94 | return callback(backup, backup[required_func]) 95 | else 96 | vim.notify( 97 | "Function " 98 | .. required_func 99 | .. " is not supported on either " 100 | .. config.compose_command 101 | .. " or " 102 | .. config.backup_compose_command 103 | ) 104 | return nil 105 | end 106 | end 107 | end 108 | 109 | M.container = {} 110 | 111 | ---Pull passed image using current container runtime 112 | ---@param image string Image to pull 113 | ---@param opts ContainerPullOpts Additional options including callbacks 114 | function M.container.pull(image, opts) 115 | return run_with_container("pull", function(instance, func) 116 | return func(instance, image, opts) 117 | end) 118 | end 119 | 120 | ---Build image from passed dockerfile using current container runtime 121 | ---@param file string Path to file (Dockerfile or something else) to build 122 | ---@param path string Path to the workspace, vim.lsp.buf.list_workspace_folders()[1] by default 123 | ---@param opts ContainerBuildOpts Additional options including callbacks and tag 124 | function M.container.build(file, path, opts) 125 | return run_with_container("build", function(instance, func) 126 | return func(instance, file, path, opts) 127 | end) 128 | end 129 | 130 | ---Run passed image using current container runtime 131 | ---NOTE: If terminal_handler is passed, then it needs to start the process too - default termopen does just that 132 | ---@param image string Image to run 133 | ---@param opts ContainerRunOpts Additional options including callbacks 134 | function M.container.run(image, opts) 135 | return run_with_container("run", function(instance, func) 136 | return func(instance, image, opts) 137 | end) 138 | end 139 | 140 | ---Run command on a container using current container runtime 141 | ---Useful for attaching to neovim, or running arbitrary commands in container 142 | ---NOTE: If terminal_handler is passed, then it needs to start the process too - default termopen does just that 143 | ---@param container_id string Container to exec on 144 | ---@param opts ContainerExecOpts Additional options including callbacks 145 | function M.container.exec(container_id, opts) 146 | return run_with_container("exec", function(instance, func) 147 | return func(instance, container_id, opts) 148 | end) 149 | end 150 | 151 | ---Stop passed containers 152 | ---@param containers table[string] ids of containers to stop 153 | ---@param opts ContainerStopOpts Additional options including callbacks 154 | function M.container.container_stop(containers, opts) 155 | return run_with_container("container_stop", function(instance, func) 156 | return func(instance, containers, opts) 157 | end) 158 | end 159 | 160 | ---Commit passed container 161 | ---@param container string id of container to commit 162 | ---@param opts ContainerCommitOpts Additional options including callbacks 163 | function M.container.container_commit(container, opts) 164 | return run_with_container("container_commit", function(instance, func) 165 | return func(instance, container, opts) 166 | end) 167 | end 168 | 169 | ---Inspect image 170 | ---@param image string id of image 171 | ---@param opts ImageInspectOpts Additional options including callbacks 172 | function M.container.image_inspect(image, opts) 173 | return run_with_container("image_inspect", function(instance, func) 174 | return func(instance, image, opts) 175 | end) 176 | end 177 | 178 | ---Checks if image contains another image 179 | ---@param parent_image string id of image that should contain other image 180 | ---@param child_image string id of image that should be contained in the parent image 181 | ---@param opts ImageContainsOpts Additional options including callbacks 182 | function M.container.image_contains(parent_image, child_image, opts) 183 | return run_with_container("image_contains", function(instance, func) 184 | return func(instance, parent_image, child_image, opts) 185 | end) 186 | end 187 | 188 | ---Removes passed images 189 | ---@param images table[string] ids of images to remove 190 | ---@param opts ImageRmOpts Additional options including callbacks 191 | function M.container.image_rm(images, opts) 192 | return run_with_container("image_rm", function(instance, func) 193 | return func(instance, images, opts) 194 | end) 195 | end 196 | 197 | ---Removes passed containers 198 | ---@param containers table[string] ids of containers to remove 199 | ---@param opts ContainerRmOpts Additional options including callbacks 200 | function M.container.container_rm(containers, opts) 201 | return run_with_container("container_rm", function(instance, func) 202 | return func(instance, containers, opts) 203 | end) 204 | end 205 | 206 | ---Lists containers 207 | ---@param opts ContainerLsOpts Additional options including callbacks 208 | function M.container.container_ls(opts) 209 | return run_with_container("container_ls", function(instance, func) 210 | return func(instance, opts) 211 | end) 212 | end 213 | 214 | M.compose = {} 215 | 216 | ---Run compose up with passed file 217 | ---@param compose_file string|table path to docker-compose.yml file or files 218 | ---@param opts ComposeUpOpts Additional options including callbacks 219 | function M.compose.up(compose_file, opts) 220 | return run_with_compose("up", function(instance, func) 221 | return func(instance, compose_file, opts) 222 | end) 223 | end 224 | 225 | ---Run compose down with passed file 226 | ---@param compose_file string|table path to docker-compose.yml file or files 227 | ---@param opts ComposeDownOpts Additional options including callbacks 228 | function M.compose.down(compose_file, opts) 229 | return run_with_compose("down", function(instance, func) 230 | return func(instance, compose_file, opts) 231 | end) 232 | end 233 | 234 | ---Run compose ps with passed file and service to get its container_id 235 | ---@param compose_file string|table path to docker-compose.yml file or files 236 | ---@param service string service name 237 | ---@param opts ComposeGetContainerIdOpts Additional options including callbacks 238 | function M.compose.get_container_id(compose_file, service, opts) 239 | return run_with_compose("get_container_id", function(instance, func) 240 | return func(instance, compose_file, service, opts) 241 | end) 242 | end 243 | 244 | return M 245 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/utils.lua: -------------------------------------------------------------------------------- 1 | local plugin_config = require("devcontainer.config") 2 | 3 | local M = {} 4 | 5 | M.path_sep = package.config:sub(1, 1) 6 | 7 | function M.add_constructor(table) 8 | table.new = function(extras) 9 | local new_instance = {} 10 | setmetatable(new_instance, { __index = table }) 11 | if extras and type(extras) == "table" and not vim.tbl_islist(extras) then 12 | for k, v in pairs(extras) do 13 | new_instance[k] = v 14 | end 15 | end 16 | return new_instance 17 | end 18 | return table 19 | end 20 | 21 | function M.get_image_cache_tag() 22 | local tag = plugin_config.workspace_folder_provider() 23 | tag = string.gsub(tag, "[%/%s%-%\\%:]", "") 24 | return "nvim_dev_container_" .. string.lower(tag) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/devcontainer/internal/validation.lua: -------------------------------------------------------------------------------- 1 | ---@brief [[ 2 | ---Internal library for validation 3 | ---@brief ]] 4 | 5 | local M = {} 6 | 7 | M.callbacks_validations = { 8 | on_success = "function", 9 | on_fail = "function", 10 | } 11 | 12 | function M.validate_opts_with_callbacks(opts, mapping) 13 | M.validate_opts(opts, vim.tbl_extend("error", M.callbacks_validations, mapping)) 14 | end 15 | 16 | function M.validate_callbacks(opts) 17 | M.validate_opts(opts, M.callbacks_validations) 18 | end 19 | 20 | function M.validate_opts(opts, mapping) 21 | M.validate_deep(opts, "opts", mapping) 22 | end 23 | 24 | function M.validate_deep(obj, name, mapping) 25 | local validation = {} 26 | for k, v in pairs(mapping) do 27 | if type(v) == "function" then 28 | validation[name .. "." .. k] = { obj[k], v } 29 | elseif type(v) == "table" then 30 | validation[name .. "." .. k] = { obj[k], vim.list_extend(v, { "nil" }) } 31 | else 32 | validation[name .. "." .. k] = { obj[k], { v, "nil" } } 33 | end 34 | end 35 | vim.validate(validation) 36 | end 37 | 38 | return M 39 | -------------------------------------------------------------------------------- /lua/devcontainer/status.lua: -------------------------------------------------------------------------------- 1 | ---@mod devcontainer.status Devcontainer plugin config module 2 | ---@brief [[ 3 | ---Provides access to current status and is used internally to update it 4 | ---Don't change directly! 5 | ---Can be used for read-only access 6 | ---@brief ]] 7 | 8 | local M = {} 9 | 10 | ---@class DevcontainerImageStatus 11 | ---@field image_id string id of the image 12 | ---@field source_dockerfile string path to the file used to build the image 13 | ---@field tmp_dockerfile? string path to temporary dockerfile if add neovim was used 14 | -- 15 | ---@class DevcontainerImageQuery 16 | ---@field image_id? string id of the image 17 | ---@field source_dockerfile? string path to the file used to build the image 18 | 19 | ---@class DevcontainerContainerStatus 20 | ---@field container_id string id of the container 21 | ---@field image_id string id of the used image 22 | ---@field autoremove boolean true if this container was started with autoremove flag 23 | 24 | ---@class DevcontainerContainerQuery 25 | ---@field container_id? string id of the container 26 | ---@field image_id? string id of the used image 27 | 28 | ---@class DevcontainerComposeStatus 29 | ---@field file string path to compose file 30 | 31 | ---@class DevcontainerBuildStatus 32 | ---@field build_title string description of the build 33 | ---@field progress number 0-100 percentage 34 | ---@field step_count number number of steps to build 35 | ---@field current_step number current step 36 | ---@field image_id? string id of the built image 37 | ---@field source_dockerfile? string path to the file used to build the image 38 | ---@field build_command string command used to build the image 39 | ---@field commands_run string list of commands run by build (layers) 40 | ---@field running boolean true if still running 41 | 42 | ---@class DevcontainerStatus 43 | ---@field images_built table[DevcontainerImageStatus] 44 | ---@field running_containers table[DevcontainerContainerStatus] 45 | ---@field stopped_containers table[DevcontainerContainerStatus] 46 | ---@field build_status table[DevcontainerBuildStatus] 47 | ---@field compose_services table[DevcontainerComposeStatus] 48 | 49 | ---@type DevcontainerStatus 50 | local current_status = { 51 | images_built = {}, 52 | running_containers = {}, 53 | stopped_containers = {}, 54 | build_status = {}, 55 | compose_services = {}, 56 | } 57 | 58 | ---Finds container with requested opts 59 | ---@param opts DevcontainerContainerQuery required opts 60 | ---@return DevcontainerContainerStatus? 61 | local function get_container(opts) 62 | local all_containers = {} 63 | vim.list_extend(all_containers, current_status.running_containers) 64 | vim.list_extend(all_containers, current_status.stopped_containers) 65 | if not opts then 66 | return all_containers[1] 67 | end 68 | for _, v in ipairs(all_containers) do 69 | if opts.image_id and v.image_id == opts.image_id then 70 | return v 71 | end 72 | if opts.container_id and v.container_id == opts.container_id then 73 | return v 74 | end 75 | end 76 | return nil 77 | end 78 | 79 | ---Finds image with requested opts 80 | ---@param opts DevcontainerImageQuery required opts 81 | ---@return DevcontainerImageStatus? 82 | local function get_image(opts) 83 | if not opts then 84 | return current_status.images_built[1] 85 | end 86 | for _, v in ipairs(current_status.images_built) do 87 | if opts.image_id and v.image_id == opts.image_id then 88 | return v 89 | end 90 | if opts.source_dockerfile and v.source_dockerfile == opts.source_dockerfile then 91 | return v 92 | end 93 | end 94 | return nil 95 | end 96 | 97 | ---Finds build with requested opts 98 | ---@param opts DevcontainerBuildStatus required opts 99 | ---@return DevcontainerBuildStatus? 100 | local function get_build(opts) 101 | if not opts then 102 | return current_status.build_status[#current_status.build_status] 103 | end 104 | for _, v in ipairs(current_status.build_status) do 105 | if opts.image_id and v.image_id == opts.image_id then 106 | return v 107 | end 108 | if opts.source_dockerfile and v.source_dockerfile == opts.source_dockerfile then 109 | return v 110 | end 111 | if opts.running and v.running == opts.running then 112 | return v 113 | end 114 | end 115 | return nil 116 | end 117 | 118 | ---@private 119 | ---Adds image to the status or replaces if item with same image_id exists 120 | ---@param image_status DevcontainerImageStatus 121 | function M.add_image(image_status) 122 | local existing = get_image({ image_id = image_status.image_id }) 123 | if existing then 124 | existing.source_dockerfile = image_status.source_dockerfile 125 | existing.tmp_dockerfile = image_status.tmp_dockerfile 126 | else 127 | table.insert(current_status.images_built, image_status) 128 | end 129 | end 130 | 131 | ---@private 132 | ---Removes image from the status 133 | ---@param image_id string 134 | function M.remove_image(image_id) 135 | for i, v in ipairs(current_status.images_built) do 136 | if v.image_id == image_id then 137 | table.remove(current_status.images_built, i) 138 | return 139 | end 140 | end 141 | end 142 | 143 | ---@private 144 | ---Adds container to the status or replaces if item with same container_id exists 145 | ---@param container_status DevcontainerContainerStatus 146 | function M.add_container(container_status) 147 | local existing = get_container({ container_id = container_status.container_id }) 148 | if existing then 149 | existing.autoremove = container_status.autoremove 150 | existing.image_id = container_status.image_id 151 | M.move_container_to_running(container_status.container_id) 152 | else 153 | table.insert(current_status.running_containers, container_status) 154 | end 155 | end 156 | 157 | ---@private 158 | ---Moves container from running_containers to stopped_containers 159 | ---@param container_id string 160 | function M.move_container_to_stopped(container_id) 161 | for i, v in ipairs(current_status.running_containers) do 162 | if v.container_id == container_id then 163 | local container_status = table.remove(current_status.running_containers, i) 164 | if not container_status.autoremove then 165 | table.insert(current_status.stopped_containers, container_status) 166 | end 167 | return 168 | end 169 | end 170 | end 171 | 172 | ---@private 173 | ---Moves container from stopped_containers to running_containers 174 | ---@param container_id string 175 | function M.move_container_to_running(container_id) 176 | for i, v in ipairs(current_status.stopped_containers) do 177 | if v.container_id == container_id then 178 | local container_status = table.remove(current_status.stopped_containers, i) 179 | table.insert(current_status.running_containers, container_status) 180 | return 181 | end 182 | end 183 | end 184 | 185 | ---@private 186 | ---Removes container from the status 187 | ---@param container_id string 188 | function M.remove_container(container_id) 189 | for i, v in ipairs(current_status.stopped_containers) do 190 | if v.container_id == container_id then 191 | table.remove(current_status.stopped_containers, i) 192 | return 193 | end 194 | end 195 | for i, v in ipairs(current_status.running_containers) do 196 | if v.container_id == container_id then 197 | table.remove(current_status.running_containers, i) 198 | return 199 | end 200 | end 201 | end 202 | 203 | ---@private 204 | ---Adds compose service to the status 205 | ---@param compose_status DevcontainerComposeStatus 206 | function M.add_compose(compose_status) 207 | M.remove_compose(compose_status.file) 208 | table.insert(current_status.compose_services, compose_status) 209 | end 210 | 211 | ---@private 212 | ---Removes compoes service from the status 213 | ---@param compose_file string 214 | function M.remove_compose(compose_file) 215 | for i, v in ipairs(current_status.compose_services) do 216 | if v.file == compose_file then 217 | table.remove(current_status.compose_services, i) 218 | return 219 | end 220 | end 221 | end 222 | 223 | ---@private 224 | ---Adds build to the status 225 | ---@param build_status DevcontainerBuildStatus 226 | function M.add_build(build_status) 227 | table.insert(current_status.build_status, build_status) 228 | end 229 | 230 | ---Returns current devcontainer status in a table 231 | ---@return DevcontainerStatus 232 | function M.get_status() 233 | return vim.deepcopy(current_status) 234 | end 235 | 236 | ---Finds container with requested opts 237 | ---Read-only 238 | ---@param opts DevcontainerContainerQuery required opts 239 | ---@return DevcontainerContainerStatus 240 | function M.find_container(opts) 241 | return vim.deepcopy(get_container(opts)) 242 | end 243 | 244 | ---Returns latest container 245 | ---Read-only 246 | ---@return DevcontainerContainerStatus 247 | function M.get_latest_container() 248 | return vim.deepcopy(current_status.running_containers[#current_status.running_containers]) 249 | end 250 | 251 | ---Finds image with requested opts 252 | ---Read-only 253 | ---@param opts DevcontainerImageQuery required opts 254 | ---@return DevcontainerImageStatus 255 | function M.find_image(opts) 256 | return vim.deepcopy(get_image(opts)) 257 | end 258 | 259 | ---Finds build status with requested opts 260 | ---Read-only 261 | ---@param opts DevcontainerBuildStatus required opts 262 | ---@return DevcontainerBuildStatus 263 | function M.find_build(opts) 264 | return vim.deepcopy(get_build(opts)) 265 | end 266 | 267 | return M 268 | -------------------------------------------------------------------------------- /scripts/devsetup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Looking for luacheck" 4 | (type luacheck > /dev/null 2>&1 || echo "Luacheck not found. Install it with 'apt install lua-check' or manually" && exit 1) 5 | echo "Looking for stylua" 6 | (type stylua > /dev/null 2>&1 || echo "Stylua not found. Install it with 'cargo install stylua' or manually" && exit 1) 7 | 8 | echo "Preparing pre-commit hook" 9 | cp scripts/pre-commit .git/hooks/pre-commit 10 | chmod +x .git/hooks/pre-commit 11 | echo "Pre-commit hook ready" 12 | -------------------------------------------------------------------------------- /scripts/docs-template.txt: -------------------------------------------------------------------------------- 1 | *devcontainer.txt* Description 2 | 3 | INTRODUCTION *devcontainer-main* 4 | 5 | Description 6 | 7 | CONTENTS *devcontainer-contents* 8 | 9 | 1. Overview |devcontainer-overview| 10 | 2. Requirements |devcontainer-requirements| 11 | 3. Installation |devcontainer-installation| 12 | 4. Usage |devcontainer-usage| 13 | 5. Commands |devcontainer-commands| 14 | 6. Functions |devcontainer-functions| 15 | 7. Events |devcontainer-autocmd-events| 16 | 8. Issues |devcontainer-issues| 17 | 9. Contributing |devcontainer-contributing| 18 | 10. Version |devcontainer-version| 19 | 11. License |devcontainer-license| 20 | 21 | OVERVIEW *devcontainer-overview* 22 | 23 | REQUIREMENTS *devcontainer-requirements* 24 | 25 | INSTALLATION *devcontainer-installation* 26 | 27 | 1. lazy.nvim 28 | 29 | Add the following to your lazy setup: > 30 | 31 | 'https://codeberg.org/esensar/nvim-dev-container' 32 | < 33 | 34 | 2. Plug 35 | 36 | Add the following to your vimrc, or something sourced therein: > 37 | 38 | Plug 'https://codeberg.org/esensar/nvim-dev-container' 39 | < 40 | Then install via `:PlugInstall` 41 | 42 | 3. Manual 43 | 44 | Clone this repository and copy the files in plugin/, lua/, and doc/ 45 | to their respective directories in your vimfiles, or copy the text from 46 | the repository into new files in those directories. Make sure to 47 | run `:helptags`. 48 | 49 | USAGE *devcontainer-usage* 50 | 51 | To use the plugin with defaults just call the `setup` function: > 52 | 53 | require("devcontainer").setup{} 54 | < 55 | 56 | It is possible to override some of the functionality of the plugin with options passed into `setup`. Everything passed to `setup` is optional. Following block represents default values: > 57 | 58 | require("devcontainer").setup { 59 | config_search_start = function() 60 | -- By default this function uses vim.loop.cwd() 61 | -- This is used to find a starting point for .devcontainer.json file search 62 | -- Since by default, it is searched for recursively 63 | -- That behavior can also be disabled 64 | end, 65 | workspace_folder_provider = function() 66 | -- By default this function uses first workspace folder for integrated lsp if available and vim.loop.cwd() as a fallback 67 | -- This is used to replace `${localWorkspaceFolder}` in devcontainer.json 68 | -- Also used for creating default .devcontainer.json file 69 | end, 70 | terminal_handler = function(command) 71 | -- By default this function creates a terminal in a new tab using :terminal command 72 | -- It also removes statusline when that tab is active, to prevent double statusline 73 | -- It can be overridden to provide custom terminal handling 74 | end, 75 | nvim_installation_commands_provider = function(path_binaries, version_string) 76 | -- Returns table - list of commands to run when adding neovim to container 77 | -- Each command can either be a string or a table (list of command parts) 78 | -- Takes binaries available in path on current container and version_string passed to the command or current version of neovim 79 | end, 80 | devcontainer_json_template = function() 81 | -- Returns table - list of lines to set when creating new devcontainer.json files 82 | -- As a template 83 | -- Used only when using functions from commands module or created commands 84 | end, 85 | -- Can be set to false to prevent generating default commands 86 | -- Default commands are listed below 87 | generate_commands = true, 88 | -- By default no autocommands are generated 89 | -- This option can be used to configure automatic starting and cleaning of containers 90 | autocommands = { 91 | -- can be set to true (or "ask") to automatically start containers when devcontainer.json is available - if set to "ask", it will prompt before starting 92 | init = false, 93 | -- can be set to true to automatically remove any started containers and any built images when exiting vim 94 | clean = false, 95 | -- can be set to true to automatically restart containers when devcontainer.json file is updated 96 | update = false, 97 | }, 98 | -- can be changed to increase or decrease logging from library 99 | log_level = "info", 100 | -- can be set to true to disable recursive search 101 | -- in that case only .devcontainer.json and .devcontainer/devcontainer.json files will be checked relative 102 | -- to the directory provided by config_search_start 103 | disable_recursive_config_search = false, 104 | -- can be set to false to disable image caching when adding neovim 105 | -- by default it is set to true to make attaching to containers faster after first time 106 | cache_images = true, 107 | -- By default all mounts are added (config, data and state) 108 | -- This can be changed to disable mounts or change their options 109 | -- This can be useful to mount local configuration 110 | -- And any other mounts when attaching to containers with this plugin 111 | attach_mounts = { 112 | neovim_config = { 113 | -- enables mounting local config to /root/.config/nvim in container 114 | enabled = false, 115 | -- makes mount readonly in container 116 | options = { "readonly" } 117 | }, 118 | neovim_data = { 119 | -- enables mounting local data to /root/.local/share/nvim in container 120 | enabled = false, 121 | -- no options by default 122 | options = {} 123 | }, 124 | -- Only useful if using neovim 0.8.0+ 125 | neovim_state = { 126 | -- enables mounting local state to /root/.local/state/nvim in container 127 | enabled = false, 128 | -- no options by default 129 | options = {} 130 | }, 131 | }, 132 | -- This takes a list of mounts (strings or tables) that should always be added to every run container 133 | -- This is passed directly as --mount option to docker command 134 | -- Or multiple --mount options if there are multiple vaues 135 | always_mount = {} 136 | -- This takes a string (usually either "podman" or "docker") representing container runtime - "devcontainer-cli" is also partially supported 137 | -- That is the command that will be invoked for container operations 138 | -- If it is nil, plugin will use whatever is available (trying "podman" first) 139 | container_runtime = nil, 140 | -- Similar to container runtime, but will be used if main runtime does not support an action - useful for "devcontainer-cli" 141 | backup_runtime = nil, 142 | -- This takes a string (usually either "podman-compose" or "docker-compose") representing compose command - "devcontainer-cli" is also partially supported 143 | -- That is the command that will be invoked for compose operations 144 | -- If it is nil, plugin will use whatever is available (trying "podman-compose" first) 145 | compose_command = nil, 146 | -- Similar to compose command, but will be used if main command does not support an action - useful for "devcontainer-cli" 147 | backup_compose_command = nil, 148 | } 149 | < 150 | 151 | Check out [wiki](https://codeberg.org/esensar/nvim-dev-container/wiki) for tips. 152 | 153 | COMMANDS *devcontainer-commands* 154 | 155 | If not disabled by using {generate_commands = false} in setup, this plugin provides the following commands: 156 | 157 | *:DevcontainerStart* - start whatever is defined in devcontainer.json 158 | *:DevcontainerAttach* - attach to whatever is defined in devcontainer.json 159 | *:DevcontainerExec* - execute a single command on container defined in devcontainer.json 160 | *:DevcontainerStop* - stop whatever was started based on devcontainer.json 161 | *:DevcontainerStopAll* - stop everything started with this plugin (in current session) 162 | *:DevcontainerRemoveAll* - remove everything started with this plugin (in current session) 163 | *:DevcontainerLogs* - open plugin log file 164 | *:DevcontainerEditNearestConfig* - opens nearest devcontainer.json file if it exists, or creates a new one if it does not 165 | 166 | FUNCTIONS *devcontainer-functions* 167 | 168 | This plugin provides multiple modules related to devcontainer functionality, 169 | but not all of them are needed for use tasks. Many of the functionalities are 170 | exposed to enable custom functionality. 171 | 172 | ---INSERT HERE--- 173 | 174 | EVENTS *devcontainer-autocmd-events* 175 | 176 | This plugin also provides autocmd events: 177 | 178 | *DevcontainerBuildProgress* 179 | Emitted when build status changes. Useful for statusline updates. 180 | See |devcontainer.status.find_build|. 181 | 182 | *DevcontainerImageBuilt* 183 | Emitted when an image is built with this plugin. Passes a table in `data` 184 | with `image_id`. 185 | 186 | *DevcontainerImageRemoved* 187 | Emitted when an image is removed with this plugin. Passes a table in `data` 188 | with `image_id`. 189 | 190 | *DevcontainerContainerStarted* 191 | Emitted when a container is started with this plugin. Passes a table in `data` 192 | with `container_id`. 193 | 194 | *DevcontainerContainerStopped* 195 | Emitted when a container is stopped with this plugin. Passes a table in `data` 196 | with `container_id`. 197 | 198 | *DevcontainerContainerCommitted* 199 | Emitted when a container is committed with this plugin. Passes a table in `data` 200 | with `container_id` and `tag`. 201 | 202 | *DevcontainerContainerRemoved* 203 | Emitted when a container is removed with this plugin. Passes a table in `data` 204 | with `container_id`. 205 | 206 | Example: 207 | autocmd User DevcontainerBuildProgress redrawstatus 208 | 209 | 210 | ISSUES *devcontainer-issues* 211 | 212 | If you experience issues using plugin, please report them at 213 | . 214 | 215 | CONTRIBUTING *devcontainer-contributing* 216 | 217 | Feel free to look at already reported issues at 218 | . 219 | If available, check out CONTRIBUTING.md in the repository. 220 | Otherwise, feel free to create a new issue or pull request. 221 | 222 | VERSION *devcontainer-version* 223 | 224 | Version 0.3.0-dev 225 | 226 | LICENSE *devcontainer-license* 227 | 228 | MIT License 229 | 230 | Copyright (c) 2022 Ensar Sarajčić 231 | 232 | Permission is hereby granted, free of charge, to any person obtaining a copy 233 | of this software and associated documentation files (the "Software"), to deal 234 | in the Software without restriction, including without limitation the rights 235 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 236 | copies of the Software, and to permit persons to whom the Software is 237 | furnished to do so, subject to the following conditions: 238 | 239 | The above copyright notice and this permission notice shall be included in all 240 | copies or substantial portions of the Software. 241 | 242 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 243 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 244 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 245 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 246 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 247 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 248 | SOFTWARE. 249 | 250 | vim:tw=78:ts=2:ft=help:norl: 251 | -------------------------------------------------------------------------------- /scripts/gendoc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! type lemmy-help > /dev/null 2>&1 4 | then 5 | echo "lemmy-help is required to generate docs. Install with 'cargo install lemmy-help --features=cli'" 6 | exit 1 7 | fi 8 | 9 | FILES="" 10 | for FILE in $(find lua/devcontainer -name "*.lua" | grep -v "internal" | sort) 11 | do 12 | if [ -f $FILE ] 13 | then 14 | FILES="$FILES $FILE" 15 | fi 16 | done 17 | 18 | LINE_NR=$(grep -n "\-\-\-INSERT HERE\-\-\-" scripts/docs-template.txt | cut -f1 -d ':') 19 | head -n $(($LINE_NR - 1)) scripts/docs-template.txt > doc/devcontainer.txt 20 | lemmy-help -f -a -c -t -M $FILES >> doc/devcontainer.txt 21 | tail --lines=+$(($LINE_NR + 1)) scripts/docs-template.txt >> doc/devcontainer.txt 22 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | stylua --check . && luacheck . && scripts/test 6 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | nvim --headless --noplugin -u tests/init.vim -c 'set display-=msgsep' -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/init.vim'}" 4 | -------------------------------------------------------------------------------- /tests/configs/docker-compose/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Your Definition Name Here (Community)", 3 | // Update the 'image' property with your Docker image name. 4 | // "image": "debian", 5 | // Or define build if using Dockerfile. 6 | // "build": { 7 | // "dockerfile": "Dockerfile", 8 | // [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile 9 | // "args": { "VARIANT: "buster" }, 10 | // } 11 | // Or use docker-compose 12 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 13 | "dockerComposeFile": "docker-compose.yml", 14 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 15 | // "forwardPorts": [], 16 | // Define mounts. 17 | // "mounts": [ "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename} ,type=bind,consistency=delegated" ], 18 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 19 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 20 | } 21 | -------------------------------------------------------------------------------- /tests/configs/docker-compose/.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | debian: 3 | image: debian 4 | -------------------------------------------------------------------------------- /tests/configs/dockerfile/.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian 2 | -------------------------------------------------------------------------------- /tests/configs/dockerfile/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Your Definition Name Here (Community)", 3 | // Update the 'image' property with your Docker image name. 4 | // "image": "debian", 5 | // Or define build if using Dockerfile. 6 | "build": { 7 | "dockerfile": "Dockerfile", 8 | // [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile 9 | // "args": { "VARIANT: "buster" }, 10 | } 11 | // Or use docker-compose 12 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 13 | // "dockerComposeFile": "docker-compose.yml", 14 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 15 | // "forwardPorts": [], 16 | // Define mounts. 17 | // "mounts": [ "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename} ,type=bind,consistency=delegated" ], 18 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 19 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 20 | } 21 | -------------------------------------------------------------------------------- /tests/configs/image/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Your Definition Name Here (Community)", 3 | // Update the 'image' property with your Docker image name. 4 | "image": "debian", 5 | "containerEnv": { 6 | "WORKSPACE_DIR": "${containerWorkspaceFolder}", 7 | "PROMPT_COMMAND": "history -a" 8 | }, 9 | // Or define build if using Dockerfile. 10 | // "build": { 11 | // "dockerfile": "Dockerfile", 12 | // [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile 13 | // "args": { "VARIANT: "buster" }, 14 | // } 15 | // Or use docker-compose 16 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 17 | // "dockerComposeFile": "docker-compose.yml", 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | // "forwardPorts": [], 20 | // Define mounts. 21 | // "mounts": [ "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename} ,type=bind,consistency=delegated" ], 22 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 23 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 24 | } 25 | -------------------------------------------------------------------------------- /tests/configs/tickets/85/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cilium", 3 | "image": "quay.io/cilium/cilium-builder:43a07ce3ec4a5475d198b1e1ee88117e47deb5f9@sha256:3a989bb73973bdc140803ef97af9bcf953c50aead2c3447cfb49292f1bd97461", 4 | "workspaceFolder": "/go/src/github.com/cilium/cilium", 5 | "workspaceMount": { 6 | "src": "${localWorkspaceFolder}", 7 | "target": "/go/src/github.com/cilium/cilium", 8 | "type": "bind" 9 | }, 10 | "features": { 11 | "ghcr.io/devcontainers/features/docker-in-docker": {} 12 | }, 13 | "postCreateCommand": "git config --global --add safe.directory /go/src/github.com/cilium/cilium" 14 | } 15 | -------------------------------------------------------------------------------- /tests/devcontainer/compose_spec.lua: -------------------------------------------------------------------------------- 1 | local subject = require("devcontainer.compose") 2 | 3 | -- TODO: Add specs 4 | describe("devcontainer.compose:", function() 5 | local _ = subject 6 | end) 7 | -------------------------------------------------------------------------------- /tests/devcontainer/config_file/jsonc_spec.lua: -------------------------------------------------------------------------------- 1 | describe("devcontainer/jsonc:", function() 2 | local subject = require("devcontainer.config_file.jsonc") 3 | 4 | describe("parse_jsonc", function() 5 | it("should have same behavior as json_decode for basic json", function() 6 | local json = '{ "test": "value", "nested": { "nested_test": "nested_value" } }' 7 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json)) 8 | end) 9 | 10 | it("should work even when comments are present", function() 11 | local json = '{ "test": "value", "nested": { "nested_test": "nested_value" } }' 12 | local json_with_comments = 13 | '{ \n//comment in line 1\n"test": //commentafter\n "value", "nested": { "nested_test": "nested_value" } }' 14 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json_with_comments)) 15 | end) 16 | 17 | it("should work even when trailing commas are present", function() 18 | local json = '{ "test": "value", "nested": { "nested_test": "nested_value" } }' 19 | local json_with_commas = '{ "test": "value", "nested": { "nested_test": "nested_value", } }' 20 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json_with_commas)) 21 | end) 22 | 23 | it("should work when text was retrieved from a buffer", function() 24 | local buffer = vim.api.nvim_create_buf(0, 0) 25 | vim.api.nvim_buf_set_lines(buffer, 0, -1, 0, { 26 | "{", 27 | " //comment in line 1", 28 | ' "test": // comment after', 29 | ' "value", "nested": { "nested_test": "nested_value" } }', 30 | }) 31 | local lines = vim.api.nvim_buf_get_lines(buffer, 0, -1, 0) 32 | vim.api.nvim_buf_delete(buffer, {}) 33 | local json = '{ "test": "value", "nested": { "nested_test": "nested_value" } }' 34 | local json_with_comments = vim.fn.join(lines, "\n") 35 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json_with_comments)) 36 | end) 37 | 38 | it("should work with block comments too", function() 39 | local json = '{ "test": "value", "nested": { "nested_test": "nested_value" } }' 40 | local json_with_comments = [[ 41 | { 42 | /* comment 43 | started above 44 | ends here */ 45 | "test": //inline comment 46 | "value", "nested": { "nested_test": "nested_value" } } 47 | ]] 48 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json_with_comments)) 49 | end) 50 | 51 | it("should not break when comment characters are found in strings", function() 52 | local json = '{ "test": "https://test.com", "nested": { "nested_test": "nested_value" } }' 53 | local json_with_comments = 54 | '{ \n//comment in line 1\n"test": //after\n "https://test.com", "nested": { "nested_test": "nested_value" } }' 55 | assert.are.same(vim.fn.json_decode(json), subject.parse_jsonc(json_with_comments)) 56 | end) 57 | end) 58 | end) 59 | -------------------------------------------------------------------------------- /tests/devcontainer/config_file/parse_async_spec.lua: -------------------------------------------------------------------------------- 1 | local mock = require("luassert.mock") 2 | local stub = require("luassert.stub") 3 | local match = require("luassert.match") 4 | 5 | local function mock_file_read(uv_mock, result, opts) 6 | opts = opts or {} 7 | uv_mock.fs_open.invokes(function(_, _, _, callback) 8 | local val = opts.fd or 1 9 | if callback then 10 | callback(nil, val) 11 | else 12 | return val 13 | end 14 | end) 15 | uv_mock.fs_fstat.invokes(function(_, callback) 16 | local val = opts.fstat or { size = string.len(result) } 17 | if callback then 18 | callback(nil, opts.fstat or { size = string.len(result) }) 19 | else 20 | return val 21 | end 22 | end) 23 | uv_mock.fs_read.invokes(function(_, _, _, callback) 24 | if callback then 25 | callback(nil, result) 26 | else 27 | return result 28 | end 29 | end) 30 | uv_mock.fs_close.invokes(function(_, callback) 31 | if callback then 32 | callback(nil, true) 33 | else 34 | return true 35 | end 36 | end) 37 | uv_mock.hrtime.returns(0) 38 | end 39 | 40 | local function missing_file_func(_, _, _, callback) 41 | callback("ENOENT", nil) 42 | end 43 | 44 | local vim_schedule_mock = stub(vim, "schedule_wrap") 45 | vim_schedule_mock.invokes(function(func) 46 | return func 47 | end) 48 | 49 | describe("devcontainer.config_file.parse(async):", function() 50 | local subject = require("devcontainer.config_file.parse") 51 | 52 | describe("given existing file", function() 53 | describe("parse_devcontainer_config", function() 54 | local mock_fd = 1 55 | local result = '{ "image": "value" }' 56 | local fstat = { 57 | size = string.len(result), 58 | } 59 | local test_it = function(name, block) 60 | it(name, function() 61 | local uv_mock = mock(vim.loop, true) 62 | mock_file_read(uv_mock, result, { 63 | fd = mock_fd, 64 | fstat = fstat, 65 | }) 66 | 67 | subject.parse_devcontainer_config("test.json", function(err, data) 68 | if err then 69 | mock.revert(uv_mock) 70 | error(err) 71 | end 72 | block(uv_mock, data) 73 | mock.revert(uv_mock) 74 | end) 75 | end) 76 | end 77 | 78 | test_it("should return contained json", function(_, data) 79 | assert.are.same("value", data.image) 80 | end) 81 | 82 | test_it("should return metadata with file_path", function(_, data) 83 | assert.are.same("test.json", data.metadata.file_path) 84 | end) 85 | 86 | test_it("should open file in read mode", function(uv_mock, _) 87 | assert.stub(uv_mock.fs_open).was_called_with("test.json", "r", match._, match._) 88 | end) 89 | 90 | test_it("should read complete file", function(uv_mock, _) 91 | assert.stub(uv_mock.fs_read).was_called_with(mock_fd, fstat.size, 0, match._) 92 | end) 93 | 94 | test_it("should close file", function(uv_mock, _) 95 | assert.stub(uv_mock.fs_close).was_called_with(mock_fd, match._) 96 | end) 97 | end) 98 | end) 99 | 100 | describe("given missing file", function() 101 | describe("parse_devcontainer_config", function() 102 | local test_it = function(name, block) 103 | it(name, function() 104 | local uv_mock = mock(vim.loop, true) 105 | uv_mock.fs_open.invokes(missing_file_func) 106 | 107 | subject.parse_devcontainer_config("test.json", function(err, data) 108 | block(uv_mock, not err, data or err) 109 | mock.revert(uv_mock) 110 | end) 111 | end) 112 | end 113 | 114 | test_it("should fail", function(_, success, _) 115 | assert.is_not_true(success) 116 | end) 117 | end) 118 | end) 119 | 120 | describe("given proper file", function() 121 | describe("parse_devcontainer_config", function() 122 | local mock_fd = 1 123 | local it_should_succeed_for_json = function(file_content, key, expected) 124 | local succeed_string = expected and "succeed" or "fail" 125 | it("should " .. succeed_string .. " when " .. key .. " is present", function() 126 | local uv_mock = mock(vim.loop, true) 127 | local fstat = { 128 | size = string.len(file_content), 129 | } 130 | mock_file_read(uv_mock, file_content, { 131 | fd = mock_fd, 132 | fstat = fstat, 133 | }) 134 | 135 | subject.parse_devcontainer_config("test.json", function(err, _) 136 | assert.are.same(expected, not err) 137 | mock.revert(uv_mock) 138 | end) 139 | end) 140 | end 141 | local function it_should_succeed_for_key(key, expected) 142 | it_should_succeed_for_json('{ "' .. key .. '": "value" }', key, expected) 143 | end 144 | 145 | it_should_succeed_for_key("image", true) 146 | it_should_succeed_for_key("dockerFile", true) 147 | it_should_succeed_for_key("dockerComposeFile", true) 148 | it_should_succeed_for_json('{ "build" : { "dockerfile": "value" } }', "build.dockerfile", true) 149 | it_should_succeed_for_key("none of these", false) 150 | end) 151 | end) 152 | 153 | describe("given existing .devcontainer.json", function() 154 | describe("parse_nearest_devcontainer_config", function() 155 | local result = '{ "image": "value" }' 156 | local cwd = "." 157 | local test_it = function(name, block) 158 | it(name, function() 159 | local uv_mock = mock(vim.loop, true) 160 | mock_file_read(uv_mock, result) 161 | uv_mock.cwd.returns(cwd) 162 | uv_mock.fs_stat.invokes(function(path, callback) 163 | if path == cwd then 164 | callback(nil, { ino = 123 }) 165 | elseif path == cwd .. "/.devcontainer.json" then 166 | callback(nil, { ino = 456 }) 167 | else 168 | callback("error", nil) 169 | end 170 | end) 171 | 172 | subject.parse_nearest_devcontainer_config(function(err, data) 173 | if err then 174 | mock.revert(uv_mock) 175 | error(err) 176 | end 177 | block(data, uv_mock) 178 | mock.revert(uv_mock) 179 | end) 180 | end) 181 | end 182 | 183 | test_it("should return value from parse_devcontainer_config", function(data, _) 184 | assert.are.same(subject.parse_devcontainer_config("./.devcontainer.json"), data) 185 | end) 186 | 187 | test_it("should call parse_devcontainer_config with .devcontainer.json path", function(_, uv_mock) 188 | assert.stub(uv_mock.fs_open).was_called_with("./.devcontainer.json", "r", match._, match._) 189 | end) 190 | end) 191 | end) 192 | 193 | describe("given existing .devcontainer/devcontainer.json", function() 194 | describe("parse_nearest_devcontainer_config", function() 195 | local result = '{ "image": "value" }' 196 | local cwd = "." 197 | local test_it = function(name, block) 198 | it(name, function() 199 | local uv_mock = mock(vim.loop, true) 200 | mock_file_read(uv_mock, result) 201 | uv_mock.cwd.returns(cwd) 202 | uv_mock.fs_stat.invokes(function(path, callback) 203 | if path == cwd then 204 | callback(nil, { ino = 123 }) 205 | elseif path == cwd .. "/.devcontainer.json" then 206 | callback("ENOENT", nil) 207 | elseif path == cwd .. "/.devcontainer/devcontainer.json" then 208 | callback(nil, { ino = 456 }) 209 | else 210 | callback("error", nil) 211 | end 212 | end) 213 | 214 | subject.parse_nearest_devcontainer_config(function(err, data) 215 | if err then 216 | mock.revert(uv_mock) 217 | error(err) 218 | end 219 | block(data, uv_mock) 220 | mock.revert(uv_mock) 221 | end) 222 | end) 223 | end 224 | 225 | test_it("should return value from parse_devcontainer_config", function(data, _) 226 | assert.are.same(subject.parse_devcontainer_config("./.devcontainer/devcontainer.json"), data) 227 | end) 228 | 229 | test_it("should call parse_devcontainer_config with .devcontainer/devcontainer.json path", function(_, uv_mock) 230 | assert.stub(uv_mock.fs_open).was_called_with("./.devcontainer/devcontainer.json", "r", match._, match._) 231 | end) 232 | end) 233 | end) 234 | 235 | describe("given no devcontainer files", function() 236 | describe("parse_nearest_devcontainer_config", function() 237 | local cwd = "." 238 | local test_it = function(name, block) 239 | it(name, function() 240 | local uv_mock = mock(vim.loop, true) 241 | uv_mock.cwd.returns(cwd) 242 | uv_mock.fs_stat.invokes(function(path, callback) 243 | if path == cwd then 244 | callback(nil, { ino = 123 }) 245 | elseif path == cwd .. "/.devcontainer.json" then 246 | callback("ENOENT", nil) 247 | elseif path == cwd .. "/.devcontainer/devcontainer.json" then 248 | callback("ENOENT", nil) 249 | elseif path == cwd .. "/.." then 250 | callback(nil, { ino = 456 }) 251 | elseif path == cwd .. "/../.devcontainer.json" then 252 | callback("ENOENT", nil) 253 | elseif path == cwd .. "/../.devcontainer/devcontainer.json" then 254 | callback("ENOENT", nil) 255 | elseif path == cwd .. "/../.." then 256 | callback(nil, { ino = 456 }) 257 | else 258 | callback("error", nil) 259 | end 260 | end) 261 | 262 | subject.parse_nearest_devcontainer_config(function(err, data) 263 | block(not err, data or err, uv_mock) 264 | mock.revert(uv_mock) 265 | end) 266 | end) 267 | end 268 | 269 | test_it("should return an error that no files were found", function(success, _, _) 270 | assert.is_not_true(success) 271 | end) 272 | end) 273 | end) 274 | end) 275 | -------------------------------------------------------------------------------- /tests/devcontainer/config_file/parse_spec.lua: -------------------------------------------------------------------------------- 1 | local mock = require("luassert.mock") 2 | local match = require("luassert.match") 3 | 4 | local function mock_file_read(uv_mock, result, opts) 5 | opts = opts or {} 6 | uv_mock.fs_open.returns(opts.fd or 1) 7 | uv_mock.fs_fstat.returns(opts.fstat or { size = string.len(result) }) 8 | uv_mock.fs_read.returns(result) 9 | uv_mock.fs_close.returns(true) 10 | uv_mock.hrtime.returns(0) 11 | end 12 | 13 | local function missing_file_func() 14 | error("ENOENT") 15 | end 16 | 17 | describe("devcontainer.config_file.parse:", function() 18 | local subject = require("devcontainer.config_file.parse") 19 | 20 | describe("given existing file", function() 21 | describe("parse_devcontainer_config", function() 22 | local mock_fd = 1 23 | local result = '{ "image": "value" }' 24 | local fstat = { 25 | size = string.len(result), 26 | } 27 | local test_it = function(name, block) 28 | it(name, function() 29 | local uv_mock = mock(vim.loop, true) 30 | mock_file_read(uv_mock, result, { 31 | fd = mock_fd, 32 | fstat = fstat, 33 | }) 34 | 35 | local data = subject.parse_devcontainer_config("test.json") 36 | block(uv_mock, data) 37 | 38 | mock.revert(uv_mock) 39 | end) 40 | end 41 | 42 | test_it("should return contained json", function(_, data) 43 | assert.are.same("value", data.image) 44 | end) 45 | 46 | test_it("should return metadata with file_path", function(_, data) 47 | assert.are.same("test.json", data.metadata.file_path) 48 | end) 49 | 50 | test_it("should open file in read mode", function(uv_mock, _) 51 | assert.stub(uv_mock.fs_open).was_called_with("test.json", "r", match._) 52 | end) 53 | 54 | test_it("should read complete file", function(uv_mock, _) 55 | assert.stub(uv_mock.fs_read).was_called_with(mock_fd, fstat.size, 0) 56 | end) 57 | 58 | test_it("should close file", function(uv_mock, _) 59 | assert.stub(uv_mock.fs_close).was_called_with(mock_fd) 60 | end) 61 | end) 62 | end) 63 | 64 | describe("given missing file", function() 65 | describe("parse_devcontainer_config", function() 66 | local test_it = function(name, block) 67 | it(name, function() 68 | local uv_mock = mock(vim.loop, true) 69 | uv_mock.fs_open.invokes(missing_file_func) 70 | 71 | local success, data = pcall(subject.parse_devcontainer_config, "test.json") 72 | block(uv_mock, success, data) 73 | 74 | mock.revert(uv_mock) 75 | end) 76 | end 77 | 78 | test_it("should fail", function(_, success, _) 79 | assert.is_not_true(success) 80 | end) 81 | end) 82 | end) 83 | 84 | describe("given proper file", function() 85 | describe("parse_devcontainer_config", function() 86 | local mock_fd = 1 87 | local it_should_succeed_for_json = function(file_content, key, expected) 88 | local succeed_string = expected and "succeed" or "fail" 89 | it("should " .. succeed_string .. " when " .. key .. " is present", function() 90 | local uv_mock = mock(vim.loop, true) 91 | local fstat = { 92 | size = string.len(file_content), 93 | } 94 | mock_file_read(uv_mock, file_content, { fd = mock_fd, fstat = fstat }) 95 | 96 | local success, _ = pcall(subject.parse_devcontainer_config, "test.json") 97 | assert.are.same(expected, success) 98 | 99 | mock.revert(uv_mock) 100 | end) 101 | end 102 | local it_should_succeed_for_key = function(key, expected) 103 | it_should_succeed_for_json('{ "' .. key .. '": "value" }', key, expected) 104 | end 105 | 106 | it_should_succeed_for_key("image", true) 107 | it_should_succeed_for_key("dockerFile", true) 108 | it_should_succeed_for_key("dockerComposeFile", true) 109 | it_should_succeed_for_json('{ "build" : { "dockerfile": "value" } }', "build.dockerfile", true) 110 | it_should_succeed_for_key("none of these", false) 111 | end) 112 | end) 113 | 114 | describe("given existing .devcontainer.json", function() 115 | describe("parse_nearest_devcontainer_config", function() 116 | local result = '{ "image": "value" }' 117 | local cwd = "." 118 | local test_it = function(name, block) 119 | it(name, function() 120 | local uv_mock = mock(vim.loop, true) 121 | local config_mock = mock(require("devcontainer.config"), true) 122 | mock_file_read(uv_mock, result) 123 | config_mock.config_search_start.returns(cwd) 124 | uv_mock.fs_stat.on_call_with(cwd).returns({ ino = 123 }) 125 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer.json").returns({ ino = 456 }) 126 | 127 | local data = subject.parse_nearest_devcontainer_config() 128 | 129 | block(data, uv_mock) 130 | 131 | mock.revert(uv_mock) 132 | mock.revert(config_mock) 133 | end) 134 | end 135 | 136 | test_it("should return value from parse_devcontainer_config", function(data, _) 137 | assert.are.same(subject.parse_devcontainer_config("./.devcontainer.json"), data) 138 | end) 139 | 140 | test_it("should call parse_devcontainer_config with .devcontainer.json path", function(_, uv_mock) 141 | assert.stub(uv_mock.fs_open).was_called_with("./.devcontainer.json", "r", match._) 142 | end) 143 | end) 144 | end) 145 | 146 | describe("given existing .devcontainer/devcontainer.json", function() 147 | describe("parse_nearest_devcontainer_config", function() 148 | local result = '{ "image": "value" }' 149 | local cwd = "." 150 | local test_it = function(name, block) 151 | it(name, function() 152 | local uv_mock = mock(vim.loop, true) 153 | mock_file_read(uv_mock, result) 154 | local config_mock = mock(require("devcontainer.config"), true) 155 | config_mock.config_search_start.returns(cwd) 156 | uv_mock.fs_stat.on_call_with(cwd).returns({ ino = 123 }) 157 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer.json").invokes(missing_file_func) 158 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer/devcontainer.json").returns({ ino = 456 }) 159 | 160 | local data = subject.parse_nearest_devcontainer_config() 161 | 162 | block(data, uv_mock) 163 | 164 | mock.revert(uv_mock) 165 | mock.revert(config_mock) 166 | end) 167 | end 168 | 169 | test_it("should return value from parse_devcontainer_config", function(data, _) 170 | assert.are.same(subject.parse_devcontainer_config("./.devcontainer/devcontainer.json"), data) 171 | end) 172 | 173 | test_it("should call parse_devcontainer_config with .devcontainer/devcontainer.json path", function(_, uv_mock) 174 | assert.stub(uv_mock.fs_open).was_called_with("./.devcontainer/devcontainer.json", "r", match._) 175 | end) 176 | end) 177 | end) 178 | 179 | describe("given no devcontainer files", function() 180 | describe("parse_nearest_devcontainer_config", function() 181 | local cwd = "." 182 | local test_it = function(name, block) 183 | it(name, function() 184 | local uv_mock = mock(vim.loop, true) 185 | local config_mock = mock(require("devcontainer.config"), true) 186 | config_mock.config_search_start.returns(cwd) 187 | uv_mock.fs_stat.on_call_with(cwd).returns({ ino = 123 }) 188 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer.json").invokes(missing_file_func) 189 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer/devcontainer.json").invokes(missing_file_func) 190 | uv_mock.fs_stat.on_call_with(cwd .. "/..").returns({ ino = 456 }) 191 | uv_mock.fs_stat.on_call_with(cwd .. "/../.devcontainer.json").invokes(missing_file_func) 192 | uv_mock.fs_stat.on_call_with(cwd .. "/../.devcontainer/devcontainer.json").invokes(missing_file_func) 193 | uv_mock.fs_stat.on_call_with(cwd .. "/../..").returns({ ino = 456 }) 194 | 195 | local success, data = pcall(subject.parse_nearest_devcontainer_config) 196 | 197 | block(success, data, uv_mock) 198 | 199 | mock.revert(uv_mock) 200 | mock.revert(config_mock) 201 | end) 202 | end 203 | 204 | test_it("should return an error that no files were found", function(success, _, _) 205 | assert.is_not_true(success) 206 | end) 207 | end) 208 | end) 209 | 210 | local function test_dockerfile_updates(given_config_provider, workspace_dir) 211 | local test_it = function(name, block) 212 | it(name, function() 213 | local config_mock = mock(require("devcontainer.config"), true) 214 | config_mock.workspace_folder_provider.returns(workspace_dir) 215 | 216 | local data = subject.fill_defaults(given_config_provider()) 217 | 218 | block(data, config_mock) 219 | 220 | mock.revert(config_mock) 221 | end) 222 | end 223 | 224 | test_it("should update build.dockerfile to absolute path", function(data, _) 225 | assert.are.same("/home/test/projects/devcontainer/.devcontainer/Dockerfile", data.build.dockerfile) 226 | end) 227 | 228 | test_it("should update dockerFile to the same value", function(data, _) 229 | assert.are.same(data.build.dockerfile, data.dockerFile) 230 | end) 231 | 232 | test_it("should set context and build.context to default value", function(data, _) 233 | assert.are.same("/home/test/projects/devcontainer/.devcontainer/.", data.context) 234 | assert.are.same(data.context, data.build.context) 235 | end) 236 | 237 | test_it("should fill out build.args", function(data, _) 238 | assert.are.same({}, data.build.args) 239 | end) 240 | 241 | test_it("should fill out runArgs", function(data, _) 242 | assert.are.same({}, data.runArgs) 243 | end) 244 | 245 | test_it("should set overrideCommand to true", function(data, _) 246 | assert.are.same(true, data.overrideCommand) 247 | end) 248 | 249 | test_it("should fill out forwardPorts", function(data, _) 250 | assert.are.same({}, data.forwardPorts) 251 | end) 252 | 253 | test_it("should fill out remoteEnv", function(data, _) 254 | assert.are.same({}, data.remoteEnv) 255 | end) 256 | end 257 | 258 | local function test_image_updates(given_config_provider, workspace_dir) 259 | local test_it = function(name, block) 260 | it(name, function() 261 | local config_mock = mock(require("devcontainer.config"), true) 262 | config_mock.workspace_folder_provider.returns(workspace_dir) 263 | 264 | local data = subject.fill_defaults(given_config_provider()) 265 | 266 | block(data, config_mock) 267 | 268 | mock.revert(config_mock) 269 | end) 270 | end 271 | 272 | test_it("should fill out runArgs", function(data, _) 273 | assert.are.same({}, data.runArgs) 274 | end) 275 | 276 | test_it("should set overrideCommand to true", function(data, _) 277 | assert.are.same(true, data.overrideCommand) 278 | end) 279 | 280 | test_it("should fill out forwardPorts", function(data, _) 281 | assert.are.same({}, data.forwardPorts) 282 | end) 283 | 284 | test_it("should fill out remoteEnv", function(data, _) 285 | assert.are.same({}, data.remoteEnv) 286 | end) 287 | end 288 | 289 | describe("given devcontainer config with just build.dockerfile", function() 290 | describe("fill_defaults", function() 291 | local given_config = function() 292 | return { 293 | build = { 294 | dockerfile = "Dockerfile", 295 | }, 296 | hostRequirements = {}, 297 | metadata = { 298 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 299 | }, 300 | } 301 | end 302 | local workspace_dir = "/home/test/projects/devcontainer" 303 | test_dockerfile_updates(given_config, workspace_dir) 304 | end) 305 | end) 306 | 307 | describe("given devcontainer config with dockerFile", function() 308 | describe("fill_defaults", function() 309 | local given_config = function() 310 | return { 311 | dockerFile = "Dockerfile", 312 | build = {}, 313 | hostRequirements = {}, 314 | metadata = { 315 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 316 | }, 317 | } 318 | end 319 | local workspace_dir = "/home/test/projects/devcontainer" 320 | test_dockerfile_updates(given_config, workspace_dir) 321 | end) 322 | end) 323 | 324 | describe("given devcontainer config with image", function() 325 | describe("fill_defaults", function() 326 | local given_config = function() 327 | return { 328 | image = "test", 329 | build = {}, 330 | hostRequirements = {}, 331 | metadata = { 332 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 333 | }, 334 | } 335 | end 336 | local workspace_dir = "/home/test/projects/devcontainer" 337 | test_image_updates(given_config, workspace_dir) 338 | end) 339 | end) 340 | 341 | describe("given devcontainer config with dockerComposeFile", function() 342 | describe("fill_defaults", function() 343 | local test_it = function(name, block) 344 | it(name, function() 345 | local given_config = { 346 | dockerComposeFile = "docker-compose.yml", 347 | build = {}, 348 | hostRequirements = {}, 349 | metadata = { 350 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 351 | }, 352 | } 353 | local workspace_dir = "/home/test/projects/devcontainer" 354 | local config_mock = mock(require("devcontainer.config"), true) 355 | config_mock.workspace_folder_provider.returns(workspace_dir) 356 | 357 | local data = subject.fill_defaults(given_config) 358 | 359 | block(data, config_mock) 360 | 361 | mock.revert(config_mock) 362 | end) 363 | end 364 | 365 | test_it("should update dockerComposeFile to absolute path", function(data, _) 366 | assert.are.same("/home/test/projects/devcontainer/.devcontainer/docker-compose.yml", data.dockerComposeFile) 367 | end) 368 | 369 | test_it("should set workspaceFolder to default", function(data, _) 370 | assert.are.same("/workspace", data.workspaceFolder) 371 | end) 372 | 373 | test_it("should set overrideCommand to false", function(data, _) 374 | assert.are.same(false, data.overrideCommand) 375 | end) 376 | 377 | test_it("should fill out forwardPorts", function(data, _) 378 | assert.are.same({}, data.forwardPorts) 379 | end) 380 | 381 | test_it("should fill out remoteEnv", function(data, _) 382 | assert.are.same({}, data.remoteEnv) 383 | end) 384 | end) 385 | end) 386 | 387 | describe("given devcontainer config with dockerComposeFile list", function() 388 | describe("fill_defaults", function() 389 | local test_it = function(name, block) 390 | it(name, function() 391 | local given_config = { 392 | dockerComposeFile = { "docker-compose.yml", "../docker-compose.yml" }, 393 | build = {}, 394 | hostRequirements = {}, 395 | metadata = { 396 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 397 | }, 398 | } 399 | local workspace_dir = "/home/test/projects/devcontainer" 400 | local config_mock = mock(require("devcontainer.config"), true) 401 | config_mock.workspace_folder_provider.returns(workspace_dir) 402 | 403 | local data = subject.fill_defaults(given_config) 404 | 405 | block(data, config_mock) 406 | 407 | mock.revert(config_mock) 408 | end) 409 | end 410 | 411 | test_it("should update all entries in dockerComposeFile to absolute path", function(data, _) 412 | assert.are.same("/home/test/projects/devcontainer/.devcontainer/docker-compose.yml", data.dockerComposeFile[1]) 413 | assert.are.same( 414 | "/home/test/projects/devcontainer/.devcontainer/../docker-compose.yml", 415 | data.dockerComposeFile[2] 416 | ) 417 | end) 418 | end) 419 | end) 420 | 421 | describe("given complex devcontainer config", function() 422 | describe("fill_defaults", function() 423 | local test_it = function(name, block) 424 | it(name, function() 425 | local given_config = { 426 | name = "test", 427 | build = { 428 | dockerfile = "Dockerfile", 429 | }, 430 | runArgs = { 431 | "--cap-add=SYS_PTRACE", 432 | "--security-opt", 433 | "seccomp=unconfined", 434 | }, 435 | settings = { 436 | ["cmake.configureOnOpen"] = true, 437 | ["editor.formatOnSave"] = true, 438 | }, 439 | extensions = {}, 440 | containerEnv = { 441 | TEST_VAR = "${localEnv:TEST_VAR}", 442 | MISSING_VAR = "${localEnv:MISSING_VAR:someDefault}", 443 | COMBINED_VARS = "${localEnv:COMBINED_VAR1}-${localEnv:COMBINED_VAR2}", 444 | }, 445 | workspaceMount = "source=${localWorkspaceFolder}," 446 | .. "target=/workspaces/${localWorkspaceFolderBasename}," 447 | .. "type=bind," 448 | .. "consistency=delegated", 449 | workspaceFolder = "/workspaces/${localWorkspaceFolderBasename}", 450 | metadata = { 451 | file_path = "/home/test/projects/devcontainer/.devcontainer/devcontainer.json", 452 | }, 453 | } 454 | vim.env.TEST_VAR = "test_var_value" 455 | vim.env.COMBINED_VAR1 = "var1_value" 456 | vim.env.COMBINED_VAR2 = "var2_value" 457 | local workspace_dir = "/home/test/projects/devcontainer" 458 | local config_mock = mock(require("devcontainer.config"), true) 459 | config_mock.workspace_folder_provider.returns(workspace_dir) 460 | 461 | local data = subject.fill_defaults(given_config) 462 | 463 | block(data, config_mock) 464 | 465 | mock.revert(config_mock) 466 | end) 467 | end 468 | 469 | test_it("should update build.dockerfile and dockerFile to absolute paths", function(data, _) 470 | assert.are.same("/home/test/projects/devcontainer/.devcontainer/Dockerfile", data.build.dockerfile) 471 | assert.are.same(data.build.dockerfile, data.dockerFile) 472 | end) 473 | 474 | test_it("should update workspaceMount with replaced variables", function(data, _) 475 | assert.are.same( 476 | "source=/home/test/projects/devcontainer," 477 | .. "target=/workspaces/devcontainer," 478 | .. "type=bind," 479 | .. "consistency=delegated", 480 | data.workspaceMount 481 | ) 482 | end) 483 | 484 | test_it("should update workspaceFolder with replaced variables", function(data, _) 485 | assert.are.same("/workspaces/devcontainer", data.workspaceFolder) 486 | end) 487 | 488 | test_it("should update containerEnv with replaced variables", function(data, _) 489 | assert.are.same("test_var_value", data.containerEnv.TEST_VAR) 490 | assert.are.same("var1_value-var2_value", data.containerEnv.COMBINED_VARS) 491 | end) 492 | 493 | test_it("should use defaults for missing variables", function(data, _) 494 | assert.are.same("someDefault", data.containerEnv.MISSING_VAR) 495 | end) 496 | end) 497 | end) 498 | 499 | describe("given disabled recursive search", function() 500 | describe("parse_nearest_devcontainer_config", function() 501 | local cwd = "." 502 | local test_it = function(name, block) 503 | it(name, function() 504 | local result = '{ "image": "value" }' 505 | local uv_mock = mock(vim.loop, true) 506 | mock_file_read(uv_mock, result, {}) 507 | local config_mock = mock(require("devcontainer.config"), true) 508 | config_mock.config_search_start.returns(cwd) 509 | config_mock.disable_recursive_config_search = true 510 | uv_mock.fs_stat.on_call_with(cwd).returns({ ino = 123 }) 511 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer.json").invokes(missing_file_func) 512 | uv_mock.fs_stat.on_call_with(cwd .. "/.devcontainer/devcontainer.json").invokes(missing_file_func) 513 | uv_mock.fs_stat.on_call_with(cwd .. "/..").returns({ ino = 456 }) 514 | uv_mock.fs_stat.on_call_with(cwd .. "/../.devcontainer.json").returns({ ino = 789 }) 515 | uv_mock.fs_stat.on_call_with(cwd .. "/../.devcontainer/devcontainer.json").returns({ ino = 987 }) 516 | 517 | local success, data = pcall(subject.parse_nearest_devcontainer_config) 518 | 519 | block(success, data, uv_mock) 520 | 521 | mock.revert(uv_mock) 522 | mock.revert(config_mock) 523 | config_mock.disable_recursive_config_search = false 524 | end) 525 | end 526 | 527 | test_it("should return an error that no files were found", function(success, _, _) 528 | assert.is_not_true(success) 529 | end) 530 | end) 531 | end) 532 | 533 | describe("given remoteEnv", function() 534 | describe("fill_remote_env", function() 535 | local test_it = function(name, block) 536 | it(name, function() 537 | local remoteEnv = { 538 | TEST_VAR = "${containerEnv:TEST_VAR}", 539 | MISSING_VAR = "${containerEnv:MISSING_VAR:someOtherDefault}", 540 | COMBINED_VARS = "${containerEnv:COMBINED_VAR1}-${containerEnv:COMBINED_VAR2}", 541 | } 542 | local env_map = { 543 | TEST_VAR = "test_var_value", 544 | COMBINED_VAR1 = "var1_value", 545 | COMBINED_VAR2 = "var2_value", 546 | } 547 | 548 | local data = subject.fill_remote_env(remoteEnv, env_map) 549 | 550 | block(data) 551 | end) 552 | end 553 | 554 | test_it("should update remoteEnv with replaced variables", function(data, _) 555 | assert.are.same("test_var_value", data.TEST_VAR) 556 | assert.are.same("var1_value-var2_value", data.COMBINED_VARS) 557 | end) 558 | 559 | test_it("should use defaults for missing variables", function(data, _) 560 | assert.are.same("someOtherDefault", data.MISSING_VAR) 561 | end) 562 | end) 563 | end) 564 | 565 | describe("given containerWorkspaceFolder", function() 566 | describe("container_workspace_folder_needs_fill", function() 567 | it("should return true", function() 568 | local config = { 569 | containerEnv = { 570 | WORKSPACE_DIR = "${containerWorkspaceFolder}", 571 | }, 572 | } 573 | 574 | assert.is_true(subject.container_workspace_folder_needs_fill(config)) 575 | end) 576 | end) 577 | 578 | describe("fill_container_workspace_folder", function() 579 | it("should fill in placeholders with passed value", function() 580 | local config = { 581 | containerEnv = { 582 | WORKSPACE_DIR = "${containerWorkspaceFolder}", 583 | COMPLEX = "Dir is ${containerWorkspaceFolder}", 584 | }, 585 | } 586 | 587 | local result = subject.fill_container_workspace_folder(config, "/test_workspace") 588 | 589 | assert.are.same("/test_workspace", result.containerEnv.WORKSPACE_DIR) 590 | assert.are.same("Dir is /test_workspace", result.containerEnv.COMPLEX) 591 | end) 592 | end) 593 | end) 594 | 595 | describe("given containerWorkspaceFolderBasename", function() 596 | describe("container_workspace_folder_needs_fill", function() 597 | it("should return true", function() 598 | local config = { 599 | containerEnv = { 600 | WORKSPACE_DIR = "${containerWorkspaceFolderBasename}", 601 | }, 602 | } 603 | 604 | assert.is_true(subject.container_workspace_folder_needs_fill(config)) 605 | end) 606 | end) 607 | 608 | describe("fill_container_workspace_folder", function() 609 | it("should fill in placeholders with passed value", function() 610 | local config = { 611 | containerEnv = { 612 | WORKSPACE_DIR = "${containerWorkspaceFolderBasename}", 613 | COMPLEX = "Dir is ${containerWorkspaceFolderBasename}", 614 | }, 615 | } 616 | 617 | local result = subject.fill_container_workspace_folder(config, "/test_workspace/base") 618 | 619 | assert.are.same("base", result.containerEnv.WORKSPACE_DIR) 620 | assert.are.same("Dir is base", result.containerEnv.COMPLEX) 621 | end) 622 | end) 623 | end) 624 | 625 | describe("given no containerWorkspaceFolder mention", function() 626 | describe("container_workspace_folder_needs_fill", function() 627 | it("should return false", function() 628 | local config = { 629 | containerEnv = { 630 | WORKSPACE_DIR = "hardcoded", 631 | }, 632 | } 633 | 634 | assert.is_not_true(subject.container_workspace_folder_needs_fill(config)) 635 | end) 636 | end) 637 | end) 638 | end) 639 | -------------------------------------------------------------------------------- /tests/devcontainer/container_spec.lua: -------------------------------------------------------------------------------- 1 | local subject = require("devcontainer.container") 2 | 3 | -- TODO: Add specs 4 | describe("devcontainer.container:", function() 5 | local _ = subject 6 | end) 7 | -------------------------------------------------------------------------------- /tests/devcontainer/internal/executor_spec.lua: -------------------------------------------------------------------------------- 1 | local subject = require("devcontainer.internal.executor") 2 | 3 | describe("devcontainer.internal.executor:", function() 4 | describe("given existing executable", function() 5 | describe("is_executable", function() 6 | it("should return true", function() 7 | local success = subject.is_executable("nvim") 8 | assert.is_true(success) 9 | end) 10 | end) 11 | describe("ensure_executable", function() 12 | it("should not fail", function() 13 | local success = pcall(subject.ensure_executable, "nvim") 14 | assert.is_true(success) 15 | end) 16 | end) 17 | end) 18 | describe("given existing executable full command", function() 19 | describe("is_executable", function() 20 | it("should return true", function() 21 | local success = subject.is_executable("nvim --version") 22 | assert.is_true(success) 23 | end) 24 | end) 25 | describe("ensure_executable", function() 26 | it("should not fail", function() 27 | local success = pcall(subject.ensure_executable, "nvim --version") 28 | assert.is_true(success) 29 | end) 30 | end) 31 | end) 32 | describe("given missing executable", function() 33 | describe("is_executable", function() 34 | it("should return false", function() 35 | local success = subject.is_executable("doker") 36 | assert.is_not_true(success) 37 | end) 38 | end) 39 | describe("ensure_executable", function() 40 | it("should fail", function() 41 | local success = pcall(subject.ensure_executable, "doker") -- intentional typo 42 | assert.is_not_true(success) 43 | end) 44 | end) 45 | end) 46 | end) 47 | -------------------------------------------------------------------------------- /tests/devcontainer/internal/runtimes/helpers/common_compose_spec.lua: -------------------------------------------------------------------------------- 1 | local mock = require("luassert.mock") 2 | 3 | describe("devcontainer.internal.runtimes.helpers.common_compose:", function() 4 | local exe = require("devcontainer.internal.executor") 5 | local subject = require("devcontainer.internal.runtimes.helpers.common_compose") 6 | describe("given runtime with spaces", function() 7 | describe("up", function() 8 | it("should add runtime parts before arguments", function() 9 | local executor_mock = mock(exe, true) 10 | executor_mock.run_command = function(command, opts, _onexit) 11 | assert.are.same("docker", command) 12 | assert.are.same({ "compose", "-f", "docker-compose.yml", "up", "-d" }, opts.args) 13 | end 14 | 15 | subject.new({ runtime = "docker compose" }):up("docker-compose.yml", {}) 16 | end) 17 | end) 18 | end) 19 | end) 20 | -------------------------------------------------------------------------------- /tests/devcontainer/internal/utils_spec.lua: -------------------------------------------------------------------------------- 1 | local mock = require("luassert.mock") 2 | 3 | describe("devcontainer.internal.utils:", function() 4 | local subject = require("devcontainer.internal.utils") 5 | local plugin_config = require("devcontainer.config") 6 | 7 | describe("get_image_cache_tag", function() 8 | local mock_workspace_folder = function(folder) 9 | local plugin_config_mock = mock(plugin_config, true) 10 | plugin_config_mock.workspace_folder_provider = function() 11 | return folder 12 | end 13 | end 14 | 15 | it("should replace slashes", function() 16 | mock_workspace_folder("/home/nvim/test") 17 | assert.are.same("nvim_dev_container_homenvimtest", subject.get_image_cache_tag()) 18 | end) 19 | 20 | it("should replace backslashes", function() 21 | mock_workspace_folder("\\test\\dir") 22 | assert.are.same("nvim_dev_container_testdir", subject.get_image_cache_tag()) 23 | end) 24 | 25 | it("should replace colons", function() 26 | mock_workspace_folder("D:\\test\\dir") 27 | assert.are.same("nvim_dev_container_dtestdir", subject.get_image_cache_tag()) 28 | end) 29 | end) 30 | end) 31 | -------------------------------------------------------------------------------- /tests/init.vim: -------------------------------------------------------------------------------- 1 | set rtp=.,../plenary.nvim,../nvim-treesitter,$VIMRUNTIME 2 | 3 | runtime! plugin/plenary.vim 4 | runtime! plugin/nvim-treesitter.vim 5 | 6 | lua << EOF 7 | require'nvim-treesitter.configs'.setup { 8 | ensure_installed = { "jsonc" }, 9 | -- Install parsers synchronously (only applied to `ensure_installed`) 10 | sync_install = true, 11 | } 12 | 13 | -- Changing path_sep for tests - for Windows tests compatibility 14 | require("devcontainer.internal.utils").path_sep = "/" 15 | EOF 16 | 17 | function! StatusLine() 18 | lua << EOF 19 | local build_status_last = require("devcontainer.status").find_build({ running = true }) 20 | if build_status_last then 21 | local status 22 | status = 23 | (build_status_last.build_title or "") 24 | .. "[" 25 | .. (build_status_last.current_step or "") 26 | .. "/" 27 | .. (build_status_last.step_count or "") 28 | .. "]" 29 | .. (build_status_last.progress and "(" .. build_status_last.progress .. "%%)" or "") 30 | vim.g.mystatus = status 31 | else 32 | vim.g.mystatus = "NONE" 33 | end 34 | EOF 35 | return g:mystatus 36 | endfunction 37 | 38 | function! SetupStatusLineAutocommand() 39 | set statusline=%!StatusLine() 40 | autocmd User DevcontainerBuildProgress redrawstatus 41 | endfunction 42 | --------------------------------------------------------------------------------