You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kie.apache.org by tk...@apache.org on 2026/07/21 08:18:44 UTC

(incubator-kie-tools) branch main updated: kie-issues#2365: [DRL Editor] Add drools-lsp to kie-tools (#3665)

This is an automated email from the ASF dual-hosted git repository.

tkobayas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-tools.git


The following commit(s) were added to refs/heads/main by this push:
     new 858dd7c041c kie-issues#2365: [DRL Editor] Add drools-lsp to kie-tools (#3665)
858dd7c041c is described below

commit 858dd7c041cf393a16d1d431daddd0b33f87cd12
Author: Toshiya Kobayashi <to...@gmail.com>
AuthorDate: Tue Jul 21 17:18:40 2026 +0900

    kie-issues#2365: [DRL Editor] Add drools-lsp to kie-tools (#3665)
    
    kie-issues#2365: Add drools-lsp to kie-tools
    
    Migrate the drools-lsp project (DRL Language Server Protocol + VS Code
    extension) from kiegroup/drools-lsp into the kie-tools monorepo.
    
    New packages:
    - packages/drools-lsp/ — Java multi-module Maven project (drools-parser,
      drools-completion, drools-lsp-server) producing an LSP server uber-JAR
    - packages/drl-vscode-extension/ — VS Code extension that launches the
      DRL Language Server for .drl file editing
    
    kie-issues#2365: Centralize dependency versions in maven-base
    
    Move drools-lsp dependency version management to maven-base so
    sub-modules can omit version tags, matching other kie-tools packages.
    
    - Add antlr4-c3, lsp4j to maven-base dependencyManagement
    - Add antlr4-maven-plugin to maven-base pluginManagement
    - Use ancestor-managed versions for antlr4, junit, assertj, logback
    - Remove all properties and dependencyManagement from drools-lsp parent POM
    - Remove all version tags from sub-module dependency declarations
    
    meet with syncpack version check
    
    README update
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 .gitignore                                         |    3 +
 .rat-excludes                                      |    5 +
 packages/drl-vscode-extension/.vscodeignore        |   11 +
 packages/drl-vscode-extension/CHANGELOG.md         |   22 +
 packages/drl-vscode-extension/DEV.md               |   36 +
 packages/drl-vscode-extension/LICENSE              |  201 +++
 packages/drl-vscode-extension/README.md            |   74 +
 .../drl-vscode-extension/images/drools-icon.png    |  Bin 0 -> 19869 bytes
 .../language-configuration.json                    |   25 +
 packages/drl-vscode-extension/package.json         |  196 +++
 packages/drl-vscode-extension/src/extension.ts     |  208 +++
 packages/drl-vscode-extension/src/test/runTest.ts  |   64 +
 .../src/test/suite/completion.test.ts              |  109 ++
 .../drl-vscode-extension/src/test/suite/helper.ts  |   54 +
 .../drl-vscode-extension/src/test/suite/index.ts   |   50 +
 .../drl-vscode-extension/src/testFixture/pom.xml   |   35 +
 .../src/main/java/org/example/model/Address.java   |   49 +
 .../src/main/java/org/example/model/Person.java    |   49 +
 .../testFixture/src/main/resources/rules/empty.drl |   18 +
 .../src/main/resources/rules/sample.drl            |   35 +
 .../drl-vscode-extension/syntaxes/drl.tmLanguage   |  101 ++
 packages/drl-vscode-extension/tsconfig.json        |    7 +
 packages/drl-vscode-extension/tsconfig.test.json   |   10 +
 packages/drl-vscode-extension/webpack.config.js    |   49 +
 packages/drools-lsp/drools-completion/pom.xml      |   85 +
 .../drools/completion/AccumulateFunctionTypes.java |   79 +
 .../java/org/drools/completion/ClassIndex.java     |  139 ++
 .../org/drools/completion/ClassMemberIndex.java    |  254 +++
 .../org/drools/completion/DRLCodeLensHelper.java   |   88 +
 .../org/drools/completion/DRLCompletionHelper.java |  393 +++++
 .../drools/completion/DRLDeclaredTypeParser.java   |  252 +++
 .../org/drools/completion/DRLDefinitionHelper.java |  254 +++
 .../org/drools/completion/DRLDiagnosticHelper.java |  143 ++
 .../org/drools/completion/DRLDocCommentParser.java |  166 ++
 .../org/drools/completion/DRLDocFormatter.java     |  147 ++
 .../drools/completion/DRLDocumentSymbolHelper.java |  243 +++
 .../drools/completion/DRLFoldingRangeHelper.java   |  100 ++
 .../java/org/drools/completion/DRLHoverHelper.java |  254 +++
 .../org/drools/completion/DRLInlayHintHelper.java  |  209 +++
 .../java/org/drools/completion/DRLLintHelper.java  | 1283 +++++++++++++++
 .../java/org/drools/completion/DRLParsers.java     |   88 +
 .../org/drools/completion/DRLReferenceScanner.java |  286 ++++
 .../org/drools/completion/DRLReferencesHelper.java |  164 ++
 .../org/drools/completion/DRLRenameHelper.java     |  177 ++
 .../drools/completion/DRLTypeHierarchyHelper.java  |  277 ++++
 .../drools/completion/DRLWorkspaceTypeIndex.java   |  312 ++++
 .../java/org/drools/completion/DeclaredType.java   |   54 +
 .../src/main/java/org/drools/completion/Field.java |   44 +
 .../org/drools/completion/JavaSourceLocator.java   |  128 ++
 .../org/drools/completion/LhsBindingResolver.java  |  359 ++++
 .../main/java/org/drools/completion/ParsedDrl.java |  104 ++
 .../main/java/org/drools/completion/Tokens.java    |   50 +
 .../completion/WorkspaceSiblingResolver.java       |   44 +
 .../completion/WorkspaceSiblingResolvers.java      |   83 +
 .../java/org/drools/completion/ClassIndexTest.java |  133 ++
 .../drools/completion/ClassMemberIndexTest.java    |  121 ++
 .../drools/completion/DRLCodeLensHelperTest.java   |  123 ++
 .../drools/completion/DRLCompletionHelperTest.java |  461 ++++++
 .../completion/DRLDeclaredTypeParserTest.java      |  130 ++
 .../drools/completion/DRLDefinitionHelperTest.java |  183 +++
 .../drools/completion/DRLDiagnosticHelperTest.java |  137 ++
 .../drools/completion/DRLDocCommentParserTest.java |  225 +++
 .../org/drools/completion/DRLDocFormatterTest.java |   86 +
 .../completion/DRLDocumentSymbolHelperTest.java    |  115 ++
 .../completion/DRLFoldingRangeHelperTest.java      |   71 +
 .../org/drools/completion/DRLHoverHelperTest.java  |  343 ++++
 .../drools/completion/DRLInlayHintHelperTest.java  |  379 +++++
 .../org/drools/completion/DRLLintHelperTest.java   |  774 +++++++++
 .../drools/completion/DRLReferencesHelperTest.java |  309 ++++
 .../org/drools/completion/DRLRenameHelperTest.java |  254 +++
 .../completion/DRLTypeHierarchyHelperTest.java     |  256 +++
 .../completion/DRLWorkspaceTypeIndexTest.java      |  123 ++
 .../java/org/drools/completion/ParsedDrlTest.java  |   80 +
 .../completion/WorkspaceSiblingResolversTest.java  |   68 +
 .../org/drools/completion/fixtures/InitProbe.java  |   29 +
 .../java/org/drools/completion/fixtures/Pet.java   |   49 +
 .../org/drools/completion/fixtures/PetKind.java    |   26 +
 .../src/test/resources/logging.properties          |   26 +
 packages/drools-lsp/drools-lsp-server/.gitignore   |   17 +
 packages/drools-lsp/drools-lsp-server/pom.xml      |   98 ++
 .../org/drools/lsp/server/BuildOutputLocator.java  |  143 ++
 .../lsp/server/DroolsLspDocumentService.java       |  638 ++++++++
 .../org/drools/lsp/server/DroolsLspServer.java     |  300 ++++
 .../drools/lsp/server/DroolsLspTCPLauncher.java    |   78 +
 .../lsp/server/DroolsLspWorkspaceService.java      |   62 +
 .../src/main/java/org/drools/lsp/server/Main.java  |   67 +
 .../drools/lsp/server/MavenClasspathResolver.java  |  223 +++
 .../java/org/drools/lsp/server/SocketLauncher.java |   60 +
 .../drools/lsp/server/BuildOutputLocatorTest.java  |  121 ++
 .../lsp/server/ClassCompletionIntegrationTest.java |  113 ++
 .../lsp/server/DroolsLspDocumentServiceTest.java   |  523 ++++++
 .../org/drools/lsp/server/DroolsLspServerTest.java |  180 ++
 .../java/org/drools/lsp/server/LauncherTest.java   |  187 +++
 .../lsp/server/MavenClasspathResolverTest.java     |  108 ++
 .../org/drools/lsp/server/TestHelperMethods.java   |   79 +
 packages/drools-lsp/drools-parser/.gitignore       |   18 +
 packages/drools-lsp/drools-parser/DEV.md           |   30 +
 packages/drools-lsp/drools-parser/pom.xml          |   98 ++
 .../drools/drl/parser/antlr4/DRL10Expressions.g4   |  700 ++++++++
 .../org/drools/drl/parser/antlr4/DRL10Lexer.g4     |  215 +++
 .../org/drools/drl/parser/antlr4/DRL10Parser.g4    |  559 +++++++
 .../org/drools/drl/parser/antlr4/JavaLexer.g4      |  245 +++
 .../org/drools/drl/parser/antlr4/JavaParser.g4     |  752 +++++++++
 .../drools/drl/parser/antlr4/DRLParserHelper.java  |   91 ++
 .../drl/parser/antlr4/DroolsSoftKeywords.java      |  136 ++
 .../org/drools/drl/parser/antlr4/LexerHelper.java  |  167 ++
 .../drl/parser/antlr4/DRLParserHelperTest.java     |  108 ++
 .../org/drools/drl/parser/antlr4/SmokeTest.java    |   90 +
 .../src/test/resources/logback-test.xml            |   33 +
 packages/drools-lsp/env/index.js                   |   31 +
 packages/drools-lsp/index.d.ts                     |   20 +
 packages/drools-lsp/index.js                       |   35 +
 packages/drools-lsp/install.js                     |   26 +
 packages/drools-lsp/package.json                   |   44 +
 packages/drools-lsp/pom.xml                        |   43 +
 packages/maven-base/pom.xml.template               |   18 +
 pnpm-lock.yaml                                     | 1724 +++++++++-----------
 repo/graph.dot                                     |    6 +
 repo/graph.json                                    |    8 +
 119 files changed, 19438 insertions(+), 927 deletions(-)

diff --git a/.gitignore b/.gitignore
index 58c606f1df4..5629fdf1c7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,6 +93,9 @@ packages/kie-sandbox-fs/coverage
 packages/kie-sandbox-fs/junit
 packages/kie-sandbox-fs/*-0.0.0-development.tgz
 
+#drl-vscode-extension
+packages/drl-vscode-extension/out-test/
+
 #angular
 **/.angular
 
diff --git a/.rat-excludes b/.rat-excludes
index 4019fa2f1b8..26e819fd60f 100644
--- a/.rat-excludes
+++ b/.rat-excludes
@@ -110,7 +110,12 @@ packages/dmn-marshaller/src/schemas/kie-1_0/KIE.xsd
 packages/dmn-testing-models/dmn-testing-models.iml
 packages/dmn-uniforms-patternfly-form-wrapper/tests/__snapshots__/FormDmn.test.tsx.snap
 packages/dmn-uniforms-patternfly-form-wrapper/tests/__snapshots__/FormDmnOutputs.test.tsx.snap
+packages/drl-vscode-extension/.vscodeignore
 packages/drools-and-kogito/.gitignore
+packages/drools-lsp/drools-lsp-server/.gitignore
+packages/drools-lsp/drools-parser/.gitignore
+packages/drools-lsp/drools-parser/src/main/antlr4/org/drools/drl/parser/antlr4/JavaLexer.g4
+packages/drools-lsp/drools-parser/src/main/antlr4/org/drools/drl/parser/antlr4/JavaParser.g4
 packages/editor/tests/embedded/embedded/__snapshots__/EmbeddedEditor.test.tsx.snap
 packages/editor/tests/embedded/embedded/__snapshots__/EmbeddedViewer.test.tsx.snap
 packages/editor/tests/envelope/KeyBindingHelpOverlay/__snapshots__/KeyBindingsHelpOverlay.test.tsx.snap
diff --git a/packages/drl-vscode-extension/.vscodeignore b/packages/drl-vscode-extension/.vscodeignore
new file mode 100644
index 00000000000..b6903715532
--- /dev/null
+++ b/packages/drl-vscode-extension/.vscodeignore
@@ -0,0 +1,11 @@
+.vscode/**
+src/**
+node_modules/**
+env/**
+*.map
+**/*.ts
+tsconfig.json
+tsconfig.test.json
+webpack.config.js
+out-test/**
+DEV.md
diff --git a/packages/drl-vscode-extension/CHANGELOG.md b/packages/drl-vscode-extension/CHANGELOG.md
new file mode 100644
index 00000000000..50281c35fb2
--- /dev/null
+++ b/packages/drl-vscode-extension/CHANGELOG.md
@@ -0,0 +1,22 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+-->
+
+# 10.3.0
+
+- Initial release of the DRL Editor extension, migrated from [kiegroup/drools-lsp](https://github.com/kiegroup/drools-lsp).
+- Syntax highlighting for `.drl` files.
+- LSP features: code completion (keywords, Java classes, fields, DRL declared types), go-to-definition, find references, rename, diagnostics with quick-fixes, hover tooltips, document symbols, type hierarchy, inlay hints, folding ranges, and code lens.
diff --git a/packages/drl-vscode-extension/DEV.md b/packages/drl-vscode-extension/DEV.md
new file mode 100644
index 00000000000..731db202c66
--- /dev/null
+++ b/packages/drl-vscode-extension/DEV.md
@@ -0,0 +1,36 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+-->
+
+## Build
+
+This package depends on the server uber-JAR produced by `@kie-tools/drools-lsp`, so build it through the workspace filter that includes dependencies:
+
+```sh
+pnpm -F drl-vscode-extension... build:dev
+```
+
+`build:prod` additionally packages the `.vsix` into `dist/`.
+
+## Test
+
+The E2E tests download VS Code via `@vscode/test-electron` and exercise the LSP features against the fixture project in `src/testFixture/`. The server JAR must have been built first (see Build above — the `...` filter suffix builds `@kie-tools/drools-lsp` too). Then:
+
+```sh
+pnpm -F drl-vscode-extension test
+```
+
+The `pretest` script rebuilds the extension bundle, compiles the test fixture, and compiles the test sources, so tests always run against the current sources.
diff --git a/packages/drl-vscode-extension/LICENSE b/packages/drl-vscode-extension/LICENSE
new file mode 100644
index 00000000000..261eeb9e9f8
--- /dev/null
+++ b/packages/drl-vscode-extension/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/packages/drl-vscode-extension/README.md b/packages/drl-vscode-extension/README.md
new file mode 100644
index 00000000000..2186dd8ff3d
--- /dev/null
+++ b/packages/drl-vscode-extension/README.md
@@ -0,0 +1,74 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+-->
+
+## DRL Editor
+
+Language support for [DRL (Drools Rule Language)](https://kie.apache.org/docs/10.2.x/drools/drools/language-reference-traditional/index.html) files in Visual Studio Code, powered by a dedicated [Language Server](https://microsoft.github.io/language-server-protocol/).
+
+## Requirements
+
+- Java 17 or later (`JAVA_HOME` must be set or `java` must be on your `PATH`)
+- Maven (for classpath resolution of Java types used in rules)
+
+## Features
+
+### Code Editing
+
+- Syntax highlighting
+- Code completion for grammar keywords, Java class names, fields/properties, and DRL `declare` types
+- Inlay hints for bound variables
+- Live class index refresh on recompile (no server restart required)
+
+### Navigation
+
+- Go-to definition for DRL and Java types
+- Find references for DRL types and bound variables
+- Rename for DRL declared types and bound variables
+- Document symbols (outline view)
+- Type hierarchy for DRL types
+- Folding ranges for DRL blocks and comments
+
+### Diagnostics
+
+- Syntax error reporting
+- Lint diagnostics (missing `end`, missing separators, unbalanced parentheses, etc.)
+- Unknown-type lint with typo quick-fix for DRL-declared types
+
+### Information
+
+- Hover tooltips for DRL/Java types with doc-comment rendering
+- Reference-count code lens for DRL declared types
+
+## Extension Settings
+
+| Setting                              | Default   | Description                                                   |
+| ------------------------------------ | --------- | ------------------------------------------------------------- |
+| `drools.lsp.logLevel`                | `INFO`    | Server-side log level                                         |
+| `drools.lsp.lint.missingEnd`         | `warning` | Severity for missing `end` keyword                            |
+| `drools.lsp.lint.missingSeparator`   | `warning` | Severity for missing constraint separator                     |
+| `drools.lsp.lint.missingSemicolon`   | `warning` | Severity for missing semicolon in consequence                 |
+| `drools.lsp.lint.unbalancedParens`   | `warning` | Severity for unbalanced parentheses                           |
+| `drools.lsp.lint.unknownTypes`       | `warning` | Severity for unrecognized type references                     |
+| `drools.lsp.lint.mvelPropertyAccess` | `off`     | Hint to prefer property-access style over getter calls in LHS |
+| `drools.lsp.inlayHints.enabled`      | `true`    | Show inline type hints for bound variables                    |
+| `drools.lsp.maven.pomPath`           | `""`      | Maven POM path(s) for classpath resolution                    |
+
+All lint settings accept: `off`, `hint`, `info`, `warning`, `error`.
+
+## Known Issues
+
+If you find any issues, please report them in [GitHub Issues](https://github.com/apache/incubator-kie-issues/issues).
diff --git a/packages/drl-vscode-extension/images/drools-icon.png b/packages/drl-vscode-extension/images/drools-icon.png
new file mode 100644
index 00000000000..42c5d6c2ade
Binary files /dev/null and b/packages/drl-vscode-extension/images/drools-icon.png differ
diff --git a/packages/drl-vscode-extension/language-configuration.json b/packages/drl-vscode-extension/language-configuration.json
new file mode 100644
index 00000000000..b155bf500c2
--- /dev/null
+++ b/packages/drl-vscode-extension/language-configuration.json
@@ -0,0 +1,25 @@
+{
+  "comments": {
+    "lineComment": "//",
+    "blockComment": ["/*", "*/"]
+  },
+  "brackets": [
+    ["{", "}"],
+    ["[", "]"],
+    ["(", ")"]
+  ],
+  "autoClosingPairs": [
+    ["{", "}"],
+    ["[", "]"],
+    ["(", ")"],
+    ["\"", "\""],
+    ["'", "'"]
+  ],
+  "surroundingPairs": [
+    ["{", "}"],
+    ["[", "]"],
+    ["(", ")"],
+    ["\"", "\""],
+    ["'", "'"]
+  ]
+}
diff --git a/packages/drl-vscode-extension/package.json b/packages/drl-vscode-extension/package.json
new file mode 100644
index 00000000000..ed243a65b20
--- /dev/null
+++ b/packages/drl-vscode-extension/package.json
@@ -0,0 +1,196 @@
+{
+  "name": "drl-vscode-extension",
+  "version": "0.0.0",
+  "description": "Edit DRL (Drools Rule Language) files",
+  "license": "Apache-2.0",
+  "publisher": "kie-group",
+  "keywords": [],
+  "homepage": "https://github.com/apache/incubator-kie-tools",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/apache/incubator-kie-tools.git"
+  },
+  "bugs": {
+    "url": "https://github.com/apache/incubator-kie-tools/issues"
+  },
+  "main": "dist/extension/extension.js",
+  "scripts": {
+    "build:dev": "rimraf dist && webpack --env dev",
+    "build:prod": "rimraf dist && webpack && pnpm pack:prod",
+    "compile": "webpack",
+    "lint": "run-script-if --bool \"$(build-env linters.run)\" --then \"kie-tools--eslint ./src\"",
+    "pack:prod": "vsce package --githubBranch main --no-dependencies -o ./dist/drl_vscode_extension_$npm_package_version.vsix",
+    "pretest": "pnpm build:dev && mvn -f src/testFixture/pom.xml -ntp compile && tsc -p tsconfig.test.json",
+    "test": "node ./out-test/test/runTest.js",
+    "watch": "export WEBPACK__sourceMaps=true; WEBPACK__minimize=false; webpack --env dev"
+  },
+  "dependencies": {
+    "vscode-languageclient": "^9.0.1"
+  },
+  "devDependencies": {
+    "@kie-tools-core/webpack-base": "workspace:*",
+    "@kie-tools-scripts/build-env": "workspace:*",
+    "@kie-tools/drools-lsp": "workspace:*",
+    "@kie-tools/eslint": "workspace:*",
+    "@kie-tools/root-env": "workspace:*",
+    "@kie-tools/tsconfig": "workspace:*",
+    "@types/mocha": "^10.0.7",
+    "@types/vscode": "1.67.0",
+    "@vscode/test-electron": "^2.3.6",
+    "@vscode/vsce": "^3.7.1",
+    "copy-webpack-plugin": "^11.0.0",
+    "glob": "^10.2.7",
+    "mocha": "^10.6.0",
+    "rimraf": "^3.0.2",
+    "run-script-os": "^1.1.6",
+    "typescript": "^5.9.3",
+    "webpack": "^5.107.1",
+    "webpack-cli": "^5.1.4",
+    "webpack-dev-server": "^5.2.5",
+    "webpack-merge": "^5.9.0"
+  },
+  "engines": {
+    "vscode": "^1.103.0"
+  },
+  "displayName": "DRL Editor",
+  "categories": [
+    "Programming Languages"
+  ],
+  "contributes": {
+    "configuration": {
+      "properties": {
+        "drools.lsp.inlayHints.enabled": {
+          "default": true,
+          "markdownDescription": "Show inline type hints for DRL rule bindings (e.g. `$count : count()` → `: Long`). VSCode's global `#editor.inlayHints.enabled#` still governs whether inlay hints are displayed at all.",
+          "type": "boolean"
+        },
+        "drools.lsp.lint.missingEnd": {
+          "default": "warning",
+          "description": "Severity for the missing 'end' keyword lint check.",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.lint.missingSemicolon": {
+          "default": "warning",
+          "description": "Severity for the missing semicolon in consequence lint check.",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.lint.missingSeparator": {
+          "default": "warning",
+          "description": "Severity for the missing constraint separator lint check (missing comma between constraints).",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.lint.mvelPropertyAccess": {
+          "default": "off",
+          "description": "Severity for the MVEL property-access style hint (prefer 'name' over 'getName()' in LHS constraints).",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.lint.unbalancedParens": {
+          "default": "warning",
+          "description": "Severity for the unbalanced parentheses lint check.",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.lint.unknownTypes": {
+          "default": "warning",
+          "description": "Severity for unrecognized type references in patterns, 'new T(...)' and qualified names, with a 'Did you mean?' quick-fix for likely typos.",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.logLevel": {
+          "default": "INFO",
+          "description": "Server-side log level (java.util.logging). Use FINE or FINER to surface diagnostics when debugging.",
+          "enum": [
+            "SEVERE",
+            "WARNING",
+            "INFO",
+            "FINE",
+            "FINER",
+            "FINEST"
+          ],
+          "type": "string"
+        },
+        "drools.lsp.maven.pomPath": {
+          "default": "",
+          "items": {
+            "type": "string"
+          },
+          "markdownDescription": "Maven POM(s) used for dependency classpath resolution — a single path or a list of paths, each absolute or workspace-relative. Each project's classpath is resolved and the results are merged. Leave empty to use `pom.xml` at the workspace root.",
+          "type": [
+            "string",
+            "array"
+          ]
+        }
+      },
+      "title": "DRL Language Server"
+    },
+    "grammars": [
+      {
+        "language": "drools",
+        "path": "./syntaxes/drl.tmLanguage",
+        "scopeName": "source.drools"
+      }
+    ],
+    "javaExtensions": [
+      "./dist/server/drools-lsp-server-jar-with-dependencies.jar"
+    ],
+    "languages": [
+      {
+        "aliases": [
+          "Drools Rule Language",
+          "DRL"
+        ],
+        "configuration": "./language-configuration.json",
+        "extensions": [
+          ".drl"
+        ],
+        "id": "drools"
+      }
+    ]
+  },
+  "icon": "images/drools-icon.png",
+  "kieTools": {
+    "requiredPreinstalledCliCommands": [
+      "java"
+    ]
+  }
+}
diff --git a/packages/drl-vscode-extension/src/extension.ts b/packages/drl-vscode-extension/src/extension.ts
new file mode 100644
index 00000000000..3c1ab2e59a1
--- /dev/null
+++ b/packages/drl-vscode-extension/src/extension.ts
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as fs from "fs";
+import * as path from "path";
+import * as vscode from "vscode";
+
+import { LanguageClient, LanguageClientOptions, ServerOptions, StreamInfo } from "vscode-languageclient/node";
+import * as net from "net";
+
+let languageClient: LanguageClient | undefined;
+
+const DEBUG_MODE = process.env.LSDEBUG === "true";
+
+let channel: vscode.OutputChannel | undefined;
+const log = {
+  info: (msg: string) => channel?.appendLine("INFO: " + msg),
+  warn: (msg: string) => channel?.appendLine("WARNING: " + msg),
+  error: (msg: string) => channel?.appendLine("ERROR: " + msg),
+  debug: (msg: string) => {
+    if (DEBUG_MODE) {
+      channel?.appendLine("DEBUG: " + msg);
+    }
+  },
+};
+
+export function activate(context: vscode.ExtensionContext) {
+  channel = vscode.window.createOutputChannel("DRL Language Server");
+  context.subscriptions.push(channel);
+
+  log.info('Activating extension "DRL Language Server"....');
+
+  context.subscriptions.push(
+    vscode.commands.registerCommand(
+      "drools.peekReferences",
+      (
+        uri: string,
+        position: { line: number; character: number },
+        locations: Array<{
+          uri: string;
+          range: {
+            start: { line: number; character: number };
+            end: { line: number; character: number };
+          };
+        }>
+      ) => {
+        const target = vscode.Uri.parse(uri);
+        const at = new vscode.Position(position.line, position.character);
+        const locs = (locations ?? []).map(
+          (l) =>
+            new vscode.Location(
+              vscode.Uri.parse(l.uri),
+              new vscode.Range(l.range.start.line, l.range.start.character, l.range.end.line, l.range.end.character)
+            )
+        );
+        return vscode.commands.executeCommand("editor.action.showReferences", target, at, locs);
+      }
+    )
+  );
+
+  let serverOptions: ServerOptions | undefined = undefined;
+
+  if (DEBUG_MODE) {
+    log.debug("Starting in debug mode");
+    const connectionInfo = {
+      port: 9925,
+      host: "127.0.0.1",
+    };
+    log.debug("connectionInfo " + JSON.stringify(connectionInfo));
+    serverOptions = () => {
+      const socket = net.connect(connectionInfo);
+      const result: StreamInfo = {
+        writer: socket,
+        reader: socket,
+      };
+      return Promise.resolve(result);
+    };
+  } else {
+    const javaHome = getJavaHome();
+
+    let executable: string = `java`;
+
+    if (javaHome) {
+      executable = path.join(javaHome, "bin", "java");
+      log.debug("java executable path : " + executable);
+    } else {
+      log.warn("java home is not found. Invoking java without path.");
+    }
+
+    const serverJar = path.join(context.extensionPath, "dist", "server", "drools-lsp-server-jar-with-dependencies.jar");
+    if (fs.existsSync(serverJar)) {
+      log.debug("server jar path : " + serverJar);
+    } else {
+      log.error(`${serverJar} not found`);
+      return;
+    }
+
+    const config = vscode.workspace.getConfiguration();
+    const args: string[] = [];
+
+    const logLevel: string | undefined = config.get("drools.lsp.logLevel");
+    if (logLevel) {
+      args.push(`-Ddrools.lsp.logLevel=${logLevel}`);
+    }
+
+    const lintProps = [
+      "drools.lsp.lint.missingEnd",
+      "drools.lsp.lint.missingSeparator",
+      "drools.lsp.lint.missingSemicolon",
+      "drools.lsp.lint.unbalancedParens",
+      "drools.lsp.lint.unknownTypes",
+      "drools.lsp.lint.mvelPropertyAccess",
+    ];
+    for (const prop of lintProps) {
+      const value: string | undefined = config.get(prop);
+      if (value !== undefined) {
+        args.push(`-D${prop}=${value}`);
+      }
+    }
+
+    const inlayHintsEnabled: boolean | undefined = config.get("drools.lsp.inlayHints.enabled");
+    if (inlayHintsEnabled !== undefined) {
+      args.push(`-Ddrools.lsp.inlayHints.enabled=${inlayHintsEnabled}`);
+    }
+
+    const pomPathSetting = config.get<string | string[]>("drools.lsp.maven.pomPath");
+    const pomPaths = (Array.isArray(pomPathSetting) ? pomPathSetting : pomPathSetting ? [pomPathSetting] : [])
+      .map((p) => p.trim())
+      .filter((p) => p.length > 0);
+    if (pomPaths.length > 0) {
+      const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+      const resolved = pomPaths.map((p) => (workspaceRoot && !path.isAbsolute(p) ? path.join(workspaceRoot, p) : p));
+      args.push(`-Ddrools.lsp.maven.pomPath=${resolved.join(path.delimiter)}`);
+    }
+
+    args.push("-jar", serverJar);
+
+    serverOptions = {
+      command: executable,
+      args: [...args],
+      options: {},
+    };
+  }
+
+  if (serverOptions) {
+    log.info("Starting language client");
+    const clientOptions: LanguageClientOptions = {
+      documentSelector: [{ scheme: "file", language: "drools" }],
+      synchronize: {
+        fileEvents: vscode.workspace.createFileSystemWatcher("**/target/classes/**/*.class"),
+      },
+      outputChannel: channel,
+    };
+    languageClient = new LanguageClient("Drools", "DRL Language Server", serverOptions, clientOptions);
+    languageClient.start();
+
+    log.info("DRL Language Server activated.");
+  }
+}
+
+export function deactivate(): Thenable<void> | undefined {
+  log.info("DRL Language Server deactivated.");
+  if (!languageClient) {
+    return undefined;
+  }
+  return languageClient.stop();
+}
+
+function getJavaHome(): string | undefined {
+  let javaHome: string | undefined;
+
+  javaHome = vscode.workspace.getConfiguration().get("java.home");
+  if (javaHome) {
+    log.debug("java.home from workspace configuration : " + javaHome);
+    return javaHome;
+  }
+
+  javaHome = process.env.GHA_JAVA_HOME;
+  if (javaHome) {
+    log.debug("GHA_JAVA_HOME from process env : " + javaHome);
+    return javaHome;
+  }
+
+  javaHome = process.env.JAVA_HOME;
+  if (javaHome) {
+    log.debug("JAVA_HOME from process env : " + javaHome);
+    return javaHome;
+  }
+
+  log.warn("JAVA_HOME not found");
+  return javaHome;
+}
diff --git a/packages/drl-vscode-extension/src/test/runTest.ts b/packages/drl-vscode-extension/src/test/runTest.ts
new file mode 100644
index 00000000000..4d62809ea9b
--- /dev/null
+++ b/packages/drl-vscode-extension/src/test/runTest.ts
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { runTests } from "@vscode/test-electron";
+
+async function main() {
+  // Use a short user-data-dir to keep VS Code's IPC socket path under the
+  // 103-char Unix socket limit (macOS), which the default path inside the
+  // package's .vscode-test directory can exceed on deep checkouts
+  const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "vsct-"));
+
+  try {
+    // The folder containing the Extension Manifest package.json
+    // Passed to `--extensionDevelopmentPath`
+    const extensionDevelopmentPath = path.resolve(__dirname, "../../");
+
+    // The path to the compiled test runner
+    // Passed to --extensionTestsPath
+    const extensionTestsPath = path.resolve(__dirname, "./suite/index");
+
+    // Use win64 instead of win32 for testing Windows
+    const platform = process.platform === "win32" ? "win32-x64-archive" : undefined;
+
+    // The workspace folder for the test — a Maven project with domain classes
+    const testWorkspace = path.resolve(__dirname, "../../src/testFixture");
+
+    // Download VS Code, unzip it and run the integration test
+    await runTests({
+      extensionDevelopmentPath,
+      extensionTestsPath,
+      platform,
+      launchArgs: [testWorkspace, `--user-data-dir=${userDataDir}`],
+    });
+
+    // Clean up the temporary VS Code profile on success only; on failure it is
+    // preserved so its logs can be inspected
+    fs.rmSync(userDataDir, { recursive: true, force: true });
+  } catch (err) {
+    console.error("Failed to run tests");
+    console.error(`VS Code user-data-dir preserved for diagnostics: ${userDataDir}`);
+    process.exitCode = 1;
+  }
+}
+
+main();
diff --git a/packages/drl-vscode-extension/src/test/suite/completion.test.ts b/packages/drl-vscode-extension/src/test/suite/completion.test.ts
new file mode 100644
index 00000000000..64e425e5456
--- /dev/null
+++ b/packages/drl-vscode-extension/src/test/suite/completion.test.ts
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as vscode from "vscode";
+import * as assert from "assert";
+import { getDocUri, activate } from "./helper";
+
+suite("Completion tests", () => {
+  const docUri = getDocUri("empty.drl");
+
+  test("Completes 'package' at the beginning", async () => {
+    await testCompletion(docUri, new vscode.Position(0, 0), {
+      items: [{ label: "package", kind: vscode.CompletionItemKind.Keyword }],
+    });
+  }).timeout(30000); // increase timeout because helper.activate waits for server startup
+});
+
+suite("Class completion tests", () => {
+  const docUri = getDocUri("sample.drl");
+
+  test("Suggests class names in pattern position", async () => {
+    await activate(docUri);
+
+    // "$p :" in the "greeting" rule — caret after the colon (line 31, 0-indexed)
+    const position = new vscode.Position(31, 7);
+
+    // Class index is built asynchronously after initialize; retry until ready
+    let classLabels: string[] = [];
+    for (let attempt = 0; attempt < 30; attempt++) {
+      const completionList = (await vscode.commands.executeCommand(
+        "vscode.executeCompletionItemProvider",
+        docUri,
+        position
+      )) as vscode.CompletionList;
+
+      classLabels = completionList.items
+        .filter((item) => item.kind === vscode.CompletionItemKind.Class)
+        .map((item) => item.label as string);
+
+      if (classLabels.includes("Person")) {
+        break;
+      }
+      await new Promise((resolve) => setTimeout(resolve, 1000));
+    }
+
+    assert.ok(classLabels.includes("Person"), "Should suggest Person class");
+    assert.ok(classLabels.includes("Address"), "Should suggest Address class");
+
+    // Re-query to check sortText on the final result
+    const actualCompletionList = (await vscode.commands.executeCommand(
+      "vscode.executeCompletionItemProvider",
+      docUri,
+      position
+    )) as vscode.CompletionList;
+
+    // Person is imported, so should have sortText starting with "0_"
+    const personItem = actualCompletionList.items.find(
+      (item) => item.label === "Person" && item.kind === vscode.CompletionItemKind.Class
+    );
+    assert.ok(personItem, "Person completion item should exist");
+    assert.ok(personItem!.sortText?.startsWith("0_"), "Imported Person should be ranked first");
+
+    // Address is not imported, so should have sortText starting with "1_"
+    const addressItem = actualCompletionList.items.find(
+      (item) => item.label === "Address" && item.kind === vscode.CompletionItemKind.Class
+    );
+    assert.ok(addressItem, "Address completion item should exist");
+    assert.ok(addressItem!.sortText?.startsWith("1_"), "Unimported Address should be ranked after imported classes");
+  }).timeout(60000); // classpath resolution via mvn adds time
+});
+
+async function testCompletion(
+  docUri: vscode.Uri,
+  position: vscode.Position,
+  expectedCompletionList: vscode.CompletionList
+) {
+  await activate(docUri);
+
+  // Executing the command `vscode.executeCompletionItemProvider` to simulate triggering completion
+  const actualCompletionList = (await vscode.commands.executeCommand(
+    "vscode.executeCompletionItemProvider",
+    docUri,
+    position
+  )) as vscode.CompletionList;
+
+  assert.ok(actualCompletionList.items.length >= expectedCompletionList.items.length);
+
+  expectedCompletionList.items.forEach((expectedItem) => {
+    const actualItem = actualCompletionList.items.find((item) => item.label === expectedItem.label);
+    assert(actualItem);
+    assert.strictEqual(actualItem.kind, expectedItem.kind);
+  });
+}
diff --git a/packages/drl-vscode-extension/src/test/suite/helper.ts b/packages/drl-vscode-extension/src/test/suite/helper.ts
new file mode 100644
index 00000000000..3b2db1686bb
--- /dev/null
+++ b/packages/drl-vscode-extension/src/test/suite/helper.ts
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as vscode from "vscode";
+import * as path from "path";
+
+export let doc: vscode.TextDocument;
+export let editor: vscode.TextEditor;
+export let documentEol: string;
+export let platformEol: string;
+
+/**
+ * Activates the drl-vscode-extension
+ */
+export async function activate(docUri: vscode.Uri) {
+  // The extensionId is `publisher.name` from package.json
+  const ext = vscode.extensions.getExtension("kie-group.drl-vscode-extension")!;
+  await ext.activate();
+  try {
+    doc = await vscode.workspace.openTextDocument(docUri);
+    editor = await vscode.window.showTextDocument(doc);
+    await sleep(5000); // Wait for server activation
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+async function sleep(ms: number) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export const getDocPath = (p: string) => {
+  return path.resolve(__dirname, "../../../src/testFixture/src/main/resources/rules", p);
+};
+
+export const getDocUri = (p: string) => {
+  return vscode.Uri.file(getDocPath(p));
+};
diff --git a/packages/drl-vscode-extension/src/test/suite/index.ts b/packages/drl-vscode-extension/src/test/suite/index.ts
new file mode 100644
index 00000000000..a7c6f00650c
--- /dev/null
+++ b/packages/drl-vscode-extension/src/test/suite/index.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as path from "path";
+import * as Mocha from "mocha";
+import { glob } from "glob";
+
+export async function run(): Promise<void> {
+  // Create the mocha test
+  const mocha = new Mocha({
+    ui: "tdd",
+    color: true,
+  });
+
+  const testsRoot = path.resolve(__dirname, "..");
+
+  const files = await glob("**/**.test.js", { cwd: testsRoot });
+  files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));
+
+  return new Promise((c, e) => {
+    try {
+      mocha.run((failures) => {
+        if (failures > 0) {
+          e(new Error(`${failures} tests failed.`));
+        } else {
+          c();
+        }
+      });
+    } catch (err) {
+      console.error(err);
+      e(err);
+    }
+  });
+}
diff --git a/packages/drl-vscode-extension/src/testFixture/pom.xml b/packages/drl-vscode-extension/src/testFixture/pom.xml
new file mode 100644
index 00000000000..2407d3a6cdb
--- /dev/null
+++ b/packages/drl-vscode-extension/src/testFixture/pom.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<project
+  xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+>
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.example</groupId>
+  <artifactId>drools-test</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <properties>
+    <maven.compiler.source>17</maven.compiler.source>
+    <maven.compiler.target>17</maven.compiler.target>
+  </properties>
+</project>
diff --git a/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Address.java b/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Address.java
new file mode 100644
index 00000000000..60953d39a57
--- /dev/null
+++ b/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Address.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.example.model;
+
+public class Address {
+
+    private String street;
+    private String city;
+
+    public Address() {
+    }
+
+    public Address(String street, String city) {
+        this.street = street;
+        this.city = city;
+    }
+
+    public String getStreet() {
+        return street;
+    }
+
+    public void setStreet(String street) {
+        this.street = street;
+    }
+
+    public String getCity() {
+        return city;
+    }
+
+    public void setCity(String city) {
+        this.city = city;
+    }
+}
diff --git a/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Person.java b/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Person.java
new file mode 100644
index 00000000000..00e7893e6ce
--- /dev/null
+++ b/packages/drl-vscode-extension/src/testFixture/src/main/java/org/example/model/Person.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.example.model;
+
+public class Person {
+
+    private String name;
+    private int age;
+
+    public Person() {
+    }
+
+    public Person(String name, int age) {
+        this.name = name;
+        this.age = age;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public int getAge() {
+        return age;
+    }
+
+    public void setAge(int age) {
+        this.age = age;
+    }
+}
diff --git a/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/empty.drl b/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/empty.drl
new file mode 100644
index 00000000000..51da6c0f2bb
--- /dev/null
+++ b/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/empty.drl
@@ -0,0 +1,18 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/sample.drl b/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/sample.drl
new file mode 100644
index 00000000000..f52a814c39b
--- /dev/null
+++ b/packages/drl-vscode-extension/src/testFixture/src/main/resources/rules/sample.drl
@@ -0,0 +1,35 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.example.rules;
+
+import org.example.model.Person;
+
+rule "adult check"
+  when
+    $p : Person(age >= 18)
+  then
+    System.out.println($p.getName() + " is an adult");
+end
+
+rule "greeting"
+  when
+    $p :
+  then
+    System.out.println("Hello");
+end
diff --git a/packages/drl-vscode-extension/syntaxes/drl.tmLanguage b/packages/drl-vscode-extension/syntaxes/drl.tmLanguage
new file mode 100644
index 00000000000..72c75d63b8d
--- /dev/null
+++ b/packages/drl-vscode-extension/syntaxes/drl.tmLanguage
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>fileTypes</key>
+	<array>
+		<string>.drl</string>
+	</array>
+	<key>name</key>
+	<string>Drools</string>
+	<key>patterns</key>
+	<array>
+		<dict>
+			<key>comment</key>
+			<string>Mathmatical operators</string>
+			<key>match</key>
+			<string>[&lt;|&gt;|==|&lt;=|&gt;=|.|!=]</string>
+			<key>name</key>
+			<string>keyword.operator.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Drools RHS functions</string>
+			<key>match</key>
+			<string>\b(insert|insertLogical|delete|modify)\b</string>
+			<key>name</key>
+			<string>entity.name.function.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Drools hard keywords</string>
+			<key>match</key>
+			<string>\b(true|false|null)\b</string>
+			<key>name</key>
+			<string>constant.language.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Drools soft keywords</string>
+			<key>match</key>
+			<string>\b(lock-on-active|date-effective|date-expires|no-loop|auto-focus|activation-group|agenda-group|ruleflow-group|entry-point|duration|package|import|dialect|salience|enabled|attributes|rule|extends|when|then|template|query|declare|function|global|eval|not|in|or|and|exists|forall|accumulate|collect|from|end|over|calendars|init|action|reverse|result|new)\b</string>
+			<key>name</key>
+			<string>keyword.control.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Bound variables like $person, $myList</string>
+			<key>match</key>
+			<string>\$([A-Za-z][A-Za-z0-9_]+)</string>
+			<key>name</key>
+			<string>variable.parameter.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Java classes</string>
+			<key>match</key>
+			<string>\b([A-Z][A-Za-z0-9_]+)\b</string>
+			<key>name</key>
+			<string>support.type.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Single line comment</string>
+			<key>match</key>
+			<string>(\/)(\/).*</string>
+			<key>name</key>
+			<string>comment.line.double-slash.source.drools</string>
+		</dict>
+		<dict>
+			<key>begin</key>
+			<string>/\*</string>
+			<key>comment</key>
+			<string>Multi line comment</string>
+			<key>end</key>
+			<string>\*/</string>
+			<key>name</key>
+			<string>comment.line.double-slash.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Double quote strings</string>
+			<key>match</key>
+			<string>("([^"]|"")*")</string>
+			<key>name</key>
+			<string>string.quoted.double.source.drools</string>
+		</dict>
+		<dict>
+			<key>comment</key>
+			<string>Numbers</string>
+			<key>match</key>
+			<string>([0-9]+(\.)?[0-9]*)</string>
+			<key>name</key>
+			<string>constant.numeric.source.drools</string>
+		</dict>
+	</array>
+	<key>scopeName</key>
+	<string>source.drools</string>
+	<key>uuid</key>
+	<string>a98fc7cc-15d1-401e-bb85-e72714276cdb</string>
+</dict>
+</plist>
\ No newline at end of file
diff --git a/packages/drl-vscode-extension/tsconfig.json b/packages/drl-vscode-extension/tsconfig.json
new file mode 100644
index 00000000000..9db9caf407b
--- /dev/null
+++ b/packages/drl-vscode-extension/tsconfig.json
@@ -0,0 +1,7 @@
+{
+  "extends": "@kie-tools/tsconfig/tsconfig.cjs.json",
+  "compilerOptions": {
+    "declaration": false,
+    "declarationMap": false
+  }
+}
diff --git a/packages/drl-vscode-extension/tsconfig.test.json b/packages/drl-vscode-extension/tsconfig.test.json
new file mode 100644
index 00000000000..c5cdad9b075
--- /dev/null
+++ b/packages/drl-vscode-extension/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+  "extends": "@kie-tools/tsconfig/tsconfig.cjs.json",
+  "compilerOptions": {
+    "declaration": false,
+    "declarationMap": false,
+    "outDir": "out-test",
+    "rootDir": "src"
+  },
+  "include": ["src/test/**/*.ts"]
+}
diff --git a/packages/drl-vscode-extension/webpack.config.js b/packages/drl-vscode-extension/webpack.config.js
new file mode 100644
index 00000000000..ee6c578f7cd
--- /dev/null
+++ b/packages/drl-vscode-extension/webpack.config.js
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const CopyWebpackPlugin = require("copy-webpack-plugin");
+const droolsLsp = require("@kie-tools/drools-lsp");
+const { merge } = require("webpack-merge");
+const common = require("@kie-tools-core/webpack-base/webpack.common.config");
+
+module.exports = async (webpackEnv) => [
+  merge(common(webpackEnv), {
+    target: "node",
+    entry: {
+      "extension/extension": "./src/extension.ts",
+    },
+    output: {
+      library: "DrlEditor",
+      libraryTarget: "umd",
+    },
+    externals: {
+      vscode: "commonjs vscode",
+    },
+    plugins: [
+      new CopyWebpackPlugin({
+        patterns: [
+          {
+            from: droolsLsp.path(),
+            to: "server/",
+          },
+        ],
+      }),
+    ],
+  }),
+];
diff --git a/packages/drools-lsp/drools-completion/pom.xml b/packages/drools-lsp/drools-completion/pom.xml
new file mode 100644
index 00000000000..625210c78a2
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/pom.xml
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<project
+  xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+>
+  <parent>
+    <artifactId>drools-lsp</artifactId>
+    <groupId>org.kie.tools</groupId>
+    <version>${revision}</version>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+
+  <artifactId>drools-completion</artifactId>
+
+  <name>Drools :: Completion</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.kie.tools</groupId>
+      <artifactId>drools-parser</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
+    <dependency>
+      <groupId>com.vmware.antlr4-c3</groupId>
+      <artifactId>antlr4-c3</artifactId>
+      <exclusions>
+        <exclusion>
+          <groupId>org.antlr</groupId>
+          <artifactId>antlr4</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.lsp4j</groupId>
+      <artifactId>org.eclipse.lsp4j</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <configuration>
+          <argLine>
+            -Djava.util.logging.config.file=${project.basedir}/src/test/resources/logging.properties
+          </argLine>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java
new file mode 100644
index 00000000000..f5082229b01
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Map;
+
+/**
+ * Holder for the accumulate-function result-type map (function name → simple
+ * result type), used by {@link LhsBindingResolver} and the inlay-hint helper to
+ * resolve accumulate result bindings ({@code $count : count()}).
+ *
+ * <p>Seeded with the complete set of Drools' built-in accumulate functions. The
+ * names and result types are transcribed from the Drools engine itself —
+ * {@code META-INF/kie.default.properties.conf} (function name → impl class) and
+ * each impl's {@code getResultType()}.
+ * Baking them in avoids a runtime dependency on {@code drools-core};
+ * the trade-off is they don't auto-track future Drools versions.
+ *
+ * <p>{@link #set} remains as an override hook (e.g. for tests, or a future
+ * registry that loads the map from the Drools dependency directly); {@code null}
+ * resets to the built-in defaults.
+ */
+public final class AccumulateFunctionTypes {
+
+    /** Drools built-in accumulate functions → result type (simple name). */
+    private static final Map<String, String> DEFAULTS = Map.ofEntries(
+            Map.entry("max", "Comparable"),
+            Map.entry("maxN", "Number"),
+            Map.entry("maxI", "Integer"),
+            Map.entry("maxL", "Long"),
+            Map.entry("min", "Comparable"),
+            Map.entry("minN", "Number"),
+            Map.entry("minI", "Integer"),
+            Map.entry("minL", "Long"),
+            Map.entry("count", "Long"),
+            Map.entry("collectList", "List"),
+            Map.entry("collectSet", "Set"),
+            Map.entry("average", "Double"),
+            Map.entry("averageBD", "BigDecimal"),
+            Map.entry("sum", "Double"),
+            Map.entry("sumI", "Integer"),
+            Map.entry("sumL", "Long"),
+            Map.entry("sumBI", "BigInteger"),
+            Map.entry("sumBD", "BigDecimal"),
+            Map.entry("variance", "Double"),
+            Map.entry("standardDeviation", "Double"));
+
+    private static volatile Map<String, String> resultTypes = DEFAULTS;
+
+    private AccumulateFunctionTypes() {
+    }
+
+    /** Installs the result-type map (copied defensively). {@code null} resets to defaults. */
+    public static void set(Map<String, String> map) {
+        resultTypes = (map == null) ? DEFAULTS : Map.copyOf(map);
+    }
+
+    /** The current function-name → result-type map (never null). */
+    public static Map<String, String> get() {
+        return resultTypes;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java
new file mode 100644
index 00000000000..8a2b2339283
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+public class ClassIndex {
+
+    private static final Logger logger = Logger.getLogger(ClassIndex.class.getName());
+
+    private final Map<String, List<String>> index;
+
+    private ClassIndex(Map<String, List<String>> index) {
+        this.index = index;
+    }
+
+    public static ClassIndex empty() {
+        return new ClassIndex(new HashMap<>());
+    }
+
+    public static ClassIndex build(Set<Path> classpathEntries) {
+        Map<String, List<String>> index = new HashMap<>();
+        for (Path entry : classpathEntries) {
+            if (Files.isDirectory(entry)) {
+                scanDirectory(entry, index);
+            } else if (entry.toString().endsWith(".jar") && Files.isRegularFile(entry)) {
+                scanJar(entry, index);
+            }
+        }
+        return new ClassIndex(index);
+    }
+
+    public static ClassIndex merge(ClassIndex base, ClassIndex overlay) {
+        Map<String, List<String>> merged = new HashMap<>(base.index);
+        for (Map.Entry<String, List<String>> entry : overlay.index.entrySet()) {
+            merged.merge(entry.getKey(), entry.getValue(), (a, b) -> {
+                List<String> combined = new ArrayList<>(a);
+                combined.addAll(b);
+                return combined;
+            });
+        }
+        return new ClassIndex(merged);
+    }
+
+    public List<String> getAll() {
+        return getMatching("");
+    }
+
+    /**
+     * The simple names of every indexed type (the index keys). Used as a typo
+     * suggestion pool for the unknown-type lint, so a misspelled classpath type
+     * can be matched to the real one. Returned as an unmodifiable view over the
+     * (effectively immutable) index to avoid copying the key set on every call.
+     */
+    public Set<String> simpleNames() {
+        return java.util.Collections.unmodifiableSet(index.keySet());
+    }
+
+    public List<String> getMatching(String prefix) {
+        List<String> result = new ArrayList<>();
+        for (Map.Entry<String, List<String>> entry : index.entrySet()) {
+            if (entry.getKey().startsWith(prefix)) {
+                result.addAll(entry.getValue());
+            }
+        }
+        result.sort(String::compareTo);
+        return result;
+    }
+
+    public int size() {
+        return index.values().stream().mapToInt(List::size).sum();
+    }
+
+    private static void scanDirectory(Path dir, Map<String, List<String>> index) {
+        try (Stream<Path> walk = Files.walk(dir)) {
+            walk.filter(p -> p.toString().endsWith(".class"))
+                .forEach(p -> {
+                    String relative = dir.relativize(p).toString();
+                    addClassEntry(relative, index);
+                });
+        } catch (IOException e) {
+            logger.log(Level.WARNING, "Failed to scan directory: " + dir, e);
+        }
+    }
+
+    private static void scanJar(Path jarPath, Map<String, List<String>> index) {
+        try (JarFile jar = new JarFile(jarPath.toFile())) {
+            jar.stream()
+                .map(JarEntry::getName)
+                .filter(name -> name.endsWith(".class"))
+                .forEach(name -> addClassEntry(name, index));
+        } catch (IOException e) {
+            logger.log(Level.WARNING, "Failed to scan JAR: " + jarPath, e);
+        }
+    }
+
+    private static void addClassEntry(String path, Map<String, List<String>> index) {
+        String fqcn = path.replace('/', '.').replace('\\', '.');
+        if (fqcn.endsWith(".class")) {
+            fqcn = fqcn.substring(0, fqcn.length() - ".class".length());
+        }
+
+        String simpleName = fqcn.contains(".") ? fqcn.substring(fqcn.lastIndexOf('.') + 1) : fqcn;
+        if (simpleName.contains("$") || simpleName.equals("module-info") || simpleName.equals("package-info")) {
+            return;
+        }
+
+        index.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqcn);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java
new file mode 100644
index 00000000000..a52ea369ddf
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Lazily reflects the members of classes on the resolved project classpath
+ * (the same entries {@link ClassIndex} is built from), so completion can
+ * offer field and bean-property names for pattern types.
+ *
+ * <p>Classes are loaded with {@code Class.forName(fqcn, false, loader)} —
+ * <b>static initializers never run</b>, so member lookup cannot execute
+ * arbitrary user code inside the language server. The loader's parent is the
+ * platform class loader, so the server's own classes never leak into user
+ * completions. Results (including misses) are cached per FQCN.
+ */
+public final class ClassMemberIndex implements AutoCloseable {
+
+    private static final Logger logger = Logger.getLogger(ClassMemberIndex.class.getName());
+
+    private static final ClassMemberIndex EMPTY = new ClassMemberIndex((ClassLoader) null);
+
+    private final ClassLoader loader;
+    private final boolean ownsLoader;
+    private final Map<String, List<Field>> cache = new ConcurrentHashMap<>();
+
+    /** Visible for tests: reflect against an existing (externally-owned) loader. */
+    ClassMemberIndex(ClassLoader loader) {
+        this(loader, false);
+    }
+
+    private ClassMemberIndex(ClassLoader loader, boolean ownsLoader) {
+        this.loader = loader;
+        this.ownsLoader = ownsLoader;
+    }
+
+    /** An index that resolves nothing. */
+    public static ClassMemberIndex empty() {
+        return EMPTY;
+    }
+
+    /** Builds an index over the given classpath entries (jars and class dirs). */
+    public static ClassMemberIndex of(Set<Path> classpathEntries) {
+        if (classpathEntries == null || classpathEntries.isEmpty()) {
+            return EMPTY;
+        }
+        List<URL> urls = new ArrayList<>(classpathEntries.size());
+        for (Path entry : classpathEntries) {
+            try {
+                urls.add(entry.toUri().toURL());
+            } catch (Exception e) {
+                logger.log(Level.FINE, "Skipping classpath entry " + entry, e);
+            }
+        }
+        URLClassLoader loader = new URLClassLoader(
+                urls.toArray(new URL[0]), ClassLoader.getPlatformClassLoader());
+        return new ClassMemberIndex(loader, true);
+    }
+
+    @Override
+    public void close() {
+        cache.clear();
+        if (ownsLoader && loader instanceof java.io.Closeable closeable) {
+            try {
+                closeable.close();
+            } catch (java.io.IOException e) {
+                logger.log(Level.FINE, "Failed to close class member index loader", e);
+            }
+        }
+    }
+
+    /**
+     * Returns the completable members of {@code fqcn}: enum constants, bean
+     * properties derived from public no-arg {@code getX()}/{@code isX()}
+     * methods (including inherited ones), and public instance fields. Empty
+     * when the class cannot be loaded.
+     */
+    public List<Field> membersOf(String fqcn) {
+        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return cache.computeIfAbsent(fqcn, this::reflectMembers);
+    }
+
+    /**
+     * Returns the fully-qualified names of {@code fqcn}'s direct supertypes —
+     * its superclass (omitting {@code java.lang.Object}) followed by its
+     * directly-implemented interfaces. Loaded without running static
+     * initializers; empty when the class can't be loaded. Used by type
+     * hierarchy to walk classpath ancestry one level at a time.
+     */
+    public List<String> supertypesOf(String fqcn) {
+        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Class<?> clazz;
+        try {
+            clazz = Class.forName(fqcn, false, loader);
+        } catch (Throwable t) {
+            return Collections.emptyList();
+        }
+        try {
+            List<String> out = new ArrayList<>();
+            Class<?> superclass = clazz.getSuperclass();
+            if (superclass != null && superclass != Object.class) {
+                out.add(superclass.getName());
+            }
+            for (Class<?> iface : clazz.getInterfaces()) {
+                out.add(iface.getName());
+            }
+            return Collections.unmodifiableList(out);
+        } catch (Throwable t) {
+            logger.log(Level.FINE, "Failed to reflect supertypes of " + fqcn, t);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * The names addressable as {@code Type.X} on {@code fqcn}: public fields
+     * (including enum constants and static fields, inherited included) and
+     * public nested-type simple names. Returns {@code null} when the class
+     * cannot be loaded — letting callers distinguish "no such member" (a real
+     * typo) from "couldn't verify" (classpath gap), so they only flag the
+     * former. Loaded without running static initializers.
+     */
+    public Set<String> memberNames(String fqcn) {
+        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+            return null;
+        }
+        Class<?> clazz;
+        try {
+            clazz = Class.forName(fqcn, false, loader);
+        } catch (Throwable t) {
+            return null;
+        }
+        try {
+            Set<String> names = new LinkedHashSet<>();
+            for (java.lang.reflect.Field f : clazz.getFields()) {
+                names.add(f.getName());
+            }
+            for (Class<?> nested : clazz.getClasses()) {
+                names.add(nested.getSimpleName());
+            }
+            return names;
+        } catch (Throwable t) {
+            logger.log(Level.FINE, "Failed to reflect member names of " + fqcn, t);
+            return null;
+        }
+    }
+
+    private List<Field> reflectMembers(String fqcn) {
+        Class<?> clazz;
+        try {
+            clazz = Class.forName(fqcn, false, loader);
+        } catch (Throwable t) {
+            return Collections.emptyList();
+        }
+        try {
+            Map<String, Field> members = new LinkedHashMap<>();
+            if (clazz.isEnum()) {
+                for (java.lang.reflect.Field f : clazz.getFields()) {
+                    if (f.isEnumConstant()) {
+                        members.put(f.getName(),
+                                new Field(f.getName(), clazz.getSimpleName()));
+                    }
+                }
+            }
+            for (Method m : clazz.getMethods()) {
+                String property = propertyNameOf(m);
+                if (property != null) {
+                    members.putIfAbsent(property,
+                            new Field(property, m.getReturnType().getSimpleName()));
+                }
+            }
+            for (java.lang.reflect.Field f : clazz.getFields()) {
+                if (!Modifier.isStatic(f.getModifiers())) {
+                    members.putIfAbsent(f.getName(),
+                            new Field(f.getName(), f.getType().getSimpleName()));
+                }
+            }
+            return Collections.unmodifiableList(new ArrayList<>(members.values()));
+        } catch (Throwable t) {
+            // LinkageError etc. while reflecting — treat as unknown.
+            logger.log(Level.FINE, "Failed to reflect members of " + fqcn, t);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Maps a public no-arg {@code getX()}/{@code isX()} method to its bean
+     * property name (JavaBeans decapitalize rule), or returns {@code null}
+     * for non-accessors and {@code getClass()}.
+     */
+    private static String propertyNameOf(Method m) {
+        if (m.getParameterCount() != 0 || m.getReturnType() == void.class
+                || Modifier.isStatic(m.getModifiers())) {
+            return null;
+        }
+        String name = m.getName();
+        String raw;
+        if (name.startsWith("get") && name.length() > 3 && Character.isUpperCase(name.charAt(3))) {
+            if ("getClass".equals(name)) {
+                return null;
+            }
+            raw = name.substring(3);
+        } else if (name.startsWith("is") && name.length() > 2 && Character.isUpperCase(name.charAt(2))) {
+            Class<?> returnType = m.getReturnType();
+            if (returnType != boolean.class && returnType != Boolean.class) {
+                return null;
+            }
+            raw = name.substring(2);
+        } else {
+            return null;
+        }
+        if (raw.length() > 1 && Character.isUpperCase(raw.charAt(0)) && Character.isUpperCase(raw.charAt(1))) {
+            return raw;
+        }
+        char[] chars = raw.toCharArray();
+        chars[0] = Character.toLowerCase(chars[0]);
+        return new String(chars);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCodeLensHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCodeLensHelper.java
new file mode 100644
index 00000000000..c6a9b89ae79
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCodeLensHelper.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.lsp4j.CodeLens;
+import org.eclipse.lsp4j.Command;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Code lenses ({@code textDocument/codeLens}) for DRL: a "{@code N references}"
+ * lens above every {@code declare}d type (and declared enum) in the document,
+ * counting its uses across the workspace via {@link DRLReferencesHelper}.
+ *
+ * <p>The lens carries the {@code drools.peekReferences} command — a thin
+ * client-side command (registered by the extension) that converts the LSP
+ * arguments into VS Code types and opens the references peek. The arguments
+ * are {@code [documentUri, declarationPosition, referenceLocations]}; the
+ * counts are computed eagerly here, so no {@code codeLens/resolve} round-trip
+ * is needed.
+ *
+ * <p>Counts exclude the declaration site itself (the LSP {@code includeDeclaration
+ * = false} convention), so "{@code 0 references}" flags an unused type.
+ */
+public final class DRLCodeLensHelper {
+
+    /** Client-side command the lens invokes; registered by the VS Code extension. */
+    public static final String PEEK_REFERENCES_COMMAND = "drools.peekReferences";
+
+    private DRLCodeLensHelper() {
+    }
+
+    /**
+     * Returns one reference-count lens per declared type in {@code text}, or an
+     * empty list. Each declared type's uses are resolved across the current
+     * document and its sibling {@code .drl} files.
+     */
+    public static List<CodeLens> codeLenses(String uri, String text, Map<Path, String> openFiles,
+                                            ClassIndex classIndex, Set<Path> buildOutputDirs) {
+        List<CodeLens> out = new ArrayList<>();
+        if (text == null) {
+            return out;
+        }
+        for (DeclaredType declared : DRLDeclaredTypeParser.parseDeclaredTypes(text)) {
+            if (declared.name == null || declared.name.isEmpty()) {
+                continue;
+            }
+            Position namePos = new Position(declared.nameLine, declared.nameCol);
+            List<Location> refs = DRLReferencesHelper.references(uri, text, namePos, openFiles,
+                    classIndex, buildOutputDirs, false);
+
+            Range range = new Range(namePos,
+                    new Position(declared.nameLine, declared.nameCol + declared.name.length()));
+            String title = refs.size() == 1 ? "1 reference" : refs.size() + " references";
+
+            List<Object> args = new ArrayList<>();
+            args.add(uri);
+            args.add(namePos);
+            args.add(refs);
+            out.add(new CodeLens(range, new Command(title, PEEK_REFERENCES_COMMAND, args), null));
+        }
+        return out;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java
new file mode 100644
index 00000000000..ea393cf3d8b
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.vmware.antlr4c3.CodeCompletionCore;
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.drools.drl.parser.antlr4.DRL10Lexer;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.services.LanguageClient;
+
+import static org.drools.drl.parser.antlr4.DRLParserHelper.computeTokenIndex;
+import static org.drools.drl.parser.antlr4.DRLParserHelper.createDrlParser;
+
+public class DRLCompletionHelper {
+
+    private static final Logger logger = Logger.getLogger(DRLCompletionHelper.class.getName());
+
+    // PREFERRED_RULES is used to filter out the rules that consist of unwanted tokens
+    // additionally, it can be used to customize getCompletionItems behavior
+    private static final Set<Integer> PREFERRED_RULES = Set.of(
+            DRL10Parser.RULE_drlIdentifier,
+            DRL10Parser.RULE_drlQualifiedName,
+            DRL10Parser.RULE_stringId,
+            DRL10Parser.RULE_consequenceBody
+    );
+
+    // The eight Java primitive-type keyword tokens. Mirrors the grammar's
+    // `primitiveType` rule (DRL10Expressions.g4), which shares DRL10Lexer's token
+    // vocab — so these ids match the ones c3 reports against DRL10Parser. ANTLR
+    // exposes no token-set constant for a rule, so the list is restated here.
+    private static final Set<Integer> PRIMITIVE_TYPE_TOKENS = Set.of(
+            DRL10Parser.BOOLEAN, DRL10Parser.CHAR, DRL10Parser.BYTE, DRL10Parser.SHORT,
+            DRL10Parser.INT, DRL10Parser.LONG, DRL10Parser.FLOAT, DRL10Parser.DOUBLE
+    );
+
+    // Inside a pattern constraint, c3 predicts the Java expression starters that
+    // can legally open a constraint: the primitive types above plus new/super and
+    // the big-decimal/-integer literal tokens. They are valid grammar but useless
+    // noise next to the field completions, so they are dropped in constraint
+    // position only — primitive types stay useful elsewhere (e.g. after `global`).
+    private static final Set<Integer> CONSTRAINT_KEYWORD_NOISE = Stream.concat(
+            PRIMITIVE_TYPE_TOKENS.stream(),
+            Stream.of(DRL10Parser.NEW, DRL10Parser.SUPER,
+                    DRL10Parser.DRL_BIG_DECIMAL_LITERAL, DRL10Parser.DRL_BIG_INTEGER_LITERAL)
+    ).collect(Collectors.toUnmodifiableSet());
+
+    private DRLCompletionHelper() {
+    }
+
+    public static List<CompletionItem> getCompletionItems(String text, Position caretPosition, LanguageClient client) {
+        return getCompletionItems(text, caretPosition, client, ClassIndex.empty());
+    }
+
+    public static List<CompletionItem> getCompletionItems(String text, Position caretPosition, LanguageClient client, ClassIndex classIndex) {
+        return getCompletionItems(text, caretPosition, client, classIndex, ClassMemberIndex.empty());
+    }
+
+    public static List<CompletionItem> getCompletionItems(String text, Position caretPosition, LanguageClient client, ClassIndex classIndex, ClassMemberIndex memberIndex) {
+        return getCompletionItems(text, caretPosition, client, classIndex, memberIndex, null);
+    }
+
+    /**
+     * @param documentPath filesystem location of the document, used to find
+     *                     sibling DRL files; {@code null} for non-file
+     *                     documents (sibling declares are then unavailable)
+     */
+    public static List<CompletionItem> getCompletionItems(String text, Position caretPosition, LanguageClient client, ClassIndex classIndex, ClassMemberIndex memberIndex, Path documentPath) {
+        return getCompletionItems(text, caretPosition, client, classIndex, memberIndex, documentPath, Map.of());
+    }
+
+    /**
+     * @param openFiles open unsaved sibling buffers keyed by path, so field
+     *                  completion on sibling-declared types reflects unsaved
+     *                  edits; may be empty
+     */
+    public static List<CompletionItem> getCompletionItems(String text, Position caretPosition, LanguageClient client, ClassIndex classIndex, ClassMemberIndex memberIndex, Path documentPath, Map<Path, String> openFiles) {
+        DRL10Parser drlParser = createDrlParser(text);
+
+        int row = caretPosition == null ? -1 : caretPosition.getLine() + 1; // caret line position is zero based
+        int col = caretPosition == null ? -1 : caretPosition.getCharacter();
+
+        DRL10Parser.CompilationUnitContext compilationUnit = drlParser.compilationUnit();
+        Integer nodeIndex = computeTokenIndex(drlParser, row, col);
+        String prefix = extractPrefix(drlParser, nodeIndex);
+
+        // null when the caret is past the last token; fall back to EOF.
+        int caretTokenIndex = nodeIndex != null
+                ? nodeIndex
+                : drlParser.getInputStream().size() - 1;
+
+        // Right after '(' the matched token is the paren itself, for which c3
+        // yields no candidates; look one token ahead for the constraint, but
+        // keep the paren's index to resolve the pattern type (its span ends at
+        // '(' until the closing ')' is typed).
+        int candidatesIndex = caretTokenIndex;
+        if (caretTokenIndex >= 0 && caretTokenIndex < drlParser.getInputStream().size() - 1
+                && drlParser.getInputStream().get(caretTokenIndex).getType() == DRL10Lexer.LPAREN) {
+            candidatesIndex = caretTokenIndex + 1;
+        }
+
+        return getCompletionItems(drlParser, candidatesIndex, caretTokenIndex, compilationUnit, classIndex, prefix, memberIndex, documentPath, openFiles);
+    }
+
+    static List<CompletionItem> getCompletionItems(DRL10Parser drlParser, int nodeIndex) {
+        return getCompletionItems(drlParser, nodeIndex, nodeIndex, null, ClassIndex.empty(), "", ClassMemberIndex.empty(), null, Map.of());
+    }
+
+    static List<CompletionItem> getCompletionItems(DRL10Parser drlParser, int nodeIndex, int patternTokenIndex, DRL10Parser.CompilationUnitContext compilationUnit, ClassIndex classIndex, String prefix, ClassMemberIndex memberIndex, Path documentPath, Map<Path, String> openFiles) {
+        CodeCompletionCore core = new CodeCompletionCore(drlParser, PREFERRED_RULES, Tokens.IGNORED);
+        CodeCompletionCore.CandidatesCollection candidates = core.collectCandidates(nodeIndex, null);
+
+        if (candidates.rules.containsKey(DRL10Parser.RULE_consequenceBody)) {
+            // in RHS consequence, parser cannot suggest DRL_RHS_END because of island mode approach, so we add it manually
+            candidates.tokens.put(DRL10Lexer.DRL_RHS_END, List.of());
+        }
+
+        boolean constraintPosition = compilationUnit != null && isConstraintPosition(candidates);
+
+        List<CompletionItem> items = candidates.tokens.keySet().stream().filter(Objects::nonNull)
+                .filter(token -> !(constraintPosition && CONSTRAINT_KEYWORD_NOISE.contains(token)))
+                .map(integer -> drlParser.getVocabulary().getDisplayName(integer).replace("'", ""))
+                .map(String::toLowerCase)
+                .map(k -> createCompletionItem(k, CompletionItemKind.Keyword))
+                .collect(Collectors.toList());
+
+        if (compilationUnit != null && classIndex.size() > 0 && isPatternPosition(candidates)) {
+            items.addAll(getClassCompletionItems(compilationUnit, classIndex, prefix));
+        }
+
+        if (constraintPosition) {
+            items.addAll(getFieldCompletionItems(compilationUnit, patternTokenIndex, classIndex, memberIndex, documentPath, openFiles));
+        }
+
+        return items;
+    }
+
+    private static boolean isConstraintPosition(CodeCompletionCore.CandidatesCollection candidates) {
+        List<Integer> path = candidates.rules.get(DRL10Parser.RULE_drlIdentifier);
+        return path != null && path.contains(DRL10Parser.RULE_constraint);
+    }
+
+    /**
+     * Completion items for the fields of the pattern enclosing the caret:
+     * fields of a DRL-declared type (current document first, then sibling
+     * files from the active {@link WorkspaceSiblingResolver}), or bean
+     * properties/fields of a classpath type resolved through imports and the
+     * class index.
+     */
+    private static List<CompletionItem> getFieldCompletionItems(DRL10Parser.CompilationUnitContext compilationUnit,
+                                                                int patternTokenIndex, ClassIndex classIndex,
+                                                                ClassMemberIndex memberIndex, Path documentPath,
+                                                                Map<Path, String> openFiles) {
+        String patternType = findEnclosingPatternTypeName(compilationUnit, patternTokenIndex);
+        if (patternType == null || patternType.isEmpty()) {
+            return List.of();
+        }
+        String simpleName = patternType.substring(patternType.lastIndexOf('.') + 1);
+
+        // DRL-declared types win over classpath types; fields include the
+        // ones inherited through the extends chain. Resolution goes through the
+        // shared workspace index so sibling (and open-buffer) declares resolve.
+        List<DeclaredType> currentDocTypes =
+                DRLDeclaredTypeParser.extractFromCompilationUnit(compilationUnit);
+        Map<String, DeclaredType> typeIndex =
+                DRLWorkspaceTypeIndex.build(currentDocTypes, documentPath, openFiles);
+        DeclaredType declared = typeIndex.get(simpleName);
+        if (declared != null) {
+            return fieldItems(DRLDeclaredTypeParser.fieldsIncludingInherited(declared, typeIndex));
+        }
+
+        String fqcn = resolveFqcn(patternType, simpleName, compilationUnit, classIndex);
+        if (fqcn == null) {
+            return List.of();
+        }
+        return fieldItems(memberIndex.membersOf(fqcn));
+    }
+
+    private static List<CompletionItem> fieldItems(List<Field> fields) {
+        List<CompletionItem> items = new ArrayList<>(fields.size());
+        for (Field field : fields) {
+            CompletionItem item = new CompletionItem();
+            item.setLabel(field.name);
+            item.setInsertText(field.name);
+            item.setDetail(field.type);
+            item.setKind(CompletionItemKind.Field);
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * Resolves a pattern's type name to a fully qualified class name.
+     *
+     * <p>Resolution order:
+     * <ol>
+     *   <li>Already qualified — returned as-is.</li>
+     *   <li>Exact (non-wildcard) import match.</li>
+     *   <li>Wildcard import match verified through the class index.</li>
+     *   <li>Class index match for the simple name (skipped when ambiguous).</li>
+     *   <li>{@code java.lang.*} — implicitly available in DRL without an import,
+     *       resolved via the platform class loader.</li>
+     * </ol>
+     *
+     * Shared with {@link DRLDefinitionHelper}.
+     */
+    static String resolveFqcn(String patternType, String simpleName,
+                              DRL10Parser.CompilationUnitContext compilationUnit,
+                              ClassIndex classIndex) {
+        if (patternType.indexOf('.') >= 0) {
+            return patternType;
+        }
+        Set<String> imports = extractImports(compilationUnit);
+        // 1. Exact import.
+        for (String imported : imports) {
+            if (imported.endsWith("." + simpleName)) {
+                return imported;
+            }
+        }
+        // 2. Wildcard import — verify the package actually provides the type via
+        //    the class index.
+        for (String imported : imports) {
+            if (imported.endsWith(".*")) {
+                String pkg = imported.substring(0, imported.length() - 1); // keep the dot
+                for (String fqcn : classIndex.getMatching(simpleName)) {
+                    if (fqcn.startsWith(pkg)
+                            && (fqcn.endsWith("." + simpleName) || fqcn.equals(simpleName))) {
+                        return fqcn;
+                    }
+                }
+            }
+        }
+        // 3. Class index (any package). An unqualified name with two classpath
+        //    classes sharing a simple name is ambiguous, so it is skipped.
+        Set<String> matches = new HashSet<>();
+        for (String fqcn : classIndex.getMatching(simpleName)) {
+            if (fqcn.endsWith("." + simpleName) || fqcn.equals(simpleName)) {
+                matches.add(fqcn);
+            }
+        }
+        if (matches.size() == 1) {
+            return matches.iterator().next();
+        }
+        if (matches.size() > 1) {
+            logger.log(Level.FINE, () -> "Ambiguous simple name '" + simpleName
+                    + "' matches " + matches + "; skipping field completion");
+            return null;
+        }
+        // 4. java.lang.* — implicitly available in Drools without an import, just
+        //    as in Java source. Loadable via the platform class loader even when
+        //    absent from the project Maven classpath.
+        try {
+            String javaLangFqcn = "java.lang." + simpleName;
+            Class.forName(javaLangFqcn, false, ClassLoader.getPlatformClassLoader());
+            return javaLangFqcn;
+        } catch (ClassNotFoundException ignored) {
+            // Not a java.lang type.
+        }
+        return null;
+    }
+
+    /**
+     * Returns the type name of the deepest {@code lhsPattern} whose token
+     * span contains {@code tokenIndex}, or {@code null} when the caret is
+     * not inside a pattern. Shared with {@link DRLHoverHelper}.
+     */
+    static String findEnclosingPatternTypeName(ParseTree node, int tokenIndex) {
+        if (!(node instanceof ParserRuleContext ctx)) {
+            return null;
+        }
+        Token start = ctx.getStart();
+        Token stop = ctx.getStop();
+        if (start == null || start.getTokenIndex() > tokenIndex
+                || (stop != null && stop.getTokenIndex() < tokenIndex)) {
+            return null;
+        }
+        String best = null;
+        if (ctx instanceof DRL10Parser.LhsPatternContext pattern && pattern.objectType != null) {
+            best = pattern.objectType.getText();
+        }
+        for (int i = 0; i < ctx.getChildCount(); i++) {
+            String deeper = findEnclosingPatternTypeName(ctx.getChild(i), tokenIndex);
+            if (deeper != null) {
+                best = deeper;
+            }
+        }
+        return best;
+    }
+
+    private static boolean isPatternPosition(CodeCompletionCore.CandidatesCollection candidates) {
+        List<Integer> path = candidates.rules.get(DRL10Parser.RULE_drlQualifiedName);
+        if (path == null) {
+            return false;
+        }
+        return path.contains(DRL10Parser.RULE_lhsPattern)
+            || path.contains(DRL10Parser.RULE_lhsPatternBind);
+    }
+
+    private static List<CompletionItem> getClassCompletionItems(DRL10Parser.CompilationUnitContext compilationUnit, ClassIndex classIndex, String prefix) {
+        Set<String> importedFqcns = extractImports(compilationUnit);
+        List<String> matchingFqcns = classIndex.getMatching(prefix);
+        List<CompletionItem> items = new ArrayList<>();
+
+        for (String fqcn : matchingFqcns) {
+            String simpleName = fqcn.substring(fqcn.lastIndexOf('.') + 1);
+            CompletionItem item = new CompletionItem();
+            item.setLabel(simpleName);
+            item.setDetail(fqcn);
+            item.setKind(CompletionItemKind.Class);
+            item.setInsertText(simpleName);
+
+            if (importedFqcns.contains(fqcn)) {
+                item.setSortText("0_" + simpleName + "_" + fqcn);
+            } else {
+                item.setSortText("1_" + simpleName + "_" + fqcn);
+            }
+
+            items.add(item);
+        }
+
+        return items;
+    }
+
+    private static Set<String> extractImports(DRL10Parser.CompilationUnitContext compilationUnit) {
+        Set<String> imports = new HashSet<>();
+        for (DRL10Parser.DrlStatementdefContext stmt : compilationUnit.drlStatementdef()) {
+            if (stmt.importdef() instanceof DRL10Parser.ImportStandardDefContext importDef) {
+                if (importDef.DRL_FUNCTION() == null && importDef.STATIC() == null) {
+                    imports.add(importDef.drlQualifiedName().getText());
+                }
+            }
+        }
+        return imports;
+    }
+
+    private static String extractPrefix(DRL10Parser drlParser, Integer nodeIndex) {
+        if (nodeIndex == null || nodeIndex < 0 || nodeIndex >= drlParser.getInputStream().size()) {
+            return "";
+        }
+        Token token = drlParser.getInputStream().get(nodeIndex);
+        String text = token.getText();
+        if (text != null && !text.isEmpty() && Character.isJavaIdentifierStart(text.charAt(0))) {
+            return text;
+        }
+        return "";
+    }
+
+    static CompletionItem createCompletionItem(String label, CompletionItemKind itemKind) {
+        CompletionItem completionItem;
+        completionItem = new CompletionItem();
+        if (label.startsWith("drl_rhs_")) {
+            // when Lexer uses "DRL_RHS_" keywords in multiple modes with type(),
+            // drlParser.getVocabulary().getDisplayName() returns the keyword name as-is (symbolicNames), so remove the prefix.
+            label = label.substring("drl_rhs_".length());
+        }
+        completionItem.setInsertText(label);
+        completionItem.setLabel(label);
+        completionItem.setKind(itemKind);
+        return completionItem;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java
new file mode 100644
index 00000000000..483f0f10c2d
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Logger;
+
+import org.drools.drl.parser.antlr4.DRL10Parser;
+
+/**
+ * Extracts {@link DeclaredType}s ({@code declare} blocks, including declared
+ * enums) from DRL source using the ANTLR parser, so completion can offer the
+ * fields of types that exist only in DRL and never on the compiled classpath.
+ */
+public final class DRLDeclaredTypeParser {
+
+    private static final Logger logger =
+            Logger.getLogger(DRLDeclaredTypeParser.class.getName());
+
+    private DRLDeclaredTypeParser() {
+    }
+
+    private static final class CachedEntry {
+        final long modMillis;
+        final List<DeclaredType> types;
+
+        CachedEntry(long modMillis, List<DeclaredType> types) {
+            this.modMillis = modMillis;
+            this.types = types;
+        }
+    }
+
+    /** Per-file cache keyed by normalized absolute path, valid by mtime. */
+    private static final Map<Path, CachedEntry> FILE_CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * Drops all cached parse results. Entries are already mtime-validated, so
+     * this is about bounding memory in a long-running server rather than
+     * correctness; call it when the workspace classpath is rebuilt or on
+     * shutdown.
+     */
+    public static void clearCache() {
+        FILE_CACHE.clear();
+    }
+
+    /**
+     * Parses declared types from a file, serving a cached result while the
+     * file's modification time is unchanged. Missing/unreadable files yield
+     * an empty list.
+     */
+    public static List<DeclaredType> parseDeclaredTypesCached(Path file) {
+        if (file == null || !Files.isRegularFile(file)) {
+            return Collections.emptyList();
+        }
+        try {
+            Path key = file.toAbsolutePath().normalize();
+            long modMillis = Files.getLastModifiedTime(file).toMillis();
+            CachedEntry cached = FILE_CACHE.get(key);
+            if (cached != null && cached.modMillis == modMillis) {
+                return cached.types;
+            }
+            List<DeclaredType> types =
+                    Collections.unmodifiableList(parseDeclaredTypes(Files.readString(file)));
+            FILE_CACHE.put(key, new CachedEntry(modMillis, types));
+            return types;
+        } catch (Exception e) {
+            logger.fine(() -> "Failed to read/parse " + file + ": " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Parses all {@code declare} blocks in {@code text}. Parser errors are
+     * ignored so partial files still yield partial results.
+     */
+    static List<DeclaredType> parseDeclaredTypes(String text) {
+        try {
+            DRL10Parser.CompilationUnitContext cu = DRLParsers.silent(text).compilationUnit();
+            return cu == null ? new ArrayList<>() : extractFromCompilationUnit(cu);
+        } catch (Exception e) {
+            logger.fine(() -> "Failed to parse DRL for declared types: " + e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * Returns the type's own fields followed by the fields inherited through
+     * its {@code extends} chain, resolving parents through {@code index} (a
+     * {@code name -> DeclaredType} map, typically from
+     * {@link DRLWorkspaceTypeIndex#build}). Cycle- and depth-guarded.
+     */
+    static List<Field> fieldsIncludingInherited(DeclaredType type,
+                                                Map<String, DeclaredType> index) {
+        List<Field> out = new ArrayList<>(type.fields);
+        Set<String> seen = new HashSet<>();
+        seen.add(type.name);
+        String parentName = type.extendsName;
+        int depth = 0;
+        while (parentName != null && depth++ < 10 && seen.add(parentName)) {
+            DeclaredType parent = index.get(parentName);
+            if (parent == null) {
+                break;
+            }
+            out.addAll(parent.fields);
+            parentName = parent.extendsName;
+        }
+        return out;
+    }
+
+    /**
+     * Extracts declared types from an already-parsed compilation unit. Use
+     * this overload when the caller already produced the parse tree (e.g.
+     * during completion) to avoid a redundant second parse.
+     */
+    static List<DeclaredType> extractFromCompilationUnit(DRL10Parser.CompilationUnitContext cu) {
+        List<DeclaredType> types = new ArrayList<>();
+        if (cu == null) {
+            return types;
+        }
+        for (DRL10Parser.DrlStatementdefContext stmt : cu.drlStatementdef()) {
+            DRL10Parser.DeclaredefContext decl = stmt.declaredef();
+            if (decl == null) {
+                continue;
+            }
+            try {
+                if (decl.typeDeclaration() != null) {
+                    DeclaredType dt = extractTypeDeclaration(decl.typeDeclaration());
+                    if (dt != null) {
+                        types.add(dt);
+                    }
+                } else if (decl.enumDeclaration() != null) {
+                    DeclaredType dt = extractEnumDeclaration(decl.enumDeclaration());
+                    if (dt != null) {
+                        types.add(dt);
+                    }
+                }
+                // entryPointDeclaration and windowDeclaration are not class types.
+            } catch (Exception e) {
+                logger.fine(() -> "Skipping malformed declare block: " + e.getMessage());
+            }
+        }
+        return types;
+    }
+
+    private static DeclaredType extractTypeDeclaration(DRL10Parser.TypeDeclarationContext ctx) {
+        if (ctx == null || ctx.name == null) {
+            return null;
+        }
+        String name = ctx.name.getText();
+        int nameLine = ctx.name.getStart() != null ? ctx.name.getStart().getLine() - 1 : 0;
+        int nameCol = ctx.name.getStart() != null ? ctx.name.getStart().getCharPositionInLine() : 0;
+        List<Field> fields = extractFields(ctx.field());
+        String extendsName = null;
+        if (ctx.superTypes != null && !ctx.superTypes.isEmpty()) {
+            String raw = ctx.superTypes.get(0).getText();
+            int dot = raw == null ? -1 : raw.lastIndexOf('.');
+            extendsName = (raw != null && dot >= 0) ? raw.substring(dot + 1) : raw;
+        }
+        return new DeclaredType(name, fields, false, nameLine, nameCol, extendsName);
+    }
+
+    private static DeclaredType extractEnumDeclaration(DRL10Parser.EnumDeclarationContext ctx) {
+        if (ctx == null || ctx.name == null) {
+            return null;
+        }
+        String name = ctx.name.getText();
+        int nameLine = ctx.name.getStart() != null ? ctx.name.getStart().getLine() - 1 : 0;
+        int nameCol = ctx.name.getStart() != null ? ctx.name.getStart().getCharPositionInLine() : 0;
+        List<Field> fields = new ArrayList<>();
+        // Enum constants carry the enum type name as their type.
+        if (ctx.enumeratives() != null) {
+            for (DRL10Parser.EnumerativeContext enumerative : ctx.enumeratives().enumerative()) {
+                if (enumerative.drlIdentifier() != null) {
+                    fields.add(new Field(enumerative.drlIdentifier().getText(), name,
+                                         extractEnumArgs(enumerative)));
+                }
+            }
+        }
+        fields.addAll(extractFields(ctx.field()));
+        return new DeclaredType(name, fields, true, nameLine, nameCol);
+    }
+
+    /**
+     * Returns the comma-separated constructor arguments of an enum constant
+     * ({@code LOW(1, "x")} → {@code 1, "x"}), or {@code null} when the
+     * constant has no argument list.
+     */
+    private static String extractEnumArgs(DRL10Parser.EnumerativeContext ctx) {
+        if (ctx == null || ctx.expression() == null || ctx.expression().isEmpty()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < ctx.expression().size(); i++) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            sb.append(ctx.expression(i).getText());
+        }
+        return sb.toString();
+    }
+
+    private static List<Field> extractFields(List<DRL10Parser.FieldContext> fieldCtxs) {
+        List<Field> fields = new ArrayList<>();
+        if (fieldCtxs == null) {
+            return fields;
+        }
+        for (DRL10Parser.FieldContext field : fieldCtxs) {
+            try {
+                if (field.label() == null || field.type() == null) {
+                    continue;
+                }
+                // label().getText() returns "name:" — strip the trailing colon.
+                String rawLabel = field.label().getText();
+                String fieldName = rawLabel.endsWith(":")
+                        ? rawLabel.substring(0, rawLabel.length() - 1).trim()
+                        : rawLabel.trim();
+                String fieldType = field.type().getText();
+                if (!fieldName.isEmpty() && !fieldType.isEmpty()) {
+                    fields.add(new Field(fieldName, fieldType));
+                }
+            } catch (Exception e) {
+                logger.fine(() -> "Skipping malformed field: " + e.getMessage());
+            }
+        }
+        return fields;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java
new file mode 100644
index 00000000000..12552be3ec3
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Lexer;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Go-to-definition for type names in DRL documents.
+ *
+ * <p>Resolution order — DRL first, then Java:
+ * <ol>
+ *   <li>{@code declare} blocks in the current document;</li>
+ *   <li>{@code declare} blocks in sibling DRL files from the active
+ *       {@link WorkspaceSiblingResolver} (same-directory by default;
+ *       non-file documents have no siblings);</li>
+ *   <li>project Java sources located by Maven convention: when the resolved
+ *       classpath contains {@code <module>/target/classes/pkg/Type.class},
+ *       the definition is {@code <module>/src/main/java/pkg/Type.java} if
+ *       that file exists. JAR classes have no navigable source.</li>
+ * </ol>
+ */
+public final class DRLDefinitionHelper {
+
+    private static final Logger logger =
+            Logger.getLogger(DRLDefinitionHelper.class.getName());
+
+    private DRLDefinitionHelper() {
+    }
+
+    /**
+     * Returns the definition locations for the identifier at
+     * {@code position}, or an empty list when it doesn't name a resolvable
+     * type.
+     *
+     * @param buildOutputDirs build-output directories from the resolved
+     *                        classpath, used for the Maven source-mapping
+     */
+    public static List<Location> findDefinitions(String uri, String text, Position position,
+                                                 ClassIndex classIndex, Set<Path> buildOutputDirs) {
+        return findDefinitions(uri, text, position, classIndex, buildOutputDirs, Map.of());
+    }
+
+    /**
+     * @param openFiles open unsaved sibling buffers keyed by path, so a
+     *                  definition in an unsaved sibling resolves to its current
+     *                  (edited) location; may be empty
+     */
+    public static List<Location> findDefinitions(String uri, String text, Position position,
+                                                 ClassIndex classIndex, Set<Path> buildOutputDirs,
+                                                 Map<Path, String> openFiles) {
+        if (text == null || position == null) {
+            return List.of();
+        }
+        String word = wordAt(text, position);
+        if (word.isEmpty() || !Character.isJavaIdentifierStart(word.charAt(0))) {
+            return List.of();
+        }
+        // Parse the current document once; the steps below reuse this parse.
+        ParsedDrl parsed = ParsedDrl.of(text);
+        if (caretInCommentOrString(parsed, position)) {
+            return List.of();
+        }
+
+        // 1. declare blocks in this document.
+        for (DeclaredType declared : parsed.declaredTypes()) {
+            if (word.equals(declared.name)) {
+                return List.of(new Location(uri, nameRange(declared, word)));
+            }
+        }
+
+        // 2. declare blocks in sibling DRL files (open buffers shadow disk).
+        Location sibling = findSiblingDefinition(word, toPath(uri), openFiles);
+        if (sibling != null) {
+            return List.of(sibling);
+        }
+
+        // 3. Java sources by Maven convention.
+        String fqcn = resolveFqcn(parsed, word, classIndex);
+        if (fqcn == null) {
+            return List.of();
+        }
+        JavaSourceLocator.Result javaSource = JavaSourceLocator.locate(fqcn, buildOutputDirs);
+        return javaSource == null ? List.of() : List.of(javaSource.location);
+    }
+
+    /**
+     * Finds {@code word}'s declaration among sibling files via the shared
+     * {@link DRLWorkspaceTypeIndex} walk (open unsaved buffers shadow disk),
+     * returning its {@link Location} or {@code null}. First match wins.
+     */
+    private static Location findSiblingDefinition(String word, Path documentPath,
+                                                  Map<Path, String> openFiles) {
+        if (documentPath == null) {
+            return null;
+        }
+        Location[] hit = {null};
+        DRLWorkspaceTypeIndex.forEachSiblingType(documentPath, openFiles, (declared, fileUri) -> {
+            if (hit[0] == null && word.equals(declared.name)) {
+                hit[0] = new Location(fileUri, nameRange(declared, word));
+            }
+        });
+        return hit[0];
+    }
+
+    private static Range nameRange(DeclaredType declared, String word) {
+        return new Range(new Position(declared.nameLine, declared.nameCol),
+                         new Position(declared.nameLine, declared.nameCol + word.length()));
+    }
+
+    /** Converts a document URI to a filesystem path, or null for non-file URIs. */
+    private static Path toPath(String uri) {
+        try {
+            return Path.of(java.net.URI.create(uri));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** Resolves {@code word} to an FQCN from {@code text}'s imports + class index. Shared with type hierarchy. */
+    static String resolveFqcn(String text, String word, ClassIndex classIndex) {
+        try {
+            return resolveFqcn(ParsedDrl.of(text), word, classIndex);
+        } catch (Exception e) {
+            logger.fine(() -> "FQCN resolution failed for " + word + ": " + e.getMessage());
+            return null;
+        }
+    }
+
+    /** As {@link #resolveFqcn(String, String, ClassIndex)}, reusing an existing parse of the document. */
+    static String resolveFqcn(ParsedDrl parsed, String word, ClassIndex classIndex) {
+        try {
+            return DRLCompletionHelper.resolveFqcn(word, word, parsed.compilationUnit, classIndex);
+        } catch (Exception e) {
+            logger.fine(() -> "FQCN resolution failed for " + word + ": " + e.getMessage());
+            return null;
+        }
+    }
+
+    /** Expands the identifier ({@code [A-Za-z0-9_$]+}) around the caret. */
+    static String wordAt(String text, Position position) {
+        String[] lines = text.split("\r?\n", -1);
+        if (position.getLine() < 0 || position.getLine() >= lines.length) {
+            return "";
+        }
+        String line = lines[position.getLine()];
+        int col = Math.min(Math.max(position.getCharacter(), 0), line.length());
+
+        int start = col;
+        while (start > 0 && isIdentifierChar(line.charAt(start - 1))) {
+            start--;
+        }
+        int end = col;
+        while (end < line.length() && isIdentifierChar(line.charAt(end))) {
+            end++;
+        }
+        return start < end ? line.substring(start, end) : "";
+    }
+
+    /**
+     * The range of the identifier ({@code [A-Za-z0-9_$]+}) around the caret, or
+     * {@code null} when the caret is not on one. Same expansion as
+     * {@link #wordAt}; used by rename to mark the editable span. Shared with
+     * {@link DRLRenameHelper}.
+     */
+    static Range wordRangeAt(String text, Position position) {
+        if (text == null || position == null) {
+            return null;
+        }
+        String[] lines = text.split("\r?\n", -1);
+        if (position.getLine() < 0 || position.getLine() >= lines.length) {
+            return null;
+        }
+        String line = lines[position.getLine()];
+        int col = Math.min(Math.max(position.getCharacter(), 0), line.length());
+
+        int start = col;
+        while (start > 0 && isIdentifierChar(line.charAt(start - 1))) {
+            start--;
+        }
+        int end = col;
+        while (end < line.length() && isIdentifierChar(line.charAt(end))) {
+            end++;
+        }
+        if (start >= end) {
+            return null;
+        }
+        return new Range(new Position(position.getLine(), start), new Position(position.getLine(), end));
+    }
+
+    private static boolean isIdentifierChar(char c) {
+        return Character.isLetterOrDigit(c) || c == '_' || c == '$';
+    }
+
+    /**
+     * True when the caret sits inside a comment or string-literal token, where an
+     * identifier-looking word is not an actual symbol occurrence. Shared by
+     * go-to-definition, find-references, and rename so they don't act on a name
+     * that merely appears in prose. Reuses the document's already-built token
+     * stream — no extra parse or lex. Best-effort: a caret that doesn't resolve
+     * to a token is treated as code, returning {@code false}.
+     */
+    static boolean caretInCommentOrString(ParsedDrl parsed, Position position) {
+        if (parsed == null || position == null) {
+            return false;
+        }
+        Token token = parsed.tokenAt(position);
+        return token != null && isCommentOrString(token.getType());
+    }
+
+    /** Lexer token types carrying comment or string-literal text, on both the LHS and the RHS consequence. */
+    private static boolean isCommentOrString(int tokenType) {
+        switch (tokenType) {
+            case DRL10Lexer.COMMENT:
+            case DRL10Lexer.LINE_COMMENT:
+            case DRL10Lexer.STRING_LITERAL:
+            case DRL10Lexer.DRL_STRING_LITERAL:
+            case DRL10Lexer.TEXT_BLOCK:
+            case DRL10Lexer.CHAR_LITERAL:
+            case DRL10Lexer.RHS_COMMENT:
+            case DRL10Lexer.RHS_LINE_COMMENT:
+            case DRL10Lexer.RHS_STRING_LITERAL:
+                return true;
+            default:
+                return false;
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
new file mode 100644
index 00000000000..a2d675d486a
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.Lexer;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.drools.drl.parser.antlr4.DRLParserHelper;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Validates DRL text with the ANTLR parser and converts lexer and parser
+ * errors into LSP {@link Diagnostic}s, so editors can surface syntax
+ * problems as the user types.
+ */
+public final class DRLDiagnosticHelper {
+
+    private DRLDiagnosticHelper() {
+    }
+
+    /** A single parse: the compilation unit and the syntax errors found building it. */
+    public static final class Parsed {
+        /** The parse tree, or {@code null} for blank input. */
+        public final DRL10Parser.CompilationUnitContext compilationUnit;
+        /** One diagnostic per syntax error, in document order. */
+        public final List<Diagnostic> diagnostics;
+
+        Parsed(DRL10Parser.CompilationUnitContext compilationUnit, List<Diagnostic> diagnostics) {
+            this.compilationUnit = compilationUnit;
+            this.diagnostics = diagnostics;
+        }
+    }
+
+    /**
+     * Parses {@code text} once, returning both the compilation unit and the
+     * syntax diagnostics. Callers that also need the tree (e.g. the unknown-type
+     * lint) can reuse it instead of re-parsing.
+     */
+    public static Parsed parse(String text) {
+        if (text == null || text.isEmpty()) {
+            return new Parsed(null, Collections.emptyList());
+        }
+        DRL10Parser parser = DRLParserHelper.createDrlParser(text);
+        List<Diagnostic> diagnostics = new ArrayList<>();
+        CollectingErrorListener listener = new CollectingErrorListener(diagnostics);
+
+        // Safe today because DRLParserHelper builds an unfilled
+        // CommonTokenStream directly over the lexer; lexer errors emitted
+        // during lazy tokenization therefore reach the listener attached
+        // below. If the helper ever pre-fills the stream, lexer errors would
+        // be lost — attach the listener before tokenization in that case.
+        Lexer lexer = (Lexer) parser.getTokenStream().getTokenSource();
+        lexer.removeErrorListeners();
+        lexer.addErrorListener(listener);
+
+        parser.removeErrorListeners();
+        parser.addErrorListener(listener);
+
+        return new Parsed(parser.compilationUnit(), diagnostics);
+    }
+
+    /**
+     * Parses {@code text} and returns one {@link Diagnostic} per syntax
+     * error, in document order. Returns an empty list for {@code null},
+     * empty, or syntactically clean input.
+     */
+    public static List<Diagnostic> validate(String text) {
+        return parse(text).diagnostics;
+    }
+
+    private static class CollectingErrorListener extends BaseErrorListener {
+
+        private final List<Diagnostic> diagnostics;
+
+        CollectingErrorListener(List<Diagnostic> diagnostics) {
+            this.diagnostics = diagnostics;
+        }
+
+        @Override
+        public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
+                                int line, int charPositionInLine, String msg,
+                                RecognitionException e) {
+            int startCol = charPositionInLine;
+            int endCol = charPositionInLine + 1;
+            if (offendingSymbol instanceof Token) {
+                Token token = (Token) offendingSymbol;
+                if (token.getType() == Token.EOF) {
+                    // EOF has no extent and its position is one past the last
+                    // char; getText() is the 5-char placeholder "<EOF>".
+                    // Anchor the range on the last real character (zero-width
+                    // at column 0) so it never points past the text.
+                    startCol = Math.max(0, charPositionInLine - 1);
+                    endCol = charPositionInLine;
+                } else {
+                    // Widen the range to the offending token, but only when
+                    // its text is usable: getText() may be null for detached
+                    // tokens, and a token spanning lines would yield an end
+                    // column computed on the wrong line — those fall back to
+                    // the 1-char caret.
+                    String tokenText = token.getText();
+                    if (tokenText != null && !tokenText.isEmpty()
+                            && tokenText.indexOf('\n') < 0 && tokenText.indexOf('\r') < 0) {
+                        endCol = charPositionInLine + tokenText.length();
+                    }
+                }
+            }
+            Diagnostic d = new Diagnostic();
+            d.setRange(new Range(new Position(line - 1, startCol),
+                                 new Position(line - 1, endCol)));
+            d.setSeverity(DiagnosticSeverity.Error);
+            d.setSource("drools-parser");
+            d.setMessage(msg);
+            diagnostics.add(d);
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocCommentParser.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocCommentParser.java
new file mode 100644
index 00000000000..96a0dc427ee
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocCommentParser.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Extracts Javadoc-style {@code /** ... *}{@code /} block comments that
+ * immediately precede top-level Drools declarations and maps each comment to
+ * the declaration's name.
+ *
+ * <p>Supported declarations:
+ * <ul>
+ *   <li>{@code declare X}  (and {@code declare X extends Y})</li>
+ *   <li>{@code function Type name(...)}</li>
+ *   <li>{@code query "Name"} or {@code query name}</li>
+ *   <li>{@code global Type name}</li>
+ * </ul>
+ *
+ * <p>Doc bodies have their leading {@code *} markers and surrounding
+ * whitespace stripped, then trimmed. The result is suitable for rendering as
+ * markdown in a hover tooltip.
+ *
+ * <p>Doc comments NOT immediately preceding a supported declaration are
+ * ignored. Whitespace and line-comments are allowed between the doc block
+ * and the declaration, but other block comments or code are not.
+ */
+public final class DRLDocCommentParser {
+
+    /**
+     * Matches a single Javadoc-style block: opens with exactly two stars
+     * ({@code /} then {@code *}{@code *}) followed by a non-star character.
+     * Lazy across the body so we stop at the first {@code *}{@code /}; the
+     * "is it followed by a declaration?" check is done separately in
+     * {@link #parseDocs(String)}.
+     */
+    private static final Pattern DOC_BLOCK =
+            Pattern.compile("(?s)/\\*\\*(?!\\*)(.*?)\\*/");
+
+    /**
+     * Anchored at the start of input — matches whitespace/{@code //} comments
+     * followed by one of the supported declaration headers.
+     *
+     * <p>Capture groups:
+     * <ol>
+     *   <li>declare name</li>
+     *   <li>function name</li>
+     *   <li>query name (quoted)</li>
+     *   <li>query name (bare)</li>
+     *   <li>global name</li>
+     * </ol>
+     */
+    private static final Pattern DECL_AFTER_DOC = Pattern.compile(
+            "(?s)\\A(?:\\s*//[^\\n]*\\n|\\s)*"
+            + "(?:"
+            +   "declare\\s+(?:enum\\s+)?(\\w+)"
+            +   "|function\\s+\\S+\\s+(\\w+)\\s*\\("
+            +   "|query\\s+(?:\"([^\"]+)\"|(\\w+))"
+            +   "|global\\s+\\S+\\s+(\\w+)"
+            + ")");
+
+    private DRLDocCommentParser() {
+    }
+
+    /**
+     * Parses {@code text} and returns a {@code declarationName -> docBody}
+     * map. Returns an empty map if {@code text} is {@code null} or empty, or
+     * contains no doc-attached declarations.
+     *
+     * <p>A doc block is only associated with a declaration when the
+     * declaration appears immediately after the {@code *}{@code /} (allowing
+     * only whitespace and {@code //} line comments between).
+     */
+    public static Map<String, String> parseDocs(String text) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<String, String> docs = new HashMap<>();
+        Matcher docMatcher = DOC_BLOCK.matcher(text);
+        while (docMatcher.find()) {
+            String body = stripDocStars(docMatcher.group(1));
+            if (body.isEmpty()) {
+                continue;
+            }
+            Matcher declMatcher = DECL_AFTER_DOC.matcher(text.substring(docMatcher.end()));
+            if (!declMatcher.find()) {
+                continue;
+            }
+            String name = firstNonNull(declMatcher.group(1), declMatcher.group(2),
+                                       declMatcher.group(3), declMatcher.group(4),
+                                       declMatcher.group(5));
+            if (name != null && !name.isEmpty()) {
+                docs.putIfAbsent(name, body);
+            }
+        }
+        return docs;
+    }
+
+    /**
+     * Returns the doc body for a named declaration in {@code text}, or
+     * {@code null} if there isn't one. Convenience wrapper around
+     * {@link #parseDocs(String)} for callers that only need one lookup.
+     */
+    public static String docFor(String text, String name) {
+        if (text == null || name == null || name.isEmpty()) {
+            return null;
+        }
+        return parseDocs(text).get(name);
+    }
+
+    /**
+     * Strips the leading {@code *} on each line of a doc body, trims trailing
+     * whitespace, and collapses to a tidy markdown-ready string.
+     */
+    static String stripDocStars(String raw) {
+        if (raw == null) {
+            return "";
+        }
+        String[] lines = raw.split("\\r?\\n", -1);
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < lines.length; i++) {
+            // Strip a single leading "*" with optional surrounding whitespace.
+            String stripped = lines[i].replaceFirst("^\\s*\\*\\s?", "");
+            // Right-trim only; preserve leading indentation that survived the * strip.
+            int end = stripped.length();
+            while (end > 0 && Character.isWhitespace(stripped.charAt(end - 1))) {
+                end--;
+            }
+            sb.append(stripped, 0, end);
+            if (i < lines.length - 1) {
+                sb.append('\n');
+            }
+        }
+        return sb.toString().strip();
+    }
+
+    private static String firstNonNull(String... candidates) {
+        for (String c : candidates) {
+            if (c != null) {
+                return c;
+            }
+        }
+        return null;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocFormatter.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocFormatter.java
new file mode 100644
index 00000000000..bd862e6bbdd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocFormatter.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Map;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Expands Javadoc-style inline tags in a doc-comment body into markdown
+ * suitable for an LSP hover {@code MarkupContent}.
+ *
+ * <p>Supported inline tags:
+ * <ul>
+ *   <li>{@code {@code text}} → {@code `text`} (inline code)</li>
+ *   <li>{@code {@literal text}} → the text with markdown formatting
+ *       characters escaped</li>
+ *   <li>{@code {@link Name}} / {@code {@link Name Label}} → a markdown link
+ *       when {@code Name} resolves via the supplied {@code linkTargets},
+ *       otherwise inline code; {@code {@linkplain ...}} is identical except
+ *       the unresolved fallback is plain text.</li>
+ * </ul>
+ *
+ * Anything else inside {@code {@...}} is left untouched.
+ */
+public final class DRLDocFormatter {
+
+    /** {@code {@code text}} — captures the inner text. */
+    private static final Pattern INLINE_CODE =
+            Pattern.compile("\\{@code\\s+([^}]*)\\}");
+
+    /** {@code {@literal text}} — captures the inner text. */
+    private static final Pattern INLINE_LITERAL =
+            Pattern.compile("\\{@literal\\s+([^}]*)\\}");
+
+    /**
+     * {@code {@link Name}}, {@code {@link Name Label text}}, or
+     * {@code {@linkplain ...}}: group(1) is {@code "plain"} for
+     * {@code @linkplain}, group(2) the reference (may include
+     * {@code #member}), group(3) the optional label.
+     */
+    private static final Pattern INLINE_LINK = Pattern.compile(
+            "\\{@link(plain)?\\s+([^}\\s]+)(?:\\s+([^}]+))?\\}");
+
+    private DRLDocFormatter() {
+    }
+
+    /**
+     * Formats {@code body}, expanding all recognised inline tags.
+     *
+     * @param body        the doc body (post-{@code *}-stripping), or null
+     * @param linkTargets {@code typeName -> markdown link href}; references
+     *                    not in the map render as fallback text (code for
+     *                    {@code @link}, plain for {@code @linkplain}). May be
+     *                    null or empty.
+     */
+    public static String format(String body, Map<String, String> linkTargets) {
+        if (body == null || body.isEmpty()) {
+            return body;
+        }
+        String out = replaceWith(body, INLINE_CODE, m -> "`" + m.group(1) + "`");
+        out = replaceWith(out, INLINE_LITERAL, m -> escapeMarkdown(m.group(1)));
+        out = replaceWith(out, INLINE_LINK, m -> renderLink(m, linkTargets));
+        return out;
+    }
+
+    private static String renderLink(Matcher m, Map<String, String> linkTargets) {
+        boolean plain = m.group(1) != null;
+        String ref = m.group(2);
+        String label = m.group(3);
+        if (label == null || label.isBlank()) {
+            label = ref;
+        }
+
+        // Strip "#member" for the lookup; the full reference stays visible.
+        String typeKey = ref;
+        int hash = typeKey.indexOf('#');
+        if (hash >= 0) {
+            typeKey = typeKey.substring(0, hash);
+        }
+
+        String href = linkTargets == null ? null : linkTargets.get(typeKey);
+        if (href != null && !href.isEmpty()) {
+            return "[" + label + "](" + href + ")";
+        }
+        return plain ? label : ("`" + label + "`");
+    }
+
+    /**
+     * Escapes characters that would otherwise be interpreted as markdown
+     * formatting, matching Javadoc's {@code @literal} semantics.
+     */
+    private static String escapeMarkdown(String s) {
+        if (s == null) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder(s.length() + 8);
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == '\\' || c == '`' || c == '*' || c == '_' || c == '{'
+                    || c == '}' || c == '[' || c == ']' || c == '<' || c == '>'
+                    || c == '#' || c == '+' || c == '-' || c == '.' || c == '!') {
+                sb.append('\\');
+            }
+            sb.append(c);
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Replaces every match of {@code p} in {@code input} with the result of
+     * applying {@code mapper} to that match, with full literal-replacement
+     * semantics so dollar signs and backslashes in the replacement don't
+     * surprise the caller.
+     */
+    private static String replaceWith(String input, Pattern p,
+                                      Function<Matcher, String> mapper) {
+        Matcher m = p.matcher(input);
+        StringBuilder sb = new StringBuilder(input.length());
+        int last = 0;
+        while (m.find()) {
+            sb.append(input, last, m.start());
+            sb.append(mapper.apply(m));
+            last = m.end();
+        }
+        sb.append(input, last, input.length());
+        return sb.toString();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocumentSymbolHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocumentSymbolHelper.java
new file mode 100644
index 00000000000..ffd38c879b1
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDocumentSymbolHelper.java
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolKind;
+
+/**
+ * Produces the document outline ({@code textDocument/documentSymbol}) for a DRL
+ * file: a flat list of top-level constructs, with {@code declare} types nesting
+ * their fields (and {@code declare enum} types their constants).
+ *
+ * <ul>
+ *   <li>{@code rule "X"} → {@link SymbolKind#Method}</li>
+ *   <li>{@code query "X"} → {@link SymbolKind#Method}</li>
+ *   <li>{@code function T f(...)} → {@link SymbolKind#Function}</li>
+ *   <li>{@code global T name} → {@link SymbolKind#Variable}</li>
+ *   <li>{@code declare X} → {@link SymbolKind#Class} (fields as {@link SymbolKind#Field})</li>
+ *   <li>{@code declare enum X} → {@link SymbolKind#Enum} (constants as {@link SymbolKind#EnumMember})</li>
+ * </ul>
+ *
+ * <p>Ranges come from the ANTLR parse tree, so they're exact regardless of
+ * formatting. Parser errors are swallowed — a partial file still outlines its
+ * well-formed constructs.
+ */
+public final class DRLDocumentSymbolHelper {
+
+    private static final Logger logger = Logger.getLogger(DRLDocumentSymbolHelper.class.getName());
+
+    private DRLDocumentSymbolHelper() {
+    }
+
+    /** Returns the outline symbols for {@code text}, or an empty list. */
+    public static List<DocumentSymbol> symbols(String text) {
+        List<DocumentSymbol> out = new ArrayList<>();
+        if (text == null || text.isBlank()) {
+            return out;
+        }
+        try {
+            DRL10Parser parser = DRLParsers.silent(text);
+            DRL10Parser.CompilationUnitContext cu = parser.compilationUnit();
+            if (cu == null) {
+                return out;
+            }
+            for (DRL10Parser.DrlStatementdefContext stmt : cu.drlStatementdef()) {
+                try {
+                    DocumentSymbol sym = toSymbol(stmt);
+                    if (sym != null) {
+                        out.add(sym);
+                    }
+                } catch (Exception e) {
+                    logger.fine(() -> "Skipping malformed statement: " + e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            logger.fine(() -> "Failed to parse DRL for document symbols: " + e.getMessage());
+        }
+        return out;
+    }
+
+    private static DocumentSymbol toSymbol(DRL10Parser.DrlStatementdefContext stmt) {
+        if (stmt.ruledef() != null) {
+            DRL10Parser.RuledefContext r = stmt.ruledef();
+            return symbol(displayName(r.name), SymbolKind.Method, r, r.name);
+        }
+        if (stmt.querydef() != null) {
+            DRL10Parser.QuerydefContext q = stmt.querydef();
+            return symbol(displayName(q.name), SymbolKind.Method, q, q.name);
+        }
+        if (stmt.functiondef() != null) {
+            DRL10Parser.FunctiondefContext f = stmt.functiondef();
+            DocumentSymbol s = symbol(text(f.drlIdentifier()), SymbolKind.Function, f, f.drlIdentifier());
+            if (s != null && f.typeTypeOrVoid() != null) {
+                s.setDetail(f.typeTypeOrVoid().getText());
+            }
+            return s;
+        }
+        if (stmt.globaldef() != null) {
+            DRL10Parser.GlobaldefContext g = stmt.globaldef();
+            DocumentSymbol s = symbol(text(g.drlIdentifier()), SymbolKind.Variable, g, g.drlIdentifier());
+            if (s != null && g.type() != null) {
+                s.setDetail(g.type().getText());
+            }
+            return s;
+        }
+        if (stmt.declaredef() != null) {
+            return declareSymbol(stmt.declaredef());
+        }
+        return null;
+    }
+
+    private static DocumentSymbol declareSymbol(DRL10Parser.DeclaredefContext decl) {
+        if (decl.typeDeclaration() != null) {
+            DRL10Parser.TypeDeclarationContext t = decl.typeDeclaration();
+            // The whole "declare ... end" block is the symbol range; the type name
+            // is the selection range.
+            DocumentSymbol s = symbol(text(t.name), SymbolKind.Class, decl, t.name);
+            if (s == null) {
+                return null;
+            }
+            List<DocumentSymbol> children = new ArrayList<>();
+            for (DRL10Parser.FieldContext field : t.field()) {
+                addIfPresent(children, fieldSymbol(field));
+            }
+            s.setChildren(children);
+            return s;
+        }
+        if (decl.enumDeclaration() != null) {
+            DRL10Parser.EnumDeclarationContext en = decl.enumDeclaration();
+            DocumentSymbol s = symbol(text(en.name), SymbolKind.Enum, decl, en.name);
+            if (s == null) {
+                return null;
+            }
+            List<DocumentSymbol> children = new ArrayList<>();
+            if (en.enumeratives() != null) {
+                for (DRL10Parser.EnumerativeContext e : en.enumeratives().enumerative()) {
+                    if (e.drlIdentifier() != null) {
+                        addIfPresent(children,
+                                symbol(text(e.drlIdentifier()), SymbolKind.EnumMember, e, e.drlIdentifier()));
+                    }
+                }
+            }
+            for (DRL10Parser.FieldContext field : en.field()) {
+                addIfPresent(children, fieldSymbol(field));
+            }
+            s.setChildren(children);
+            return s;
+        }
+        // entryPoint / window declarations are not class types — not outlined.
+        return null;
+    }
+
+    private static DocumentSymbol fieldSymbol(DRL10Parser.FieldContext field) {
+        if (field.label() == null) {
+            return null;
+        }
+        // label().getText() returns "name:" — strip the trailing colon.
+        String raw = field.label().getText();
+        String name = raw.endsWith(":") ? raw.substring(0, raw.length() - 1).trim() : raw.trim();
+        DocumentSymbol s = symbol(name, SymbolKind.Field, field, field.label());
+        if (s != null && field.type() != null) {
+            s.setDetail(field.type().getText());
+        }
+        return s;
+    }
+
+    /**
+     * Builds a symbol from a name, a kind, the context spanning the whole
+     * construct ({@code range}), and the context of the name token
+     * ({@code selectionRange}). Returns {@code null} when the name is empty or
+     * positions can't be derived.
+     */
+    private static DocumentSymbol symbol(String name, SymbolKind kind,
+                                         ParserRuleContext rangeCtx, ParserRuleContext selectionCtx) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        Range range = rangeOf(rangeCtx);
+        Range selection = rangeOf(selectionCtx);
+        if (range == null) {
+            return null;
+        }
+        // LSP requires selectionRange ⊆ range; fall back to the full range otherwise.
+        if (selection == null || !contains(range, selection)) {
+            selection = range;
+        }
+        return new DocumentSymbol(name, kind, range, selection);
+    }
+
+    private static void addIfPresent(List<DocumentSymbol> list, DocumentSymbol symbol) {
+        if (symbol != null) {
+            list.add(symbol);
+        }
+    }
+
+    private static String text(ParserRuleContext ctx) {
+        return ctx == null ? null : ctx.getText();
+    }
+
+    /** Strips surrounding double quotes from a {@code stringId} name for display. */
+    private static String displayName(DRL10Parser.StringIdContext name) {
+        if (name == null) {
+            return null;
+        }
+        String t = name.getText();
+        if (t != null && t.length() >= 2 && t.startsWith("\"") && t.endsWith("\"")) {
+            return t.substring(1, t.length() - 1);
+        }
+        return t;
+    }
+
+    private static Range rangeOf(ParserRuleContext ctx) {
+        if (ctx == null) {
+            return null;
+        }
+        Token start = ctx.getStart();
+        if (start == null) {
+            return null;
+        }
+        Token stop = ctx.getStop() != null ? ctx.getStop() : start;
+        int startLine = start.getLine() - 1;
+        int startCol = start.getCharPositionInLine();
+        int endLine = stop.getLine() - 1;
+        int endCol = stop.getCharPositionInLine() + (stop.getText() == null ? 0 : stop.getText().length());
+        return new Range(new Position(startLine, startCol), new Position(endLine, endCol));
+    }
+
+    private static boolean contains(Range outer, Range inner) {
+        return !before(inner.getStart(), outer.getStart()) && !before(outer.getEnd(), inner.getEnd());
+    }
+
+    /** True when {@code a} is strictly before {@code b}. */
+    private static boolean before(Position a, Position b) {
+        return a.getLine() < b.getLine()
+                || (a.getLine() == b.getLine() && a.getCharacter() < b.getCharacter());
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLFoldingRangeHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLFoldingRangeHelper.java
new file mode 100644
index 00000000000..b958e5491b6
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLFoldingRangeHelper.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.FoldingRange;
+import org.eclipse.lsp4j.FoldingRangeKind;
+
+/**
+ * Produces folding ranges ({@code textDocument/foldingRange}) for a DRL file:
+ *
+ * <ul>
+ *   <li>each multi-line top-level construct (rule / declare / function / query)
+ *       folds as a {@link FoldingRangeKind#Region}, reusing the spans computed
+ *       by {@link DRLDocumentSymbolHelper};</li>
+ *   <li>each multi-line {@code /* ... *}{@code /} block comment folds as a
+ *       {@link FoldingRangeKind#Comment}.</li>
+ * </ul>
+ *
+ * <p>Single-line constructs (e.g. a one-line {@code global}) produce no fold.
+ * Whole-construct folds only — a rule's {@code when}/{@code then} are not split
+ * into separate sub-folds.
+ */
+public final class DRLFoldingRangeHelper {
+
+    /** A {@code /* ... *}{@code /} block comment (DOTALL, lazy body). */
+    private static final Pattern BLOCK_COMMENT = Pattern.compile("(?s)/\\*.*?\\*/");
+
+    private DRLFoldingRangeHelper() {
+    }
+
+    /** Returns the folding ranges for {@code text}, or an empty list. */
+    public static List<FoldingRange> foldingRanges(String text) {
+        List<FoldingRange> out = new ArrayList<>();
+        if (text == null || text.isEmpty()) {
+            return out;
+        }
+
+        // 1. Top-level constructs — reuse the document-symbol spans.
+        for (DocumentSymbol symbol : DRLDocumentSymbolHelper.symbols(text)) {
+            int start = symbol.getRange().getStart().getLine();
+            int end = symbol.getRange().getEnd().getLine();
+            if (end > start) {
+                out.add(folding(start, end, FoldingRangeKind.Region));
+            }
+        }
+
+        // 2. Multi-line block comments.
+        Matcher m = BLOCK_COMMENT.matcher(text);
+        while (m.find()) {
+            int startLine = lineAt(text, m.start());
+            int endLine = lineAt(text, Math.max(m.start(), m.end() - 1));
+            if (endLine > startLine) {
+                out.add(folding(startLine, endLine, FoldingRangeKind.Comment));
+            }
+        }
+
+        return out;
+    }
+
+    private static FoldingRange folding(int startLine, int endLine, String kind) {
+        FoldingRange range = new FoldingRange(startLine, endLine);
+        range.setKind(kind);
+        return range;
+    }
+
+    /** Zero-based line index of {@code offset} within {@code text}. */
+    private static int lineAt(String text, int offset) {
+        int safe = Math.max(0, Math.min(offset, text.length()));
+        int line = 0;
+        for (int i = 0; i < safe; i++) {
+            if (text.charAt(i) == '\n') {
+                line++;
+            }
+        }
+        return line;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
new file mode 100644
index 00000000000..6136119804a
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.MarkupContent;
+import org.eclipse.lsp4j.MarkupKind;
+import org.eclipse.lsp4j.Position;
+
+/**
+ * Hover content for DRL documents: type structure for pattern type names and
+ * type information for fields inside constraints.
+ *
+ * <p>Type names resolve through {@link DRLWorkspaceTypeIndex} — the same
+ * layered view (current document, open unsaved siblings, on-disk siblings)
+ * that completion and go-to-definition use — then classpath types through
+ * imports and the class index. Declared types render as their declare block
+ * with the doc comment above it, classpath types as their member list.
+ */
+public final class DRLHoverHelper {
+
+    private DRLHoverHelper() {
+    }
+
+    /**
+     * Equivalent to {@link #hover(String, Position, ClassIndex, ClassMemberIndex, Path, Map)}
+     * with no open sibling buffers — sibling declares resolve from disk only.
+     */
+    public static Hover hover(String text, Position position, ClassIndex classIndex,
+                              ClassMemberIndex memberIndex, Path documentPath) {
+        return hover(text, position, classIndex, memberIndex, documentPath, Map.of());
+    }
+
+    /**
+     * Returns hover content for the identifier at {@code position}, or
+     * {@code null} when there is nothing useful to show.
+     *
+     * @param documentPath filesystem location of the document, used to find
+     *                     sibling DRL files; {@code null} for non-file
+     *                     documents
+     * @param openFiles    open unsaved sibling buffers keyed by path, so
+     *                     cross-file resolution reflects unsaved edits; may be
+     *                     empty
+     */
+    public static Hover hover(String text, Position position, ClassIndex classIndex,
+                              ClassMemberIndex memberIndex, Path documentPath,
+                              Map<Path, String> openFiles) {
+        if (text == null || position == null) {
+            return null;
+        }
+        String word = DRLDefinitionHelper.wordAt(text, position);
+        if (word.isEmpty() || !Character.isJavaIdentifierStart(word.charAt(0))) {
+            return null;
+        }
+
+        // Parse the current document once; every step below reuses this parse.
+        ParsedDrl parsed = ParsedDrl.of(text);
+        List<DeclaredType> currentDocTypes = parsed.declaredTypes();
+        Map<String, DeclaredType> typeIndex =
+                DRLWorkspaceTypeIndex.build(currentDocTypes, documentPath, openFiles);
+
+        // 1. The word is itself a DRL-declared type.
+        DeclaredType declared = typeIndex.get(word);
+        if (declared != null) {
+            return markdown(renderDeclaredHover(
+                    declared, typeIndex, currentDocTypes, text, documentPath, openFiles));
+        }
+
+        DRL10Parser.CompilationUnitContext compilationUnit = parsed.compilationUnit;
+        Integer nodeIndex = parsed.tokenIndexAt(position);
+
+        // 2. Bound variable: resolve $var to its type via the shared binding
+        //    engine (pattern, field, nested-path, JDK-accessor, accumulate),
+        //    scoped to the rule under the caret so a binding name reused across
+        //    rules resolves to the right one, then hover that type.
+        if (word.startsWith("$")) {
+            int offset = positionToOffset(text, position);
+            String boundType =
+                    LhsBindingResolver.resolveAt(text, offset, typeIndex).get(word.substring(1));
+            if (boundType != null) {
+                DeclaredType boundDeclared = typeIndex.get(boundType);
+                if (boundDeclared != null) {
+                    return markdown(renderDeclaredHover(
+                            boundDeclared, typeIndex, currentDocTypes, text, documentPath, openFiles));
+                }
+                String boundFqcn = DRLCompletionHelper.resolveFqcn(
+                        boundType, boundType, compilationUnit, classIndex);
+                if (boundFqcn != null) {
+                    return markdown(renderJavaType(boundType, boundFqcn, memberIndex.membersOf(boundFqcn)));
+                }
+            }
+        }
+
+        // 3. Field of the pattern enclosing the caret.
+        if (nodeIndex != null) {
+            String patternType = DRLCompletionHelper.findEnclosingPatternTypeName(
+                    compilationUnit, nodeIndex);
+            if (patternType != null && !patternType.equals(word)) {
+                Field field = findField(patternType, word, typeIndex,
+                                        compilationUnit, classIndex, memberIndex);
+                if (field != null) {
+                    String owner = patternType.substring(patternType.lastIndexOf('.') + 1);
+                    return markdown("**" + field.name + "** : `" + field.type
+                            + "`\n\nField of `" + owner + "`");
+                }
+            }
+        }
+
+        // 4. Classpath type (or java.lang built-in). Show the hover even with no
+        //    members — knowing the FQN (e.g. java.lang.Object) is still useful.
+        String fqcn = DRLCompletionHelper.resolveFqcn(word, word, compilationUnit, classIndex);
+        if (fqcn != null) {
+            return markdown(renderJavaType(word, fqcn, memberIndex.membersOf(fqcn)));
+        }
+        return null;
+    }
+
+    /**
+     * Renders the full declared-type hover: doc comment (with {@code {@link}}
+     * references resolved to the declarations they name), the declare block,
+     * and inherited fields.
+     */
+    private static String renderDeclaredHover(DeclaredType declared,
+                                              Map<String, DeclaredType> typeIndex,
+                                              List<DeclaredType> currentDocTypes, String text,
+                                              Path documentPath, Map<Path, String> openFiles) {
+        List<Field> allFields = DRLDeclaredTypeParser.fieldsIncludingInherited(declared, typeIndex);
+        String doc = DRLWorkspaceTypeIndex.docFor(
+                declared.name, currentDocTypes, text, documentPath, openFiles);
+        Map<String, String> linkTargets =
+                DRLWorkspaceTypeIndex.buildLinkTargets(currentDocTypes, text, documentPath, openFiles);
+        return renderDeclared(declared, allFields, doc, linkTargets);
+    }
+
+    private static Field findField(String patternType, String fieldName,
+                                   Map<String, DeclaredType> typeIndex,
+                                   DRL10Parser.CompilationUnitContext compilationUnit,
+                                   ClassIndex classIndex, ClassMemberIndex memberIndex) {
+        String simpleName = patternType.substring(patternType.lastIndexOf('.') + 1);
+        DeclaredType declared = typeIndex.get(simpleName);
+        List<Field> fields;
+        if (declared != null) {
+            fields = DRLDeclaredTypeParser.fieldsIncludingInherited(declared, typeIndex);
+        } else {
+            String fqcn = DRLCompletionHelper.resolveFqcn(patternType, simpleName,
+                                                          compilationUnit, classIndex);
+            fields = fqcn == null ? List.of() : memberIndex.membersOf(fqcn);
+        }
+        for (Field field : fields) {
+            if (fieldName.equals(field.name)) {
+                return field;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * @param allFields the type's own fields followed by inherited ones, as
+     *                  produced by
+     *                  {@link DRLDeclaredTypeParser#fieldsIncludingInherited};
+     *                  the inherited tail is rendered as a separate section
+     * @param doc         the type's doc comment from {@link DRLWorkspaceTypeIndex#docFor},
+     *                    rendered as a leading prose section; {@code null} when undocumented
+     * @param linkTargets {@code typeName -> href} for resolving {@code {@link}} references,
+     *                    from {@link DRLWorkspaceTypeIndex#buildLinkTargets}; may be empty
+     */
+    private static String renderDeclared(DeclaredType dt, List<Field> allFields, String doc,
+                                         Map<String, String> linkTargets) {
+        StringBuilder sb = new StringBuilder();
+        if (doc != null) {
+            sb.append(DRLDocFormatter.format(doc, linkTargets)).append("\n\n");
+        }
+        sb.append("```\n");
+        sb.append("declare ").append(dt.isEnum ? "enum " : "").append(dt.name);
+        if (dt.extendsName != null) {
+            sb.append(" extends ").append(dt.extendsName);
+        }
+        sb.append('\n');
+        for (Field field : dt.fields) {
+            if (dt.isEnum && dt.name.equals(field.type)) {
+                sb.append("  ").append(field.name);
+                if (field.args != null) {
+                    sb.append('(').append(field.args).append(')');
+                }
+                sb.append('\n');
+            } else {
+                sb.append("  ").append(field.name).append(" : ").append(field.type).append('\n');
+            }
+        }
+        sb.append("end\n```");
+
+        List<Field> inherited = allFields.subList(dt.fields.size(), allFields.size());
+        if (!inherited.isEmpty()) {
+            sb.append("\n\n_Inherited:_");
+            for (Field field : inherited) {
+                sb.append("\n- ").append(field.name).append(" : ").append(field.type);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static String renderJavaType(String simpleName, String fqcn, List<Field> members) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("**").append(simpleName).append("** — `").append(fqcn).append("`\n");
+        for (Field member : members) {
+            sb.append("\n- ").append(member.name).append(" : ").append(member.type);
+        }
+        return sb.toString();
+    }
+
+    private static Hover markdown(String content) {
+        return new Hover(new MarkupContent(MarkupKind.MARKDOWN, content));
+    }
+
+    /**
+     * Converts an LSP {@link Position} (zero-based line + UTF-16 character) to a
+     * character offset into {@code text}. DRL files are ASCII in practice, so
+     * the character offset matches code units. Clamped to {@code text.length()}.
+     */
+    private static int positionToOffset(String text, Position position) {
+        int line = position.getLine();
+        int offset = 0;
+        int currentLine = 0;
+        while (currentLine < line && offset < text.length()) {
+            if (text.charAt(offset) == '\n') {
+                currentLine++;
+            }
+            offset++;
+        }
+        return Math.min(offset + position.getCharacter(), text.length());
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java
new file mode 100644
index 00000000000..f417a10bd5d
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.lsp4j.InlayHint;
+import org.eclipse.lsp4j.InlayHintKind;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+
+/**
+ * Produces inlay hints for DRL files. Binding types are resolved through the
+ * shared {@link LhsBindingResolver#collect}, so inlay sees exactly the same
+ * bindings as completion and hover:
+ *
+ * <ul>
+ *   <li><b>Outer pattern bindings</b> — {@code $p : Person(...)}. The type is
+ *       explicit at the declaration, so no hint is emitted.</li>
+ *   <li><b>Implicit bindings</b> — field ({@code $n : fieldName}), nested-path
+ *       ({@code $name : Person.name}), JDK-accessor ({@code $n : intValue}), and
+ *       accumulate-result ({@code $c : count()}) bindings. A {@code : Type} hint
+ *       is emitted at the declaration site and at every usage.</li>
+ * </ul>
+ *
+ * <p>Hints are emitted at LHS constraint usages (a {@code $name} that isn't the
+ * binding declaration) and at RHS usages; implicit bindings additionally get a
+ * hint at their declaration site, where the type isn't otherwise visible.
+ *
+ * <p>Out of scope: DRL-declared types beyond the shared workspace type index,
+ * and RHS parameter-name hints.
+ */
+public final class DRLInlayHintHelper {
+
+    private static final Logger logger = Logger.getLogger(DRLInlayHintHelper.class.getName());
+
+    /** Matches one full {@code rule "..." when ... then ... end} block. */
+    private static final Pattern RULE_BLOCK = Pattern.compile(
+            "(?s)\\brule\\b.*?\\bwhen\\b(.*?)\\bthen\\b(.*?)\\bend\\b");
+
+    /** Matches any {@code $name} reference (used on the RHS). */
+    private static final Pattern RHS_VAR = Pattern.compile("\\$(\\w+)\\b");
+
+    /**
+     * Matches a {@code $name} usage on the LHS — i.e. a {@code $name} that is
+     * NOT immediately followed by {@code :} (which would mark it as a binding
+     * declaration like {@code $ref: ref}). Lets us hint at cross-pattern
+     * constraint usages like {@code ref.order == $ref.order} without
+     * double-hinting the original binding site.
+     */
+    private static final Pattern LHS_VAR_USAGE = Pattern.compile("\\$(\\w+)\\b(?!\\s*:)");
+
+    private DRLInlayHintHelper() {
+    }
+
+    public static List<InlayHint> getHints(String text, Range range, Path filePath,
+                                           Map<Path, String> openFiles) {
+        try {
+            return getHintsImpl(text, range, filePath, openFiles);
+        } catch (Throwable t) {
+            logger.warning("[inlayHint] threw " + t.getClass().getSimpleName()
+                    + ": " + t.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /** Convenience overload for tests and callers without a workspace context. */
+    public static List<InlayHint> getHints(String text, Range range) {
+        return getHints(text, range, null, Collections.emptyMap());
+    }
+
+    private static List<InlayHint> getHintsImpl(String text, Range range, Path filePath,
+                                                Map<Path, String> openFiles) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Map<String, DeclaredType> typesByName = DRLWorkspaceTypeIndex.build(text, filePath, openFiles);
+        Map<String, String> accum = AccumulateFunctionTypes.get();
+        List<InlayHint> hints = new ArrayList<>();
+
+        Matcher ruleMatch = RULE_BLOCK.matcher(text);
+        while (ruleMatch.find()) {
+            String whenSection = ruleMatch.group(1);
+            String thenSection = ruleMatch.group(2);
+            int whenStart = ruleMatch.start(1);
+            int thenStart = ruleMatch.start(2);
+
+            LhsBindingResolver.Bindings b =
+                    LhsBindingResolver.collect(whenSection, typesByName, accum);
+            Map<String, String> bindings = b.types;
+
+            for (LhsBindingResolver.Decl d : b.declarations) {
+                emitHint(hints, text, whenStart + d.hintOffset, d.type, range);
+            }
+
+            if (bindings.isEmpty()) {
+                continue;
+            }
+
+            // LHS usages: $name references inside constraints that aren't the
+            // binding declaration itself.
+            Matcher lhsUse = LHS_VAR_USAGE.matcher(whenSection);
+            while (lhsUse.find()) {
+                String type = bindings.get(lhsUse.group(1));
+                if (type == null) {
+                    continue;
+                }
+                emitHint(hints, text, whenStart + lhsUse.end(), type, range);
+            }
+
+            // RHS usages.
+            Matcher v = RHS_VAR.matcher(thenSection);
+            while (v.find()) {
+                String type = bindings.get(v.group(1));
+                if (type == null) {
+                    continue;
+                }
+                emitHint(hints, text, thenStart + v.end(), type, range);
+            }
+        }
+
+        return hints;
+    }
+
+    /**
+     * Appends an inlay hint of {@code ": typeName"} at the given absolute
+     * character offset, unless the offset falls outside the requested
+     * {@code range}.
+     */
+    private static void emitHint(List<InlayHint> hints, String text, int absOffset,
+                                 String typeName, Range range) {
+        Position pos = offsetToPosition(text, absOffset);
+        if (range != null && !isPositionInRange(pos, range)) {
+            return;
+        }
+        InlayHint hint = new InlayHint(pos, Either.forLeft(": " + typeName));
+        hint.setKind(InlayHintKind.Type);
+        hint.setPaddingLeft(Boolean.FALSE);
+        hint.setPaddingRight(Boolean.TRUE);
+        hints.add(hint);
+    }
+
+    /**
+     * Converts a zero-based character offset within {@code text} to an LSP
+     * {@link Position} (zero-based line + UTF-16 character offset on that line).
+     * DRL files are ASCII in practice, so character offset matches code units.
+     */
+    static Position offsetToPosition(String text, int offset) {
+        int safe = Math.max(0, Math.min(offset, text.length()));
+        int line = 0;
+        int lastNewline = -1;
+        for (int i = 0; i < safe; i++) {
+            if (text.charAt(i) == '\n') {
+                line++;
+                lastNewline = i;
+            }
+        }
+        return new Position(line, safe - lastNewline - 1);
+    }
+
+    /**
+     * Tests whether {@code pos} lies within {@code range}. The end is exclusive,
+     * matching LSP range semantics — a position exactly at {@code range.end} is
+     * outside the range, so no hint is emitted there.
+     */
+    private static boolean isPositionInRange(Position pos, Range range) {
+        Position start = range.getStart();
+        Position end = range.getEnd();
+        if (pos.getLine() < start.getLine()) {
+            return false;
+        }
+        if (pos.getLine() > end.getLine()) {
+            return false;
+        }
+        if (pos.getLine() == start.getLine() && pos.getCharacter() < start.getCharacter()) {
+            return false;
+        }
+        if (pos.getLine() == end.getLine() && pos.getCharacter() >= end.getCharacter()) {
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
new file mode 100644
index 00000000000..ac97bb038f1
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
@@ -0,0 +1,1283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+
+/**
+ * Heuristic, parser-free lint passes for DRL source files, complementing the
+ * ANTLR-based syntax diagnostics from {@link DRLDiagnosticHelper} with
+ * friendlier messages anchored at the offending construct (the parser often
+ * reports structural mistakes far from their cause, e.g. a missing
+ * {@code end} surfaces as a confusing error at end-of-file).
+ *
+ * <p>Each pass is configurable through a system property, because every
+ * heuristic has false-positive potential and teams differ on how loudly they
+ * want to be warned:
+ *
+ * <pre>
+ *   drools.lsp.lint.missingEnd         = off | hint | info | warning | error
+ *   drools.lsp.lint.missingSeparator   = off | hint | info | warning | error
+ *   drools.lsp.lint.missingSemicolon   = off | hint | info | warning | error
+ *   drools.lsp.lint.unbalancedParens   = off | hint | info | warning | error
+ *   drools.lsp.lint.unknownTypes       = off | hint | info | warning | error
+ *   drools.lsp.lint.mvelPropertyAccess = off | hint | info | warning | error
+ * </pre>
+ *
+ * The structural passes default to {@code warning}. The MVEL property-access
+ * pass is purely stylistic (both forms are valid DRL), so it defaults to
+ * {@code off} and only runs for teams that opt in.
+ *
+ * <p>The structural passes are stateless heuristics that operate on a sanitized
+ * copy of the text in which comments and string-literal contents are blanked
+ * out (preserving line/column positions), so quoted parentheses,
+ * {@code when}/{@code then} inside comments, and trailing comments cannot
+ * confuse them. {@link #lintUnknownTypes} is the exception: it parses the
+ * document (once) and resolves types through {@link DRLCompletionHelper#resolveFqcn}
+ * — the same resolution hover, completion, and go-to-definition use — so its
+ * notion of "known" matches the rest of the language server.
+ */
+public final class DRLLintHelper {
+
+    private static final String PROP_MISSING_END          = "drools.lsp.lint.missingEnd";
+    private static final String PROP_MISSING_SEPARATOR    = "drools.lsp.lint.missingSeparator";
+    private static final String PROP_MISSING_SEMICOLON    = "drools.lsp.lint.missingSemicolon";
+    private static final String PROP_UNBALANCED_PARENS    = "drools.lsp.lint.unbalancedParens";
+    private static final String PROP_UNKNOWN_TYPES        = "drools.lsp.lint.unknownTypes";
+    private static final String PROP_MVEL_PROPERTY_ACCESS = "drools.lsp.lint.mvelPropertyAccess";
+
+    private static final int MAX_DIAGNOSTICS_PER_PASS = 20;
+    private static final int MAX_UNKNOWN_TYPE_DIAGNOSTICS = 50;
+
+    private static final Pattern THEN_END_BLOCK =
+            Pattern.compile("(?is)\\bthen\\b(.*?)\\bend\\b");
+    private static final Pattern THEN_NEW_TYPE =
+            Pattern.compile("(?is)\\bnew\\s+([A-Z][A-Za-z0-9_$.]*)\\s*\\(");
+    private static final Pattern QUALIFIED_REF =
+            Pattern.compile("(?<![\\w.$])([A-Z][A-Za-z0-9_$]*(?:\\.[A-Za-z0-9_$]+)+)");
+
+    private static final Pattern RULE_KEYWORD =
+            Pattern.compile("^\\s*(rule|query)\\b", Pattern.CASE_INSENSITIVE);
+    private static final Pattern DECLARE_KEYWORD =
+            Pattern.compile("^\\s*declare\\b", Pattern.CASE_INSENSITIVE);
+    private static final Pattern END_AT_START =
+            Pattern.compile("^end\\b", Pattern.CASE_INSENSITIVE);
+    private static final Pattern END_AT_END =
+            Pattern.compile("(?:^|\\s)end$", Pattern.CASE_INSENSITIVE);
+    private static final Pattern WHEN_KEYWORD =
+            Pattern.compile("^\\s*when\\b", Pattern.CASE_INSENSITIVE);
+    private static final Pattern THEN_KEYWORD =
+            Pattern.compile("^\\s*then\\b", Pattern.CASE_INSENSITIVE);
+    /** A bare pattern header opening its paren at end of line: {@code Person(}. */
+    private static final Pattern PATTERN_HEADER_LINE = Pattern.compile(
+            "^\\s*(?:(?:not|exists|forall)\\s+)?\\$?[A-Za-z_][\\w$]*\\s*\\(\\s*$",
+            Pattern.CASE_INSENSITIVE);
+    /** A line that starts like a field constraint: {@code name == ...}. */
+    private static final Pattern CONSTRAINT_START = Pattern.compile(
+            "^\\s*\\$?[A-Za-z_][\\w$]*(?:\\.[A-Za-z_][\\w$]*)*\\s*"
+                    + "(?:[=!<>]|contains\\b|matches\\b|memberOf\\b|in\\b).*");
+
+    private DRLLintHelper() {
+    }
+
+    /**
+     * Runs all enabled lint passes over {@code text} and returns their
+     * combined diagnostics. Returns an empty list for null/empty input or
+     * when every pass is disabled.
+     */
+    public static List<Diagnostic> lint(String text) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String sanitized = sanitize(text);
+        List<Diagnostic> out = new ArrayList<>();
+
+        DiagnosticSeverity endSeverity = severityFor(PROP_MISSING_END);
+        if (endSeverity != null) {
+            out.addAll(lintMissingRuleEnds(sanitized, endSeverity));
+        }
+        DiagnosticSeverity separatorSeverity = severityFor(PROP_MISSING_SEPARATOR);
+        if (separatorSeverity != null) {
+            out.addAll(lintMissingConstraintSeparators(sanitized, separatorSeverity));
+        }
+        DiagnosticSeverity semicolonSeverity = severityFor(PROP_MISSING_SEMICOLON);
+        if (semicolonSeverity != null) {
+            out.addAll(lintMissingThenSemicolons(sanitized, text, semicolonSeverity));
+        }
+        DiagnosticSeverity parenSeverity = severityFor(PROP_UNBALANCED_PARENS);
+        if (parenSeverity != null) {
+            out.addAll(lintUnbalancedParens(sanitized, parenSeverity));
+        }
+        DiagnosticSeverity mvelSeverity = severityFor(PROP_MVEL_PROPERTY_ACCESS, "off");
+        if (mvelSeverity != null) {
+            out.addAll(lintMvelPropertyAccess(sanitized, mvelSeverity));
+        }
+        return out;
+    }
+
+    // ── unbalanced parentheses in the LHS ────────────────────────────────
+
+    /**
+     * Reports unbalanced parentheses inside rule LHS (when-sections), rule
+     * consequences (then-sections), and query bodies — the regions where a
+     * missing {@code )} is both easy to type and hard to spot from the
+     * parser's far-away recovery error. An unclosed {@code (} is anchored
+     * at the paren itself; a stray {@code )} at its own position. Runs on
+     * sanitized text, so parens in strings and comments don't count.
+     */
+    private static List<Diagnostic> lintUnbalancedParens(String sanitized,
+                                                         DiagnosticSeverity severity) {
+        String[] lines = sanitized.split("\r?\n", -1);
+        List<Diagnostic> out = new ArrayList<>();
+        List<int[]> openParens = new ArrayList<>(); // {line, col}
+        boolean inLhs = false;
+        boolean inRhs = false;
+
+        for (int i = 0; i < lines.length && out.size() < MAX_DIAGNOSTICS_PER_PASS; i++) {
+            String raw = lines[i];
+            String line = raw.trim();
+
+            java.util.regex.Matcher ruleStart = RULE_KEYWORD.matcher(line);
+            if (ruleStart.find()) {
+                // A new rule/query while parens are still open: flush what the
+                // previous section left unclosed, then track the new block
+                // (query bodies are pattern regions from the start; rules from `when`).
+                reportUnclosed(openParens, lines, out, severity);
+                inLhs = "query".equalsIgnoreCase(ruleStart.group(1));
+                inRhs = false;
+                continue;
+            }
+            if (WHEN_KEYWORD.matcher(line).find()) {
+                inLhs = true;
+                inRhs = false;
+                openParens.clear();
+                continue;
+            }
+            if (inLhs && THEN_KEYWORD.matcher(line).find()) {
+                reportUnclosed(openParens, lines, out, severity);
+                inLhs = false;
+                inRhs = true;
+                continue;
+            }
+            if (END_AT_START.matcher(line).find() && (inLhs || inRhs)) {
+                reportUnclosed(openParens, lines, out, severity);
+                inLhs = false;
+                inRhs = false;
+                continue;
+            }
+            if (!inLhs && !inRhs) {
+                continue;
+            }
+
+            for (int col = 0; col < raw.length(); col++) {
+                char c = raw.charAt(col);
+                if (c == '(') {
+                    openParens.add(new int[] {i, col});
+                } else if (c == ')') {
+                    if (openParens.isEmpty()) {
+                        Diagnostic d = new Diagnostic();
+                        d.setSeverity(severity);
+                        d.setSource("drools-lint");
+                        d.setMessage("Unmatched ')' — no corresponding '('");
+                        d.setRange(new Range(new Position(i, col),
+                                             new Position(i, col + 1)));
+                        out.add(d);
+                        if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                            return out;
+                        }
+                    } else {
+                        openParens.remove(openParens.size() - 1);
+                    }
+                }
+            }
+        }
+        reportUnclosed(openParens, lines, out, severity);
+        return out;
+    }
+
+    private static void reportUnclosed(List<int[]> openParens, String[] lines,
+                                       List<Diagnostic> out, DiagnosticSeverity severity) {
+        for (int[] open : openParens) {
+            if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                break;
+            }
+            Diagnostic d = new Diagnostic();
+            d.setSeverity(severity);
+            d.setSource("drools-lint");
+            d.setMessage("Unclosed '(' — missing matching ')'");
+            d.setRange(new Range(new Position(open[0], open[1]),
+                                 new Position(open[0], open[1] + 1)));
+            out.add(d);
+        }
+        openParens.clear();
+    }
+
+    // ── MVEL property access in constraints ──────────────────────────────
+
+    /**
+     * A no-arg JavaBean accessor call with an explicit instance receiver:
+     * {@code $p.getCode()} / {@code address.isActive()}. group(1) is the
+     * accessor name without the leading dot.
+     */
+    private static final Pattern ACCESSOR_CALL = Pattern.compile(
+            "\\.(get[A-Z][A-Za-z0-9_]*|is[A-Z][A-Za-z0-9_]*)\\(\\s*\\)");
+
+    /**
+     * A no-arg JavaBean accessor call with no explicit receiver — the bare
+     * form used inside constraint bodies: {@code getName()} in
+     * {@code Person(getName() == "John")}. The negative lookbehind excludes
+     * calls already matched by {@link #ACCESSOR_CALL} (preceded by {@code .})
+     * and calls that are part of a longer identifier (preceded by a word char).
+     * group(1) is the accessor name.
+     */
+    private static final Pattern BARE_ACCESSOR_CALL = Pattern.compile(
+            "(?<![.A-Za-z0-9_$])(get[A-Z][A-Za-z0-9_]*|is[A-Z][A-Za-z0-9_]*)\\(\\s*\\)");
+
+    /** A flagged getter call: line/col range, accessor name, and MVEL property name. */
+    static final class AccessorFinding {
+        final int line;
+        final int startCol;
+        final int endCol;
+        final String accessor;
+        final String property;
+
+        AccessorFinding(int line, int startCol, int endCol, String accessor, String property) {
+            this.line = line;
+            this.startCol = startCol;
+            this.endCol = endCol;
+            this.accessor = accessor;
+            this.property = property;
+        }
+    }
+
+    /**
+     * Scans the WHEN sections of {@code sanitized} text for JavaBean getter
+     * calls eligible for MVEL property-access rewriting. Single source of
+     * truth for both the diagnostic pass ({@link #lintMvelPropertyAccess}) and
+     * the quick-fix edits ({@link #mvelPropertyAccessEdits}).
+     *
+     * <p>Covers both the explicit-receiver form ({@code $p.getCode()}) and the
+     * bare constraint form ({@code getName() == "x"}). Skips
+     * {@code getClass()} and {@code Type.getX()} static calls.
+     */
+    static List<AccessorFinding> findLhsAccessorFindings(String sanitized) {
+        String[] lines = sanitized.split("\r?\n", -1);
+        List<AccessorFinding> out = new ArrayList<>();
+        boolean inWhen = false;
+
+        for (int i = 0; i < lines.length; i++) {
+            String raw = lines[i];
+            String trimmed = raw.trim();
+
+            if (RULE_KEYWORD.matcher(trimmed).find()) {
+                inWhen = false;
+            }
+            if (WHEN_KEYWORD.matcher(trimmed).find()) {
+                inWhen = true;
+                continue;
+            }
+            if (inWhen && THEN_KEYWORD.matcher(trimmed).find()) {
+                inWhen = false;
+                continue;
+            }
+            if (!inWhen) {
+                continue;
+            }
+
+            java.util.regex.Matcher m = ACCESSOR_CALL.matcher(raw);
+            while (m.find()) {
+                String accessor = m.group(1);
+                if ("getClass".equals(accessor)) {
+                    continue;
+                }
+                if (receiverLooksLikeType(raw, m.start())) {
+                    continue;
+                }
+                out.add(new AccessorFinding(i, m.start(1), m.end(), accessor, toProperty(accessor)));
+                if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                    return out;
+                }
+            }
+            java.util.regex.Matcher m2 = BARE_ACCESSOR_CALL.matcher(raw);
+            while (m2.find()) {
+                String accessor = m2.group(1);
+                if ("getClass".equals(accessor)) {
+                    continue;
+                }
+                out.add(new AccessorFinding(i, m2.start(1), m2.end(), accessor, toProperty(accessor)));
+                if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                    return out;
+                }
+            }
+        }
+        return out;
+    }
+
+    /**
+     * Flags JavaBean getter calls in LHS constraints and suggests the MVEL
+     * property-access form. Covers both the bare form ({@code getName()} inside
+     * a pattern constraint) and the explicit-receiver form
+     * ({@code address.getCode()}). Scoped to the WHEN section only — the THEN
+     * consequence is real Java, where getter calls are correct and the property
+     * sugar is unavailable. Skips {@code getClass()} (its property form
+     * {@code .class} is a reserved MVEL construct) and {@code Type.getX()}
+     * static calls.
+     */
+    private static List<Diagnostic> lintMvelPropertyAccess(String sanitized,
+                                                           DiagnosticSeverity severity) {
+        List<Diagnostic> out = new ArrayList<>();
+        for (AccessorFinding f : findLhsAccessorFindings(sanitized)) {
+            Diagnostic d = new Diagnostic();
+            d.setSeverity(severity);
+            d.setSource("drools-lint");
+            d.setCode("mvel-property-access");
+            d.setMessage("Prefer MVEL property access '" + f.property
+                    + "' over '" + f.accessor + "()' in constraints");
+            d.setRange(new Range(new Position(f.line, f.startCol),
+                                 new Position(f.line, f.endCol)));
+            out.add(d);
+            if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                break;
+            }
+        }
+        return out;
+    }
+
+    /**
+     * Returns the {@link TextEdit}s that rewrite every flagged LHS getter call
+     * to MVEL property access ({@code getCode()} → {@code code}). Drives the
+     * code-action quick-fix; uses the same detection as
+     * {@link #lintMvelPropertyAccess}.
+     */
+    public static List<TextEdit> mvelPropertyAccessEdits(String text) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String sanitized = sanitize(text);
+        List<TextEdit> out = new ArrayList<>();
+        for (AccessorFinding f : findLhsAccessorFindings(sanitized)) {
+            out.add(new TextEdit(
+                    new Range(new Position(f.line, f.startCol), new Position(f.line, f.endCol)),
+                    f.property));
+        }
+        return out;
+    }
+
+    private static String toProperty(String accessor) {
+        return decapitalize(accessor.startsWith("get") ? accessor.substring(3) : accessor.substring(2));
+    }
+
+    /**
+     * Returns true when the identifier immediately before the dot at
+     * {@code dotIndex} starts with an uppercase letter — a {@code Type.getX()}
+     * static call rather than a bean property access on an instance.
+     */
+    private static boolean receiverLooksLikeType(String line, int dotIndex) {
+        int end = dotIndex;
+        int start = end;
+        while (start > 0) {
+            char c = line.charAt(start - 1);
+            if (Character.isLetterOrDigit(c) || c == '_' || c == '$') {
+                start--;
+            } else {
+                break;
+            }
+        }
+        return start < end && Character.isUpperCase(line.charAt(start));
+    }
+
+    /**
+     * JavaBeans {@code Introspector.decapitalize} rule, inlined to avoid a
+     * {@code java.desktop} dependency: lowercase the first character unless
+     * the first two are both uppercase ({@code URL} stays {@code URL}) —
+     * matching how Drools resolves pattern properties back to accessors.
+     */
+    private static String decapitalize(String name) {
+        if (name.isEmpty()) {
+            return name;
+        }
+        if (name.length() > 1 && Character.isUpperCase(name.charAt(0))
+                && Character.isUpperCase(name.charAt(1))) {
+            return name;
+        }
+        char[] chars = name.toCharArray();
+        chars[0] = Character.toLowerCase(chars[0]);
+        return new String(chars);
+    }
+
+    // ── missing ';' in THEN ──────────────────────────────────────────────
+
+    /** Matches a {@code dialect "mvel"} attribute (checked on original text). */
+    private static final Pattern MVEL_DIALECT =
+            Pattern.compile("(?i)\\bdialect\\s*\"mvel\"");
+    private static final Pattern CONTROL_FLOW_START = Pattern.compile(
+            "^(if|for|while|else|try|catch|finally|switch|do)\\b.*",
+            Pattern.CASE_INSENSITIVE);
+
+    /**
+     * Reports Java statements in the {@code then} consequence that end
+     * without a semicolon. Exemptions for the legal semicolon-free cases:
+     * MVEL-dialect rules (file- or rule-level attribute), statement bodies
+     * inside braces (e.g. {@code modify(...) { ... }}), fluent-chain
+     * continuations (next line starts with {@code .}), multi-line call
+     * arguments (open paren depth), and control-flow/brace lines.
+     *
+     * @param sanitized comment/string-blanked text the heuristics run on
+     * @param original  unmodified text, needed for dialect detection (the
+     *                  sanitizer blanks the {@code "mvel"} literal)
+     */
+    private static List<Diagnostic> lintMissingThenSemicolons(String sanitized,
+                                                              String original,
+                                                              DiagnosticSeverity severity) {
+        String[] lines = sanitized.split("\r?\n", -1);
+        String[] originalLines = original.split("\r?\n", -1);
+        List<Diagnostic> out = new ArrayList<>();
+
+        boolean inRuleHeader = false;
+        boolean inRhs = false;
+        boolean fileMvel = false;
+        boolean ruleMvel = false;
+        int parenDepth = 0;
+        int braceDepth = 0;
+
+        for (int i = 0; i < lines.length; i++) {
+            String raw = lines[i];
+            String line = raw.trim();
+            String originalLine = i < originalLines.length ? originalLines[i] : "";
+
+            // dialect "mvel" before the first rule applies to the whole file.
+            if (!inRuleHeader && !inRhs && MVEL_DIALECT.matcher(originalLine).find()) {
+                if (!containsRuleBefore(lines, i)) {
+                    fileMvel = true;
+                } else {
+                    ruleMvel = true;
+                }
+            }
+
+            if (RULE_KEYWORD.matcher(line).find()) {
+                inRuleHeader = true;
+                inRhs = false;
+                ruleMvel = false;
+                parenDepth = 0;
+                braceDepth = 0;
+            }
+            if (inRuleHeader && MVEL_DIALECT.matcher(originalLine).find()) {
+                ruleMvel = true;
+            }
+            if (WHEN_KEYWORD.matcher(line).find()) {
+                inRuleHeader = false;
+            }
+            if (THEN_KEYWORD.matcher(line).find()) {
+                inRuleHeader = false;
+                inRhs = true;
+                parenDepth = 0;
+                braceDepth = 0;
+                continue;
+            }
+            if (!inRhs) {
+                continue;
+            }
+            if (END_AT_START.matcher(line).find()) {
+                inRhs = false;
+                continue;
+            }
+            if (line.isEmpty()) {
+                continue;
+            }
+            if (fileMvel || ruleMvel) {
+                continue;
+            }
+
+            int braceDelta = countChar(raw, '{') - countChar(raw, '}');
+            boolean wasInsideBraces = braceDepth > 0;
+            braceDepth = Math.max(0, braceDepth + braceDelta);
+            parenDepth = Math.max(0, parenDepth + countChar(raw, '(') - countChar(raw, ')'));
+
+            // Inside a braced body (modify blocks, if/for bodies) the
+            // heuristic stands down — modify uses commas legally, and
+            // distinguishing it from Java blocks isn't worth false positives.
+            if (wasInsideBraces || braceDelta != 0
+                    || line.endsWith("{") || line.endsWith("}")) {
+                continue;
+            }
+            // A statement whose call parens are still open continues on the
+            // next line.
+            if (parenDepth > 0) {
+                continue;
+            }
+            if (CONTROL_FLOW_START.matcher(line).matches()) {
+                continue;
+            }
+            if (line.endsWith(";") || line.endsWith(",")) {
+                continue;
+            }
+            // Fluent chain: the statement continues on a `.method(...)` line.
+            String next = nextNonBlankLine(lines, i + 1);
+            if (next != null && next.startsWith(".")) {
+                continue;
+            }
+
+            String lower = line.toLowerCase();
+            boolean looksLikeStatement = line.endsWith(")")
+                    || lower.startsWith("insert") || lower.startsWith("update")
+                    || lower.startsWith("retract") || lower.startsWith("delete")
+                    || lower.startsWith("modify") || lower.startsWith("new ")
+                    || line.contains("=");
+            if (!looksLikeStatement) {
+                continue;
+            }
+
+            int col = Math.max(0, lastNonWhitespaceCol(raw));
+            Diagnostic d = new Diagnostic();
+            d.setSeverity(severity);
+            d.setSource("drools-lint");
+            d.setMessage("Likely missing ';' at the end of this consequence statement");
+            d.setRange(new Range(new Position(i, col),
+                                 new Position(i, col + 1)));
+            out.add(d);
+            if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                break;
+            }
+        }
+        return out;
+    }
+
+    private static boolean containsRuleBefore(String[] lines, int index) {
+        for (int i = 0; i < index && i < lines.length; i++) {
+            if (RULE_KEYWORD.matcher(lines[i].trim()).find()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // ── missing constraint separators ────────────────────────────────────
+
+    /**
+     * Reports adjacent constraint lines inside an open pattern paren where
+     * the first line ends without a separator ({@code ,}, {@code &&},
+     * {@code ||}, {@code and}, {@code or}) — a newline is not a constraint
+     * separator in DRL. Operates on sanitized text, so parens inside string
+     * literals and comment lines cannot corrupt the depth bookkeeping.
+     */
+    private static List<Diagnostic> lintMissingConstraintSeparators(String sanitized,
+                                                                    DiagnosticSeverity severity) {
+        String[] lines = sanitized.split("\r?\n", -1);
+        List<Diagnostic> out = new ArrayList<>();
+
+        boolean inWhen = false;
+        int parenDepth = 0;
+
+        for (int i = 0; i < lines.length; i++) {
+            String raw = lines[i];
+            String a = raw.trim();
+
+            if (RULE_KEYWORD.matcher(a).find()) {
+                inWhen = false;
+                parenDepth = 0;
+            }
+            if (WHEN_KEYWORD.matcher(a).find()) {
+                inWhen = true;
+                parenDepth = 0;
+                continue;
+            }
+            if (inWhen && THEN_KEYWORD.matcher(a).find()) {
+                inWhen = false;
+                parenDepth = 0;
+                continue;
+            }
+            if (!inWhen) {
+                continue;
+            }
+
+            // Depth bookkeeping happens for every line in the when-section —
+            // skipping it for "uninteresting" lines is how comment lines used
+            // to silence the pass for the rest of the rule.
+            parenDepth += countChar(raw, '(') - countChar(raw, ')');
+
+            if (a.isEmpty() || parenDepth <= 0) {
+                continue;
+            }
+            if (PATTERN_HEADER_LINE.matcher(a).matches()) {
+                continue;
+            }
+            if (!CONSTRAINT_START.matcher(a).matches()) {
+                continue;
+            }
+
+            String lower = a.toLowerCase();
+            boolean endsWithSeparator = a.endsWith(",") || a.endsWith("&&")
+                    || a.endsWith("||") || a.endsWith("(")
+                    || lower.endsWith(" and") || lower.endsWith(" or");
+            if (endsWithSeparator) {
+                continue;
+            }
+
+            String next = nextNonBlankLine(lines, i + 1);
+            if (next == null || !CONSTRAINT_START.matcher(next).matches()) {
+                continue;
+            }
+
+            int col = Math.max(0, lastNonWhitespaceCol(raw));
+            Diagnostic d = new Diagnostic();
+            d.setSeverity(severity);
+            d.setSource("drools-lint");
+            d.setMessage("Likely missing ',' between constraints (newline is not a separator)");
+            d.setRange(new Range(new Position(i, col),
+                                 new Position(i, col + 1)));
+            out.add(d);
+            if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                break;
+            }
+        }
+        return out;
+    }
+
+    // ── missing 'end' ────────────────────────────────────────────────────
+
+    /**
+     * Reports {@code rule}/{@code query} blocks that are never closed with
+     * {@code end}. {@code declare} blocks are tracked separately so their
+     * {@code end} doesn't close a rule; a declare block left unclosed does
+     * not suppress tracking of subsequent rules (a {@code rule} keyword
+     * cannot legally appear inside {@code declare}, so it implicitly
+     * terminates the tracking of one).
+     */
+    private static List<Diagnostic> lintMissingRuleEnds(String sanitized,
+                                                        DiagnosticSeverity severity) {
+        String[] lines = sanitized.split("\r?\n", -1);
+        List<Diagnostic> out = new ArrayList<>();
+        List<int[]> openRules = new ArrayList<>(); // {line, startCol}
+        boolean inDeclare = false;
+
+        for (int i = 0; i < lines.length; i++) {
+            String raw = lines[i];
+            String trimmed = raw.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+
+            if (RULE_KEYWORD.matcher(trimmed).find()) {
+                inDeclare = false;
+                openRules.add(new int[] {i, firstNonWhitespaceCol(raw)});
+            } else if (DECLARE_KEYWORD.matcher(trimmed).find()) {
+                inDeclare = true;
+            }
+
+            boolean closes = END_AT_START.matcher(trimmed).find()
+                    || END_AT_END.matcher(trimmed).find();
+            if (closes) {
+                if (inDeclare) {
+                    inDeclare = false;
+                } else if (!openRules.isEmpty()) {
+                    openRules.remove(openRules.size() - 1);
+                }
+            }
+        }
+
+        for (int[] rs : openRules) {
+            String raw = lines[rs[0]];
+            int startCol = rs[1];
+            int keywordLen = raw.trim().toLowerCase().startsWith("query") ? 5 : 4;
+            int endCol = Math.min(raw.length(), startCol + keywordLen);
+            if (endCol <= startCol) {
+                endCol = Math.min(raw.length(), startCol + 1);
+            }
+            Diagnostic d = new Diagnostic();
+            d.setSeverity(severity);
+            d.setSource("drools-lint");
+            d.setRange(new Range(new Position(rs[0], startCol),
+                                 new Position(rs[0], endCol)));
+            d.setMessage("Missing 'end' for rule/query starting at line " + (rs[0] + 1));
+            out.add(d);
+            if (out.size() >= MAX_DIAGNOSTICS_PER_PASS) {
+                break;
+            }
+        }
+        return out;
+    }
+
+    // ── unknown types ────────────────────────────────────────────────────
+
+    /**
+     * Convenience entry point that parses {@code text} itself, for callers with
+     * no compilation unit in hand (e.g. tests). Server requests share the syntax
+     * pass's parse through the {@code cu} overload instead.
+     */
+    public static List<Diagnostic> lintUnknownTypes(String text, Path documentPath,
+                                                    Map<Path, String> openFiles, ClassIndex classIndex,
+                                                    ClassMemberIndex memberIndex, boolean classpathResolved) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return lintUnknownTypes(text, DRLParsers.silent(text).compilationUnit(), documentPath,
+                openFiles, classIndex, memberIndex, classpathResolved);
+    }
+
+    /**
+     * Flags type references that resolve to no known type and, for likely typos
+     * (within bounded Levenshtein ≤ 2 of a known name), suggests the right one.
+     * Candidates come from three sources:
+     * <ul>
+     *   <li>pattern object types, read from the parse tree (so nested
+     *       {@code exists}/{@code not}/{@code from}/{@code accumulate} patterns
+     *       are covered);</li>
+     *   <li>upper-case-led qualified references in LHS constraints, e.g.
+     *       {@code Pet.Dog.name} — the head type is
+     *       resolved, then the first upper-case member segment is verified
+     *       against the head's reflected members (enum constants, fields, nested
+     *       types); lower-case property segments are not chased;</li>
+     *   <li>{@code new T(...)} in the consequence, matched by regex — the RHS is
+     *       lexed into opaque chunks the grammar does not structure.</li>
+     * </ul>
+     * A type is known when it is a declared type or resolves via
+     * {@link DRLCompletionHelper#resolveFqcn} (imports, wildcard imports, the
+     * class index, or {@code java.lang}) — the same resolution hover, completion
+     * and go-to-definition use. The caller supplies the parsed {@code cu}
+     * (shared with the syntax pass) and it is reused here for declared-type
+     * extraction. Self-gated by {@code drools.lsp.lint.unknownTypes}
+     * (default {@code warning}; {@code off} disables).
+     *
+     * @param cu                the parsed compilation unit (shared with the syntax pass); may be {@code null}
+     * @param documentPath      document path for sibling declared-type lookup; may be {@code null}
+     * @param openFiles         open unsaved sibling buffers, so cross-file declares count; may be empty
+     * @param classIndex        resolved classpath index, for type resolution and suggestions
+     * @param memberIndex       member reflection, for verifying classpath qualified-reference members
+     * @param classpathResolved whether the project classpath has resolved; when {@code false}, only
+     *                          checks derivable from the DRL alone run (declared types and declared-enum
+     *                          members), since a non-declared name can't be confirmed unknown without it
+     */
+    public static List<Diagnostic> lintUnknownTypes(String text, DRL10Parser.CompilationUnitContext cu,
+                                                    Path documentPath, Map<Path, String> openFiles,
+                                                    ClassIndex classIndex, ClassMemberIndex memberIndex,
+                                                    boolean classpathResolved) {
+        if (text == null || text.isEmpty() || cu == null) {
+            return Collections.emptyList();
+        }
+        DiagnosticSeverity severity = severityFor(PROP_UNKNOWN_TYPES, "warning");
+        if (severity == null) {
+            return Collections.emptyList();
+        }
+        // Strings/comments are blanked so type-like text inside them can't match;
+        // the cu and the sanitized text share character positions.
+        String sanitized = sanitize(text);
+
+        // Declared types of the document + siblings, kept as full types so a
+        // declared enum's constants can be verified for qualified references.
+        Map<String, DeclaredType> declared = new HashMap<>();
+        for (DeclaredType dt : DRLDeclaredTypeParser.extractFromCompilationUnit(cu)) {
+            if (dt.name != null) {
+                declared.putIfAbsent(dt.name, dt);
+            }
+        }
+        DRLWorkspaceTypeIndex.forEachSiblingType(documentPath, openFiles, (dt, uri) -> {
+            if (dt.name != null) {
+                declared.putIfAbsent(dt.name, dt);
+            }
+        });
+        Set<String> known = declared.keySet();
+        // Typo suggestions are drawn from declared types plus classpath simple names.
+        Set<String> suggestions = new HashSet<>(known);
+        suggestions.addAll(classIndex.simpleNames());
+
+        List<Diagnostic> out = new ArrayList<>();
+        collectPatternTypes(cu, cu, known, suggestions, classIndex, classpathResolved, severity, out);
+        scanQualifiedRefs(cu, sanitized, declared, suggestions, cu, classIndex, memberIndex,
+                          classpathResolved, severity, out);
+
+        Matcher then = THEN_END_BLOCK.matcher(sanitized);
+        while (then.find() && out.size() < MAX_UNKNOWN_TYPE_DIAGNOSTICS) {
+            int base = then.start(1);
+            Matcher newType = THEN_NEW_TYPE.matcher(then.group(1));
+            while (newType.find() && out.size() < MAX_UNKNOWN_TYPE_DIAGNOSTICS) {
+                Range range = rangeOf(sanitized, base + newType.start(1), base + newType.end(1));
+                addUnknown(newType.group(1), range, known, suggestions, cu, classIndex,
+                           classpathResolved, severity, out);
+            }
+        }
+        return out;
+    }
+
+    /** Walks {@code node}, checking each pattern's object type against {@code cu}'s resolution. */
+    private static void collectPatternTypes(ParseTree node, DRL10Parser.CompilationUnitContext cu,
+                                            Set<String> known, Set<String> suggestions,
+                                            ClassIndex classIndex, boolean classpathResolved,
+                                            DiagnosticSeverity severity, List<Diagnostic> out) {
+        if (out.size() >= MAX_UNKNOWN_TYPE_DIAGNOSTICS) {
+            return;
+        }
+        if (node instanceof DRL10Parser.LhsPatternContext pattern && pattern.objectType != null) {
+            Token start = pattern.objectType.getStart();
+            Token stop = pattern.objectType.getStop();
+            if (start != null && stop != null) {
+                Range range = new Range(
+                        new Position(start.getLine() - 1, start.getCharPositionInLine()),
+                        new Position(stop.getLine() - 1,
+                                     stop.getCharPositionInLine() + stop.getText().length()));
+                addUnknown(pattern.objectType.getText(), range, known, suggestions, cu, classIndex,
+                           classpathResolved, severity, out);
+            }
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            collectPatternTypes(node.getChild(i), cu, known, suggestions, classIndex, classpathResolved,
+                                severity, out);
+        }
+    }
+
+    /**
+     * Walks {@code node} for {@code when} sections and checks every upper-case-led
+     * qualified reference in each (constraints carry these inside expression
+     * subtrees, so the chain is located by regex over the section's text and
+     * resolution stays with {@link DRLCompletionHelper#resolveFqcn} / the member
+     * index).
+     */
+    private static void scanQualifiedRefs(ParseTree node, String sanitized,
+                                          Map<String, DeclaredType> declared, Set<String> suggestions,
+                                          DRL10Parser.CompilationUnitContext cu, ClassIndex classIndex,
+                                          ClassMemberIndex memberIndex, boolean classpathResolved,
+                                          DiagnosticSeverity severity, List<Diagnostic> out) {
+        if (out.size() >= MAX_UNKNOWN_TYPE_DIAGNOSTICS) {
+            return;
+        }
+        if (node instanceof DRL10Parser.LhsContext lhs && lhs.getStart() != null && lhs.getStop() != null) {
+            int start = lhs.getStart().getStartIndex();
+            int stop = lhs.getStop().getStopIndex();
+            if (start >= 0 && stop >= start && stop < sanitized.length()) {
+                Matcher m = QUALIFIED_REF.matcher(sanitized.substring(start, stop + 1));
+                while (m.find() && out.size() < MAX_UNKNOWN_TYPE_DIAGNOSTICS) {
+                    checkChain(m.group(1), start + m.start(1), sanitized, declared, suggestions,
+                               cu, classIndex, memberIndex, classpathResolved, severity, out);
+                }
+            }
+            return; // the section's whole text is covered; no nested when-sections
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            scanQualifiedRefs(node.getChild(i), sanitized, declared, suggestions, cu, classIndex,
+                              memberIndex, classpathResolved, severity, out);
+        }
+    }
+
+    /**
+     * Checks the head type and first upper-case member of a qualified chain like
+     * {@code QuestionIds.TargetResponse.groupType}. The head is verified against
+     * declared types and {@link DRLCompletionHelper#resolveFqcn}; the member is
+     * verified against a declared enum's constants (no classpath needed) or a
+     * resolved classpath type's reflected members. A lower-case member (a
+     * property) is not chased.
+     */
+    private static void checkChain(String chain, int chainStart, String sanitized,
+                                   Map<String, DeclaredType> declared, Set<String> suggestions,
+                                   DRL10Parser.CompilationUnitContext cu, ClassIndex classIndex,
+                                   ClassMemberIndex memberIndex, boolean classpathResolved,
+                                   DiagnosticSeverity severity, List<Diagnostic> out) {
+        int dot = chain.indexOf('.');
+        String head = chain.substring(0, dot);
+        int memberStart = dot + 1;
+        int nextDot = chain.indexOf('.', memberStart);
+        String member = nextDot < 0 ? chain.substring(memberStart) : chain.substring(memberStart, nextDot);
+        boolean memberIsType = !member.isEmpty() && Character.isUpperCase(member.charAt(0));
+
+        DeclaredType headType = declared.get(head);
+        if (headType != null) {
+            // Declared head. Only an enum exposes verifiable Type.MEMBER access
+            // (its constants); other declares have no static members to check.
+            if (headType.isEnum && memberIsType) {
+                Set<String> names = declaredMemberNames(headType);
+                if (!names.contains(member)) {
+                    out.add(memberWarning(member, head, memberRange(sanitized, chainStart, memberStart, member),
+                            bestTypoSuggestion(member, names), severity));
+                }
+            }
+            return;
+        }
+
+        // Classpath head.
+        String headFqcn = DRLCompletionHelper.resolveFqcn(head, head, cu, classIndex);
+        if (headFqcn == null) {
+            // Can't be sure it's a typo vs an unresolved real classpath type.
+            if (classpathResolved) {
+                out.add(makeUnknownTypeWarning(head,
+                        rangeOf(sanitized, chainStart, chainStart + head.length()),
+                        bestTypoSuggestion(head, suggestions), severity));
+            }
+            return;
+        }
+        if (!memberIsType) {
+            return; // a property (lower-case) — not chased
+        }
+        Set<String> members = memberIndex.memberNames(headFqcn);
+        if (members == null || members.contains(member)) {
+            return; // head not loadable (can't verify) or member is valid
+        }
+        out.add(memberWarning(member, toSimpleTypeName(head),
+                memberRange(sanitized, chainStart, memberStart, member),
+                bestTypoSuggestion(member, members), severity));
+    }
+
+    private static Range memberRange(String sanitized, int chainStart, int memberStart, String member) {
+        return rangeOf(sanitized, chainStart + memberStart, chainStart + memberStart + member.length());
+    }
+
+    /** Field and enum-constant names declared on {@code dt} (its own members). */
+    private static Set<String> declaredMemberNames(DeclaredType dt) {
+        Set<String> names = new HashSet<>();
+        if (dt.fields != null) {
+            for (Field f : dt.fields) {
+                if (f != null && f.name != null) {
+                    names.add(f.name);
+                }
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Emits an unknown-type warning for {@code candidate} unless it is a declared
+     * type or resolves. When the classpath is unresolved a non-resolving name is
+     * left alone — it can't be told apart from a real-but-unindexed type.
+     */
+    private static void addUnknown(String candidate, Range range, Set<String> known,
+                                   Set<String> suggestions, DRL10Parser.CompilationUnitContext cu,
+                                   ClassIndex classIndex, boolean classpathResolved,
+                                   DiagnosticSeverity severity, List<Diagnostic> out) {
+        if (candidate == null) {
+            return;
+        }
+        String trimmed = candidate.trim();
+        if (trimmed.isEmpty()) {
+            return;
+        }
+        String simple = toSimpleTypeName(trimmed);
+        if (known.contains(simple)
+                || DRLCompletionHelper.resolveFqcn(trimmed, simple, cu, classIndex) != null) {
+            return;
+        }
+        if (!classpathResolved) {
+            return;
+        }
+        out.add(makeUnknownTypeWarning(trimmed, range, bestTypoSuggestion(simple, suggestions), severity));
+    }
+
+    private static Range rangeOf(String text, int startOffset, int endOffset) {
+        return new Range(offsetToPosition(text, startOffset), offsetToPosition(text, endOffset));
+    }
+
+    private static Diagnostic memberWarning(String member, String container, Range range,
+                                            String suggestion, DiagnosticSeverity severity) {
+        Diagnostic d = new Diagnostic();
+        d.setSeverity(severity);
+        d.setSource("drools-type");
+        d.setCode("unknown-member");
+        d.setRange(range);
+        String msg = "'" + member + "' is not a member of '" + container + "'";
+        if (suggestion != null && !suggestion.isBlank() && !suggestion.equals(member)) {
+            msg += ". Did you mean '" + suggestion + "'?";
+            d.setData(suggestion); // drives the "Replace with '<suggestion>'" quick-fix
+        } else {
+            msg += ".";
+        }
+        d.setMessage(msg);
+        return d;
+    }
+
+    private static Diagnostic makeUnknownTypeWarning(String candidate, Range range,
+                                                     String suggestion, DiagnosticSeverity severity) {
+        Diagnostic d = new Diagnostic();
+        d.setSeverity(severity);
+        d.setSource("drools-type");
+        d.setCode("unknown-type");
+        d.setRange(range);
+
+        String msg = "Unknown type '" + candidate + "'";
+        if (suggestion != null && !suggestion.isBlank() && !suggestion.equals(candidate)) {
+            msg += " (possible typo). Did you mean '" + suggestion + "'?";
+            // The quick-fix reads this to offer "Replace with '<suggestion>'".
+            d.setData(suggestion);
+        } else {
+            msg += ".";
+        }
+        msg += " If this is a Java type, add an import or ensure it is on the resolved classpath.";
+        d.setMessage(msg);
+        return d;
+    }
+
+    /**
+     * Closest known simple name to {@code candidateSimple} within edit distance
+     * 2 (a likely typo), or {@code null} when nothing is close enough. Exact
+     * matches return {@code null} — the caller only reaches here for unknowns.
+     */
+    static String bestTypoSuggestion(String candidateSimple, Set<String> knownSimpleNames) {
+        if (candidateSimple == null || candidateSimple.isBlank()
+                || knownSimpleNames == null || knownSimpleNames.isEmpty()) {
+            return null;
+        }
+        String c = candidateSimple.trim();
+        if (c.length() < 2) {
+            return null;
+        }
+        String best = null;
+        int bestDist = Integer.MAX_VALUE;
+        for (String k : knownSimpleNames) {
+            if (k == null || k.isBlank()) {
+                continue;
+            }
+            if (k.equals(c)) {
+                return null;
+            }
+            if (Math.abs(k.length() - c.length()) > 2) {
+                continue;
+            }
+            int dist = levenshteinDistanceAtMost(c, k, 2);
+            if (dist >= 0 && dist < bestDist) {
+                bestDist = dist;
+                best = k;
+                if (bestDist == 1) {
+                    return best;
+                }
+            }
+        }
+        return bestDist <= 2 ? best : null;
+    }
+
+    /** Levenshtein distance between {@code a} and {@code b}, or -1 once it exceeds {@code max}. */
+    private static int levenshteinDistanceAtMost(String a, String b, int max) {
+        if (a == null || b == null) {
+            return -1;
+        }
+        int n = a.length();
+        int m = b.length();
+        if (Math.abs(n - m) > max) {
+            return -1;
+        }
+        if (n == 0) {
+            return m <= max ? m : -1;
+        }
+        if (m == 0) {
+            return n <= max ? n : -1;
+        }
+        if (n > m) {
+            String tmp = a;
+            a = b;
+            b = tmp;
+            n = a.length();
+            m = b.length();
+        }
+        int[] prev = new int[n + 1];
+        int[] cur = new int[n + 1];
+        for (int i = 0; i <= n; i++) {
+            prev[i] = i;
+        }
+        for (int j = 1; j <= m; j++) {
+            char bj = b.charAt(j - 1);
+            cur[0] = j;
+            int rowMin = cur[0];
+            for (int i = 1; i <= n; i++) {
+                int cost = a.charAt(i - 1) == bj ? 0 : 1;
+                cur[i] = Math.min(prev[i] + 1, Math.min(cur[i - 1] + 1, prev[i - 1] + cost));
+                if (cur[i] < rowMin) {
+                    rowMin = cur[i];
+                }
+            }
+            if (rowMin > max) {
+                return -1;
+            }
+            int[] tmp = prev;
+            prev = cur;
+            cur = tmp;
+        }
+        return prev[n] <= max ? prev[n] : -1;
+    }
+
+    static String toSimpleTypeName(String candidate) {
+        if (candidate == null) {
+            return "";
+        }
+        int idx = Math.max(candidate.lastIndexOf('.'), candidate.lastIndexOf('$'));
+        return idx >= 0 && idx < candidate.length() - 1 ? candidate.substring(idx + 1) : candidate;
+    }
+
+    /** Converts an absolute character offset into a 0-based line/column position. */
+    private static Position offsetToPosition(String text, int offset) {
+        int line = 0;
+        int col = 0;
+        int end = Math.min(offset, text.length());
+        for (int i = 0; i < end; i++) {
+            if (text.charAt(i) == '\n') {
+                line++;
+                col = 0;
+            } else {
+                col++;
+            }
+        }
+        return new Position(line, col);
+    }
+
+    // ── shared helpers ───────────────────────────────────────────────────
+
+    private static DiagnosticSeverity severityFor(String property) {
+        return severityFor(property, "warning");
+    }
+
+    /**
+     * Resolves the severity for a pass from its system property: {@code off}
+     * returns null (pass disabled), unknown values fall back to
+     * {@code defaultValue}.
+     */
+    private static DiagnosticSeverity severityFor(String property, String defaultValue) {
+        String value = System.getProperty(property, defaultValue)
+                .trim().toLowerCase();
+        switch (value) {
+            case "off":
+                return null;
+            case "hint":
+                return DiagnosticSeverity.Hint;
+            case "info":
+                return DiagnosticSeverity.Information;
+            case "error":
+                return DiagnosticSeverity.Error;
+            case "warning":
+            default:
+                return DiagnosticSeverity.Warning;
+        }
+    }
+
+    /**
+     * Returns a copy of {@code text} in which line comments ({@code //},
+     * {@code #}), block comments and the contents of string literals are
+     * replaced by spaces. Newlines are preserved (CR normalised to a space
+     * within lines is avoided by keeping CR as-is only when followed by LF),
+     * so every remaining character keeps its original line and column.
+     */
+    static String sanitize(String text) {
+        char[] chars = text.toCharArray();
+        boolean inString = false;
+        boolean inLineComment = false;
+        boolean inBlockComment = false;
+
+        for (int i = 0; i < chars.length; i++) {
+            char c = chars[i];
+            if (c == '\n' || c == '\r') {
+                inLineComment = false;
+                inString = false; // DRL string literals do not span lines
+                continue;
+            }
+            if (inLineComment) {
+                chars[i] = ' ';
+                continue;
+            }
+            if (inBlockComment) {
+                if (c == '*' && i + 1 < chars.length && chars[i + 1] == '/') {
+                    chars[i] = ' ';
+                    chars[i + 1] = ' ';
+                    i++;
+                    inBlockComment = false;
+                } else {
+                    chars[i] = ' ';
+                }
+                continue;
+            }
+            if (inString) {
+                if (c == '\\' && i + 1 < chars.length) {
+                    chars[i] = ' ';
+                    chars[i + 1] = ' ';
+                    i++;
+                } else if (c == '"') {
+                    inString = false;
+                } else {
+                    chars[i] = ' ';
+                }
+                continue;
+            }
+            if (c == '"') {
+                inString = true;
+            } else if (c == '/' && i + 1 < chars.length && chars[i + 1] == '/') {
+                chars[i] = ' ';
+                chars[i + 1] = ' ';
+                i++;
+                inLineComment = true;
+            } else if (c == '#') {
+                chars[i] = ' ';
+                inLineComment = true;
+            } else if (c == '/' && i + 1 < chars.length && chars[i + 1] == '*') {
+                chars[i] = ' ';
+                chars[i + 1] = ' ';
+                i++;
+                inBlockComment = true;
+            }
+        }
+        return new String(chars);
+    }
+
+    private static int firstNonWhitespaceCol(String line) {
+        for (int i = 0; i < line.length(); i++) {
+            if (!Character.isWhitespace(line.charAt(i))) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    private static int lastNonWhitespaceCol(String line) {
+        for (int i = line.length() - 1; i >= 0; i--) {
+            if (!Character.isWhitespace(line.charAt(i))) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    private static String nextNonBlankLine(String[] lines, int from) {
+        for (int i = from; i < lines.length; i++) {
+            String trimmed = lines[i].trim();
+            if (!trimmed.isEmpty()) {
+                return trimmed;
+            }
+        }
+        return null;
+    }
+
+    private static int countChar(String s, char c) {
+        int n = 0;
+        for (int i = 0; i < s.length(); i++) {
+            if (s.charAt(i) == c) {
+                n++;
+            }
+        }
+        return n;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLParsers.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLParsers.java
new file mode 100644
index 00000000000..25d38c009bb
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLParsers.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.Lexer;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.drools.drl.parser.antlr4.DRLParserHelper;
+
+/**
+ * Shared ANTLR DRL parser construction for editor features.
+ *
+ * <p>Editor documents are routinely partial or mid-edit, so syntax errors are
+ * the norm rather than the exception. {@link #silent(String)} builds a parser
+ * whose lexer and parser error listeners are both replaced with a no-op, so
+ * feature code (hover, completion, go-to-definition, outline, declared-type
+ * extraction) can parse best-effort without the default listeners printing to
+ * stderr. Features that need to surface parse errors — diagnostics — attach
+ * their own collecting listener instead and don't use this.
+ */
+final class DRLParsers {
+
+    /** A parse-error listener that discards every error. */
+    private static final BaseErrorListener SILENT = new BaseErrorListener() {
+        @Override
+        public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
+                                int line, int charPositionInLine, String msg,
+                                RecognitionException e) {
+        }
+    };
+
+    /**
+     * Counts parser constructions. Lets tests (and ad-hoc profiling) assert that
+     * a single editor operation parses the current document once rather than
+     * re-parsing it in each helper.
+     */
+    private static final AtomicLong PARSE_COUNT = new AtomicLong();
+
+    private DRLParsers() {
+    }
+
+    /**
+     * Creates a DRL parser over {@code text} with the lexer's and parser's
+     * error listeners silenced. Callers typically follow with
+     * {@code parser.compilationUnit()}.
+     */
+    static DRL10Parser silent(String text) {
+        PARSE_COUNT.incrementAndGet();
+        DRL10Parser parser = DRLParserHelper.createDrlParser(text);
+        Lexer lexer = (Lexer) parser.getTokenStream().getTokenSource();
+        lexer.removeErrorListeners();
+        lexer.addErrorListener(SILENT);
+        parser.removeErrorListeners();
+        parser.addErrorListener(SILENT);
+        return parser;
+    }
+
+    /** Number of parsers built since the last {@link #resetParseCount()}. */
+    static long parseCount() {
+        return PARSE_COUNT.get();
+    }
+
+    /** Resets the parser-construction counter; call before a measured operation. */
+    static void resetParseCount() {
+        PARSE_COUNT.set(0);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferenceScanner.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferenceScanner.java
new file mode 100644
index 00000000000..e0b2388e67d
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferenceScanner.java
@@ -0,0 +1,286 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
+import org.drools.drl.parser.antlr4.DRL10Lexer;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Finds the ranges where a symbol is used within a single DRL document — the
+ * shared core of find-references and rename. Two complementary passes:
+ *
+ * <ul>
+ *   <li><b>Structured</b> ({@link #typeOccurrences}) walks the ANTLR parse tree
+ *       and collects the matching type-name token from each grammar position
+ *       that bears a type: pattern object types, {@code declare}/{@code enum}
+ *       names (the declaration site) and their {@code extends} parents, field
+ *       types, global types, function return/parameter types, and import names.
+ *       Because it reads named grammar contexts, it never matches strings,
+ *       comments, field labels, or binding variables.</li>
+ *   <li><b>Consequence</b> the RHS {@code then ... end} block is lexed into
+ *       opaque chunk tokens, so the parse tree can't see type uses there. The
+ *       consequence pass scans {@code RHS_CHUNK} tokens for whole-word matches
+ *       and skips {@code RHS_STRING_LITERAL} / RHS comment tokens.</li>
+ * </ul>
+ *
+ * <p>{@link #bindingOccurrences} is the bound-variable counterpart: it scopes
+ * to the rule enclosing the caret and finds every use of a {@code $binding}
+ * there (LHS tokens and RHS chunks), so rename stays within one rule.
+ */
+final class DRLReferenceScanner {
+
+    private static final Logger logger = Logger.getLogger(DRLReferenceScanner.class.getName());
+
+    private DRLReferenceScanner() {
+    }
+
+    /** A type-name use: its range, and whether it is the declaration site itself. */
+    static final class Occurrence {
+        final Range range;
+        final boolean declaration;
+
+        Occurrence(Range range, boolean declaration) {
+            this.range = range;
+            this.declaration = declaration;
+        }
+    }
+
+    /**
+     * All uses of the simple type name {@code simpleName} in {@code text}:
+     * structured type positions (with the declaration site flagged) plus
+     * whole-word matches inside RHS consequence chunks. Best-effort — any parse
+     * failure yields an empty list.
+     */
+    static List<Occurrence> typeOccurrences(String text, String simpleName) {
+        if (text == null || simpleName == null || simpleName.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return typeOccurrences(ParsedDrl.of(text), simpleName);
+    }
+
+    /** As {@link #typeOccurrences(String, String)}, reusing an existing parse of the document. */
+    static List<Occurrence> typeOccurrences(ParsedDrl parsed, String simpleName) {
+        List<Occurrence> out = new ArrayList<>();
+        if (parsed == null || simpleName == null || simpleName.isEmpty()) {
+            return out;
+        }
+        try {
+            collectStructured(parsed.compilationUnit, simpleName, out);
+            collectConsequence(parsed.tokens(), simpleName, out);
+        } catch (Exception e) {
+            logger.fine(() -> "typeOccurrences failed for " + simpleName + ": " + e.getMessage());
+        }
+        return out;
+    }
+
+    /**
+     * Every use of {@code bindingName} (the {@code $}-prefixed variable) within
+     * the rule (or query) enclosing {@code caret} — both LHS tokens and RHS
+     * consequence chunks. Returns an empty list when the caret is not inside a
+     * rule. Scoping to the enclosing rule is what keeps a binding rename from
+     * touching an identically-named binding in another rule.
+     */
+    static List<Range> bindingOccurrences(String text, Position caret, String bindingName) {
+        if (text == null || caret == null || bindingName == null || bindingName.length() < 2) {
+            return new ArrayList<>();
+        }
+        return bindingOccurrences(ParsedDrl.of(text), caret, bindingName);
+    }
+
+    /** As {@link #bindingOccurrences(String, Position, String)}, reusing an existing parse. */
+    static List<Range> bindingOccurrences(ParsedDrl parsed, Position caret, String bindingName) {
+        List<Range> out = new ArrayList<>();
+        if (parsed == null || caret == null || bindingName == null || bindingName.length() < 2) {
+            return out;
+        }
+        try {
+            Integer tokenIndex = parsed.tokenIndexAt(caret);
+            if (tokenIndex == null) {
+                return out;
+            }
+            ParserRuleContext rule = enclosingRule(parsed.compilationUnit, tokenIndex);
+            if (rule == null || rule.getStart() == null || rule.getStop() == null) {
+                return out;
+            }
+            List<Token> all = parsed.tokens().getTokens();
+            int from = rule.getStart().getTokenIndex();
+            int to = rule.getStop().getTokenIndex();
+            for (int i = from; i <= to && i < all.size(); i++) {
+                Token t = all.get(i);
+                if (t.getType() == DRL10Lexer.RHS_CHUNK) {
+                    // In the consequence, "$p" sits inside a chunk token.
+                    scanChunk(t, bindingName, out);
+                } else if (bindingName.equals(t.getText())) {
+                    // In the LHS, "$p" is a single identifier token (Java letters
+                    // include '$'); string/comment tokens carry their delimiters
+                    // so they never collide with "$name".
+                    out.add(rangeAt(t.getLine(), t.getCharPositionInLine(), bindingName.length()));
+                }
+            }
+        } catch (Exception e) {
+            logger.fine(() -> "bindingOccurrences failed for " + bindingName + ": " + e.getMessage());
+        }
+        return out;
+    }
+
+    // --- structured pass -------------------------------------------------
+
+    private static void collectStructured(ParseTree node, String simpleName, List<Occurrence> out) {
+        if (node == null) {
+            return;
+        }
+        if (node instanceof DRL10Parser.LhsPatternContext p) {
+            if (p.objectType != null) {
+                collectMatches(p.objectType, simpleName, false, out);
+            }
+        } else if (node instanceof DRL10Parser.TypeDeclarationContext td) {
+            if (td.name != null) {
+                collectMatches(td.name, simpleName, true, out);
+            }
+            for (DRL10Parser.DrlQualifiedNameContext st : td.superTypes) {
+                collectMatches(st, simpleName, false, out);
+            }
+        } else if (node instanceof DRL10Parser.EnumDeclarationContext ed) {
+            if (ed.name != null) {
+                collectMatches(ed.name, simpleName, true, out);
+            }
+        } else if (node instanceof DRL10Parser.FieldContext f) {
+            if (f.type() != null) {
+                collectMatches(f.type(), simpleName, false, out);
+            }
+        } else if (node instanceof DRL10Parser.GlobaldefContext g) {
+            if (g.type() != null) {
+                collectMatches(g.type(), simpleName, false, out);
+            }
+        } else if (node instanceof DRL10Parser.FunctiondefContext fn) {
+            if (fn.typeTypeOrVoid() != null) {
+                collectMatches(fn.typeTypeOrVoid(), simpleName, false, out);
+            }
+            if (fn.formalParameters() != null) {
+                collectMatches(fn.formalParameters(), simpleName, false, out);
+            }
+        } else if (node instanceof DRL10Parser.ImportdefContext imp) {
+            collectMatches(imp, simpleName, false, out);
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            collectStructured(node.getChild(i), simpleName, out);
+        }
+    }
+
+    /** Adds an occurrence for every terminal under {@code node} whose text is {@code simpleName}. */
+    private static void collectMatches(ParseTree node, String simpleName, boolean declaration,
+                                       List<Occurrence> out) {
+        if (node instanceof TerminalNode tn) {
+            Token t = tn.getSymbol();
+            if (simpleName.equals(t.getText())) {
+                out.add(new Occurrence(
+                        rangeAt(t.getLine(), t.getCharPositionInLine(), simpleName.length()), declaration));
+            }
+            return;
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            collectMatches(node.getChild(i), simpleName, declaration, out);
+        }
+    }
+
+    // --- consequence (RHS) pass -----------------------------------------
+
+    private static void collectConsequence(CommonTokenStream tokens, String simpleName, List<Occurrence> out) {
+        for (Token t : tokens.getTokens()) {
+            if (t.getType() == DRL10Lexer.RHS_CHUNK) {
+                List<Range> ranges = new ArrayList<>();
+                scanChunk(t, simpleName, ranges);
+                for (Range r : ranges) {
+                    out.add(new Occurrence(r, false));
+                }
+            }
+        }
+    }
+
+    /**
+     * Finds whole-word occurrences of {@code word} inside a single-line RHS
+     * chunk token, appending a range for each. {@code RHS_CHUNK} never spans a
+     * newline, so all matches share the token's line.
+     */
+    private static void scanChunk(Token chunk, String word, List<Range> out) {
+        String text = chunk.getText();
+        int line = chunk.getLine();
+        int baseCol = chunk.getCharPositionInLine();
+        int from = 0;
+        int hit;
+        while ((hit = text.indexOf(word, from)) >= 0) {
+            int end = hit + word.length();
+            boolean leftOk = hit == 0 || !isIdentifierChar(text.charAt(hit - 1));
+            boolean rightOk = end >= text.length() || !isIdentifierChar(text.charAt(end));
+            if (leftOk && rightOk) {
+                out.add(rangeAt(line, baseCol + hit, word.length()));
+            }
+            from = hit + 1;
+        }
+    }
+
+    // --- enclosing-rule lookup ------------------------------------------
+
+    /** Deepest {@code rule}/{@code query} context whose token span contains {@code tokenIndex}. */
+    private static ParserRuleContext enclosingRule(ParseTree node, int tokenIndex) {
+        if (!(node instanceof ParserRuleContext ctx) || !spans(ctx, tokenIndex)) {
+            return null;
+        }
+        ParserRuleContext best =
+                (ctx instanceof DRL10Parser.RuledefContext || ctx instanceof DRL10Parser.QuerydefContext)
+                        ? ctx : null;
+        for (int i = 0; i < ctx.getChildCount(); i++) {
+            ParserRuleContext deeper = enclosingRule(ctx.getChild(i), tokenIndex);
+            if (deeper != null) {
+                best = deeper;
+            }
+        }
+        return best;
+    }
+
+    private static boolean spans(ParserRuleContext ctx, int tokenIndex) {
+        return ctx.getStart() != null && ctx.getStop() != null
+                && ctx.getStart().getTokenIndex() <= tokenIndex
+                && tokenIndex <= ctx.getStop().getTokenIndex();
+    }
+
+    // --- helpers ---------------------------------------------------------
+
+    /** Builds a single-line range from a 1-based ANTLR line and 0-based column. */
+    private static Range rangeAt(int antlrLine, int col, int length) {
+        int line = antlrLine - 1;
+        return new Range(new Position(line, col), new Position(line, col + length));
+    }
+
+    private static boolean isIdentifierChar(char c) {
+        return Character.isLetterOrDigit(c) || c == '_' || c == '$';
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferencesHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferencesHelper.java
new file mode 100644
index 00000000000..869baebaec9
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLReferencesHelper.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+
+/**
+ * Find-references ({@code textDocument/references}) for DRL.
+ *
+ * <p>Resolves the identifier at the caret to a symbol and collects its uses
+ * across the workspace via {@link DRLReferenceScanner}:
+ * <ul>
+ *   <li><b>Bound variable</b> ({@code $x}) — uses within the enclosing rule
+ *       only (rename-safe scoping); current document.</li>
+ *   <li><b>Declared type</b> — uses across the current document and its sibling
+ *       {@code .drl} files (open buffers shadow disk). The {@code declare} site
+ *       is included only when {@code includeDeclaration} is set.</li>
+ *   <li><b>Classpath type</b> — DRL uses across files that resolve the simple
+ *       name to the <em>same</em> FQCN, so a different same-named type from
+ *       another package isn't swept in. The Java source is not scanned, so
+ *       these are read-only references (rename is blocked elsewhere).</li>
+ * </ul>
+ *
+ * <p>All operations are best-effort — any failure yields the references found
+ * so far (typically empty).
+ */
+public final class DRLReferencesHelper {
+
+    private DRLReferencesHelper() {
+    }
+
+    /**
+     * Returns the locations referencing the symbol at {@code position}, or an
+     * empty list when the caret is not on a resolvable symbol.
+     *
+     * @param openFiles          open unsaved sibling buffers keyed by path, so
+     *                           cross-file references reflect unsaved edits
+     * @param includeDeclaration whether to include a declared type's
+     *                           {@code declare} site (LSP {@code ReferenceContext})
+     */
+    public static List<Location> references(String uri, String text, Position position,
+                                            Map<Path, String> openFiles, ClassIndex classIndex,
+                                            Set<Path> buildOutputDirs, boolean includeDeclaration) {
+        if (text == null || position == null) {
+            return new ArrayList<>();
+        }
+        // Parse the current document once; every step below reuses this parse.
+        return collectReferences(uri, ParsedDrl.of(text), position, openFiles, classIndex,
+                buildOutputDirs, includeDeclaration);
+    }
+
+    /** As {@link #references}, reusing an existing parse (rename shares its parse with this). */
+    static List<Location> collectReferences(String uri, ParsedDrl parsed, Position position,
+                                            Map<Path, String> openFiles, ClassIndex classIndex,
+                                            Set<Path> buildOutputDirs, boolean includeDeclaration) {
+        List<Location> out = new ArrayList<>();
+        String word = DRLDefinitionHelper.wordAt(parsed.text, position);
+        if (word.isEmpty()) {
+            return out;
+        }
+        if (DRLDefinitionHelper.caretInCommentOrString(parsed, position)) {
+            return out;
+        }
+        Path docPath = toPath(uri);
+
+        // Bound variable: rule-scoped, current document only.
+        if (word.charAt(0) == '$') {
+            for (var range : DRLReferenceScanner.bindingOccurrences(parsed, position, word)) {
+                out.add(new Location(uri, range));
+            }
+            return out;
+        }
+        if (!Character.isJavaIdentifierStart(word.charAt(0))) {
+            return out;
+        }
+
+        // Declared type anywhere in the workspace → simple-name match across files.
+        Map<String, DeclaredType> typeIndex =
+                DRLWorkspaceTypeIndex.build(parsed.declaredTypes(), docPath, openFiles);
+        if (typeIndex.containsKey(word)) {
+            addTypeRefs(uri, DRLReferenceScanner.typeOccurrences(parsed, word), includeDeclaration, out);
+            DRLWorkspaceTypeIndex.forEachSiblingFile(docPath, openFiles, (fileUri, fileText) ->
+                    addTypeRefs(fileUri, DRLReferenceScanner.typeOccurrences(fileText, word),
+                            includeDeclaration, out));
+            return out;
+        }
+
+        // Classpath type → DRL uses in files resolving the name to the same FQCN.
+        String fqcn = DRLDefinitionHelper.resolveFqcn(parsed, word, classIndex);
+        if (fqcn == null) {
+            return out;
+        }
+        addClasspathRefs(uri, parsed, word, fqcn, classIndex, out);
+        DRLWorkspaceTypeIndex.forEachSiblingFile(docPath, openFiles,
+                (fileUri, fileText) -> addClasspathRefs(fileUri, fileText, word, fqcn, classIndex, out));
+        return out;
+    }
+
+    private static void addTypeRefs(String uri, List<DRLReferenceScanner.Occurrence> occurrences,
+                                    boolean includeDeclaration, List<Location> out) {
+        for (DRLReferenceScanner.Occurrence occ : occurrences) {
+            if (occ.declaration && !includeDeclaration) {
+                continue;
+            }
+            out.add(new Location(uri, occ.range));
+        }
+    }
+
+    /** Current-document classpath refs, reusing the shared parse. */
+    private static void addClasspathRefs(String uri, ParsedDrl parsed, String simpleName, String fqcn,
+                                         ClassIndex classIndex, List<Location> out) {
+        if (!parsed.text.contains(simpleName)) {
+            return;
+        }
+        if (!fqcn.equals(DRLDefinitionHelper.resolveFqcn(parsed, simpleName, classIndex))) {
+            return;
+        }
+        for (DRLReferenceScanner.Occurrence occ : DRLReferenceScanner.typeOccurrences(parsed, simpleName)) {
+            out.add(new Location(uri, occ.range));
+        }
+    }
+
+    /** Sibling-file classpath refs: cheap text pre-check, then a single parse of that sibling. */
+    private static void addClasspathRefs(String uri, String text, String simpleName, String fqcn,
+                                         ClassIndex classIndex, List<Location> out) {
+        if (text == null || !text.contains(simpleName)) {
+            return;
+        }
+        addClasspathRefs(uri, ParsedDrl.of(text), simpleName, fqcn, classIndex, out);
+    }
+
+    /** Converts a document URI to a filesystem path, or null for non-file URIs. */
+    private static Path toPath(String uri) {
+        try {
+            return Path.of(java.net.URI.create(uri));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLRenameHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLRenameHelper.java
new file mode 100644
index 00000000000..456c55472c8
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLRenameHelper.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextEdit;
+import org.eclipse.lsp4j.WorkspaceEdit;
+
+/**
+ * Rename ({@code textDocument/prepareRename} + {@code textDocument/rename}) for
+ * DRL, built on {@link DRLReferencesHelper}: every reference becomes a
+ * {@link TextEdit}, grouped into a {@link WorkspaceEdit} by file.
+ *
+ * <p>Scope policy:
+ * <ul>
+ *   <li><b>Declared types</b> — renamed across all workspace DRL files
+ *       (declaration site included).</li>
+ *   <li><b>Bound variables</b> ({@code $x}) — renamed within the enclosing rule
+ *       only. The new name is normalized to keep its {@code $} prefix.</li>
+ *   <li><b>Classpath types</b> — <em>not</em> renameable: this server must not
+ *       rewrite Java sources or the references the rest of the project has to
+ *       them. {@link #prepare} returns {@code null} so the client refuses.</li>
+ * </ul>
+ *
+ * <p>A new name that is not a legal identifier is rejected (rename yields
+ * {@code null}).
+ */
+public final class DRLRenameHelper {
+
+    private DRLRenameHelper() {
+    }
+
+    /** The editable span of a renameable symbol and its current text. */
+    public static final class Prepared {
+        public final Range range;
+        public final String placeholder;
+
+        Prepared(Range range, String placeholder) {
+            this.range = range;
+            this.placeholder = placeholder;
+        }
+    }
+
+    /**
+     * Returns the editable range + current name when the caret is on a
+     * renameable symbol (a declared type or a bound variable), or {@code null}
+     * otherwise (a classpath type, or nothing renameable).
+     */
+    public static Prepared prepare(String uri, String text, Position position,
+                                   Map<Path, String> openFiles, ClassIndex classIndex,
+                                   Set<Path> buildOutputDirs) {
+        if (text == null || position == null) {
+            return null;
+        }
+        String word = DRLDefinitionHelper.wordAt(text, position);
+        if (word.isEmpty()) {
+            return null;
+        }
+        ParsedDrl parsed = ParsedDrl.of(text);
+        if (DRLDefinitionHelper.caretInCommentOrString(parsed, position)
+                || !isRenameable(word, parsed, uri, openFiles)) {
+            return null;
+        }
+        Range range = DRLDefinitionHelper.wordRangeAt(text, position);
+        return range == null ? null : new Prepared(range, word);
+    }
+
+    /**
+     * Builds the rename edit for the symbol at the caret, or {@code null} when
+     * it is not renameable or {@code newName} is not a legal identifier.
+     */
+    public static WorkspaceEdit rename(String uri, String text, Position position, String newName,
+                                       Map<Path, String> openFiles, ClassIndex classIndex,
+                                       Set<Path> buildOutputDirs) {
+        if (text == null || position == null || newName == null) {
+            return null;
+        }
+        String word = DRLDefinitionHelper.wordAt(text, position);
+        if (word.isEmpty()) {
+            return null;
+        }
+        ParsedDrl parsed = ParsedDrl.of(text);
+
+        String replacement;
+        if (word.charAt(0) == '$') {
+            // Bound variable: always renameable; keep the '$' prefix.
+            String bare = newName.startsWith("$") ? newName.substring(1) : newName;
+            if (!isIdentifier(bare)) {
+                return null;
+            }
+            replacement = "$" + bare;
+        } else {
+            // Type: renameable only when declared in the workspace (not classpath).
+            if (!isDeclaredType(word, parsed, uri, openFiles) || !isIdentifier(newName)) {
+                return null;
+            }
+            replacement = newName;
+        }
+
+        List<Location> refs = DRLReferencesHelper.collectReferences(uri, parsed, position, openFiles,
+                classIndex, buildOutputDirs, true);
+        if (refs.isEmpty()) {
+            return null;
+        }
+        Map<String, List<TextEdit>> changes = new LinkedHashMap<>();
+        for (Location loc : refs) {
+            changes.computeIfAbsent(loc.getUri(), k -> new ArrayList<>())
+                   .add(new TextEdit(loc.getRange(), replacement));
+        }
+        return new WorkspaceEdit(changes);
+    }
+
+    private static boolean isRenameable(String word, ParsedDrl parsed, String uri,
+                                        Map<Path, String> openFiles) {
+        if (word.charAt(0) == '$') {
+            return true; // bound variable
+        }
+        if (!Character.isJavaIdentifierStart(word.charAt(0))) {
+            return false;
+        }
+        // Declared types are renameable; classpath types resolve but are not.
+        return isDeclaredType(word, parsed, uri, openFiles);
+    }
+
+    private static boolean isDeclaredType(String word, ParsedDrl parsed, String uri,
+                                          Map<Path, String> openFiles) {
+        return DRLWorkspaceTypeIndex.build(parsed.declaredTypes(), toPath(uri), openFiles)
+                .containsKey(word);
+    }
+
+    private static boolean isIdentifier(String s) {
+        if (s == null || s.isEmpty() || !Character.isJavaIdentifierStart(s.charAt(0))) {
+            return false;
+        }
+        for (int i = 1; i < s.length(); i++) {
+            if (!Character.isJavaIdentifierPart(s.charAt(i))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Converts a document URI to a filesystem path, or null for non-file URIs. */
+    private static Path toPath(String uri) {
+        try {
+            return Path.of(java.net.URI.create(uri));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java
new file mode 100644
index 00000000000..4dc0797f477
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolKind;
+import org.eclipse.lsp4j.TypeHierarchyItem;
+
+/**
+ * Type hierarchy ({@code textDocument/prepareTypeHierarchy} +
+ * {@code typeHierarchy/supertypes} / {@code subtypes}) for DRL types.
+ *
+ * <ul>
+ *   <li><b>Prepare</b> resolves the identifier at the caret to its declaration:
+ *       a DRL {@code declare} (current doc or sibling) → its {@code .drl}
+ *       declare-site; or a classpath type → its project {@code .java} source
+ *       (navigable only — JAR-only types resolve to nothing).</li>
+ *   <li><b>Supertypes</b>: a declare's {@code extends} parent (declare → {@code .drl},
+ *       Java parent → reflected ancestry, navigable-only); a classpath type's
+ *       direct superclass + interfaces via {@link ClassMemberIndex#supertypesOf}.</li>
+ *   <li><b>Subtypes</b>: reachable declares (current doc + siblings) whose
+ *       {@code extends} names this type. Classpath subtypes aren't enumerable
+ *       without a full classpath scan — see the note in {@link #subtypes}.</li>
+ * </ul>
+ *
+ * <p>An item's {@code data} carries the classpath FQCN for classpath items;
+ * declare items leave it {@code null} and are re-resolved from their {@code uri}
+ * (the declaring {@code .drl}) + {@code name}. All operations are best-effort —
+ * any failure yields an empty list.
+ */
+public final class DRLTypeHierarchyHelper {
+
+    private static final Logger logger = Logger.getLogger(DRLTypeHierarchyHelper.class.getName());
+
+    private DRLTypeHierarchyHelper() {
+    }
+
+    /** A declared type together with the URI of the file that declares it. */
+    private static final class Declared {
+        final String uri;
+        final DeclaredType type;
+
+        Declared(String uri, DeclaredType type) {
+            this.uri = uri;
+            this.type = type;
+        }
+    }
+
+    /**
+     * Resolves the type at {@code position} to a single root {@link TypeHierarchyItem},
+     * or an empty list when the caret isn't on a navigable type.
+     */
+    public static List<TypeHierarchyItem> prepare(String text, Position position, String uri,
+                                                  Map<Path, String> openFiles, ClassIndex classIndex,
+                                                  Set<Path> buildOutputDirs) {
+        try {
+            if (text == null || position == null) {
+                return Collections.emptyList();
+            }
+            String word = DRLDefinitionHelper.wordAt(text, position);
+            if (word.isEmpty() || !Character.isJavaIdentifierStart(word.charAt(0))) {
+                return Collections.emptyList();
+            }
+            Declared declared = locateDeclare(word, text, uri, openFiles);
+            if (declared != null) {
+                return Collections.singletonList(declareItem(declared));
+            }
+            TypeHierarchyItem classpath = classpathItem(
+                    DRLDefinitionHelper.resolveFqcn(text, word, classIndex), buildOutputDirs);
+            return classpath == null ? Collections.emptyList() : Collections.singletonList(classpath);
+        } catch (Exception e) {
+            logger.fine(() -> "prepareTypeHierarchy failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Direct supertypes of {@code item}. {@code declaringText} is the text of the
+     * item's own file (for declares); classpath items ignore it.
+     */
+    public static List<TypeHierarchyItem> supertypes(TypeHierarchyItem item, String declaringText,
+                                                     Map<Path, String> openFiles, ClassIndex classIndex,
+                                                     ClassMemberIndex memberIndex, Set<Path> buildOutputDirs) {
+        try {
+            String fqcn = fqcnData(item);
+            if (fqcn != null) {
+                // Classpath type: direct superclass + interfaces, navigable-only.
+                List<TypeHierarchyItem> out = new ArrayList<>();
+                for (String parent : memberIndex.supertypesOf(fqcn)) {
+                    TypeHierarchyItem parentItem = classpathItem(parent, buildOutputDirs);
+                    if (parentItem != null) {
+                        out.add(parentItem);
+                    }
+                }
+                return out;
+            }
+            // Declare: resolve its single `extends` parent.
+            DeclaredType self = findDeclare(declaringText, item.getName());
+            if (self == null || self.extendsName == null) {
+                return Collections.emptyList();
+            }
+            Declared parentDeclare = locateDeclare(self.extendsName, declaringText, item.getUri(), openFiles);
+            if (parentDeclare != null) {
+                return Collections.singletonList(declareItem(parentDeclare));
+            }
+            TypeHierarchyItem classpathParent = classpathItem(
+                    DRLDefinitionHelper.resolveFqcn(declaringText, self.extendsName, classIndex), buildOutputDirs);
+            return classpathParent == null ? Collections.emptyList()
+                    : Collections.singletonList(classpathParent);
+        } catch (Exception e) {
+            logger.fine(() -> "typeHierarchy/supertypes failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Direct subtypes of {@code item}: reachable declares (the item's file +
+     * siblings) whose {@code extends} names it. {@code declaringText} is the
+     * item's own file text.
+     */
+    public static List<TypeHierarchyItem> subtypes(TypeHierarchyItem item, String declaringText,
+                                                   Map<Path, String> openFiles) {
+        try {
+            if (fqcnData(item) != null) {
+                // build-out: classpath subtypes would need a full classpath scan
+                // (an index of class -> direct subclasses); not enumerable here.
+                return Collections.emptyList();
+            }
+            String name = item.getName();
+            List<TypeHierarchyItem> out = new ArrayList<>();
+            for (DeclaredType dt : DRLDeclaredTypeParser.parseDeclaredTypes(declaringText)) {
+                if (name.equals(dt.extendsName)) {
+                    out.add(declareItem(new Declared(item.getUri(), dt)));
+                }
+            }
+            Path docPath = toPath(item.getUri());
+            if (docPath != null) {
+                DRLWorkspaceTypeIndex.forEachSiblingType(docPath, openFiles, (dt, fileUri) -> {
+                    if (name.equals(dt.extendsName)) {
+                        out.add(declareItem(new Declared(fileUri, dt)));
+                    }
+                });
+            }
+            return out;
+        } catch (Exception e) {
+            logger.fine(() -> "typeHierarchy/subtypes failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /** Finds {@code name}'s declaration in the current document, then siblings. */
+    private static Declared locateDeclare(String name, String text, String uri,
+                                          Map<Path, String> openFiles) {
+        for (DeclaredType dt : DRLDeclaredTypeParser.parseDeclaredTypes(text)) {
+            if (name.equals(dt.name)) {
+                return new Declared(uri, dt);
+            }
+        }
+        Path docPath = toPath(uri);
+        if (docPath == null) {
+            return null;
+        }
+        Declared[] hit = {null};
+        DRLWorkspaceTypeIndex.forEachSiblingType(docPath, openFiles, (dt, fileUri) -> {
+            if (hit[0] == null && name.equals(dt.name)) {
+                hit[0] = new Declared(fileUri, dt);
+            }
+        });
+        return hit[0];
+    }
+
+    private static DeclaredType findDeclare(String text, String name) {
+        if (text == null) {
+            return null;
+        }
+        for (DeclaredType dt : DRLDeclaredTypeParser.parseDeclaredTypes(text)) {
+            if (name.equals(dt.name)) {
+                return dt;
+            }
+        }
+        return null;
+    }
+
+    private static TypeHierarchyItem declareItem(Declared d) {
+        Range range = nameRange(d.type);
+        SymbolKind kind = d.type.isEnum ? SymbolKind.Enum : SymbolKind.Class;
+        TypeHierarchyItem item = new TypeHierarchyItem(d.type.name, kind, d.uri, range, range);
+        if (d.type.extendsName != null) {
+            item.setDetail("extends " + d.type.extendsName);
+        }
+        return item;  // data left null → re-resolved from uri + name
+    }
+
+    /** Builds a navigable classpath item, or {@code null} when no project source resolves. */
+    private static TypeHierarchyItem classpathItem(String fqcn, Set<Path> buildOutputDirs) {
+        if (fqcn == null) {
+            return null;
+        }
+        JavaSourceLocator.Result res = JavaSourceLocator.locate(fqcn, buildOutputDirs);
+        if (res == null) {
+            return null;
+        }
+        TypeHierarchyItem item = new TypeHierarchyItem(simpleName(fqcn), res.kind,
+                res.location.getUri(), res.location.getRange(), res.location.getRange());
+        item.setData(fqcn);
+        item.setDetail(fqcn);
+        return item;
+    }
+
+    private static Range nameRange(DeclaredType type) {
+        return new Range(new Position(type.nameLine, type.nameCol),
+                         new Position(type.nameLine, type.nameCol + type.name.length()));
+    }
+
+    /**
+     * True when {@code item} is a classpath type (an FQCN is stashed in
+     * {@code data}). Such items resolve via reflection and ignore the file text
+     * and sibling buffers, so callers can skip computing those.
+     */
+    public static boolean isClasspathItem(TypeHierarchyItem item) {
+        return fqcnData(item) != null;
+    }
+
+    /** The classpath FQCN stashed in {@code data}, or {@code null} for a declare item. */
+    private static String fqcnData(TypeHierarchyItem item) {
+        Object data = item.getData();
+        if (data == null) {
+            return null;
+        }
+        if (data instanceof String s) {
+            return s.isEmpty() ? null : s;
+        }
+        if (data instanceof com.google.gson.JsonPrimitive primitive) {
+            return primitive.getAsString();
+        }
+        return data.toString();
+    }
+
+    private static String simpleName(String fqcn) {
+        int dot = fqcn.lastIndexOf('.');
+        return dot >= 0 ? fqcn.substring(dot + 1) : fqcn;
+    }
+
+    private static Path toPath(String uri) {
+        try {
+            return Path.of(java.net.URI.create(uri));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLWorkspaceTypeIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLWorkspaceTypeIndex.java
new file mode 100644
index 00000000000..42a198d32cd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLWorkspaceTypeIndex.java
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiConsumer;
+
+/**
+ * Central, layered view of the declared types reachable from a DRL document.
+ * This is the single place the workspace type lookup lives, so every consumer
+ * — hover, completion, go-to-definition (and, later, inlay hints) — shares one
+ * notion of "what types exist and where", regardless of which layer resolved a
+ * given type.
+ *
+ * <p>Layers, highest priority first:
+ * <ol>
+ *   <li>the current document;</li>
+ *   <li>open <em>unsaved</em> sibling buffers (same directory) — their editor
+ *       content is newer than disk, so it shadows the on-disk version;</li>
+ *   <li>on-disk sibling {@code .drl} files from the active
+ *       {@link WorkspaceSiblingResolver}.</li>
+ * </ol>
+ *
+ * <p>The earliest layer to provide a given name wins. All on-disk reads go
+ * through {@link DRLDeclaredTypeParser}'s mtime-keyed cache, so this is cheap
+ * to call per request.
+ */
+public final class DRLWorkspaceTypeIndex {
+
+    private DRLWorkspaceTypeIndex() {
+    }
+
+    /**
+     * Builds the {@code name -> DeclaredType} index for {@code text}. Equivalent
+     * to {@link #build(List, Path, Map)} with {@code text}'s parsed declares as
+     * the current-document layer.
+     */
+    public static Map<String, DeclaredType> build(String text, Path documentPath,
+                                                  Map<Path, String> openFiles) {
+        return build(DRLDeclaredTypeParser.parseDeclaredTypes(text), documentPath, openFiles);
+    }
+
+    /**
+     * Builds the {@code name -> DeclaredType} index from already-parsed
+     * current-document types (the overload completion uses, since it has the
+     * compilation unit in hand and need not re-parse) plus the sibling layers.
+     */
+    public static Map<String, DeclaredType> build(List<DeclaredType> currentDocTypes,
+                                                  Path documentPath, Map<Path, String> openFiles) {
+        Map<String, DeclaredType> byName = new HashMap<>();
+        if (currentDocTypes != null) {
+            for (DeclaredType t : currentDocTypes) {
+                putType(byName, t);
+            }
+        }
+        forEachSiblingType(documentPath, openFiles, (t, uri) -> putType(byName, t));
+        return byName;
+    }
+
+    /**
+     * Builds a {@code typeName -> markdown href} map so doc-comment
+     * {@code {@link}} references render as clickable links to the declaration.
+     * Each href has the form {@code file:///abs/path#L<line>} (1-based line) —
+     * the anchor VSCode hovers understand. Coverage mirrors {@link #build}'s
+     * layers; classpath types are intentionally absent (no source to navigate
+     * to). Returns an empty map when there is nothing navigable.
+     */
+    public static Map<String, String> buildLinkTargets(String text, Path documentPath,
+                                                        Map<Path, String> openFiles) {
+        return buildLinkTargets(DRLDeclaredTypeParser.parseDeclaredTypes(text), text,
+                documentPath, openFiles);
+    }
+
+    /**
+     * As {@link #buildLinkTargets(String, Path, Map)}, reusing the already-parsed
+     * current-document declares instead of re-parsing {@code text}.
+     */
+    public static Map<String, String> buildLinkTargets(List<DeclaredType> currentDocTypes, String text,
+                                                        Path documentPath, Map<Path, String> openFiles) {
+        Map<String, String> targets = new HashMap<>();
+        if (documentPath != null && text != null && !text.isEmpty()) {
+            String uri = documentPath.toUri().toString();
+            for (DeclaredType t : currentDocTypes) {
+                addTarget(targets, t, uri);
+            }
+        }
+        forEachSiblingType(documentPath, openFiles, (t, uri) -> addTarget(targets, t, uri));
+        return targets;
+    }
+
+    /**
+     * Returns the doc comment for declared type {@code name}, read from the
+     * file that declares it (current document, then open buffers, then disk),
+     * or {@code null} when the type is absent or undocumented. Buffer-aware so
+     * an unsaved edit to a sibling's doc is reflected. Extraction is delegated
+     * to {@link DRLDocCommentParser}.
+     */
+    public static String docFor(String name, String text, Path documentPath,
+                                Map<Path, String> openFiles) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        return docFor(name, DRLDeclaredTypeParser.parseDeclaredTypes(text), text, documentPath, openFiles);
+    }
+
+    /**
+     * As {@link #docFor(String, String, Path, Map)}, reusing the already-parsed
+     * current-document declares for the layer-1 check instead of re-parsing.
+     */
+    public static String docFor(String name, List<DeclaredType> currentDocTypes, String text,
+                                Path documentPath, Map<Path, String> openFiles) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        // Layer 1: current document.
+        if (containsName(currentDocTypes, name)) {
+            return DRLDocCommentParser.docFor(text, name);
+        }
+        if (documentPath == null) {
+            return null;
+        }
+        Path docNorm = documentPath.toAbsolutePath().normalize();
+        Path dir = docNorm.getParent();
+        Set<Path> shadowed = new HashSet<>();
+        // Layer 2: open unsaved siblings.
+        if (openFiles != null) {
+            for (Map.Entry<Path, String> e : openFiles.entrySet()) {
+                Path p = normalizedSibling(e.getKey(), docNorm, dir);
+                if (p == null) {
+                    continue;
+                }
+                shadowed.add(p);
+                if (declaresType(e.getValue(), name)) {
+                    return DRLDocCommentParser.docFor(e.getValue(), name);
+                }
+            }
+        }
+        // Layer 3: on-disk siblings.
+        for (Path sibling : WorkspaceSiblingResolvers.active().resolveSiblings(documentPath)) {
+            if (shadowed.contains(sibling.toAbsolutePath().normalize())) {
+                continue;
+            }
+            for (DeclaredType t : DRLDeclaredTypeParser.parseDeclaredTypesCached(sibling)) {
+                if (name.equals(t.name)) {
+                    return DRLDocCommentParser.docFor(readFileSilently(sibling), name);
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Visits every declared type reachable through the sibling layers (open
+     * unsaved buffers first, then on-disk siblings not shadowed by a buffer),
+     * passing each type and its file URI to {@code sink}. The current document
+     * is <em>not</em> included — callers handle that layer themselves.
+     */
+    static void forEachSiblingType(Path documentPath, Map<Path, String> openFiles,
+                                   BiConsumer<DeclaredType, String> sink) {
+        if (documentPath == null) {
+            return;
+        }
+        Path docNorm = documentPath.toAbsolutePath().normalize();
+        Path dir = docNorm.getParent();
+        Set<Path> shadowed = new HashSet<>();
+
+        // Layer 2: open unsaved siblings (same directory, not the current file).
+        if (openFiles != null) {
+            for (Map.Entry<Path, String> e : openFiles.entrySet()) {
+                Path p = normalizedSibling(e.getKey(), docNorm, dir);
+                if (p == null) {
+                    continue;
+                }
+                shadowed.add(p);
+                String uri = p.toUri().toString();
+                for (DeclaredType t : DRLDeclaredTypeParser.parseDeclaredTypes(e.getValue())) {
+                    sink.accept(t, uri);
+                }
+            }
+        }
+
+        // Layer 3: on-disk siblings not shadowed by an open buffer.
+        for (Path sibling : WorkspaceSiblingResolvers.active().resolveSiblings(documentPath)) {
+            if (shadowed.contains(sibling.toAbsolutePath().normalize())) {
+                continue;
+            }
+            String uri = sibling.toUri().toString();
+            for (DeclaredType t : DRLDeclaredTypeParser.parseDeclaredTypesCached(sibling)) {
+                sink.accept(t, uri);
+            }
+        }
+    }
+
+    /**
+     * Visits each sibling {@code .drl} file reachable through the workspace
+     * layers — open unsaved buffers first (their editor text shadows disk),
+     * then on-disk siblings not shadowed by a buffer — passing the file's URI
+     * and its <em>current</em> text to {@code sink}. The current document is
+     * <em>not</em> included; callers scan that themselves. Used by
+     * find-references / rename, which need each sibling's raw text rather than
+     * its parsed declares.
+     */
+    static void forEachSiblingFile(Path documentPath, Map<Path, String> openFiles,
+                                   BiConsumer<String, String> sink) {
+        if (documentPath == null) {
+            return;
+        }
+        Path docNorm = documentPath.toAbsolutePath().normalize();
+        Path dir = docNorm.getParent();
+        Set<Path> shadowed = new HashSet<>();
+
+        // Layer 2: open unsaved siblings (same directory, not the current file).
+        if (openFiles != null) {
+            for (Map.Entry<Path, String> e : openFiles.entrySet()) {
+                Path p = normalizedSibling(e.getKey(), docNorm, dir);
+                if (p == null) {
+                    continue;
+                }
+                shadowed.add(p);
+                sink.accept(p.toUri().toString(), e.getValue());
+            }
+        }
+
+        // Layer 3: on-disk siblings not shadowed by an open buffer.
+        for (Path sibling : WorkspaceSiblingResolvers.active().resolveSiblings(documentPath)) {
+            if (shadowed.contains(sibling.toAbsolutePath().normalize())) {
+                continue;
+            }
+            String content = readFileSilently(sibling);
+            if (content != null) {
+                sink.accept(sibling.toUri().toString(), content);
+            }
+        }
+    }
+
+    /**
+     * Returns the normalized form of {@code candidate} when it is a same-directory
+     * sibling of {@code docNorm} (and not the document itself), else {@code null}.
+     */
+    private static Path normalizedSibling(Path candidate, Path docNorm, Path dir) {
+        if (candidate == null || dir == null) {
+            return null;
+        }
+        Path norm = candidate.toAbsolutePath().normalize();
+        if (norm.equals(docNorm) || !dir.equals(norm.getParent())) {
+            return null;
+        }
+        return norm;
+    }
+
+    private static void putType(Map<String, DeclaredType> byName, DeclaredType t) {
+        if (t != null && t.name != null) {
+            byName.putIfAbsent(t.name, t);
+        }
+    }
+
+    private static void addTarget(Map<String, String> targets, DeclaredType t, String fileUri) {
+        if (t != null && t.name != null && fileUri != null) {
+            // VSCode hover markdown supports file:///...#L<n> for line anchors.
+            targets.putIfAbsent(t.name, fileUri + "#L" + (t.nameLine + 1));
+        }
+    }
+
+    private static boolean declaresType(String text, String name) {
+        if (text == null || text.isEmpty()) {
+            return false;
+        }
+        return containsName(DRLDeclaredTypeParser.parseDeclaredTypes(text), name);
+    }
+
+    private static boolean containsName(List<DeclaredType> types, String name) {
+        if (types != null) {
+            for (DeclaredType t : types) {
+                if (name.equals(t.name)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static String readFileSilently(Path file) {
+        try {
+            return Files.readString(file);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeclaredType.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeclaredType.java
new file mode 100644
index 00000000000..344f4684cab
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeclaredType.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+/** A type declared in DRL via a {@code declare} block. */
+public class DeclaredType {
+
+    public final String name;
+    public final List<Field> fields;
+    public final boolean isEnum;
+    /** 0-based line of the type name token (for future go-to-definition). */
+    public final int nameLine;
+    /** 0-based column of the type name token (for future go-to-definition). */
+    public final int nameCol;
+    /**
+     * Simple name of the parent type from {@code extends}, or {@code null}
+     * when no inheritance is declared.
+     */
+    public final String extendsName;
+
+    DeclaredType(String name, List<Field> fields, boolean isEnum,
+                 int nameLine, int nameCol) {
+        this(name, fields, isEnum, nameLine, nameCol, null);
+    }
+
+    DeclaredType(String name, List<Field> fields, boolean isEnum,
+                 int nameLine, int nameCol, String extendsName) {
+        this.name = name;
+        this.fields = fields;
+        this.isEnum = isEnum;
+        this.nameLine = nameLine;
+        this.nameCol = nameCol;
+        this.extendsName = extendsName;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
new file mode 100644
index 00000000000..be69e1d4006
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+/** A field, bean property, or enum constant of a {@link DeclaredType} or Java class. */
+public class Field {
+
+    public final String name;
+    public final String type;
+    /**
+     * For enum constants declared with constructor arguments
+     * (e.g. {@code LOW(1)}), the raw argument list as written in source —
+     * without the surrounding parentheses. {@code null} for ordinary fields
+     * and for enum constants without arguments.
+     */
+    public final String args;
+
+    Field(String name, String type) {
+        this(name, type, null);
+    }
+
+    Field(String name, String type, String args) {
+        this.name = name;
+        this.type = type;
+        this.args = args;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceLocator.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceLocator.java
new file mode 100644
index 00000000000..c4a70902391
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceLocator.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.SymbolKind;
+
+/**
+ * Maps a classpath FQCN to its project Maven source file, when present, along
+ * with the declaration's range and kind. Shared by go-to-definition and type
+ * hierarchy: when the resolved classpath contains
+ * {@code <module>/target/classes/pkg/Type.class}, the source is
+ * {@code <module>/src/main/java/pkg/Type.java} if it exists.
+ *
+ * <p>JAR classes (and anything without project source) yield {@code null} — i.e.
+ * no navigable location.
+ */
+final class JavaSourceLocator {
+
+    private static final Logger logger = Logger.getLogger(JavaSourceLocator.class.getName());
+
+    /** {@code class|interface|enum|record <Simple>}; %s is the quoted simple name. */
+    private static final String TYPE_DECL_TEMPLATE = "\\b(class|interface|enum|record)\\s+(%s)\\b";
+
+    private JavaSourceLocator() {
+    }
+
+    /** A resolved Java source: its {@link Location} plus the declaration's symbol kind. */
+    static final class Result {
+        final Location location;
+        final SymbolKind kind;
+
+        Result(Location location, SymbolKind kind) {
+            this.location = location;
+            this.kind = kind;
+        }
+    }
+
+    /**
+     * Resolves {@code fqcn} to its project Maven source, or {@code null} when no
+     * source is found (JAR-only classes, or no build-output directories).
+     */
+    static Result locate(String fqcn, Set<Path> buildOutputDirs) {
+        if (fqcn == null || fqcn.isEmpty() || buildOutputDirs == null || buildOutputDirs.isEmpty()) {
+            return null;
+        }
+        String relClass = fqcn.replace('.', '/') + ".class";
+        String relJava = fqcn.replace('.', '/') + ".java";
+        String simpleName = fqcn.substring(fqcn.lastIndexOf('.') + 1);
+
+        for (Path outputDir : buildOutputDirs) {
+            if (!Files.isRegularFile(outputDir.resolve(relClass))) {
+                continue;
+            }
+            // <module>/target/classes → <module>
+            Path target = outputDir.getParent();
+            Path module = target == null ? null : target.getParent();
+            if (module == null) {
+                continue;
+            }
+            Path javaFile = module.resolve("src/main/java").resolve(relJava);
+            if (!Files.isRegularFile(javaFile)) {
+                continue;
+            }
+            return readDeclaration(javaFile, simpleName);
+        }
+        return null;
+    }
+
+    private static Result readDeclaration(Path javaFile, String simpleName) {
+        Pattern decl = Pattern.compile(String.format(TYPE_DECL_TEMPLATE, Pattern.quote(simpleName)));
+        String uri = javaFile.toUri().toString();
+        try {
+            List<String> lines = Files.readAllLines(javaFile);
+            for (int i = 0; i < lines.size(); i++) {
+                Matcher m = decl.matcher(lines.get(i));
+                if (m.find()) {
+                    Range range = new Range(new Position(i, m.start(2)),
+                                            new Position(i, m.start(2) + simpleName.length()));
+                    return new Result(new Location(uri, range), kindOf(m.group(1)));
+                }
+            }
+        } catch (Exception e) {
+            logger.fine(() -> "Could not locate declaration in " + javaFile + ": " + e.getMessage());
+        }
+        // Source exists but the declaration line wasn't found — anchor at the top.
+        return new Result(new Location(uri, new Range(new Position(0, 0), new Position(0, 0))),
+                          SymbolKind.Class);
+    }
+
+    private static SymbolKind kindOf(String keyword) {
+        switch (keyword) {
+            case "interface":
+                return SymbolKind.Interface;
+            case "enum":
+                return SymbolKind.Enum;
+            default:
+                return SymbolKind.Class;  // class, record
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java
new file mode 100644
index 00000000000..748524994c4
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Resolves LHS variable bindings in DRL rule {@code when} sections to their
+ * (simple) type names. Binding shapes handled:
+ *
+ * <ul>
+ *   <li>Outer pattern bindings — {@code $p : Person(...)} → {@code p -> Person}.</li>
+ *   <li>Field bindings — {@code TypeName($n : fieldName)} → {@code n -> } the
+ *       field's declared type, looked up (with inheritance) in the supplied
+ *       type index.</li>
+ *   <li>Nested-path bindings — {@code $name : Person.name} → the tail field's type.</li>
+ *   <li>Accumulate-result bindings — {@code $count : count()} → the function's
+ *       result type, from {@link AccumulateFunctionTypes}.</li>
+ * </ul>
+ *
+ * <p>{@link #collect} scans one {@code when} section and returns both the
+ * name → type map and, for each <em>implicit</em> binding, a {@link Decl} with
+ * the offset where an inlay hint belongs. The whole-text {@link #resolve}
+ * methods merge {@code collect(...).types} across rules — used by completion
+ * and hover; the inlay-hint helper consumes the full {@link Bindings}. All
+ * therefore resolve bindings the same way. Resolution is pure regex over the
+ * text, so it adds no per-keystroke cost beyond the on-demand calls.
+ */
+public final class LhsBindingResolver {
+
+    private static final Pattern RULE_WHEN = Pattern.compile(
+            "(?s)\\brule\\b.*?\\bwhen\\b(.*?)\\bthen\\b");
+    private static final Pattern RULE_BLOCK = Pattern.compile(
+            "(?s)\\brule\\b.*?\\bwhen\\b(.*?)\\bthen\\b.*?\\bend\\b");
+
+    /**
+     * Pattern head: an optional {@code $name :} binding prefix, then the pattern
+     * type, then {@code (}. group(1) is the binding name (or {@code null}); group(2)
+     * the type, which may be fully qualified ({@code org.example.Person}) — the
+     * leading package segments are stripped by {@link #simpleName}. The final
+     * (simple-name) segment must start uppercase, which keeps this from matching
+     * lowercase field accessors/method calls; DRL patterns always have parens, so
+     * the trailing {@code (} is required.
+     */
+    private static final Pattern PATTERN_START = Pattern.compile(
+            "(?:\\$(\\w+)\\s*:\\s*)?\\b((?:\\w+\\.)*[A-Z]\\w*)\\s*\\(");
+
+    private static final Pattern FIELD_BINDING = Pattern.compile(
+            "\\$(\\w+)\\s*:\\s*([a-z]\\w*)(?![\\w.(])");
+
+    /**
+     * Field binding to a nested path, e.g. {@code $name : Person.name}. group(1)
+     * is the binding name; group(2) the dotted path, resolved by walking the
+     * type index in {@link #resolvePath}.
+     */
+    private static final Pattern NESTED_FIELD_BINDING = Pattern.compile(
+            "\\$(\\w+)\\s*:\\s*([a-z]\\w*(?:\\.[a-z]\\w*)+)");
+
+    /**
+     * Accumulate result binding: {@code $count : count()}. group(1) is the binding
+     * name, group(2) the function name (resolved only when it's a known accumulate
+     * function in the injected map).
+     */
+    private static final Pattern ACCUM_RESULT_BINDING = Pattern.compile(
+            "\\$(\\w+)\\s*:\\s*(\\w+)\\s*\\(");
+
+    /**
+     * Narrow fallback for field bindings on JDK types not in the declared index —
+     * the well-known {@code Number} accessor methods used as Drools properties
+     * (e.g. {@code $count : intValue} on a {@code Number(...)} pattern).
+     */
+    private static final Map<String, String> JDK_ACCESSOR_TYPES = Map.of(
+            "intValue", "int",
+            "longValue", "long",
+            "doubleValue", "double",
+            "floatValue", "float",
+            "shortValue", "short",
+            "byteValue", "byte");
+
+    private LhsBindingResolver() {
+    }
+
+    /**
+     * Bindings collected from a single {@code when} section: the name → simple
+     * type map (every binding, including outer-pattern ones) plus, for each
+     * <em>implicit</em> binding (field / nested-path / JDK-accessor / accumulate
+     * result), a {@link Decl} marking where an inlay hint should sit. Outer
+     * pattern bindings ({@code $p : Patient(...)}) appear in {@link #types} but
+     * get no {@code Decl} — their type is already explicit at the site.
+     */
+    public static final class Bindings {
+        public final Map<String, String> types;
+        public final List<Decl> declarations;
+
+        Bindings(Map<String, String> types, List<Decl> declarations) {
+            this.types = types;
+            this.declarations = declarations;
+        }
+    }
+
+    /**
+     * An implicit binding declaration: its name (without {@code $}), resolved
+     * simple type, and {@code hintOffset} — the character offset, relative to the
+     * analyzed {@code when} section, where a {@code ": Type"} hint belongs (right
+     * after the field/path name, or after an accumulate function's close paren).
+     */
+    public static final class Decl {
+        public final String name;
+        public final String type;
+        public final int hintOffset;
+
+        Decl(String name, String type, int hintOffset) {
+            this.name = name;
+            this.type = type;
+            this.hintOffset = hintOffset;
+        }
+    }
+
+    /**
+     * Returns a map of binding name (without the leading {@code $}) to the simple
+     * type name it resolves to, across all rule {@code when} sections in
+     * {@code text}. Field bindings are resolved via {@code typesByName}.
+     */
+    public static Map<String, String> resolve(String text,
+                                              Map<String, DeclaredType> typesByName) {
+        return resolve(text, typesByName, AccumulateFunctionTypes.get());
+    }
+
+    /**
+     * As {@link #resolve(String, Map)}, plus accumulate result bindings.
+     */
+    public static Map<String, String> resolve(String text,
+                                              Map<String, DeclaredType> typesByName,
+                                              Map<String, String> accumResultTypes) {
+        Map<String, String> bindings = new HashMap<>();
+        if (text == null || text.isEmpty()) {
+            return bindings;
+        }
+        Matcher rule = RULE_WHEN.matcher(text);
+        while (rule.find()) {
+            bindings.putAll(collect(rule.group(1), typesByName, accumResultTypes).types);
+        }
+        return bindings;
+    }
+
+    /**
+     * As {@link #resolve(String, Map)}, but scoped to the single rule block
+     * enclosing {@code offset} (a character offset into {@code text}). Callers
+     * with a caret position — hover, go-to-definition — should prefer this:
+     * {@link #resolve} merges bindings across <em>all</em> rules last-write-wins,
+     * so a binding name reused across rules (e.g. {@code $p}) would otherwise
+     * resolve to whichever rule appears last in the file rather than the one
+     * under the caret. Falls back to whole-text {@link #resolve} when
+     * {@code offset} is outside every rule block.
+     */
+    public static Map<String, String> resolveAt(String text, int offset,
+                                                Map<String, DeclaredType> typesByName) {
+        return resolveAt(text, offset, typesByName, AccumulateFunctionTypes.get());
+    }
+
+    /**
+     * As {@link #resolveAt(String, int, Map)}, plus accumulate result bindings.
+     */
+    public static Map<String, String> resolveAt(String text, int offset,
+                                                Map<String, DeclaredType> typesByName,
+                                                Map<String, String> accumResultTypes) {
+        if (text == null || text.isEmpty()) {
+            return new HashMap<>();
+        }
+        Matcher block = RULE_BLOCK.matcher(text);
+        while (block.find()) {
+            if (offset >= block.start() && offset < block.end()) {
+                return collect(block.group(1), typesByName, accumResultTypes).types;
+            }
+        }
+        return resolve(text, typesByName, accumResultTypes);
+    }
+
+    /**
+     * Collects the bindings declared in a single {@code when} section, with
+     * positions. The whole-text {@link #resolve} methods merge
+     * {@code collect(...).types} across rules; the inlay-hint helper uses the
+     * full {@link Bindings} (including {@link Decl} offsets), so completion,
+     * hover and inlay all resolve bindings through this one method.
+     *
+     * <p>Within a section, intra-section key collisions resolve last-write-wins
+     * in scan order (outer patterns, then field/nested bindings per pattern, then
+     * accumulate results).
+     */
+    public static Bindings collect(String whenSection,
+                                   Map<String, DeclaredType> typesByName,
+                                   Map<String, String> accumResultTypes) {
+        Map<String, String> bindings = new HashMap<>();
+        List<Decl> declarations = new ArrayList<>();
+        if (whenSection == null || whenSection.isEmpty()) {
+            return new Bindings(bindings, declarations);
+        }
+
+        Matcher m = PATTERN_START.matcher(whenSection);
+        int searchFrom = 0;
+        while (m.find(searchFrom)) {
+            String outerName = m.group(1);
+            String typeName = simpleName(m.group(2));
+            int openParen = m.end() - 1;
+
+            if (outerName != null) {
+                bindings.put(outerName, typeName);  // outer pattern: type explicit, no Decl
+            }
+
+            int closeParen = findMatchingParen(whenSection, openParen);
+            if (closeParen < 0) {
+                searchFrom = openParen + 1;
+                continue;
+            }
+
+            int bodyStart = openParen + 1;
+            String body = whenSection.substring(bodyStart, closeParen);
+            DeclaredType declared = typesByName.get(typeName);
+
+            Matcher fb = FIELD_BINDING.matcher(body);
+            while (fb.find()) {
+                String field = fb.group(2);
+                String fieldType = (declared != null)
+                        ? lookupFieldType(declared, field, typesByName)
+                        : null;
+                if (fieldType == null) {
+                    // Pattern type isn't a declared DRL type (e.g. a JDK type like
+                    // Number), or the field isn't declared — try the narrow JDK map.
+                    fieldType = JDK_ACCESSOR_TYPES.get(field);
+                }
+                if (fieldType != null) {
+                    bindings.put(fb.group(1), fieldType);
+                    declarations.add(new Decl(fb.group(1), fieldType, bodyStart + fb.end(2)));
+                }
+            }
+
+            if (declared != null) {
+                Matcher nfb = NESTED_FIELD_BINDING.matcher(body);
+                while (nfb.find()) {
+                    String pathType = resolvePath(declared, nfb.group(2), typesByName);
+                    if (pathType != null) {
+                        bindings.put(nfb.group(1), pathType);
+                        declarations.add(new Decl(nfb.group(1), pathType, bodyStart + nfb.end(2)));
+                    }
+                }
+            }
+
+            searchFrom = openParen + 1;
+        }
+
+        if (accumResultTypes != null && !accumResultTypes.isEmpty()) {
+            Matcher af = ACCUM_RESULT_BINDING.matcher(whenSection);
+            while (af.find()) {
+                String resultType = accumResultTypes.get(af.group(2));
+                if (resultType != null) {
+                    bindings.put(af.group(1), resultType);
+                    int open = af.end() - 1;
+                    int close = findMatchingParen(whenSection, open);
+                    int hintOffset = (close >= 0) ? close + 1 : af.end();
+                    declarations.add(new Decl(af.group(1), resultType, hintOffset));
+                }
+            }
+        }
+
+        return new Bindings(bindings, declarations);
+    }
+
+    /**
+     * Looks up the type of {@code fieldName} on {@code type}, walking the
+     * {@link DeclaredType#extendsName} chain if not found directly.
+     */
+    private static String lookupFieldType(DeclaredType type, String fieldName,
+                                          Map<String, DeclaredType> typesByName) {
+        final int maxDepth = 10;
+        DeclaredType current = type;
+        for (int depth = 0; depth < maxDepth && current != null; depth++) {
+            if (current.fields != null) {
+                for (Field f : current.fields) {
+                    if (f != null && fieldName.equals(f.name)) {
+                        return simpleName(f.type);
+                    }
+                }
+            }
+            if (current.extendsName == null) {
+                return null;
+            }
+            current = typesByName.get(current.extendsName);
+        }
+        return null;
+    }
+
+    /**
+     * Walks a dotted field path ({@code ref.dynIndex}) from {@code type},
+     * resolving each segment's field type via {@link #lookupFieldType} and
+     * advancing through {@code typesByName}. Returns the final segment's (simple)
+     * type name, or {@code null} if any segment can't be resolved.
+     */
+    private static String resolvePath(DeclaredType type, String path,
+                                      Map<String, DeclaredType> typesByName) {
+        DeclaredType current = type;
+        String resolved = null;
+        for (String seg : path.split("\\.")) {
+            if (current == null) {
+                return null;
+            }
+            resolved = lookupFieldType(current, seg, typesByName);
+            if (resolved == null) {
+                return null;
+            }
+            current = typesByName.get(resolved);
+        }
+        return resolved;
+    }
+
+    private static String simpleName(String type) {
+        if (type == null) {
+            return null;
+        }
+        int dot = type.lastIndexOf('.');
+        return dot >= 0 ? type.substring(dot + 1) : type;
+    }
+
+    private static int findMatchingParen(String text, int openIdx) {
+        int depth = 1;
+        int i = openIdx + 1;
+        while (i < text.length() && depth > 0) {
+            char c = text.charAt(i);
+            if (c == '(') {
+                depth++;
+            } else if (c == ')') {
+                depth--;
+            }
+            i++;
+        }
+        return depth == 0 ? i - 1 : -1;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ParsedDrl.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ParsedDrl.java
new file mode 100644
index 00000000000..68bb2a74c79
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ParsedDrl.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.drools.drl.parser.antlr4.DRLParserHelper;
+import org.eclipse.lsp4j.Position;
+
+/**
+ * A single parse of one DRL document, shared across the editor features that
+ * operate on it within a request — go-to-definition, find-references, rename,
+ * hover — so the current document is parsed once rather than re-parsed by each
+ * helper.
+ *
+ * <p>Holds the parser (its token stream filled by {@code compilationUnit()}),
+ * the compilation unit, and the {@code declare}d types (extracted lazily, once).
+ * Parse errors are silenced (see {@link DRLParsers#silent}), so a malformed
+ * document yields a best-effort, possibly partial compilation unit and an empty
+ * or partial declared-type list rather than throwing.
+ *
+ * <p>Not thread-safe: build one per request and use it on a single thread. The
+ * parse tree and token stream are read-only once built, so sharing the instance
+ * among the helpers within that request is safe.
+ */
+final class ParsedDrl {
+
+    final String text;
+    private final DRL10Parser parser;
+    final DRL10Parser.CompilationUnitContext compilationUnit;
+    private List<DeclaredType> declaredTypes;
+
+    private ParsedDrl(String text, DRL10Parser parser,
+                      DRL10Parser.CompilationUnitContext compilationUnit) {
+        this.text = text;
+        this.parser = parser;
+        this.compilationUnit = compilationUnit;
+    }
+
+    /** Parses {@code text} once with errors silenced; never returns {@code null}. */
+    static ParsedDrl of(String text) {
+        DRL10Parser parser = DRLParsers.silent(text);
+        return new ParsedDrl(text, parser, parser.compilationUnit());
+    }
+
+    /** The {@code declare}d types in this document, extracted once and cached. */
+    List<DeclaredType> declaredTypes() {
+        if (declaredTypes == null) {
+            try {
+                declaredTypes = DRLDeclaredTypeParser.extractFromCompilationUnit(compilationUnit);
+            } catch (Exception e) {
+                declaredTypes = List.of();
+            }
+        }
+        return declaredTypes;
+    }
+
+    /** This document's filled token stream (all channels), for whole-document scans. */
+    CommonTokenStream tokens() {
+        return (CommonTokenStream) parser.getTokenStream();
+    }
+
+    /**
+     * Index into {@link #tokens()} of the token the caret sits in (or just
+     * after), or {@code null} when {@code position} resolves to no token.
+     */
+    Integer tokenIndexAt(Position position) {
+        return DRLParserHelper.computeTokenIndex(
+                parser, position.getLine() + 1, position.getCharacter());
+    }
+
+    /**
+     * The token under {@code position}, or {@code null} when the caret resolves
+     * to no token. Reuses this document's already-built token stream — no re-lex.
+     */
+    Token tokenAt(Position position) {
+        Integer index = tokenIndexAt(position);
+        CommonTokenStream tokens = tokens();
+        if (index == null || index < 0 || index >= tokens.size()) {
+            return null;
+        }
+        return tokens.get(index);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Tokens.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Tokens.java
new file mode 100644
index 00000000000..407cb003700
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Tokens.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Set;
+
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Lexer;
+
+public class Tokens {
+
+    public static Set<Integer> IGNORED = Set.of(
+            Token.EPSILON, Token.EOF, Token.INVALID_TYPE,
+
+            DRL10Lexer.TIME_INTERVAL, DRL10Lexer.HASH, DRL10Lexer.DRL_UNIFY, DRL10Lexer.NULL_SAFE_DOT, DRL10Lexer.QUESTION_DIV, DRL10Lexer.MISC,
+
+            DRL10Lexer.DECIMAL_LITERAL, DRL10Lexer.HEX_LITERAL,
+            DRL10Lexer.OCT_LITERAL, DRL10Lexer.BINARY_LITERAL, DRL10Lexer.FLOAT_LITERAL, DRL10Lexer.HEX_FLOAT_LITERAL,
+            DRL10Lexer.BOOL_LITERAL, DRL10Lexer.CHAR_LITERAL, DRL10Lexer.STRING_LITERAL, DRL10Lexer.TEXT_BLOCK,
+            DRL10Lexer.NULL_LITERAL, DRL10Lexer.LPAREN, DRL10Lexer.RPAREN, DRL10Lexer.LBRACE, DRL10Lexer.RBRACE, DRL10Lexer.LBRACK,
+            DRL10Lexer.RBRACK, DRL10Lexer.SEMI, DRL10Lexer.COMMA, DRL10Lexer.DOT, DRL10Lexer.ASSIGN, DRL10Lexer.GT, DRL10Lexer.LT,
+            DRL10Lexer.BANG, DRL10Lexer.TILDE, DRL10Lexer.QUESTION, DRL10Lexer.COLON, DRL10Lexer.EQUAL, DRL10Lexer.LE, DRL10Lexer.GE,
+            DRL10Lexer.NOTEQUAL, DRL10Lexer.AND, DRL10Lexer.OR, DRL10Lexer.INC, DRL10Lexer.DEC, DRL10Lexer.ADD, DRL10Lexer.SUB, DRL10Lexer.MUL,
+            DRL10Lexer.DIV, DRL10Lexer.BITAND, DRL10Lexer.BITOR, DRL10Lexer.CARET, DRL10Lexer.MOD, DRL10Lexer.ADD_ASSIGN, DRL10Lexer.SUB_ASSIGN,
+            DRL10Lexer.MUL_ASSIGN, DRL10Lexer.DIV_ASSIGN, DRL10Lexer.AND_ASSIGN, DRL10Lexer.OR_ASSIGN, DRL10Lexer.XOR_ASSIGN,
+            DRL10Lexer.MOD_ASSIGN, DRL10Lexer.LSHIFT_ASSIGN, DRL10Lexer.RSHIFT_ASSIGN, DRL10Lexer.URSHIFT_ASSIGN,
+            DRL10Lexer.ARROW, DRL10Lexer.COLONCOLON, DRL10Lexer.AT, DRL10Lexer.ELLIPSIS, DRL10Lexer.WS, DRL10Lexer.COMMENT,
+            DRL10Lexer.LINE_COMMENT, DRL10Lexer.IDENTIFIER, DRL10Lexer.TEXT,
+            DRL10Lexer.DRL_STRING_LITERAL,
+
+            DRL10Lexer.RHS_COMMENT, DRL10Lexer.RHS_LINE_COMMENT, DRL10Lexer.RHS_STRING_LITERAL, DRL10Lexer.RHS_NAMED_CONSEQUENCE_THEN, DRL10Lexer.RHS_CHUNK
+    );
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolver.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolver.java
new file mode 100644
index 00000000000..4cac309b95c
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolver.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Resolves the set of DRL files that should be considered "siblings" of a
+ * given document — files in the same logical group whose declared types are
+ * in scope for completion and navigation.
+ *
+ * <p>The default resolver (see {@link WorkspaceSiblingResolvers}) groups by
+ * directory: every other {@code .drl} file beside the current one. Hosts
+ * with an explicit grouping model (build configuration, rule sets, …) can
+ * install their own implementation via
+ * {@link WorkspaceSiblingResolvers#setActive}.
+ */
+public interface WorkspaceSiblingResolver {
+
+    /**
+     * Returns the absolute paths of the files grouped with
+     * {@code currentFile} (excluding the file itself), or an empty list when
+     * no grouping applies.
+     */
+    List<Path> resolveSiblings(Path currentFile);
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolvers.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolvers.java
new file mode 100644
index 00000000000..eff2f06a22e
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/WorkspaceSiblingResolvers.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Process-wide registry for the active {@link WorkspaceSiblingResolver}.
+ * Defaults to directory grouping: all other {@code .drl} files in the same
+ * directory as the current document, in stable (sorted) order.
+ */
+public final class WorkspaceSiblingResolvers {
+
+    private static final Logger logger = Logger.getLogger(WorkspaceSiblingResolvers.class.getName());
+
+    private static final WorkspaceSiblingResolver SAME_DIRECTORY =
+            WorkspaceSiblingResolvers::sameDirectorySiblings;
+
+    private static volatile WorkspaceSiblingResolver active = SAME_DIRECTORY;
+
+    private WorkspaceSiblingResolvers() {
+    }
+
+    public static WorkspaceSiblingResolver active() {
+        return active;
+    }
+
+    /**
+     * Installs {@code resolver}, or restores the same-directory default when
+     * {@code null}.
+     */
+    public static void setActive(WorkspaceSiblingResolver resolver) {
+        active = (resolver == null) ? SAME_DIRECTORY : resolver;
+    }
+
+    private static List<Path> sameDirectorySiblings(Path currentFile) {
+        if (currentFile == null) {
+            return Collections.emptyList();
+        }
+        Path dir = currentFile.toAbsolutePath().getParent();
+        if (dir == null || !Files.isDirectory(dir)) {
+            return Collections.emptyList();
+        }
+        Path normalizedCurrent = currentFile.toAbsolutePath().normalize();
+        List<Path> siblings = new ArrayList<>();
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.drl")) {
+            for (Path candidate : stream) {
+                if (!candidate.toAbsolutePath().normalize().equals(normalizedCurrent)) {
+                    siblings.add(candidate);
+                }
+            }
+        } catch (Exception e) {
+            logger.log(Level.WARNING, "Failed to resolve sibling DRL files for " + currentFile, e);
+            return Collections.emptyList();
+        }
+        siblings.sort(Path::compareTo);
+        return siblings;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java
new file mode 100644
index 00000000000..eb118ef5b61
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ClassIndexTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void scanDirectory() throws IOException {
+        Path classesDir = tempDir.resolve("target/classes");
+        Path personClass = classesDir.resolve("org/example/Person.class");
+        Files.createDirectories(personClass.getParent());
+        Files.createFile(personClass);
+
+        Path addressClass = classesDir.resolve("org/example/Address.class");
+        Files.createFile(addressClass);
+
+        // Inner class should be skipped
+        Path innerClass = classesDir.resolve("org/example/Person$Builder.class");
+        Files.createFile(innerClass);
+
+        // module-info should be skipped
+        Path moduleInfo = classesDir.resolve("module-info.class");
+        Files.createFile(moduleInfo);
+
+        ClassIndex index = ClassIndex.build(Set.of(classesDir));
+
+        assertThat(index.getMatching("Per")).containsExactly("org.example.Person");
+        assertThat(index.getMatching("Addr")).containsExactly("org.example.Address");
+        assertThat(index.getMatching("Person$")).isEmpty();
+        assertThat(index.getMatching("module")).isEmpty();
+    }
+
+    @Test
+    void scanJar() throws IOException {
+        Path jarPath = tempDir.resolve("test.jar");
+        try (java.util.jar.JarOutputStream jos = new java.util.jar.JarOutputStream(
+                Files.newOutputStream(jarPath))) {
+            jos.putNextEntry(new java.util.jar.JarEntry("com/acme/Order.class"));
+            jos.closeEntry();
+            jos.putNextEntry(new java.util.jar.JarEntry("com/acme/Order$Item.class"));
+            jos.closeEntry();
+            jos.putNextEntry(new java.util.jar.JarEntry("com/acme/Product.class"));
+            jos.closeEntry();
+        }
+
+        ClassIndex index = ClassIndex.build(Set.of(jarPath));
+
+        assertThat(index.getMatching("Or")).containsExactly("com.acme.Order");
+        assertThat(index.getMatching("Prod")).containsExactly("com.acme.Product");
+        assertThat(index.getMatching("Order$")).isEmpty();
+    }
+
+    @Test
+    void mixedSourcesAndPrefixMatching() throws IOException {
+        Path classesDir = tempDir.resolve("classes");
+        Path personClass = classesDir.resolve("org/example/Person.class");
+        Files.createDirectories(personClass.getParent());
+        Files.createFile(personClass);
+
+        Path jarPath = tempDir.resolve("security.jar");
+        try (java.util.jar.JarOutputStream jos = new java.util.jar.JarOutputStream(
+                Files.newOutputStream(jarPath))) {
+            jos.putNextEntry(new java.util.jar.JarEntry("java/security/Permission.class"));
+            jos.closeEntry();
+        }
+
+        ClassIndex index = ClassIndex.build(Set.of(classesDir, jarPath));
+
+        assertThat(index.getMatching("Per"))
+            .containsExactlyInAnyOrder("org.example.Person", "java.security.Permission");
+        assertThat(index.size()).isEqualTo(2);
+        assertThat(index.getMatching("Xyz")).isEmpty();
+    }
+
+    @Test
+    void emptyIndex() {
+        ClassIndex index = ClassIndex.empty();
+        assertThat(index.getMatching("Any")).isEmpty();
+        assertThat(index.size()).isEqualTo(0);
+    }
+
+    @Test
+    void mergeIndices() throws IOException {
+        Path classesDir = tempDir.resolve("classes");
+        Files.createDirectories(classesDir.resolve("org/example"));
+        Files.createFile(classesDir.resolve("org/example/Person.class"));
+
+        Path jarPath = tempDir.resolve("dep.jar");
+        try (java.util.jar.JarOutputStream jos = new java.util.jar.JarOutputStream(
+                Files.newOutputStream(jarPath))) {
+            jos.putNextEntry(new java.util.jar.JarEntry("com/acme/Order.class"));
+            jos.closeEntry();
+        }
+
+        ClassIndex dirIndex = ClassIndex.build(Set.of(classesDir));
+        ClassIndex jarIndex = ClassIndex.build(Set.of(jarPath));
+        ClassIndex merged = ClassIndex.merge(jarIndex, dirIndex);
+
+        assertThat(merged.getMatching("Per")).containsExactly("org.example.Person");
+        assertThat(merged.getMatching("Or")).containsExactly("com.acme.Order");
+        assertThat(merged.size()).isEqualTo(2);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java
new file mode 100644
index 00000000000..a8b3d7823a0
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+import org.drools.completion.fixtures.InitProbe;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ClassMemberIndexTest {
+
+    private final ClassMemberIndex index =
+            new ClassMemberIndex(getClass().getClassLoader());
+
+    @Test
+    void exposesBeanPropertiesAndPublicFields() {
+        List<Field> members = index.membersOf("org.drools.completion.fixtures.Pet");
+
+        assertThat(members).extracting(f -> f.name)
+                .contains("name", "friendly", "legs");
+        assertThat(members).extracting(f -> f.name)
+                // namedAfter: isNamedAfter() returns String, not boolean — not a property.
+                .doesNotContain("class", "getClass", "ignoredBecauseItTakesArgs", "namedAfter");
+        assertThat(members)
+                .anySatisfy(f -> {
+                    assertThat(f.name).isEqualTo("name");
+                    assertThat(f.type).isEqualTo("String");
+                });
+    }
+
+    @Test
+    void reflectionDoesNotRunStaticInitializers() {
+        index.membersOf("org.drools.completion.fixtures.Pet");
+        assertThat(InitProbe.petInitialized)
+                .as("membersOf must not execute static initializers of user classes")
+                .isFalse();
+    }
+
+    @Test
+    void exposesEnumConstants() {
+        List<Field> members = index.membersOf("org.drools.completion.fixtures.PetKind");
+
+        assertThat(members).extracting(f -> f.name).contains("CAT", "DOG");
+        assertThat(members)
+                .anySatisfy(f -> {
+                    assertThat(f.name).isEqualTo("CAT");
+                    assertThat(f.type).isEqualTo("PetKind");
+                });
+    }
+
+    @Test
+    void supertypesOfReportsSuperclassAndInterfaces() {
+        // ArrayList extends AbstractList implements List, RandomAccess,
+        // Cloneable, Serializable — a stable JDK hierarchy.
+        List<String> supers = index.supertypesOf("java.util.ArrayList");
+
+        assertThat(supers)
+                .contains("java.util.AbstractList", "java.util.List")
+                .as("java.lang.Object is omitted as a supertype")
+                .doesNotContain("java.lang.Object");
+    }
+
+    @Test
+    void supertypesOfOmitsObjectForPlainClass() {
+        // Pet extends Object directly and implements nothing.
+        assertThat(index.supertypesOf("org.drools.completion.fixtures.Pet")).isEmpty();
+    }
+
+    @Test
+    void supertypesOfUnknownClassIsEmpty() {
+        assertThat(index.supertypesOf("does.not.Exist")).isEmpty();
+    }
+
+    @Test
+    void supertypesOfEmptyIndexIsEmpty() {
+        assertThat(ClassMemberIndex.empty().supertypesOf("java.util.ArrayList")).isEmpty();
+    }
+
+    @Test
+    void unknownClassYieldsNoMembers() {
+        assertThat(index.membersOf("does.not.Exist")).isEmpty();
+        // Negative result is cached and stays consistent.
+        assertThat(index.membersOf("does.not.Exist")).isEmpty();
+    }
+
+    @Test
+    void emptyIndexYieldsNoMembers() {
+        assertThat(ClassMemberIndex.empty().membersOf("java.lang.String")).isEmpty();
+    }
+
+    @Test
+    void closingDoesNotCloseExternallyOwnedLoader() throws Exception {
+        ClassLoader borrowed = getClass().getClassLoader();
+        ClassMemberIndex borrowedIndex = new ClassMemberIndex(borrowed);
+
+        borrowedIndex.close();
+        borrowedIndex.close();
+
+        assertThat(borrowed.loadClass("java.lang.String")).isNotNull();
+        ClassMemberIndex.empty().close(); // must not throw
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCodeLensHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCodeLensHelperTest.java
new file mode 100644
index 00000000000..957fd79c08c
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCodeLensHelperTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.lsp4j.CodeLens;
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLCodeLensHelperTest {
+
+    private static final String DRL = """
+            package demo;
+            declare Person
+              name : String
+            end
+            declare Unused
+              x : int
+            end
+            rule R
+              when
+                Person( )
+              then
+                insert(new Person());
+            end
+            """;
+
+    @Test
+    void oneLensPerDeclaredTypeWithUseCount() {
+        List<CodeLens> lenses = DRLCodeLensHelper.codeLenses(
+                "myDocument", DRL, Map.of(), ClassIndex.empty(), Set.of());
+
+        assertThat(lenses).hasSize(2);
+
+        CodeLens person = lensAtLine(lenses, 1);
+        assertThat(person.getCommand().getTitle()).isEqualTo("2 references"); // pattern + RHS new
+        assertThat(person.getCommand().getCommand()).isEqualTo("drools.peekReferences");
+        assertThat(person.getRange().getStart().getCharacter()).isEqualTo(8); // "declare Person"
+
+        CodeLens unused = lensAtLine(lenses, 4);
+        assertThat(unused.getCommand().getTitle()).isEqualTo("0 references");
+    }
+
+    @Test
+    void singleReferenceIsSingular() {
+        String drl = """
+                package demo;
+                declare Person
+                  name : String
+                end
+                rule R
+                  when
+                    Person( )
+                  then
+                end
+                """;
+        List<CodeLens> lenses = DRLCodeLensHelper.codeLenses(
+                "myDocument", drl, Map.of(), ClassIndex.empty(), Set.of());
+
+        assertThat(lenses).hasSize(1);
+        assertThat(lenses.get(0).getCommand().getTitle()).isEqualTo("1 reference");
+    }
+
+    @Test
+    void lensCommandCarriesUriPositionAndLocations() {
+        List<CodeLens> lenses = DRLCodeLensHelper.codeLenses(
+                "myDocument", DRL, Map.of(), ClassIndex.empty(), Set.of());
+
+        List<Object> args = lensAtLine(lenses, 1).getCommand().getArguments();
+        assertThat(args).hasSize(3);
+        assertThat(args.get(0)).isEqualTo("myDocument");
+        assertThat(args.get(1)).isInstanceOfSatisfying(Position.class,
+                p -> assertThat(p.getLine()).isEqualTo(1));
+        assertThat(args.get(2)).asInstanceOf(
+                org.assertj.core.api.InstanceOfAssertFactories.list(Location.class)).hasSize(2);
+    }
+
+    @Test
+    void includesEnumDeclarations() {
+        String drl = "package demo;\ndeclare enum Color\n  RED, GREEN\nend\n";
+        List<CodeLens> lenses = DRLCodeLensHelper.codeLenses(
+                "myDocument", drl, Map.of(), ClassIndex.empty(), Set.of());
+
+        assertThat(lenses).hasSize(1);
+        assertThat(lenses.get(0).getCommand().getTitle()).isEqualTo("0 references");
+    }
+
+    @Test
+    void noLensesForNullText() {
+        assertThat(DRLCodeLensHelper.codeLenses(
+                "myDocument", null, Map.of(), ClassIndex.empty(), Set.of()))
+                .isEmpty();
+    }
+
+    private static CodeLens lensAtLine(List<CodeLens> lenses, int line) {
+        return lenses.stream()
+                .filter(l -> l.getRange().getStart().getLine() == line)
+                .findFirst().orElseThrow();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java
new file mode 100644
index 00000000000..6adf55bbe26
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java
@@ -0,0 +1,461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.MessageActionItem;
+import org.eclipse.lsp4j.MessageParams;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.PublishDiagnosticsParams;
+import org.eclipse.lsp4j.ShowMessageRequestParams;
+import org.eclipse.lsp4j.services.LanguageClient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLCompletionHelperTest {
+
+    @Test
+    void getCompletionItems() {
+        String text = """
+                package org.example;
+                
+                import org.example.model.Dog;
+                
+                global java.util.List list;
+                
+                rule MyRule
+                  when
+                    $dog : Dog(name == "Bart")
+                  then
+                    list.add($dog.getName());
+                end
+                """;
+
+        Position caretPosition = new Position();
+        List<CompletionItem> result;
+
+        // at the beginning of the file
+        caretPosition.setCharacter(0); // caret character(column) position is zero-based
+        caretPosition.setLine(0); // caret line position is zero-based
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("package", "unit"); // once in a file
+        assertThat(completionItemStrings(result)).contains("import", "global", "rule"); // top level statements
+
+        // 'package '
+        caretPosition.setCharacter(8);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(result).isEmpty(); // no completion for identifier
+
+        // 'package org.example;\n\n'
+        caretPosition.setLine(2);
+        caretPosition.setCharacter(0);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).doesNotContain("package");  // once in a file (already used)
+        assertThat(completionItemStrings(result)).contains("unit"); // once in a file (not yet used)
+        assertThat(completionItemStrings(result)).contains("import", "global", "rule"); // top level statements
+
+        // 'import '
+        caretPosition.setLine(2);
+        caretPosition.setCharacter(7);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("static", "function", "accumulate", "acc"); // 'import static' etc.
+
+        // 'import org.example.model.Dog;\n\n'
+        caretPosition.setLine(4);
+        caretPosition.setCharacter(0);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).doesNotContain("package", "unit");  // should be placed before import
+        assertThat(completionItemStrings(result)).contains("import", "global", "rule"); // top level statements
+
+        // 'global '
+        caretPosition.setLine(4);
+        caretPosition.setCharacter(7);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("boolean", "int"); // types (primitives are not useful, but valid)
+
+        // `rule My`
+        caretPosition.setLine(6);
+        caretPosition.setCharacter(7);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(result).isEmpty(); // no completion for identifier
+
+        // `  wh`
+        caretPosition.setLine(7);
+        caretPosition.setCharacter(3);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("when", "salience"); // inside rule
+
+        // `    $d`
+        caretPosition.setLine(8);
+        caretPosition.setCharacter(6);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("exists", "not"); // inside LHS
+
+        // `    $dog : Dog(` — constraint position. The expression-starter keyword
+        // noise is filtered out, and with no class/member index Dog's fields
+        // can't be resolved here, so nothing is offered.
+        caretPosition.setLine(8);
+        caretPosition.setCharacter(15);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(result).isEmpty();
+
+        // `  th`
+        caretPosition.setLine(9);
+        caretPosition.setCharacter(3);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).doesNotContain("salience"); // attribute is not allowed
+        assertThat(completionItemStrings(result)).contains("then", "exists"); // inside LHS
+
+        // `    li`
+        caretPosition.setLine(10);
+        caretPosition.setCharacter(6);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("end"); // inside RHS
+
+        // at the end of the file
+        caretPosition.setLine(12);
+        caretPosition.setCharacter(0);
+        result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient());
+        assertThat(completionItemStrings(result)).contains("import", "global", "rule"); // top level statements
+    }
+
+    @Test
+    void classCompletionInPatternPosition() throws IOException {
+        Path tempDir = Files.createTempDirectory("drools-test-classes");
+        try {
+            Files.createDirectories(tempDir.resolve("org/example"));
+            Files.createFile(tempDir.resolve("org/example/Person.class"));
+            Files.createFile(tempDir.resolve("org/example/Pet.class"));
+            Files.createFile(tempDir.resolve("org/example/Address.class"));
+
+            ClassIndex classIndex = ClassIndex.build(Set.of(tempDir));
+
+            String text = """
+                    package org.example;
+
+                    import org.example.Person;
+
+                    rule MyRule
+                      when
+                        $p : \s
+                      then
+                    end
+                    """;
+
+            Position caretPosition = new Position(6, 8);
+            List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient(), classIndex);
+
+            List<String> labels = result.stream().map(CompletionItem::getLabel).toList();
+            assertThat(labels).contains("Person", "Pet", "Address");
+
+            List<CompletionItem> classItems = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Class)
+                .toList();
+            assertThat(classItems).isNotEmpty();
+
+            CompletionItem personItem = classItems.stream()
+                .filter(item -> item.getLabel().equals("Person")).findFirst().orElseThrow();
+            CompletionItem addressItem = classItems.stream()
+                .filter(item -> item.getLabel().equals("Address")).findFirst().orElseThrow();
+
+            // Person is imported, so should sort before Address
+            assertThat(personItem.getSortText().compareTo(addressItem.getSortText())).isLessThan(0);
+        } finally {
+            try (var walk = Files.walk(tempDir)) {
+                walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+                    try { Files.delete(p); } catch (IOException ignored) {}
+                });
+            }
+        }
+    }
+
+    @Test
+    void classCompletionWithTypedPrefix() throws IOException {
+        Path tempDir = Files.createTempDirectory("drools-test-classes");
+        try {
+            Files.createDirectories(tempDir.resolve("org/example"));
+            Files.createFile(tempDir.resolve("org/example/Person.class"));
+            Files.createFile(tempDir.resolve("org/example/Pet.class"));
+            Files.createFile(tempDir.resolve("org/example/Address.class"));
+
+            ClassIndex classIndex = ClassIndex.build(Set.of(tempDir));
+
+            String text = """
+                    package org.example;
+
+                    rule MyRule
+                      when
+                        $p : Per
+                      then
+                    end
+                    """;
+
+            Position caretPosition = new Position(4, 12);
+            List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(text, caretPosition, getLanguageClient(), classIndex);
+
+            List<String> classLabels = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Class)
+                .map(CompletionItem::getLabel)
+                .toList();
+
+            assertThat(classLabels).containsExactly("Person");
+            assertThat(classLabels).doesNotContain("Pet", "Address");
+        } finally {
+            try (var walk = Files.walk(tempDir)) {
+                walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+                    try { Files.delete(p); } catch (IOException ignored) {}
+                });
+            }
+        }
+    }
+
+    @Test
+    void fieldCompletionFromDeclaredType() {
+        String text = """
+                package demo;
+
+                declare Person
+                  name : String
+                  age : int
+                end
+
+                rule R
+                  when
+                    Person(  )
+                  then
+                end
+                """;
+
+        // Inside the pattern's parens.
+        Position caretPosition = new Position(9, 12);
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        List<CompletionItem> fieldItems = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Field)
+                .toList();
+        assertThat(fieldItems).extracting(CompletionItem::getLabel)
+                .contains("name", "age");
+        CompletionItem nameItem = fieldItems.stream()
+                .filter(item -> item.getLabel().equals("name")).findFirst().orElseThrow();
+        assertThat(nameItem.getDetail()).isEqualTo("String");
+    }
+
+    @Test
+    void fieldCompletionIncludesInheritedDeclareFields() {
+        String text = """
+                package demo;
+
+                declare Person
+                  name : String
+                end
+
+                declare Employee extends Person
+                  salary : double
+                end
+
+                rule R
+                  when
+                    Employee(  )
+                  then
+                end
+                """;
+
+        Position caretPosition = new Position(12, 14);
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        List<String> fieldLabels = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Field)
+                .map(CompletionItem::getLabel)
+                .toList();
+        assertThat(fieldLabels).contains("salary", "name");
+    }
+
+    @Test
+    void fieldCompletionFromSiblingFileDeclare(@TempDir Path tmp) throws IOException {
+        // The Person type is declared in a sibling .drl file in the same
+        // directory as the current document.
+        Files.writeString(tmp.resolve("Types.drl"),
+                "package demo;\ndeclare Person\n  name : String\n  age : int\nend\n");
+        Path currentDoc = tmp.resolve("rules.drl");
+        String text = """
+                package demo;
+
+                rule R
+                  when
+                    Person(  )
+                  then
+                end
+                """;
+        Files.writeString(currentDoc, text);
+
+        Position caretPosition = new Position(4, 12);
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(),
+                ClassIndex.empty(), ClassMemberIndex.empty(), currentDoc);
+
+        List<String> fieldLabels = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Field)
+                .map(CompletionItem::getLabel)
+                .toList();
+        assertThat(fieldLabels).contains("name", "age");
+    }
+
+    @Test
+    void fieldCompletionFromClasspathTypeViaImport() {
+        String text = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                rule R
+                  when
+                    Pet(  )
+                  then
+                end
+                """;
+
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        Position caretPosition = new Position(6, 9);
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+        List<String> fieldLabels = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Field)
+                .map(CompletionItem::getLabel)
+                .toList();
+        assertThat(fieldLabels).contains("name", "friendly", "legs");
+    }
+
+    @Test
+    void fieldCompletionWithCaretRightAfterOpeningParen() {
+        String text = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                rule R
+                  when
+                    Pet(
+                  then
+                end
+                """;
+
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        Position caretPosition = new Position(6, 8); // right after 'Pet('
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+        List<String> fieldLabels = result.stream()
+                .filter(item -> item.getKind() == CompletionItemKind.Field)
+                .map(CompletionItem::getLabel)
+                .toList();
+        assertThat(fieldLabels).contains("name", "friendly", "legs");
+    }
+
+    @Test
+    void constraintPositionOmitsPrimitiveKeywordNoise() {
+        // Inside a pattern's parens c3 also predicts Java expression starters
+        // (boolean/int/new/super/literal tokens). Those are dropped so only the
+        // field completions remain; primitive types stay available elsewhere.
+        String text = """
+                package test;
+
+                declare TestFact
+                    name: String
+                    type: String
+                end
+
+                rule R
+                when
+                    TestFact(  )
+                then
+                end
+                """;
+
+        Position caretPosition = new Position(9, 13); // between the parens of TestFact(  )
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient());
+
+        List<String> labels = result.stream().map(CompletionItem::getLabel).toList();
+        assertThat(labels).contains("name", "type");
+        assertThat(labels).doesNotContain(
+                "boolean", "byte", "char", "short", "int", "long", "float", "double",
+                "new", "super", "drl_big_decimal_literal", "drl_big_integer_literal");
+    }
+
+    @Test
+    void caretBeyondLastTokenDoesNotThrow() {
+        String text = "package demo;\n\nrule R\nwhen\nthen\nend\n";
+        Position caretPosition = new Position(6, 0);
+
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient());
+
+        assertThat(result).isNotNull();
+    }
+
+    private List<String> completionItemStrings(List<CompletionItem> result) {
+        return result.stream().map(CompletionItem::getInsertText).toList();
+    }
+
+    private LanguageClient getLanguageClient() {
+        List<Diagnostic> diagnostics = new ArrayList<>();
+       return  new LanguageClient() {
+            @Override
+            public void telemetryEvent(Object object) {
+            }
+
+            @Override
+            public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) {
+                return null;
+            }
+
+            @Override
+            public void showMessage(MessageParams messageParams) {
+            }
+
+            @Override
+            public void publishDiagnostics(PublishDiagnosticsParams d) {
+                diagnostics.clear();
+                diagnostics.addAll(d.getDiagnostics());
+            }
+
+            @Override
+            public void logMessage(MessageParams message) {
+            }
+        };
+
+    }
+}
\ No newline at end of file
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDeclaredTypeParserTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDeclaredTypeParserTest.java
new file mode 100644
index 00000000000..193193b05cd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDeclaredTypeParserTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.drools.drl.parser.antlr4.DRL10Parser;
+import org.drools.drl.parser.antlr4.DRLParserHelper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDeclaredTypeParserTest {
+
+    private static List<DeclaredType> parse(String drl) {
+        DRL10Parser parser = DRLParserHelper.createDrlParser(drl);
+        parser.removeErrorListeners(); // partial/malformed DRL is expected here
+        return DRLDeclaredTypeParser.extractFromCompilationUnit(parser.compilationUnit());
+    }
+
+    @Test
+    void parsesDeclaredTypeWithFields() {
+        String drl = "package demo;\n"
+                + "declare Person\n"
+                + "  name : String\n"
+                + "  age : int\n"
+                + "end\n";
+        List<DeclaredType> types = parse(drl);
+
+        assertThat(types).hasSize(1);
+        DeclaredType person = types.get(0);
+        assertThat(person.name).isEqualTo("Person");
+        assertThat(person.isEnum).isFalse();
+        assertThat(person.fields).extracting(f -> f.name).containsExactly("name", "age");
+        assertThat(person.fields).extracting(f -> f.type).containsExactly("String", "int");
+    }
+
+    @Test
+    void parsesDeclaredEnumWithConstants() {
+        String drl = "declare enum Severity\n"
+                + "  LOW(1), HIGH(2);\n"
+                + "  level : int\n"
+                + "end\n";
+        List<DeclaredType> types = parse(drl);
+
+        assertThat(types).hasSize(1);
+        DeclaredType severity = types.get(0);
+        assertThat(severity.isEnum).isTrue();
+        assertThat(severity.fields).extracting(f -> f.name)
+                .contains("LOW", "HIGH", "level");
+        assertThat(severity.fields.get(0).type).isEqualTo("Severity");
+        assertThat(severity.fields.get(0).args).isEqualTo("1");
+    }
+
+    @Test
+    void recordsExtendsParent() {
+        String drl = "declare Employee extends Person\n"
+                + "  salary : double\n"
+                + "end\n";
+        List<DeclaredType> types = parse(drl);
+
+        assertThat(types).hasSize(1);
+        assertThat(types.get(0).extendsName).isEqualTo("Person");
+    }
+
+    @Test
+    void malformedSurroundingsStillYieldPartialResults() {
+        // Parse errors after the declare must neither throw nor discard the
+        // already-parsed type. (Recovery from errors *before* a declare may
+        // legitimately consume it — partial results are best-effort.)
+        String drl = "declare Alpha\n"
+                + "  id : long\n"
+                + "end\n"
+                + "rule broken when Person( then\n";
+        List<DeclaredType> types = parse(drl);
+
+        assertThat(types).extracting(t -> t.name).contains("Alpha");
+    }
+
+    @Test
+    void nullAndEmptyTextYieldNoTypes() {
+        assertThat(DRLDeclaredTypeParser.extractFromCompilationUnit(null)).isEmpty();
+        assertThat(parse("")).isEmpty();
+    }
+
+    @Test
+    void cachedFileParsingTracksModificationTime(@TempDir Path tmp) throws Exception {
+        Path file = tmp.resolve("Types.drl");
+        Files.writeString(file, "declare Alpha\n  id : long\nend\n");
+
+        List<DeclaredType> first = DRLDeclaredTypeParser.parseDeclaredTypesCached(file);
+        assertThat(first).extracting(t -> t.name).containsExactly("Alpha");
+
+        // Unchanged file: the cached list is served (same instance).
+        assertThat(DRLDeclaredTypeParser.parseDeclaredTypesCached(file)).isSameAs(first);
+
+        // Changed content with a new mtime is re-parsed.
+        Files.writeString(file, "declare Beta\n  id : long\nend\n");
+        Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(
+                Files.getLastModifiedTime(file).toMillis() + 5_000));
+        assertThat(DRLDeclaredTypeParser.parseDeclaredTypesCached(file))
+                .extracting(t -> t.name).containsExactly("Beta");
+    }
+
+    @Test
+    void cachedFileParsingHandlesMissingFiles(@TempDir Path tmp) {
+        assertThat(DRLDeclaredTypeParser.parseDeclaredTypesCached(tmp.resolve("missing.drl"))).isEmpty();
+        assertThat(DRLDeclaredTypeParser.parseDeclaredTypesCached(null)).isEmpty();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java
new file mode 100644
index 00000000000..aa7ca421d93
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.lsp4j.Location;
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDefinitionHelperTest {
+
+    private static final String DECLARE_DRL = """
+            package demo;
+
+            declare Person
+              name : String
+            end
+
+            rule R
+              when
+                Person( name == "x" )
+              then
+            end
+            """;
+
+    @Test
+    void declaredTypeDefinitionInSameDocument() {
+        // Caret on "Person" inside the pattern.
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", DECLARE_DRL, new Position(8, 6),
+                ClassIndex.empty(), Set.of());
+
+        assertThat(defs).hasSize(1);
+        Location loc = defs.get(0);
+        assertThat(loc.getUri()).isEqualTo("myDocument");
+        assertThat(loc.getRange().getStart().getLine()).isEqualTo(2);
+        assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(8);
+    }
+
+    @Test
+    void javaSourceDefinitionViaMavenConvention(@TempDir Path module) throws Exception {
+        // Maven layout: target/classes holds the compiled class, the source
+        // sits at the conventional src/main/java location.
+        Path classes = module.resolve("target/classes/org/example");
+        Files.createDirectories(classes);
+        Files.createFile(classes.resolve("Pet.class"));
+        Path srcDir = module.resolve("src/main/java/org/example");
+        Files.createDirectories(srcDir);
+        Path petJava = srcDir.resolve("Pet.java");
+        Files.writeString(petJava, "package org.example;\npublic class Pet {\n}\n");
+
+        String drl = """
+                package demo;
+
+                import org.example.Pet;
+
+                rule R
+                  when
+                    Pet( )
+                  then
+                end
+                """;
+
+        // Caret on "Pet" in the pattern.
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", drl, new Position(6, 5),
+                ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+
+        assertThat(defs).hasSize(1);
+        Location loc = defs.get(0);
+        assertThat(loc.getUri()).isEqualTo(petJava.toUri().toString());
+        assertThat(loc.getRange().getStart().getLine()).isEqualTo(1);
+        assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(13);
+    }
+
+    @Test
+    void declaredTypeWinsOverJavaSourceOfTheSameName(@TempDir Path module) throws Exception {
+        Path classes = module.resolve("target/classes/demo");
+        Files.createDirectories(classes);
+        Files.createFile(classes.resolve("Person.class"));
+        Path srcDir = module.resolve("src/main/java/demo");
+        Files.createDirectories(srcDir);
+        Files.writeString(srcDir.resolve("Person.java"),
+                "package demo;\npublic class Person {\n}\n");
+
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", DECLARE_DRL, new Position(8, 6),
+                ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+
+        assertThat(defs).hasSize(1);
+        assertThat(defs.get(0).getUri()).isEqualTo("myDocument");
+    }
+
+    @Test
+    void declaredTypeDefinitionInSiblingFile(@TempDir Path dir) throws Exception {
+        Path types = dir.resolve("Types.drl");
+        Files.writeString(types,
+                "package demo;\ndeclare Person\n  name : String\nend\n");
+        Path current = dir.resolve("rules.drl");
+        String drl = "package demo;\nrule R\n  when\n    Person( )\n  then\nend\n";
+        Files.writeString(current, drl);
+
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                current.toUri().toString(), drl, new Position(3, 5),
+                ClassIndex.empty(), Set.of());
+
+        assertThat(defs).hasSize(1);
+        assertThat(defs.get(0).getUri()).isEqualTo(types.toUri().toString());
+        assertThat(defs.get(0).getRange().getStart().getLine()).isEqualTo(1);
+        assertThat(defs.get(0).getRange().getStart().getCharacter()).isEqualTo(8);
+    }
+
+    @Test
+    void caretInCommentYieldsNoDefinitions() {
+        String drl = """
+                package demo;
+                declare Person
+                  name : String
+                end
+                rule R
+                  when
+                    // Person mentioned
+                    Person( )
+                  then
+                end
+                """;
+        // Caret on "Person" inside the LHS // comment (line 6) — not a real use.
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", drl, new Position(6, 9),
+                ClassIndex.empty(), Set.of());
+
+        assertThat(defs).isEmpty();
+    }
+
+    @Test
+    void unknownSymbolYieldsNoDefinitions() {
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", DECLARE_DRL, new Position(8, 14),
+                ClassIndex.empty(), Set.of());
+        // "name" is a field, not a definable type in v1.
+        assertThat(defs).isEmpty();
+    }
+
+    @Test
+    void findDefinitionsParsesTheCurrentDocumentOnce() {
+        // No siblings (non-file URI), so every parser built is for the current doc.
+        DRLParsers.resetParseCount();
+        DRLDefinitionHelper.findDefinitions(
+                "myDocument", DECLARE_DRL, new Position(8, 6), ClassIndex.empty(), Set.of());
+        assertThat(DRLParsers.parseCount()).isEqualTo(1);
+    }
+
+    @Test
+    void nullTextYieldsNoDefinitions() {
+        assertThat(DRLDefinitionHelper.findDefinitions(
+                "myDocument", null, new Position(0, 0), ClassIndex.empty(), Set.of()))
+                .isEmpty();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDiagnosticHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDiagnosticHelperTest.java
new file mode 100644
index 00000000000..02b47b977b1
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDiagnosticHelperTest.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDiagnosticHelperTest {
+
+    @Test
+    void syntaxErrorProducesDiagnostic() {
+        String text = "rule R when Person( then end";
+        List<Diagnostic> diags = DRLDiagnosticHelper.validate(text);
+        assertThat(diags).isNotEmpty();
+    }
+
+    @Test
+    void cleanFileProducesNoDiagnostics() {
+        String text = "package demo;\n"
+                + "rule \"R\"\n"
+                + "  when\n"
+                + "  then\n"
+                + "end\n";
+        assertThat(DRLDiagnosticHelper.validate(text)).isEmpty();
+    }
+
+    @Test
+    void syntaxErrorHasRangeSeverityAndSource() {
+        // Constraint paren never closed before `then`.
+        String text = "package demo;\n"
+                + "rule \"R\"\n"
+                + "  when Person( then\n"
+                + "end\n";
+        List<Diagnostic> diags = DRLDiagnosticHelper.validate(text);
+
+        assertThat(diags).isNotEmpty();
+        for (Diagnostic d : diags) {
+            assertThat(d.getSeverity()).isEqualTo(DiagnosticSeverity.Error);
+            assertThat(d.getSource()).isEqualTo("drools-parser");
+            assertThat(d.getMessage()).isNotBlank();
+            assertThat(d.getRange().getStart().getLine())
+                    .isEqualTo(d.getRange().getEnd().getLine());
+            assertThat(d.getRange().getEnd().getCharacter())
+                    .isGreaterThan(d.getRange().getStart().getCharacter());
+        }
+        // ANTLR recovery may anchor the error on the broken constraint line
+        // (2) or the following `end` (3), depending on the recovery path —
+        // both are within the broken region.
+        assertThat(diags)
+                .anySatisfy(d -> assertThat(d.getRange().getStart().getLine()).isBetween(2, 3));
+    }
+
+    @Test
+    void multipleBrokenRulesReportMultipleDiagnostics() {
+        String text = "package demo;\n"
+                + "rule \"A\"\n"
+                + "  when Person( then\n"
+                + "end\n"
+                + "rule \"B\"\n"
+                + "  when Account( then\n"
+                + "end\n";
+        List<Diagnostic> diags = DRLDiagnosticHelper.validate(text);
+
+        assertThat(diags.size()).isGreaterThanOrEqualTo(2);
+        assertThat(diags.stream().map(d -> d.getRange().getStart().getLine()).distinct().count())
+                .isGreaterThanOrEqualTo(2);
+    }
+
+    @Test
+    void lexerErrorProducesDiagnosticWithSaneRange() {
+        // Unterminated string literal — reported by the lexer, where the
+        // offending symbol is null rather than a Token.
+        String text = "package demo;\n"
+                + "rule \"R\n"
+                + "  when\n"
+                + "  then\n"
+                + "end\n";
+        List<Diagnostic> diags = DRLDiagnosticHelper.validate(text);
+
+        assertThat(diags).isNotEmpty();
+        for (Diagnostic d : diags) {
+            assertThat(d.getRange().getStart().getCharacter())
+                    .isGreaterThanOrEqualTo(0);
+            assertThat(d.getRange().getEnd().getCharacter())
+                    .isGreaterThan(d.getRange().getStart().getCharacter());
+        }
+    }
+
+    @Test
+    void eofErrorRangeStaysWithinTheText() {
+        // Truncated input: the offending symbol is the EOF token, whose
+        // getText() is the 5-char literal "<EOF>" — the range must not be
+        // widened past the actual end of the text by that placeholder.
+        String text = "rule R when";
+        List<Diagnostic> diags = DRLDiagnosticHelper.validate(text);
+
+        assertThat(diags).isNotEmpty();
+        for (Diagnostic d : diags) {
+            assertThat(d.getRange().getEnd().getCharacter())
+                    .as("range end must not extend past the line (len=%d): %s",
+                            text.length(), d)
+                    .isLessThanOrEqualTo(text.length());
+        }
+    }
+
+    @Test
+    void nullTextReturnsEmptyDiagnostics() {
+        assertThat(DRLDiagnosticHelper.validate(null)).isEmpty();
+    }
+
+    @Test
+    void emptyTextReturnsNoErrors() {
+        assertThat(DRLDiagnosticHelper.validate("")).isEmpty();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocCommentParserTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocCommentParserTest.java
new file mode 100644
index 00000000000..39ec58cda81
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocCommentParserTest.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDocCommentParserTest {
+
+    @Test
+    void emptyOrNullReturnsEmpty() {
+        assertThat(DRLDocCommentParser.parseDocs(null)).isEmpty();
+        assertThat(DRLDocCommentParser.parseDocs("")).isEmpty();
+    }
+
+    @Test
+    void docOnDeclaredTypeIsCaptured() {
+        String drl =
+            "/**\n" +
+            " * A reference to a clinical question's location\n" +
+            " * within a lesion assessment.\n" +
+            " */\n" +
+            "declare RelevantLesionState\n" +
+            "    ref : QuestionRef\n" +
+            "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsOnlyKeys("RelevantLesionState");
+        assertThat(docs.get("RelevantLesionState"))
+            .isEqualTo("A reference to a clinical question's location\n"
+                + "within a lesion assessment.");
+    }
+
+    @Test
+    void docOnFunctionIsCaptured() {
+        String drl =
+            "/**\n" +
+            " * Returns the sum of two ints.\n" +
+            " */\n" +
+            "function int add(int a, int b) {\n" +
+            "    return a + b;\n" +
+            "}\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsEntry("add", "Returns the sum of two ints.");
+    }
+
+    @Test
+    void docOnQueryIsCaptured() {
+        String drl =
+            "/** Finds patients over the given age. */\n" +
+            "query findPatientsOver(int minAge)\n" +
+            "    $p : Patient(age > minAge)\n" +
+            "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsEntry("findPatientsOver",
+            "Finds patients over the given age.");
+    }
+
+    @Test
+    void docOnQuotedQueryNameIsCaptured() {
+        String drl =
+            "/** Finds adults. */\n" +
+            "query \"Adults\"\n" +
+            "    $p : Patient(age >= 18)\n" +
+            "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsEntry("Adults", "Finds adults.");
+    }
+
+    @Test
+    void docOnGlobalIsCaptured() {
+        String drl =
+            "/** Audit log handle shared across rules. */\n" +
+            "global org.example.AuditLog auditLog\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsEntry("auditLog",
+            "Audit log handle shared across rules.");
+    }
+
+    @Test
+    void docOnDeclareEnumIsCaptured() {
+        // `declare enum X` uses the `enum` keyword between `declare` and
+        // the name. The captured key must be X, not "enum".
+        String drl =
+            "/** Lookup table of lesion categories. */\n"
+            + "declare enum LesionCategories\n"
+            + "  Target,\n"
+            + "  NonTarget,\n"
+            + "  New;\n"
+            + "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsOnlyKeys("LesionCategories");
+        assertThat(docs.get("LesionCategories"))
+            .isEqualTo("Lookup table of lesion categories.");
+    }
+
+    @Test
+    void docOnDeclareWithExtendsIsCaptured() {
+        String drl =
+            "/** A child fact type. */\n" +
+            "declare InputLocationFact extends LocationFact\n" +
+            "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsEntry("InputLocationFact", "A child fact type.");
+    }
+
+    @Test
+    void docNotImmediatelyFollowedByDeclIsIgnored() {
+        // A free-standing /** ... */ comment without a following declaration
+        // shouldn't pollute the map.
+        String drl =
+            "/** This is just floating text. */\n"
+            + "\n"
+            + "// Some other code.\n"
+            + "rule \"r\" when then end\n";
+
+        assertThat(DRLDocCommentParser.parseDocs(drl)).isEmpty();
+    }
+
+    @Test
+    void multipleDocsOnDifferentDeclsAreAllCaptured() {
+        String drl =
+            "/** Doc for A. */\n"
+            + "declare A\n  x : int\nend\n"
+            + "\n"
+            + "/** Doc for B. */\n"
+            + "declare B\n  y : int\nend\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs)
+            .containsEntry("A", "Doc for A.")
+            .containsEntry("B", "Doc for B.");
+    }
+
+    @Test
+    void decorativeBannerBeforeImportsDoesNotPollutLaterDocs() {
+        // Real-world layout: a `/**** Imports ****/` banner, followed by
+        // imports, then a `/**** Inference facts ****/` banner, then the
+        // actual `/** real doc */` immediately above the declare. Only the
+        // real doc should attach to the type -- the banners must not be
+        // smuggled in by lazy regex expansion.
+        String drl =
+            "package com.example;\n"
+            + "\n"
+            + "/**************************\n"
+            + "Imports from base\n"
+            + "**************************/\n"
+            + "import com.example.Foo;\n"
+            + "import com.example.Bar;\n"
+            + "\n"
+            + "/**************************\n"
+            + "Inference facts\n"
+            + "**************************/\n"
+            + "/** Real doc for LesionCategory. */\n"
+            + "declare LesionCategory\n"
+            + "  x : int\n"
+            + "end\n";
+
+        Map<String, String> docs = DRLDocCommentParser.parseDocs(drl);
+
+        assertThat(docs).containsOnlyKeys("LesionCategory");
+        assertThat(docs.get("LesionCategory"))
+            .isEqualTo("Real doc for LesionCategory.");
+    }
+
+    @Test
+    void bannerCommentWithThreeOrMoreStarsIsNotADoc() {
+        // Even when a `/**** banner ****/` sits immediately above a
+        // declaration, it must not be treated as documentation: three or
+        // more leading stars marks it as a regular block comment by
+        // Javadoc convention.
+        String drl =
+            "/**************************\n"
+            + "Banner above LesionCategory\n"
+            + "**************************/\n"
+            + "declare LesionCategory\n"
+            + "  x : int\n"
+            + "end\n";
+
+        assertThat(DRLDocCommentParser.parseDocs(drl)).isEmpty();
+    }
+
+    @Test
+    void docConvenienceLookup() {
+        String drl = "/** Hello. */\ndeclare Foo\nend\n";
+        assertThat(DRLDocCommentParser.docFor(drl, "Foo")).isEqualTo("Hello.");
+        assertThat(DRLDocCommentParser.docFor(drl, "Bar")).isNull();
+        assertThat(DRLDocCommentParser.docFor(null, "Foo")).isNull();
+        assertThat(DRLDocCommentParser.docFor(drl, null)).isNull();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocFormatterTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocFormatterTest.java
new file mode 100644
index 00000000000..2ced0d301dd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocFormatterTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDocFormatterTest {
+
+    @Test
+    void inlineCodeBecomesBackticks() {
+        assertThat(DRLDocFormatter.format("uses {@code getX()} here", null))
+                .isEqualTo("uses `getX()` here");
+    }
+
+    @Test
+    void literalContentIsMarkdownEscaped() {
+        assertThat(DRLDocFormatter.format("{@literal *not bold*}", null))
+                .isEqualTo("\\*not bold\\*");
+    }
+
+    @Test
+    void linkWithTargetRendersMarkdownLink() {
+        Map<String, String> targets = Map.of("Person", "file:///types.drl");
+        assertThat(DRLDocFormatter.format("see {@link Person}", targets))
+                .isEqualTo("see [Person](file:///types.drl)");
+    }
+
+    @Test
+    void linkLabelIsUsedWhenPresent() {
+        Map<String, String> targets = Map.of("Person", "file:///types.drl");
+        assertThat(DRLDocFormatter.format("see {@link Person the patient}", targets))
+                .isEqualTo("see [the patient](file:///types.drl)");
+    }
+
+    @Test
+    void memberReferenceLooksUpTheTypePart() {
+        Map<String, String> targets = Map.of("Person", "file:///types.drl");
+        assertThat(DRLDocFormatter.format("{@link Person#name}", targets))
+                .isEqualTo("[Person#name](file:///types.drl)");
+    }
+
+    @Test
+    void unresolvedLinkFallsBackToCode() {
+        assertThat(DRLDocFormatter.format("see {@link Person}", null))
+                .isEqualTo("see `Person`");
+    }
+
+    @Test
+    void unresolvedLinkplainFallsBackToPlainText() {
+        assertThat(DRLDocFormatter.format("see {@linkplain Person}", null))
+                .isEqualTo("see Person");
+    }
+
+    @Test
+    void unsupportedTagsAreLeftUntouched() {
+        assertThat(DRLDocFormatter.format("{@value Config#MAX}", null))
+                .isEqualTo("{@value Config#MAX}");
+    }
+
+    @Test
+    void nullAndEmptyPassThrough() {
+        assertThat(DRLDocFormatter.format(null, null)).isNull();
+        assertThat(DRLDocFormatter.format("", null)).isEmpty();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocumentSymbolHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocumentSymbolHelperTest.java
new file mode 100644
index 00000000000..af7d29a6ace
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDocumentSymbolHelperTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+import org.eclipse.lsp4j.DocumentSymbol;
+import org.eclipse.lsp4j.SymbolKind;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DRLDocumentSymbolHelperTest {
+
+    @Test
+    void emptyOrNullYieldsNoSymbols() {
+        assertThat(DRLDocumentSymbolHelper.symbols(null)).isEmpty();
+        assertThat(DRLDocumentSymbolHelper.symbols("")).isEmpty();
+    }
+
+    @Test
+    void outlinesAllTopLevelConstructs() {
+        String drl = "package demo;\n"
+                + "global java.util.List auditLog\n"
+                + "function int add(int a, int b) { return a + b; }\n"
+                + "declare Person\n"
+                + "  name : String\n"
+                + "  age : int\n"
+                + "end\n"
+                + "declare enum Color\n"
+                + "  RED, GREEN, BLUE;\n"
+                + "end\n"
+                + "query \"adults\"\n"
+                + "  $p : Person(age >= 18)\n"
+                + "end\n"
+                + "rule \"R1\"\n"
+                + "  when\n"
+                + "    Person()\n"
+                + "  then\n"
+                + "end\n";
+
+        List<DocumentSymbol> symbols = DRLDocumentSymbolHelper.symbols(drl);
+
+        assertThat(symbols).extracting(DocumentSymbol::getName)
+                .containsExactly("auditLog", "add", "Person", "Color", "adults", "R1");
+        assertThat(symbols).extracting(DocumentSymbol::getKind)
+                .containsExactly(SymbolKind.Variable, SymbolKind.Function, SymbolKind.Class,
+                                 SymbolKind.Enum, SymbolKind.Method, SymbolKind.Method);
+    }
+
+    @Test
+    void declareNestsFieldsAsChildren() {
+        String drl = "declare Person\n"
+                + "  name : String\n"
+                + "  age : int\n"
+                + "end\n";
+
+        DocumentSymbol person = DRLDocumentSymbolHelper.symbols(drl).get(0);
+
+        assertThat(person.getName()).isEqualTo("Person");
+        assertThat(person.getKind()).isEqualTo(SymbolKind.Class);
+        assertThat(person.getChildren()).extracting(DocumentSymbol::getName)
+                .containsExactly("name", "age");
+        assertThat(person.getChildren()).allSatisfy(c ->
+                assertThat(c.getKind()).isEqualTo(SymbolKind.Field));
+        assertThat(person.getChildren().get(0).getDetail()).isEqualTo("String");
+    }
+
+    @Test
+    void enumNestsConstantsAsChildren() {
+        String drl = "declare enum Color\n"
+                + "  RED, GREEN, BLUE;\n"
+                + "end\n";
+
+        DocumentSymbol color = DRLDocumentSymbolHelper.symbols(drl).get(0);
+
+        assertThat(color.getKind()).isEqualTo(SymbolKind.Enum);
+        assertThat(color.getChildren()).extracting(DocumentSymbol::getName)
+                .containsExactly("RED", "GREEN", "BLUE");
+        assertThat(color.getChildren()).allSatisfy(c ->
+                assertThat(c.getKind()).isEqualTo(SymbolKind.EnumMember));
+    }
+
+    @Test
+    void selectionRangeIsWithinFullRange() {
+        String drl = "rule \"R1\"\n  when\n  then\nend\n";
+
+        DocumentSymbol rule = DRLDocumentSymbolHelper.symbols(drl).get(0);
+
+        // Full range spans the rule block; selection range marks the name and
+        // sits inside it (LSP requirement).
+        assertThat(rule.getRange().getStart().getLine()).isEqualTo(0);
+        assertThat(rule.getSelectionRange().getStart().getLine())
+                .isGreaterThanOrEqualTo(rule.getRange().getStart().getLine());
+        assertThat(rule.getRange().getEnd().getLine())
+                .isGreaterThan(rule.getRange().getStart().getLine());
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLFoldingRangeHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLFoldingRangeHelperTest.java
new file mode 100644
index 00000000000..3ee3a176012
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLFoldingRangeHelperTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.completion;
+
+import java.util.List;
+
+import org.eclipse.lsp4j.FoldingRange;
+import org.eclipse.lsp4j.FoldingRangeKind;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+class DRLFoldingRangeHelperTest {
+
+    @Test
+    void emptyOrNullYieldsNoRanges() {
+        assertThat(DRLFoldingRangeHelper.foldingRanges(null)).isEmpty();
+        assertThat(DRLFoldingRangeHelper.foldingRanges("")).isEmpty();
+    }
+
+    @Test
+    void foldsConstructsAndBlockComments() {
+        String drl =
+            "/*\n"                           // 0
+            + " * banner\n"                  // 1
+            + " */\n"                        // 2
+            + "global java.util.List log\n"  // 3 (single line -> no fold)
+            + "declare Person\n"             // 4
+            + "  name : String\n"            // 5
+            + "end\n"                        // 6
+            + "rule \"R\"\n"                 // 7
+            + "  when\n"                     // 8
+            + "  then\n"                     // 9
+            + "end\n";                       // 10
+
+        List<FoldingRange> ranges = DRLFoldingRangeHelper.foldingRanges(drl);
+
+        assertThat(ranges)
... 14555 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@kie.apache.org
For additional commands, e-mail: commits-help@kie.apache.org