jdtls-root.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { describe, test, expect, afterAll } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import os from "os"
  5. import * as LSPServer from "@/lsp/server"
  6. import type { InstanceContext } from "@/project/instance-context"
  7. // ---------------------------------------------------------------------------
  8. // Helpers
  9. // ---------------------------------------------------------------------------
  10. const tmpBase = path.join(os.tmpdir(), "opencode-jdtls-test")
  11. function makeCtx(directory: string): InstanceContext {
  12. return { directory, worktree: "/", project: {} as any }
  13. }
  14. async function mkdirp(p: string) {
  15. await fs.mkdir(p, { recursive: true })
  16. }
  17. async function touch(p: string) {
  18. await mkdirp(path.dirname(p))
  19. await fs.writeFile(p, "", "utf-8")
  20. }
  21. // ---------------------------------------------------------------------------
  22. // Cleanup
  23. // ---------------------------------------------------------------------------
  24. afterAll(async () => {
  25. await fs.rm(tmpBase, { recursive: true, force: true }).catch(() => {})
  26. })
  27. // ---------------------------------------------------------------------------
  28. // Tests
  29. // ---------------------------------------------------------------------------
  30. describe("JDTLS.root", () => {
  31. // -------------------------------------------------------------------------
  32. // Maven
  33. // -------------------------------------------------------------------------
  34. describe("Maven", () => {
  35. test("single-module Maven project returns pom.xml directory", async () => {
  36. const root = path.join(tmpBase, "single-maven")
  37. await mkdirp(root)
  38. await touch(path.join(root, "pom.xml"))
  39. const srcDir = path.join(root, "src", "main", "java", "com", "example")
  40. await mkdirp(srcDir)
  41. await touch(path.join(srcDir, "App.java"))
  42. const file = path.join(srcDir, "App.java")
  43. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  44. expect(result).toBe(root)
  45. })
  46. test("multi-module Maven project follows <module> chain to top-level pom.xml", async () => {
  47. const root = path.join(tmpBase, "multi-maven")
  48. await mkdirp(root)
  49. // Parent pom with <module>module-a</module>
  50. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a</module></modules></project>")
  51. // Child module with its own pom.xml
  52. const childDir = path.join(root, "module-a")
  53. await mkdirp(childDir)
  54. await touch(path.join(childDir, "pom.xml"))
  55. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  56. await mkdirp(srcDir)
  57. await touch(path.join(srcDir, "App.java"))
  58. const file = path.join(srcDir, "App.java")
  59. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  60. // Parent declares module-a as module → root is parent directory
  61. expect(result).toBe(root)
  62. })
  63. test("Maven project inside a nested directory (ctx.directory is workspace root)", async () => {
  64. // Workspace root = ctx.directory, Maven project in a subdirectory
  65. const workspace = path.join(tmpBase, "maven-workspace")
  66. await mkdirp(workspace)
  67. const projectDir = path.join(workspace, "my-maven-app")
  68. await touch(path.join(projectDir, "pom.xml"))
  69. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  70. await mkdirp(srcDir)
  71. await touch(path.join(srcDir, "App.java"))
  72. const file = path.join(srcDir, "App.java")
  73. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  74. // findUp finds projectDir's pom.xml (only one), returns projectDir
  75. expect(result).toBe(projectDir)
  76. })
  77. test("nested independent Maven project stops at its own pom.xml", async () => {
  78. const workspace = path.join(tmpBase, "nested-independent")
  79. await mkdirp(workspace)
  80. // Parent pom WITHOUT <module>tools/sample</module>
  81. await Bun.write(
  82. path.join(workspace, "pom.xml"),
  83. "<project><modules><module>module-a</module></modules></project>",
  84. )
  85. // Independent project nested inside
  86. const projectDir = path.join(workspace, "tools", "sample")
  87. await mkdirp(projectDir)
  88. await touch(path.join(projectDir, "pom.xml"))
  89. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  90. await mkdirp(srcDir)
  91. await touch(path.join(srcDir, "App.java"))
  92. const file = path.join(srcDir, "App.java")
  93. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  94. // workspace/pom.xml does NOT declare tools/sample as module → stop at tools/sample
  95. expect(result).toBe(projectDir)
  96. })
  97. test("three-level Maven module chain resolves to top-level", async () => {
  98. const root = path.join(tmpBase, "three-level")
  99. await mkdirp(root)
  100. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>apps</module></modules></project>")
  101. const appsDir = path.join(root, "apps")
  102. await mkdirp(appsDir)
  103. await Bun.write(path.join(appsDir, "pom.xml"), "<project><modules><module>my-app</module></modules></project>")
  104. const appDir = path.join(appsDir, "my-app")
  105. await mkdirp(appDir)
  106. await touch(path.join(appDir, "pom.xml"))
  107. const srcDir = path.join(appDir, "src", "main", "java", "com", "example")
  108. await mkdirp(srcDir)
  109. await touch(path.join(srcDir, "App.java"))
  110. const file = path.join(srcDir, "App.java")
  111. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  112. expect(result).toBe(root)
  113. })
  114. test("three-level Maven chain stops when <module> link is broken", async () => {
  115. const root = path.join(tmpBase, "broken-chain")
  116. await mkdirp(root)
  117. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>apps</module></modules></project>")
  118. const appsDir = path.join(root, "apps")
  119. await mkdirp(appsDir)
  120. await touch(path.join(appsDir, "pom.xml")) // Empty pom, no <module> declaration
  121. const appDir = path.join(appsDir, "my-app")
  122. await mkdirp(appDir)
  123. await touch(path.join(appDir, "pom.xml"))
  124. const srcDir = path.join(appDir, "src", "main", "java", "com", "example")
  125. await mkdirp(srcDir)
  126. await touch(path.join(srcDir, "App.java"))
  127. const file = path.join(srcDir, "App.java")
  128. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  129. // apps/pom.xml has no <module>my-app</module> → stop at my-app
  130. expect(result).toBe(appDir)
  131. })
  132. test("<module> with ./ prefix is normalized correctly", async () => {
  133. const root = path.join(tmpBase, "dot-slash-module")
  134. await mkdirp(root)
  135. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>./module-a</module></modules></project>")
  136. const childDir = path.join(root, "module-a")
  137. await mkdirp(childDir)
  138. await touch(path.join(childDir, "pom.xml"))
  139. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  140. await mkdirp(srcDir)
  141. await touch(path.join(srcDir, "App.java"))
  142. const file = path.join(srcDir, "App.java")
  143. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  144. expect(result).toBe(root)
  145. })
  146. test("<module> with trailing slash is normalized correctly", async () => {
  147. const root = path.join(tmpBase, "trailing-slash-module")
  148. await mkdirp(root)
  149. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a/</module></modules></project>")
  150. const childDir = path.join(root, "module-a")
  151. await mkdirp(childDir)
  152. await touch(path.join(childDir, "pom.xml"))
  153. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  154. await mkdirp(srcDir)
  155. await touch(path.join(srcDir, "App.java"))
  156. const file = path.join(srcDir, "App.java")
  157. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  158. expect(result).toBe(root)
  159. })
  160. })
  161. // -------------------------------------------------------------------------
  162. // Gradle
  163. // -------------------------------------------------------------------------
  164. describe("Gradle", () => {
  165. test("Gradle project with settings.gradle in a subdirectory of ctx.directory", async () => {
  166. // Workspace root = ctx.directory, Gradle project in a subdirectory
  167. const workspace = path.join(tmpBase, "gradle-sub")
  168. await mkdirp(workspace)
  169. const projectDir = path.join(workspace, "gradle-app")
  170. await touch(path.join(projectDir, "settings.gradle"))
  171. await touch(path.join(projectDir, "build.gradle"))
  172. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  173. await mkdirp(srcDir)
  174. await touch(path.join(srcDir, "App.java"))
  175. const file = path.join(srcDir, "App.java")
  176. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  177. expect(result).toBe(projectDir)
  178. })
  179. test("Gradle project with only build.gradle in a subdirectory", async () => {
  180. const workspace = path.join(tmpBase, "gradle-build-sub")
  181. await mkdirp(workspace)
  182. const projectDir = path.join(workspace, "gradle-app")
  183. await touch(path.join(projectDir, "build.gradle"))
  184. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  185. await mkdirp(srcDir)
  186. await touch(path.join(srcDir, "App.java"))
  187. const file = path.join(srcDir, "App.java")
  188. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  189. expect(result).toBe(projectDir)
  190. })
  191. test("Gradle monorepo with settings.gradle takes precedence over nested pom.xml", async () => {
  192. const workspace = path.join(tmpBase, "gradle-monorepo")
  193. await mkdirp(workspace)
  194. const gradleRoot = path.join(workspace, "gradle-project")
  195. await touch(path.join(gradleRoot, "settings.gradle"))
  196. await touch(path.join(gradleRoot, "gradlew"))
  197. // Submodule has pom.xml too
  198. const subDir = path.join(gradleRoot, "module-a")
  199. await mkdirp(subDir)
  200. await touch(path.join(subDir, "pom.xml"))
  201. const srcDir = path.join(subDir, "src", "main", "java", "com", "example")
  202. await mkdirp(srcDir)
  203. await touch(path.join(srcDir, "App.java"))
  204. const file = path.join(srcDir, "App.java")
  205. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  206. // Gradle markers found at gradleRoot level
  207. expect(result).toBe(gradleRoot)
  208. })
  209. test("settings.gradle.kts (Kotlin DSL) is recognized", async () => {
  210. const workspace = path.join(tmpBase, "gradle-kts-settings")
  211. await mkdirp(workspace)
  212. const projectDir = path.join(workspace, "gradle-app")
  213. await touch(path.join(projectDir, "settings.gradle.kts"))
  214. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  215. await mkdirp(srcDir)
  216. await touch(path.join(srcDir, "App.java"))
  217. const file = path.join(srcDir, "App.java")
  218. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  219. expect(result).toBe(projectDir)
  220. })
  221. test("build.gradle.kts (Kotlin DSL) is recognized", async () => {
  222. const workspace = path.join(tmpBase, "gradle-kts-build")
  223. await mkdirp(workspace)
  224. const projectDir = path.join(workspace, "gradle-app")
  225. await touch(path.join(projectDir, "build.gradle.kts"))
  226. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  227. await mkdirp(srcDir)
  228. await touch(path.join(srcDir, "App.java"))
  229. const file = path.join(srcDir, "App.java")
  230. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  231. expect(result).toBe(projectDir)
  232. })
  233. test("gradlew (without settings.gradle) in a subdirectory is recognized", async () => {
  234. const workspace = path.join(tmpBase, "gradlew-sub")
  235. await mkdirp(workspace)
  236. const projectDir = path.join(workspace, "gradle-app")
  237. await touch(path.join(projectDir, "gradlew"))
  238. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  239. await mkdirp(srcDir)
  240. await touch(path.join(srcDir, "App.java"))
  241. const file = path.join(srcDir, "App.java")
  242. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  243. expect(result).toBe(projectDir)
  244. })
  245. test("pom.xml is excluded when gradlew is present at same level", async () => {
  246. const workspace = path.join(tmpBase, "gradle-excludes-maven")
  247. await mkdirp(workspace)
  248. const projectDir = path.join(workspace, "mixed-project")
  249. // Both pom.xml and gradlew exist
  250. await touch(path.join(projectDir, "pom.xml"))
  251. await touch(path.join(projectDir, "gradlew"))
  252. const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
  253. await mkdirp(srcDir)
  254. await touch(path.join(srcDir, "App.java"))
  255. const file = path.join(srcDir, "App.java")
  256. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  257. // Gradle wrapper takes precedence
  258. expect(result).toBe(projectDir)
  259. })
  260. })
  261. // -------------------------------------------------------------------------
  262. // Eclipse
  263. // -------------------------------------------------------------------------
  264. describe("Eclipse", () => {
  265. test("Eclipse project with .project in a subdirectory", async () => {
  266. const workspace = path.join(tmpBase, "eclipse-sub")
  267. await mkdirp(workspace)
  268. const projectDir = path.join(workspace, "eclipse-app")
  269. await touch(path.join(projectDir, ".project"))
  270. await touch(path.join(projectDir, ".classpath"))
  271. const srcDir = path.join(projectDir, "src", "com", "example")
  272. await mkdirp(srcDir)
  273. await touch(path.join(srcDir, "App.java"))
  274. const file = path.join(srcDir, "App.java")
  275. const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
  276. expect(result).toBe(projectDir)
  277. })
  278. })
  279. // -------------------------------------------------------------------------
  280. // No markers
  281. // -------------------------------------------------------------------------
  282. describe("No build markers", () => {
  283. test("Java file with no build markers returns undefined", async () => {
  284. const root = path.join(tmpBase, "no-build")
  285. await mkdirp(root)
  286. const srcDir = path.join(root, "src")
  287. await mkdirp(srcDir)
  288. await touch(path.join(srcDir, "App.java"))
  289. const file = path.join(srcDir, "App.java")
  290. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  291. expect(result).toBeUndefined()
  292. })
  293. })
  294. // -------------------------------------------------------------------------
  295. // Additional validation scenarios
  296. // -------------------------------------------------------------------------
  297. describe("Additional Maven module-chain validation", () => {
  298. // Scenario 1: <module> with multi-segment path (e.g. <module>tools/sample</module>)
  299. test("<module> multi-segment path matches nested directory", async () => {
  300. const root = path.join(tmpBase, "multi-seg-module")
  301. await mkdirp(root)
  302. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>tools/sample</module></modules></project>")
  303. const childDir = path.join(root, "tools", "sample")
  304. await mkdirp(childDir)
  305. await touch(path.join(childDir, "pom.xml"))
  306. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  307. await mkdirp(srcDir)
  308. await touch(path.join(srcDir, "App.java"))
  309. const file = path.join(srcDir, "App.java")
  310. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  311. expect(result).toBe(root)
  312. })
  313. // Scenario 2: <module> declaration does not match actual directory name
  314. test("<module> declaration mismatch does not falsely match", async () => {
  315. const root = path.join(tmpBase, "module-mismatch")
  316. await mkdirp(root)
  317. // Parent declares module-a, but actual directory is module-b
  318. await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a</module></modules></project>")
  319. const childDir = path.join(root, "module-b")
  320. await mkdirp(childDir)
  321. await touch(path.join(childDir, "pom.xml"))
  322. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  323. await mkdirp(srcDir)
  324. await touch(path.join(srcDir, "App.java"))
  325. const file = path.join(srcDir, "App.java")
  326. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  327. // module-b is not declared as a module → stop at module-b
  328. expect(result).toBe(childDir)
  329. })
  330. // Scenario 3: Multiple <module> declarations
  331. test("multiple <module> declarations allow second module to traverse up", async () => {
  332. const root = path.join(tmpBase, "multi-modules")
  333. await mkdirp(root)
  334. await Bun.write(
  335. path.join(root, "pom.xml"),
  336. "<project><modules><module>module-a</module><module>module-b</module></modules></project>",
  337. )
  338. const childA = path.join(root, "module-a")
  339. await mkdirp(childA)
  340. await touch(path.join(childA, "pom.xml"))
  341. const childB = path.join(root, "module-b")
  342. await mkdirp(childB)
  343. await touch(path.join(childB, "pom.xml"))
  344. const srcDir = path.join(childB, "src", "main", "java", "com", "example")
  345. await mkdirp(srcDir)
  346. await touch(path.join(srcDir, "App.java"))
  347. const file = path.join(srcDir, "App.java")
  348. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  349. expect(result).toBe(root)
  350. })
  351. // Scenario 4: XML comments should not be matched as module declarations
  352. test("XML-commented <module> is not matched", async () => {
  353. const root = path.join(tmpBase, "commented-module")
  354. await mkdirp(root)
  355. await Bun.write(
  356. path.join(root, "pom.xml"),
  357. "<project><modules><!-- <module>module-a</module> --></modules></project>",
  358. )
  359. const childDir = path.join(root, "module-a")
  360. await mkdirp(childDir)
  361. await touch(path.join(childDir, "pom.xml"))
  362. const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
  363. await mkdirp(srcDir)
  364. await touch(path.join(srcDir, "App.java"))
  365. const file = path.join(srcDir, "App.java")
  366. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  367. // Commented-out module should not be matched → stop at module-a
  368. expect(result).toBe(childDir)
  369. })
  370. // Scenario 5: pom.xml at ctx.directory itself
  371. test("pom.xml at ctx.directory itself is found correctly", async () => {
  372. const root = path.join(tmpBase, "pom-at-ctx")
  373. await mkdirp(root)
  374. await touch(path.join(root, "pom.xml"))
  375. const srcDir = path.join(root, "src", "main", "java", "com", "example")
  376. await mkdirp(srcDir)
  377. await touch(path.join(srcDir, "App.java"))
  378. const file = path.join(srcDir, "App.java")
  379. const result = await LSPServer.JDTLS.root(file, makeCtx(root))
  380. expect(result).toBe(root)
  381. })
  382. // Scenario 6: Mixed Gradle + Maven sibling projects don't interfere
  383. test("Maven and Gradle sibling projects don't interfere", async () => {
  384. const workspace = path.join(tmpBase, "mixed-siblings")
  385. await mkdirp(workspace)
  386. // Gradle project
  387. const gradleDir = path.join(workspace, "gradle-project")
  388. await touch(path.join(gradleDir, "settings.gradle"))
  389. const gradleSrc = path.join(gradleDir, "src", "main", "java", "com", "example")
  390. await mkdirp(gradleSrc)
  391. await touch(path.join(gradleSrc, "GradleApp.java"))
  392. // Maven project
  393. const mavenDir = path.join(workspace, "maven-project")
  394. await touch(path.join(mavenDir, "pom.xml"))
  395. const mavenSrc = path.join(mavenDir, "src", "main", "java", "com", "example")
  396. await mkdirp(mavenSrc)
  397. await touch(path.join(mavenSrc, "MavenApp.java"))
  398. const gradleResult = await LSPServer.JDTLS.root(path.join(gradleSrc, "GradleApp.java"), makeCtx(workspace))
  399. expect(gradleResult).toBe(gradleDir)
  400. const mavenResult = await LSPServer.JDTLS.root(path.join(mavenSrc, "MavenApp.java"), makeCtx(workspace))
  401. expect(mavenResult).toBe(mavenDir)
  402. })
  403. })
  404. })