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/08/25 01:10:01 UTC
(incubator-kie-tools) branch main updated: kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)
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 47daecc1f91 kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)
47daecc1f91 is described below
commit 47daecc1f91ebea19772bac89450e240254122f0
Author: Daniel Clark <da...@gmail.com>
AuthorDate: Tue Aug 25 03:09:56 2026 +0200
kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)
---
packages/drl-vscode-extension/README.md | 11 +
packages/drl-vscode-extension/package.json | 16 +
packages/drl-vscode-extension/src/extension.ts | 23 +-
.../drools/completion/AccumulateFunctionTypes.java | 8 +-
.../java/org/drools/completion/ClassIndex.java | 26 +-
.../org/drools/completion/ClassMemberIndex.java | 172 ++++-
.../drools/completion/ClasspathTypeMembers.java | 98 +++
.../org/drools/completion/DRLCompletionHelper.java | 201 ++++++
.../drools/completion/DRLDeclaredTypeParser.java | 3 +
.../org/drools/completion/DRLDefinitionHelper.java | 43 +-
.../java/org/drools/completion/DRLHoverHelper.java | 431 ++++++++++++-
.../org/drools/completion/DRLInlayHintHelper.java | 7 +-
.../drools/completion/DRLTypeHierarchyHelper.java | 52 +-
.../src/main/java/org/drools/completion/Field.java | 11 +-
.../org/drools/completion/JavaMemberSource.java | 49 ++
.../org/drools/completion/JavaSourceRoots.java | 146 +++++
.../java/org/drools/completion/JavaSourceType.java | 68 ++
.../org/drools/completion/JavaSourceTypeIndex.java | 379 ++++++++++++
.../drools/completion/JavaSourceTypeParser.java | 462 ++++++++++++++
.../org/drools/completion/LhsBindingResolver.java | 86 ++-
.../java/org/drools/completion/ClassIndexTest.java | 44 ++
.../drools/completion/ClassMemberIndexTest.java | 115 +++-
.../drools/completion/DRLCompletionHelperTest.java | 291 +++++++++
.../drools/completion/DRLDefinitionHelperTest.java | 81 ++-
.../org/drools/completion/DRLHoverHelperTest.java | 689 +++++++++++++++++++++
.../drools/completion/DRLInlayHintHelperTest.java | 59 ++
.../org/drools/completion/DRLLintHelperTest.java | 28 +
.../completion/DRLTypeHierarchyHelperTest.java | 177 +++++-
.../java/org/drools/completion/FieldTest.java} | 34 +-
.../drools/completion/JavaSourceTypeIndexTest.java | 257 ++++++++
.../completion/JavaSourceTypeParserTest.java | 162 +++++
.../drools/completion/LhsBindingResolverTest.java | 95 +++
.../lsp/server/DroolsLspDocumentService.java | 69 ++-
.../org/drools/lsp/server/DroolsLspServer.java | 164 ++++-
.../lsp/server/DroolsLspDocumentServiceTest.java | 29 +-
.../lsp/server/JavaSourceTypingServerTest.java | 183 ++++++
36 files changed, 4639 insertions(+), 130 deletions(-)
diff --git a/packages/drl-vscode-extension/README.md b/packages/drl-vscode-extension/README.md
index 6b2927e1116..13b95ad00cf 100644
--- a/packages/drl-vscode-extension/README.md
+++ b/packages/drl-vscode-extension/README.md
@@ -128,9 +128,20 @@ Same-named groups from several files are merged, with a warning.
| `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 |
+| `drools.lsp.java.sourcePaths` | `[]` | Extra Java source roots, beyond those found automatically |
+| `drools.lsp.java.packageFilters` | `[]` | Package prefixes limiting which Java source types are indexed |
All lint settings accept: `off`, `hint`, `info`, `warning`, `error`.
+The project's own Java types resolve from `.java` sources, so completion, hover,
+navigation and the unknown-type lint work on a fresh checkout, before Maven has
+produced any class files. Compiled classes take precedence as soon as they
+exist. Every `src/main/java` directory under the workspace is found
+automatically, so `drools.lsp.java.sourcePaths` is only needed for source roots
+that sit elsewhere; each entry is a literal directory path, absolute or
+workspace-relative, not a glob. Both settings are read when the language server
+starts, so changing either needs a restart.
+
## 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/package.json b/packages/drl-vscode-extension/package.json
index e7d7c478f53..36709db081b 100644
--- a/packages/drl-vscode-extension/package.json
+++ b/packages/drl-vscode-extension/package.json
@@ -182,6 +182,22 @@
"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.java.packageFilters": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "markdownDescription": "Package prefixes limiting which Java source types are indexed, e.g. `com.example.*`. Empty means all packages. Applied when the language server next starts.",
+ "type": "array"
+ },
+ "drools.lsp.java.sourcePaths": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "markdownDescription": "Additional Java source roots used to resolve the project's own types before a build. Every `src/main/java` under the workspace is found automatically, so this is only for roots that sit elsewhere. Each entry is a literal directory path, absolute or workspace-relative — not a glob. Applied when the language server next starts.",
+ "type": "array"
+ },
"drools.lsp.lint.missingEnd": {
"default": "warning",
"description": "Severity for the missing 'end' keyword lint check.",
diff --git a/packages/drl-vscode-extension/src/extension.ts b/packages/drl-vscode-extension/src/extension.ts
index 07ec83664a7..c16e3161d5c 100644
--- a/packages/drl-vscode-extension/src/extension.ts
+++ b/packages/drl-vscode-extension/src/extension.ts
@@ -151,6 +151,22 @@ export async function activate(context: vscode.ExtensionContext) {
args.push(`-Ddrools.lsp.maven.pomPath=${resolved.join(path.delimiter)}`);
}
+ // Java source roots and package filters for resolving the project's own
+ // types before a build. Joined with ';' regardless of platform, since the
+ // server splits on that; read at spawn, so a change needs a restart.
+ const javaSourcePaths = (config.get<string[]>("drools.lsp.java.sourcePaths") ?? [])
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0);
+ if (javaSourcePaths.length > 0) {
+ args.push(`-Ddrools.lsp.java.sourcePaths=${javaSourcePaths.join(";")}`);
+ }
+ const javaPackageFilters = (config.get<string[]>("drools.lsp.java.packageFilters") ?? [])
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0);
+ if (javaPackageFilters.length > 0) {
+ args.push(`-Ddrools.lsp.java.packageFilters=${javaPackageFilters.join(";")}`);
+ }
+
args.push("-jar", serverJar);
serverOptions = {
@@ -165,7 +181,12 @@ export async function activate(context: vscode.ExtensionContext) {
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: "file", language: "drools" }],
synchronize: {
- fileEvents: vscode.workspace.createFileSystemWatcher("**/target/classes/**/*.class"),
+ fileEvents: [
+ vscode.workspace.createFileSystemWatcher("**/target/classes/**/*.class"),
+ // Feeds .java edits into the server's watched-files debounce, so the
+ // source type index tracks them without waiting for a build.
+ vscode.workspace.createFileSystemWatcher("**/*.java"),
+ ],
},
outputChannel: channel,
// Sent with `initialize`, so the server has both the grouping and the file
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
index f5082229b01..5871ac0fe92 100644
--- 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
@@ -26,8 +26,8 @@ import java.util.Map;
* 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 —
+ * <p>Seeded with Drools 10.2.0's complete set of 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};
@@ -45,10 +45,14 @@ public final class AccumulateFunctionTypes {
Map.entry("maxN", "Number"),
Map.entry("maxI", "Integer"),
Map.entry("maxL", "Long"),
+ Map.entry("maxBI", "BigInteger"),
+ Map.entry("maxBD", "BigDecimal"),
Map.entry("min", "Comparable"),
Map.entry("minN", "Number"),
Map.entry("minI", "Integer"),
Map.entry("minL", "Long"),
+ Map.entry("minBI", "BigInteger"),
+ Map.entry("minBD", "BigDecimal"),
Map.entry("count", "Long"),
Map.entry("collectList", "List"),
Map.entry("collectSet", "Set"),
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
index 8a2b2339283..97940a4af7e 100644
--- 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
@@ -23,6 +23,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -47,6 +48,15 @@ public class ClassIndex {
return new ClassIndex(new HashMap<>());
}
+ /** Builds an index directly from a simple-name → FQCNs map (defensively copied). */
+ public static ClassIndex of(Map<String, List<String>> nameToFqcns) {
+ Map<String, List<String>> index = new HashMap<>();
+ for (Map.Entry<String, List<String>> e : nameToFqcns.entrySet()) {
+ index.put(e.getKey(), new ArrayList<>(e.getValue()));
+ }
+ return new ClassIndex(index);
+ }
+
public static ClassIndex build(Set<Path> classpathEntries) {
Map<String, List<String>> index = new HashMap<>();
for (Path entry : classpathEntries) {
@@ -63,9 +73,9 @@ public class ClassIndex {
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);
+ java.util.LinkedHashSet<String> combined = new java.util.LinkedHashSet<>(a);
combined.addAll(b);
- return combined;
+ return new ArrayList<>(combined);
});
}
return new ClassIndex(merged);
@@ -85,6 +95,18 @@ public class ClassIndex {
return java.util.Collections.unmodifiableSet(index.keySet());
}
+ /**
+ * The FQCNs carrying exactly {@code simpleName}, or empty. The index is
+ * keyed by simple name, so this is a lookup — unlike {@link #getMatching},
+ * which is a prefix search over every entry and answers for longer names
+ * too. Callers confirming one name should prefer this: they run per pattern
+ * per request, where a scan of the whole index is felt.
+ */
+ public List<String> forSimpleName(String simpleName) {
+ List<String> fqcns = simpleName == null ? null : index.get(simpleName);
+ return fqcns == null ? List.of() : Collections.unmodifiableList(fqcns);
+ }
+
public List<String> getMatching(String prefix) {
List<String> result = new ArrayList<>();
for (Map.Entry<String, List<String>> entry : index.entrySet()) {
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
index a52ea369ddf..31eaa94fbdc 100644
--- 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
@@ -45,6 +45,10 @@ import java.util.logging.Logger;
* 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.
+ *
+ * <p>When reflection can't load a type, an optional {@link JavaMemberSource}
+ * fallback (e.g. one backed by uncompiled {@code .java} sources) is consulted
+ * instead — compiled classes always win over the fallback when both know a type.
*/
public final class ClassMemberIndex implements AutoCloseable {
@@ -55,6 +59,8 @@ public final class ClassMemberIndex implements AutoCloseable {
private final ClassLoader loader;
private final boolean ownsLoader;
private final Map<String, List<Field>> cache = new ConcurrentHashMap<>();
+ private final Set<String> notLoadable = ConcurrentHashMap.newKeySet();
+ private volatile JavaMemberSource fallback;
/** Visible for tests: reflect against an existing (externally-owned) loader. */
ClassMemberIndex(ClassLoader loader) {
@@ -71,10 +77,32 @@ public final class ClassMemberIndex implements AutoCloseable {
return EMPTY;
}
- /** Builds an index over the given classpath entries (jars and class dirs). */
+ /**
+ * Sets the fallback consulted when reflection can't load a type — the
+ * server owns the single instance and sets this right after construction.
+ * No-op on the shared {@link #empty()} index. Public so a caller in
+ * another module can install the fallback after building an
+ * {@link #of} instance.
+ */
+ public void setSourceFallback(JavaMemberSource f) {
+ if (this == EMPTY) {
+ logger.log(Level.FINE, "ignoring source fallback on shared empty index");
+ return;
+ }
+ this.fallback = f;
+ }
+
+ /**
+ * Builds an index over the given classpath entries (jars and class dirs).
+ * A {@code null}/empty set yields a fresh, fallback-capable instance (no
+ * loader, so every lookup misses reflection and falls straight through to
+ * a fallback set via {@link #setSourceFallback} — the source-only,
+ * works-before-build workspace) rather than the shared {@link #empty()}
+ * singleton, which never accepts a fallback.
+ */
public static ClassMemberIndex of(Set<Path> classpathEntries) {
if (classpathEntries == null || classpathEntries.isEmpty()) {
- return EMPTY;
+ return new ClassMemberIndex((ClassLoader) null, false);
}
List<URL> urls = new ArrayList<>(classpathEntries.size());
for (Path entry : classpathEntries) {
@@ -92,6 +120,7 @@ public final class ClassMemberIndex implements AutoCloseable {
@Override
public void close() {
cache.clear();
+ notLoadable.clear();
if (ownsLoader && loader instanceof java.io.Closeable closeable) {
try {
closeable.close();
@@ -104,32 +133,46 @@ public final class ClassMemberIndex implements AutoCloseable {
/**
* 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.
+ * methods (including inherited ones), and public instance fields. When
+ * the class cannot be loaded, consults the {@link #fallback} source (if
+ * set); empty when neither knows the type. Compiled classes win: a
+ * successful reflection load is always used over the fallback.
*/
public List<Field> membersOf(String fqcn) {
- if (loader == null || fqcn == null || fqcn.isEmpty()) {
+ if (fqcn == null || fqcn.isEmpty()) {
return Collections.emptyList();
}
- return cache.computeIfAbsent(fqcn, this::reflectMembers);
+ List<Field> cached = cache.get(fqcn);
+ if (cached != null) {
+ return cached;
+ }
+ Class<?> clazz = tryLoad(fqcn);
+ if (clazz == null) {
+ // Not cached: the source index maintains its own mtime-based cache.
+ JavaMemberSource f = fallback;
+ return f != null ? f.membersOf(fqcn) : Collections.emptyList();
+ }
+ return cache.computeIfAbsent(fqcn, key -> reflectMembers(clazz));
}
/**
- * 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.
+ * Returns {@code fqcn}'s direct supertypes — its superclass (omitting
+ * {@code java.lang.Object}) followed by its directly-implemented
+ * interfaces. Loaded without running static initializers. When the class
+ * can't be loaded, consults the {@link #fallback} source (if set); empty
+ * when neither knows the type. Used by type hierarchy to walk classpath
+ * ancestry one level at a time. Both the reflected path and the {@link
+ * #fallback} path return fully-qualified names — see {@link
+ * JavaMemberSource#supertypesOf} for the fallback's resolution contract.
*/
public List<String> supertypesOf(String fqcn) {
- if (loader == null || fqcn == null || fqcn.isEmpty()) {
+ if (fqcn == null || fqcn.isEmpty()) {
return Collections.emptyList();
}
- Class<?> clazz;
- try {
- clazz = Class.forName(fqcn, false, loader);
- } catch (Throwable t) {
- return Collections.emptyList();
+ Class<?> clazz = tryLoad(fqcn);
+ if (clazz == null) {
+ JavaMemberSource f = fallback;
+ return f != null ? f.supertypesOf(fqcn) : Collections.emptyList();
}
try {
List<String> out = new ArrayList<>();
@@ -151,19 +194,19 @@ public final class ClassMemberIndex implements AutoCloseable {
* 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
+ * cannot be loaded and the {@link #fallback} source (if set) doesn't know
+ * it either — letting callers distinguish "no such member" (a real typo)
+ * from "couldn't verify" (classpath/source 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()) {
+ if (fqcn == null || fqcn.isEmpty()) {
return null;
}
- Class<?> clazz;
- try {
- clazz = Class.forName(fqcn, false, loader);
- } catch (Throwable t) {
- return null;
+ Class<?> clazz = tryLoad(fqcn);
+ if (clazz == null) {
+ JavaMemberSource f = fallback;
+ return f != null ? f.memberNames(fqcn) : null;
}
try {
Set<String> names = new LinkedHashSet<>();
@@ -180,20 +223,85 @@ public final class ClassMemberIndex implements AutoCloseable {
}
}
- private List<Field> reflectMembers(String fqcn) {
- Class<?> clazz;
+ /**
+ * Reflected public constructor signatures of {@code fqcn} —
+ * {@code SimpleName(ParamSimple, ParamSimple)}, in the order the JVM
+ * reports them — or the {@link #fallback} source's when the class can't
+ * load, or empty when neither knows the type. Not cached: constructors
+ * are only reflected on hover.
+ */
+ public List<String> constructorsOf(String fqcn) {
+ if (fqcn == null || fqcn.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Class<?> clazz = tryLoad(fqcn);
+ if (clazz == null) {
+ JavaMemberSource f = fallback;
+ return f != null ? f.constructorsOf(fqcn) : Collections.emptyList();
+ }
try {
- clazz = Class.forName(fqcn, false, loader);
+ List<String> out = new ArrayList<>();
+ for (java.lang.reflect.Constructor<?> ctor : clazz.getDeclaredConstructors()) {
+ if (!Modifier.isPublic(ctor.getModifiers())) {
+ continue;
+ }
+ StringBuilder signature = new StringBuilder(clazz.getSimpleName()).append('(');
+ Class<?>[] params = ctor.getParameterTypes();
+ for (int i = 0; i < params.length; i++) {
+ if (i > 0) {
+ signature.append(", ");
+ }
+ signature.append(params[i].getSimpleName());
+ }
+ signature.append(')');
+ out.add(signature.toString());
+ }
+ return Collections.unmodifiableList(out);
} catch (Throwable t) {
+ logger.log(Level.FINE, "Failed to reflect constructors of " + fqcn, t);
return Collections.emptyList();
}
+ }
+
+ /**
+ * Attempts to load {@code fqcn} without running static initializers.
+ * Returns {@code null} (rather than throwing) when the loader is absent
+ * or the class can't be loaded — the signal callers use to fall back to
+ * {@link #fallback}. A failed load is memoized in {@link #notLoadable} so
+ * repeated lookups of the same unloadable FQCN (the common case for a
+ * works-before-build workspace) skip the {@code Class.forName}
+ * attempt; the fallback itself is still consulted fresh every call.
+ */
+ private Class<?> tryLoad(String fqcn) {
+ if (loader == null || notLoadable.contains(fqcn)) {
+ return null;
+ }
+ try {
+ return Class.forName(fqcn, false, loader);
+ } catch (ClassNotFoundException e) {
+ // Settled: the name is not on this classpath, and asking again on
+ // every keystroke would cost the same answer.
+ notLoadable.add(fqcn);
+ return null;
+ } catch (Throwable t) {
+ // A link error can be transient — a dependency jar rewritten by a
+ // build running alongside the editor — so the name is not written
+ // off. Logged because the effect is otherwise invisible: an
+ // unloadable class also reports "unverifiable" to the lint, which
+ // then stops flagging typos on it.
+ logger.log(Level.FINE, t, () -> "Could not load " + fqcn);
+ return null;
+ }
+ }
+
+ private List<Field> reflectMembers(Class<?> clazz) {
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()));
+ new Field(f.getName(), clazz.getSimpleName(), null, Field.Origin.ENUM_CONSTANT));
}
}
}
@@ -201,19 +309,19 @@ public final class ClassMemberIndex implements AutoCloseable {
String property = propertyNameOf(m);
if (property != null) {
members.putIfAbsent(property,
- new Field(property, m.getReturnType().getSimpleName()));
+ new Field(property, m.getReturnType().getSimpleName(), null, Field.Origin.GETTER));
}
}
for (java.lang.reflect.Field f : clazz.getFields()) {
if (!Modifier.isStatic(f.getModifiers())) {
members.putIfAbsent(f.getName(),
- new Field(f.getName(), f.getType().getSimpleName()));
+ new Field(f.getName(), f.getType().getSimpleName(), null, Field.Origin.FIELD));
}
}
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);
+ logger.log(Level.FINE, "Failed to reflect members of " + clazz.getName(), t);
return Collections.emptyList();
}
}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java
new file mode 100644
index 00000000000..847b2a78fbd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java
@@ -0,0 +1,98 @@
+/*
+ * 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.List;
+import java.util.function.Function;
+import java.util.logging.Logger;
+
+/**
+ * Members of types the DRL does not declare, supplied by the host.
+ *
+ * <p>A DRL {@code declare} may extend a Java class
+ * ({@code declare Assessment extends InputFact}), and a pattern may name a Java
+ * class directly. In both cases the type's members live on the classpath — or,
+ * before a build, in workspace sources — not in the declared-type index, so the
+ * inheritance walks in {@link DRLDeclaredTypeParser#fieldsIncludingInherited}
+ * and {@link LhsBindingResolver} terminate at the boundary and the inherited
+ * members simply vanish from hover, completion, and binding types.
+ *
+ * <p>The host installs a lookup that resolves a type name to its members with
+ * inherited ones already folded in; this class is the single seam both walks
+ * consult. Absent a lookup every method is inert, so the module works
+ * standalone, and in tests, with no host at all.
+ *
+ * <p>The lookup is keyed by the type name as the DRL writes it, which carries no
+ * import context. A host resolving a bare simple name therefore cannot tell
+ * which of two same-named classes the document meant, and answers for neither.
+ * Closing that needs the document's imports to reach the lookup — the callers
+ * are text-level and hold none today — so the seam would take a resolved name
+ * instead of a simple one.
+ */
+public final class ClasspathTypeMembers {
+
+ private static final Logger logger = Logger.getLogger(ClasspathTypeMembers.class.getName());
+
+ private static volatile Function<String, List<Field>> lookup;
+
+ private ClasspathTypeMembers() {
+ }
+
+ /**
+ * Installs the members lookup, keyed by the type name as written in the DRL
+ * (simple or qualified) and returning members with inherited ones folded in.
+ * {@code null} clears it. The host is expected to read its live type indexes
+ * inside the function so a rebuilt classpath needs no re-install.
+ */
+ public static void install(Function<String, List<Field>> memberLookup) {
+ lookup = memberLookup;
+ }
+
+ /** Members of {@code typeName}, or empty when unknown or no lookup is installed. */
+ static List<Field> membersOf(String typeName) {
+ Function<String, List<Field>> current = lookup;
+ if (current == null || typeName == null || typeName.isEmpty()) {
+ return Collections.emptyList();
+ }
+ try {
+ List<Field> members = current.apply(typeName);
+ return members == null ? Collections.emptyList() : members;
+ } catch (RuntimeException e) {
+ logger.fine(() -> "Member lookup failed for " + typeName + ": " + e.getMessage());
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * {@code typeName} as a {@link DeclaredType} stand-in so the declared-type
+ * walks can continue through it, or {@code null} when it has no known
+ * members. The stand-in carries no supertype of its own: the member list is
+ * already flattened, so a walk must stop there rather than looking for a
+ * parent the host has already accounted for.
+ */
+ static DeclaredType asDeclaredType(String typeName) {
+ List<Field> members = membersOf(typeName);
+ if (members.isEmpty()) {
+ return null;
+ }
+ return new DeclaredType(typeName, members, false, 0, 0);
+ }
+}
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
index ea393cf3d8b..6360758cb19 100644
--- 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
@@ -20,6 +20,7 @@ package org.drools.completion;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -77,6 +78,19 @@ public class DRLCompletionHelper {
DRL10Parser.DRL_BIG_DECIMAL_LITERAL, DRL10Parser.DRL_BIG_INTEGER_LITERAL)
).collect(Collectors.toUnmodifiableSet());
+ // Rule attributes are only legal in a rule/query header. c3 predicts them at
+ // a statement boundary too (after `end`, or in an empty document), where they
+ // cannot be typed — 14 unusable items next to the 6 that are usable. They are
+ // dropped there only; the rule header keeps offering them.
+ private static final Set<Integer> RULE_ATTRIBUTE_TOKENS = Set.of(
+ DRL10Parser.DRL_ATTRIBUTES, DRL10Parser.DRL_SALIENCE, DRL10Parser.DRL_ENABLED,
+ DRL10Parser.DRL_NO_LOOP, DRL10Parser.DRL_AUTO_FOCUS, DRL10Parser.DRL_LOCK_ON_ACTIVE,
+ DRL10Parser.DRL_ACTIVATION_GROUP, DRL10Parser.DRL_RULEFLOW_GROUP,
+ DRL10Parser.DRL_DATE_EFFECTIVE, DRL10Parser.DRL_DATE_EXPIRES,
+ DRL10Parser.DRL_DIALECT, DRL10Parser.DRL_CALENDARS,
+ DRL10Parser.DRL_TIMER, DRL10Parser.DRL_DURATION
+ );
+
private DRLCompletionHelper() {
}
@@ -121,6 +135,22 @@ public class DRLCompletionHelper {
? nodeIndex
: drlParser.getInputStream().size() - 1;
+ // A caret directly after a dot is a member position: the only thing that
+ // can follow is a member of the path's type. c3 predicts the constraint
+ // operators there instead (it cannot see that the path is incomplete), so
+ // this position is answered from the type walk alone, keywords included —
+ // but only when the walk recognises the path. A dot also separates the
+ // segments of a qualified name, where the head names no type and the
+ // walk must leave the position to c3 rather than answer with nothing.
+ String[] chain = dottedChainBeforeCaret(text, caretPosition);
+ if (chain != null) {
+ List<CompletionItem> members = memberItemsForChain(chain, text, caretPosition,
+ compilationUnit, caretTokenIndex, classIndex, memberIndex, documentPath, openFiles);
+ if (members != null) {
+ return members;
+ }
+ }
+
// 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
@@ -148,9 +178,11 @@ public class DRLCompletionHelper {
}
boolean constraintPosition = compilationUnit != null && isConstraintPosition(candidates);
+ boolean statementBoundary = isStatementBoundary(candidates);
List<CompletionItem> items = candidates.tokens.keySet().stream().filter(Objects::nonNull)
.filter(token -> !(constraintPosition && CONSTRAINT_KEYWORD_NOISE.contains(token)))
+ .filter(token -> !(statementBoundary && RULE_ATTRIBUTE_TOKENS.contains(token)))
.map(integer -> drlParser.getVocabulary().getDisplayName(integer).replace("'", ""))
.map(String::toLowerCase)
.map(k -> createCompletionItem(k, CompletionItemKind.Keyword))
@@ -172,6 +204,16 @@ public class DRLCompletionHelper {
return path != null && path.contains(DRL10Parser.RULE_constraint);
}
+ /**
+ * Whether the caret sits where a whole top-level statement can start, which
+ * c3 reports by predicting the statement keywords together. A rule header
+ * predicts the attributes without them, so the two positions stay distinct.
+ */
+ private static boolean isStatementBoundary(CodeCompletionCore.CandidatesCollection candidates) {
+ return candidates.tokens.containsKey(DRL10Parser.IMPORT)
+ && candidates.tokens.containsKey(DRL10Parser.DRL_RULE);
+ }
+
/**
* Completion items for the fields of the pattern enclosing the caret:
* fields of a DRL-declared type (current document first, then sibling
@@ -208,6 +250,165 @@ public class DRLCompletionHelper {
return fieldItems(memberIndex.membersOf(fqcn));
}
+ /**
+ * The dot-separated segments preceding a caret that sits immediately after a
+ * dot ({@code $ref.order.|} → {@code [$ref, order]}), or {@code null} when the
+ * caret follows something else. A numeric head is a decimal literal being
+ * typed, not a path.
+ */
+ private static String[] dottedChainBeforeCaret(String text, Position caret) {
+ if (text == null || caret == null) {
+ return null;
+ }
+ String[] lines = text.split("\r?\n", -1);
+ int row = caret.getLine();
+ if (row < 0 || row >= lines.length) {
+ return null;
+ }
+ String line = lines[row];
+ int col = Math.min(caret.getCharacter(), line.length());
+ if (col == 0 || line.charAt(col - 1) != '.') {
+ return null;
+ }
+ int start = col - 1;
+ while (start > 0 && isChainChar(line.charAt(start - 1))) {
+ start--;
+ }
+ String path = line.substring(start, col - 1);
+ if (path.isEmpty() || path.endsWith(".") || Character.isDigit(path.charAt(0))) {
+ return null;
+ }
+ return path.split("\\.", -1);
+ }
+
+ private static boolean isChainChar(char c) {
+ return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '.';
+ }
+
+ /**
+ * Member items for a dotted path the caret sits at the end of. The head is a
+ * binding ({@code $p.}), a field of the enclosing pattern's type ({@code ref.}
+ * inside {@code Fact(...)}), or a type name ({@code Status.}); the remaining
+ * segments are fields. Every hop goes through the same walker the bindings and
+ * hover use, so all three agree on what a path resolves to.
+ */
+ /**
+ * Completion items for the members of the type the chain resolves to, or
+ * {@code null} when the chain's head names no type the document knows — a
+ * qualified name rather than a member access. An empty list means the path
+ * did resolve and simply has no members to offer, which is an answer: after
+ * a dot nothing but a member is legal.
+ */
+ private static List<CompletionItem> memberItemsForChain(String[] chain, String text, Position caret,
+ DRL10Parser.CompilationUnitContext compilationUnit,
+ int caretTokenIndex, ClassIndex classIndex,
+ ClassMemberIndex memberIndex, Path documentPath,
+ Map<Path, String> openFiles) {
+ if (compilationUnit == null) {
+ return null;
+ }
+ Map<String, DeclaredType> typeIndex = DRLWorkspaceTypeIndex.build(
+ DRLDeclaredTypeParser.extractFromCompilationUnit(compilationUnit), documentPath, openFiles);
+
+ String head = chain[0];
+ String rootType;
+ int firstFieldSegment = 1;
+ if (head.startsWith("$")) {
+ rootType = LhsBindingResolver.resolveAt(text, DRLHoverHelper.positionToOffset(text, caret), typeIndex)
+ .get(head.substring(1));
+ } else if (!head.isEmpty() && Character.isUpperCase(head.charAt(0))) {
+ rootType = head;
+ } else {
+ // A bare lower-case head is a field of the pattern the caret is in.
+ rootType = enclosingPatternTypeFromText(text, DRLHoverHelper.positionToOffset(text, caret));
+ firstFieldSegment = 0;
+ }
+ if (rootType == null || rootType.isEmpty()) {
+ // The head names no type the document knows, so this dot is not a
+ // member access at all — a qualified name, most likely.
+ return null;
+ }
+
+ // Kept qualified. A pattern head written as a fully-qualified name is
+ // how an author disambiguates a simple name that collides on the
+ // classpath, and resolveFqcn returns a dotted name as-is, so dropping
+ // the package here is what leaves it unresolvable without an import.
+ // Both consumers below simplify for themselves where they need to.
+ String resolved = rootType;
+ if (firstFieldSegment < chain.length) {
+ String path = String.join(".", Arrays.copyOfRange(chain, firstFieldSegment, chain.length));
+ resolved = LhsBindingResolver.resolvePath(
+ LhsBindingResolver.typeOrClasspath(resolved, typeIndex), path, typeIndex);
+ if (resolved == null) {
+ return List.of();
+ }
+ }
+ return memberItemsOfType(resolved, typeIndex, compilationUnit, classIndex, memberIndex);
+ }
+
+ /**
+ * The type name of the pattern whose parentheses are still open at
+ * {@code offset}, read from the text rather than the parse tree: a caret in
+ * mid-edit leaves the constraint incomplete, and error recovery then parses
+ * the incomplete path itself as a pattern head, which
+ * {@link #findEnclosingPatternTypeName} would return in preference to the
+ * real pattern. Non-pattern parens ({@code accumulate(}, {@code eval(}, a
+ * method call) are stepped over — a DRL pattern type's simple name always
+ * begins upper case, which is also how {@code LhsBindingResolver} finds
+ * pattern heads. {@code null} when no pattern encloses the offset.
+ */
+ private static String enclosingPatternTypeFromText(String text, int offset) {
+ // Scanned over the masked text: a parenthesis or a terminator inside a
+ // string literal or a comment would otherwise unbalance the count and
+ // find the wrong pattern head, or none. The mask keeps every offset, so
+ // the indices below still address the original.
+ String scan = LhsBindingResolver.maskCommentsAndStrings(text);
+ int closed = 0;
+ for (int i = offset - 1; i >= 0; i--) {
+ char c = scan.charAt(i);
+ if (c == ')') {
+ closed++;
+ } else if (c == ';' || c == '}') {
+ return null;
+ } else if (c == '(') {
+ if (closed > 0) {
+ closed--;
+ continue;
+ }
+ int end = i;
+ while (end > 0 && Character.isWhitespace(scan.charAt(end - 1))) {
+ end--;
+ }
+ int start = end;
+ while (start > 0 && isChainChar(scan.charAt(start - 1))) {
+ start--;
+ }
+ String word = text.substring(start, end);
+ String simple = word.substring(word.lastIndexOf('.') + 1);
+ if (!simple.isEmpty() && Character.isUpperCase(simple.charAt(0))) {
+ return word;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** Field items for a type name: DRL declares win, then the classpath. */
+ private static List<CompletionItem> memberItemsOfType(String typeName, Map<String, DeclaredType> typeIndex,
+ DRL10Parser.CompilationUnitContext compilationUnit,
+ ClassIndex classIndex, ClassMemberIndex memberIndex) {
+ String simple = typeName.substring(typeName.lastIndexOf('.') + 1);
+ DeclaredType declared = typeIndex.get(simple);
+ if (declared != null) {
+ return fieldItems(DRLDeclaredTypeParser.fieldsIncludingInherited(declared, typeIndex));
+ }
+ String fqcn = resolveFqcn(typeName, simple, 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) {
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
index 483f0f10c2d..1506c92827a 100644
--- 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
@@ -124,6 +124,9 @@ public final class DRLDeclaredTypeParser {
while (parentName != null && depth++ < 10 && seen.add(parentName)) {
DeclaredType parent = index.get(parentName);
if (parent == null) {
+ // A declare may extend a Java class, whose members only the host
+ // can supply; they arrive already flattened, so the walk ends here.
+ out.addAll(ClasspathTypeMembers.membersOf(parentName));
break;
}
out.addAll(parent.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
index 12552be3ec3..be32b12fcd9 100644
--- 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
@@ -44,6 +44,9 @@ import org.eclipse.lsp4j.Range;
* 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>
+ * <li>when no compiled {@code .class} exists yet (pre-build), the {@code
+ * .java} source location from {@code sourceIndex}, if the type was
+ * parsed from workspace source.</li>
* </ol>
*/
public final class DRLDefinitionHelper {
@@ -61,10 +64,14 @@ public final class DRLDefinitionHelper {
*
* @param buildOutputDirs build-output directories from the resolved
* classpath, used for the Maven source-mapping
+ * @param sourceIndex source-parsed types, consulted when
+ * {@code buildOutputDirs} has no compiled {@code
+ * .class} for the type yet
*/
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());
+ ClassIndex classIndex, Set<Path> buildOutputDirs,
+ JavaSourceTypeIndex sourceIndex) {
+ return findDefinitions(uri, text, position, classIndex, buildOutputDirs, sourceIndex, Map.of());
}
/**
@@ -74,7 +81,7 @@ public final class DRLDefinitionHelper {
*/
public static List<Location> findDefinitions(String uri, String text, Position position,
ClassIndex classIndex, Set<Path> buildOutputDirs,
- Map<Path, String> openFiles) {
+ JavaSourceTypeIndex sourceIndex, Map<Path, String> openFiles) {
if (text == null || position == null) {
return List.of();
}
@@ -107,7 +114,27 @@ public final class DRLDefinitionHelper {
return List.of();
}
JavaSourceLocator.Result javaSource = JavaSourceLocator.locate(fqcn, buildOutputDirs);
- return javaSource == null ? List.of() : List.of(javaSource.location);
+ if (javaSource != null) {
+ return List.of(javaSource.location);
+ }
+ Location fromSource = sourceLocation(sourceIndex, fqcn);
+ return fromSource == null ? List.of() : List.of(fromSource);
+ }
+
+ /**
+ * Builds a {@link Location} anchored at {@code fqcn}'s declaration-name
+ * token from {@code sourceIndex}, or {@code null} when the index doesn't
+ * know {@code fqcn} (unparsed, or not under any indexed source root).
+ */
+ private static Location sourceLocation(JavaSourceTypeIndex sourceIndex, String fqcn) {
+ Path file = sourceIndex.fileOf(fqcn);
+ JavaSourceType type = sourceIndex.byFqcn(fqcn);
+ if (file == null || type == null) {
+ return null;
+ }
+ Range range = new Range(new Position(type.declLine, type.declColumn),
+ new Position(type.declLine, type.declColumn + type.simpleName.length()));
+ return new Location(file.toUri().toString(), range);
}
/**
@@ -214,7 +241,13 @@ public final class DRLDefinitionHelper {
return new Range(new Position(position.getLine(), start), new Position(position.getLine(), end));
}
- private static boolean isIdentifierChar(char c) {
+ /**
+ * The identifier alphabet shared by the word/chain extractors: DRL
+ * identifiers plus the {@code $} binding prefix. Package-private so
+ * {@link DRLHoverHelper}'s chain extraction stays character-for-character
+ * consistent with {@link #wordAt}.
+ */
+ 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/DRLHoverHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
index 6136119804a..2a7e6bfb18a 100644
--- 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
@@ -23,6 +23,8 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Map;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
import org.drools.drl.parser.antlr4.DRL10Parser;
import org.eclipse.lsp4j.Hover;
import org.eclipse.lsp4j.MarkupContent;
@@ -30,14 +32,19 @@ 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.
+ * Hover content for DRL documents: declared and classpath types, fields inside
+ * constraints, bound variables, dotted member chains ({@code $o.total},
+ * {@code Status.ACTIVE}) rendered at the hovered segment, qualified enum
+ * constants, accumulate-function names, and doc comments for documented
+ * {@code function}/{@code query}/{@code global} declarations.
*
* <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.
+ * Accumulate functions are detected structurally from the parse tree; their
+ * result types come from {@link AccumulateFunctionTypes}.
*/
public final class DRLHoverHelper {
@@ -75,12 +82,21 @@ public final class DRLHoverHelper {
return null;
}
+ // Dotted member chain ($o.total, Status.ACTIVE, $a.b.c): handled by a
+ // dedicated segment walk. Single-segment words take the steps below.
+ Chain chain = chainAt(text, position);
+
// 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);
+ if (chain.segments.length >= 2) {
+ return hoverChain(chain, text, position, parsed, currentDocTypes, typeIndex,
+ classIndex, memberIndex, documentPath, openFiles);
+ }
+
// 1. The word is itself a DRL-declared type.
DeclaredType declared = typeIndex.get(word);
if (declared != null) {
@@ -91,7 +107,19 @@ public final class DRLHoverHelper {
DRL10Parser.CompilationUnitContext compilationUnit = parsed.compilationUnit;
Integer nodeIndex = parsed.tokenIndexAt(position);
- // 2. Bound variable: resolve $var to its type via the shared binding
+ // 1b. Accumulate function name ($c : count()): detected purely from
+ // the parse tree — the caret's terminal sits under the function's
+ // own identifier. Structural, so it outranks the name-based doc
+ // step below: at this position the identifier can only be an
+ // accumulate function, even when a DRL function shares its name.
+ if (nodeIndex != null) {
+ Hover accumulate = accumulateFunctionHover(word, compilationUnit, nodeIndex);
+ if (accumulate != null) {
+ return accumulate;
+ }
+ }
+
+ // 3. 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.
@@ -108,12 +136,16 @@ public final class DRLHoverHelper {
String boundFqcn = DRLCompletionHelper.resolveFqcn(
boundType, boundType, compilationUnit, classIndex);
if (boundFqcn != null) {
- return markdown(renderJavaType(boundType, boundFqcn, memberIndex.membersOf(boundFqcn)));
+ return markdown(renderJavaType(boundType, boundFqcn, memberIndex.membersOf(boundFqcn),
+ memberIndex.constructorsOf(boundFqcn)));
}
+ // Nothing to describe beyond the name: a primitive has no class
+ // to load and no members, and the type is the useful part anyway.
+ return markdown(fencedHeader(word + " : " + boundType).stripTrailing());
}
}
- // 3. Field of the pattern enclosing the caret.
+ // 4. Field of the pattern enclosing the caret.
if (nodeIndex != null) {
String patternType = DRLCompletionHelper.findEnclosingPatternTypeName(
compilationUnit, nodeIndex);
@@ -121,22 +153,363 @@ public final class DRLHoverHelper {
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 + "`");
+ return markdown(renderField(field, simpleName(patternType)));
}
}
}
- // 4. Classpath type (or java.lang built-in). Show the hover even with no
+ // 5. 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 markdown(renderJavaType(word, fqcn, memberIndex.membersOf(fqcn),
+ memberIndex.constructorsOf(fqcn)));
+ }
+
+ // 6. Documented function/query/global, last of all. The doc-comment
+ // parser maps names across the whole document with no position
+ // scoping, so anything the position itself identifies — a binding, a
+ // field, a type — describes itself first, and a doc comment answers
+ // only for a name nothing else claims.
+ return docHover(word, currentDocTypes, text, documentPath, openFiles);
+ }
+
+ /**
+ * The dotted member chain around the caret ({@code $o.total},
+ * {@code Status.ACTIVE.level}): the dot-separated segments (an empty entry
+ * marks a stray dot, e.g. {@code a..b} mid-edit) and the index of the
+ * segment the caret sits in.
+ */
+ private static final class Chain {
+
+ final String[] segments;
+ final int hoveredIndex;
+
+ Chain(String[] segments, int hoveredIndex) {
+ this.segments = segments;
+ this.hoveredIndex = hoveredIndex;
+ }
+ }
+
+ /**
+ * Extracts the dotted chain around the caret by expanding over identifier
+ * characters and {@code .} on the caret's line. Only called after the word
+ * guard in {@link #hover}, so the line index is valid and at least one
+ * segment is non-empty. Kept separate from
+ * {@link DRLDefinitionHelper#wordAt}, whose single-identifier expansion is
+ * shared with rename and go-to-definition.
+ */
+ private static Chain chainAt(String text, Position position) {
+ String[] lines = text.split("\r?\n", -1);
+ String line = lines[position.getLine()];
+ int col = Math.min(Math.max(position.getCharacter(), 0), line.length());
+
+ int start = col;
+ while (start > 0 && isChainChar(line.charAt(start - 1))) {
+ start--;
+ }
+ int end = col;
+ while (end < line.length() && isChainChar(line.charAt(end))) {
+ end++;
+ }
+ String[] segments = line.substring(start, end).split("\\.", -1);
+ return new Chain(segments, hoveredSegment(segments, col - start));
+ }
+
+ private static boolean isChainChar(char c) {
+ return DRLDefinitionHelper.isIdentifierChar(c) || c == '.';
+ }
+
+ /** Index of the segment containing the caret, {@code rel} chars into the chain. */
+ private static int hoveredSegment(String[] segments, int rel) {
+ int segmentStart = 0;
+ for (int i = 0; i < segments.length; i++) {
+ int segmentEnd = segmentStart + segments[i].length();
+ if (rel <= segmentEnd) {
+ return i;
+ }
+ segmentStart = segmentEnd + 1; // skip the '.'
+ }
+ return segments.length - 1;
+ }
+
+ /**
+ * Resolves a dotted chain segment by segment up to the hovered one,
+ * tracking the running type, and renders only the hovered segment.
+ *
+ * <p>Segment 0 resolves as a {@code $binding} (rule-scoped), a declared
+ * type, a classpath type, or a field of the enclosing pattern — and, when
+ * it is itself the hovered segment, falls back to a documented
+ * function/query/global by that name (the chain head is a plain identifier
+ * reference, e.g. {@code results} in {@code results.add(..)}). Later
+ * segments are enum constants or fields of the running type; a constant is
+ * an instance of its enum, so member access on it continues from the enum
+ * type itself.
+ *
+ * <p>Any segment before the hovered one that does not resolve yields no
+ * hover at all: falling back to the single-word steps with one segment of
+ * the chain would mis-render it (e.g. a chain tail as a field of the
+ * enclosing pattern).
+ */
+ private static Hover hoverChain(Chain chain, String text, Position position,
+ ParsedDrl parsed, List<DeclaredType> currentDocTypes,
+ Map<String, DeclaredType> typeIndex,
+ ClassIndex classIndex, ClassMemberIndex memberIndex,
+ Path documentPath, Map<Path, String> openFiles) {
+ String runningType = null;
+ int start = 0;
+
+ // A chain can open with a fully-qualified type name, whose leading
+ // segments are package names that resolve to nothing on their own
+ // (`com` in `com.example.model.Order`). Consume the longest dotted
+ // prefix that names a known type first, so the walk below starts from
+ // the type rather than failing on the package.
+ int fqcnEnd = fqcnPrefixEnd(chain.segments, classIndex);
+ if (fqcnEnd >= 1) {
+ String fqcn = joinSegments(chain.segments, fqcnEnd);
+ if (chain.hoveredIndex <= fqcnEnd) {
+ // Anywhere inside the qualified name describes that type.
+ String simple = chain.segments[fqcnEnd];
+ return markdown(renderJavaType(simple, fqcn, memberIndex.membersOf(fqcn),
+ memberIndex.constructorsOf(fqcn)));
+ }
+ runningType = fqcn;
+ start = fqcnEnd + 1;
+ }
+
+ for (int i = start; i <= chain.hoveredIndex; i++) {
+ String segment = chain.segments[i];
+ boolean hovered = i == chain.hoveredIndex;
+ if (segment.isEmpty()) {
+ return null;
+ }
+
+ if (i == 0) {
+ if (segment.charAt(0) == '$') {
+ int offset = positionToOffset(text, position);
+ String boundType = LhsBindingResolver.resolveAt(text, offset, typeIndex)
+ .get(segment.substring(1));
+ if (boundType == null) {
+ return null;
+ }
+ if (hovered) {
+ return markdown(renderBinding(segment, boundType, parsed, typeIndex,
+ currentDocTypes, text, classIndex, memberIndex,
+ documentPath, openFiles));
+ }
+ runningType = boundType;
+ } else if (typeIndex.get(segment) != null) {
+ if (hovered) {
+ return markdown(renderDeclaredHover(typeIndex.get(segment), typeIndex,
+ currentDocTypes, text, documentPath, openFiles));
+ }
+ runningType = segment;
+ } else {
+ String fqcn = DRLCompletionHelper.resolveFqcn(
+ segment, segment, parsed.compilationUnit, classIndex);
+ if (fqcn != null) {
+ if (hovered) {
+ return markdown(renderJavaType(segment, fqcn, memberIndex.membersOf(fqcn),
+ memberIndex.constructorsOf(fqcn)));
+ }
+ runningType = fqcn;
+ } else {
+ Integer nodeIndex = parsed.tokenIndexAt(position);
+ String patternType = nodeIndex == null ? null
+ : DRLCompletionHelper.findEnclosingPatternTypeName(
+ parsed.compilationUnit, nodeIndex);
+ Field field = patternType == null || patternType.equals(segment) ? null
+ : findField(patternType, segment, typeIndex,
+ parsed.compilationUnit, classIndex, memberIndex);
+ if (field != null) {
+ if (hovered) {
+ return markdown(renderField(field, simpleName(patternType)));
+ }
+ runningType = field.type;
+ } else if (hovered) {
+ return docHover(segment, currentDocTypes, text,
+ documentPath, openFiles);
+ } else {
+ return null;
+ }
+ }
+ }
+ } else {
+ DeclaredType declared = typeIndex.get(simpleName(runningType));
+ if (declared != null && declared.isEnum && isEnumConstant(declared, segment)) {
+ if (hovered) {
+ return markdown(fencedHeader(declared.name + "." + segment)
+ + renderDeclaredHover(declared, typeIndex, currentDocTypes,
+ text, documentPath, openFiles));
+ }
+ // Running type stays the enum: the constant is an instance of it.
+ } else {
+ Field field = findField(runningType, segment, typeIndex,
+ parsed.compilationUnit, classIndex, memberIndex);
+ if (field == null) {
+ return null;
+ }
+ if (hovered) {
+ return markdown(renderField(field, simpleName(runningType)));
+ }
+ runningType = field.type;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The index of the last segment of the longest leading run of
+ * {@code segments} that names a type on the classpath, or {@code -1} when
+ * no prefix of two or more segments does. Longest-first so
+ * {@code com.example.Order.status} prefers the type {@code com.example.Order}
+ * over a shorter accidental match.
+ */
+ private static int fqcnPrefixEnd(String[] segments, ClassIndex classIndex) {
+ for (int end = segments.length - 1; end >= 1; end--) {
+ String simple = segments[end];
+ if (simple.isEmpty()) {
+ continue;
+ }
+ String candidate = joinSegments(segments, end);
+ if (classIndex.forSimpleName(simple).contains(candidate)) {
+ return end;
+ }
+ }
+ return -1;
+ }
+
+ /** {@code segments[0..end]} rejoined with dots. */
+ private static String joinSegments(String[] segments, int end) {
+ StringBuilder sb = new StringBuilder(segments[0]);
+ for (int i = 1; i <= end; i++) {
+ sb.append('.').append(segments[i]);
+ }
+ return sb.toString();
+ }
+
+ /** True when {@code name} is a constant of {@code enumType} (a field typed as the enum). */
+ private static boolean isEnumConstant(DeclaredType enumType, String name) {
+ return enumType.fields.stream()
+ .anyMatch(field -> name.equals(field.name) && enumType.name.equals(field.type));
+ }
+
+ /** Header {@code $x : Type} followed by the bound type's details. */
+ private static String renderBinding(String binding, String typeName, ParsedDrl parsed,
+ Map<String, DeclaredType> typeIndex,
+ List<DeclaredType> currentDocTypes, String text,
+ ClassIndex classIndex, ClassMemberIndex memberIndex,
+ Path documentPath, Map<Path, String> openFiles) {
+ String header = fencedHeader(binding + " : " + typeName);
+ DeclaredType declared = typeIndex.get(typeName);
+ if (declared != null) {
+ return header + renderDeclaredHover(declared, typeIndex, currentDocTypes, text,
+ documentPath, openFiles);
+ }
+ String fqcn = DRLCompletionHelper.resolveFqcn(
+ typeName, typeName, parsed.compilationUnit, classIndex);
+ if (fqcn != null) {
+ return header + renderJavaType(typeName, fqcn, memberIndex.membersOf(fqcn),
+ memberIndex.constructorsOf(fqcn));
+ }
+ return header.stripTrailing();
+ }
+
+ /**
+ * Hover for an accumulate function name ({@code count} in
+ * {@code $c : count()}), or {@code null} when the word at
+ * {@code tokenIndex} is not one. Detection is purely structural: the
+ * terminal at that token index must BE (part of) the function identifier
+ * of an {@link DRL10Parser.AccumulateFunctionContext} — an argument whose
+ * text merely equals the function name does not qualify — and the name
+ * must be a known accumulate function.
+ */
+ private static Hover accumulateFunctionHover(String word,
+ DRL10Parser.CompilationUnitContext compilationUnit,
+ int tokenIndex) {
+ TerminalNode terminal = terminalAtTokenIndex(compilationUnit, tokenIndex);
+ if (terminal == null) {
+ return null;
+ }
+ DRL10Parser.AccumulateFunctionContext function = enclosingAccumulateFunction(terminal);
+ if (function == null || function.drlIdentifier() == null
+ || !word.equals(function.drlIdentifier().getText())
+ || !isUnder(terminal, function.drlIdentifier())) {
+ return null;
+ }
+ String resultType = AccumulateFunctionTypes.get().get(word);
+ if (resultType == null) {
+ return null;
+ }
+ return markdown(fencedHeader(word + " : " + resultType) + "_accumulate function_");
+ }
+
+ /**
+ * The terminal node whose token sits at {@code tokenIndex} in the parse
+ * tree rooted at {@code node}, or {@code null} when none does.
+ */
+ private static TerminalNode terminalAtTokenIndex(ParseTree node, int tokenIndex) {
+ if (node instanceof TerminalNode terminal) {
+ return terminal.getSymbol().getTokenIndex() == tokenIndex ? terminal : null;
+ }
+ for (int i = 0; i < node.getChildCount(); i++) {
+ TerminalNode found = terminalAtTokenIndex(node.getChild(i), tokenIndex);
+ if (found != null) {
+ return found;
+ }
}
return null;
}
+ /** The nearest {@code accumulateFunction} ancestor of {@code node}, or {@code null}. */
+ private static DRL10Parser.AccumulateFunctionContext enclosingAccumulateFunction(ParseTree node) {
+ for (ParseTree current = node; current != null; current = current.getParent()) {
+ if (current instanceof DRL10Parser.AccumulateFunctionContext function) {
+ return function;
+ }
+ }
+ return null;
+ }
+
+ /** True when {@code node} is {@code ancestor} itself or sits beneath it. */
+ private static boolean isUnder(ParseTree node, ParseTree ancestor) {
+ for (ParseTree current = node; current != null; current = current.getParent()) {
+ if (current == ancestor) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String fencedHeader(String content) {
+ return "```\n" + content + "\n```\n\n";
+ }
+
+ private static String renderField(Field field, String owner) {
+ return "**" + field.name + "** : `" + field.type + "`\n\nField of `" + owner + "`";
+ }
+
+ private static String simpleName(String typeName) {
+ return typeName.substring(typeName.lastIndexOf('.') + 1);
+ }
+
+ /**
+ * Hover for a documented function, query, or global named {@code name}, or
+ * {@code null} when it has no doc comment. Shared by the single-word flow
+ * and the chain-head fallback.
+ */
+ private static Hover docHover(String name, List<DeclaredType> currentDocTypes, String text,
+ Path documentPath, Map<Path, String> openFiles) {
+ String doc = DRLDocCommentParser.docFor(text, name);
+ if (doc == null) {
+ return null;
+ }
+ Map<String, String> linkTargets = DRLWorkspaceTypeIndex.buildLinkTargets(
+ currentDocTypes, text, documentPath, openFiles);
+ return markdown(DRLDocFormatter.format(doc, linkTargets));
+ }
+
/**
* Renders the full declared-type hover: doc comment (with {@code {@link}}
* references resolved to the declarations they name), the declare block,
@@ -221,15 +594,45 @@ public final class DRLHoverHelper {
return sb.toString();
}
- private static String renderJavaType(String simpleName, String fqcn, List<Field> members) {
+ private static String renderJavaType(String simpleName, String fqcn, List<Field> members,
+ List<String> constructors) {
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);
+ appendMemberSection(sb, "Constants", members, Field.Origin.ENUM_CONSTANT);
+ appendMemberSection(sb, "Fields", members, Field.Origin.FIELD);
+ appendMemberSection(sb, "Getters", members, Field.Origin.GETTER);
+ if (!constructors.isEmpty()) {
+ sb.append("\n\n**Constructors**");
+ for (String signature : constructors) {
+ sb.append("\n- `").append(signature).append('`');
+ }
}
return sb.toString();
}
+ /** Appends {@code title}'s section for {@code members} of the given {@code origin}, or nothing when none match. */
+ private static void appendMemberSection(StringBuilder sb, String title, List<Field> members,
+ Field.Origin origin) {
+ boolean headerWritten = false;
+ for (Field member : members) {
+ if (member.origin != origin) {
+ continue;
+ }
+ if (!headerWritten) {
+ sb.append("\n\n**").append(title).append("**");
+ headerWritten = true;
+ }
+ if (origin == Field.Origin.ENUM_CONSTANT) {
+ sb.append("\n- ").append(member.name);
+ if (member.args != null) {
+ sb.append(" (").append(member.args).append(')');
+ }
+ } else {
+ sb.append("\n- ").append(member.name).append(" : ").append(member.type);
+ }
+ }
+ }
+
private static Hover markdown(String content) {
return new Hover(new MarkupContent(MarkupKind.MARKDOWN, content));
}
@@ -239,7 +642,7 @@ public final class DRLHoverHelper {
* 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) {
+ static int positionToOffset(String text, Position position) {
int line = position.getLine();
int offset = 0;
int currentLine = 0;
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
index f417a10bd5d..30d7832236f 100644
--- 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
@@ -104,7 +104,12 @@ public final class DRLInlayHintHelper {
Map<String, String> accum = AccumulateFunctionTypes.get();
List<InlayHint> hints = new ArrayList<>();
- Matcher ruleMatch = RULE_BLOCK.matcher(text);
+ // Scanned with comments and string literals blanked out, so a "then"
+ // inside either does not end a rule's condition early. The mask keeps
+ // the text's length, so every offset below still indexes into text
+ // itself; it also stops a $name inside a comment or literal being
+ // mistaken for a usage worth annotating.
+ Matcher ruleMatch = RULE_BLOCK.matcher(LhsBindingResolver.maskCommentsAndStrings(text));
while (ruleMatch.find()) {
String whenSection = ruleMatch.group(1);
String thenSection = ruleMatch.group(2);
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
index 4dc0797f477..6d6f2aa37fe 100644
--- 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
@@ -39,8 +39,11 @@ import org.eclipse.lsp4j.TypeHierarchyItem;
* <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>
+ * declare-site; or a classpath type → its project {@code .java} source,
+ * compiled-first, falling back to a source-parsed location (pre-build)
+ * when the type has no compiled {@code .class} yet — see
+ * {@link JavaSourceTypeIndex} (navigable only — JAR-only types with no
+ * project source 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>
@@ -78,7 +81,7 @@ public final class DRLTypeHierarchyHelper {
*/
public static List<TypeHierarchyItem> prepare(String text, Position position, String uri,
Map<Path, String> openFiles, ClassIndex classIndex,
- Set<Path> buildOutputDirs) {
+ Set<Path> buildOutputDirs, JavaSourceTypeIndex sourceIndex) {
try {
if (text == null || position == null) {
return Collections.emptyList();
@@ -92,7 +95,7 @@ public final class DRLTypeHierarchyHelper {
return Collections.singletonList(declareItem(declared));
}
TypeHierarchyItem classpath = classpathItem(
- DRLDefinitionHelper.resolveFqcn(text, word, classIndex), buildOutputDirs);
+ DRLDefinitionHelper.resolveFqcn(text, word, classIndex), buildOutputDirs, sourceIndex);
return classpath == null ? Collections.emptyList() : Collections.singletonList(classpath);
} catch (Exception e) {
logger.fine(() -> "prepareTypeHierarchy failed: " + e.getMessage());
@@ -106,14 +109,15 @@ public final class DRLTypeHierarchyHelper {
*/
public static List<TypeHierarchyItem> supertypes(TypeHierarchyItem item, String declaringText,
Map<Path, String> openFiles, ClassIndex classIndex,
- ClassMemberIndex memberIndex, Set<Path> buildOutputDirs) {
+ ClassMemberIndex memberIndex, Set<Path> buildOutputDirs,
+ JavaSourceTypeIndex sourceIndex) {
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);
+ TypeHierarchyItem parentItem = classpathItem(parent, buildOutputDirs, sourceIndex);
if (parentItem != null) {
out.add(parentItem);
}
@@ -130,7 +134,8 @@ public final class DRLTypeHierarchyHelper {
return Collections.singletonList(declareItem(parentDeclare));
}
TypeHierarchyItem classpathParent = classpathItem(
- DRLDefinitionHelper.resolveFqcn(declaringText, self.extendsName, classIndex), buildOutputDirs);
+ DRLDefinitionHelper.resolveFqcn(declaringText, self.extendsName, classIndex),
+ buildOutputDirs, sourceIndex);
return classpathParent == null ? Collections.emptyList()
: Collections.singletonList(classpathParent);
} catch (Exception e) {
@@ -217,17 +222,40 @@ public final class DRLTypeHierarchyHelper {
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) {
+ /**
+ * Builds a navigable classpath item: a compiled {@code .class}'s project
+ * source first, else — when no {@code .class} exists yet (pre-build) —
+ * {@code sourceIndex}'s parsed location; {@code null} when neither
+ * resolves.
+ */
+ private static TypeHierarchyItem classpathItem(String fqcn, Set<Path> buildOutputDirs,
+ JavaSourceTypeIndex sourceIndex) {
if (fqcn == null) {
return null;
}
JavaSourceLocator.Result res = JavaSourceLocator.locate(fqcn, buildOutputDirs);
- if (res == null) {
+ if (res != 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;
+ }
+ return sourceItem(fqcn, sourceIndex);
+ }
+
+ /** As the source-index branch of {@link #classpathItem}; {@code null} when {@code fqcn} is unindexed. */
+ private static TypeHierarchyItem sourceItem(String fqcn, JavaSourceTypeIndex sourceIndex) {
+ Path file = sourceIndex.fileOf(fqcn);
+ JavaSourceType type = sourceIndex.byFqcn(fqcn);
+ if (file == null || type == null) {
return null;
}
- TypeHierarchyItem item = new TypeHierarchyItem(simpleName(fqcn), res.kind,
- res.location.getUri(), res.location.getRange(), res.location.getRange());
+ Range range = new Range(new Position(type.declLine, type.declColumn),
+ new Position(type.declLine, type.declColumn + type.simpleName.length()));
+ SymbolKind kind = type.isEnum ? SymbolKind.Enum : SymbolKind.Class;
+ TypeHierarchyItem item = new TypeHierarchyItem(type.simpleName, kind,
+ file.toUri().toString(), range, range);
item.setData(fqcn);
item.setDetail(fqcn);
return item;
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
index be69e1d4006..0321860fb5e 100644
--- 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
@@ -22,6 +22,9 @@ package org.drools.completion;
/** A field, bean property, or enum constant of a {@link DeclaredType} or Java class. */
public class Field {
+ /** Where a member came from, so hover can group fields, getters, and enum constants separately. */
+ public enum Origin { FIELD, GETTER, ENUM_CONSTANT }
+
public final String name;
public final String type;
/**
@@ -31,14 +34,20 @@ public class Field {
* and for enum constants without arguments.
*/
public final String args;
+ public final Origin origin;
Field(String name, String type) {
- this(name, type, null);
+ this(name, type, null, Origin.FIELD);
}
Field(String name, String type, String args) {
+ this(name, type, args, Origin.FIELD);
+ }
+
+ Field(String name, String type, String args, Origin origin) {
this.name = name;
this.type = type;
this.args = args;
+ this.origin = origin;
}
}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.java
new file mode 100644
index 00000000000..d07206916ee
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.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.drools.completion;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A source of type members that {@link ClassMemberIndex} can consult on a
+ * reflection miss (e.g. a type declared in {@code .java} source that has no
+ * compiled classpath entry yet), so completion/hover/lint keep working before
+ * a build exists.
+ *
+ * <p>Contracts mirror {@code ClassMemberIndex}'s own: {@link #membersOf} is
+ * empty (never {@code null}) when {@code fqcn} is unknown; {@link #memberNames}
+ * returns {@code null} when unknown, letting callers distinguish "no such
+ * member" (a real typo) from "couldn't verify" (classpath/source gap) — see
+ * {@link ClassMemberIndex#memberNames}; {@link #supertypesOf} returns direct
+ * supertypes as fully-qualified names where resolvable within the source
+ * index — unresolvable supertypes are omitted, never guessed at — empty when
+ * unknown; {@link #constructorsOf} returns signatures, empty when unknown.
+ */
+public interface JavaMemberSource {
+
+ List<Field> membersOf(String fqcn);
+
+ Set<String> memberNames(String fqcn);
+
+ List<String> supertypesOf(String fqcn);
+
+ List<String> constructorsOf(String fqcn);
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java
new file mode 100644
index 00000000000..433f97710c3
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java
@@ -0,0 +1,146 @@
+/*
+ * 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.InvalidPathException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Discovers Java source roots to feed {@link JavaSourceTypeIndex#build}: a
+ * bounded Maven-convention walk of {@code workspaceRoot} for directories
+ * ending {@code src/main/java}, unioned with each of {@code configuredPaths}
+ * that resolves to an existing directory, with roots nested inside other roots
+ * collapsed away.
+ */
+public final class JavaSourceRoots {
+
+ private static final Logger logger = Logger.getLogger(JavaSourceRoots.class.getName());
+
+ private static final int MAX_DEPTH = 8;
+ private static final Path SRC_MAIN_JAVA = Path.of("src", "main", "java");
+
+ private JavaSourceRoots() {
+ }
+
+ /**
+ * Convention roots (dirs under {@code workspaceRoot}, depth ≤ {@value
+ * #MAX_DEPTH}, ending {@code src/main/java}) unioned with {@code
+ * configuredPaths} (each absolute, or resolved against {@code
+ * workspaceRoot}) that exist and are directories. Both contributions are
+ * always included — auto-discovery is not a fallback for configured
+ * entries. Walk failures are swallowed (logged at FINE) so a partially
+ * unreadable tree still yields whatever roots were found. Order:
+ * convention roots in walk-encounter order, then configured entries;
+ * duplicates (by normalized absolute path) are dropped.
+ */
+ public static List<Path> discover(Path workspaceRoot, List<String> configuredPaths) {
+ LinkedHashSet<Path> seen = new LinkedHashSet<>();
+ List<Path> out = new ArrayList<>();
+
+ if (workspaceRoot != null && Files.isDirectory(workspaceRoot)) {
+ try (Stream<Path> walk = Files.walk(workspaceRoot, MAX_DEPTH)) {
+ walk.filter(Files::isDirectory)
+ .filter(p -> p.endsWith(SRC_MAIN_JAVA))
+ .forEach(p -> addIfNew(out, seen, p));
+ } catch (IOException | RuntimeException e) {
+ logger.fine(() -> "Failed to walk " + workspaceRoot + " for source roots: " + e.getMessage());
+ }
+ }
+
+ if (configuredPaths != null) {
+ for (String configured : configuredPaths) {
+ if (configured == null || configured.isBlank()) {
+ continue;
+ }
+ Path candidate;
+ try {
+ candidate = Path.of(configured);
+ } catch (InvalidPathException e) {
+ // A setting is user input, and on Windows a glob character
+ // is not a legal path character at all. Skipping one entry
+ // must not cost the caller its other roots.
+ logger.fine(() -> "Ignoring source path '" + configured
+ + "': not a usable path (" + e.getMessage() + ")");
+ continue;
+ }
+ if (!candidate.isAbsolute() && workspaceRoot != null) {
+ candidate = workspaceRoot.resolve(candidate);
+ }
+ if (Files.isDirectory(candidate)) {
+ addIfNew(out, seen, candidate);
+ } else {
+ logger.fine(() -> "Ignoring source path '" + configured
+ + "': not an existing directory");
+ }
+ }
+ }
+
+ return collapseNested(out);
+ }
+
+ /**
+ * Drops every root that lies inside another root, keeping the outermost.
+ * Indexing walks each root recursively, so an ancestor already covers its
+ * descendants: a configured {@code module/src} alongside the discovered
+ * {@code module/src/main/java} would otherwise parse every file under
+ * {@code src/main/java} twice. The ancestor is the one kept because it is
+ * the broader instruction — a caller naming {@code src} means to index
+ * everything beneath it, test sources included.
+ */
+ private static List<Path> collapseNested(List<Path> roots) {
+ List<Path> out = new ArrayList<>(roots.size());
+ for (Path candidate : roots) {
+ boolean insideAnother = false;
+ for (Path other : roots) {
+ if (candidate != other && isInside(candidate, other)) {
+ insideAnother = true;
+ break;
+ }
+ }
+ if (!insideAnother) {
+ out.add(candidate);
+ } else {
+ logger.fine(() -> "Source root " + candidate + " lies inside another root; skipping");
+ }
+ }
+ return out;
+ }
+
+ /** True when {@code inner} is a strict descendant of {@code outer}. */
+ private static boolean isInside(Path inner, Path outer) {
+ Path a = inner.toAbsolutePath().normalize();
+ Path b = outer.toAbsolutePath().normalize();
+ return !a.equals(b) && a.startsWith(b);
+ }
+
+ private static void addIfNew(List<Path> out, LinkedHashSet<Path> seen, Path p) {
+ Path key = p.toAbsolutePath().normalize();
+ if (seen.add(key)) {
+ out.add(p);
+ }
+ }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java
new file mode 100644
index 00000000000..c7f23582c9c
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java
@@ -0,0 +1,68 @@
+/*
+ * 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 Java type parsed from {@code .java} source (top-level class, enum, interface,
+ * or record). Carries the members, constructor signatures, supertype simple
+ * names, and the declaration's source position, so the source-typing layer can
+ * feed completion/hover/lint/definition before a compile exists. Immutable.
+ */
+public final class JavaSourceType {
+
+ public final String fqcn;
+ public final String simpleName;
+ public final boolean isEnum;
+ /** Simple name of the {@code extends} supertype, or {@code null}. */
+ public final String extendsSimpleName;
+ /** Simple names of implemented (or, for interfaces, extended) types. */
+ public final List<String> interfaceSimpleNames;
+ /** Fields, bean-property getters, and enum constants, deduped by name. */
+ public final List<Field> members;
+ /** Constructor signatures, e.g. {@code "Foo(int, String)"}. */
+ public final List<String> constructors;
+ /**
+ * Names of the public static fields. Not members — a static is not a fact
+ * property — but nameable through {@code Type.NAME}, which is what
+ * {@link JavaSourceTypeIndex#memberNames} answers for.
+ */
+ public final List<String> staticFieldNames;
+ /** 0-based line/column of the type's name token. */
+ public final int declLine;
+ public final int declColumn;
+
+ JavaSourceType(String fqcn, String simpleName, boolean isEnum, String extendsSimpleName,
+ List<String> interfaceSimpleNames, List<Field> members,
+ List<String> constructors, List<String> staticFieldNames,
+ int declLine, int declColumn) {
+ this.fqcn = fqcn;
+ this.simpleName = simpleName;
+ this.isEnum = isEnum;
+ this.extendsSimpleName = extendsSimpleName;
+ this.interfaceSimpleNames = List.copyOf(interfaceSimpleNames);
+ this.members = List.copyOf(members);
+ this.constructors = List.copyOf(constructors);
+ this.staticFieldNames = List.copyOf(staticFieldNames);
+ this.declLine = declLine;
+ this.declColumn = declColumn;
+ }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java
new file mode 100644
index 00000000000..7c2727c8ff1
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java
@@ -0,0 +1,379 @@
+/*
+ * 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.HashSet;
+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.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Indexes {@code .java} source under a workspace's source roots via {@link
+ * JavaSourceTypeParser}, so completion/hover/lint can resolve types and
+ * members before a compile exists. An instance is an immutable snapshot;
+ * callers rebuild via {@link #build} on workspace changes rather than
+ * mutating one in place — the same model {@code ClassIndex} uses.
+ */
+public final class JavaSourceTypeIndex implements JavaMemberSource {
+
+ private static final Logger logger = Logger.getLogger(JavaSourceTypeIndex.class.getName());
+
+ private static final JavaSourceTypeIndex EMPTY =
+ new JavaSourceTypeIndex(Set.of(), Map.of(), Map.of(), Map.of());
+
+ private static final class CachedEntry {
+ final long modMillis;
+ final List<JavaSourceType> types;
+
+ CachedEntry(long modMillis, List<JavaSourceType> types) {
+ this.modMillis = modMillis;
+ this.types = types;
+ }
+ }
+
+ /** Per-file cache keyed by normalized absolute path, valid by mtime. Shared across builds. */
+ private static final Map<Path, CachedEntry> FILE_CACHE = new ConcurrentHashMap<>();
+
+ /** The root set most recently announced at INFO, so a rebuild over the same roots stays quiet. */
+ private static volatile Set<Path> lastAnnouncedRoots = Set.of();
+
+ /**
+ * 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 source roots are rebuilt or on
+ * shutdown.
+ */
+ public static void clearCache() {
+ FILE_CACHE.clear();
+ }
+
+ private final Set<Path> roots;
+ private final Map<String, List<String>> classNames;
+ private final Map<String, JavaSourceType> typesByFqcn;
+ private final Map<String, Path> fileByFqcn;
+
+ private JavaSourceTypeIndex(Set<Path> roots, Map<String, List<String>> classNames,
+ Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+ this.roots = roots;
+ this.classNames = classNames;
+ this.typesByFqcn = typesByFqcn;
+ this.fileByFqcn = fileByFqcn;
+ }
+
+ /** An index over no source roots; resolves nothing. */
+ public static JavaSourceTypeIndex empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Walks each of {@code sourceRoots} for {@code .java} files, parses each
+ * (via the mtime cache) into its top-level types, and keeps those whose
+ * package passes {@code packageFilters} — a type's package is its FQCN
+ * minus the last segment; it passes when {@code packageFilters} is empty,
+ * when a filter equals the package exactly, or when a filter ends with
+ * {@code *} and the package starts with the prefix before it. A
+ * default-package type passes only when {@code packageFilters} is empty.
+ * On a duplicate FQCN across roots, the first one seen wins (walk order);
+ * later ones are logged at FINE and dropped.
+ */
+ public static JavaSourceTypeIndex build(Set<Path> sourceRoots, List<String> packageFilters) {
+ List<String> filters = packageFilters == null ? List.of() : packageFilters;
+ Set<Path> usedRoots = new LinkedHashSet<>();
+ Map<String, JavaSourceType> typesByFqcn = new LinkedHashMap<>();
+ Map<String, Path> fileByFqcn = new LinkedHashMap<>();
+
+ if (sourceRoots != null) {
+ for (Path root : sourceRoots) {
+ if (root == null || !Files.isDirectory(root)) {
+ continue;
+ }
+ usedRoots.add(root);
+ indexRoot(root, filters, typesByFqcn, fileByFqcn);
+ }
+ }
+
+ // The roots are the one fact needed to explain everything else this
+ // index reports — including duplicate-FQCN drops, which are expected
+ // when two roots legitimately see the same file and alarming otherwise.
+ // Announced only when the set changes, though: a rebuild runs on every
+ // watched .java save, and repeating this at INFO would bury the log it
+ // is meant to inform.
+ if (!usedRoots.isEmpty()) {
+ String message = "Indexed " + typesByFqcn.size() + " Java source type(s) from "
+ + usedRoots.size() + " root(s): " + usedRoots;
+ if (usedRoots.equals(lastAnnouncedRoots)) {
+ logger.fine(message);
+ } else {
+ lastAnnouncedRoots = Set.copyOf(usedRoots);
+ logger.info(message);
+ }
+ }
+
+ Map<String, List<String>> classNames = new LinkedHashMap<>();
+ for (JavaSourceType type : typesByFqcn.values()) {
+ classNames.computeIfAbsent(type.simpleName, k -> new ArrayList<>()).add(type.fqcn);
+ }
+ Map<String, List<String>> classNamesOut = new LinkedHashMap<>();
+ for (Map.Entry<String, List<String>> entry : classNames.entrySet()) {
+ classNamesOut.put(entry.getKey(), List.copyOf(entry.getValue()));
+ }
+
+ return new JavaSourceTypeIndex(Set.copyOf(usedRoots), Map.copyOf(classNamesOut),
+ Map.copyOf(typesByFqcn), Map.copyOf(fileByFqcn));
+ }
+
+ /**
+ * Indexes files one at a time (rather than collecting the walk to a list
+ * first) so a failure partway through the walk — an unreadable
+ * subdirectory, say — still leaves everything found before it in {@code
+ * typesByFqcn}/{@code fileByFqcn}; only the remainder of this root is
+ * lost.
+ */
+ private static void indexRoot(Path root, List<String> filters,
+ Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+ try (Stream<Path> walk = Files.walk(root)) {
+ walk.filter(Files::isRegularFile)
+ .filter(p -> p.toString().endsWith(".java"))
+ .forEach(file -> indexFile(file, filters, typesByFqcn, fileByFqcn));
+ } catch (IOException | RuntimeException e) {
+ logger.fine(() -> "Failed to walk source root " + root + ": " + e.getMessage());
+ }
+ }
+
+ private static void indexFile(Path file, List<String> filters,
+ Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+ for (JavaSourceType type : cachedParse(file)) {
+ if (!passesFilter(type.fqcn, filters)) {
+ continue;
+ }
+ if (typesByFqcn.containsKey(type.fqcn)) {
+ logger.fine(() -> "Duplicate FQCN " + type.fqcn + " from " + file + " ignored (first wins)");
+ continue;
+ }
+ typesByFqcn.put(type.fqcn, type);
+ fileByFqcn.put(type.fqcn, file);
+ }
+ }
+
+ private static boolean passesFilter(String fqcn, List<String> filters) {
+ if (filters.isEmpty()) {
+ return true;
+ }
+ int dot = fqcn.lastIndexOf('.');
+ String pkg = dot >= 0 ? fqcn.substring(0, dot) : "";
+ if (pkg.isEmpty()) {
+ return false;
+ }
+ for (String filter : filters) {
+ if (!filter.endsWith("*")) {
+ if (pkg.equals(filter)) {
+ return true;
+ }
+ continue;
+ }
+ String prefix = filter.substring(0, filter.length() - 1);
+ // "com.example.*" means that package and everything under it. Read as
+ // a bare prefix it would exclude com.example itself, which is the one
+ // package the author certainly meant to include.
+ if (prefix.endsWith(".") && pkg.equals(prefix.substring(0, prefix.length() - 1))) {
+ return true;
+ }
+ if (pkg.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Parses a file's top-level types, serving a cached result while the
+ * file's modification time is unchanged. Missing/unreadable files yield
+ * an empty list.
+ */
+ private static List<JavaSourceType> cachedParse(Path file) {
+ if (file == null || !Files.isRegularFile(file)) {
+ return List.of();
+ }
+ 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<JavaSourceType> types = JavaSourceTypeParser.parse(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 List.of();
+ }
+ }
+
+ /** Simple name → FQCNs, for merging into {@code ClassIndex}'s own name map. */
+ public Map<String, List<String>> classNames() {
+ return classNames;
+ }
+
+ /** The parsed model for {@code fqcn}, or {@code null} when unknown. */
+ public JavaSourceType byFqcn(String fqcn) {
+ return fqcn == null ? null : typesByFqcn.get(fqcn);
+ }
+
+ /** The source file {@code fqcn} was parsed from, or {@code null} when unknown. */
+ public Path fileOf(String fqcn) {
+ return fqcn == null ? null : fileByFqcn.get(fqcn);
+ }
+
+ /** The source roots this index was built from, for logging/tests. */
+ public Set<Path> roots() {
+ return roots;
+ }
+
+ @Override
+ public List<Field> membersOf(String fqcn) {
+ JavaSourceType type = byFqcn(fqcn);
+ return type == null ? List.of() : List.copyOf(membersIncludingInherited(fqcn, type).values());
+ }
+
+ @Override
+ /**
+ * Every name reachable through {@code Type.NAME}, which is wider than the
+ * member list: it adds the public static fields, because a constant is not a
+ * fact property but is still written that way. Mirrors reflection's
+ * {@code Class#getFields()}, which the compiled path uses here and which
+ * likewise includes statics — without this the unknown-type lint would call
+ * {@code Type.CONSTANT} an unknown member until the type is compiled.
+ */
+ public Set<String> memberNames(String fqcn) {
+ JavaSourceType type = byFqcn(fqcn);
+ if (type == null) {
+ return null;
+ }
+ Set<String> names = new LinkedHashSet<>(membersIncludingInherited(fqcn, type).keySet());
+ names.addAll(type.staticFieldNames);
+ return names;
+ }
+
+ /**
+ * Returns {@code type}'s own members followed by those of its ancestors,
+ * walked through {@code extendsSimpleName} within this index only, so a
+ * source-only subclass shows the same member set the reflection path
+ * (which walks {@code getMethods()}/superclass automatically) would show
+ * once a build replaces it. Deduped by name via {@code putIfAbsent} — own
+ * members and nearer ancestors win over farther ones. Depth-capped at 10
+ * and cycle-guarded by a seen-FQCN set, mirroring {@code
+ * DRLDeclaredTypeParser#fieldsIncludingInherited}'s shape. Stops (rather
+ * than guessing) the moment a parent's simple name can't be resolved to
+ * exactly one FQCN in this index.
+ */
+ private Map<String, Field> membersIncludingInherited(String fqcn, JavaSourceType type) {
+ Map<String, Field> members = new LinkedHashMap<>();
+ for (Field f : type.members) {
+ members.putIfAbsent(f.name, f);
+ }
+ Set<String> seen = new HashSet<>();
+ seen.add(fqcn);
+ String currentFqcn = fqcn;
+ String parentSimpleName = type.extendsSimpleName;
+ int depth = 0;
+ while (parentSimpleName != null && depth++ < 10) {
+ String parentFqcn = resolveParentFqcn(currentFqcn, parentSimpleName);
+ if (parentFqcn == null || !seen.add(parentFqcn)) {
+ break;
+ }
+ JavaSourceType parent = typesByFqcn.get(parentFqcn);
+ if (parent == null) {
+ break;
+ }
+ for (Field f : parent.members) {
+ members.putIfAbsent(f.name, f);
+ }
+ currentFqcn = parentFqcn;
+ parentSimpleName = parent.extendsSimpleName;
+ }
+ return members;
+ }
+
+ /**
+ * Resolves {@code parentSimpleName} (an {@code extends} target) to an
+ * FQCN within this index: first the same package as {@code childFqcn}
+ * (the common case), else — only when {@code classNames()} maps the
+ * simple name to exactly one FQCN — that unique cross-package match.
+ * Returns {@code null} (stop the walk) rather than guess among several
+ * same-named candidates.
+ */
+ private String resolveParentFqcn(String childFqcn, String parentSimpleName) {
+ int dot = childFqcn.lastIndexOf('.');
+ String childPackage = dot >= 0 ? childFqcn.substring(0, dot) : "";
+ String samePackageCandidate = childPackage.isEmpty()
+ ? parentSimpleName : childPackage + "." + parentSimpleName;
+ if (typesByFqcn.containsKey(samePackageCandidate)) {
+ return samePackageCandidate;
+ }
+ List<String> candidates = classNames.get(parentSimpleName);
+ return (candidates != null && candidates.size() == 1) ? candidates.get(0) : null;
+ }
+
+ /**
+ * Returns {@code fqcn}'s direct supertypes — its {@code extends} target
+ * followed by its directly-implemented interfaces — resolved to FQCNs via
+ * {@link #resolveParentFqcn} (same package first, else a unique
+ * cross-package simple-name match within this index). A supertype that
+ * can't be resolved that way is omitted rather than guessed at, mirroring
+ * {@link #membersIncludingInherited}'s stop-don't-guess discipline.
+ */
+ @Override
+ public List<String> supertypesOf(String fqcn) {
+ JavaSourceType type = byFqcn(fqcn);
+ if (type == null) {
+ return List.of();
+ }
+ List<String> out = new ArrayList<>();
+ if (type.extendsSimpleName != null) {
+ String resolved = resolveParentFqcn(fqcn, type.extendsSimpleName);
+ if (resolved != null) {
+ out.add(resolved);
+ }
+ }
+ for (String interfaceSimpleName : type.interfaceSimpleNames) {
+ String resolved = resolveParentFqcn(fqcn, interfaceSimpleName);
+ if (resolved != null) {
+ out.add(resolved);
+ }
+ }
+ return out;
+ }
+
+ @Override
+ public List<String> constructorsOf(String fqcn) {
+ JavaSourceType type = byFqcn(fqcn);
+ return type == null ? List.of() : type.constructors;
+ }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java
new file mode 100644
index 00000000000..a20a7803be2
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java
@@ -0,0 +1,462 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.drools.drl.parser.antlr4.JavaLexer;
+import org.drools.drl.parser.antlr4.JavaParser;
+
+/**
+ * Parses {@code .java} source into {@link JavaSourceType}s using the ANTLR Java
+ * grammar generated into the {@code drools-parser} jar
+ * ({@code org.drools.drl.parser.antlr4.JavaParser}). Only top-level types are
+ * indexed; nested types are skipped. Best-effort: syntax errors are silenced so
+ * partial/edited buffers still yield whatever parsed cleanly, and the parser
+ * never throws.
+ *
+ * <p>Known limits (acceptable for typing/hover/lint): nested types are not
+ * indexed; interface member extraction is name-first (fields/constants may be
+ * partial); generic type arguments and array dimensions are erased to the raw
+ * simple name.
+ */
+public final class JavaSourceTypeParser {
+
+ private static final Logger logger = Logger.getLogger(JavaSourceTypeParser.class.getName());
+
+ private static final BaseErrorListener SILENT = new BaseErrorListener() {
+ @Override
+ public void syntaxError(Recognizer<?, ?> r, Object sym, int line, int col,
+ String msg, RecognitionException e) {
+ }
+ };
+
+ private JavaSourceTypeParser() {
+ }
+
+ public static List<JavaSourceType> parse(String source) {
+ if (source == null || source.isBlank()) {
+ return Collections.emptyList();
+ }
+ try {
+ JavaLexer lexer = new JavaLexer(CharStreams.fromString(source));
+ lexer.removeErrorListeners();
+ lexer.addErrorListener(SILENT);
+ JavaParser parser = new JavaParser(new CommonTokenStream(lexer));
+ parser.removeErrorListeners();
+ parser.addErrorListener(SILENT);
+
+ JavaParser.CompilationUnitContext cu = parser.compilationUnit();
+ if (cu == null) {
+ return Collections.emptyList();
+ }
+ String pkg = (cu.packageDeclaration() != null
+ && cu.packageDeclaration().qualifiedName() != null)
+ ? cu.packageDeclaration().qualifiedName().getText() : "";
+
+ List<JavaSourceType> out = new ArrayList<>();
+ for (JavaParser.TypeDeclarationContext td : cu.typeDeclaration()) {
+ try {
+ JavaSourceType t = fromTypeDeclaration(td, pkg);
+ if (t != null) {
+ out.add(t);
+ }
+ } catch (Exception e) {
+ logger.fine(() -> "Skipping malformed top-level type: " + e.getMessage());
+ }
+ }
+ return out;
+ } catch (Exception e) {
+ logger.fine(() -> "Failed to parse Java source: " + e.getMessage());
+ return Collections.emptyList();
+ }
+ }
+
+ private static JavaSourceType fromTypeDeclaration(JavaParser.TypeDeclarationContext td, String pkg) {
+ if (td.classDeclaration() != null) {
+ return fromClass(td.classDeclaration(), pkg);
+ }
+ if (td.enumDeclaration() != null) {
+ return fromEnum(td.enumDeclaration(), pkg);
+ }
+ if (td.interfaceDeclaration() != null) {
+ return fromInterface(td.interfaceDeclaration(), pkg);
+ }
+ if (td.recordDeclaration() != null) {
+ return fromRecord(td.recordDeclaration(), pkg);
+ }
+ return null; // annotation type / bare ';'
+ }
+
+ private static JavaSourceType fromClass(JavaParser.ClassDeclarationContext cd, String pkg) {
+ String simpleName = cd.identifier().getText();
+ String extendsName = extendsSimpleNameOf(cd.typeType());
+ List<String> interfaces = (cd.IMPLEMENTS() != null && !cd.typeList().isEmpty())
+ ? simplifyAll(cd.typeList(0)) : List.of();
+
+ List<Field> fields = new ArrayList<>();
+ List<Field> getters = new ArrayList<>();
+ List<String> ctors = new ArrayList<>();
+ List<String> staticFields = new ArrayList<>();
+ if (cd.classBody() != null) {
+ collectBodyMembers(cd.classBody().classBodyDeclaration(), fields, getters, ctors,
+ staticFields, simpleName);
+ }
+
+ Map<String, Field> members = new LinkedHashMap<>();
+ mergeGettersThenFields(members, getters, fields);
+
+ return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, extendsName, interfaces,
+ new ArrayList<>(members.values()), ctors, staticFields,
+ declLine(cd.identifier()), declColumn(cd.identifier()));
+ }
+
+ private static JavaSourceType fromEnum(JavaParser.EnumDeclarationContext ed, String pkg) {
+ String simpleName = ed.identifier().getText();
+ List<String> interfaces = (ed.IMPLEMENTS() != null && ed.typeList() != null)
+ ? simplifyAll(ed.typeList()) : List.of();
+
+ Map<String, Field> members = new LinkedHashMap<>();
+ if (ed.enumConstants() != null) {
+ for (JavaParser.EnumConstantContext ec : ed.enumConstants().enumConstant()) {
+ String name = ec.identifier().getText();
+ String args = ec.arguments() != null ? argsText(ec.arguments()) : null;
+ members.put(name, new Field(name, simpleName, args, Field.Origin.ENUM_CONSTANT));
+ }
+ }
+
+ List<Field> fields = new ArrayList<>();
+ List<Field> getters = new ArrayList<>();
+ List<String> ctors = new ArrayList<>();
+ List<String> staticFields = new ArrayList<>();
+ if (ed.enumBodyDeclarations() != null) {
+ collectBodyMembers(ed.enumBodyDeclarations().classBodyDeclaration(), fields, getters, ctors,
+ staticFields, simpleName);
+ }
+ mergeGettersThenFields(members, getters, fields);
+
+ return new JavaSourceType(fqcn(pkg, simpleName), simpleName, true, null, interfaces,
+ new ArrayList<>(members.values()), ctors, staticFields,
+ declLine(ed.identifier()), declColumn(ed.identifier()));
+ }
+
+ private static JavaSourceType fromInterface(JavaParser.InterfaceDeclarationContext id, String pkg) {
+ String simpleName = id.identifier().getText();
+ List<String> interfaces = (id.EXTENDS() != null && !id.typeList().isEmpty())
+ ? simplifyAll(id.typeList(0)) : List.of();
+
+ List<Field> fields = new ArrayList<>();
+ List<Field> getters = new ArrayList<>();
+ if (id.interfaceBody() != null) {
+ for (JavaParser.InterfaceBodyDeclarationContext ibd : id.interfaceBody().interfaceBodyDeclaration()) {
+ try {
+ collectInterfaceMember(ibd, fields, getters);
+ } catch (Exception e) {
+ logger.fine(() -> "Skipping interface member in " + simpleName + ": " + e.getMessage());
+ }
+ }
+ }
+ Map<String, Field> members = new LinkedHashMap<>();
+ mergeGettersThenFields(members, getters, fields);
+
+ return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, null, interfaces,
+ new ArrayList<>(members.values()), List.of(), List.of(),
+ declLine(id.identifier()), declColumn(id.identifier()));
+ }
+
+ private static JavaSourceType fromRecord(JavaParser.RecordDeclarationContext rd, String pkg) {
+ String simpleName = rd.identifier().getText();
+ List<String> interfaces = (rd.IMPLEMENTS() != null && rd.typeList() != null)
+ ? simplifyAll(rd.typeList()) : List.of();
+
+ List<JavaParser.RecordComponentContext> components =
+ (rd.recordHeader() != null && rd.recordHeader().recordComponentList() != null)
+ ? rd.recordHeader().recordComponentList().recordComponent() : List.of();
+
+ // Records expose components only as accessor methods — there is no
+ // separate private field worth modeling — so each component is a
+ // single GETTER, consistent with getters beating fields elsewhere.
+ Map<String, Field> members = new LinkedHashMap<>();
+ List<String> ctorTypes = new ArrayList<>();
+ for (JavaParser.RecordComponentContext rc : components) {
+ String name = rc.identifier().getText();
+ String type = simplify(rc.typeType());
+ members.putIfAbsent(name, new Field(name, type, null, Field.Origin.GETTER));
+ ctorTypes.add(type);
+ }
+ String canonicalCtor = simpleName + "(" + String.join(", ", ctorTypes) + ")";
+
+ return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, null, interfaces,
+ new ArrayList<>(members.values()), List.of(canonicalCtor), List.of(),
+ declLine(rd.identifier()), declColumn(rd.identifier()));
+ }
+
+ /**
+ * Merges getters then fields into {@code into} via {@code putIfAbsent} —
+ * a getter beats a same-named field. This mirrors the insertion order
+ * {@code ClassMemberIndex.reflectMembers} uses when reflecting a compiled
+ * class, so a source-parsed type's member list doesn't reshuffle once a
+ * build replaces it with the reflected view.
+ */
+ private static void mergeGettersThenFields(Map<String, Field> into, List<Field> getters, List<Field> fields) {
+ for (Field f : getters) {
+ into.putIfAbsent(f.name, f);
+ }
+ for (Field f : fields) {
+ into.putIfAbsent(f.name, f);
+ }
+ }
+
+ /**
+ * Walks a class/enum body's declarations, sorting each into a field,
+ * getter, or constructor-signature list. Per-member failures are
+ * swallowed so one malformed declaration doesn't drop the rest.
+ */
+ private static void collectBodyMembers(List<JavaParser.ClassBodyDeclarationContext> decls,
+ List<Field> fieldsOut, List<Field> gettersOut,
+ List<String> ctorsOut, List<String> staticFieldsOut,
+ String simpleName) {
+ for (JavaParser.ClassBodyDeclarationContext cbd : decls) {
+ try {
+ JavaParser.MemberDeclarationContext md = cbd.memberDeclaration();
+ if (md == null) {
+ continue; // static block or bare ';'
+ }
+ if (md.fieldDeclaration() != null && hasPublicModifier(cbd.modifier())) {
+ JavaParser.FieldDeclarationContext fd = md.fieldDeclaration();
+ String type = simplify(fd.typeType());
+ boolean isStatic = hasStaticModifier(cbd.modifier());
+ for (JavaParser.VariableDeclaratorContext vd : fd.variableDeclarators().variableDeclarator()) {
+ String name = vd.variableDeclaratorId().identifier().getText();
+ if (isStatic) {
+ // Kept by name only: reachable as Type.NAME, but not a
+ // property of a fact, so out of the member list.
+ staticFieldsOut.add(name);
+ } else {
+ fieldsOut.add(new Field(name, type, null, Field.Origin.FIELD));
+ }
+ }
+ } else if (md.methodDeclaration() != null && isPublicInstanceMember(cbd.modifier())) {
+ JavaParser.MethodDeclarationContext mt = md.methodDeclaration();
+ String property = getterPropertyOf(mt.typeTypeOrVoid(), mt.identifier(), mt.formalParameters());
+ if (property != null) {
+ gettersOut.add(new Field(property, simplify(mt.typeTypeOrVoid().typeType()), null,
+ Field.Origin.GETTER));
+ }
+ } else if (md.constructorDeclaration() != null && hasPublicModifier(cbd.modifier())) {
+ ctorsOut.add(constructorSignature(simpleName, md.constructorDeclaration().formalParameters()));
+ }
+ } catch (Exception e) {
+ logger.fine(() -> "Skipping class member in " + simpleName + ": " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * A public, non-static member — the shape reflection reports as an instance
+ * member, and so the only shape this view may claim. Statics are excluded
+ * rather than modelled separately: parity with the compiled path is what
+ * keeps a member from appearing before a build and vanishing after it.
+ */
+ private static boolean isPublicInstanceMember(List<JavaParser.ModifierContext> modifiers) {
+ return hasPublicModifier(modifiers) && !hasStaticModifier(modifiers);
+ }
+
+ private static boolean hasStaticModifier(List<JavaParser.ModifierContext> modifiers) {
+ for (JavaParser.ModifierContext modifier : modifiers) {
+ JavaParser.ClassOrInterfaceModifierContext coim = modifier.classOrInterfaceModifier();
+ if (coim != null && coim.STATIC() != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** True when {@code modifiers} includes {@code public}. */
+ private static boolean hasPublicModifier(List<JavaParser.ModifierContext> modifiers) {
+ for (JavaParser.ModifierContext modifier : modifiers) {
+ JavaParser.ClassOrInterfaceModifierContext coim = modifier.classOrInterfaceModifier();
+ if (coim != null && coim.PUBLIC() != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Extracts a {@code constDeclaration} or no-arg getter from one interface body member, if any. */
+ private static void collectInterfaceMember(JavaParser.InterfaceBodyDeclarationContext ibd,
+ List<Field> fieldsOut, List<Field> gettersOut) {
+ JavaParser.InterfaceMemberDeclarationContext imd = ibd.interfaceMemberDeclaration();
+ if (imd == null) {
+ return; // bare ';'
+ }
+ if (imd.constDeclaration() != null) {
+ JavaParser.ConstDeclarationContext cdecl = imd.constDeclaration();
+ String type = simplify(cdecl.typeType());
+ for (JavaParser.ConstantDeclaratorContext decl : cdecl.constantDeclarator()) {
+ String name = decl.identifier().getText();
+ fieldsOut.add(new Field(name, type, null, Field.Origin.FIELD));
+ }
+ } else if (imd.interfaceMethodDeclaration() != null) {
+ JavaParser.InterfaceCommonBodyDeclarationContext body =
+ imd.interfaceMethodDeclaration().interfaceCommonBodyDeclaration();
+ String property = getterPropertyOf(body.typeTypeOrVoid(), body.identifier(), body.formalParameters());
+ if (property != null) {
+ gettersOut.add(new Field(property, simplify(body.typeTypeOrVoid().typeType()), null,
+ Field.Origin.GETTER));
+ }
+ }
+ }
+
+ /**
+ * Maps a no-arg {@code getX()}/{@code isX()} declaration to its bean
+ * property name (JavaBeans decapitalize rule, mirroring
+ * {@code ClassMemberIndex.propertyNameOf}), or {@code null} for
+ * non-accessors and {@code getClass}.
+ */
+ private static String getterPropertyOf(JavaParser.TypeTypeOrVoidContext returnType,
+ JavaParser.IdentifierContext nameCtx,
+ JavaParser.FormalParametersContext params) {
+ if (returnType == null || returnType.typeType() == null || nameCtx == null) {
+ return null; // void return, or malformed
+ }
+ if (params != null && params.formalParameterList() != null) {
+ return null; // getters take no arguments
+ }
+ String name = nameCtx.getText();
+ if (name.startsWith("get") && name.length() > 3 && Character.isUpperCase(name.charAt(3))) {
+ if ("getClass".equals(name)) {
+ return null;
+ }
+ return decapitalize(name.substring(3));
+ }
+ if (name.startsWith("is") && name.length() > 2 && Character.isUpperCase(name.charAt(2))) {
+ String returnSimple = simplify(returnType.typeType());
+ if ("boolean".equals(returnSimple) || "Boolean".equals(returnSimple)) {
+ return decapitalize(name.substring(2));
+ }
+ }
+ return null;
+ }
+
+ /** JavaBeans decapitalize: keep as-is when the first two letters are both uppercase. */
+ private static String decapitalize(String raw) {
+ 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);
+ }
+
+ private static String constructorSignature(String simpleName, JavaParser.FormalParametersContext params) {
+ List<String> types = new ArrayList<>();
+ JavaParser.FormalParameterListContext list = params == null ? null : params.formalParameterList();
+ if (list != null) {
+ for (JavaParser.FormalParameterContext fp : list.formalParameter()) {
+ types.add(simplify(fp.typeType()));
+ }
+ if (list.lastFormalParameter() != null) {
+ types.add(simplify(list.lastFormalParameter().typeType()) + "...");
+ }
+ }
+ return simpleName + "(" + String.join(", ", types) + ")";
+ }
+
+ private static List<String> simplifyAll(JavaParser.TypeListContext typeList) {
+ List<String> out = new ArrayList<>();
+ if (typeList != null) {
+ for (JavaParser.TypeTypeContext t : typeList.typeType()) {
+ out.add(simplify(t));
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Simple name of an {@code extends} supertype: the last {@code identifier()}
+ * of its {@code classOrInterfaceType()} when present (avoids any ambiguity
+ * from generics/arrays in raw text), else the type's text run through
+ * {@link #simplify(String)}.
+ */
+ private static String extendsSimpleNameOf(JavaParser.TypeTypeContext typeType) {
+ if (typeType == null) {
+ return null;
+ }
+ JavaParser.ClassOrInterfaceTypeContext coit = typeType.classOrInterfaceType();
+ if (coit != null && !coit.identifier().isEmpty()) {
+ List<JavaParser.IdentifierContext> ids = coit.identifier();
+ return ids.get(ids.size() - 1).getText();
+ }
+ return simplify(typeType.getText());
+ }
+
+ private static String simplify(JavaParser.TypeTypeContext typeType) {
+ return typeType == null ? null : simplify(typeType.getText());
+ }
+
+ /** Strips generic type arguments and array brackets, then takes the last dotted segment. */
+ private static String simplify(String rawType) {
+ if (rawType == null) {
+ return null;
+ }
+ String t = rawType;
+ int generics = t.indexOf('<');
+ if (generics >= 0) {
+ t = t.substring(0, generics);
+ }
+ while (t.endsWith("[]")) {
+ t = t.substring(0, t.length() - 2);
+ }
+ int dot = t.lastIndexOf('.');
+ return dot >= 0 ? t.substring(dot + 1) : t;
+ }
+
+ /** The raw text between an {@code arguments()} node's parentheses. */
+ private static String argsText(JavaParser.ArgumentsContext args) {
+ String text = args.getText();
+ if (text.length() >= 2 && text.charAt(0) == '(' && text.charAt(text.length() - 1) == ')') {
+ return text.substring(1, text.length() - 1);
+ }
+ return text;
+ }
+
+ private static String fqcn(String pkg, String simpleName) {
+ return pkg.isEmpty() ? simpleName : pkg + "." + simpleName;
+ }
+
+ private static int declLine(JavaParser.IdentifierContext id) {
+ return id.getStart().getLine() - 1;
+ }
+
+ private static int declColumn(JavaParser.IdentifierContext id) {
+ return id.getStart().getCharPositionInLine();
+ }
+}
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
index 748524994c4..34308dbd34c 100644
--- 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
@@ -158,13 +158,71 @@ public final class LhsBindingResolver {
if (text == null || text.isEmpty()) {
return bindings;
}
- Matcher rule = RULE_WHEN.matcher(text);
+ Matcher rule = RULE_WHEN.matcher(maskCommentsAndStrings(text));
while (rule.find()) {
bindings.putAll(collect(rule.group(1), typesByName, accumResultTypes).types);
}
return bindings;
}
+ /**
+ * Replaces the contents of comments and string literals with spaces, keeping
+ * the text the same length so every offset into it still refers to the same
+ * character.
+ *
+ * <p>The section patterns here match bare keywords, and a rule's condition
+ * ends at the first {@code then}. Left unmasked, a comment reading
+ * "…if X then Y" or a constraint comparing against {@code "then"} ends the
+ * condition where it appears, hiding every binding past that point — and,
+ * when it lands inside a pattern, unbalancing that pattern's parentheses so
+ * even the bindings before it are lost. Quote characters themselves survive,
+ * so a masked literal is still a literal.
+ */
+ static String maskCommentsAndStrings(String text) {
+ char[] chars = text.toCharArray();
+ int i = 0;
+ while (i < chars.length) {
+ char c = chars[i];
+ if (c == '/' && i + 1 < chars.length && chars[i + 1] == '/') {
+ while (i < chars.length && chars[i] != '\n') {
+ chars[i++] = ' ';
+ }
+ } else if (c == '/' && i + 1 < chars.length && chars[i + 1] == '*') {
+ chars[i++] = ' ';
+ chars[i++] = ' ';
+ while (i < chars.length
+ && !(chars[i] == '*' && i + 1 < chars.length && chars[i + 1] == '/')) {
+ blankUnlessNewline(chars, i++);
+ }
+ if (i < chars.length) {
+ chars[i++] = ' ';
+ chars[i++] = ' ';
+ }
+ } else if (c == '"' || c == '\'') {
+ i++;
+ while (i < chars.length && chars[i] != c) {
+ if (chars[i] == '\\' && i + 1 < chars.length) {
+ chars[i++] = ' ';
+ }
+ blankUnlessNewline(chars, i++);
+ }
+ if (i < chars.length) {
+ i++;
+ }
+ } else {
+ i++;
+ }
+ }
+ return new String(chars);
+ }
+
+ /** Newlines stay, so masking never joins two lines into one. */
+ private static void blankUnlessNewline(char[] chars, int index) {
+ if (index < chars.length && chars[index] != '\n') {
+ chars[index] = ' ';
+ }
+ }
+
/**
* As {@link #resolve(String, Map)}, but scoped to the single rule block
* enclosing {@code offset} (a character offset into {@code text}). Callers
@@ -189,7 +247,7 @@ public final class LhsBindingResolver {
if (text == null || text.isEmpty()) {
return new HashMap<>();
}
- Matcher block = RULE_BLOCK.matcher(text);
+ Matcher block = RULE_BLOCK.matcher(maskCommentsAndStrings(text));
while (block.find()) {
if (offset >= block.start() && offset < block.end()) {
return collect(block.group(1), typesByName, accumResultTypes).types;
@@ -237,7 +295,7 @@ public final class LhsBindingResolver {
int bodyStart = openParen + 1;
String body = whenSection.substring(bodyStart, closeParen);
- DeclaredType declared = typesByName.get(typeName);
+ DeclaredType declared = typeOrClasspath(typeName, typesByName);
Matcher fb = FIELD_BINDING.matcher(body);
while (fb.find()) {
@@ -306,7 +364,9 @@ public final class LhsBindingResolver {
if (current.extendsName == null) {
return null;
}
- current = typesByName.get(current.extendsName);
+ // The supertype may be a Java class rather than another declare, in
+ // which case only the host can describe it.
+ current = typeOrClasspath(current.extendsName, typesByName);
}
return null;
}
@@ -316,8 +376,11 @@ public final class LhsBindingResolver {
* 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.
+ *
+ * <p>Also used by member completion after a dot, so that the path a caret
+ * sits at the end of resolves exactly as a binding on the same path would.
*/
- private static String resolvePath(DeclaredType type, String path,
+ static String resolvePath(DeclaredType type, String path,
Map<String, DeclaredType> typesByName) {
DeclaredType current = type;
String resolved = null;
@@ -329,11 +392,22 @@ public final class LhsBindingResolver {
if (resolved == null) {
return null;
}
- current = typesByName.get(resolved);
+ current = typeOrClasspath(resolved, typesByName);
}
return resolved;
}
+ /**
+ * The DRL-declared type named {@code name}, or — when none is declared — a
+ * stand-in built from the host's member lookup so bindings on classpath and
+ * workspace-source types resolve too.
+ */
+ static DeclaredType typeOrClasspath(String name,
+ Map<String, DeclaredType> typesByName) {
+ DeclaredType declared = typesByName.get(name);
+ return declared != null ? declared : ClasspathTypeMembers.asDeclaredType(name);
+ }
+
private static String simpleName(String type) {
if (type == null) {
return null;
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
index eb118ef5b61..3cb995b6840 100644
--- 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
@@ -22,15 +22,36 @@ package org.drools.completion;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
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;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class ClassIndexTest {
+ /**
+ * The index is keyed by simple name, so answering "which FQCNs carry this
+ * exact name" needs no scan. getMatching is a prefix search — it also
+ * answers for OrderLine — which makes it the wrong tool for the callers
+ * that only want to confirm a single name.
+ */
+ @Test
+ void forSimpleNameIsExactWhereGetMatchingIsAPrefixSearch() {
+ ClassIndex index = ClassIndex.of(Map.of(
+ "Order", List.of("com.example.Order"),
+ "OrderLine", List.of("com.example.OrderLine")));
+
+ assertEquals(List.of("com.example.Order"), index.forSimpleName("Order"));
+ assertEquals(2, index.getMatching("Order").size(), "getMatching stays a prefix search");
+ assertTrue(index.forSimpleName("Nope").isEmpty());
+ }
+
@TempDir
Path tempDir;
@@ -130,4 +151,27 @@ class ClassIndexTest {
assertThat(merged.getMatching("Or")).containsExactly("com.acme.Order");
assertThat(merged.size()).isEqualTo(2);
}
+
+ @Test
+ void ofBuildsFromNameMap() {
+ ClassIndex idx = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+ assertTrue(idx.getMatching("Pat").contains("com.example.Patient"));
+ assertEquals(1, idx.size());
+ }
+
+ @Test
+ void mergeDeduplicatesSameFqcn() {
+ ClassIndex source = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+ ClassIndex compiled = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+ ClassIndex merged = ClassIndex.merge(source, compiled);
+ assertEquals(List.of("com.example.Patient"), merged.getMatching("Patient"));
+ assertEquals(1, merged.size());
+ }
+
+ @Test
+ void mergeKeepsDistinctCollisions() {
+ ClassIndex a = ClassIndex.of(Map.of("List", List.of("java.util.List")));
+ ClassIndex b = ClassIndex.of(Map.of("List", List.of("com.example.List")));
+ assertEquals(2, ClassIndex.merge(a, b).getMatching("List").size());
+ }
}
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
index a8b3d7823a0..dbfa2d6b9db 100644
--- 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
@@ -19,15 +19,47 @@
package org.drools.completion;
+import java.nio.file.Path;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.drools.completion.fixtures.InitProbe;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class ClassMemberIndexTest {
+ /**
+ * A link error can be transient — a dependency jar being rewritten by a
+ * build running alongside the editor is the ordinary case — so it must not
+ * write the name off for the rest of the index's life. Only a genuine "not
+ * on the classpath" answer is worth remembering, because a name that cannot
+ * be loaded also reports "unverifiable" to the unknown-type lint.
+ */
+ @Test
+ void aTransientLinkFailureIsRetried() {
+ String fqcn = "org.drools.completion.fixtures.Pet";
+ AtomicBoolean failNextLoad = new AtomicBoolean(true);
+ ClassLoader flaky = new ClassLoader(getClass().getClassLoader()) {
+ @Override
+ public Class<?> loadClass(String name) throws ClassNotFoundException {
+ if (fqcn.equals(name) && failNextLoad.getAndSet(false)) {
+ throw new NoClassDefFoundError("jar being rewritten");
+ }
+ return super.loadClass(name);
+ }
+ };
+ ClassMemberIndex index = new ClassMemberIndex(flaky);
+
+ assertTrue(index.membersOf(fqcn).isEmpty(), "the failed attempt yields nothing");
+ assertThat(index.membersOf(fqcn)).as("and the name is not written off").isNotEmpty();
+ }
+
private final ClassMemberIndex index =
new ClassMemberIndex(getClass().getClassLoader());
@@ -98,7 +130,8 @@ class ClassMemberIndexTest {
@Test
void unknownClassYieldsNoMembers() {
assertThat(index.membersOf("does.not.Exist")).isEmpty();
- // Negative result is cached and stays consistent.
+ // The failed load is memoized (not the empty result itself); repeated
+ // lookups skip Class.forName but keep returning the same empty list.
assertThat(index.membersOf("does.not.Exist")).isEmpty();
}
@@ -118,4 +151,84 @@ class ClassMemberIndexTest {
assertThat(borrowed.loadClass("java.lang.String")).isNotNull();
ClassMemberIndex.empty().close(); // must not throw
}
+
+ private JavaMemberSource fakeSource() {
+ return new JavaMemberSource() {
+ public List<Field> membersOf(String fqcn) {
+ return "com.example.Only".equals(fqcn)
+ ? List.of(new Field("code", "String", null, Field.Origin.FIELD),
+ new Field("active", "boolean", null, Field.Origin.GETTER))
+ : List.of();
+ }
+ public Set<String> memberNames(String fqcn) {
+ return "com.example.Only".equals(fqcn) ? Set.of("code", "active") : null;
+ }
+ public List<String> supertypesOf(String fqcn) {
+ // FQCN, per the JavaMemberSource contract: resolvable supertypes only.
+ return "com.example.Only".equals(fqcn) ? List.of("com.example.Base") : List.of();
+ }
+ public List<String> constructorsOf(String fqcn) {
+ return "com.example.Only".equals(fqcn) ? List.of("Only(String)") : List.of();
+ }
+ };
+ }
+
+ @Test
+ void reflectsConstructorsForClasspathType() {
+ ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+ List<String> ctors = idx.constructorsOf("java.lang.String");
+ assertTrue(ctors.stream().anyMatch(c -> c.startsWith("String(")),
+ () -> "ctors=" + ctors);
+ }
+
+ @Test
+ void tagsMemberOrigins() {
+ ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+ // java.awt.Point's getX()/getY()/getLocation() are bean-property getters that
+ // win over the same-named fields x,y (DRL property access binds to bean
+ // properties, so the getter-derived view is the semantically correct one).
+ List<Field> pointMembers = idx.membersOf("java.awt.Point");
+ assertTrue(pointMembers.stream().anyMatch(f -> f.name.equals("x") && f.origin == Field.Origin.GETTER));
+ assertTrue(pointMembers.stream().anyMatch(f -> f.name.equals("location") && f.origin == Field.Origin.GETTER));
+ // Pet.legs has no getter, so it surfaces as a plain FIELD.
+ List<Field> petMembers = idx.membersOf("org.drools.completion.fixtures.Pet");
+ assertTrue(petMembers.stream().anyMatch(f -> f.name.equals("legs") && f.origin == Field.Origin.FIELD));
+ }
+
+ @Test
+ void fallsBackToSourceWhenClassNotLoadable() {
+ ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+ idx.setSourceFallback(fakeSource());
+ // com.example.Only is not on the test classpath -> reflection miss -> fallback
+ assertTrue(idx.membersOf("com.example.Only").stream().anyMatch(f -> f.name.equals("code")));
+ assertEquals(Set.of("code", "active"), idx.memberNames("com.example.Only"));
+ assertEquals(List.of("com.example.Base"), idx.supertypesOf("com.example.Only"));
+ assertEquals(List.of("Only(String)"), idx.constructorsOf("com.example.Only"));
+ }
+
+ @Test
+ void compiledWinsOverSource() {
+ ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+ idx.setSourceFallback(fakeSource());
+ // java.lang.String IS loadable -> fallback never consulted (fake returns empty for it anyway;
+ // assert we get real reflected members, not the fake's)
+ assertTrue(idx.memberNames("java.lang.String").contains("CASE_INSENSITIVE_ORDER"));
+ }
+
+ @Test
+ void memberNamesStillNullWhenNeitherKnows() {
+ ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+ idx.setSourceFallback(fakeSource());
+ assertNull(idx.memberNames("com.nope.Nothing"));
+ }
+
+ @Test
+ void sourceOnlyWorkspaceUsesFallback() {
+ // Zero classpath entries -> the works-before-build scenario. of() must not
+ // hand back the shared empty() singleton, or a later setSourceFallback would
+ // silently no-op.
+ ClassMemberIndex idx = ClassMemberIndex.of(Set.<Path>of());
+ idx.setSourceFallback(fakeSource());
+ assertTrue(idx.membersOf("com.example.Only").stream().anyMatch(f -> f.name.equals("code")));
+ }
}
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
index 6adf55bbe26..2a0016b6adf 100644
--- 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
@@ -384,6 +384,297 @@ class DRLCompletionHelperTest {
assertThat(fieldLabels).contains("name", "friendly", "legs");
}
+ /**
+ * A caret after a dot can only be followed by a member of the path's type.
+ * The grammar predicts the constraint operators there (it cannot see the path
+ * is unfinished), so that position is answered from the type walk alone.
+ */
+ @Test
+ void memberCompletionAfterADotOnAPatternField() {
+ String text = """
+ package demo;
+
+ declare Fact
+ order : int
+ ref : Fact
+ end
+
+ rule R
+ when
+ Fact( ref. )
+ then
+ end
+ """;
+
+ Position caretPosition = new Position(9, 14); // right after 'ref.'
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).extracting(CompletionItem::getLabel)
+ .containsExactlyInAnyOrder("order", "ref");
+ assertThat(result).allSatisfy(item ->
+ assertThat(item.getKind()).isEqualTo(CompletionItemKind.Field));
+ }
+
+ /** Mid-edit the constraint is unfinished; the members must still resolve. */
+ @Test
+ void memberCompletionAfterADotWithTheConstraintStillUnclosed() {
+ String text = """
+ package demo;
+
+ declare Fact
+ order : int
+ ref : Fact
+ end
+
+ rule R
+ when
+ Fact( ref.""";
+
+ Position caretPosition = new Position(9, 14);
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).extracting(CompletionItem::getLabel)
+ .containsExactlyInAnyOrder("order", "ref");
+ }
+
+ @Test
+ void memberCompletionAfterADotWalksEveryHopOfTheChain() {
+ String text = """
+ package demo;
+
+ declare Inner
+ depth : int
+ end
+
+ declare Fact
+ inner : Inner
+ end
+
+ rule R
+ when
+ Fact( inner.depth. )
+ then
+ end
+ """;
+
+ // After 'inner.' — one hop, then after 'inner.depth.' — two hops onto an int.
+ List<CompletionItem> oneHop = DRLCompletionHelper.getCompletionItems(
+ text, new Position(12, 16), getLanguageClient(), ClassIndex.empty());
+ assertThat(oneHop).extracting(CompletionItem::getLabel).containsExactly("depth");
+
+ List<CompletionItem> twoHops = DRLCompletionHelper.getCompletionItems(
+ text, new Position(12, 22), getLanguageClient(), ClassIndex.empty());
+ assertThat(twoHops).as("int has no DRL-visible members").isEmpty();
+ }
+
+ @Test
+ void memberCompletionAfterADotOnABinding() {
+ String text = """
+ package demo;
+
+ declare Fact
+ order : int
+ end
+
+ rule R
+ when
+ $f : Fact( order > 0 )
+ Fact( order > $f. )
+ then
+ end
+ """;
+
+ Position caretPosition = new Position(9, 21); // right after '$f.'
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).extracting(CompletionItem::getLabel).containsExactly("order");
+ }
+
+ /**
+ * Writing the pattern head fully qualified is how an author disambiguates a
+ * simple name that collides on the classpath. So the head has to reach member
+ * resolution still qualified: reduced to its simple name it runs into the very
+ * ambiguity it was written to avoid.
+ */
+ @Test
+ void aFullyQualifiedPatternHeadResolvesWhenItsSimpleNameIsAmbiguous() {
+ String text = """
+ package demo;
+
+ rule R
+ when
+ org.drools.completion.fixtures.Pet( name. )
+ then
+ end
+ """;
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+ // Stands in for the ambiguous case: the host can answer for a qualified
+ // name and refuses a bare one, as it does when two classes share a
+ // simple name.
+ ClasspathTypeMembers.install(n -> n != null && n.indexOf('.') >= 0
+ ? memberIndex.membersOf(n) : List.of());
+ try {
+ // Line 4 is the pattern; the caret sits right after 'name.' at col 45.
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, new Position(4, 45), getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+ assertThat(result).as("members of the field's type").isNotEmpty();
+ } finally {
+ ClasspathTypeMembers.install(null);
+ }
+ }
+
+ /**
+ * The scan back to the enclosing pattern counts parentheses, so a paren
+ * inside a string literal would unbalance it and find the wrong pattern —
+ * or none. The same mask the binding resolver uses keeps the count honest.
+ */
+ @Test
+ void memberCompletionSurvivesAParenInsideAStringConstraint() {
+ String text = """
+ package demo;
+
+ declare Inner
+ depth : int
+ end
+
+ declare Fact
+ note : String
+ ref : Inner
+ end
+
+ rule R
+ when
+ Fact( note == ")", ref. )
+ then
+ end
+ """;
+
+ int col = text.split("\n")[13].indexOf("ref.") + "ref.".length();
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, new Position(13, col), getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).extracting(CompletionItem::getLabel).containsExactly("depth");
+ }
+
+ /**
+ * A dot is not only a member access: it also separates the segments of a
+ * qualified name. The head of such a name resolves to no type, so the member
+ * walk finds nothing and must leave the position to the grammar's own
+ * candidates rather than answering with an empty list.
+ */
+ @Test
+ void aDotInAQualifiedNameStillOffersTheGrammarCandidates() {
+ String text = "package demo;\n\nimport com.example.\n";
+
+ Position caretPosition = new Position(2, 19); // right after 'com.example.'
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).isNotEmpty();
+ }
+
+ @Test
+ void memberCompletionAfterADotOnAClasspathType() {
+ String text = """
+ package demo;
+
+ import org.drools.completion.fixtures.Pet;
+
+ declare Owner
+ pet : Pet
+ end
+
+ rule R
+ when
+ Owner( pet. )
+ then
+ end
+ """;
+
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+ Position caretPosition = new Position(10, 15); // right after 'pet.'
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, caretPosition, getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+ assertThat(result).extracting(CompletionItem::getLabel)
+ .contains("name", "friendly", "legs");
+ }
+
+ /** A decimal literal being typed is not a member position. */
+ @Test
+ void aTrailingDotOnANumberIsNotAMemberPosition() {
+ String text = """
+ package demo;
+
+ declare Fact
+ order : int
+ end
+
+ rule R
+ when
+ Fact( order > 1. )
+ then
+ end
+ """;
+
+ List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+ text, new Position(9, 20), getLanguageClient(), ClassIndex.empty());
+
+ assertThat(result).extracting(CompletionItem::getKind)
+ .doesNotContain(CompletionItemKind.Field);
+ }
+
+ /**
+ * Rule attributes are legal only in a rule/query header. c3 predicts them at a
+ * statement boundary too, where they cannot be typed.
+ */
+ @Test
+ void statementBoundaryOmitsRuleAttributes() {
+ String text = """
+ package demo;
+
+ rule R
+ when
+ then
+ end
+
+ """;
+
+ List<String> labels = DRLCompletionHelper.getCompletionItems(
+ text, new Position(6, 0), getLanguageClient())
+ .stream().map(CompletionItem::getLabel).toList();
+
+ assertThat(labels).contains("import", "global", "rule", "declare", "function", "query");
+ assertThat(labels).doesNotContain(
+ "salience", "no-loop", "dialect", "ruleflow-group", "agenda-group",
+ "auto-focus", "lock-on-active", "activation-group", "date-effective",
+ "date-expires", "calendars", "timer", "duration", "attributes", "enabled");
+ }
+
+ /** The same attributes stay available where they belong. */
+ @Test
+ void ruleHeaderKeepsOfferingRuleAttributes() {
+ String text = """
+ package demo;
+
+ rule R
+ \s
+ when
+ then
+ end
+ """;
+
+ List<String> labels = DRLCompletionHelper.getCompletionItems(
+ text, new Position(3, 2), getLanguageClient())
+ .stream().map(CompletionItem::getLabel).toList();
+
+ assertThat(labels).contains("salience", "no-loop", "dialect", "when");
+ }
+
@Test
void constraintPositionOmitsPrimitiveKeywordNoise() {
// Inside a pattern's parens c3 also predicts Java expression starters
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
index aa7ca421d93..88a450834a9 100644
--- 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
@@ -52,7 +52,7 @@ class DRLDefinitionHelperTest {
// Caret on "Person" inside the pattern.
List<Location> defs = DRLDefinitionHelper.findDefinitions(
"myDocument", DECLARE_DRL, new Position(8, 6),
- ClassIndex.empty(), Set.of());
+ ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
assertThat(defs).hasSize(1);
Location loc = defs.get(0);
@@ -88,7 +88,7 @@ class DRLDefinitionHelperTest {
// 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")));
+ ClassIndex.empty(), Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
assertThat(defs).hasSize(1);
Location loc = defs.get(0);
@@ -97,6 +97,69 @@ class DRLDefinitionHelperTest {
assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(13);
}
+ @Test
+ void javaSourceDefinitionFallsBackToSourceIndexBeforeBuild(@TempDir Path srcRoot) throws Exception {
+ // No target/classes at all — the type has never been compiled.
+ Path fooDir = srcRoot.resolve("com/example");
+ Files.createDirectories(fooDir);
+ Path fooJava = fooDir.resolve("Foo.java");
+ Files.writeString(fooJava, "package com.example;\npublic class Foo {\n}\n");
+
+ JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+ String drl = """
+ package demo;
+
+ import com.example.Foo;
+
+ rule R
+ when
+ Foo( )
+ then
+ end
+ """;
+
+ // Caret on "Foo" in the pattern (line 6, col 5); buildOutputDirs is
+ // empty so JavaSourceLocator.locate has nothing to find.
+ List<Location> defs = DRLDefinitionHelper.findDefinitions(
+ "myDocument", drl, new Position(6, 5),
+ ClassIndex.empty(), Set.of(), sourceIndex);
+
+ assertThat(defs).hasSize(1);
+ Location loc = defs.get(0);
+ assertThat(loc.getUri()).isEqualTo(fooJava.toUri().toString());
+ assertThat(loc.getRange().getStart().getLine()).isEqualTo(1);
+ assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(13);
+ assertThat(loc.getRange().getEnd().getCharacter()).isEqualTo(16);
+ }
+
+ @Test
+ void javaSourceDefinitionWithEmptySourceIndexYieldsNothing(@TempDir Path srcRoot) throws Exception {
+ Path fooDir = srcRoot.resolve("com/example");
+ Files.createDirectories(fooDir);
+ Files.writeString(fooDir.resolve("Foo.java"), "package com.example;\npublic class Foo {\n}\n");
+
+ String drl = """
+ package demo;
+
+ import com.example.Foo;
+
+ rule R
+ when
+ Foo( )
+ then
+ end
+ """;
+
+ // Same shape as the fallback test, but with an empty source index and
+ // no build output — pre-existing "no navigable location" behavior.
+ List<Location> defs = DRLDefinitionHelper.findDefinitions(
+ "myDocument", drl, new Position(6, 5),
+ ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
+
+ assertThat(defs).isEmpty();
+ }
+
@Test
void declaredTypeWinsOverJavaSourceOfTheSameName(@TempDir Path module) throws Exception {
Path classes = module.resolve("target/classes/demo");
@@ -109,7 +172,7 @@ class DRLDefinitionHelperTest {
List<Location> defs = DRLDefinitionHelper.findDefinitions(
"myDocument", DECLARE_DRL, new Position(8, 6),
- ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+ ClassIndex.empty(), Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
assertThat(defs).hasSize(1);
assertThat(defs.get(0).getUri()).isEqualTo("myDocument");
@@ -126,7 +189,7 @@ class DRLDefinitionHelperTest {
List<Location> defs = DRLDefinitionHelper.findDefinitions(
current.toUri().toString(), drl, new Position(3, 5),
- ClassIndex.empty(), Set.of());
+ ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
assertThat(defs).hasSize(1);
assertThat(defs.get(0).getUri()).isEqualTo(types.toUri().toString());
@@ -151,7 +214,7 @@ class DRLDefinitionHelperTest {
// 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());
+ ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
assertThat(defs).isEmpty();
}
@@ -160,7 +223,7 @@ class DRLDefinitionHelperTest {
void unknownSymbolYieldsNoDefinitions() {
List<Location> defs = DRLDefinitionHelper.findDefinitions(
"myDocument", DECLARE_DRL, new Position(8, 14),
- ClassIndex.empty(), Set.of());
+ ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
// "name" is a field, not a definable type in v1.
assertThat(defs).isEmpty();
}
@@ -170,14 +233,16 @@ class DRLDefinitionHelperTest {
// 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());
+ "myDocument", DECLARE_DRL, new Position(8, 6), ClassIndex.empty(), Set.of(),
+ JavaSourceTypeIndex.empty());
assertThat(DRLParsers.parseCount()).isEqualTo(1);
}
@Test
void nullTextYieldsNoDefinitions() {
assertThat(DRLDefinitionHelper.findDefinitions(
- "myDocument", null, new Position(0, 0), ClassIndex.empty(), Set.of()))
+ "myDocument", null, new Position(0, 0), ClassIndex.empty(), Set.of(),
+ JavaSourceTypeIndex.empty()))
.isEmpty();
}
}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
index 09279f1a4a1..f4f68478f98 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
@@ -21,6 +21,8 @@ package org.drools.completion;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
import org.eclipse.lsp4j.Hover;
import org.eclipse.lsp4j.Position;
@@ -177,6 +179,175 @@ class DRLHoverHelperTest {
assertThat(md).contains("name").contains("friendly").contains("legs");
}
+ @Test
+ void hoverOnDeclareExtendingAJavaClassShowsTheInheritedJavaFields() {
+ String drl = """
+ package demo;
+
+ import org.drools.completion.fixtures.Pet;
+
+ declare SpecialPet extends Pet
+ nickname : String
+ end
+
+ rule R
+ when
+ SpecialPet( )
+ then
+ end
+ """;
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+ ClasspathTypeMembers.install(name -> "Pet".equals(name)
+ ? memberIndex.membersOf("org.drools.completion.fixtures.Pet")
+ : List.of());
+ try {
+ // Caret on the declare's own name (line 4: "declare SpecialPet extends Pet").
+ Hover hover = DRLHoverHelper.hover(drl, new Position(4, 10),
+ ClassIndex.empty(), memberIndex, null);
+
+ String md = content(hover);
+ assertThat(md).contains("nickname");
+ // Inherited from the Java supertype, which the declared-type index
+ // cannot describe on its own.
+ assertThat(md).contains("legs").contains("name").contains("friendly");
+ } finally {
+ ClasspathTypeMembers.install(null);
+ }
+ }
+
+ @Test
+ void hoverOnFullyQualifiedTypeNameResolvesTheType() {
+ String drl = """
+ package demo;
+
+ rule R
+ when
+ org.drools.completion.fixtures.Pet( )
+ then
+ end
+ """;
+ ClassIndex classIndex = ClassIndex.of(
+ Map.of("Pet", List.of("org.drools.completion.fixtures.Pet")));
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+ // Caret on the "Pet" segment of the qualified name.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(4, 36), classIndex, memberIndex, null);
+
+ String md = content(hover);
+ assertThat(md).contains("org.drools.completion.fixtures.Pet");
+ assertThat(md).contains("name").contains("legs");
+ }
+
+ /**
+ * The package segments of a qualified name resolve to nothing on their own,
+ * so the chain walk cannot start from one. Any segment of the name describes
+ * the type the whole name identifies.
+ */
+ @Test
+ void hoverOnAPackageSegmentOfAQualifiedNameStillDescribesTheType() {
+ String drl = """
+ package demo;
+
+ rule R
+ when
+ org.drools.completion.fixtures.Pet( )
+ then
+ end
+ """;
+ ClassIndex classIndex = ClassIndex.of(
+ Map.of("Pet", List.of("org.drools.completion.fixtures.Pet")));
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+ // Caret on "org", the first package segment.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(4, 5), classIndex, memberIndex, null);
+
+ assertThat(content(hover)).contains("org.drools.completion.fixtures.Pet");
+ }
+
+ @Test
+ void hoverOnClasspathTypeShowsFieldsGettersAndConstructorsAsSections() {
+ String drl = """
+ package demo;
+
+ import org.drools.completion.fixtures.Pet;
+
+ rule R
+ when
+ Pet( )
+ then
+ end
+ """;
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+ Hover hover = DRLHoverHelper.hover(drl, new Position(6, 5),
+ ClassIndex.empty(), memberIndex, null);
+
+ String md = content(hover);
+ int fieldsAt = md.indexOf("**Fields**");
+ int gettersAt = md.indexOf("**Getters**");
+ int constructorsAt = md.indexOf("**Constructors**");
+ assertThat(fieldsAt).isPositive();
+ assertThat(gettersAt).isGreaterThan(fieldsAt);
+ assertThat(constructorsAt).isGreaterThan(gettersAt);
+ assertThat(md).contains("- legs : int");
+ assertThat(md).contains("- name : String");
+ assertThat(md).contains("- `Pet()`");
+ }
+
+ @Test
+ void hoverOnClasspathTypeWithNoMembersOrConstructorsRendersHeaderOnly() {
+ // java.lang.Math: only static fields/methods, so membersOf is empty,
+ // and its sole constructor is private, so constructorsOf is empty too.
+ String drl = """
+ package demo;
+
+ rule R
+ when
+ Math()
+ then
+ end
+ """;
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+ Hover hover = DRLHoverHelper.hover(drl, new Position(4, 4),
+ ClassIndex.empty(), memberIndex, null);
+
+ String md = content(hover);
+ assertThat(md).contains("java.lang.Math");
+ assertThat(md).doesNotContain("**Fields**")
+ .doesNotContain("**Getters**")
+ .doesNotContain("**Constructors**")
+ .doesNotContain("**Constants**");
+ }
+
+ @Test
+ void hoverOnClasspathEnumShowsConstantsSection() {
+ String drl = """
+ package demo;
+
+ import org.drools.completion.fixtures.PetKind;
+
+ rule R
+ when
+ PetKind( )
+ then
+ end
+ """;
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+ Hover hover = DRLHoverHelper.hover(drl, new Position(6, 9),
+ ClassIndex.empty(), memberIndex, null);
+
+ String md = content(hover);
+ // PetKind also picks up "declaringClass" as an inherited Enum getter,
+ // so only Fields (no FIELD-origin members exist) is asserted absent.
+ assertThat(md).contains("**Constants**");
+ assertThat(md).contains("- CAT");
+ assertThat(md).contains("- DOG");
+ assertThat(md).doesNotContain("**Fields**");
+ assertThat(md.indexOf("**Constants**")).isLessThan(md.indexOf("**Getters**"));
+ }
+
private static final String EXTENDS_DRL = """
package demo;
@@ -322,6 +493,86 @@ class DRLHoverHelperTest {
assertThat(md).doesNotContain("Account");
}
+ @Test
+ void hoverOnDocumentedFunctionShowsItsDocComment() {
+ String drl = """
+ package org.example;
+ /**
+ * Computes the risk score for a finding.
+ */
+ function int riskScore(int base) {
+ return base * 2;
+ }
+ rule R
+ when
+ then
+ int x = riskScore(1);
+ end
+ """;
+ // Caret on "riskScore" in the call on the RHS (line 10, 0-based).
+ Hover hover = DRLHoverHelper.hover(drl, new Position(10, 17),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("Computes the risk score for a finding.");
+ }
+
+ @Test
+ void hoverOnDocumentedGlobalShowsItsDocComment() {
+ String drl = """
+ package org.example;
+ /**
+ * Shared results collector.
+ */
+ global java.util.List results;
+ rule R
+ when
+ then
+ results.add("x");
+ end
+ """;
+ // Caret on "results" in the RHS usage.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(8, 9),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("Shared results collector.");
+ }
+
+ @Test
+ void hoverOnDocumentedQueryShowsItsDocComment() {
+ String drl = """
+ package org.example;
+ declare Person
+ name : String
+ end
+ /**
+ * All persons with the given name.
+ */
+ query personsNamed(String n)
+ Person(name == n)
+ end
+ """;
+ // Caret on "personsNamed" in the query header.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(7, 8),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("All persons with the given name.");
+ }
+
+ @Test
+ void undocumentedFunctionNameYieldsNoDocHover() {
+ String drl = """
+ package org.example;
+ function int plain(int base) {
+ return base;
+ }
+ """;
+ // Caret on "plain" in the function header; no preceding doc comment.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(1, 14),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(hover).isNull();
+ }
+
@Test
void hoverOnJavaLangTypeResolvesWithoutImport() {
// java.lang.Object is implicitly available and has no bean getters, yet
@@ -340,4 +591,442 @@ class DRLHoverHelperTest {
assertThat(content(hover)).contains("java.lang.Object");
}
+
+ private static final String ENUM_CHAIN_DRL = """
+ package demo;
+
+ declare enum Status
+ ACTIVE, CLOSED;
+ end
+
+ declare Ticket
+ status : Status
+ end
+
+ rule R
+ when
+ Ticket( status == Status.ACTIVE )
+ then
+ end
+ """;
+
+ @Test
+ void hoverOnQualifiedEnumConstantShowsTheEnum() {
+ // Caret on "ACTIVE" in "Status.ACTIVE": line 12 is
+ // ` Ticket( status == Status.ACTIVE )`, "ACTIVE" spans cols 29-34.
+ Hover hover = DRLHoverHelper.hover(ENUM_CHAIN_DRL, new Position(12, 30),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("Status.ACTIVE");
+ assertThat(md).contains("declare enum Status");
+ assertThat(md).contains("CLOSED");
+ }
+
+ @Test
+ void hoverOnEnumQualifierShowsTheEnumItself() {
+ // Caret on "Status" in "Status.ACTIVE" (cols 22-27 of line 12): the
+ // enum's own render, not the constant header.
+ Hover hover = DRLHoverHelper.hover(ENUM_CHAIN_DRL, new Position(12, 23),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("declare enum Status");
+ assertThat(md).doesNotContain("Status.ACTIVE");
+ }
+
+ @Test
+ void hoverOnChainedFieldResolvesThroughTheBinding() {
+ String drl = """
+ package demo;
+
+ declare Order
+ total : double
+ end
+
+ declare Line
+ amount : double
+ end
+
+ rule R
+ when
+ $o : Order( )
+ Line( amount > $o.total )
+ then
+ end
+ """;
+ // Caret on "total" in "$o.total": line 13 is
+ // ` Line( amount > $o.total )`, "total" spans cols 22-26.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(13, 23),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("**total**").contains("double");
+ assertThat(md).contains("Field of `Order`");
+ }
+
+ @Test
+ void hoverMidChainSegmentRendersThatSegment() {
+ String drl = """
+ package demo;
+
+ declare Inner
+ c : int
+ end
+
+ declare Outer
+ b : Inner
+ end
+
+ rule R
+ when
+ $a : Outer( )
+ Inner( c > $a.b.c )
+ then
+ end
+ """;
+ // Caret on "b" in "$a.b.c": line 13 is ` Inner( c > $a.b.c )`,
+ // "b" is col 18. The hover renders b (type Inner, owner Outer), not c.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(13, 18),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("**b**").contains("Inner");
+ assertThat(md).contains("Field of `Outer`");
+ assertThat(md).doesNotContain("**c**").doesNotContain("int");
+ }
+
+ @Test
+ void unresolvableChainYieldsNull() {
+ // `$nope` binds nothing, so the chain must not resolve — and must not
+ // fall back to hovering "age" as a field of the enclosing Person
+ // pattern (here it is $nope's member, not Person's).
+ String drl = """
+ package demo;
+
+ declare Person
+ age : int
+ end
+
+ rule R
+ when
+ Person( age > $nope.age )
+ then
+ end
+ """;
+ // Caret on "age" in "$nope.age": line 8 is
+ // ` Person( age > $nope.age )`, the chained "age" spans cols 24-26.
+ assertThat(DRLHoverHelper.hover(drl, new Position(8, 25),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null)).isNull();
+ }
+
+ private static final String ACCUMULATE_DRL = """
+ package demo;
+
+ declare Person
+ name : String
+ end
+
+ rule R
+ when
+ accumulate( $p : Person(); $c : count() )
+ then
+ end
+ """;
+
+ @Test
+ void hoverOnAccumulateFunctionShowsResultType() {
+ // Caret on "count" in "$c : count()": line 8 is
+ // ` accumulate( $p : Person(); $c : count() )`, "count" spans
+ // cols 36-40.
+ Hover hover = DRLHoverHelper.hover(ACCUMULATE_DRL, new Position(8, 38),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("count : Long");
+ assertThat(md).contains("accumulate function");
+ }
+
+ private static final String COUNT_FIELD_DRL = """
+ package demo;
+
+ declare Bucket
+ count : int
+ end
+
+ rule R
+ when
+ Bucket( count > 0 )
+ then
+ end
+ """;
+
+ @Test
+ void countOutsideAccumulateIsNotAFunctionHover() {
+ // "count" here is a plain field of Bucket, not an accumulate function:
+ // line 8 is ` Bucket( count > 0 )`, "count" spans cols 12-16. It must
+ // render as a field, never as the accumulate-function hover.
+ Hover hover = DRLHoverHelper.hover(COUNT_FIELD_DRL, new Position(8, 14),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("count").contains("int");
+ assertThat(md).doesNotContain("accumulate function");
+ }
+
+ private static final String COLLECT_ARG_DRL = """
+ package demo;
+
+ declare Person
+ name : String
+ end
+
+ rule R
+ when
+ accumulate( $p : Person(); $l : collectList($p) )
+ then
+ end
+ """;
+
+ @Test
+ void hoverOnAccumulateArgumentResolvesAsTheBinding() {
+ // Caret on "$p" inside "collectList($p)" (line 8, cols 48-49): the
+ // argument must resolve through the binding step, never as the
+ // function itself.
+ Hover hover = DRLHoverHelper.hover(COLLECT_ARG_DRL, new Position(8, 48),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("declare Person");
+ assertThat(md).doesNotContain("accumulate function");
+ }
+
+ private static final String SUM_ARG_DRL = """
+ package demo;
+
+ declare Person
+ weight : int
+ end
+
+ rule R
+ when
+ accumulate( $p : Person(); $t : sum(sum) )
+ then
+ end
+ """;
+
+ @Test
+ void accumulateArgumentTextEqualToTheFunctionNameIsNotAFunctionHover() {
+ // Line 8 is ` accumulate( $p : Person(); $t : sum(sum) )`: the
+ // function identifier spans cols 36-38, the argument cols 40-42. Only
+ // the identifier itself may render the function hover; the argument
+ // resolves as nothing here and must yield no hover at all.
+ Hover hover = DRLHoverHelper.hover(SUM_ARG_DRL, new Position(8, 41),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(hover).isNull();
+ }
+
+ private static final String DOCUMENTED_SUM_DRL = """
+ package demo;
+
+ declare Person
+ weight : int
+ end
+
+ /**
+ * Adds two numbers the slow way.
+ */
+ function int sum(int a, int b) {
+ return a + b;
+ }
+
+ rule R
+ when
+ accumulate( Person( $w : weight ); $t : sum($w) )
+ then
+ end
+ """;
+
+ @Test
+ void accumulateUsageOutranksASameNamedDocumentedFunction() {
+ // Line 15 is ` accumulate( Person( $w : weight ); $t : sum($w) )`;
+ // the accumulate identifier "sum" spans cols 44-46. At that position
+ // the name is structurally an accumulate function, so the hover must
+ // show the function's result type — not the doc of the DRL function
+ // that happens to share its name.
+ Hover hover = DRLHoverHelper.hover(DOCUMENTED_SUM_DRL, new Position(15, 45),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("sum : Double").contains("accumulate function");
+ assertThat(md).doesNotContain("slow way");
+ }
+
+ @Test
+ void documentedFunctionStillShowsItsDocOutsideAccumulate() {
+ // Line 9 is `function int sum(int a, int b) {`; "sum" spans cols
+ // 13-15. Outside an accumulate span the doc-comment hover applies.
+ Hover hover = DRLHoverHelper.hover(DOCUMENTED_SUM_DRL, new Position(9, 14),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("Adds two numbers the slow way.");
+ }
+
+ @Test
+ void hoverOnAccumulateResultBindingResolvesAsTheBinding() {
+ // Caret on "$c" in "$c : count()" (line 8, cols 31-32): the label
+ // sits inside the accumulateFunction node, but only the function
+ // identifier itself may render the function hover — the label resolves
+ // through the binding step to the function's result type.
+ Hover hover = DRLHoverHelper.hover(ACCUMULATE_DRL, new Position(8, 31),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("Long");
+ assertThat(md).doesNotContain("accumulate function");
+ }
+
+ private static final String BIG_DECIMAL_ACCUMULATE_DRL = """
+ package demo;
+
+ declare Person
+ amount : java.math.BigDecimal
+ end
+
+ rule R
+ when
+ accumulate( Person( $a : amount ); $m : maxBD($a) )
+ then
+ end
+ """;
+
+ @Test
+ void hoverOnABigDecimalAccumulateFunctionShowsItsResultType() {
+ // Line 8 is ` accumulate( Person( $a : amount ); $m : maxBD($a) )`;
+ // the function identifier "maxBD" spans cols 44-48.
+ Hover hover = DRLHoverHelper.hover(BIG_DECIMAL_ACCUMULATE_DRL, new Position(8, 46),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("maxBD : BigDecimal").contains("accumulate function");
+ }
+
+ @Test
+ void hoverOnABindingUsedInTheConsequenceIgnoresAConditionCommentSayingThen() {
+ String drl = """
+ package demo;
+
+ global java.util.List results;
+
+ declare Fact
+ code : String
+ end
+
+ rule R
+ when
+ Fact( $first : code )
+ // match this and then the other
+ Fact( $second : code )
+ then
+ results.add($second);
+ end
+ """;
+ // Line 14 is ` results.add($second);`; "$second" spans cols 16-22.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(14, 18),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("String");
+ }
+
+ private static final String PRIMITIVE_BINDING_DRL = """
+ package demo;
+
+ global java.util.List results;
+
+ declare Fact
+ size : int
+ end
+
+ rule R
+ when
+ Fact( $size : size )
+ then
+ results.add($size);
+ end
+ """;
+
+ @Test
+ void hoverOnAPrimitiveTypedBindingNamesItsType() {
+ // Line 10 is ` Fact( $size : size )`; the binding spans cols 10-14.
+ Hover hover = DRLHoverHelper.hover(PRIMITIVE_BINDING_DRL, new Position(10, 12),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("$size").contains("int");
+ }
+
+ @Test
+ void hoverOnAPrimitiveTypedBindingInTheConsequenceNamesItsType() {
+ // Line 12 is ` results.add($size);`; the binding spans cols 16-20.
+ Hover hover = DRLHoverHelper.hover(PRIMITIVE_BINDING_DRL, new Position(12, 18),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ assertThat(content(hover)).contains("$size").contains("int");
+ }
+
+ @Test
+ void aDocumentedFunctionDoesNotShadowAnImportedTypeOfTheSameName() {
+ String drl = """
+ package demo;
+
+ import org.drools.completion.fixtures.Pet;
+
+ /** Counts the pets. */
+ function int Pet(int n) {
+ return n;
+ }
+
+ rule R
+ when
+ Pet( )
+ then
+ end
+ """;
+ // Line 11 is ` Pet( )`; the pattern head spans cols 4-6.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(11, 5), ClassIndex.empty(),
+ new ClassMemberIndex(getClass().getClassLoader()), null);
+
+ String md = content(hover);
+ assertThat(md).contains("org.drools.completion.fixtures.Pet");
+ assertThat(md).doesNotContain("Counts the pets.");
+ }
+
+ @Test
+ void aDocumentedGlobalDoesNotShadowAFieldOfTheSameName() {
+ String drl = """
+ package demo;
+
+ /** Running order total. */
+ global java.math.BigDecimal total
+
+ declare Order
+ total : int
+ end
+
+ rule R
+ when
+ Order( total > 5 )
+ then
+ end
+ """;
+ // Line 11 is ` Order( total > 5 )`; "total" spans cols 11-15. The
+ // doc-comment parser maps names document-wide, with no position scoping,
+ // so the global's doc must not answer for the field in the constraint.
+ Hover hover = DRLHoverHelper.hover(drl, new Position(11, 13),
+ ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+ String md = content(hover);
+ assertThat(md).contains("total").contains("int").contains("Order");
+ assertThat(md).doesNotContain("Running order total.");
+ }
}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
index 4cee22ea7bc..1d8a3a5743d 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
@@ -38,6 +38,37 @@ class DRLInlayHintHelperTest {
assertThat(DRLInlayHintHelper.getHints(null, null)).isEmpty();
}
+ /**
+ * A declare may extend a Java class, and a binding on one of the supertype's
+ * fields has no type unless the host's member lookup is consulted — the
+ * declared-type index knows nothing beyond the DRL.
+ */
+ @Test
+ void emitsHintForFieldInheritedFromAJavaSupertype() {
+ ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+ ClasspathTypeMembers.install(name -> "Pet".equals(name)
+ ? memberIndex.membersOf("org.drools.completion.fixtures.Pet")
+ : List.of());
+ try {
+ String drl =
+ "declare SpecialPet extends Pet\n" +
+ " nickname : String\n" +
+ "end\n" +
+ "rule \"r\" when\n" +
+ " SpecialPet( $l : legs )\n" +
+ " SpecialPet( legs == $l )\n" +
+ "then\n" +
+ "end\n";
+
+ List<InlayHint> hints = DRLInlayHintHelper.getHints(drl, null);
+
+ // Both the binding declaration and the later naked usage carry the type.
+ assertThat(hints).filteredOn(h -> ": int".equals(labelOf(h))).hasSize(2);
+ } finally {
+ ClasspathTypeMembers.install(null);
+ }
+ }
+
@Test
void emitsAccumulateResultBindingHint() {
AccumulateFunctionTypes.set(java.util.Map.of("count", "Long"));
@@ -372,6 +403,34 @@ class DRLInlayHintHelperTest {
.containsExactlyInAnyOrder(": int", ": int");
}
+ @Test
+ void emitsHintsForBindingsAfterAConditionCommentSayingThen() {
+ String drl = """
+ package demo;
+
+ global java.util.List results;
+
+ declare Fact
+ code : String
+ end
+
+ rule R
+ when
+ Fact( $first : code )
+ // match this and then the other
+ Fact( $second : code )
+ then
+ results.add($second);
+ end
+ """;
+
+ List<InlayHint> hints = DRLInlayHintHelper.getHints(drl, null);
+
+ // Both declarations, plus the consequence usage.
+ assertThat(hints).extracting(DRLInlayHintHelperTest::labelOf)
+ .containsExactlyInAnyOrder(": String", ": String", ": String");
+ }
+
private static String labelOf(InlayHint hint) {
Either<String, ?> label = hint.getLabel();
return label.isLeft() ? label.getLeft() : null;
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
index 9f46834d200..fb9c0fed45b 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
@@ -31,6 +31,34 @@ import static org.assertj.core.api.Assertions.assertThat;
class DRLLintHelperTest {
+ /**
+ * A constant on a type known only from source. Reflection would report it
+ * through Class#getFields, so once compiled the reference verifies; the
+ * source view has to answer the same, or every Type.CONSTANT in the project
+ * is flagged as an unknown member until a build has run.
+ */
+ @Test
+ void aStaticConstantOnASourceOnlyTypeIsNotAnUnknownMember(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir)
+ throws Exception {
+ java.nio.file.Path src = dir.resolve("src/main/java/com/example");
+ java.nio.file.Files.createDirectories(src);
+ java.nio.file.Files.writeString(src.resolve("NumberFact.java"),
+ "package com.example;\npublic class NumberFact {\n"
+ + " public static final String ONE_HUNDRED = \"100\";\n}\n");
+
+ ClassMemberIndex memberIndex = ClassMemberIndex.of(java.util.Set.of());
+ memberIndex.setSourceFallback(
+ JavaSourceTypeIndex.build(java.util.Set.of(dir.resolve("src/main/java")), java.util.List.of()));
+
+ String text = "package demo;\n\ndeclare Fact\n value : String\nend\n\n"
+ + "rule R\n when\n Fact( value == NumberFact.ONE_HUNDRED )\n then\nend\n";
+
+ assertThat(DRLLintHelper.lintUnknownTypes(text, ParsedDrl.of(text).compilationUnit, null,
+ java.util.Map.of(),
+ ClassIndex.of(java.util.Map.of("NumberFact", java.util.List.of("com.example.NumberFact"))),
+ memberIndex, true)).isEmpty();
+ }
+
@AfterEach
void clearLintProperties() {
System.clearProperty("drools.lsp.lint.missingEnd");
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
index 6b04345afd6..41242a2720f 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
@@ -55,7 +55,7 @@ class DRLTypeHierarchyHelperTest {
// Caret on "Dog" at its declare site (line 6, cols 8..11).
List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
HIERARCHY_DRL, new Position(6, 9), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of());
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
assertThat(items).hasSize(1);
TypeHierarchyItem item = items.get(0);
@@ -93,7 +93,8 @@ class DRLTypeHierarchyHelperTest {
// Caret on "Pet" in the pattern (line 6, col 5).
List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
drl, new Position(6, 5), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+ Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")),
+ JavaSourceTypeIndex.empty());
assertThat(items).hasSize(1);
TypeHierarchyItem item = items.get(0);
@@ -103,11 +104,78 @@ class DRLTypeHierarchyHelperTest {
.isEqualTo("org.example.Pet");
}
+ @Test
+ void prepareResolvesClasspathTypeToSourceIndexBeforeBuild(@TempDir Path srcRoot) throws Exception {
+ // No target/classes at all — the type has never been compiled.
+ Path petDir = srcRoot.resolve("org/example");
+ Files.createDirectories(petDir);
+ Path petJava = petDir.resolve("Pet.java");
+ Files.writeString(petJava, "package org.example;\npublic class Pet {\n}\n");
+
+ JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+ String drl = """
+ package demo;
+
+ import org.example.Pet;
+
+ rule R
+ when
+ Pet( )
+ then
+ end
+ """;
+
+ // Caret on "Pet" in the pattern (line 6, col 5); buildOutputDirs is
+ // empty so JavaSourceLocator.locate has nothing to find.
+ List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
+ drl, new Position(6, 5), "myDocument",
+ Map.of(), ClassIndex.empty(), Set.of(), sourceIndex);
+
+ assertThat(items).hasSize(1);
+ TypeHierarchyItem item = items.get(0);
+ assertThat(item.getName()).isEqualTo("Pet");
+ assertThat(item.getKind()).isEqualTo(SymbolKind.Class);
+ assertThat(item.getUri()).isEqualTo(petJava.toUri().toString());
+ assertThat(item.getData()).as("classpath items round-trip their FQCN in data")
+ .isEqualTo("org.example.Pet");
+ }
+
+ @Test
+ void prepareResolvesEnumClasspathTypeToSourceIndexWithEnumKind(@TempDir Path srcRoot) throws Exception {
+ Path dir = srcRoot.resolve("org/example");
+ Files.createDirectories(dir);
+ Path statusJava = dir.resolve("Status.java");
+ Files.writeString(statusJava, "package org.example;\npublic enum Status {\n ON, OFF\n}\n");
+
+ JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+ String drl = """
+ package demo;
+
+ import org.example.Status;
+
+ rule R
+ when
+ Status( )
+ then
+ end
+ """;
+
+ // Caret on "Status" in the pattern (line 6, col 8).
+ List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
+ drl, new Position(6, 8), "myDocument",
+ Map.of(), ClassIndex.empty(), Set.of(), sourceIndex);
+
+ assertThat(items).hasSize(1);
+ assertThat(items.get(0).getKind()).isEqualTo(SymbolKind.Enum);
+ }
+
@Test
void prepareYieldsNothingForUnknownWord() {
assertThat(DRLTypeHierarchyHelper.prepare(
HIERARCHY_DRL, new Position(3, 3), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of()))
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
.isEmpty();
}
@@ -115,7 +183,7 @@ class DRLTypeHierarchyHelperTest {
void prepareYieldsNothingForNullText() {
assertThat(DRLTypeHierarchyHelper.prepare(
null, new Position(0, 0), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of()))
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
.isEmpty();
}
@@ -125,11 +193,11 @@ class DRLTypeHierarchyHelperTest {
void supertypesOfDeclareResolvesExtendsParentInSameDocument() {
TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
HIERARCHY_DRL, new Position(6, 9), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of()).get(0);
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
dog, HIERARCHY_DRL, Map.of(), ClassIndex.empty(),
- ClassMemberIndex.empty(), Set.of());
+ ClassMemberIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
assertThat(supers).hasSize(1);
TypeHierarchyItem parent = supers.get(0);
@@ -144,11 +212,11 @@ class DRLTypeHierarchyHelperTest {
void supertypesOfDeclareWithoutExtendsYieldsNothing() {
TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
HIERARCHY_DRL, new Position(2, 9), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of()).get(0);
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
assertThat(DRLTypeHierarchyHelper.supertypes(
animal, HIERARCHY_DRL, Map.of(), ClassIndex.empty(),
- ClassMemberIndex.empty(), Set.of()))
+ ClassMemberIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
.isEmpty();
}
@@ -174,11 +242,12 @@ class DRLTypeHierarchyHelperTest {
TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
drl, new Position(4, 9), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes"))).get(0);
+ Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")),
+ JavaSourceTypeIndex.empty()).get(0);
List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
dog, drl, Map.of(), ClassIndex.empty(), ClassMemberIndex.empty(),
- Set.of(module.resolve("target/classes")));
+ Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
assertThat(supers).hasSize(1);
TypeHierarchyItem parent = supers.get(0);
@@ -187,6 +256,87 @@ class DRLTypeHierarchyHelperTest {
assertThat(parent.getData()).isEqualTo("org.example.Animal");
}
+ @Test
+ void supertypesOfDeclareResolvesSourceIndexParentBeforeBuild(@TempDir Path srcRoot) throws Exception {
+ // No target/classes at all — the type has never been compiled.
+ Path dir = srcRoot.resolve("org/example");
+ Files.createDirectories(dir);
+ Path animalJava = dir.resolve("Animal.java");
+ Files.writeString(animalJava, "package org.example;\npublic class Animal {\n}\n");
+
+ JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+ String drl = """
+ package demo;
+
+ import org.example.Animal;
+
+ declare Dog extends Animal
+ good : boolean
+ end
+ """;
+
+ TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
+ drl, new Position(4, 9), "myDocument",
+ Map.of(), ClassIndex.empty(), Set.of(), sourceIndex).get(0);
+
+ List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
+ dog, drl, Map.of(), ClassIndex.empty(), ClassMemberIndex.empty(),
+ Set.of(), sourceIndex);
+
+ assertThat(supers).hasSize(1);
+ TypeHierarchyItem parent = supers.get(0);
+ assertThat(parent.getName()).isEqualTo("Animal");
+ assertThat(parent.getUri()).isEqualTo(animalJava.toUri().toString());
+ assertThat(parent.getData()).isEqualTo("org.example.Animal");
+ }
+
+ @Test
+ void supertypesOfSourceOnlyClasspathItemResolvesCrossPackageParentToFqcn(
+ @TempDir Path srcRoot) throws Exception {
+ // Child and Parent are both source-only (no target/classes anywhere)
+ // and live in different packages, so the parent can only be found by
+ // its unique simple name — the same seam JavaSourceTypeIndex#supertypesOf
+ // must resolve to an FQCN for classpathItem's FQCN-keyed lookups to work.
+ Path childDir = srcRoot.resolve("com/a");
+ Files.createDirectories(childDir);
+ Files.writeString(childDir.resolve("Child.java"),
+ "package com.a;\npublic class Child extends Parent {\n}\n");
+ Path parentDir = srcRoot.resolve("com/b");
+ Files.createDirectories(parentDir);
+ Path parentJava = parentDir.resolve("Parent.java");
+ Files.writeString(parentJava, "package com.b;\npublic class Parent {\n}\n");
+
+ JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+ ClassMemberIndex memberIndex = ClassMemberIndex.of(Set.<Path>of());
+ memberIndex.setSourceFallback(sourceIndex);
+
+ String drl = """
+ package demo;
+
+ import com.a.Child;
+
+ rule R
+ when
+ Child( )
+ then
+ end
+ """;
+
+ TypeHierarchyItem child = DRLTypeHierarchyHelper.prepare(
+ drl, new Position(6, 5), "myDocument",
+ Map.of(), ClassIndex.empty(), Set.of(), sourceIndex).get(0);
+
+ List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
+ child, drl, Map.of(), ClassIndex.empty(), memberIndex, Set.of(), sourceIndex);
+
+ assertThat(supers).hasSize(1);
+ TypeHierarchyItem parent = supers.get(0);
+ assertThat(parent.getName()).isEqualTo("Parent");
+ assertThat(parent.getUri()).isEqualTo(parentJava.toUri().toString());
+ assertThat(parent.getData()).isEqualTo("com.b.Parent");
+ }
+
@Test
void supertypesOfClasspathItemReflectsAncestry() {
// A classpath item whose FQCN is a JDK type with a clear, stable
@@ -202,7 +352,8 @@ class DRLTypeHierarchyHelperTest {
ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
assertThat(DRLTypeHierarchyHelper.supertypes(
- arrayList, null, Map.of(), ClassIndex.empty(), memberIndex, Set.of()))
+ arrayList, null, Map.of(), ClassIndex.empty(), memberIndex, Set.of(),
+ JavaSourceTypeIndex.empty()))
.isEmpty();
}
@@ -212,7 +363,7 @@ class DRLTypeHierarchyHelperTest {
void subtypesOfDeclareFindsChildrenInSameDocument() {
TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
HIERARCHY_DRL, new Position(2, 9), "myDocument",
- Map.of(), ClassIndex.empty(), Set.of()).get(0);
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
List<TypeHierarchyItem> subs = DRLTypeHierarchyHelper.subtypes(
animal, HIERARCHY_DRL, Map.of());
@@ -233,7 +384,7 @@ class DRLTypeHierarchyHelperTest {
String animalText = Files.readString(animalFile);
TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
animalText, new Position(1, 9), animalFile.toUri().toString(),
- Map.of(), ClassIndex.empty(), Set.of()).get(0);
+ Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
List<TypeHierarchyItem> subs = DRLTypeHierarchyHelper.subtypes(
animal, animalText, Map.of());
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
similarity index 55%
copy from packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
copy to packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
index be69e1d4006..4d36ba72569 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
@@ -19,26 +19,26 @@
package org.drools.completion;
-/** A field, bean property, or enum constant of a {@link DeclaredType} or Java class. */
-public class Field {
+import static org.junit.jupiter.api.Assertions.assertEquals;
- 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;
+import org.junit.jupiter.api.Test;
- Field(String name, String type) {
- this(name, type, null);
+class FieldTest {
+
+ @Test
+ void defaultsToFieldOrigin() {
+ Field f = new Field("name", "String");
+ assertEquals(Field.Origin.FIELD, f.origin);
+ assertEquals("name", f.name);
+ assertEquals("String", f.type);
}
- Field(String name, String type, String args) {
- this.name = name;
- this.type = type;
- this.args = args;
+ @Test
+ void carriesExplicitOrigin() {
+ Field g = new Field("active", "boolean", null, Field.Origin.GETTER);
+ assertEquals(Field.Origin.GETTER, g.origin);
+ Field c = new Field("LOW", "Severity", "1", Field.Origin.ENUM_CONSTANT);
+ assertEquals(Field.Origin.ENUM_CONSTANT, c.origin);
+ assertEquals("1", c.args);
}
}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java
new file mode 100644
index 00000000000..79cae1b1518
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class JavaSourceTypeIndexTest {
+
+ private Path write(Path root, String rel, String content) throws Exception {
+ Path p = root.resolve(rel);
+ Files.createDirectories(p.getParent());
+ Files.writeString(p, content);
+ return p;
+ }
+
+ @Test
+ void indexesTypesAndMembersFromSourceRoot(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/Patient.java",
+ "package com.example;\npublic class Patient { private String name; public String getName(){return name;} }\n");
+ write(src, "com/example/Severity.java",
+ "package com.example;\npublic enum Severity { LOW, HIGH }\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ assertTrue(idx.classNames().getOrDefault("Patient", List.of()).contains("com.example.Patient"));
+ assertTrue(idx.classNames().getOrDefault("Severity", List.of()).contains("com.example.Severity"));
+ assertEquals("com.example.Patient", idx.byFqcn("com.example.Patient").fqcn);
+ assertTrue(idx.membersOf("com.example.Patient").stream().anyMatch(f -> f.name.equals("name")));
+ assertTrue(idx.memberNames("com.example.Severity").contains("LOW"));
+ assertNull(idx.memberNames("com.example.DoesNotExist"));
+ }
+
+ /**
+ * A public static field is not a fact property, so it stays out of the
+ * member list — but it is still written as Type.NAME, so it has to be in the
+ * name view. Reflection draws the same line, and the unknown-type lint reads
+ * the name view.
+ */
+ @Test
+ void aStaticFieldIsNameableWithoutBeingAMember(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/NumberFact.java",
+ "package com.example;\npublic class NumberFact {\n"
+ + " public static final String ONE_HUNDRED = \"100\";\n"
+ + " public String getLabel() { return \"l\"; }\n}\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ assertTrue(idx.membersOf("com.example.NumberFact").stream()
+ .noneMatch(f -> f.name.equals("ONE_HUNDRED")),
+ () -> "not a property: " + idx.membersOf("com.example.NumberFact"));
+ assertTrue(idx.memberNames("com.example.NumberFact").contains("ONE_HUNDRED"),
+ () -> "but nameable: " + idx.memberNames("com.example.NumberFact"));
+ assertTrue(idx.memberNames("com.example.NumberFact").contains("label"));
+ }
+
+ @Test
+ void appliesPackageFilters(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/A.java", "package com.example;\npublic class A {}\n");
+ write(src, "org/other/B.java", "package org.other;\npublic class B {}\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of("com.example*"));
+ assertTrue(idx.classNames().containsKey("A"));
+ assertTrue(!idx.classNames().containsKey("B"));
+ }
+
+ /**
+ * {@code com.example.*} — the documented form — must cover the package
+ * itself as well as everything under it. Reading it as a bare prefix
+ * excludes exactly the types the author meant to include.
+ */
+ @Test
+ void aDottedStarFilterCoversItsOwnPackage(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/A.java", "package com.example;\npublic class A {}\n");
+ write(src, "com/example/model/M.java", "package com.example.model;\npublic class M {}\n");
+ write(src, "com/examplefoo/F.java", "package com.examplefoo;\npublic class F {}\n");
+ write(src, "org/other/B.java", "package org.other;\npublic class B {}\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of("com.example.*"));
+
+ assertTrue(idx.classNames().containsKey("A"), "the filtered package's own types");
+ assertTrue(idx.classNames().containsKey("M"), "and its sub-packages");
+ assertTrue(!idx.classNames().containsKey("F"), "but not a package merely sharing the prefix");
+ assertTrue(!idx.classNames().containsKey("B"));
+ }
+
+ @Test
+ void discoverUnionsConventionAndConfigured(@TempDir Path root) throws Exception {
+ Files.createDirectories(root.resolve("module-a/src/main/java"));
+ Files.createDirectories(root.resolve("custom/gen"));
+ List<Path> roots = JavaSourceRoots.discover(root, List.of("custom/gen"));
+ assertTrue(roots.stream().anyMatch(p -> p.endsWith(Path.of("src/main/java"))));
+ assertTrue(roots.stream().anyMatch(p -> p.endsWith(Path.of("custom/gen"))));
+ }
+
+ /**
+ * A configured entry is a literal path, and on Windows a glob character is
+ * not even a legal one. Discovery must skip such an entry and still return
+ * the convention roots, rather than letting the setting abort the caller.
+ */
+ @Test
+ void discoverSkipsAConfiguredEntryThatIsNotAUsablePath(@TempDir Path root) throws Exception {
+ Files.createDirectories(root.resolve("module-a/src/main/java"));
+
+ List<Path> roots = JavaSourceRoots.discover(
+ root, List.of("**/src/main/java", "modules/*/src/main/java", "does/not/exist"));
+
+ assertEquals(1, roots.size(), () -> "expected only the convention root, got " + roots);
+ assertTrue(roots.get(0).endsWith(Path.of("src/main/java")),
+ () -> "expected the convention root, got " + roots.get(0));
+ }
+
+ /**
+ * A configured root that contains a discovered one must collapse to the
+ * outermost: indexing walks recursively, so keeping both parses every file
+ * beneath the inner root twice. The ancestor is kept because it is the
+ * broader instruction — naming {@code src} asks for test sources too.
+ */
+ @Test
+ void discoverCollapsesARootNestedInsideAnother(@TempDir Path root) throws Exception {
+ Files.createDirectories(root.resolve("module-a/src/main/java"));
+ Files.createDirectories(root.resolve("module-a/src/test/java"));
+
+ List<Path> roots = JavaSourceRoots.discover(root, List.of("module-a/src"));
+
+ assertEquals(1, roots.size(), () -> "expected only the outermost root, got " + roots);
+ assertTrue(roots.get(0).endsWith(Path.of("module-a/src")),
+ () -> "expected module-a/src, got " + roots.get(0));
+ }
+
+ @Test
+ void siblingRootsAreBothKept(@TempDir Path root) throws Exception {
+ Files.createDirectories(root.resolve("module-a/src/main/java"));
+ Files.createDirectories(root.resolve("module-b/src/main/java"));
+
+ assertEquals(2, JavaSourceRoots.discover(root, List.of()).size());
+ }
+
+ /** Overlapping roots must not index the same file twice. */
+ @Test
+ void overlappingRootsIndexEachTypeOnce(@TempDir Path root) throws Exception {
+ Path pkg = root.resolve("module-a/src/main/java/com/example");
+ Files.createDirectories(pkg);
+ Files.writeString(pkg.resolve("Order.java"),
+ "package com.example;\npublic class Order { private int id; }\n");
+
+ List<Path> roots = JavaSourceRoots.discover(root, List.of("module-a/src"));
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.copyOf(roots), List.of());
+
+ assertEquals(1, idx.roots().size());
+ assertEquals(List.of("com.example.Order"), idx.classNames().get("Order"));
+ }
+
+ @Test
+ void emptyIsInert() {
+ assertTrue(JavaSourceTypeIndex.empty().classNames().isEmpty());
+ assertNull(JavaSourceTypeIndex.empty().memberNames("x"));
+ assertTrue(JavaSourceTypeIndex.empty().membersOf("x").isEmpty());
+ }
+
+ @Test
+ void membersOfIncludesInheritedFieldsFromSamePackageParent(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/Parent.java",
+ "package com.example;\npublic class Parent { public String parentField; }\n");
+ write(src, "com/example/Child.java",
+ "package com.example;\npublic class Child extends Parent { public String childField; }\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ assertTrue(idx.membersOf("com.example.Child").stream().anyMatch(f -> f.name.equals("parentField")));
+ assertTrue(idx.memberNames("com.example.Child").contains("parentField"));
+ assertTrue(idx.membersOf("com.example.Parent").stream().noneMatch(f -> f.name.equals("childField")));
+ }
+
+ @Test
+ void membersOfLetsOwnFieldShadowInheritedOne(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/Parent.java",
+ "package com.example;\npublic class Parent { public String value; }\n");
+ write(src, "com/example/Child.java",
+ "package com.example;\npublic class Child extends Parent { public int value; }\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ List<Field> values = idx.membersOf("com.example.Child").stream()
+ .filter(f -> f.name.equals("value")).toList();
+ assertEquals(1, values.size());
+ assertEquals("int", values.get(0).type);
+ }
+
+ @Test
+ void membersOfResolvesCrossPackageParentByUniqueSimpleName(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/a/Child.java", "package com.a;\npublic class Child extends Base {}\n");
+ write(src, "com/b/Base.java", "package com.b;\npublic class Base { public String baseField; }\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ assertTrue(idx.membersOf("com.a.Child").stream().anyMatch(f -> f.name.equals("baseField")));
+ }
+
+ @Test
+ void supertypesOfResolvesCrossPackageParentByUniqueSimpleNameToFqcn(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/a/Child.java", "package com.a;\npublic class Child extends Parent {}\n");
+ write(src, "com/b/Parent.java", "package com.b;\npublic class Parent {}\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ assertEquals(List.of("com.b.Parent"), idx.supertypesOf("com.a.Child"));
+ }
+
+ @Test
+ void membersOfTerminatesOnExtendsCycle(@TempDir Path root) throws Exception {
+ Path src = root.resolve("src/main/java");
+ write(src, "com/example/A.java",
+ "package com.example;\npublic class A extends B { public String aField; }\n");
+ write(src, "com/example/B.java",
+ "package com.example;\npublic class B extends A { public String bField; }\n");
+
+ JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+ List<Field> members = idx.membersOf("com.example.A");
+ assertEquals(1, members.stream().filter(f -> f.name.equals("aField")).count());
+ assertEquals(1, members.stream().filter(f -> f.name.equals("bField")).count());
+ }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java
new file mode 100644
index 00000000000..d4da542246d
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class JavaSourceTypeParserTest {
+
+ private JavaSourceType only(String src) {
+ List<JavaSourceType> ts = JavaSourceTypeParser.parse(src);
+ assertEquals(1, ts.size(), () -> "expected exactly one top-level type, got " + ts.size());
+ return ts.get(0);
+ }
+
+ private Optional<Field> member(JavaSourceType t, String name) {
+ return t.members.stream().filter(f -> f.name.equals(name)).findFirst();
+ }
+
+ /**
+ * Reflection lists instance members: it filters static fields out and its
+ * property scan rejects static methods. A static offered as a fact property
+ * before a build and withdrawn after it is worse than never offering it,
+ * because a rule written against it does not compile.
+ */
+ @Test
+ void staticMembersAreNotFactProperties() {
+ JavaSourceType t = only(
+ "package com.example;\n"
+ + "public class Order {\n"
+ + " public static final String VERSION = \"1\";\n"
+ + " public int id;\n"
+ + " public static String getBuild() { return \"b\"; }\n"
+ + " public String getCode() { return \"c\"; }\n"
+ + "}\n");
+
+ assertTrue(member(t, "id").isPresent(), () -> "members=" + t.members);
+ assertTrue(member(t, "code").isPresent(), () -> "members=" + t.members);
+ assertTrue(member(t, "VERSION").isEmpty(),
+ () -> "a static field is not a fact property: " + t.members);
+ assertTrue(member(t, "build").isEmpty(),
+ () -> "nor is a static getter: " + t.members);
+ }
+
+ /**
+ * Reflection reports public methods and public constructors only, so the
+ * source view must not offer more than the compiled view will: a member that
+ * appears before a build and vanishes after it is worse than one that never
+ * appeared, and hover's Constructors section would otherwise name a
+ * constructor the author cannot call.
+ */
+ @Test
+ void nonPublicGettersAndConstructorsAreNotMembers() {
+ JavaSourceType t = only(
+ "package com.example;\n"
+ + "public class Order {\n"
+ + " public int id;\n"
+ + " private String secret;\n"
+ + " private String getSecret() { return secret; }\n"
+ + " public String getCode() { return \"c\"; }\n"
+ + " private Order() { }\n"
+ + " public Order(int id) { }\n"
+ + "}\n");
+
+ assertTrue(member(t, "id").isPresent(), () -> "members=" + t.members);
+ assertTrue(member(t, "code").isPresent(), () -> "members=" + t.members);
+ assertTrue(member(t, "secret").isEmpty(),
+ () -> "a non-public getter must not be a member: " + t.members);
+ assertEquals(List.of("Order(int)"), t.constructors);
+ }
+
+ @Test
+ void parsesClassFieldsGettersAndFqcn() {
+ JavaSourceType t = only(
+ "package com.example.model;\n"
+ + "public class Patient {\n"
+ + " private String name;\n"
+ + " public int ageYears;\n"
+ + " public String getName() { return name; }\n"
+ + " public boolean isActive() { return true; }\n"
+ + " public Patient(String name, int ageYears) { }\n"
+ + "}\n");
+ assertEquals("com.example.model.Patient", t.fqcn);
+ assertEquals("Patient", t.simpleName);
+ assertEquals(Field.Origin.GETTER, member(t, "name").orElseThrow().origin);
+ assertEquals("int", member(t, "ageYears").orElseThrow().type);
+ assertEquals(Field.Origin.GETTER, member(t, "active").orElseThrow().origin);
+ assertTrue(t.constructors.contains("Patient(String, int)"),
+ () -> "constructors=" + t.constructors);
+ }
+
+ @Test
+ void parsesEnumConstantsWithArgs() {
+ JavaSourceType t = only(
+ "package com.example;\n"
+ + "public enum Severity {\n"
+ + " LOW(1), HIGH(3);\n"
+ + " private final int weight;\n"
+ + " Severity(int weight) { this.weight = weight; }\n"
+ + " public int getWeight() { return weight; }\n"
+ + "}\n");
+ assertTrue(t.isEnum);
+ Field low = member(t, "LOW").orElseThrow();
+ assertEquals(Field.Origin.ENUM_CONSTANT, low.origin);
+ assertEquals("1", low.args);
+ assertEquals("Severity", low.type);
+ assertEquals(Field.Origin.GETTER, member(t, "weight").orElseThrow().origin);
+ }
+
+ @Test
+ void parsesInterfaceAndRecordNames() {
+ assertEquals("com.example.Repo", only(
+ "package com.example;\npublic interface Repo { String id(); }\n").fqcn);
+ JavaSourceType rec = only(
+ "package com.example;\npublic record Point(int x, int y) { }\n");
+ assertEquals("com.example.Point", rec.fqcn);
+ assertEquals("int", member(rec, "x").orElseThrow().type);
+ assertTrue(rec.constructors.contains("Point(int, int)"),
+ () -> "constructors=" + rec.constructors);
+ }
+
+ @Test
+ void capturesExtendsAndPosition() {
+ JavaSourceType t = only(
+ "package com.example;\n"
+ + "public class Child extends com.example.Parent {\n"
+ + "}\n");
+ assertEquals("Parent", t.extendsSimpleName);
+ assertEquals(1, t.declLine); // 0-based; "public class Child" is line index 1
+ }
+
+ @Test
+ void toleratesGarbageAndDefaultPackage() {
+ assertTrue(JavaSourceTypeParser.parse("").isEmpty());
+ // Garbage must not throw; what it returns is not specified.
+ assertDoesNotThrow(() -> JavaSourceTypeParser.parse("this is not java {{{"));
+ JavaSourceType t = only("public class NoPkg { int x; }");
+ assertEquals("NoPkg", t.fqcn); // default package => fqcn == simpleName
+ }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java
new file mode 100644
index 00000000000..d6ab7fdc466
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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 LhsBindingResolverTest {
+
+ private static final String DECLARE = """
+ package demo;
+
+ global java.util.List results;
+
+ declare Fact
+ code : String
+ end
+
+ """;
+
+ /**
+ * The bindings visible from the consequence, which is where a rule's author
+ * uses them.
+ */
+ private static Map<String, String> bindingsFromConsequence(String lhs) {
+ String drl = DECLARE + "rule R\n when\n" + lhs
+ + " then\n results.add(\"x\");\nend\n";
+ return LhsBindingResolver.resolveAt(drl, drl.indexOf("results.add"),
+ DRLWorkspaceTypeIndex.build(drl, null, Map.of()));
+ }
+
+ @Test
+ void resolvesEveryBindingInTheCondition() {
+ assertThat(bindingsFromConsequence(" Fact( $first : code )\n"
+ + " Fact( $second : code )\n"))
+ .containsEntry("first", "String")
+ .containsEntry("second", "String");
+ }
+
+ @Test
+ void aLineCommentSayingThenDoesNotHideTheBindingsAfterIt() {
+ assertThat(bindingsFromConsequence(" Fact( $first : code )\n"
+ + " // match this and then the other\n"
+ + " Fact( $second : code )\n"))
+ .containsEntry("first", "String")
+ .containsEntry("second", "String");
+ }
+
+ @Test
+ void aBlockCommentSayingThenDoesNotHideTheBindingsAfterIt() {
+ assertThat(bindingsFromConsequence(" Fact( $first : code )\n"
+ + " /* match this and then\n the other */\n"
+ + " Fact( $second : code )\n"))
+ .containsEntry("first", "String")
+ .containsEntry("second", "String");
+ }
+
+ @Test
+ void aStringLiteralSayingThenLeavesItsOwnPatternIntact() {
+ // The literal sits mid-pattern, so truncating there also unbalances the
+ // parentheses and loses the binding declared before it.
+ assertThat(bindingsFromConsequence(" Fact( $first : code, code == \"then\" )\n"
+ + " Fact( $second : code )\n"))
+ .containsEntry("first", "String")
+ .containsEntry("second", "String");
+ }
+
+ @Test
+ void aStringLiteralSayingEndDoesNotTruncateTheRule() {
+ assertThat(bindingsFromConsequence(" Fact( $first : code, code == \"end\" )\n"
+ + " Fact( $second : code )\n"))
+ .containsEntry("first", "String")
+ .containsEntry("second", "String");
+ }
+}
diff --git a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
index f7e6b4eb8f2..4e5b12a9863 100644
--- a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
+++ b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
@@ -37,6 +37,8 @@ import java.util.logging.Logger;
import org.drools.completion.ClassIndex;
import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.ClasspathTypeMembers;
+import org.drools.completion.Field;
import org.drools.completion.DRLCompletionHelper;
import org.drools.completion.DRLCodeLensHelper;
import org.drools.completion.DRLDefinitionHelper;
@@ -49,6 +51,7 @@ import org.drools.completion.DRLHoverHelper;
import org.drools.completion.DRLInlayHintHelper;
import org.drools.completion.DRLLintHelper;
import org.drools.completion.DRLTypeHierarchyHelper;
+import org.drools.completion.JavaSourceTypeIndex;
import org.drools.drl.parser.antlr4.DRLParserHelper;
import org.eclipse.lsp4j.CodeAction;
import org.eclipse.lsp4j.CodeActionKind;
@@ -106,11 +109,54 @@ public class DroolsLspDocumentService implements TextDocumentService {
private final Map<String, String> sourcesMap = new ConcurrentHashMap<>();
private volatile ClassIndex classIndex = ClassIndex.empty();
private volatile ClassMemberIndex classMemberIndex = ClassMemberIndex.empty();
+ private volatile JavaSourceTypeIndex javaSourceIndex = JavaSourceTypeIndex.empty();
private final DroolsLspServer server;
public DroolsLspDocumentService(DroolsLspServer server) {
this.server = server;
+ // Lets binding resolution describe types the DRL does not declare, so
+ // hover and inlay hints work on Java fact classes. The closure reads the
+ // live indexes on every call, so a rebuilt classpath needs no re-install.
+ ClasspathTypeMembers.install(this::membersOfTypeName);
+ }
+
+ /**
+ * Members of the type named {@code typeName} as written in the DRL, with
+ * inherited members folded in — {@link ClassMemberIndex} reflects over the
+ * full hierarchy, and its source fallback walks the {@code extends} chain,
+ * so a superclass field resolves before and after a build. Empty when the
+ * name is unknown or ambiguous.
+ */
+ private List<Field> membersOfTypeName(String typeName) {
+ if (typeName == null || typeName.isEmpty()) {
+ return Collections.emptyList();
+ }
+ String fqcn = typeName.indexOf('.') >= 0
+ ? typeName
+ : uniqueFqcnForSimpleName(typeName);
+ return fqcn == null ? Collections.emptyList() : classMemberIndex.membersOf(fqcn);
+ }
+
+ /**
+ * The single classpath FQCN whose simple name is {@code simpleName}, or
+ * {@code null} when none or several match.
+ *
+ * <p>Weaker than {@code DRLCompletionHelper.resolveFqcn}, which consults the
+ * document's imports first and so picks the right one of two same-named
+ * classes. The seam carries no import context, so an explicitly imported type
+ * whose simple name collides elsewhere on the classpath resolves to nothing
+ * here, and a {@code declare ... extends} on it loses its inherited members.
+ */
+ private String uniqueFqcnForSimpleName(String simpleName) {
+ String found = null;
+ for (String fqcn : classIndex.forSimpleName(simpleName)) {
+ if (found != null && !found.equals(fqcn)) {
+ return null;
+ }
+ found = fqcn;
+ }
+ return found;
}
public void setClassIndex(ClassIndex classIndex) {
@@ -121,10 +167,18 @@ public class DroolsLspDocumentService implements TextDocumentService {
this.classMemberIndex = classMemberIndex;
}
+ public void setJavaSourceIndex(JavaSourceTypeIndex javaSourceIndex) {
+ this.javaSourceIndex = javaSourceIndex;
+ }
+
ClassIndex getClassIndexForTest() {
return classIndex;
}
+ ClassMemberIndex getClassMemberIndexForTest() {
+ return classMemberIndex;
+ }
+
@Override
public void didOpen(DidOpenTextDocumentParams params) {
sourcesMap.put(params.getTextDocument().getUri(), params.getTextDocument().getText());
@@ -167,8 +221,13 @@ public class DroolsLspDocumentService implements TextDocumentService {
return Collections.emptyList();
}
Path documentPath = toPath(uri);
+ // Gated on the dependency classpath, not on the class index being
+ // non-empty: source-derived names land in that index before Maven has
+ // resolved anything, and judging a dependency type unknown on that basis
+ // reports every one of them as a typo until the build catches up.
return DRLLintHelper.lintUnknownTypes(text, parsed.compilationUnit, documentPath,
- openSiblings(documentPath), classIndex, classMemberIndex, classIndex.size() > 0);
+ openSiblings(documentPath), classIndex, classMemberIndex,
+ server.isDependencyClasspathResolved());
}
@Override
@@ -366,7 +425,8 @@ public class DroolsLspDocumentService implements TextDocumentService {
String uri = params.getTextDocument().getUri();
Path documentPath = toPath(uri);
return DRLTypeHierarchyHelper.prepare(textForUri(uri), params.getPosition(), uri,
- openSiblings(documentPath), classIndex, server.getBuildOutputDirs());
+ openSiblings(documentPath), classIndex, server.getBuildOutputDirs(),
+ javaSourceIndex);
});
}
@@ -383,7 +443,8 @@ public class DroolsLspDocumentService implements TextDocumentService {
return DRLTypeHierarchyHelper.supertypes(item,
classpath ? null : textForUri(item.getUri()),
classpath ? Collections.emptyMap() : openSiblings(toPath(item.getUri())),
- classIndex, classMemberIndex, server.getBuildOutputDirs());
+ classIndex, classMemberIndex, server.getBuildOutputDirs(),
+ javaSourceIndex);
});
}
@@ -411,7 +472,7 @@ public class DroolsLspDocumentService implements TextDocumentService {
Path documentPath = toPath(uri);
List<Location> definitions = attempt(() -> DRLDefinitionHelper.findDefinitions(
uri, text, params.getPosition(), classIndex, server.getBuildOutputDirs(),
- openSiblings(documentPath)));
+ javaSourceIndex, openSiblings(documentPath)));
return Either.forLeft(definitions == null ? List.of() : definitions);
});
}
diff --git a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
index e7e886c1cc7..552618ae32b 100644
--- a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
+++ b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
@@ -36,7 +36,10 @@ import java.util.logging.Logger;
import org.drools.completion.ClassIndex;
import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.ClasspathTypeMembers;
import org.drools.completion.DRLDeclaredTypeParser;
+import org.drools.completion.JavaSourceRoots;
+import org.drools.completion.JavaSourceTypeIndex;
import org.drools.completion.WorkspaceSiblingResolver;
import org.drools.completion.WorkspaceSiblingResolvers;
import org.eclipse.lsp4j.jsonrpc.Endpoint;
@@ -71,6 +74,22 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
private volatile Set<Path> buildOutputDirs = Set.of();
private volatile ClassIndex jarClassIndex = ClassIndex.empty();
private volatile ClassMemberIndex classMemberIndex = ClassMemberIndex.empty();
+ private volatile JavaSourceTypeIndex javaSourceIndex = JavaSourceTypeIndex.empty();
+
+ /** The LSP workspace root, once {@code initialize} has parsed a usable {@code rootUri}. */
+ private volatile Path workspaceRootPath;
+
+ /** {@code drools.lsp.java.sourcePaths}, parsed once at {@code initialize}. */
+ private volatile List<String> javaSourcePathsSetting = List.of();
+
+ /** {@code drools.lsp.java.packageFilters}, parsed once at {@code initialize}. */
+ private volatile List<String> javaPackageFiltersSetting = List.of();
+
+ /**
+ * Set once the Maven resolve returns, whether or not it found anything. Not
+ * inferred from the class index, which also carries source-derived names.
+ */
+ private volatile boolean dependencyClasspathResolved = false;
/** Tracks whether {@code shutdown} preceded {@code exit} (LSP spec). */
private volatile boolean shutdownReceived = false;
@@ -99,13 +118,20 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
}
public void rebuildClassIndex() {
- Set<Path> dirs = buildOutputDirs;
- if (dirs.isEmpty() && jarClassIndex.size() == 0) {
- return;
- }
try {
- ClassIndex outputIndex = ClassIndex.build(dirs);
- textService.setClassIndex(ClassIndex.merge(jarClassIndex, outputIndex));
+ // Refreshed inside the try: an escaped exception here (e.g. an
+ // unreadable source root) must not silently abort the rebuild
+ // with no log — the debounce executor never inspects the
+ // resulting future.
+ if (workspaceRootPath != null) {
+ refreshJavaSourceIndex(workspaceRootPath);
+ }
+ // Published unconditionally. Having nothing to index is itself a
+ // result the consumers need: the source index reaches them only
+ // through publishClassIndex and swapMemberIndex, so returning early
+ // on an empty workspace would leave them answering from the previous
+ // snapshot — for types whose files have just been deleted.
+ publishClassIndex();
// Fresh loader so recompiled classes aren't served from the old one's cache.
swapMemberIndex(ClassMemberIndex.of(classpathEntries));
// Drop the declared-type parse cache so edited sibling files re-parse
@@ -116,8 +142,59 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
}
}
+ /**
+ * Recomputes {@link #javaSourceIndex} from {@code root} using the
+ * current {@link #javaSourcePathsSetting}/{@link #javaPackageFiltersSetting}.
+ * The single choke point for the discover+build pair — every site that
+ * (re)builds the source index (initialize's fast path,
+ * {@link #rebuildClassIndex}, the test hook) calls this rather than
+ * repeating it inline.
+ */
+ private void refreshJavaSourceIndex(Path root) {
+ List<Path> roots = JavaSourceRoots.discover(root, javaSourcePathsSetting);
+ javaSourceIndex = JavaSourceTypeIndex.build(new LinkedHashSet<>(roots), javaPackageFiltersSetting);
+ }
+
+ /**
+ * Publishes the merged class index — compiled classpath classes, this
+ * workspace's own build output, and Java-source-derived type names —
+ * to the document service. The single choke point for every publish
+ * site (initialize's fast/post-mvn phases, {@link #rebuildClassIndex}),
+ * so source names are never missing from one path but not another.
+ * Nudges the client to re-pull diagnostics afterward (same pull-model
+ * nudge the build-diagnostics tier uses) so unknown-type squiggles
+ * against a type this publish just resolved clear promptly rather than
+ * waiting for the next edit/save.
+ */
+ private void publishClassIndex() {
+ ClassIndex outputIndex = ClassIndex.build(buildOutputDirs);
+ ClassIndex merged = ClassIndex.merge(ClassIndex.merge(jarClassIndex, outputIndex),
+ ClassIndex.of(javaSourceIndex.classNames()));
+ textService.setClassIndex(merged);
+ textService.setJavaSourceIndex(javaSourceIndex);
+ if (client != null) {
+ try {
+ // Pull model: nudge the client to re-request diagnostics, same
+ // as the build-diagnostics tier's refresh — some clients (and
+ // test doubles) don't implement this LSP4J capability method.
+ client.refreshDiagnostics();
+ } catch (Exception e) {
+ logger.log(Level.FINE, "Failed to refresh diagnostics after publishing class index", e);
+ }
+ }
+ }
+
+ /**
+ * The unknown-type lint waits on this: it cannot call a type unknown while
+ * part of the classpath is still missing.
+ */
+ boolean isDependencyClasspathResolved() {
+ return dependencyClasspathResolved;
+ }
+
private void setResolvedClasspath(Set<Path> entries) {
this.classpathEntries = entries;
+ this.dependencyClasspathResolved = true;
this.buildOutputDirs = filterDirectories(entries);
Set<Path> jars = filterJars(entries);
this.jarClassIndex = jars.isEmpty() ? ClassIndex.empty() : ClassIndex.build(jars);
@@ -138,6 +215,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
private synchronized void swapMemberIndex(ClassMemberIndex next) {
ClassMemberIndex previous = this.classMemberIndex;
this.classMemberIndex = next;
+ next.setSourceFallback(javaSourceIndex);
textService.setClassMemberIndex(next);
if (previous != next) {
try {
@@ -152,6 +230,45 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
setResolvedClasspath(entries);
}
+ /**
+ * Test-only entry point: runs the same fast-path source-typing steps
+ * {@code initialize} runs before the mvn phase (re-read the
+ * {@code drools.lsp.java.*} settings, refresh the source index via
+ * {@link #refreshJavaSourceIndex}, install it as the member-index
+ * fallback, publish the merged class index) without the async plumbing
+ * or a real LSP client — so a test exercising the settings path doesn't
+ * need to duplicate {@code initialize}'s sys-prop parsing.
+ */
+ void initializeJavaSourceTypingForTest(Path workspaceRoot) {
+ this.workspaceRootPath = workspaceRoot;
+ this.javaSourcePathsSetting = parseSemicolonSetting(
+ System.getProperty("drools.lsp.java.sourcePaths"));
+ this.javaPackageFiltersSetting = parseSemicolonSetting(
+ System.getProperty("drools.lsp.java.packageFilters"));
+ refreshJavaSourceIndex(workspaceRoot);
+ swapMemberIndex(ClassMemberIndex.of(classpathEntries));
+ publishClassIndex();
+ }
+
+ /**
+ * Splits a {@code drools.lsp.java.*} system property on {@code ;},
+ * trimming and dropping empty entries. {@code null}/blank yields an
+ * empty list.
+ */
+ private static List<String> parseSemicolonSetting(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return List.of();
+ }
+ List<String> out = new ArrayList<>();
+ for (String part : raw.split(";")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ out.add(trimmed);
+ }
+ }
+ return out;
+ }
+
private static Set<Path> filterDirectories(Set<Path> entries) {
Set<Path> dirs = new LinkedHashSet<>();
for (Path entry : entries) {
@@ -228,6 +345,20 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
if (rootUri != null) {
WorkspaceSiblingResolver resolver = WorkspaceSiblingResolvers.active();
+ // Java source typing needs the root and its settings before either
+ // async block runs: a rebuild triggered by a watch event reads them
+ // to refresh the source index. Guarded, because a non-file rootUri
+ // must not fail initialize.
+ try {
+ this.workspaceRootPath = Paths.get(URI.create(rootUri));
+ } catch (Exception e) {
+ logger.log(Level.WARNING, "Failed to read the workspace root for Java source typing", e);
+ }
+ this.javaSourcePathsSetting = parseSemicolonSetting(
+ System.getProperty("drools.lsp.java.sourcePaths"));
+ this.javaPackageFiltersSetting = parseSemicolonSetting(
+ System.getProperty("drools.lsp.java.packageFilters"));
+
// Applied before returning, so that a notification the client sends
// once initialize completes is necessarily the newer value. Deferring
// these would let a startup value captured here land afterwards and
@@ -259,6 +390,18 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
try {
Path rootPath = Paths.get(URI.create(rootUri));
+ // Build the Java-source type index first. Like the build-output
+ // scan below, this only touches the filesystem (no mvn), so
+ // source-derived type and member data is available within
+ // milliseconds — the works-before-build case for a fresh
+ // checkout with no compiled output yet.
+ refreshJavaSourceIndex(rootPath);
+ // classpathEntries is still Set.of() here (setResolvedClasspath
+ // hasn't run yet) — of() yields a loader-less but
+ // fallback-capable index, so member data from sources works
+ // even before the classpath resolves below.
+ swapMemberIndex(ClassMemberIndex.of(classpathEntries));
+
// Resolve against the configured custom POM root(s) when set,
// otherwise the workspace root.
String pomPathProp = System.getProperty("drools.lsp.maven.pomPath");
@@ -281,7 +424,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
outputDirs.addAll(MavenClasspathResolver.resolveBuildOutputDirs(mavenRoot));
}
buildOutputDirs = outputDirs;
- textService.setClassIndex(ClassIndex.build(outputDirs));
+ publishClassIndex();
// Then resolve the full classpath (dependency JARs via mvn) and
// merge, so member hover and field completion over dependencies
@@ -291,8 +434,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
resolved.addAll(MavenClasspathResolver.resolve(mavenRoot));
}
setResolvedClasspath(resolved);
- ClassIndex outputIndex = ClassIndex.build(buildOutputDirs);
- textService.setClassIndex(ClassIndex.merge(jarClassIndex, outputIndex));
+ publishClassIndex();
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to build class index at startup", e);
}
@@ -478,6 +620,10 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
logger.log(Level.FINE, "Failed to close class member index on shutdown", e);
}
DRLDeclaredTypeParser.clearCache();
+ JavaSourceTypeIndex.clearCache();
+ // The lookup closes over the document service, so leaving it installed
+ // keeps a stopped server — and its indexes — reachable and answering.
+ ClasspathTypeMembers.install(null);
return CompletableFuture.completedFuture(null);
}
diff --git a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
index ad63f509cdd..187b89ffc21 100644
--- a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
+++ b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
@@ -487,14 +487,39 @@ class DroolsLspDocumentServiceTest {
.noneSatisfy(d -> assertThat(d.getSource()).isEqualTo("drools-type"));
}
+ /**
+ * A source-only workspace before Maven has run: the class index carries
+ * Java-source-derived names, so it is not empty, but nothing is known about
+ * the dependencies. Judging a type unknown on that basis reports every
+ * dependency type as a typo, so the pass waits.
+ */
+ @Test
+ void unknownTypeLintWaitsForTheDependencyClasspath(@TempDir Path dir) throws Exception {
+ Path classFile = dir.resolve("com/example/Marker.class");
+ Files.createDirectories(classFile.getParent());
+ Files.createFile(classFile);
+
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument(TYPO_DRL);
+ DroolsLspDocumentService service = server.getTextDocumentService();
+ service.setClassIndex(ClassIndex.build(Set.of(dir)));
+
+ assertThat(service.validate("myDocument"))
+ .noneSatisfy(d -> assertThat(d.getSource()).isEqualTo("drools-type"));
+ }
+
@Test
void unknownTypeLintReportsTypoOnceClasspathResolved(@TempDir Path dir) throws Exception {
Path classFile = dir.resolve("com/example/Marker.class");
Files.createDirectories(classFile.getParent());
Files.createFile(classFile);
- DroolsLspDocumentService service = getDroolsLspDocumentService(TYPO_DRL);
- service.setClassIndex(ClassIndex.build(Set.of(dir))); // classpath now "resolved"
+ // Readiness is the dependency classpath having resolved, which is what
+ // the lint waits for — a populated class index no longer implies it,
+ // since source-derived names reach that index before Maven runs.
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument(TYPO_DRL);
+ DroolsLspDocumentService service = server.getTextDocumentService();
+ service.setClassIndex(ClassIndex.build(Set.of(dir)));
+ server.setClasspathEntriesForTest(Set.of(dir));
List<Diagnostic> diags = service.validate("myDocument");
assertThat(diags).anySatisfy(d -> {
diff --git a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.java b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.java
new file mode 100644
index 00000000000..a2527888c3b
--- /dev/null
+++ b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.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.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.drools.completion.ClassIndex;
+import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.Field;
+import org.drools.completion.LhsBindingResolver;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A fresh checkout with only {@code .java} sources (no {@code target/classes},
+ * no {@code mvn}) still flips the classpath gate and surfaces member data,
+ * because the server merges Java source-derived type names into the
+ * published {@link ClassIndex} and installs the source index as the {@link
+ * ClassMemberIndex} fallback.
+ */
+class JavaSourceTypingServerTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void sourceOnlyWorkspaceFlipsGateAndExposesSourceMembers() throws IOException {
+ Path srcDir = tempDir.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Files.writeString(srcDir.resolve("Foo.java"), """
+ package com.example;
+
+ public class Foo {
+ private String code;
+
+ public String getCode() {
+ return code;
+ }
+ }
+ """);
+
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+ server.initializeJavaSourceTypingForTest(tempDir);
+
+ ClassIndex classIndex = server.getTextDocumentService().getClassIndexForTest();
+ assertThat(classIndex.size()).isGreaterThan(0);
+ assertThat(classIndex.getMatching("Foo")).contains("com.example.Foo");
+
+ ClassMemberIndex memberIndex = server.getTextDocumentService().getClassMemberIndexForTest();
+ List<Field> members = memberIndex.membersOf("com.example.Foo");
+ assertThat(members).anyMatch(f -> f.name.equals("code"));
+ }
+
+ /**
+ * When the last indexed source disappears, the refreshed — now empty — index
+ * still has to reach its consumers. The rebuild's early return is taken in
+ * exactly this state (no build output, no jars, nothing left to index), and
+ * skipping the publish there leaves definition, type hierarchy and member
+ * lookup answering for a type that no longer exists.
+ */
+ @Test
+ void removingTheLastSourceClearsWhatTheConsumersSee() throws IOException {
+ Path srcDir = tempDir.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Path foo = srcDir.resolve("Foo.java");
+ Files.writeString(foo, """
+ package com.example;
+
+ public class Foo {
+ public String getCode() {
+ return "c";
+ }
+ }
+ """);
+
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+ server.initializeJavaSourceTypingForTest(tempDir);
+ assertThat(server.getTextDocumentService().getClassMemberIndexForTest()
+ .membersOf("com.example.Foo")).isNotEmpty();
+
+ Files.delete(foo);
+ server.rebuildClassIndex();
+
+ assertThat(server.getTextDocumentService().getClassMemberIndexForTest()
+ .membersOf("com.example.Foo"))
+ .as("a deleted type must stop answering")
+ .isEmpty();
+ assertThat(server.getTextDocumentService().getClassIndexForTest().size())
+ .as("and stop being offered")
+ .isZero();
+ }
+
+ /**
+ * The classpath-members seam the document service installs is JVM-wide
+ * static state, and shutdown already releases this server's other global
+ * state — the member index's loader, and both parse caches. The seam has to
+ * go with them, or a stopped server keeps answering through a closure that
+ * pins it.
+ */
+ @Test
+ void shutdownReleasesTheClasspathMembersSeam() throws IOException {
+ Path srcDir = tempDir.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Files.writeString(srcDir.resolve("Foo.java"), """
+ package com.example;
+
+ public class Foo {
+ public String getCode() {
+ return "c";
+ }
+ }
+ """);
+
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+ server.initializeJavaSourceTypingForTest(tempDir);
+
+ String drl = "rule R\n when\n Foo( $c : code )\n then\nend\n";
+ int caret = drl.indexOf("$c");
+ assertThat(LhsBindingResolver.resolveAt(drl, caret, Map.of()))
+ .as("the seam answers while the server is up")
+ .containsEntry("c", "String");
+
+ server.shutdown().join();
+
+ assertThat(LhsBindingResolver.resolveAt(drl, caret, Map.of()))
+ .as("and not after it has stopped")
+ .doesNotContainKey("c");
+ }
+
+ @Test
+ void packageFiltersSettingExcludesNonMatchingPackage() throws IOException {
+ Path srcDir = tempDir.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Files.writeString(srcDir.resolve("Foo.java"), """
+ package com.example;
+
+ public class Foo {
+ private String code;
+
+ public String getCode() {
+ return code;
+ }
+ }
+ """);
+
+ System.setProperty("drools.lsp.java.packageFilters", "org.other*");
+ try {
+ DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+ server.initializeJavaSourceTypingForTest(tempDir);
+
+ ClassIndex classIndex = server.getTextDocumentService().getClassIndexForTest();
+ assertThat(classIndex.getMatching("Foo")).doesNotContain("com.example.Foo");
+
+ ClassMemberIndex memberIndex = server.getTextDocumentService().getClassMemberIndexForTest();
+ assertThat(memberIndex.membersOf("com.example.Foo")).isEmpty();
+ } finally {
+ System.clearProperty("drools.lsp.java.packageFilters");
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@kie.apache.org
For additional commands, e-mail: commits-help@kie.apache.org