-- Language-aware project runner. -- -- Detects what kind of project the current buffer lives in and exposes a small -- set of verbs (run / run file / build / test / flash / ...) that map onto the -- right tool for that project. Bound under R -- see -- lua/plugins/project-run.lua. -- -- Everything is discovered from the project on disk; nothing is hardcoded to a -- machine, and per-project choices (like the Zephyr board) are cached in -- stdpath("state") rather than written into the repo. local M = {} --------------------------------------------------------------------------- -- helpers --------------------------------------------------------------------------- --- The file the project should be judged from. --- --- Not simply the current buffer: after one of these verbs opens a terminal, --- that terminal is the focused buffer, and walking up from it finds no project --- markers. So prefer the focused buffer only when it is a real file, and --- otherwise fall back to the most recently used normal buffer, then cwd. ---@return string function M.source_path() local buf = vim.api.nvim_get_current_buf() if vim.bo[buf].buftype == "" then local name = vim.api.nvim_buf_get_name(buf) if name ~= "" then return name end end -- Tracked by the autocmd in lua/plugins/project-run.lua. local last = vim.g.project_last_file if type(last) == "string" and last ~= "" then return last end return vim.uv.cwd() end ---@param markers string[] ---@return string|nil local function root_with(markers) return vim.fs.root(M.source_path(), markers) end local function exists(path) return path and vim.uv.fs_stat(path) ~= nil end --- Run a command in a terminal at `cwd` so you can watch it and answer prompts. local function term(cmd, cwd, opts) opts = opts or {} local ok, Snacks = pcall(require, "snacks") if ok and Snacks.terminal then Snacks.terminal.open(cmd, { cwd = cwd, interactive = true, win = { position = opts.position or "float" }, }) else vim.cmd("botright split | terminal " .. cmd) end vim.notify(cmd, vim.log.levels.INFO, { title = "project" }) end --- Prefer a project-local virtualenv interpreter when there is one. local function python_for(root) for _, rel in ipairs({ ".venv/bin/python", "venv/bin/python" }) do local p = vim.fs.joinpath(root, rel) if exists(p) then return vim.fn.shellescape(p) end end return "python3" end --------------------------------------------------------------------------- -- per-project state (board selection, etc.) --------------------------------------------------------------------------- local state_file = vim.fs.joinpath(vim.fn.stdpath("state"), "project-run.json") local function load_state() if not exists(state_file) then return {} end local ok, data = pcall(function() return vim.json.decode(table.concat(vim.fn.readfile(state_file), "\n")) end) return (ok and type(data) == "table") and data or {} end local function save_state(tbl) pcall(vim.fn.writefile, vim.fn.split(vim.json.encode(tbl), "\n"), state_file) end ---@param root string ---@param key string function M.get_setting(root, key) local s = load_state() return (s[root] or {})[key] end ---@param root string ---@param key string ---@param value any function M.set_setting(root, key, value) local s = load_state() s[root] = s[root] or {} s[root][key] = value save_state(s) end --------------------------------------------------------------------------- -- project detection --------------------------------------------------------------------------- --- Order matters: a Zephyr app is also a CMake project, so it must win. local DETECT = { { type = "zephyr", markers = { "west.yml", ".west", "prj.conf" } }, { type = "rust", markers = { "Cargo.toml" } }, { type = "python", markers = { "pyproject.toml", "setup.py", "requirements.txt" } }, { type = "cmake", markers = { "CMakeLists.txt", "CMakePresets.json" } }, { type = "make", markers = { "Makefile" } }, } ---@return string type, string root function M.detect() for _, d in ipairs(DETECT) do local root = root_with(d.markers) if root then -- A Zephyr app has prj.conf next to CMakeLists.txt; plain CMake does not. return d.type, root end end return "unknown", vim.uv.cwd() end function M.info() local t, root = M.detect() local extra = "" if t == "zephyr" then extra = " board: " .. (M.get_setting(root, "board") or "") end vim.notify(("project: %s\nroot: %s%s"):format(t, root, extra), vim.log.levels.INFO, { title = "project" }) end --------------------------------------------------------------------------- -- Zephyr --------------------------------------------------------------------------- local board_list_cache = nil --- `west boards` walks the whole tree and is slow, so cache the result. local function zephyr_boards(root, cb) if board_list_cache then return cb(board_list_cache) end vim.notify("listing boards (west boards)...", vim.log.levels.INFO, { title = "zephyr" }) vim.system({ "west", "boards", "-f", "{name}" }, { cwd = root, text = true }, function(res) vim.schedule(function() if res.code ~= 0 then vim.notify("west boards failed:\n" .. (res.stderr or ""), vim.log.levels.ERROR, { title = "zephyr" }) return cb({}) end local boards = {} for _, line in ipairs(vim.split(res.stdout or "", "\n")) do line = vim.trim(line) if line ~= "" then boards[#boards + 1] = line end end table.sort(boards) board_list_cache = boards cb(boards) end) end) end --- Pick a board with the configured fuzzy picker (vim.ui.select is routed to it). ---@param root string ---@param cb fun(board:string)|nil function M.zephyr_select_board(root, cb) root = root or select(2, M.detect()) zephyr_boards(root, function(boards) if #boards == 0 then return end vim.ui.select(boards, { prompt = "Zephyr board:" }, function(choice) if not choice then return end M.set_setting(root, "board", choice) vim.notify("board = " .. choice, vim.log.levels.INFO, { title = "zephyr" }) if cb then cb(choice) end end) end) end --- Get the board, prompting for one if it has not been chosen yet. local function with_board(root, cb) local board = M.get_setting(root, "board") if board then return cb(board) end M.zephyr_select_board(root, cb) end local function zephyr_build_dir(root) return M.get_setting(root, "build_dir") or "build" end --------------------------------------------------------------------------- -- verbs --------------------------------------------------------------------------- --- Primary "run this project". function M.run() local t, root = M.detect() if t == "rust" then term("cargo run", root) elseif t == "python" then local py = python_for(root) -- main.py at the root wins; then any package with a __main__.py. if exists(vim.fs.joinpath(root, "main.py")) then term(py .. " main.py", root) return end local mains = vim.fs.find("__main__.py", { path = root, type = "file", limit = 5 }) if #mains > 0 then local dir = vim.fs.dirname(mains[1]) local pkg = vim.fs.basename(dir) local parent = vim.fs.dirname(dir) -- run as a module so intra-package imports resolve term(("PYTHONPATH=%s %s -m %s"):format(vim.fn.shellescape(parent), py, pkg), root) return end M.run_file() elseif t == "zephyr" then M.flash() elseif t == "cmake" then -- run the first executable cmake produced, if we can find one local bin = vim.fs.find(function(name, path) return path:match("/build$") ~= nil and vim.fn.executable(vim.fs.joinpath(path, name)) == 1 end, { path = vim.fs.joinpath(root, "build"), type = "file", limit = 1 })[1] if bin then term(vim.fn.shellescape(bin), root) else vim.notify("no executable found in build/ -- build first", vim.log.levels.WARN, { title = "project" }) end elseif t == "make" then term("make run", root) else M.run_file() end end --- Run just the current file. function M.run_file() local file = M.source_path() if file == "" or vim.fn.filereadable(file) == 0 then return vim.notify("no file to run", vim.log.levels.WARN, { title = "project" }) end local _, root = M.detect() local ft = vim.filetype.match({ filename = file }) or vim.bo.filetype local runners = { python = function() return python_for(root) .. " " .. vim.fn.shellescape(file) end, lua = function() return "lua " .. vim.fn.shellescape(file) end, sh = function() return "bash " .. vim.fn.shellescape(file) end, rust = function() return "cargo run" end, go = function() return "go run " .. vim.fn.shellescape(file) end, } local build = runners[ft] if not build then -- Compiled languages have no meaningful "run this one file"; say what to -- use instead rather than just refusing. return vim.notify( ("no single-file runner for '%s' -- use Rr to run the project"):format(ft), vim.log.levels.WARN, { title = "project" } ) end term(build(), root) end function M.build() local t, root = M.detect() if t == "zephyr" then with_board(root, function(board) term(("west build -b %s -d %s ."):format(board, zephyr_build_dir(root)), root) end) elseif t == "rust" then term("cargo build", root) elseif t == "cmake" then term("cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON && cmake --build build", root) elseif t == "make" then term("make", root) elseif t == "python" then term(python_for(root) .. " -m build", root) else vim.notify("no build for project type '" .. t .. "'", vim.log.levels.WARN, { title = "project" }) end end function M.test() local t, root = M.detect() if t == "rust" then term("cargo test", root) elseif t == "python" then term(python_for(root) .. " -m pytest", root) elseif t == "zephyr" then with_board(root, function(board) term(("west twister -p %s -T ."):format(board), root) end) elseif t == "cmake" then term("ctest --test-dir build --output-on-failure", root) elseif t == "make" then term("make test", root) else vim.notify("no test for project type '" .. t .. "'", vim.log.levels.WARN, { title = "project" }) end end --- Zephyr flash (and the obvious analogue elsewhere). function M.flash() local t, root = M.detect() if t ~= "zephyr" then return vim.notify("flash is Zephyr-only (project is '" .. t .. "')", vim.log.levels.WARN, { title = "project" }) end with_board(root, function(board) term(("west build -b %s -d %s . && west flash -d %s"):format(board, zephyr_build_dir(root), zephyr_build_dir(root)), root) end) end function M.clean() local t, root = M.detect() if t == "zephyr" then term("west build -t pristine -d " .. zephyr_build_dir(root), root) elseif t == "rust" then term("cargo clean", root) elseif t == "cmake" then term("cmake --build build --target clean", root) elseif t == "make" then term("make clean", root) else vim.notify("no clean for project type '" .. t .. "'", vim.log.levels.WARN, { title = "project" }) end end --- Zephyr menuconfig / rust clippy / python lint -- the "configure" slot. function M.configure() local t, root = M.detect() if t == "zephyr" then term("west build -t menuconfig -d " .. zephyr_build_dir(root), root) elseif t == "rust" then term("cargo clippy --all-targets", root) elseif t == "cmake" then term("cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", root) else vim.notify("nothing to configure for '" .. t .. "'", vim.log.levels.WARN, { title = "project" }) end end --- Select the target: Zephyr board today, extensible later. function M.select_target() local t, root = M.detect() if t == "zephyr" then M.zephyr_select_board(root) else vim.notify("no target to select for project type '" .. t .. "'", vim.log.levels.WARN, { title = "project" }) end end return M