Copilot commented on code in PR #3965:
URL: 
https://github.com/apache/incubator-kie-tools/pull/3965#discussion_r3840971816


##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java:
##########
@@ -106,11 +109,49 @@ 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);

Review Comment:
   This lookup drops DRL import context. If `a.Pet` is explicitly imported 
while `b.Pet` is also indexed, `uniqueFqcnForSimpleName("Pet")` returns null, 
so a `declare ... extends Pet` loses all inherited fields and bindings even 
though the source is unambiguous. The seam needs to receive an import-resolved 
FQCN (or enough document context to resolve one) rather than resolving by 
global simple-name uniqueness.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java:
##########
@@ -208,6 +250,155 @@ private static List<CompletionItem> 
getFieldCompletionItems(DRL10Parser.Compilat
         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("\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;
+        }
+
+        String resolved = rootType.substring(rootType.lastIndexOf('.') + 1);
+        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) {
+        int closed = 0;

Review Comment:
   The backward parenthesis scan uses raw text, so parentheses or terminators 
inside a preceding string/comment corrupt its balance. For example, completion 
after `ref.` in `Fact(note == ")", ref.)` skips the real `Fact(` and fails to 
offer `ref` members. Reuse the existing length-preserving mask before scanning.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java:
##########
@@ -108,35 +136,383 @@ public static Hover hover(String text, Position 
position, ClassIndex classIndex,
                 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);
             if (patternType != null && !patternType.equals(word)) {
                 Field field = findField(patternType, word, typeIndex,
                                         compilationUnit, classIndex, 
memberIndex);
                 if (field != null) {
-                    String owner = 
patternType.substring(patternType.lastIndexOf('.') + 1);
-                    return markdown("**" + field.name + "** : `" + field.type
-                            + "`\n\nField of `" + owner + "`");
+                    return markdown(renderField(field, 
simpleName(patternType)));
                 }
             }
         }
 
-        // 4. Classpath type (or java.lang built-in). Show the hover even with 
no
+        // 5. Documented function/query/global. The doc-comment parser maps 
names
+        //    across the whole document with no position scoping, so this comes
+        //    after the binding and pattern-field steps: a field sharing its 
name
+        //    with a documented declaration must still describe the field.
+        Hover doc = docHover(word, currentDocTypes, text, documentPath, 
openFiles);
+        if (doc != null) {
+            return doc;

Review Comment:
   This name-only doc lookup runs before classpath-type resolution, so a 
documented declaration can shadow an unrelated type hover. For example, with an 
imported Java `Pet` plus `/**...*/ function int Pet(...)`, hovering `Pet` in a 
`Pet(...)` pattern shows the function documentation instead of the Java type. 
Resolve doc targets structurally from the parse tree (as done for accumulate 
functions) rather than by document-wide name alone.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java:
##########
@@ -0,0 +1,357 @@
+/*
+ * 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<>();
+
+    /**
+     * 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.
+        if (!usedRoots.isEmpty()) {
+            logger.info("Indexed " + typesByFqcn.size() + " Java source 
type(s) from "
+                    + usedRoots.size() + " root(s): " + usedRoots);
+        }
+
+        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;

Review Comment:
   Truncating `FileTime` to milliseconds can reuse stale parsed types after a 
rapid same-path edit. The `.java` watch event does rebuild the index, but if 
both saves share the truncated timestamp this branch returns the old members 
and keeps doing so until a later timestamp change. Invalidate cache entries 
from watch events or use a cache key that cannot alias changed content.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java:
##########
@@ -0,0 +1,435 @@
+/*
+ * 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<>();
+        if (cd.classBody() != null) {
+            collectBodyMembers(cd.classBody().classBodyDeclaration(), fields, 
getters, ctors, 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,
+                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<>();
+        if (ed.enumBodyDeclarations() != null) {
+            
collectBodyMembers(ed.enumBodyDeclarations().classBodyDeclaration(), fields, 
getters, ctors, simpleName);
+        }
+        mergeGettersThenFields(members, getters, fields);
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, true, 
null, interfaces,
+                new ArrayList<>(members.values()), ctors,
+                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(),
+                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),
+                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, 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());
+                    for (JavaParser.VariableDeclaratorContext vd : 
fd.variableDeclarators().variableDeclarator()) {
+                        String name = 
vd.variableDeclaratorId().identifier().getText();
+                        fieldsOut.add(new Field(name, type, null, 
Field.Origin.FIELD));

Review Comment:
   This includes public static fields in the instance-member list, while the 
compiled reflection path explicitly excludes static fields (`ClassMemberIndex` 
lines 315-318). Thus completion/hover offers `VERSION` before a build and 
removes it afterward. Static fields need separate modeling so qualified 
static-member checks can retain them without exposing them as fact properties.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java:
##########
@@ -0,0 +1,357 @@
+/*
+ * 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<>();
+
+    /**
+     * 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.
+        if (!usedRoots.isEmpty()) {
+            logger.info("Indexed " + typesByFqcn.size() + " Java source 
type(s) from "
+                    + usedRoots.size() + " root(s): " + usedRoots);
+        }
+
+        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
+    public Set<String> memberNames(String fqcn) {
+        JavaSourceType type = byFqcn(fqcn);
+        if (type == null) {
+            return null;
+        }
+        return new LinkedHashSet<>(membersIncludingInherited(fqcn, 
type).keySet());
+    }
+
+    /**
+     * 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) {

Review Comment:
   This inheritance walk only follows a class parent that is also present in 
this source index. It omits source interfaces entirely and stops when a 
source-only child extends a loadable dependency class, so inherited 
getters/fields disappear before the child is compiled and then appear after 
compilation. Traverse all direct supertypes and allow the composite member 
source to supply compiled parents.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java:
##########
@@ -217,17 +222,40 @@ private static TypeHierarchyItem declareItem(Declared d) {
         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);

Review Comment:
   Every non-enum source type is reported as `Class`, including interfaces. The 
compiled-source path already returns `SymbolKind.Interface`, so type hierarchy 
changes kind after the first build. Preserve the parsed declaration kind in 
`JavaSourceType` and map interfaces to `SymbolKind.Interface` here.



##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java:
##########
@@ -116,6 +136,48 @@ public void rebuildClassIndex() {
         }
     }
 
+    /**
+     * 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()));

Review Comment:
   Merging source names into this same index prematurely flips the unknown-type 
lint gate (`classIndex.size() > 0` in `DroolsLspDocumentService`). On a fresh 
checkout with any indexed source, dependency types are reported as unknown 
before Maven finishes—and indefinitely if dependency resolution fails. Track 
source readiness and dependency-classpath readiness separately instead of 
deriving both from the merged index size.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java:
##########
@@ -0,0 +1,435 @@
+/*
+ * 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<>();
+        if (cd.classBody() != null) {
+            collectBodyMembers(cd.classBody().classBodyDeclaration(), fields, 
getters, ctors, simpleName);
+        }

Review Comment:
   A public class with no explicit constructor has an implicit public no-arg 
constructor, but this list stays empty, so the pre-build hover omits `Foo()` 
and changes after compilation. Detect the absence of constructor declarations 
and add the implicit signature when the top-level class is public.



##########
packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java:
##########
@@ -0,0 +1,435 @@
+/*
+ * 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<>();
+        if (cd.classBody() != null) {
+            collectBodyMembers(cd.classBody().classBodyDeclaration(), fields, 
getters, ctors, 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,
+                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<>();
+        if (ed.enumBodyDeclarations() != null) {
+            
collectBodyMembers(ed.enumBodyDeclarations().classBodyDeclaration(), fields, 
getters, ctors, simpleName);
+        }
+        mergeGettersThenFields(members, getters, fields);
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, true, 
null, interfaces,
+                new ArrayList<>(members.values()), ctors,
+                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(),
+                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),
+                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, 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());
+                    for (JavaParser.VariableDeclaratorContext vd : 
fd.variableDeclarators().variableDeclarator()) {
+                        String name = 
vd.variableDeclaratorId().identifier().getText();
+                        fieldsOut.add(new Field(name, type, null, 
Field.Origin.FIELD));
+                    }
+                } else if (md.methodDeclaration() != null && 
hasPublicModifier(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));

Review Comment:
   Public static `getX`/`isX` methods pass this branch and become instance 
properties, but `ClassMemberIndex.propertyNameOf` rejects static methods. These 
properties therefore appear only before compilation. Check the declaration 
modifiers for `static` before adding a getter-derived member.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to