diff --git a/lua/java.lua b/lua/java.lua index 59332f3..5e8ba50 100644 --- a/lua/java.lua +++ b/lua/java.lua @@ -79,6 +79,10 @@ function M.setup(custom_config) ---------------------------------------------------------------------- -- init -- ---------------------------------------------------------------------- + if config.experimental.fix_generated_sources then + require('java.experimental.fix-generated-sources').patch(vim.fn.getcwd()) + end + require('java.startup.lsp_setup').setup(config) require('java.startup.decompile-watcher').setup() require('java-refactor').setup() diff --git a/lua/java/config.lua b/lua/java/config.lua index 8b65edc..bd5c32b 100644 --- a/lua/java/config.lua +++ b/lua/java/config.lua @@ -27,6 +27,7 @@ local V = jdtls_version_map[JDTLS_VERSION] ---@field java_debug_adapter { enable: boolean, version: string } ---@field spring_boot_tools { enable: boolean, version: string } ---@field jdk { auto_install: boolean, version: string } +---@field experimental { fix_generated_sources: boolean } ---@field log java-core.Log2Config ---@class java.PartialConfig @@ -37,6 +38,7 @@ local V = jdtls_version_map[JDTLS_VERSION] ---@field java_debug_adapter? { enable?: boolean, version?: string } ---@field spring_boot_tools? { enable?: boolean, version?: string } ---@field jdk? { auto_install?: boolean, version?: string } +---@field experimental? { fix_generated_sources?: boolean } ---@field log? java-core.PartialLog2Config ---@type java.Config @@ -46,6 +48,10 @@ local config = { nvim_jdtls_conflict = true, }, + experimental = { + fix_generated_sources = false, + }, + jdtls = { version = JDTLS_VERSION, }, diff --git a/lua/java/experimental/fix-generated-sources.lua b/lua/java/experimental/fix-generated-sources.lua new file mode 100644 index 0000000..744a6f2 --- /dev/null +++ b/lua/java/experimental/fix-generated-sources.lua @@ -0,0 +1,252 @@ +local path_utils = require('java-core.utils.path') +local log = require('java-core.utils.log2') + +local M = {} + +---Normalize path separators to `/` for .classpath operations. +---Eclipse .classpath files always use `/` regardless of OS. +---@param p string +---@return string +local function classpath_normalize(p) + return p:gsub('\\', '/') +end + +---Set or update the `excluding` attribute on a classpathentry line. +---Parses any existing exclusions, merges with the new set (union), +---and writes the sorted result back. +---@param line string +---@param exclusions string[] +---@return string +local function set_exclusions(line, exclusions) + -- Parse existing exclusions from the line + local existing = {} + local existing_match = line:match('excluding="([^"]+)"') + if existing_match then + for entry in existing_match:gmatch('[^|]+') do + existing[entry] = true + end + end + + -- Merge: union existing + new (new keys take no precedence — it's a set union) + local merged = vim.deepcopy(existing) + for _, excl in ipairs(exclusions) do + merged[excl] = true + end + + local merged_list = vim.tbl_keys(merged) + table.sort(merged_list) + local exclusion_value = table.concat(merged_list, '|') + + if existing_match then + return line:gsub('excluding="[^"]+"', 'excluding="' .. exclusion_value .. '"', 1) + end + + return line:gsub(' line. +---@param lines string[] +---@param entry_path string +local function add_classpath_entry(lines, entry_path) + local entry = { + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + } + + local output_index = #lines + 1 + for index, line in ipairs(lines) do + if line:find('= 3 and segments[segment_count] == 'java' and segments[segment_count - 2] == 'src' then + local source_root = get_relative_path(module_root, java_root) + if not seen_roots[source_root] then + seen_roots[source_root] = true + table.insert(source_roots, source_root) + end + end + end + + table.sort(source_roots) + return source_roots +end + +---Build exclusion list for the parent target/generated-sources entry. +---Always seeds `annotations/` — Eclipse/Maven convention excludes the +---annotations subdirectory from generated-sources to avoid processing +---compiled annotation processor output as source. +---@param source_roots string[] +---@return string[] +local function get_generated_source_exclusions(source_roots) + local exclusions = { ['annotations/'] = true } + -- Normalize prefix to / for comparison with source_roots (which are /-normalized) + local generated_root_prefix = classpath_normalize(path_utils.join('target', 'generated-sources')) .. '/' + + for _, source_root in ipairs(source_roots) do + if source_root:sub(1, #generated_root_prefix) == generated_root_prefix then + local relative_to_generated_root = source_root:sub(#generated_root_prefix + 1) + local first_segment = vim.split(relative_to_generated_root, '/', { plain = true })[1] + if first_segment then + exclusions[first_segment .. '/'] = true + end + end + end + + local ordered_exclusions = vim.tbl_keys(exclusions) + table.sort(ordered_exclusions) + return ordered_exclusions +end + +---Patch a single .classpath file to include generated source roots +---and exclude them from the parent generated-sources entry. +---Safe to re-run — idempotent (has_classpath_entry + set_exclusions +---no-op when already applied). +---@param classpath_file string +---@return boolean changed +local function patch_module_classpath(classpath_file) + local module_root = vim.fs.dirname(classpath_file) + local lines = vim.fn.readfile(classpath_file) + local generated_sources_entry_index = find_generated_sources_entry(lines) + if not generated_sources_entry_index then + return false + end + + local file_changed = false + local source_roots = get_generated_source_roots(module_root) + local line = lines[generated_sources_entry_index] + local patched = set_exclusions(line, get_generated_source_exclusions(source_roots)) + + if patched ~= line then + lines[generated_sources_entry_index] = patched + file_changed = true + end + + for _, source_root in ipairs(source_roots) do + if not has_classpath_entry(lines, source_root) then + add_classpath_entry(lines, source_root) + file_changed = true + end + end + + if file_changed then + log.info('nvim-java: patching .classpath with generated sources', classpath_file) + vim.fn.writefile(lines, classpath_file) + end + + return file_changed +end + +---Patch all .classpath files under `root` to include generated source roots. +---This is an experimental workaround for JDTLS import failures caused by +---nested generated sources under target/generated-sources. +---@param root string # Project root directory +---@return boolean changed # true if any .classpath file was modified +function M.patch(root) + local changed = false + local start = vim.uv.hrtime() + local count = 0 + local LIMIT = 500 + + for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = LIMIT })) do + if patch_module_classpath(file) then + changed = true + end + count = count + 1 + end + + if count >= LIMIT then + log.warn('nvim-java: fix_generated_sources hit .classpath find limit (' .. LIMIT .. ') — some files may be missed') + end + + local elapsed = (vim.uv.hrtime() - start) / 1e6 + log.debug(('nvim-java: fix_generated_sources scanned %d .classpath files in %.2fms'):format(count, elapsed)) + + return changed +end + +return M diff --git a/tests/specs/generated_sources_classpath_spec.lua b/tests/specs/generated_sources_classpath_spec.lua new file mode 100644 index 0000000..b474162 --- /dev/null +++ b/tests/specs/generated_sources_classpath_spec.lua @@ -0,0 +1,166 @@ +local assert = require('luassert') + +describe('Generated sources classpath patcher', function() + local classpath = require('java.experimental.fix-generated-sources') + local path = require('java-core.utils.path') + + local temp_dir + local module_root + local classpath_file + + local function write_classpath(lines) + vim.fn.writefile(lines, classpath_file) + end + + local function read_classpath() + return table.concat(vim.fn.readfile(classpath_file), '\n') + end + + before_each(function() + temp_dir = vim.fn.tempname() + module_root = path.join(temp_dir, 'project') + classpath_file = path.join(module_root, '.classpath') + + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'src', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'openapi', 'src', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'foo', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'openapi', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'java'), 'p') + + write_classpath({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }) + end) + + after_each(function() + vim.fn.delete(temp_dir, 'rf') + end) + + it('adds only generated roots that end with src/{segment}/java and excludes them from the parent root', function() + assert.is_true(classpath.patch(temp_dir)) + + local content = read_classpath() + + assert.is_truthy(content:find('excluding="annotations/|openapi/|src/"', 1, true)) + assert.is_truthy(content:find('path="target/generated-sources/src/main/java"', 1, true)) + assert.is_truthy(content:find('path="target/generated-sources/openapi/src/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/foo/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/openapi/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/java"', 1, true)) + end) + + it('does not add generated roots when the parent generated-sources entry is missing', function() + write_classpath({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }) + + assert.is_false(classpath.patch(temp_dir)) + + local content = read_classpath() + assert.is_falsy(content:find('target/generated-sources/src/main/java', 1, true)) + end) + + it('is idempotent — second call returns false and leaves file unchanged', function() + assert.is_true(classpath.patch(temp_dir)) + local after_first = read_classpath() + + assert.is_false(classpath.patch(temp_dir)) + local after_second = read_classpath() + + assert.equals(after_first, after_second) + end) + + it('preserves pre-existing exclusions in the generated-sources entry', function() + write_classpath({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }) + + assert.is_true(classpath.patch(temp_dir)) + + local content = read_classpath() + -- Original exclusions are preserved + assert.is_truthy(content:find('custom/', 1, true)) + assert.is_truthy(content:find('other/', 1, true)) + -- New exclusions are added + assert.is_truthy(content:find('annotations/', 1, true)) + assert.is_truthy(content:find('openapi/', 1, true)) + assert.is_truthy(content:find('src/', 1, true)) + -- Generated roots are still added + assert.is_truthy(content:find('path="target/generated-sources/src/main/java"', 1, true)) + end) + + it('handles multi-module projects with multiple .classpath files', function() + local module_b_root = path.join(temp_dir, 'module-b') + local module_b_classpath = path.join(module_b_root, '.classpath') + + vim.fn.mkdir(path.join(module_b_root, 'target', 'generated-sources', 'grpc', 'src', 'main', 'java'), 'p') + + vim.fn.writefile({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }, module_b_classpath) + + assert.is_true(classpath.patch(temp_dir)) + + -- Module A (original) is patched + local content_a = read_classpath() + assert.is_truthy(content_a:find('path="target/generated-sources/src/main/java"', 1, true)) + + -- Module B is also patched + local content_b = table.concat(vim.fn.readfile(module_b_classpath), '\n') + assert.is_truthy(content_b:find('path="target/generated-sources/grpc/src/main/java"', 1, true)) + end) +end)