From 9061d69ee1265be4809855e0ae1166b16b64e960 Mon Sep 17 00:00:00 2001 From: 0xfraso Date: Mon, 9 Mar 2026 09:14:45 +0100 Subject: [PATCH] feat: add env support to Java profiles --- CLAUDE.md | 2 + README.md | 5 +- doc/nvim-java.txt | 5 +- lua/java-dap/init.lua | 31 ++++- lua/java-runner/run.lua | 4 +- lua/java-runner/runner.lua | 22 +++- lua/java/api/profile_config.lua | 23 +++- lua/java/ui/profile.lua | 114 +++++++++++------ lua/java/utils/env.lua | 155 ++++++++++++++++++++++++ tests/specs/profile_spec.lua | 208 ++++++++++++++++++++++++++++++++ 10 files changed, 523 insertions(+), 46 deletions(-) create mode 100644 lua/java/utils/env.lua create mode 100644 tests/specs/profile_spec.lua diff --git a/CLAUDE.md b/CLAUDE.md index 65aa678..cd5a08d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,8 @@ tests/ **Config merging:** `vim.tbl_deep_extend('force', global_config, user_config or {})` +**Profiles:** `JavaProfile` persists vm args, program args, inline env entries, and an env file; inline env overrides env file values for both DAP and built-in runner + **Config type sync:** When modifying `lua/java/config.lua` (add/update/delete properties), update both `java.Config` type and `java.PartialConfig` in `lua/java.lua` to keep types in sync **Complex types:** If type contains complex object, create class type instead of inlining type everywhere diff --git a/README.md b/README.md index d7776b2..2607764 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Yep! That's all :) ### Profiles -- `JavaProfile` - Opens the profiles UI +- `JavaProfile` - Opens the profiles UI for VM args, program args, env, and env file ### Refactor @@ -245,6 +245,9 @@ require('java').test.view_last_report() require('java').profile.ui() ``` +Profiles can store VM args, program args, `KEY=VALUE` env entries, and an env file path. +Inline env overrides env file values. Relative env file paths resolve from the current project cwd. + ### Refactor - `extract_variable` - Create a variable from value at cursor/selection diff --git a/doc/nvim-java.txt b/doc/nvim-java.txt index 709c13b..d242de4 100644 --- a/doc/nvim-java.txt +++ b/doc/nvim-java.txt @@ -138,7 +138,7 @@ TEST ~ PROFILES ~ -- `JavaProfile` - Opens the profiles UI +- `JavaProfile` - Opens the profiles UI for vm args, program args, env, and env file REFACTOR ~ @@ -262,6 +262,9 @@ PROFILES ~ require('java').profile.ui() < +Profiles can store vm args, program args, `KEY=VALUE` env entries, and an env file path. +Inline env overrides env file values. Relative env file paths resolve from the current project cwd. + REFACTOR ~ diff --git a/lua/java-dap/init.lua b/lua/java-dap/init.lua index f93db4b..37971a1 100644 --- a/lua/java-dap/init.lua +++ b/lua/java-dap/init.lua @@ -25,12 +25,14 @@ end ---Configure dap function M.config_dap() local get_error_handler = require('java-core.utils.error_handler') + local notify = require('java-core.utils.notify') local runner = require('async.runner') return runner(function() local lsp_utils = require('java-core.utils.lsp') local nvim_dap = require('dap') local profile_config = require('java.api.profile_config') + local env_utils = require('java.utils.env') local DapSetup = require('java-dap.setup') local client = lsp_utils.get_jdtls() @@ -51,12 +53,37 @@ function M.config_dap() ---------------------------------------------------------------------- local dap_config = dap:get_dap_config() + local applied_dap_config = {} + local preserved_dap_config = {} + for _, config in ipairs(nvim_dap.configurations.java or {}) do + if not config._nvim_java_managed then + table.insert(preserved_dap_config, config) + end + end for _, config in ipairs(dap_config) do + local is_valid = true local profile = profile_config.get_active_profile(config.name) if profile then config.vmArgs = profile.vm_args config.args = profile.prog_args + + local env, err = env_utils.load_profile_env( + profile, + profile_config.current_project_path + ) + if err then + notify.error(err) + is_valid = false + else + config.env = env + config.envFile = profile.env_file + end + end + + if is_valid then + config._nvim_java_managed = true + table.insert(applied_dap_config, config) end end @@ -64,8 +91,8 @@ function M.config_dap() nvim_dap.terminate() end - nvim_dap.configurations.java = nvim_dap.configurations.java or {} - vim.list_extend(nvim_dap.configurations.java, dap_config) + nvim_dap.configurations.java = preserved_dap_config + vim.list_extend(nvim_dap.configurations.java, applied_dap_config) end) .catch(get_error_handler('dap configuration failed')) .run() diff --git a/lua/java-runner/run.lua b/lua/java-runner/run.lua index 41fc87e..aa67cfb 100644 --- a/lua/java-runner/run.lua +++ b/lua/java-runner/run.lua @@ -25,12 +25,14 @@ function Run:_init(dap_config) end ---@param cmd string[] -function Run:start(cmd) +---@param env table|nil +function Run:start(cmd, env) local merged_cmd = table.concat(cmd, ' ') self.is_running = true self:send_term(merged_cmd) self.job_chan_id = vim.fn.jobstart(merged_cmd, { + env = env, pty = true, on_stdout = function(_, data) self:send_term(data) diff --git a/lua/java-runner/runner.lua b/lua/java-runner/runner.lua index bce939e..9a02630 100644 --- a/lua/java-runner/runner.lua +++ b/lua/java-runner/runner.lua @@ -1,10 +1,12 @@ local ui = require('java.ui.utils') local class = require('java-core.utils.class') local lsp_utils = require('java-core.utils.lsp') +local notify = require('java-core.utils.notify') local profile_config = require('java.api.profile_config') local Run = require('java-runner.run') local RunLogger = require('java-runner.run-logger') local DapSetup = require('java-dap.setup') +local env_utils = require('java.utils.env') ---@class java.Runner ---@field runs table @@ -20,7 +22,7 @@ end ---Starts a new run ---@param args string function Runner:start_run(args) - local cmd, dap_config = self:select_dap_config(args) + local cmd, dap_config, env = self:select_dap_config(args) if not cmd or not dap_config then return @@ -41,7 +43,7 @@ function Runner:start_run(args) self.curr_run = run self.logger:set_buffer(run.buffer) - run:start(cmd) + run:start(cmd, env) end ---Stops the user selected run @@ -100,6 +102,7 @@ end ---@param args string additional program arguments to pass ---@return string[] | nil ---@return java-dap.DapLauncherConfig | nil +---@return table | nil function Runner:select_dap_config(args) local dap = DapSetup(lsp_utils.get_jdtls()) local dap_config_list = dap:get_dap_config() @@ -109,7 +112,7 @@ function Runner:select_dap_config(args) end) if not selected_dap_config then - return nil, nil + return nil, nil, nil end local enriched_config = dap:enrich_config(selected_dap_config) @@ -122,10 +125,21 @@ function Runner:select_dap_config(args) local vm_args = '' local prog_args = args + local env = nil if active_profile then prog_args = (active_profile.prog_args or '') .. ' ' .. (args or '') vm_args = active_profile.vm_args or '' + + local err + env, err = env_utils.load_profile_env( + active_profile, + profile_config.current_project_path + ) + if err then + notify.error(err) + return nil, nil, nil + end end local cmd = { @@ -137,7 +151,7 @@ function Runner:select_dap_config(args) prog_args, } - return cmd, selected_dap_config + return cmd, selected_dap_config, env end return Runner diff --git a/lua/java/api/profile_config.lua b/lua/java/api/profile_config.lua index 6389cae..302819b 100644 --- a/lua/java/api/profile_config.lua +++ b/lua/java/api/profile_config.lua @@ -9,17 +9,23 @@ local config_path = vim.fn.stdpath('data') .. '/nvim-java-profiles.json' --- @field prog_args string --- @field name string --- @field is_active boolean +--- @field env table +--- @field env_file string|nil local Profile = class() --- @param vm_args string --- @param prog_args string --- @param name string --- @param is_active boolean -function Profile:_init(vm_args, prog_args, name, is_active) +--- @param env table|nil +--- @param env_file string|nil +function Profile:_init(vm_args, prog_args, name, is_active, env, env_file) self.vm_args = vm_args self.prog_args = prog_args self.name = name self.is_active = is_active or false + self.env = env or {} + self.env_file = env_file end -- palin config structure @@ -30,12 +36,18 @@ end -- "vm_args": "-Xmx1024m", -- "prog_args": "arg1 arg2", -- "name": "profile_name1", +-- "env": { +-- "SPRING_PROFILES_ACTIVE": "dev" +-- }, +-- "env_file": ".env.dev", -- "is_active": true -- }, -- { -- "vm_args": "-Xmx1024m", -- "prog_args": "arg1 arg2", -- "name": "profile_name2", +-- "env": {}, +-- "env_file": ".env.local", -- "is_active": false -- } -- ], @@ -90,7 +102,14 @@ function M.load_current_project_profiles() result[dap_config_name] = {} for _, profile in pairs(dap_config_name_val) do result[dap_config_name][profile.name] = - Profile(profile.vm_args, profile.prog_args, profile.name, profile.is_active) + Profile( + profile.vm_args, + profile.prog_args, + profile.name, + profile.is_active, + profile.env, + profile.env_file + ) end end return result diff --git a/lua/java/ui/profile.lua b/lua/java/ui/profile.lua index 83876ff..753d6c1 100644 --- a/lua/java/ui/profile.lua +++ b/lua/java/ui/profile.lua @@ -3,6 +3,7 @@ local notify = require('java-core.utils.notify') local profile_config = require('java.api.profile_config') local class = require('java-core.utils.class') local dap_api = require('java-dap') +local env_utils = require('java.utils.env') local lsp_utils = require('java-core.utils.lsp') local ui = require('java.ui.utils') @@ -13,20 +14,22 @@ local DapSetup = require('java-dap.setup') local new_profile = 'New Profile' ---- @param up_win number ---- @param down_win number -local function map_keys_for_profile_editor(popup, up_win, down_win) - local function go_up() - vim.api.nvim_set_current_win(up_win) - end +---@param ordered_popups Popup[] +local function map_keys_for_profile_editor(ordered_popups) + for index, popup in ipairs(ordered_popups) do + local up_index = index == 1 and #ordered_popups or index - 1 + local down_index = index == #ordered_popups and 1 or index + 1 - local function go_down() - vim.api.nvim_set_current_win(down_win) + popup:map('n', '', function() + vim.api.nvim_set_current_win(ordered_popups[down_index].winid) + end) + popup:map('n', 'k', function() + vim.api.nvim_set_current_win(ordered_popups[up_index].winid) + end) + popup:map('n', 'j', function() + vim.api.nvim_set_current_win(ordered_popups[down_index].winid) + end) end - - popup:map('n', '', go_down) - popup:map('n', 'k', go_up) - popup:map('n', 'j', go_down) end --- @param popup Popup @@ -39,6 +42,17 @@ local function get_popup_value(popup) notify.error('Failed to get popup value for ' .. popup.name) end +---@param popup Popup +---@return string[] +local function get_popup_lines(popup) + local ok, value = pcall(vim.api.nvim_buf_get_lines, popup.bufnr, 0, -1, false) + if ok then + return value + end + notify.error('Failed to get popup value for ' .. popup.name) + return {} +end + --- @param name string --- @return boolean local function is_contains_active_postfix(name) @@ -57,8 +71,34 @@ end local function save_profile(popups, target_profile, main_class) local vm_args = get_popup_value(popups.vm_args) local prog_args = get_popup_value(popups.prog_args) + local env_file = vim.trim(get_popup_value(popups.env_file) or '') + local env, err = env_utils.parse_lines(get_popup_lines(popups.env)) local name = get_popup_value(popups.name) - local profile = profile_config.Profile(vm_args, prog_args, name) + + if err then + notify.warn('Invalid environment entries: ' .. err) + return false + end + + if env_file ~= '' then + local _, env_file_err = env_utils.read_file( + env_file, + profile_config.current_project_path + ) + if env_file_err then + notify.warn(env_file_err) + return false + end + end + + local profile = profile_config.Profile( + vm_args, + prog_args, + name, + nil, + env, + env_file ~= '' and env_file or nil + ) if profile.name == nil or profile.name == '' then notify.warn('Profile name is required') @@ -186,12 +226,20 @@ function ProfileUI:get_and_fill_popup(title, key, target_profile, enter, keymaps -- fill the popup with the config value -- if target_profile is nil, it's a new profile if target_profile then + local value = profile_config.get_profile(target_profile, self.main_class)[key] + if key == 'env' then + value = env_utils.stringify(value) + end + if value == nil or value == '' then + value = '' + end + vim.api.nvim_buf_set_lines( popup.bufnr, 0, -1, false, - { profile_config.get_profile(target_profile, self.main_class)[key] } + vim.split(value, '\n', { plain = true }) ) end return popup @@ -202,20 +250,31 @@ function ProfileUI:open_profile_editor(target_profile) local popups = { name = self:get_and_fill_popup('Name', 'name', target_profile, true, false), vm_args = self:get_and_fill_popup('VM arguments', 'vm_args', target_profile, false, false), - prog_args = self:get_and_fill_popup('Program arguments', 'prog_args', target_profile, false, true), + prog_args = self:get_and_fill_popup('Program arguments', 'prog_args', target_profile, false, false), + env = self:get_and_fill_popup('Environment', 'env', target_profile, false, false), + env_file = self:get_and_fill_popup('Environment file', 'env_file', target_profile, false, true), + } + local ordered_popups = { + popups.name, + popups.vm_args, + popups.prog_args, + popups.env, + popups.env_file, } local layout = Layout( { relative = 'editor', position = '50%', - size = { height = 15, width = 60 }, + size = { height = 22, width = 60 }, }, Layout.Box({ - Layout.Box(popups.name, { grow = 0.2 }), - Layout.Box(popups.vm_args, { grow = 0.4 }), - Layout.Box(popups.prog_args, { grow = 0.4 }), + Layout.Box(popups.name, { grow = 0.15 }), + Layout.Box(popups.vm_args, { grow = 0.2 }), + Layout.Box(popups.prog_args, { grow = 0.2 }), + Layout.Box(popups.env, { grow = 0.3 }), + Layout.Box(popups.env_file, { grow = 0.15 }), }, { dir = 'col' }) ) @@ -238,22 +297,7 @@ function ProfileUI:open_profile_editor(target_profile) end) end - map_keys_for_profile_editor( - popups.name, -- popup (first) - popups.prog_args.winid, -- up_win - popups.vm_args.winid -- down_win - ) - - map_keys_for_profile_editor( - popups.vm_args, -- popup (second) - popups.name.winid, -- up_win - popups.prog_args.winid -- down_win - ) - map_keys_for_profile_editor( - popups.prog_args, -- popup (third) - popups.vm_args.winid, -- up_win - popups.name.winid -- down_win - ) + map_keys_for_profile_editor(ordered_popups) end ---@private diff --git a/lua/java/utils/env.lua b/lua/java/utils/env.lua new file mode 100644 index 0000000..3380095 --- /dev/null +++ b/lua/java/utils/env.lua @@ -0,0 +1,155 @@ +local M = {} + +---@param path string +---@return boolean +local function is_absolute_path(path) + return vim.startswith(path, '/') + or path:match('^%a:[/\\]') ~= nil + or vim.startswith(path, '\\\\') +end + +---@param path string +---@param base_dir string|nil +---@return string +local function resolve_path(path, base_dir) + local expanded = vim.fn.expand(path) + + if is_absolute_path(expanded) then + return vim.fn.fnamemodify(expanded, ':p') + end + + if base_dir and vim.trim(base_dir) ~= '' then + return vim.fn.fnamemodify(base_dir .. '/' .. expanded, ':p') + end + + return vim.fn.fnamemodify(expanded, ':p') +end + +---@param value string +---@return string +local function trim(value) + return vim.trim(value or '') +end + +---@param value string +---@return string +local function normalize_value(value) + local trimmed = trim(value) + local quote = trimmed:sub(1, 1) + + if (quote == '"' or quote == "'") and trimmed:sub(-1) == quote then + return trimmed:sub(2, -2) + end + + return trimmed +end + +---@param line string +---@return string|nil +---@return string|nil +function M.parse_line(line) + local trimmed = trim(line) + + if trimmed == '' or trimmed:sub(1, 1) == '#' then + return nil, nil + end + + trimmed = trimmed:gsub('^export%s+', '') + local key, value = trimmed:match('^([%w_.%-]+)%s*=%s*(.*)$') + + if not key then + return nil, 'Expected KEY=VALUE' + end + + return key, normalize_value(value) +end + +---@param lines string[] +---@return table +---@return string|nil +function M.parse_lines(lines) + local env = {} + + for index, line in ipairs(lines) do + local key, value_or_error = M.parse_line(line) + + if key == nil and value_or_error then + return {}, string.format('Line %s: %s', index, value_or_error) + end + + if key then + env[key] = value_or_error + end + end + + return env, nil +end + +---@param value string +---@return table +---@return string|nil +function M.parse_string(value) + return M.parse_lines(vim.split(value or '', '\n', { plain = true })) +end + +---@param env table|nil +---@return string +function M.stringify(env) + if not env or vim.tbl_isempty(env) then + return '' + end + + local keys = vim.tbl_keys(env) + table.sort(keys) + + local lines = {} + for _, key in ipairs(keys) do + table.insert(lines, string.format('%s=%s', key, env[key])) + end + + return table.concat(lines, '\n') +end + +---@param env_file string|nil +---@param base_dir string|nil +---@return table +---@return string|nil +function M.read_file(env_file, base_dir) + local path = trim(env_file) + + if path == '' then + return {}, nil + end + + path = resolve_path(path, base_dir) + + local file = io.open(path, 'r') + if not file then + return {}, 'Failed to open env file: ' .. path + end + + local data = file:read('*a') + file:close() + + local env, err = M.parse_string(data) + if err then + return {}, string.format('Failed to parse env file %s: %s', path, err) + end + + return env, nil +end + +---@param profile Profile +---@param base_dir string|nil +---@return table +---@return string|nil +function M.load_profile_env(profile, base_dir) + local env_from_file, err = M.read_file(profile.env_file, base_dir) + if err then + return {}, err + end + + return vim.tbl_extend('force', env_from_file, profile.env or {}), nil +end + +return M diff --git a/tests/specs/profile_spec.lua b/tests/specs/profile_spec.lua new file mode 100644 index 0000000..01de891 --- /dev/null +++ b/tests/specs/profile_spec.lua @@ -0,0 +1,208 @@ +local assert = require('luassert') + +local function with_package_overrides(overrides, callback) + local original = {} + + for module_name, module_value in pairs(overrides) do + original[module_name] = package.loaded[module_name] + package.loaded[module_name] = module_value + end + + local ok, result = pcall(callback) + + for module_name, module_value in pairs(original) do + package.loaded[module_name] = module_value + end + + for module_name, _ in pairs(overrides) do + if original[module_name] == nil then + package.loaded[module_name] = nil + end + end + + package.loaded['java-dap'] = nil + + if not ok then + error(result) + end + + return result +end + +describe('Profile env utils', function() + it('parses env lines and stringifies sorted keys', function() + local env_utils = require('java.utils.env') + local env, err = env_utils.parse_lines({ + '# comment', + '', + 'export FOO = bar', + 'BAR="baz qux"', + "QUX='quoted'", + }) + + assert.is_nil(err) + assert.same({ + FOO = 'bar', + BAR = 'baz qux', + QUX = 'quoted', + }, env) + assert.equals('BAR=baz qux\nFOO=bar\nQUX=quoted', env_utils.stringify(env)) + end) + + it('resolves relative env files from project cwd and merges inline env last', function() + local env_utils = require('java.utils.env') + local original_cwd = vim.fn.getcwd() + local temp_root = vim.fn.tempname() + local project_dir = temp_root .. '/project' + local other_dir = temp_root .. '/other' + + local ok, result = pcall(function() + vim.fn.mkdir(project_dir, 'p') + vim.fn.mkdir(other_dir, 'p') + vim.fn.writefile({ 'FROM_FILE=1', 'OVERRIDE=file' }, project_dir .. '/.env.dev') + + vim.cmd.lcd(other_dir) + + local env, err = env_utils.read_file('.env.dev', project_dir) + assert.is_nil(err) + assert.same({ + FROM_FILE = '1', + OVERRIDE = 'file', + }, env) + + local merged_env, merged_err = env_utils.load_profile_env({ + env_file = '.env.dev', + env = { + INLINE = '2', + OVERRIDE = 'inline', + }, + }, project_dir) + + assert.is_nil(merged_err) + assert.same({ + FROM_FILE = '1', + INLINE = '2', + OVERRIDE = 'inline', + }, merged_env) + end) + + vim.cmd.lcd(original_cwd) + vim.fn.delete(temp_root, 'rf') + + if not ok then + error(result) + end + end) + + it('returns helpful errors for invalid env content', function() + local env_utils = require('java.utils.env') + local env, err = env_utils.parse_lines({ 'NOT VALID' }) + + assert.same({}, env) + assert.equals('Line 1: Expected KEY=VALUE', err) + end) +end) + +describe('Java DAP profile application', function() + it('preserves user configs while replacing managed ones', function() + local fake_dap = { + adapters = {}, + configurations = { + java = { + { name = 'user-config', request = 'launch' }, + { name = 'stale-managed', _nvim_java_managed = true }, + }, + }, + session = nil, + terminate = function() end, + } + + local fake_profile = { + vm_args = '-Xmx1g', + prog_args = '--debug', + env = { INLINE = '1' }, + env_file = '.env.dev', + } + + with_package_overrides({ + ['async.runner'] = function(callback) + local runner_result + runner_result = { + catch = function(_) + return runner_result + end, + run = function() + callback() + end, + } + + return runner_result + end, + ['java-core.utils.error_handler'] = function() + return function(err) + error(err) + end + end, + ['java-core.utils.lsp'] = { + get_jdtls = function() + return { id = 1 } + end, + }, + ['dap'] = fake_dap, + ['java.api.profile_config'] = { + current_project_path = '/tmp/project', + get_active_profile = function(name) + if name == 'main-class' then + return fake_profile + end + end, + }, + ['java.utils.env'] = { + load_profile_env = function(profile, base_dir) + assert.same(fake_profile, profile) + assert.equals('/tmp/project', base_dir) + return { + FROM_FILE = 'yes', + INLINE = '1', + }, nil + end, + }, + ['java-core.utils.notify'] = { + error = function(err) + error(err) + end, + }, + ['java-dap.setup'] = function(client) + assert.same({ id = 1 }, client) + return { + get_dap_adapter = function() + return { type = 'server', host = '127.0.0.1', port = 5005 } + end, + get_dap_config = function() + return { + { name = 'main-class', cwd = '/tmp/project/app' }, + } + end, + } + end, + }, function() + local java_dap = require('java-dap') + java_dap.config_dap() + end) + + assert.equals(2, #fake_dap.configurations.java) + assert.same({ name = 'user-config', request = 'launch' }, fake_dap.configurations.java[1]) + assert.same({ + name = 'main-class', + cwd = '/tmp/project/app', + vmArgs = '-Xmx1g', + args = '--debug', + env = { + FROM_FILE = 'yes', + INLINE = '1', + }, + envFile = '.env.dev', + _nvim_java_managed = true, + }, fake_dap.configurations.java[2]) + end) +end)