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]