|
| 1 | +local path_utils = require('java-core.utils.path') |
| 2 | +local log = require('java-core.utils.log2') |
| 3 | + |
| 4 | +local M = {} |
| 5 | + |
| 6 | +---Normalize path separators to `/` for .classpath operations. |
| 7 | +---Eclipse .classpath files always use `/` regardless of OS. |
| 8 | +---@param p string |
| 9 | +---@return string |
| 10 | +local function classpath_normalize(p) |
| 11 | + return p:gsub('\\', '/') |
| 12 | +end |
| 13 | + |
| 14 | +---Set or update the `excluding` attribute on a classpathentry line. |
| 15 | +---Parses any existing exclusions, merges with the new set (union), |
| 16 | +---and writes the sorted result back. |
| 17 | +---@param line string |
| 18 | +---@param exclusions string[] |
| 19 | +---@return string |
| 20 | +local function set_exclusions(line, exclusions) |
| 21 | + -- Parse existing exclusions from the line |
| 22 | + local existing = {} |
| 23 | + local existing_match = line:match('excluding="([^"]+)"') |
| 24 | + if existing_match then |
| 25 | + for entry in existing_match:gmatch('[^|]+') do |
| 26 | + existing[entry] = true |
| 27 | + end |
| 28 | + end |
| 29 | + |
| 30 | + -- Merge: union existing + new (new keys take no precedence — it's a set union) |
| 31 | + local merged = vim.deepcopy(existing) |
| 32 | + for _, excl in ipairs(exclusions) do |
| 33 | + merged[excl] = true |
| 34 | + end |
| 35 | + |
| 36 | + local merged_list = vim.tbl_keys(merged) |
| 37 | + table.sort(merged_list) |
| 38 | + local exclusion_value = table.concat(merged_list, '|') |
| 39 | + |
| 40 | + if existing_match then |
| 41 | + return line:gsub('excluding="[^"]+"', 'excluding="' .. exclusion_value .. '"', 1) |
| 42 | + end |
| 43 | + |
| 44 | + return line:gsub('<classpathentry ', '<classpathentry excluding="' .. exclusion_value .. '" ', 1) |
| 45 | +end |
| 46 | + |
| 47 | +---Get the relative path from root. Result is normalized to `/` |
| 48 | +---for .classpath compatibility. |
| 49 | +---@param root string |
| 50 | +---@param absolute_path string |
| 51 | +---@return string |
| 52 | +local function get_relative_path(root, absolute_path) |
| 53 | + -- vim.fs.find always joins children with '/' regardless of OS, so |
| 54 | + -- normalize both sides before stripping the prefix. |
| 55 | + local normalized_root = classpath_normalize(root) |
| 56 | + local normalized_path = classpath_normalize(absolute_path) |
| 57 | + local prefix = normalized_root .. '/' |
| 58 | + if normalized_path:sub(1, #prefix) == prefix then |
| 59 | + return normalized_path:sub(#prefix + 1) |
| 60 | + end |
| 61 | + return normalized_path |
| 62 | +end |
| 63 | + |
| 64 | +---@param lines string[] |
| 65 | +---@param entry_path string |
| 66 | +---@return boolean |
| 67 | +local function has_classpath_entry(lines, entry_path) |
| 68 | + local pattern = 'path="' .. entry_path .. '"' |
| 69 | + for _, line in ipairs(lines) do |
| 70 | + if line:find(pattern, 1, true) then |
| 71 | + return true |
| 72 | + end |
| 73 | + end |
| 74 | + return false |
| 75 | +end |
| 76 | + |
| 77 | +---Find the index of the target/generated-sources classpathentry. |
| 78 | +---Assumes .classpath uses double or single quotes and one tag per line. |
| 79 | +---@param lines string[] |
| 80 | +---@return integer|nil |
| 81 | +local function find_generated_sources_entry(lines) |
| 82 | + for index, line in ipairs(lines) do |
| 83 | + if line:find('path="target/generated%-sources"') or line:find("path='target/generated%-sources'") then |
| 84 | + return index |
| 85 | + end |
| 86 | + end |
| 87 | + return nil |
| 88 | +end |
| 89 | + |
| 90 | +---Insert a new classpathentry before the <classpathentry kind="output"> line. |
| 91 | +---@param lines string[] |
| 92 | +---@param entry_path string |
| 93 | +local function add_classpath_entry(lines, entry_path) |
| 94 | + local entry = { |
| 95 | + '\t<classpathentry kind="src" output="target/classes" path="' .. entry_path .. '">', |
| 96 | + '\t\t<attributes>', |
| 97 | + '\t\t\t<attribute name="optional" value="true"/>', |
| 98 | + '\t\t\t<attribute name="maven.pomderived" value="true"/>', |
| 99 | + '\t\t</attributes>', |
| 100 | + '\t</classpathentry>', |
| 101 | + } |
| 102 | + |
| 103 | + local output_index = #lines + 1 |
| 104 | + for index, line in ipairs(lines) do |
| 105 | + if line:find('<classpathentry kind="output"') then |
| 106 | + output_index = index |
| 107 | + break |
| 108 | + end |
| 109 | + end |
| 110 | + |
| 111 | + for offset = #entry, 1, -1 do |
| 112 | + table.insert(lines, output_index, entry[offset]) |
| 113 | + end |
| 114 | +end |
| 115 | + |
| 116 | +---Find generated source roots under target/generated-sources that |
| 117 | +---match the pattern `*/src/{segment}/java`. |
| 118 | +---@param module_root string |
| 119 | +---@return string[] |
| 120 | +local function get_generated_source_roots(module_root) |
| 121 | + local generated_root = path_utils.join(module_root, 'target', 'generated-sources') |
| 122 | + if not vim.uv.fs_stat(generated_root) then |
| 123 | + return {} |
| 124 | + end |
| 125 | + |
| 126 | + -- vim.fs.find always yields '/'-separated paths regardless of OS, so |
| 127 | + -- match against a '/'-normalized substring even on Windows. |
| 128 | + local target_gen_src = 'target/generated-sources' |
| 129 | + local java_roots = vim.fs.find(function(name, generated_path) |
| 130 | + return name == 'java' and classpath_normalize(generated_path):find(target_gen_src, 1, true) ~= nil |
| 131 | + end, { |
| 132 | + path = generated_root, |
| 133 | + type = 'directory', |
| 134 | + limit = 500, |
| 135 | + }) |
| 136 | + |
| 137 | + local source_roots = {} |
| 138 | + local seen_roots = {} |
| 139 | + for _, java_root in ipairs(java_roots) do |
| 140 | + local relative_to_generated_root = get_relative_path(generated_root, java_root) |
| 141 | + -- Split on / since get_relative_path normalizes |
| 142 | + local segments = vim.split(relative_to_generated_root, '/', { plain = true }) |
| 143 | + local segment_count = #segments |
| 144 | + |
| 145 | + if segment_count >= 3 and segments[segment_count] == 'java' and segments[segment_count - 2] == 'src' then |
| 146 | + local source_root = get_relative_path(module_root, java_root) |
| 147 | + if not seen_roots[source_root] then |
| 148 | + seen_roots[source_root] = true |
| 149 | + table.insert(source_roots, source_root) |
| 150 | + end |
| 151 | + end |
| 152 | + end |
| 153 | + |
| 154 | + table.sort(source_roots) |
| 155 | + return source_roots |
| 156 | +end |
| 157 | + |
| 158 | +---Build exclusion list for the parent target/generated-sources entry. |
| 159 | +---Always seeds `annotations/` — Eclipse/Maven convention excludes the |
| 160 | +---annotations subdirectory from generated-sources to avoid processing |
| 161 | +---compiled annotation processor output as source. |
| 162 | +---@param source_roots string[] |
| 163 | +---@return string[] |
| 164 | +local function get_generated_source_exclusions(source_roots) |
| 165 | + local exclusions = { ['annotations/'] = true } |
| 166 | + -- Normalize prefix to / for comparison with source_roots (which are /-normalized) |
| 167 | + local generated_root_prefix = classpath_normalize(path_utils.join('target', 'generated-sources')) .. '/' |
| 168 | + |
| 169 | + for _, source_root in ipairs(source_roots) do |
| 170 | + if source_root:sub(1, #generated_root_prefix) == generated_root_prefix then |
| 171 | + local relative_to_generated_root = source_root:sub(#generated_root_prefix + 1) |
| 172 | + local first_segment = vim.split(relative_to_generated_root, '/', { plain = true })[1] |
| 173 | + if first_segment then |
| 174 | + exclusions[first_segment .. '/'] = true |
| 175 | + end |
| 176 | + end |
| 177 | + end |
| 178 | + |
| 179 | + local ordered_exclusions = vim.tbl_keys(exclusions) |
| 180 | + table.sort(ordered_exclusions) |
| 181 | + return ordered_exclusions |
| 182 | +end |
| 183 | + |
| 184 | +---Patch a single .classpath file to include generated source roots |
| 185 | +---and exclude them from the parent generated-sources entry. |
| 186 | +---Safe to re-run — idempotent (has_classpath_entry + set_exclusions |
| 187 | +---no-op when already applied). |
| 188 | +---@param classpath_file string |
| 189 | +---@return boolean changed |
| 190 | +local function patch_module_classpath(classpath_file) |
| 191 | + local module_root = vim.fs.dirname(classpath_file) |
| 192 | + local lines = vim.fn.readfile(classpath_file) |
| 193 | + local generated_sources_entry_index = find_generated_sources_entry(lines) |
| 194 | + if not generated_sources_entry_index then |
| 195 | + return false |
| 196 | + end |
| 197 | + |
| 198 | + local file_changed = false |
| 199 | + local source_roots = get_generated_source_roots(module_root) |
| 200 | + local line = lines[generated_sources_entry_index] |
| 201 | + local patched = set_exclusions(line, get_generated_source_exclusions(source_roots)) |
| 202 | + |
| 203 | + if patched ~= line then |
| 204 | + lines[generated_sources_entry_index] = patched |
| 205 | + file_changed = true |
| 206 | + end |
| 207 | + |
| 208 | + for _, source_root in ipairs(source_roots) do |
| 209 | + if not has_classpath_entry(lines, source_root) then |
| 210 | + add_classpath_entry(lines, source_root) |
| 211 | + file_changed = true |
| 212 | + end |
| 213 | + end |
| 214 | + |
| 215 | + if file_changed then |
| 216 | + log.info('nvim-java: patching .classpath with generated sources', classpath_file) |
| 217 | + vim.fn.writefile(lines, classpath_file) |
| 218 | + end |
| 219 | + |
| 220 | + return file_changed |
| 221 | +end |
| 222 | + |
| 223 | +---Patch all .classpath files under `root` to include generated source roots. |
| 224 | +---This is an experimental workaround for JDTLS import failures caused by |
| 225 | +---nested generated sources under target/generated-sources. |
| 226 | +---@param root string # Project root directory |
| 227 | +---@return boolean changed # true if any .classpath file was modified |
| 228 | +function M.patch(root) |
| 229 | + local changed = false |
| 230 | + local start = vim.uv.hrtime() |
| 231 | + local count = 0 |
| 232 | + local LIMIT = 500 |
| 233 | + |
| 234 | + for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = LIMIT })) do |
| 235 | + if patch_module_classpath(file) then |
| 236 | + changed = true |
| 237 | + end |
| 238 | + count = count + 1 |
| 239 | + end |
| 240 | + |
| 241 | + if count >= LIMIT then |
| 242 | + log.warn( |
| 243 | + 'nvim-java: fix_generated_sources hit .classpath find limit (' .. LIMIT .. ') — some files may be missed' |
| 244 | + ) |
| 245 | + end |
| 246 | + |
| 247 | + local elapsed = (vim.uv.hrtime() - start) / 1e6 |
| 248 | + log.debug(('nvim-java: fix_generated_sources scanned %d .classpath files in %.2fms'):format(count, elapsed)) |
| 249 | + |
| 250 | + return changed |
| 251 | +end |
| 252 | + |
| 253 | +return M |
0 commit comments