├── tests ├── configs │ ├── tickets │ │ ├── 85 │ │ │ └── .devcontainer │ │ │ │ └── devcontainer.json │ │ ├── 127 │ │ │ └── .devcontainer │ │ │ │ └── devcontainer.json │ │ └── 136 │ │ │ ├── .dev.env │ │ │ ├── .devcontainer │ │ │ ├── Containerfile │ │ │ └── devcontainer.json │ │ │ └── podman-compose.dev.yaml │ ├── dockerfile │ │ └── .devcontainer │ │ │ ├── Dockerfile │ │ │ └── devcontainer.json │ ├── docker-compose │ │ └── .devcontainer │ │ │ ├── docker-compose.yml │ │ │ └── devcontainer.json │ └── image │ │ └── .devcontainer │ │ └── devcontainer.json ├── devcontainer │ ├── compose_spec.lua │ ├── container_spec.lua │ ├── internal │ │ ├── runtimes │ │ │ └── helpers │ │ │ │ └── common_compose_spec.lua │ │ ├── utils_spec.lua │ │ └── executor_spec.lua │ └── config_file │ │ ├── jsonc_spec.lua │ │ ├── parse_async_spec.lua │ │ └── parse_spec.lua └── init.vim ├── .luacheckrc ├── scripts ├── pre-commit ├── test ├── devsetup ├── gendoc └── docs-template.txt ├── .luarc.json ├── .editorconfig ├── lua └── devcontainer │ ├── internal │ ├── runtimes │ │ ├── compose │ │ │ ├── docker.lua │ │ │ ├── docker-compose.lua │ │ │ ├── devcontainer.lua │ │ │ └── podman-compose.lua │ │ ├── container │ │ │ ├── docker.lua │ │ │ ├── podman.lua │ │ │ └── devcontainer.lua │ │ ├── helpers │ │ │ ├── common_compose.lua │ │ │ └── common_container.lua │ │ └── init.lua │ ├── utils.lua │ ├── validation.lua │ ├── cmdline.lua │ ├── container_executor.lua │ ├── log.lua │ ├── executor.lua │ └── nvim.lua │ ├── config_file │ ├── jsonc.lua │ └── parse.lua │ ├── container_utils.lua │ ├── health.lua │ ├── compose.lua │ ├── config.lua │ ├── status.lua │ ├── init.lua │ └── container.lua ├── .gitignore ├── .forgejo ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── LICENSE ├── CONTRIBUTING.md └── README.md /tests/configs/tickets/136/.dev.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = { 2 | "vim", 3 | } 4 | -------------------------------------------------------------------------------- /tests/configs/dockerfile/.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian 2 | -------------------------------------------------------------------------------- /tests/configs/tickets/136/.devcontainer/Containerfile: -------------------------------------------------------------------------------- 1 | FROM debian 2 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | stylua --check . && luacheck . && scripts/test 6 | -------------------------------------------------------------------------------- /tests/configs/docker-compose/.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | debian: 3 | image: debian 4 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.checkThirdParty": false, 3 | "diagnostics.globals": [ 4 | "describe", 5 | "it" 6 | ] 7 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/configs/tickets/127/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python environment", 3 | "image": "docker.io/debian:bookworm-slim", 4 | "features": { 5 | "ghcr.io/devcontainers/features/python:1": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/configs/tickets/136/.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Developer Container", 3 | "dockerComposeFile": "../podman-compose.dev.yaml", 4 | "service": "dev", 5 | "shutdownAction": "stopCompose", 6 | "workspaceFolder": "/workspace" 7 | } 8 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/configs/tickets/136/podman-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dev: 3 | build: 4 | context: .devcontainer/ 5 | dockerfile: Containerfile 6 | container_name: dev 7 | volumes: 8 | - .:/workspace:cached 9 | env_file: .dev.env 10 | ports: 11 | - "0.0.0.0:8000:8000" 12 | command: sleep infinity 13 | bev: 14 | build: 15 | context: .devcontainer/ 16 | dockerfile: Containerfile 17 | container_name: bev 18 | volumes: 19 | - .:/workspace:cached 20 | env_file: .dev.env 21 | ports: 22 | - "0.0.0.0:8001:8001" 23 | command: sleep infinity 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | 12 | jobs: 13 | docs-test: 14 | runs-on: codeberg-tiny 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 | runs-on: codeberg-tiny 28 | steps: 29 | - name: Checkout nvim-dev-container 30 | uses: actions/checkout@v4 31 | - name: Cache stylua 32 | id: cache-stylua 33 | uses: actions/cache@v4 34 | with: 35 | path: stylua 36 | key: ${{ runner.os }}-stylua-${{ env.STYLUA_VERSION }} 37 | - name: Download stylua 38 | if: steps.cache-stylua.outputs.cache-hit != 'true' 39 | run: | 40 | wget "https://github.com/JohnnyMorganz/StyLua/releases/download/${{ env.STYLUA_VERSION }}/stylua-linux-x86_64.zip" -O stylua.zip 41 | unzip stylua.zip 42 | - name: Run stylua 43 | run: ./stylua --check . 44 | 45 | luacheck: 46 | runs-on: codeberg-tiny 47 | container: 48 | image: ghcr.io/lunarmodules/luacheck:v1.2.0 49 | steps: 50 | - name: Install git 51 | run: apk add --no-cache git 52 | - name: Checkout nvim-dev-container 53 | run: git clone "${{ github.server_url }}/${{ github.repository }}" . 54 | - name: Run luacheck 55 | run: luacheck . 56 | 57 | test: 58 | runs-on: codeberg-tiny 59 | steps: 60 | - name: Checkout nvim-dev-container 61 | uses: actions/checkout@v4 62 | - name: Setup neovim 63 | uses: https://github.com/rhysd/action-setup-vim@v1 64 | with: 65 | neovim: true 66 | version: stable 67 | - name: Checkout plenary 68 | run: git clone https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim 69 | - name: Checkout treesitter 70 | run: git clone https://github.com/nvim-treesitter/nvim-treesitter ~/.local/share/nvim/site/pack/vendor/start/nvim-treesitter 71 | - name: Link plenary and treesitter 72 | run: ln -s "$(pwd)" ~/.local/share/nvim/site/pack/vendor/start 73 | - name: Run tests 74 | run: scripts/test 75 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ---@field exec_args? table list of additional arguments to exec command 17 | 18 | ---Run all passed commands sequentially on the container 19 | ---If any of them fail, on_fail callback will be called immediately 20 | ---@param container_id string of container to run commands on 21 | ---@param commands table[string] commands to run on the container 22 | ---@param opts? RunAllOpts Additional options including callbacks 23 | function M.run_all_seq(container_id, commands, opts) 24 | vim.validate({ 25 | container_id = { container_id, "string" }, 26 | commands = { commands, "table" }, 27 | opts = { opts, { "table", "nil" } }, 28 | }) 29 | opts = opts or {} 30 | v.validate_callbacks(opts) 31 | v.validate_opts(opts, { 32 | on_step = "function", 33 | args = function(x) 34 | return x == nil or vim.islist(x) 35 | end, 36 | }) 37 | opts.on_success = opts.on_success 38 | or function() 39 | vim.notify("Successfully ran commands (" .. vim.inspect(commands) .. ") on container (" .. container_id .. ")") 40 | end 41 | opts.on_fail = opts.on_fail 42 | or function() 43 | vim.notify( 44 | "Running commands (" .. vim.inspect(commands) .. ") on (" .. container_id .. ") has failed!", 45 | vim.log.levels.ERROR 46 | ) 47 | end 48 | opts.on_step = opts.on_step 49 | or function(step) 50 | vim.notify("Successfully ran command (" .. table.concat(step, " ") .. ") on (" .. container_id .. ")!") 51 | end 52 | 53 | -- Index starts at 1 - first iteration is fake 54 | local index = 0 55 | local on_success_step 56 | on_success_step = function() 57 | if index > 0 then 58 | opts.on_step(commands[index]) 59 | end 60 | if index == #commands then 61 | opts.on_success() 62 | else 63 | index = index + 1 64 | runtimes.container.exec(container_id, { 65 | command = commands[index], 66 | args = opts.exec_args, 67 | on_success = on_success_step, 68 | on_fail = opts.on_fail, 69 | }) 70 | end 71 | end 72 | -- Start looping 73 | on_success_step() 74 | end 75 | 76 | log.wrap(M) 77 | return M 78 | -------------------------------------------------------------------------------- /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/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 exe = require("devcontainer.internal.executor") 6 | local log = require("devcontainer.internal.log") 7 | local common = require("devcontainer.internal.runtimes.helpers.common_compose") 8 | 9 | local M = common.new({ runtime = "podman-compose" }) 10 | 11 | ---Runs compose command with passed arguments 12 | ---@param args string[] 13 | ---@param opts? RunCommandOpts 14 | ---@param onexit function(code, signal) 15 | local function run_current_compose_command(args, opts, onexit) 16 | local runtime = "podman-compose" 17 | exe.ensure_executable(runtime) 18 | 19 | opts = opts or {} 20 | exe.run_command( 21 | runtime, 22 | vim.tbl_extend("force", opts, { 23 | args = args, 24 | stderr = vim.schedule_wrap(function(err, data) 25 | if data then 26 | log.fmt_error("%s command (%s): %s", runtime, args, data) 27 | end 28 | if opts.stderr then 29 | opts.stderr(err, data) 30 | end 31 | end), 32 | }), 33 | onexit 34 | ) 35 | end 36 | 37 | ---Prepare compose command arguments with file or files 38 | ---@param compose_file string|table 39 | local function get_compose_files_command(compose_file) 40 | local command = nil 41 | if type(compose_file) == "table" then 42 | command = {} 43 | for _, file in ipairs(compose_file) do 44 | table.insert(command, "-f") 45 | table.insert(command, file) 46 | end 47 | elseif type(compose_file) == "string" then 48 | command = { "-f", compose_file } 49 | end 50 | return command 51 | end 52 | 53 | ---Run compose ps with passed file and service to get its container_id 54 | ---@param compose_file string|table path to docker-compose.yml file or files 55 | ---@param service string service name 56 | ---@param opts ComposeGetContainerIdOpts Additional options including callbacks 57 | function M:get_container_id(compose_file, service, opts) 58 | local _ = self 59 | local command = get_compose_files_command(compose_file) 60 | vim.list_extend(command, { "ps", "--format", '{{ if eq .Names "' .. service .. '" }}{{.ID}}{{ else }}{{ end }}' }) 61 | local container_id = nil 62 | run_current_compose_command(command, { 63 | stdout = function(_, data) 64 | if data then 65 | local items = vim.split(data, "\n") 66 | for _, item in ipairs(items) do 67 | if item ~= "" then 68 | container_id = item 69 | end 70 | end 71 | end 72 | end, 73 | }, function(code, _) 74 | if code == 0 then 75 | opts.on_success(container_id) 76 | else 77 | opts.on_fail() 78 | end 79 | end) 80 | end 81 | 82 | log.wrap(M) 83 | return M 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | local status = require("devcontainer.status") 12 | 13 | local M = {} 14 | 15 | ---Runs command with passed arguments 16 | ---@param args string[] 17 | ---@param opts? RunCommandOpts 18 | ---@param onexit function(code, signal) 19 | local function run_with_current_runtime(args, opts, onexit) 20 | exe.ensure_executable("devcontainer") 21 | 22 | opts = opts or {} 23 | exe.run_command( 24 | "devcontainer", 25 | vim.tbl_extend("force", opts, { 26 | args = args, 27 | stderr = vim.schedule_wrap(function(err, data) 28 | if data then 29 | log.fmt_error("devcontainer command (%s): %s", args, data) 30 | end 31 | if opts.stderr then 32 | opts.stderr(err, data) 33 | end 34 | end), 35 | stdout = vim.schedule_wrap(function(err, data) 36 | if opts.stdout then 37 | opts.stdout(err, data) 38 | end 39 | end), 40 | }), 41 | onexit 42 | ) 43 | end 44 | 45 | ---Build image for passed workspace 46 | ---@param _ string Path to Dockerfile to build 47 | ---@param path string Path to the workspace 48 | ---@param opts ContainerBuildOpts Additional options including callbacks and tag 49 | function M:build(_, path, opts) 50 | local _ = self 51 | local command = { "--workspace-folder", path, "build" } 52 | run_with_current_runtime(command, {}, function(code, _) 53 | if code == 0 then 54 | opts.on_success("") 55 | else 56 | opts.on_fail() 57 | end 58 | end) 59 | end 60 | 61 | ---Run passed image using devcontainer CLI 62 | ---@param _ string image to run - ignored - using workspace folder 63 | ---@param opts ContainerRunOpts Additional options including callbacks 64 | function M:run(_, opts) 65 | local _ = self 66 | local container_id = nil 67 | local workspace_dir = nil 68 | local command = { "--workspace-folder", config.workspace_folder_provider(), "up" } 69 | run_with_current_runtime(command, { 70 | stdout = function(_, data) 71 | if data then 72 | local decoded = vim.json.decode(data) 73 | container_id = decoded["containerId"] 74 | workspace_dir = decoded["remoteWorkspaceFolder"] 75 | end 76 | end, 77 | }, function(code, _) 78 | if code == 0 then 79 | status.add_container({ 80 | image_id = "devcontainer-custom", 81 | container_id = container_id, 82 | workspace_dir = workspace_dir, 83 | autoremove = opts.autoremove, 84 | }) 85 | opts.on_success(container_id) 86 | else 87 | opts.on_fail() 88 | end 89 | end) 90 | end 91 | 92 | log.wrap(M) 93 | return M 94 | -------------------------------------------------------------------------------- /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/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 | vim.health.start("Neovim version") 12 | 13 | if vim.fn.has("nvim-0.12") == 0 then 14 | vim.health.warn("Latest Neovim version (0.12+) is required for improved attach command!") 15 | end 16 | if vim.fn.has("nvim-0.11") == 0 then 17 | vim.health.warn("Latest Neovim version is recommended for full feature set!") 18 | else 19 | vim.health.ok("Neovim version tested and supported: " .. vim_version_string()) 20 | end 21 | 22 | vim.health.start("Required plugins") 23 | 24 | local has_jsonc, jsonc_info = pcall(vim.treesitter.language.inspect, "jsonc") 25 | 26 | if not has_jsonc then 27 | vim.health.error("Jsonc treesitter parser missing! devcontainer.json files parsing will fail!") 28 | else 29 | vim.health.ok("Jsonc treesitter parser available. ABI version: " .. jsonc_info.abi_version) 30 | end 31 | 32 | vim.health.start("External dependencies") 33 | 34 | if config.container_runtime ~= nil then 35 | if executor.is_executable(config.container_runtime) then 36 | local handle = io.popen(config.container_runtime .. " --version") 37 | if handle ~= nil then 38 | local version = handle:read("*a") 39 | handle:close() 40 | vim.health.ok(version) 41 | end 42 | else 43 | vim.health.error(config.container_runtime .. " is not executable. Make sure it is installed!") 44 | end 45 | else 46 | local runtimes = { "podman", "docker" } 47 | local has_any = false 48 | for _, executable in ipairs(runtimes) do 49 | if executor.is_executable(executable) then 50 | has_any = true 51 | local handle = io.popen(executable .. " --version") 52 | if handle ~= nil then 53 | local version = handle:read("*a") 54 | handle:close() 55 | vim.health.ok("Found " .. executable .. ": " .. version) 56 | end 57 | end 58 | end 59 | if not has_any then 60 | vim.health.error("No container runtime is available! Install either podman or docker!") 61 | end 62 | end 63 | 64 | if config.compose_command ~= nil then 65 | if executor.is_executable(config.compose_command) then 66 | local handle = io.popen(config.compose_command .. " --version") 67 | if handle ~= nil then 68 | local version = handle:read("*a") 69 | handle:close() 70 | vim.health.ok(version) 71 | end 72 | else 73 | vim.health.error( 74 | config.compose_command .. " is not executable! It is required for full functionality of this plugin!" 75 | ) 76 | end 77 | else 78 | local compose_runtimes = { "podman-compose", "docker-compose", "docker compose" } 79 | local has_any = false 80 | for _, executable in ipairs(compose_runtimes) do 81 | if executor.is_executable(executable) then 82 | has_any = true 83 | local handle = io.popen(executable .. " --version") 84 | if handle ~= nil then 85 | local version = handle:read("*a") 86 | handle:close() 87 | vim.health.ok("Found " .. executable .. ": " .. version) 88 | end 89 | end 90 | end 91 | if not has_any then 92 | vim.health.error("No compose tool is available! Install either podman-compose or docker-compose!") 93 | end 94 | end 95 | end, 96 | } 97 | -------------------------------------------------------------------------------- /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/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/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 | ---@field install_as_root? boolean can be set to true to run installation as root 22 | 23 | ---Adds neovim to passed container using exec 24 | ---@param container_id string id of container to add neovim to 25 | ---@param opts? AddNeovimOpts Additional options including callbacks 26 | function M.add_neovim(container_id, opts) 27 | vim.validate({ 28 | container_id = { container_id, "string" }, 29 | opts = { opts, { "table", "nil" } }, 30 | }) 31 | opts = opts or {} 32 | v.validate_callbacks(opts) 33 | v.validate_opts(opts, { version = "string", install_as_root = "boolean" }) 34 | opts.on_success = opts.on_success 35 | or function() 36 | vim.notify("Successfully added neovim to container (" .. container_id .. ")") 37 | end 38 | opts.on_fail = opts.on_fail 39 | or function() 40 | vim.notify("Adding neovim to container (" .. container_id .. ") has failed!", vim.log.levels.ERROR) 41 | end 42 | opts.on_step = opts.on_step 43 | or function(step) 44 | vim.notify("Executed " .. table.concat(step, " ") .. " on container (" .. container_id .. ")!") 45 | end 46 | 47 | local function run_commands(commands) 48 | local build_status = { 49 | build_title = "Adding neovim to: " .. container_id, 50 | progress = 0, 51 | step_count = #commands, 52 | current_step = 0, 53 | image_id = nil, 54 | source_dockerfile = nil, 55 | build_command = "nvim.add_neovim", 56 | commands_run = {}, 57 | running = true, 58 | } 59 | local current_step = 0 60 | status.add_build(build_status) 61 | local exec_args = nil 62 | if opts.install_as_root then 63 | exec_args = { "-u", "0" } 64 | end 65 | 66 | container_executor.run_all_seq(container_id, commands, { 67 | on_success = function() 68 | build_status.running = false 69 | vim.api.nvim_exec_autocmds("User", { pattern = "DevcontainerBuildProgress", modeline = false }) 70 | if config.cache_images then 71 | local tag = u.get_image_cache_tag() 72 | container_runtime.container_commit(container_id, { 73 | tag = tag, 74 | }) 75 | end 76 | opts.on_success() 77 | end, 78 | on_step = function(step) 79 | current_step = current_step + 1 80 | build_status.current_step = current_step 81 | build_status.progress = math.floor((build_status.current_step / build_status.step_count) * 100) 82 | vim.api.nvim_exec_autocmds("User", { pattern = "DevcontainerBuildProgress", modeline = false }) 83 | opts.on_step(step) 84 | end, 85 | on_fail = opts.on_fail, 86 | exec_args = exec_args, 87 | }) 88 | end 89 | 90 | local version_string = opts.version 91 | if not version_string then 92 | local version = vim.version() 93 | version_string = "v" .. version.major .. "." .. version.minor .. "." .. version.patch 94 | end 95 | container_runtime.exec(container_id, { 96 | command = { "compgen", "-c" }, 97 | on_success = function(result) 98 | local available_commands = {} 99 | if result then 100 | local result_lines = vim.split(result, "\n") 101 | for _, line in ipairs(result_lines) do 102 | if v then 103 | table.insert(available_commands, line) 104 | end 105 | end 106 | end 107 | local commands = config.nvim_installation_commands_provider(available_commands, version_string) 108 | run_commands(commands) 109 | end, 110 | on_fail = function() 111 | local commands = config.nvim_installation_commands_provider({}, version_string) 112 | run_commands(commands) 113 | end, 114 | }) 115 | end 116 | 117 | log.wrap(M) 118 | return M 119 | -------------------------------------------------------------------------------- /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.12.0+ (previous versions may be supported, but are not tested - commands and autocommands will definitely fail and attaching will resort to terminal buffer instead of Neovim client/server mode) 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 | -- Can be set to true to install neovim as root in container 75 | -- Usually not required, but could help if permission errors occur during install 76 | nvim_install_as_root = false, 77 | devcontainer_json_template = function() 78 | -- Returns table - list of lines to set when creating new devcontainer.json files 79 | -- As a template 80 | -- Used only when using functions from commands module or created commands 81 | end, 82 | -- Can be set to false to prevent generating default commands 83 | -- Default commands are listed below 84 | generate_commands = true, 85 | -- By default no autocommands are generated 86 | -- This option can be used to configure automatic starting and cleaning of containers 87 | autocommands = { 88 | -- can be set to true to automatically start containers when devcontainer.json is available 89 | init = false, 90 | -- can be set to true to automatically remove any started containers and any built images when exiting vim 91 | clean = false, 92 | -- can be set to true to automatically restart containers when devcontainer.json file is updated 93 | update = false, 94 | }, 95 | -- can be changed to increase or decrease logging from library 96 | log_level = "info", 97 | -- can be set to true to disable recursive search 98 | -- in that case only .devcontainer.json and .devcontainer/devcontainer.json files will be checked relative 99 | -- to the directory provided by config_search_start 100 | disable_recursive_config_search = false, 101 | -- can be set to false to disable image caching when adding neovim 102 | -- by default it is set to true to make attaching to containers faster after first time 103 | cache_images = true, 104 | -- By default all mounts are added (config, data and state) 105 | -- This can be changed to disable mounts or change their options 106 | -- This can be useful to mount local configuration 107 | -- And any other mounts when attaching to containers with this plugin 108 | attach_mounts = { 109 | neovim_config = { 110 | -- enables mounting local config to /root/.config/nvim in container 111 | enabled = false, 112 | -- makes mount readonly in container 113 | options = { "readonly" } 114 | }, 115 | neovim_data = { 116 | -- enables mounting local data to /root/.local/share/nvim in container 117 | enabled = false, 118 | -- no options by default 119 | options = {} 120 | }, 121 | -- Only useful if using neovim 0.8.0+ 122 | neovim_state = { 123 | -- enables mounting local state to /root/.local/state/nvim in container 124 | enabled = false, 125 | -- no options by default 126 | options = {} 127 | }, 128 | }, 129 | -- This takes a list of mounts (strings) that should always be added to every run container 130 | -- This is passed directly as --mount option to docker command 131 | -- Or multiple --mount options if there are multiple values 132 | always_mount = {}, 133 | -- This takes a string (usually either "podman" or "docker") representing container runtime - "devcontainer-cli" is also partially supported 134 | -- That is the command that will be invoked for container operations 135 | -- If it is nil, plugin will use whatever is available (trying "podman" first) 136 | container_runtime = nil, 137 | -- Similar to container runtime, but will be used if main runtime does not support an action - useful for "devcontainer-cli" 138 | backup_runtime = nil, 139 | -- This takes a string (usually either "podman-compose" or "docker-compose") representing compose command - "devcontainer-cli" is also partially supported 140 | -- That is the command that will be invoked for compose operations 141 | -- If it is nil, plugin will use whatever is available (trying "podman-compose" first) 142 | compose_command = nil, 143 | -- Similar to compose command, but will be used if main command does not support an action - useful for "devcontainer-cli" 144 | backup_compose_command = nil, 145 | } 146 | ``` 147 | 148 | Check out [wiki](https://codeberg.org/esensar/nvim-dev-container/wiki) for more information. 149 | 150 | ### Commands 151 | 152 | If not disabled by using `generate_commands = false` in setup, this plugin provides the following commands: 153 | 154 | - `DevcontainerStart` - start whatever is defined in devcontainer.json 155 | - `DevcontainerAttach` - attach to whatever is defined in devcontainer.json 156 | - `DevcontainerExec` - execute a single command on container defined in devcontainer.json 157 | - `DevcontainerStop` - stop whatever was started based on devcontainer.json 158 | - `DevcontainerStopAll` - stop everything started with this plugin (in current session) 159 | - `DevcontainerRemoveAll` - remove everything started with this plugin (in current session) 160 | - `DevcontainerLogs` - open plugin log file 161 | - `DevcontainerEditNearestConfig` - opens nearest devcontainer.json file if it exists, or creates a new one if it does not 162 | 163 | ### Functions 164 | 165 | Check out [:h devcontainer](doc/devcontainer.txt) for full list of functions. 166 | 167 | ## Contributing 168 | 169 | Check out [contributing guidelines](CONTRIBUTING.md). 170 | 171 | ## License 172 | 173 | [MIT](LICENSE) 174 | -------------------------------------------------------------------------------- /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 | ---Can be set to true to install neovim as root 161 | ---This is usually not required, 162 | ---but if default container user can't run commands defined in M.nvim_installation_commands_provider this is required 163 | ---@type boolean 164 | M.nvim_install_as_root = false 165 | 166 | ---Provides template for creating new .devcontainer.json files 167 | ---This function should return a table listing lines of the file 168 | ---@type function 169 | M.devcontainer_json_template = default_devcontainer_json_template 170 | 171 | ---Used to set current container runtime 172 | ---By default plugin will try to use "docker" or "podman" 173 | ---@type string? 174 | M.container_runtime = nil 175 | 176 | ---Used to set backup runtime when main runtime does not support a command 177 | ---By default plugin will try to use "docker" or "podman" 178 | ---@type string? 179 | M.backup_runtime = nil 180 | 181 | ---Used to set current compose command 182 | ---By default plugin will try to use "docker-compose" or "podman-compose" 183 | ---@type string? 184 | M.compose_command = nil 185 | 186 | ---Used to set backup command when main command does not support a command 187 | ---By default plugin will try to use "docker-compose" or "podman-compose" 188 | ---@type string? 189 | M.backup_compose_command = nil 190 | 191 | ---@class MountOpts 192 | ---@field enabled boolean if true this mount is enabled 193 | ---@field options table[string]|nil additional bind options, useful to define { "readonly" } 194 | 195 | ---@class AttachMountsOpts 196 | ---@field neovim_config? MountOpts if true attaches neovim local config to /root/.config/nvim in container 197 | ---@field neovim_data? MountOpts if true attaches neovim data to /root/.local/share/nvim in container 198 | ---@field neovim_state? MountOpts if true attaches neovim state to /root/.local/state/nvim in container 199 | 200 | ---Configuration for mounts when using attach command 201 | ---NOTE: when attaching in a separate command, it is useful to set 202 | ---always to true, since these have to be attached when starting 203 | ---Useful to mount neovim configuration into container 204 | ---Applicable only to `devcontainer.commands` functions! 205 | ---@type AttachMountsOpts 206 | M.attach_mounts = { 207 | neovim_config = { 208 | enabled = false, 209 | options = { "readonly" }, 210 | }, 211 | neovim_data = { 212 | enabled = false, 213 | options = {}, 214 | }, 215 | neovim_state = { 216 | enabled = false, 217 | options = {}, 218 | }, 219 | } 220 | 221 | ---List of mounts to always add to all containers 222 | ---Applicable only to `devcontainer.commands` functions! 223 | ---@type table[string] 224 | M.always_mount = {} 225 | 226 | ---@alias LogLevel 227 | ---| '"trace"' 228 | ---| '"debug"' 229 | ---| '"info"' 230 | ---| '"warn"' 231 | ---| '"error"' 232 | ---| '"fatal"' 233 | 234 | ---Current log level 235 | ---@type LogLevel 236 | M.log_level = "info" 237 | 238 | ---List of env variables to add to all containers started with this plugin 239 | ---Applicable only to `devcontainer.commands` functions! 240 | ---NOTE: This does not support "${localEnv:VAR_NAME}" syntax - use vim.env 241 | ---@type table[string, string] 242 | M.container_env = {} 243 | 244 | ---List of env variables to add to all containers when attaching 245 | ---Applicable only to `devcontainer.commands` functions! 246 | ---NOTE: This supports "${containerEnv:VAR_NAME}" syntax to use variables from container 247 | ---@type table[string, string] 248 | M.remote_env = {} 249 | 250 | return M 251 | -------------------------------------------------------------------------------- /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 | ---@field workspace_dir? string overriden workspace folder for this container 24 | 25 | ---@class DevcontainerContainerQuery 26 | ---@field container_id? string id of the container 27 | ---@field image_id? string id of the used image 28 | 29 | ---@class DevcontainerComposeStatus 30 | ---@field file string path to compose file 31 | 32 | ---@class DevcontainerBuildStatus 33 | ---@field build_title string description of the build 34 | ---@field progress number 0-100 percentage 35 | ---@field step_count number number of steps to build 36 | ---@field current_step number current step 37 | ---@field image_id? string id of the built image 38 | ---@field source_dockerfile? string path to the file used to build the image 39 | ---@field build_command string command used to build the image 40 | ---@field commands_run string list of commands run by build (layers) 41 | ---@field running boolean true if still running 42 | 43 | ---@class DevcontainerStatus 44 | ---@field images_built table[DevcontainerImageStatus] 45 | ---@field running_containers table[DevcontainerContainerStatus] 46 | ---@field stopped_containers table[DevcontainerContainerStatus] 47 | ---@field build_status table[DevcontainerBuildStatus] 48 | ---@field compose_services table[DevcontainerComposeStatus] 49 | 50 | ---@type DevcontainerStatus 51 | local current_status = { 52 | images_built = {}, 53 | running_containers = {}, 54 | stopped_containers = {}, 55 | build_status = {}, 56 | compose_services = {}, 57 | } 58 | 59 | ---Finds container with requested opts 60 | ---@param opts DevcontainerContainerQuery required opts 61 | ---@return DevcontainerContainerStatus? 62 | local function get_container(opts) 63 | local all_containers = {} 64 | vim.list_extend(all_containers, current_status.running_containers) 65 | vim.list_extend(all_containers, current_status.stopped_containers) 66 | if not opts then 67 | return all_containers[1] 68 | end 69 | for _, v in ipairs(all_containers) do 70 | if opts.image_id and v.image_id == opts.image_id then 71 | return v 72 | end 73 | if opts.container_id and v.container_id == opts.container_id then 74 | return v 75 | end 76 | end 77 | return nil 78 | end 79 | 80 | ---Finds image with requested opts 81 | ---@param opts DevcontainerImageQuery required opts 82 | ---@return DevcontainerImageStatus? 83 | local function get_image(opts) 84 | if not opts then 85 | return current_status.images_built[1] 86 | end 87 | for _, v in ipairs(current_status.images_built) do 88 | if opts.image_id and v.image_id == opts.image_id then 89 | return v 90 | end 91 | if opts.source_dockerfile and v.source_dockerfile == opts.source_dockerfile then 92 | return v 93 | end 94 | end 95 | return nil 96 | end 97 | 98 | ---Finds build with requested opts 99 | ---@param opts DevcontainerBuildStatus required opts 100 | ---@return DevcontainerBuildStatus? 101 | local function get_build(opts) 102 | if not opts then 103 | return current_status.build_status[#current_status.build_status] 104 | end 105 | for _, v in ipairs(current_status.build_status) do 106 | if opts.image_id and v.image_id == opts.image_id then 107 | return v 108 | end 109 | if opts.source_dockerfile and v.source_dockerfile == opts.source_dockerfile then 110 | return v 111 | end 112 | if opts.running and v.running == opts.running then 113 | return v 114 | end 115 | end 116 | return nil 117 | end 118 | 119 | ---@private 120 | ---Adds image to the status or replaces if item with same image_id exists 121 | ---@param image_status DevcontainerImageStatus 122 | function M.add_image(image_status) 123 | local existing = get_image({ image_id = image_status.image_id }) 124 | if existing then 125 | existing.source_dockerfile = image_status.source_dockerfile 126 | existing.tmp_dockerfile = image_status.tmp_dockerfile 127 | else 128 | table.insert(current_status.images_built, image_status) 129 | end 130 | end 131 | 132 | ---@private 133 | ---Removes image from the status 134 | ---@param image_id string 135 | function M.remove_image(image_id) 136 | for i, v in ipairs(current_status.images_built) do 137 | if v.image_id == image_id then 138 | table.remove(current_status.images_built, i) 139 | return 140 | end 141 | end 142 | end 143 | 144 | ---@private 145 | ---Adds container to the status or replaces if item with same container_id exists 146 | ---@param container_status DevcontainerContainerStatus 147 | function M.add_container(container_status) 148 | local existing = get_container({ container_id = container_status.container_id }) 149 | if existing then 150 | existing.autoremove = container_status.autoremove 151 | existing.image_id = container_status.image_id 152 | existing.workspace_dir = container_status.workspace_dir 153 | M.move_container_to_running(container_status.container_id) 154 | else 155 | table.insert(current_status.running_containers, container_status) 156 | end 157 | end 158 | 159 | ---@private 160 | ---Moves container from running_containers to stopped_containers 161 | ---@param container_id string 162 | function M.move_container_to_stopped(container_id) 163 | for i, v in ipairs(current_status.running_containers) do 164 | if v.container_id == container_id then 165 | local container_status = table.remove(current_status.running_containers, i) 166 | if not container_status.autoremove then 167 | table.insert(current_status.stopped_containers, container_status) 168 | end 169 | return 170 | end 171 | end 172 | end 173 | 174 | ---@private 175 | ---Moves container from stopped_containers to running_containers 176 | ---@param container_id string 177 | function M.move_container_to_running(container_id) 178 | for i, v in ipairs(current_status.stopped_containers) do 179 | if v.container_id == container_id then 180 | local container_status = table.remove(current_status.stopped_containers, i) 181 | table.insert(current_status.running_containers, container_status) 182 | return 183 | end 184 | end 185 | end 186 | 187 | ---@private 188 | ---Removes container from the status 189 | ---@param container_id string 190 | function M.remove_container(container_id) 191 | for i, v in ipairs(current_status.stopped_containers) do 192 | if v.container_id == container_id then 193 | table.remove(current_status.stopped_containers, i) 194 | return 195 | end 196 | end 197 | for i, v in ipairs(current_status.running_containers) do 198 | if v.container_id == container_id then 199 | table.remove(current_status.running_containers, i) 200 | return 201 | end 202 | end 203 | end 204 | 205 | ---@private 206 | ---Adds compose service to the status 207 | ---@param compose_status DevcontainerComposeStatus 208 | function M.add_compose(compose_status) 209 | M.remove_compose(compose_status.file) 210 | table.insert(current_status.compose_services, compose_status) 211 | end 212 | 213 | ---@private 214 | ---Removes compoes service from the status 215 | ---@param compose_file string 216 | function M.remove_compose(compose_file) 217 | for i, v in ipairs(current_status.compose_services) do 218 | if v.file == compose_file then 219 | table.remove(current_status.compose_services, i) 220 | return 221 | end 222 | end 223 | end 224 | 225 | ---@private 226 | ---Adds build to the status 227 | ---@param build_status DevcontainerBuildStatus 228 | function M.add_build(build_status) 229 | table.insert(current_status.build_status, build_status) 230 | end 231 | 232 | ---Returns current devcontainer status in a table 233 | ---@return DevcontainerStatus 234 | function M.get_status() 235 | return vim.deepcopy(current_status) 236 | end 237 | 238 | ---Finds container with requested opts 239 | ---Read-only 240 | ---@param opts DevcontainerContainerQuery required opts 241 | ---@return DevcontainerContainerStatus 242 | function M.find_container(opts) 243 | return vim.deepcopy(get_container(opts)) 244 | end 245 | 246 | ---Returns latest container 247 | ---Read-only 248 | ---@return DevcontainerContainerStatus 249 | function M.get_latest_container() 250 | return vim.deepcopy(current_status.running_containers[#current_status.running_containers]) 251 | end 252 | 253 | ---Finds image with requested opts 254 | ---Read-only 255 | ---@param opts DevcontainerImageQuery required opts 256 | ---@return DevcontainerImageStatus 257 | function M.find_image(opts) 258 | return vim.deepcopy(get_image(opts)) 259 | end 260 | 261 | ---Finds build status with requested opts 262 | ---Read-only 263 | ---@param opts DevcontainerBuildStatus required opts 264 | ---@return DevcontainerBuildStatus 265 | function M.find_build(opts) 266 | return vim.deepcopy(get_build(opts)) 267 | end 268 | 269 | return M 270 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 runtime = tostring(self.runtime) or config.container_runtime 73 | local build_status = { 74 | build_title = "Dockerfile: " .. file, 75 | progress = 0, 76 | step_count = 0, 77 | current_step = 0, 78 | image_id = nil, 79 | source_dockerfile = file, 80 | build_command = table.concat(vim.list_extend({ runtime }, command), " "), 81 | commands_run = {}, 82 | running = true, 83 | } 84 | status.add_build(build_status) 85 | 86 | local image_id = nil 87 | run_with_current_runtime(self, command, { 88 | stdout = vim.schedule_wrap(function(_, data) 89 | if data then 90 | local lines = vim.split(data, "\n") 91 | local step_regex = vim.regex("\\cStep [[:digit:]]*/[[:digit:]]* *: .*") 92 | for _, line in ipairs(lines) do 93 | ---@diagnostic disable-next-line: need-check-nil 94 | if step_regex:match_str(line) then 95 | local step_line = vim.split(line, ":") 96 | local step_numbers = vim.split(vim.split(step_line[1], " ")[2], "/") 97 | table.insert(build_status.commands_run, string.sub(step_line[2], 2)) 98 | build_status.current_step = tonumber(step_numbers[1]) 99 | build_status.step_count = tonumber(step_numbers[2]) 100 | build_status.progress = math.floor((build_status.current_step / build_status.step_count) * 100) 101 | opts.on_progress(vim.deepcopy(build_status)) 102 | end 103 | end 104 | end 105 | end), 106 | }, function(code, _) 107 | image_id = vim.fn.readfile(id_temp_file)[1] 108 | vim.fn.delete(id_temp_file) 109 | build_status.running = false 110 | opts.on_progress(vim.deepcopy(build_status)) 111 | if code == 0 then 112 | status.add_image({ 113 | image_id = image_id, 114 | source_dockerfile = file, 115 | }) 116 | opts.on_success(image_id) 117 | else 118 | opts.on_fail() 119 | end 120 | end) 121 | end 122 | 123 | ---Run passed image using 124 | ---@param image string image to run 125 | ---@param opts ContainerRunOpts Additional options including callbacks 126 | function M:run(image, opts) 127 | local command = { "run", "-i", "-d" } 128 | if opts.autoremove == true then 129 | table.insert(command, "--rm") 130 | end 131 | 132 | vim.list_extend(command, opts.args or {}) 133 | 134 | table.insert(command, image) 135 | if opts.command then 136 | if type(opts.command) == "string" then 137 | table.insert(command, opts.command) 138 | elseif type(opts.command) == "table" then 139 | ---@diagnostic disable-next-line: param-type-mismatch 140 | vim.list_extend(command, opts.command) 141 | end 142 | end 143 | 144 | local container_id = nil 145 | run_with_current_runtime(self, command, { 146 | stdout = function(_, data) 147 | if data then 148 | container_id = vim.split(data, "\n")[1] 149 | end 150 | end, 151 | }, function(code, _) 152 | if code == 0 then 153 | status.add_container({ 154 | image_id = image, 155 | container_id = container_id, 156 | autoremove = opts.autoremove, 157 | }) 158 | opts.on_success(container_id) 159 | else 160 | opts.on_fail() 161 | end 162 | end) 163 | end 164 | 165 | ---Run command on a container using 166 | ---@param container_id string container to exec on 167 | ---@param opts ContainerExecOpts Additional options including callbacks 168 | function M:exec(container_id, opts) 169 | local command = { "exec", "-i" } 170 | if opts.tty then 171 | table.insert(command, "-t") 172 | end 173 | 174 | vim.list_extend(command, opts.args or {}) 175 | 176 | table.insert(command, container_id) 177 | if opts.command then 178 | if type(opts.command) == "string" then 179 | table.insert(command, opts.command) 180 | elseif type(opts.command) == "table" then 181 | ---@diagnostic disable-next-line: param-type-mismatch 182 | vim.list_extend(command, opts.command) 183 | end 184 | end 185 | 186 | local runtime = tostring(self.runtime) or config.container_runtime 187 | if opts.tty then 188 | (opts.terminal_handler or config.terminal_handler)(vim.list_extend({ runtime }, command)) 189 | else 190 | local run_opts = nil 191 | local captured = nil 192 | if opts.capture_output then 193 | run_opts = { 194 | stdout = function(_, data) 195 | if data then 196 | captured = data 197 | end 198 | end, 199 | } 200 | end 201 | run_with_current_runtime(self, command, run_opts, function(code, _) 202 | if code == 0 then 203 | if opts.capture_output then 204 | opts.on_success(captured) 205 | else 206 | opts.on_success(nil) 207 | end 208 | else 209 | opts.on_fail() 210 | end 211 | end) 212 | end 213 | end 214 | 215 | ---Stop passed containers 216 | ---@param containers table[string] ids of containers to stop 217 | ---@param opts ContainerStopOpts Additional options including callbacks 218 | function M:container_stop(containers, opts) 219 | local command = { "container", "stop" } 220 | 221 | vim.list_extend(command, containers) 222 | run_with_current_runtime(self, command, nil, function(code, _) 223 | if code == 0 then 224 | for _, container in ipairs(containers) do 225 | status.move_container_to_stopped(container) 226 | end 227 | opts.on_success() 228 | else 229 | opts.on_fail() 230 | end 231 | end) 232 | end 233 | 234 | ---Commit container into an image 235 | ---@param container string id of container to commit 236 | ---@param opts ContainerCommitOpts Additional options including callbacks 237 | function M:container_commit(container, opts) 238 | local command = { "commit", container, opts.tag } 239 | 240 | run_with_current_runtime(self, command, nil, function(code, _) 241 | if code == 0 then 242 | opts.on_success() 243 | else 244 | opts.on_fail() 245 | end 246 | end) 247 | end 248 | 249 | ---Removes passed images 250 | ---@param images table[string] ids of images to remove 251 | ---@param opts ImageRmOpts Additional options including callbacks 252 | function M:image_rm(images, opts) 253 | local command = { "image", "rm" } 254 | 255 | if opts.force then 256 | table.insert(command, "-f") 257 | end 258 | 259 | vim.list_extend(command, images) 260 | run_with_current_runtime(self, command, nil, function(code, _) 261 | if code == 0 then 262 | for _, image in ipairs(images) do 263 | status.remove_image(image) 264 | end 265 | opts.on_success() 266 | else 267 | opts.on_fail() 268 | end 269 | end) 270 | end 271 | 272 | ---Inspect image using image inspect command 273 | ---@param image string id of image 274 | ---@param opts ImageInspectOpts Additional options including callbacks 275 | function M:image_inspect(image, opts) 276 | local command = { "image", "inspect", image } 277 | if opts.format ~= nil then 278 | vim.list_extend(command, { "--format", opts.format }) 279 | end 280 | 281 | local response = nil 282 | run_with_current_runtime(self, command, { 283 | stdout = function(_, data) 284 | if data then 285 | if opts.format ~= nil then 286 | response = data 287 | else 288 | response = vim.json.decode(data) 289 | end 290 | end 291 | end, 292 | }, function(code, _) 293 | if code == 0 then 294 | opts.on_success(response) 295 | else 296 | opts.on_fail() 297 | end 298 | end) 299 | end 300 | 301 | ---Checks if image contains another image 302 | ---@param parent_image string id of image that should contain other image 303 | ---@param child_image string id of image that should be contained in the parent image 304 | ---@param opts ImageContainsOpts Additional options including callbacks 305 | function M:image_contains(parent_image, child_image, opts) 306 | local notified_error = false 307 | local notified_success = false 308 | local parent_done = false 309 | local parent_layers = {} 310 | local child_done = false 311 | local child_layers = {} 312 | 313 | local function parse_layers(data) 314 | if data then 315 | local cleaned = string.gsub(data, "[%[%]%\n]", "") 316 | return vim.split(cleaned, " ") 317 | else 318 | return {} 319 | end 320 | end 321 | 322 | local function notify_error() 323 | if not notified_error then 324 | notified_error = true 325 | opts.on_fail() 326 | end 327 | end 328 | 329 | local function notify_success() 330 | if parent_done and child_done and not notified_success then 331 | notified_success = true 332 | for _, v in ipairs(child_layers) do 333 | local contains = false 334 | for _, pv in ipairs(parent_layers) do 335 | if v == pv then 336 | contains = true 337 | break 338 | end 339 | end 340 | if not contains then 341 | opts.on_success(false) 342 | return 343 | end 344 | end 345 | opts.on_success(true) 346 | end 347 | end 348 | 349 | local format = "{{.RootFS.Layers}}" 350 | 351 | self:image_inspect(parent_image, { 352 | format = format, 353 | on_success = function(response) 354 | parent_layers = parse_layers(response) 355 | parent_done = true 356 | notify_success() 357 | end, 358 | on_fail = notify_error, 359 | }) 360 | self:image_inspect(child_image, { 361 | format = format, 362 | on_success = function(response) 363 | child_layers = parse_layers(response) 364 | child_done = true 365 | notify_success() 366 | end, 367 | on_fail = notify_error, 368 | }) 369 | end 370 | 371 | ---Removes passed containers 372 | ---@param containers table[string] ids of containers to remove 373 | ---@param opts ContainerRmOpts Additional options including callbacks 374 | function M:container_rm(containers, opts) 375 | local command = { "container", "rm" } 376 | 377 | if opts.force then 378 | table.insert(command, "-f") 379 | end 380 | 381 | vim.list_extend(command, containers) 382 | run_with_current_runtime(self, command, nil, function(code, _) 383 | if code == 0 then 384 | for _, container in ipairs(containers) do 385 | status.remove_container(container) 386 | end 387 | opts.on_success() 388 | else 389 | opts.on_fail() 390 | end 391 | end) 392 | end 393 | 394 | ---Lists containers 395 | ---@param opts ContainerLsOpts Additional options including callbacks 396 | function M:container_ls(opts) 397 | local command = { "container", "ls", "--format", "{{.Names}}" } 398 | 399 | if opts.all then 400 | table.insert(command, "-a") 401 | end 402 | 403 | local containers = {} 404 | local parse_and_store_containers = function(data) 405 | if data then 406 | local new_containers = vim.split(data, "\n") 407 | for _, v in ipairs(new_containers) do 408 | if v then 409 | table.insert(containers, v) 410 | end 411 | end 412 | end 413 | end 414 | if opts.async ~= false then 415 | run_with_current_runtime(self, command, { 416 | stdout = function(_, data) 417 | parse_and_store_containers(data) 418 | end, 419 | }, function(code, _) 420 | if code == 0 then 421 | opts.on_success(containers) 422 | else 423 | opts.on_fail() 424 | end 425 | end) 426 | else 427 | local runtime = tostring(self.runtime) or config.container_runtime 428 | table.insert(command, 1, runtime) 429 | local code, result = exe.run_command_sync(command) 430 | if code == 0 then 431 | parse_and_store_containers(result) 432 | return containers 433 | else 434 | error("Code: " .. code .. ". Message: " .. result) 435 | end 436 | end 437 | end 438 | 439 | M = utils.add_constructor(M) 440 | log.wrap(M) 441 | return M 442 | -------------------------------------------------------------------------------- /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 nvim_install_as_root? boolean can be set to true to install neovim as root in container - usually not required 30 | ---@field generate_commands? boolean can be set to false to prevent plugin from creating commands (true by default) 31 | ---@field autocommands? DevcontainerAutocommandOpts can be set to enable autocommands, disabled by default 32 | ---@field log_level? LogLevel can be used to override library logging level 33 | ---@field container_env? table can be used to override containerEnv for all started containers 34 | ---@field remote_env? table can be used to override remoteEnv when attaching to containers 35 | ---@field disable_recursive_config_search? boolean can be used to disable recursive .devcontainer search 36 | ---@field cache_images? boolean can be used to cache images after adding neovim - true by default 37 | ---@field attach_mounts? AttachMountsOpts can be used to configure mounts when adding neovim to containers 38 | ---@field always_mount? table[table|string] list of mounts to add to every container 39 | ---@field container_runtime? string container runtime to use ("docker", "podman", "devcontainer-cli") 40 | ---@field backup_runtime? string container runtime to use when main does not support an action ("docker", "podman") 41 | ---@field compose_command? string command to use for compose 42 | ---@field backup_compose_command? string command to use for compose when main does not support an action 43 | 44 | ---Starts the plugin and sets it up with provided options 45 | ---@param opts? DevcontainerSetupOpts 46 | function M.setup(opts) 47 | if configured then 48 | log.info("Already configured, skipping!") 49 | return 50 | end 51 | 52 | vim.validate({ 53 | opts = { opts, "table" }, 54 | }) 55 | opts = opts or {} 56 | v.validate_opts(opts, { 57 | config_search_start = "function", 58 | workspace_folder_provider = "function", 59 | terminal_handler = "function", 60 | devcontainer_json_template = "function", 61 | nvim_installation_commands_provider = "function", 62 | nvim_install_as_root = "boolean", 63 | generate_commands = "boolean", 64 | autocommands = "table", 65 | log_level = "string", 66 | container_env = "table", 67 | remote_env = "table", 68 | disable_recursive_config_search = "boolean", 69 | cache_images = "boolean", 70 | attach_mounts = "table", 71 | always_mount = function(t) 72 | return t == nil or vim.islist(t) 73 | end, 74 | }) 75 | if opts.autocommands then 76 | v.validate_deep(opts.autocommands, "opts.autocommands", { 77 | init = { "boolean", "string" }, 78 | clean = "boolean", 79 | update = "boolean", 80 | }) 81 | end 82 | local am = opts.attach_mounts 83 | if am then 84 | v.validate_deep(am, "opts.attach_mounts", { 85 | neovim_config = "table", 86 | neovim_data = "table", 87 | neovim_state = "table", 88 | }) 89 | 90 | local mount_opts_mapping = { 91 | enabled = "boolean", 92 | options = function(t) 93 | return t == nil or vim.islist(t) 94 | end, 95 | } 96 | 97 | if am.neovim_config then 98 | v.validate_deep(am.neovim_config, "opts.attach_mounts.neovim_config", mount_opts_mapping) 99 | end 100 | 101 | if am.neovim_data then 102 | v.validate_deep(am.neovim_data, "opts.attach_mounts.neovim_data", mount_opts_mapping) 103 | end 104 | 105 | if am.neovim_state then 106 | v.validate_deep(am.neovim_state, "opts.attach_mounts.neovim_state", mount_opts_mapping) 107 | end 108 | end 109 | 110 | configured = true 111 | 112 | config.terminal_handler = opts.terminal_handler or config.terminal_handler 113 | config.devcontainer_json_template = opts.devcontainer_json_template or config.devcontainer_json_template 114 | config.nvim_installation_commands_provider = opts.nvim_installation_commands_provider 115 | or config.nvim_installation_commands_provider 116 | config.nvim_install_as_root = opts.nvim_install_as_root or config.nvim_install_as_root 117 | config.workspace_folder_provider = opts.workspace_folder_provider or config.workspace_folder_provider 118 | config.config_search_start = opts.config_search_start or config.config_search_start 119 | config.always_mount = opts.always_mount or config.always_mount 120 | config.attach_mounts = opts.attach_mounts or config.attach_mounts 121 | config.disable_recursive_config_search = opts.disable_recursive_config_search 122 | or config.disable_recursive_config_search 123 | if opts.cache_images ~= nil then 124 | config.cache_images = opts.cache_images 125 | end 126 | if vim.env.NVIM_DEVCONTAINER_DEBUG then 127 | config.log_level = "trace" 128 | else 129 | config.log_level = opts.log_level or config.log_level 130 | end 131 | config.container_env = opts.container_env or config.container_env 132 | config.remote_env = opts.remote_env or config.remote_env 133 | config.container_runtime = opts.container_runtime or config.container_runtime 134 | config.backup_runtime = opts.backup_runtime or config.backup_runtime 135 | config.compose_command = opts.compose_command or config.compose_command 136 | config.backup_compose_command = opts.backup_compose_command or config.backup_compose_command 137 | 138 | if config.compose_command == nil then 139 | if executor.is_executable("podman-compose") then 140 | config.compose_command = "podman-compose" 141 | elseif executor.is_executable("docker-compose") then 142 | config.compose_command = "docker-compose" 143 | elseif executor.is_executable("docker compose") then 144 | config.compose_command = "docker compose" 145 | end 146 | end 147 | 148 | if config.backup_compose_command == nil then 149 | if executor.is_executable("podman-compose") then 150 | config.backup_compose_command = "podman-compose" 151 | elseif executor.is_executable("docker-compose") then 152 | config.backup_compose_command = "docker-compose" 153 | elseif executor.is_executable("docker compose") then 154 | config.backup_compose_command = "docker compose" 155 | end 156 | end 157 | 158 | if config.container_runtime == nil then 159 | if executor.is_executable("podman") then 160 | config.container_runtime = "podman" 161 | elseif executor.is_executable("docker") then 162 | config.container_runtime = "docker" 163 | end 164 | end 165 | 166 | if config.backup_runtime == nil then 167 | if executor.is_executable("podman") then 168 | config.backup_runtime = "podman" 169 | elseif executor.is_executable("docker") then 170 | config.backup_runtime = "docker" 171 | end 172 | end 173 | 174 | if opts.generate_commands ~= false then 175 | local container_command_complete = cmdline.complete_parse(function(cmdline_status) 176 | local command_suggestions = { "nvim", "sh" } 177 | -- Filling second arg 178 | if cmdline_status.current_arg == 2 then 179 | return command_suggestions 180 | elseif cmdline_status.current_arg == 1 then 181 | local options = { "devcontainer", "latest" } 182 | local containers = runtime.container.container_ls({ async = false }) 183 | vim.list_extend(options, containers) 184 | 185 | if cmdline_status.arg_count == 1 then 186 | vim.list_extend(options, command_suggestions) 187 | end 188 | return options 189 | end 190 | return {} 191 | end) 192 | 193 | -- Automatic 194 | vim.api.nvim_create_user_command("DevcontainerStart", function(_) 195 | commands.start_auto() 196 | end, { 197 | nargs = 0, 198 | desc = "Start either compose, dockerfile or image from .devcontainer.json", 199 | }) 200 | vim.api.nvim_create_user_command("DevcontainerAttach", function(args) 201 | local target = "devcontainer" 202 | local command = "nvim" 203 | if #args.fargs == 1 then 204 | command = args.fargs[1] 205 | elseif #args.fargs > 1 then 206 | target = args.fargs[1] 207 | command = args.fargs 208 | table.remove(command, 1) 209 | end 210 | commands.attach_auto(target, command) 211 | end, { 212 | nargs = "*", 213 | desc = "Attach to either compose, dockerfile or image from .devcontainer.json", 214 | complete = container_command_complete, 215 | }) 216 | vim.api.nvim_create_user_command("DevcontainerExec", function(args) 217 | local target = "devcontainer" 218 | local command = "nvim" 219 | if #args.fargs == 1 then 220 | command = args.fargs[1] 221 | elseif #args.fargs > 1 then 222 | target = args.fargs[1] 223 | command = args.fargs 224 | table.remove(command, 1) 225 | end 226 | commands.exec(target, command) 227 | end, { 228 | nargs = "*", 229 | desc = "Execute a command on running container", 230 | complete = container_command_complete, 231 | }) 232 | vim.api.nvim_create_user_command("DevcontainerStop", function(_) 233 | commands.stop_auto() 234 | end, { 235 | nargs = 0, 236 | desc = "Stop either compose, dockerfile or image from .devcontainer.json", 237 | }) 238 | 239 | -- Cleanup 240 | vim.api.nvim_create_user_command("DevcontainerStopAll", function(_) 241 | commands.stop_all() 242 | end, { 243 | nargs = 0, 244 | desc = "Stop everything started with devcontainer", 245 | }) 246 | vim.api.nvim_create_user_command("DevcontainerRemoveAll", function(_) 247 | commands.remove_all() 248 | end, { 249 | nargs = 0, 250 | desc = "Remove everything started with devcontainer", 251 | }) 252 | 253 | -- Util 254 | vim.api.nvim_create_user_command("DevcontainerLogs", function(_) 255 | commands.open_logs() 256 | end, { 257 | nargs = 0, 258 | desc = "Open devcontainer plugin logs in a new buffer", 259 | }) 260 | vim.api.nvim_create_user_command("DevcontainerEditNearestConfig", function(_) 261 | commands.edit_devcontainer_config() 262 | end, { 263 | nargs = 0, 264 | desc = "Opens nearest devcontainer.json file in a new buffer or creates one if it does not exist", 265 | }) 266 | end 267 | 268 | if opts.autocommands then 269 | local au_id = vim.api.nvim_create_augroup("devcontainer_autostart", {}) 270 | 271 | if opts.autocommands.init then 272 | local last_devcontainer_file = nil 273 | 274 | local function auto_start() 275 | parse.find_nearest_devcontainer_config(vim.schedule_wrap(function(err, data) 276 | if err == nil and data ~= nil then 277 | if vim.loop.fs_realpath(data) ~= last_devcontainer_file then 278 | if opts.autocommands.init == "ask" then 279 | vim.ui.select( 280 | { "Yes", "No" }, 281 | { prompt = "Devcontainer file found! Would you like to start the container?" }, 282 | function(choice) 283 | if choice == "Yes" then 284 | commands.start_auto() 285 | last_devcontainer_file = vim.loop.fs_realpath(data) 286 | end 287 | end 288 | ) 289 | else 290 | commands.start_auto() 291 | last_devcontainer_file = vim.loop.fs_realpath(data) 292 | end 293 | end 294 | end 295 | end)) 296 | end 297 | 298 | vim.api.nvim_create_autocmd("BufEnter", { 299 | pattern = "*", 300 | group = au_id, 301 | callback = function() 302 | auto_start() 303 | end, 304 | once = true, 305 | }) 306 | 307 | vim.api.nvim_create_autocmd("DirChanged", { 308 | pattern = "*", 309 | group = au_id, 310 | callback = function() 311 | auto_start() 312 | end, 313 | }) 314 | end 315 | 316 | if opts.autocommands.clean then 317 | vim.api.nvim_create_autocmd("VimLeavePre", { 318 | pattern = "*", 319 | group = au_id, 320 | callback = function() 321 | commands.remove_all() 322 | end, 323 | }) 324 | end 325 | 326 | if opts.autocommands.update then 327 | vim.api.nvim_create_autocmd({ "BufWritePost", "FileWritePost" }, { 328 | pattern = "*devcontainer.json", 329 | group = au_id, 330 | callback = function(event) 331 | parse.find_nearest_devcontainer_config(function(err, data) 332 | if err == nil and data ~= nil then 333 | if data == event.match then 334 | commands.stop_auto(function() 335 | commands.start_auto() 336 | end) 337 | end 338 | end 339 | end) 340 | end, 341 | }) 342 | end 343 | end 344 | 345 | log.info("Setup complete!") 346 | end 347 | 348 | return M 349 | -------------------------------------------------------------------------------- /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.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.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.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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------