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>[<|>|==|<=|>=|.|!=]</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