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


##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/ProjectEngine.java:
##########
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.lsp.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * The project's own Drools engine, driven through the bridge class.
+ *
+ * <p>The project loader sees the project's classpath and the platform classes
+ * only. The server's loader is deliberately not its parent: the server jar
+ * carries a DRL parser and an ANTLR runtime of its own, and the project's
+ * versions must win inside the engine. The bridge class is defined from this
+ * server's own jar into a child of the project loader, so its references to
+ * kie-api resolve against the project.
+ */
+final class ProjectEngine implements Engine {
+
+    static final String BRIDGE_CLASS = "org.drools.lsp.bridge.EngineBridge";
+    static final String COMPILER_CLASS = 
"org.drools.compiler.kie.builder.impl.KieServicesImpl";
+    static final String MVEL_CLASS = "org.drools.mvel.MVELConstraint";
+    private static final String BRIDGE_PACKAGE_PREFIX = 
"org.drools.lsp.bridge.";
+    private static final Logger logger = 
Logger.getLogger(ProjectEngine.class.getName());
+
+    private final Set<Path> entries;
+    private final URLClassLoader projectLoader;
+    private final Class<?> bridge;
+    private final Method build;
+    private volatile boolean closed;
+
+    private ProjectEngine(Set<Path> entries, URLClassLoader projectLoader, 
Class<?> bridge, Method build) {
+        this.entries = entries;
+        this.projectLoader = projectLoader;
+        this.bridge = bridge;
+        this.build = build;
+    }
+
+    static ProjectEngine over(Collection<Path> classpathEntries) {
+        Set<Path> entries = new LinkedHashSet<>(classpathEntries);
+        List<URL> urls = new ArrayList<>(entries.size());
+        for (Path entry : entries) {
+            try {
+                urls.add(entry.toUri().toURL());
+            } catch (MalformedURLException e) {
+                logger.fine(() -> "Skipping classpath entry " + entry + ": " + 
e.getMessage());
+            }
+        }
+        URLClassLoader projectLoader = new URLClassLoader("drools-lsp-project",
+                urls.toArray(new URL[0]), 
ClassLoader.getPlatformClassLoader());
+        Class<?> bridge = null;
+        Method build = null;
+        if (hasCompiler(projectLoader)) {
+            try {
+                bridge = Class.forName(BRIDGE_CLASS, true, new 
BridgeClassLoader(projectLoader));
+                build = bridge.getMethod("build", Map.class);
+            } catch (ReflectiveOperationException | LinkageError e) {
+                logger.log(Level.WARNING, "A Drools engine is on the project 
classpath but the compile bridge did not link", e);
+            }
+        }
+        ProjectEngine engine = new ProjectEngine(entries, projectLoader, 
bridge, build);
+        if (build == null) {
+            // Permanently unavailable: release the loader's open jar handles 
now
+            // rather than holding them until the next classpath change 
replaces it.
+            engine.close();
+        }
+        return engine;
+    }
+
+    Set<Path> entries() {
+        return entries;
+    }
+
+    @Override
+    public boolean available() {
+        return build != null && !closed;
+    }
+
+    @Override
+    public List<Map<String, Object>> build(Map<String, String> drlByPath) 
throws Exception {
+        if (!available()) {
+            throw new IllegalStateException("No Drools engine available");
+        }
+        AtomicReference<Object> outcome = new AtomicReference<>();
+        Thread worker = new Thread(() -> {
+            
Thread.currentThread().setContextClassLoader(bridge.getClassLoader());
+            try {
+                outcome.set(build.invoke(null, drlByPath));
+            } catch (Throwable t) {
+                outcome.set(t);
+            }
+        }, "drools-lsp-engine-build");
+        worker.setDaemon(true);
+        worker.start();
+        worker.join();
+        Object result = outcome.get();
+        if (result instanceof InvocationTargetException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof Exception exception) {
+                throw exception;
+            }
+            throw e;
+        }
+        if (result instanceof Exception e) {
+            throw e;
+        }
+        if (result instanceof Error e) {
+            throw e;
+        }
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> messages = (List<Map<String, Object>>) 
result;
+        return messages;
+    }
+
+    @Override
+    public void close() {
+        closed = true;
+        try {
+            projectLoader.close();
+        } catch (IOException e) {
+            logger.log(Level.FINE, "Closing the project class loader failed", 
e);
+        }
+    }
+
+    boolean isClosed() {
+        return closed;
+    }
+
+    Class<?> bridgeClassForTest() {
+        return bridge;
+    }
+
+    ClassLoader projectLoaderForTest() {
+        return projectLoader;
+    }
+
+    private static boolean hasCompiler(ClassLoader projectLoader) {
+        try {
+            Class.forName("org.kie.api.KieServices", false, projectLoader);
+        } catch (ClassNotFoundException e) {
+            logger.fine("No Drools engine on the project classpath; compile 
diagnostics stay off until it changes");
+            return false;
+        }
+        try {
+            Class.forName(COMPILER_CLASS, false, projectLoader);
+        } catch (ClassNotFoundException e) {
+            logger.fine("kie-api is on the project classpath but 
drools-compiler is not; compile diagnostics stay off until it changes");
+            return false;
+        }
+        try {
+            Class.forName(MVEL_CLASS, false, projectLoader);
+        } catch (ClassNotFoundException e) {
+            logger.fine("drools-compiler is on the project classpath but 
drools-mvel is not; the classic build cannot "
+                    + "compile constraints, compile diagnostics stay off until 
it changes");
+            return false;
+        }
+        return true;
+    }

Review Comment:
   These availability probes only handle an absent class. A partially resolved 
or incompatible project classpath can throw `NoClassDefFoundError`, 
`UnsupportedClassVersionError`, or another `LinkageError` while loading one of 
these classes; that escapes `ProjectEngine.over()` and can abort class-index 
initialization, violating the intended no-engine fallback. Treat linkage 
failures as an unavailable engine just like the bridge-linking block already 
does.



##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/CompileDiagnostics.java:
##########
@@ -0,0 +1,645 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import org.drools.completion.DRLDiagnosticHelper;
+import org.drools.completion.WorkspaceScan;
+import org.drools.completion.WorkspaceSiblingResolvers;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.MessageType;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Compiles a saved DRL file together with its group through the project's own
+ * engine and keeps the engine's messages per file until the file is edited,
+ * saved again or regrouped. One compile runs at a time; a save during a 
compile
+ * queues one more and the latest wins.
+ */
+final class CompileDiagnostics {
+
+    static final String SOURCE = "drools";
+    static final String KFS_PREFIX = "src/main/resources/";
+    static final int MAX_DIAGNOSTICS_PER_FILE = 500;
+    static final String NOT_BUILT =
+            "Compile diagnostics need the project's compiled classes. Build 
the project, then save again.";
+
+    private static final Pattern NOISY_CASCADE = Pattern.compile(
+            
"(?i)(unknown\\s*type|cannot\\s*resolve\\s*type|unresolved\\s*type|unable\\s*to\\s*resolve\\s*type|class\\s*not\\s*found)");
+    private static final Logger logger = 
Logger.getLogger(CompileDiagnostics.class.getName());
+
+    private final DroolsLspServer server;
+    private final Function<Path, String> openText;
+    private final Map<Path, List<Diagnostic>> cached = new 
ConcurrentHashMap<>();
+    private final AtomicBoolean buildInFlight = new AtomicBoolean(false);
+    private final AtomicReference<PendingBuild> pending = new 
AtomicReference<>();
+    private final ExecutorService gateExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile-gate"));
+    private final ExecutorService buildExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile"));
+    private volatile boolean onSave = true;
+    private final AtomicReference<Engine> engine = new AtomicReference<>();
+    private final AtomicBoolean notBuiltNoticeShown = new AtomicBoolean(false);
+
+    CompileDiagnostics(DroolsLspServer server, Function<Path, String> 
openText) {
+        this.server = server;
+        this.openText = openText;
+    }
+
+    boolean onSave() {
+        return onSave;
+    }
+
+    void setOnSave(boolean onSave) {
+        this.onSave = onSave;
+    }
+
+    Engine engine() {
+        return engine.get();
+    }
+
+    /** Installs the engine; the previous one is closed once the build in 
flight, if any, has finished. */
+    synchronized void setEngine(Engine next) {
+        if (buildExecutor.isShutdown()) {
+            next.close();
+            return;
+        }
+        Engine previous = engine.getAndSet(next);
+        if (previous == null || previous.available() != next.available()) {
+            logger.info(next.available()
+                    ? "Drools engine found on the project classpath; compile 
diagnostics are on"
+                    : "No usable Drools engine on the project classpath (needs 
drools-compiler and drools-mvel); "
+                            + "compile diagnostics are off");
+        }
+        if (previous != null && previous != next) {
+            buildExecutor.submit(previous::close);
+        }
+    }
+
+    /** Releases the engine and stops both executors; the close runs after any 
build in flight. */
+    synchronized void shutdown() {
+        Engine current = engine.getAndSet(null);
+        if (current != null) {
+            buildExecutor.submit(current::close);
+        }
+        gateExecutor.shutdown();
+        buildExecutor.shutdown();
+    }
+
+    List<Diagnostic> cachedFor(Path path) {
+        return path == null ? List.of() : cached.getOrDefault(normalize(path), 
List.of());
+    }
+
+    void invalidate(Path path) {
+        if (path != null) {
+            cached.remove(normalize(path));
+        }
+    }
+
+    void invalidateAll() {
+        cached.clear();
+    }
+
+    void onDidSave(Path path, String text) {
+        if (!onSave || path == null || text == null) {
+            return;
+        }
+        trigger(path, text, false);
+    }
+
+    void rebuildWorkspace(Path path) {
+        if (path == null) {
+            return;
+        }
+        String text = openText.apply(normalize(path));
+        if (text == null) {
+            text = readQuietly(path);
+        }
+        if (text == null) {
+            server.showMessage(MessageType.Warning, "Workspace compile 
skipped: cannot read " + path);
+            return;
+        }
+        trigger(path, text, true);
+    }
+
+    private void trigger(Path path, String text, boolean fullScope) {
+        gateExecutor.submit(() -> triggerNow(path, text, fullScope));
+    }
+
+    void triggerNow(Path path, String text, boolean fullScope) {
+        Engine current = engine.get();
+        if (current == null || !current.available()) {
+            logger.fine(() -> "No Drools engine on the project classpath; not 
compiling " + path);
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: no Drools engine on the project classpath");
+            }
+            return;
+        }
+        if (server.projectClassesMissing()) {
+            if (notBuiltNoticeShown.compareAndSet(false, true)) {
+                logger.info(NOT_BUILT);
+                server.showMessage(MessageType.Info, NOT_BUILT);
+            }
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: the project has not been built");
+            }
+            return;
+        }
+        notBuiltNoticeShown.set(false);
+        if (!fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.info("Compile skipped, " + path + " has syntax errors");
+            return;
+        }
+        if (!buildInFlight.compareAndSet(false, true)) {
+            pending.accumulateAndGet(new PendingBuild(path, text, fullScope),
+                    (old, next) -> old != null && old.fullScope && 
!next.fullScope
+                            ? new PendingBuild(next.path, next.text, true)
+                            : next);
+            drainPendingIfUnclaimed();
+            return;
+        }
+        submit(path, text, fullScope);
+    }
+
+    private void drainPendingIfUnclaimed() {
+        while (pending.get() != null) {
+            if (!buildInFlight.compareAndSet(false, true)) {
+                return;
+            }
+            PendingBuild queued = pending.getAndSet(null);
+            if (queued != null) {
+                submit(queued.path, queued.text, queued.fullScope);
+                return;
+            }
+            buildInFlight.set(false);
+        }
+    }
+
+    private void submit(Path path, String text, boolean fullScope) {
+        buildExecutor.submit(() -> {
+            Engine target = engine.get();
+            boolean updated = false;
+            boolean ran = false;
+            boolean noEngine = false;
+            int errors = 0;
+            int warnings = 0;
+            Compilation result = null;
+            try {
+                if (target == null || !target.available()) {
+                    noEngine = true;
+                    logger.fine(() -> "No Drools engine on the project 
classpath; not compiling " + path);
+                    return;
+                }
+                if (fullScope) {
+                    server.showMessage(MessageType.Info, "Compiling every DRL 
file in the workspace");
+                }
+                result = compile(normalize(path), text, fullScope, target);
+                updated = commit(result);
+                errors = result.count(DiagnosticSeverity.Error);
+                warnings = result.count(DiagnosticSeverity.Warning);
+                ran = true;
+            } catch (Throwable t) {
+                logger.log(Level.WARNING, "Drools compile failed for " + path, 
t);
+                result = failure(normalize(path), text, t);
+                updated = commit(result);
+                errors = 1;
+                ran = true;
+            } finally {
+                if (updated) {
+                    server.refreshDiagnostics();
+                }
+                if (fullScope) {
+                    if (noEngine) {
+                        server.showMessage(MessageType.Warning,
+                                "Workspace compile skipped: no Drools engine 
on the project classpath");
+                    } else if (ran) {
+                        server.showMessage(errors > 0 ? MessageType.Warning : 
MessageType.Info,
+                                summaryMessage(errors, warnings, result));
+                    }
+                }
+                buildInFlight.set(false);
+                PendingBuild queued = pending.getAndSet(null);
+                if (queued != null) {
+                    trigger(queued.path, queued.text, queued.fullScope);
+                }
+            }
+        });
+    }
+
+    private static String summaryMessage(int errors, int warnings, Compilation 
result) {
+        String base = "Workspace compile finished: " + errors + " error(s), " 
+ warnings + " warning(s)";
+        if (errors == 0 || result == null) {
+            return base;
+        }
+        return base + " in " + joinFileNames(result.filesWithErrors());
+    }
+
+    private static String joinFileNames(List<String> files) {
+        StringBuilder names = new StringBuilder();
+        int shown = Math.min(3, files.size());
+        for (int i = 0; i < shown; i++) {
+            if (i > 0) {
+                names.append(", ");
+            }
+            names.append(files.get(i));
+        }
+        if (files.size() > 3) {
+            names.append(" and ").append(files.size() - 3).append(" more");
+        }
+        return names.toString();
+    }
+
+    private Compilation compile(Path saved, String text, boolean fullScope, 
Engine target) throws Exception {
+        Path root = rootFor(saved);
+        Map<Path, String> sources = new LinkedHashMap<>();
+        Set<Path> skipped = new LinkedHashSet<>();
+        for (Path sibling : scope(saved, fullScope)) {
+            Path normalized = normalize(sibling);
+            if (normalized.equals(saved)) {
+                continue;
+            }
+            String siblingText = openText.apply(normalized);
+            if (siblingText == null) {
+                siblingText = readQuietly(normalized);
+            }
+            if (siblingText == null) {
+                skipped.add(normalized);
+                continue;
+            }
+            if (hasErrors(DRLDiagnosticHelper.parse(siblingText).diagnostics)) 
{
+                logger.fine(() -> "Leaving " + normalized + " out of the 
compile, it has syntax errors");
+                skipped.add(normalized);
+                continue;
+            }
+            sources.put(normalized, siblingText);
+        }
+        if (fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.fine(() -> "Leaving " + saved + " out of the compile, it 
has syntax errors");
+            skipped.add(saved);
+        } else {
+            sources.put(saved, text);

Review Comment:
   A full rebuild always adds the active file even when `workspaceDrlFiles()` 
deliberately omitted it. Opening a DRL excluded by `files.exclude` and running 
the command therefore compiles that excluded file, despite the rebuild being 
defined by the client-enumerated file set. Add the saved file only for 
save-scoped builds or when it is present in the authoritative full scope.



##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/CompileDiagnostics.java:
##########
@@ -0,0 +1,645 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import org.drools.completion.DRLDiagnosticHelper;
+import org.drools.completion.WorkspaceScan;
+import org.drools.completion.WorkspaceSiblingResolvers;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.MessageType;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Compiles a saved DRL file together with its group through the project's own
+ * engine and keeps the engine's messages per file until the file is edited,
+ * saved again or regrouped. One compile runs at a time; a save during a 
compile
+ * queues one more and the latest wins.
+ */
+final class CompileDiagnostics {
+
+    static final String SOURCE = "drools";
+    static final String KFS_PREFIX = "src/main/resources/";
+    static final int MAX_DIAGNOSTICS_PER_FILE = 500;
+    static final String NOT_BUILT =
+            "Compile diagnostics need the project's compiled classes. Build 
the project, then save again.";
+
+    private static final Pattern NOISY_CASCADE = Pattern.compile(
+            
"(?i)(unknown\\s*type|cannot\\s*resolve\\s*type|unresolved\\s*type|unable\\s*to\\s*resolve\\s*type|class\\s*not\\s*found)");
+    private static final Logger logger = 
Logger.getLogger(CompileDiagnostics.class.getName());
+
+    private final DroolsLspServer server;
+    private final Function<Path, String> openText;
+    private final Map<Path, List<Diagnostic>> cached = new 
ConcurrentHashMap<>();
+    private final AtomicBoolean buildInFlight = new AtomicBoolean(false);
+    private final AtomicReference<PendingBuild> pending = new 
AtomicReference<>();
+    private final ExecutorService gateExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile-gate"));
+    private final ExecutorService buildExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile"));
+    private volatile boolean onSave = true;
+    private final AtomicReference<Engine> engine = new AtomicReference<>();
+    private final AtomicBoolean notBuiltNoticeShown = new AtomicBoolean(false);
+
+    CompileDiagnostics(DroolsLspServer server, Function<Path, String> 
openText) {
+        this.server = server;
+        this.openText = openText;
+    }
+
+    boolean onSave() {
+        return onSave;
+    }
+
+    void setOnSave(boolean onSave) {
+        this.onSave = onSave;
+    }
+
+    Engine engine() {
+        return engine.get();
+    }
+
+    /** Installs the engine; the previous one is closed once the build in 
flight, if any, has finished. */
+    synchronized void setEngine(Engine next) {
+        if (buildExecutor.isShutdown()) {
+            next.close();
+            return;
+        }
+        Engine previous = engine.getAndSet(next);
+        if (previous == null || previous.available() != next.available()) {
+            logger.info(next.available()
+                    ? "Drools engine found on the project classpath; compile 
diagnostics are on"
+                    : "No usable Drools engine on the project classpath (needs 
drools-compiler and drools-mvel); "
+                            + "compile diagnostics are off");
+        }
+        if (previous != null && previous != next) {
+            buildExecutor.submit(previous::close);
+        }
+    }
+
+    /** Releases the engine and stops both executors; the close runs after any 
build in flight. */
+    synchronized void shutdown() {
+        Engine current = engine.getAndSet(null);
+        if (current != null) {
+            buildExecutor.submit(current::close);
+        }
+        gateExecutor.shutdown();
+        buildExecutor.shutdown();
+    }
+
+    List<Diagnostic> cachedFor(Path path) {
+        return path == null ? List.of() : cached.getOrDefault(normalize(path), 
List.of());
+    }
+
+    void invalidate(Path path) {
+        if (path != null) {
+            cached.remove(normalize(path));
+        }
+    }
+
+    void invalidateAll() {
+        cached.clear();
+    }
+
+    void onDidSave(Path path, String text) {
+        if (!onSave || path == null || text == null) {
+            return;
+        }
+        trigger(path, text, false);
+    }
+
+    void rebuildWorkspace(Path path) {
+        if (path == null) {
+            return;
+        }
+        String text = openText.apply(normalize(path));
+        if (text == null) {
+            text = readQuietly(path);
+        }
+        if (text == null) {
+            server.showMessage(MessageType.Warning, "Workspace compile 
skipped: cannot read " + path);
+            return;
+        }
+        trigger(path, text, true);
+    }
+
+    private void trigger(Path path, String text, boolean fullScope) {
+        gateExecutor.submit(() -> triggerNow(path, text, fullScope));
+    }
+
+    void triggerNow(Path path, String text, boolean fullScope) {
+        Engine current = engine.get();
+        if (current == null || !current.available()) {
+            logger.fine(() -> "No Drools engine on the project classpath; not 
compiling " + path);
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: no Drools engine on the project classpath");
+            }
+            return;
+        }
+        if (server.projectClassesMissing()) {
+            if (notBuiltNoticeShown.compareAndSet(false, true)) {
+                logger.info(NOT_BUILT);
+                server.showMessage(MessageType.Info, NOT_BUILT);
+            }
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: the project has not been built");
+            }
+            return;
+        }
+        notBuiltNoticeShown.set(false);
+        if (!fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.info("Compile skipped, " + path + " has syntax errors");
+            return;
+        }
+        if (!buildInFlight.compareAndSet(false, true)) {
+            pending.accumulateAndGet(new PendingBuild(path, text, fullScope),
+                    (old, next) -> old != null && old.fullScope && 
!next.fullScope
+                            ? new PendingBuild(next.path, next.text, true)
+                            : next);
+            drainPendingIfUnclaimed();
+            return;
+        }
+        submit(path, text, fullScope);
+    }
+
+    private void drainPendingIfUnclaimed() {
+        while (pending.get() != null) {
+            if (!buildInFlight.compareAndSet(false, true)) {
+                return;
+            }
+            PendingBuild queued = pending.getAndSet(null);
+            if (queued != null) {
+                submit(queued.path, queued.text, queued.fullScope);
+                return;
+            }
+            buildInFlight.set(false);
+        }
+    }
+
+    private void submit(Path path, String text, boolean fullScope) {
+        buildExecutor.submit(() -> {
+            Engine target = engine.get();
+            boolean updated = false;
+            boolean ran = false;
+            boolean noEngine = false;
+            int errors = 0;
+            int warnings = 0;
+            Compilation result = null;
+            try {
+                if (target == null || !target.available()) {
+                    noEngine = true;
+                    logger.fine(() -> "No Drools engine on the project 
classpath; not compiling " + path);
+                    return;
+                }
+                if (fullScope) {
+                    server.showMessage(MessageType.Info, "Compiling every DRL 
file in the workspace");
+                }
+                result = compile(normalize(path), text, fullScope, target);
+                updated = commit(result);
+                errors = result.count(DiagnosticSeverity.Error);
+                warnings = result.count(DiagnosticSeverity.Warning);
+                ran = true;
+            } catch (Throwable t) {
+                logger.log(Level.WARNING, "Drools compile failed for " + path, 
t);
+                result = failure(normalize(path), text, t);
+                updated = commit(result);
+                errors = 1;
+                ran = true;
+            } finally {
+                if (updated) {
+                    server.refreshDiagnostics();
+                }
+                if (fullScope) {
+                    if (noEngine) {
+                        server.showMessage(MessageType.Warning,
+                                "Workspace compile skipped: no Drools engine 
on the project classpath");
+                    } else if (ran) {
+                        server.showMessage(errors > 0 ? MessageType.Warning : 
MessageType.Info,
+                                summaryMessage(errors, warnings, result));
+                    }
+                }
+                buildInFlight.set(false);
+                PendingBuild queued = pending.getAndSet(null);
+                if (queued != null) {
+                    trigger(queued.path, queued.text, queued.fullScope);
+                }
+            }
+        });
+    }
+
+    private static String summaryMessage(int errors, int warnings, Compilation 
result) {
+        String base = "Workspace compile finished: " + errors + " error(s), " 
+ warnings + " warning(s)";
+        if (errors == 0 || result == null) {
+            return base;
+        }
+        return base + " in " + joinFileNames(result.filesWithErrors());
+    }
+
+    private static String joinFileNames(List<String> files) {
+        StringBuilder names = new StringBuilder();
+        int shown = Math.min(3, files.size());
+        for (int i = 0; i < shown; i++) {
+            if (i > 0) {
+                names.append(", ");
+            }
+            names.append(files.get(i));
+        }
+        if (files.size() > 3) {
+            names.append(" and ").append(files.size() - 3).append(" more");
+        }
+        return names.toString();
+    }
+
+    private Compilation compile(Path saved, String text, boolean fullScope, 
Engine target) throws Exception {
+        Path root = rootFor(saved);
+        Map<Path, String> sources = new LinkedHashMap<>();
+        Set<Path> skipped = new LinkedHashSet<>();
+        for (Path sibling : scope(saved, fullScope)) {
+            Path normalized = normalize(sibling);
+            if (normalized.equals(saved)) {
+                continue;
+            }
+            String siblingText = openText.apply(normalized);
+            if (siblingText == null) {
+                siblingText = readQuietly(normalized);
+            }
+            if (siblingText == null) {
+                skipped.add(normalized);
+                continue;
+            }
+            if (hasErrors(DRLDiagnosticHelper.parse(siblingText).diagnostics)) 
{
+                logger.fine(() -> "Leaving " + normalized + " out of the 
compile, it has syntax errors");
+                skipped.add(normalized);
+                continue;
+            }
+            sources.put(normalized, siblingText);
+        }
+        if (fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.fine(() -> "Leaving " + saved + " out of the compile, it 
has syntax errors");
+            skipped.add(saved);
+        } else {
+            sources.put(saved, text);
+        }
+
+        Map<String, Path> fileByKfsPath = new LinkedHashMap<>();
+        Map<String, String> drlByKfsPath = new LinkedHashMap<>();
+        for (Map.Entry<Path, String> entry : sources.entrySet()) {
+            String kfsPath = kfsPathFor(root, entry.getKey(), 
fileByKfsPath.size());
+            fileByKfsPath.put(kfsPath, entry.getKey());
+            drlByKfsPath.put(kfsPath, entry.getValue());
+        }
+        logger.info(() -> "Compiling " + sources.size() + " DRL file(s) for " 
+ saved
+                + (fullScope ? " (whole workspace)" : ""));
+
+        List<Map<String, Object>> messages = target.build(drlByKfsPath);
+
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        for (Path file : sources.keySet()) {
+            perFile.put(file, new ArrayList<>());
+        }
+        Path fallback = sources.containsKey(saved) ? saved : 
sources.keySet().stream().findFirst().orElse(null);
+        if (fallback != null) {
+            for (Map<String, Object> m : messages) {
+                Path file = fileFor(m.get("path"), fileByKfsPath, fallback);
+                List<Diagnostic> bucket = perFile.get(file);
+                if (bucket == null) {
+                    file = fallback;
+                    bucket = perFile.get(fallback);
+                }
+                String messageText = m.get("text") == null ? "" : 
String.valueOf(m.get("text"));
+                Diagnostic d = new Diagnostic();
+                d.setSeverity(severityOf(m.get("level")));
+                d.setSource(SOURCE);
+                d.setMessage(messageText);
+                d.setRange(BuildMessageRanges.rangeFor(messageText, 
intOf(m.get("line")), intOf(m.get("column")),
+                        sources.get(file)));
+                bucket.add(d);
+            }
+        }
+        perFile.replaceAll((file, list) -> cap(dedupe(dropCascades(list))));
+        return new Compilation(sources, perFile, skipped);
+    }
+
+    private Compilation failure(Path current, String text, Throwable t) {
+        Diagnostic d = new Diagnostic();
+        d.setSource(SOURCE);
+        String missing = missingClass(t);
+        if (missing != null) {
+            d.setSeverity(DiagnosticSeverity.Warning);
+            d.setMessage("The Drools compile needs the project's compiled 
classes: " + missing
+                    + " is not on the classpath. Build the project, then save 
again.");
+        } else {
+            d.setSeverity(DiagnosticSeverity.Error);
+            d.setMessage("Drools compile failed: " + summarize(t));
+        }
+        d.setRange(BuildMessageRanges.firstRuleRange(text));
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        perFile.put(current, List.of(d));
+        return new Compilation(Map.of(current, text), perFile, Set.of());
+    }
+
+    private boolean commit(Compilation result) {
+        boolean[] updated = {false};
+        result.perFile.forEach((file, list) -> {
+            String compiled = result.sources.get(file);
+            cached.compute(file, (k, previous) -> {
+                String now = openText.apply(file);
+                if (now != null && !now.equals(compiled)) {
+                    return previous;
+                }
+                updated[0] = true;
+                return list.isEmpty() ? null : List.copyOf(list);

Review Comment:
   A cache invalidation can be undone by an already-running compile. After 
`didClose`, `openText.apply(file)` returns `null`, so this branch accepts and 
re-caches the old result; reopening the file can then show stale diagnostics. 
The same race lets an old-group build repopulate the cache after 
`invalidateAll()`. Capture an invalidation/generation token when the build 
starts and reject commits whose per-file or global generation changed.



##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/CompileDiagnostics.java:
##########
@@ -0,0 +1,645 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import org.drools.completion.DRLDiagnosticHelper;
+import org.drools.completion.WorkspaceScan;
+import org.drools.completion.WorkspaceSiblingResolvers;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.MessageType;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Compiles a saved DRL file together with its group through the project's own
+ * engine and keeps the engine's messages per file until the file is edited,
+ * saved again or regrouped. One compile runs at a time; a save during a 
compile
+ * queues one more and the latest wins.
+ */
+final class CompileDiagnostics {
+
+    static final String SOURCE = "drools";
+    static final String KFS_PREFIX = "src/main/resources/";
+    static final int MAX_DIAGNOSTICS_PER_FILE = 500;
+    static final String NOT_BUILT =
+            "Compile diagnostics need the project's compiled classes. Build 
the project, then save again.";
+
+    private static final Pattern NOISY_CASCADE = Pattern.compile(
+            
"(?i)(unknown\\s*type|cannot\\s*resolve\\s*type|unresolved\\s*type|unable\\s*to\\s*resolve\\s*type|class\\s*not\\s*found)");
+    private static final Logger logger = 
Logger.getLogger(CompileDiagnostics.class.getName());
+
+    private final DroolsLspServer server;
+    private final Function<Path, String> openText;
+    private final Map<Path, List<Diagnostic>> cached = new 
ConcurrentHashMap<>();
+    private final AtomicBoolean buildInFlight = new AtomicBoolean(false);
+    private final AtomicReference<PendingBuild> pending = new 
AtomicReference<>();
+    private final ExecutorService gateExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile-gate"));
+    private final ExecutorService buildExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile"));
+    private volatile boolean onSave = true;
+    private final AtomicReference<Engine> engine = new AtomicReference<>();
+    private final AtomicBoolean notBuiltNoticeShown = new AtomicBoolean(false);
+
+    CompileDiagnostics(DroolsLspServer server, Function<Path, String> 
openText) {
+        this.server = server;
+        this.openText = openText;
+    }
+
+    boolean onSave() {
+        return onSave;
+    }
+
+    void setOnSave(boolean onSave) {
+        this.onSave = onSave;
+    }
+
+    Engine engine() {
+        return engine.get();
+    }
+
+    /** Installs the engine; the previous one is closed once the build in 
flight, if any, has finished. */
+    synchronized void setEngine(Engine next) {
+        if (buildExecutor.isShutdown()) {
+            next.close();
+            return;
+        }
+        Engine previous = engine.getAndSet(next);
+        if (previous == null || previous.available() != next.available()) {
+            logger.info(next.available()
+                    ? "Drools engine found on the project classpath; compile 
diagnostics are on"
+                    : "No usable Drools engine on the project classpath (needs 
drools-compiler and drools-mvel); "
+                            + "compile diagnostics are off");
+        }
+        if (previous != null && previous != next) {
+            buildExecutor.submit(previous::close);
+        }
+    }
+
+    /** Releases the engine and stops both executors; the close runs after any 
build in flight. */
+    synchronized void shutdown() {
+        Engine current = engine.getAndSet(null);
+        if (current != null) {
+            buildExecutor.submit(current::close);
+        }
+        gateExecutor.shutdown();
+        buildExecutor.shutdown();
+    }
+
+    List<Diagnostic> cachedFor(Path path) {
+        return path == null ? List.of() : cached.getOrDefault(normalize(path), 
List.of());
+    }
+
+    void invalidate(Path path) {
+        if (path != null) {
+            cached.remove(normalize(path));
+        }
+    }
+
+    void invalidateAll() {
+        cached.clear();
+    }
+
+    void onDidSave(Path path, String text) {
+        if (!onSave || path == null || text == null) {
+            return;
+        }
+        trigger(path, text, false);
+    }
+
+    void rebuildWorkspace(Path path) {
+        if (path == null) {
+            return;
+        }
+        String text = openText.apply(normalize(path));
+        if (text == null) {
+            text = readQuietly(path);
+        }
+        if (text == null) {
+            server.showMessage(MessageType.Warning, "Workspace compile 
skipped: cannot read " + path);
+            return;
+        }
+        trigger(path, text, true);
+    }
+
+    private void trigger(Path path, String text, boolean fullScope) {
+        gateExecutor.submit(() -> triggerNow(path, text, fullScope));
+    }
+
+    void triggerNow(Path path, String text, boolean fullScope) {
+        Engine current = engine.get();
+        if (current == null || !current.available()) {
+            logger.fine(() -> "No Drools engine on the project classpath; not 
compiling " + path);
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: no Drools engine on the project classpath");
+            }
+            return;
+        }
+        if (server.projectClassesMissing()) {
+            if (notBuiltNoticeShown.compareAndSet(false, true)) {
+                logger.info(NOT_BUILT);
+                server.showMessage(MessageType.Info, NOT_BUILT);
+            }
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: the project has not been built");
+            }
+            return;
+        }
+        notBuiltNoticeShown.set(false);
+        if (!fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.info("Compile skipped, " + path + " has syntax errors");
+            return;
+        }
+        if (!buildInFlight.compareAndSet(false, true)) {
+            pending.accumulateAndGet(new PendingBuild(path, text, fullScope),
+                    (old, next) -> old != null && old.fullScope && 
!next.fullScope
+                            ? new PendingBuild(next.path, next.text, true)
+                            : next);
+            drainPendingIfUnclaimed();
+            return;
+        }
+        submit(path, text, fullScope);
+    }
+
+    private void drainPendingIfUnclaimed() {
+        while (pending.get() != null) {
+            if (!buildInFlight.compareAndSet(false, true)) {
+                return;
+            }
+            PendingBuild queued = pending.getAndSet(null);
+            if (queued != null) {
+                submit(queued.path, queued.text, queued.fullScope);
+                return;
+            }
+            buildInFlight.set(false);
+        }
+    }
+
+    private void submit(Path path, String text, boolean fullScope) {
+        buildExecutor.submit(() -> {
+            Engine target = engine.get();
+            boolean updated = false;
+            boolean ran = false;
+            boolean noEngine = false;
+            int errors = 0;
+            int warnings = 0;
+            Compilation result = null;
+            try {
+                if (target == null || !target.available()) {
+                    noEngine = true;
+                    logger.fine(() -> "No Drools engine on the project 
classpath; not compiling " + path);
+                    return;
+                }
+                if (fullScope) {
+                    server.showMessage(MessageType.Info, "Compiling every DRL 
file in the workspace");
+                }
+                result = compile(normalize(path), text, fullScope, target);
+                updated = commit(result);
+                errors = result.count(DiagnosticSeverity.Error);
+                warnings = result.count(DiagnosticSeverity.Warning);
+                ran = true;
+            } catch (Throwable t) {
+                logger.log(Level.WARNING, "Drools compile failed for " + path, 
t);
+                result = failure(normalize(path), text, t);
+                updated = commit(result);
+                errors = 1;
+                ran = true;
+            } finally {
+                if (updated) {
+                    server.refreshDiagnostics();
+                }
+                if (fullScope) {
+                    if (noEngine) {
+                        server.showMessage(MessageType.Warning,
+                                "Workspace compile skipped: no Drools engine 
on the project classpath");
+                    } else if (ran) {
+                        server.showMessage(errors > 0 ? MessageType.Warning : 
MessageType.Info,
+                                summaryMessage(errors, warnings, result));
+                    }
+                }
+                buildInFlight.set(false);
+                PendingBuild queued = pending.getAndSet(null);
+                if (queued != null) {
+                    trigger(queued.path, queued.text, queued.fullScope);
+                }
+            }
+        });
+    }
+
+    private static String summaryMessage(int errors, int warnings, Compilation 
result) {
+        String base = "Workspace compile finished: " + errors + " error(s), " 
+ warnings + " warning(s)";
+        if (errors == 0 || result == null) {
+            return base;
+        }
+        return base + " in " + joinFileNames(result.filesWithErrors());
+    }
+
+    private static String joinFileNames(List<String> files) {
+        StringBuilder names = new StringBuilder();
+        int shown = Math.min(3, files.size());
+        for (int i = 0; i < shown; i++) {
+            if (i > 0) {
+                names.append(", ");
+            }
+            names.append(files.get(i));
+        }
+        if (files.size() > 3) {
+            names.append(" and ").append(files.size() - 3).append(" more");
+        }
+        return names.toString();
+    }
+
+    private Compilation compile(Path saved, String text, boolean fullScope, 
Engine target) throws Exception {
+        Path root = rootFor(saved);
+        Map<Path, String> sources = new LinkedHashMap<>();
+        Set<Path> skipped = new LinkedHashSet<>();
+        for (Path sibling : scope(saved, fullScope)) {
+            Path normalized = normalize(sibling);
+            if (normalized.equals(saved)) {
+                continue;
+            }
+            String siblingText = openText.apply(normalized);
+            if (siblingText == null) {
+                siblingText = readQuietly(normalized);
+            }
+            if (siblingText == null) {
+                skipped.add(normalized);
+                continue;
+            }
+            if (hasErrors(DRLDiagnosticHelper.parse(siblingText).diagnostics)) 
{
+                logger.fine(() -> "Leaving " + normalized + " out of the 
compile, it has syntax errors");
+                skipped.add(normalized);
+                continue;
+            }
+            sources.put(normalized, siblingText);
+        }
+        if (fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.fine(() -> "Leaving " + saved + " out of the compile, it 
has syntax errors");
+            skipped.add(saved);
+        } else {
+            sources.put(saved, text);
+        }
+
+        Map<String, Path> fileByKfsPath = new LinkedHashMap<>();
+        Map<String, String> drlByKfsPath = new LinkedHashMap<>();
+        for (Map.Entry<Path, String> entry : sources.entrySet()) {
+            String kfsPath = kfsPathFor(root, entry.getKey(), 
fileByKfsPath.size());
+            fileByKfsPath.put(kfsPath, entry.getKey());
+            drlByKfsPath.put(kfsPath, entry.getValue());
+        }
+        logger.info(() -> "Compiling " + sources.size() + " DRL file(s) for " 
+ saved
+                + (fullScope ? " (whole workspace)" : ""));
+
+        List<Map<String, Object>> messages = target.build(drlByKfsPath);
+
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        for (Path file : sources.keySet()) {
+            perFile.put(file, new ArrayList<>());
+        }
+        Path fallback = sources.containsKey(saved) ? saved : 
sources.keySet().stream().findFirst().orElse(null);
+        if (fallback != null) {
+            for (Map<String, Object> m : messages) {
+                Path file = fileFor(m.get("path"), fileByKfsPath, fallback);
+                List<Diagnostic> bucket = perFile.get(file);
+                if (bucket == null) {
+                    file = fallback;
+                    bucket = perFile.get(fallback);
+                }
+                String messageText = m.get("text") == null ? "" : 
String.valueOf(m.get("text"));
+                Diagnostic d = new Diagnostic();
+                d.setSeverity(severityOf(m.get("level")));
+                d.setSource(SOURCE);
+                d.setMessage(messageText);
+                d.setRange(BuildMessageRanges.rangeFor(messageText, 
intOf(m.get("line")), intOf(m.get("column")),
+                        sources.get(file)));
+                bucket.add(d);
+            }
+        }
+        perFile.replaceAll((file, list) -> cap(dedupe(dropCascades(list))));
+        return new Compilation(sources, perFile, skipped);
+    }
+
+    private Compilation failure(Path current, String text, Throwable t) {
+        Diagnostic d = new Diagnostic();
+        d.setSource(SOURCE);
+        String missing = missingClass(t);
+        if (missing != null) {
+            d.setSeverity(DiagnosticSeverity.Warning);
+            d.setMessage("The Drools compile needs the project's compiled 
classes: " + missing
+                    + " is not on the classpath. Build the project, then save 
again.");
+        } else {
+            d.setSeverity(DiagnosticSeverity.Error);
+            d.setMessage("Drools compile failed: " + summarize(t));
+        }
+        d.setRange(BuildMessageRanges.firstRuleRange(text));
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        perFile.put(current, List.of(d));
+        return new Compilation(Map.of(current, text), perFile, Set.of());
+    }
+
+    private boolean commit(Compilation result) {
+        boolean[] updated = {false};
+        result.perFile.forEach((file, list) -> {
+            String compiled = result.sources.get(file);
+            cached.compute(file, (k, previous) -> {
+                String now = openText.apply(file);
+                if (now != null && !now.equals(compiled)) {
+                    return previous;
+                }
+                updated[0] = true;
+                return list.isEmpty() ? null : List.copyOf(list);
+            });
+        });
+        for (Path file : result.skipped()) {
+            if (cached.remove(file) != null) {
+                updated[0] = true;
+            }
+        }
+        return updated[0];
+    }
+
+    private List<Path> scope(Path current, boolean fullScope) {
+        List<Path> files;
+        if (!fullScope) {
+            files = 
WorkspaceSiblingResolvers.active().resolveSiblings(current);
+        } else {
+            files = WorkspaceSiblingResolvers.active().workspaceDrlFiles();
+            if (files.isEmpty()) {
+                files = WorkspaceScan.of(rootFor(current)).drlFiles();

Review Comment:
   An empty list is also the valid authoritative result when VS Code enumerates 
no DRL files (for example, all are excluded). Falling back to a disk walk then 
reintroduces files filtered by `files.exclude`/ignore rules, contrary to the 
rebuild contract. Preserve whether the resolver has an authoritative workspace 
view separately from the list's emptiness.



##########
packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/CompileDiagnostics.java:
##########
@@ -0,0 +1,645 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.drools.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import org.drools.completion.DRLDiagnosticHelper;
+import org.drools.completion.WorkspaceScan;
+import org.drools.completion.WorkspaceSiblingResolvers;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.MessageType;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Compiles a saved DRL file together with its group through the project's own
+ * engine and keeps the engine's messages per file until the file is edited,
+ * saved again or regrouped. One compile runs at a time; a save during a 
compile
+ * queues one more and the latest wins.
+ */
+final class CompileDiagnostics {
+
+    static final String SOURCE = "drools";
+    static final String KFS_PREFIX = "src/main/resources/";
+    static final int MAX_DIAGNOSTICS_PER_FILE = 500;
+    static final String NOT_BUILT =
+            "Compile diagnostics need the project's compiled classes. Build 
the project, then save again.";
+
+    private static final Pattern NOISY_CASCADE = Pattern.compile(
+            
"(?i)(unknown\\s*type|cannot\\s*resolve\\s*type|unresolved\\s*type|unable\\s*to\\s*resolve\\s*type|class\\s*not\\s*found)");
+    private static final Logger logger = 
Logger.getLogger(CompileDiagnostics.class.getName());
+
+    private final DroolsLspServer server;
+    private final Function<Path, String> openText;
+    private final Map<Path, List<Diagnostic>> cached = new 
ConcurrentHashMap<>();
+    private final AtomicBoolean buildInFlight = new AtomicBoolean(false);
+    private final AtomicReference<PendingBuild> pending = new 
AtomicReference<>();
+    private final ExecutorService gateExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile-gate"));
+    private final ExecutorService buildExecutor = 
Executors.newSingleThreadExecutor(daemon("drools-lsp-compile"));
+    private volatile boolean onSave = true;
+    private final AtomicReference<Engine> engine = new AtomicReference<>();
+    private final AtomicBoolean notBuiltNoticeShown = new AtomicBoolean(false);
+
+    CompileDiagnostics(DroolsLspServer server, Function<Path, String> 
openText) {
+        this.server = server;
+        this.openText = openText;
+    }
+
+    boolean onSave() {
+        return onSave;
+    }
+
+    void setOnSave(boolean onSave) {
+        this.onSave = onSave;
+    }
+
+    Engine engine() {
+        return engine.get();
+    }
+
+    /** Installs the engine; the previous one is closed once the build in 
flight, if any, has finished. */
+    synchronized void setEngine(Engine next) {
+        if (buildExecutor.isShutdown()) {
+            next.close();
+            return;
+        }
+        Engine previous = engine.getAndSet(next);
+        if (previous == null || previous.available() != next.available()) {
+            logger.info(next.available()
+                    ? "Drools engine found on the project classpath; compile 
diagnostics are on"
+                    : "No usable Drools engine on the project classpath (needs 
drools-compiler and drools-mvel); "
+                            + "compile diagnostics are off");
+        }
+        if (previous != null && previous != next) {
+            buildExecutor.submit(previous::close);
+        }
+    }
+
+    /** Releases the engine and stops both executors; the close runs after any 
build in flight. */
+    synchronized void shutdown() {
+        Engine current = engine.getAndSet(null);
+        if (current != null) {
+            buildExecutor.submit(current::close);
+        }
+        gateExecutor.shutdown();
+        buildExecutor.shutdown();
+    }
+
+    List<Diagnostic> cachedFor(Path path) {
+        return path == null ? List.of() : cached.getOrDefault(normalize(path), 
List.of());
+    }
+
+    void invalidate(Path path) {
+        if (path != null) {
+            cached.remove(normalize(path));
+        }
+    }
+
+    void invalidateAll() {
+        cached.clear();
+    }
+
+    void onDidSave(Path path, String text) {
+        if (!onSave || path == null || text == null) {
+            return;
+        }
+        trigger(path, text, false);
+    }
+
+    void rebuildWorkspace(Path path) {
+        if (path == null) {
+            return;
+        }
+        String text = openText.apply(normalize(path));
+        if (text == null) {
+            text = readQuietly(path);
+        }
+        if (text == null) {
+            server.showMessage(MessageType.Warning, "Workspace compile 
skipped: cannot read " + path);
+            return;
+        }
+        trigger(path, text, true);
+    }
+
+    private void trigger(Path path, String text, boolean fullScope) {
+        gateExecutor.submit(() -> triggerNow(path, text, fullScope));
+    }
+
+    void triggerNow(Path path, String text, boolean fullScope) {
+        Engine current = engine.get();
+        if (current == null || !current.available()) {
+            logger.fine(() -> "No Drools engine on the project classpath; not 
compiling " + path);
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: no Drools engine on the project classpath");
+            }
+            return;
+        }
+        if (server.projectClassesMissing()) {
+            if (notBuiltNoticeShown.compareAndSet(false, true)) {
+                logger.info(NOT_BUILT);
+                server.showMessage(MessageType.Info, NOT_BUILT);
+            }
+            if (fullScope) {
+                server.showMessage(MessageType.Warning, "Workspace compile 
skipped: the project has not been built");
+            }
+            return;
+        }
+        notBuiltNoticeShown.set(false);
+        if (!fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.info("Compile skipped, " + path + " has syntax errors");
+            return;
+        }
+        if (!buildInFlight.compareAndSet(false, true)) {
+            pending.accumulateAndGet(new PendingBuild(path, text, fullScope),
+                    (old, next) -> old != null && old.fullScope && 
!next.fullScope
+                            ? new PendingBuild(next.path, next.text, true)
+                            : next);
+            drainPendingIfUnclaimed();
+            return;
+        }
+        submit(path, text, fullScope);
+    }
+
+    private void drainPendingIfUnclaimed() {
+        while (pending.get() != null) {
+            if (!buildInFlight.compareAndSet(false, true)) {
+                return;
+            }
+            PendingBuild queued = pending.getAndSet(null);
+            if (queued != null) {
+                submit(queued.path, queued.text, queued.fullScope);
+                return;
+            }
+            buildInFlight.set(false);
+        }
+    }
+
+    private void submit(Path path, String text, boolean fullScope) {
+        buildExecutor.submit(() -> {
+            Engine target = engine.get();
+            boolean updated = false;
+            boolean ran = false;
+            boolean noEngine = false;
+            int errors = 0;
+            int warnings = 0;
+            Compilation result = null;
+            try {
+                if (target == null || !target.available()) {
+                    noEngine = true;
+                    logger.fine(() -> "No Drools engine on the project 
classpath; not compiling " + path);
+                    return;
+                }
+                if (fullScope) {
+                    server.showMessage(MessageType.Info, "Compiling every DRL 
file in the workspace");
+                }
+                result = compile(normalize(path), text, fullScope, target);
+                updated = commit(result);
+                errors = result.count(DiagnosticSeverity.Error);
+                warnings = result.count(DiagnosticSeverity.Warning);
+                ran = true;
+            } catch (Throwable t) {
+                logger.log(Level.WARNING, "Drools compile failed for " + path, 
t);
+                result = failure(normalize(path), text, t);
+                updated = commit(result);
+                errors = 1;
+                ran = true;
+            } finally {
+                if (updated) {
+                    server.refreshDiagnostics();
+                }
+                if (fullScope) {
+                    if (noEngine) {
+                        server.showMessage(MessageType.Warning,
+                                "Workspace compile skipped: no Drools engine 
on the project classpath");
+                    } else if (ran) {
+                        server.showMessage(errors > 0 ? MessageType.Warning : 
MessageType.Info,
+                                summaryMessage(errors, warnings, result));
+                    }
+                }
+                buildInFlight.set(false);
+                PendingBuild queued = pending.getAndSet(null);
+                if (queued != null) {
+                    trigger(queued.path, queued.text, queued.fullScope);
+                }
+            }
+        });
+    }
+
+    private static String summaryMessage(int errors, int warnings, Compilation 
result) {
+        String base = "Workspace compile finished: " + errors + " error(s), " 
+ warnings + " warning(s)";
+        if (errors == 0 || result == null) {
+            return base;
+        }
+        return base + " in " + joinFileNames(result.filesWithErrors());
+    }
+
+    private static String joinFileNames(List<String> files) {
+        StringBuilder names = new StringBuilder();
+        int shown = Math.min(3, files.size());
+        for (int i = 0; i < shown; i++) {
+            if (i > 0) {
+                names.append(", ");
+            }
+            names.append(files.get(i));
+        }
+        if (files.size() > 3) {
+            names.append(" and ").append(files.size() - 3).append(" more");
+        }
+        return names.toString();
+    }
+
+    private Compilation compile(Path saved, String text, boolean fullScope, 
Engine target) throws Exception {
+        Path root = rootFor(saved);
+        Map<Path, String> sources = new LinkedHashMap<>();
+        Set<Path> skipped = new LinkedHashSet<>();
+        for (Path sibling : scope(saved, fullScope)) {
+            Path normalized = normalize(sibling);
+            if (normalized.equals(saved)) {
+                continue;
+            }
+            String siblingText = openText.apply(normalized);
+            if (siblingText == null) {
+                siblingText = readQuietly(normalized);
+            }
+            if (siblingText == null) {
+                skipped.add(normalized);
+                continue;
+            }
+            if (hasErrors(DRLDiagnosticHelper.parse(siblingText).diagnostics)) 
{
+                logger.fine(() -> "Leaving " + normalized + " out of the 
compile, it has syntax errors");
+                skipped.add(normalized);
+                continue;
+            }
+            sources.put(normalized, siblingText);
+        }
+        if (fullScope && 
hasErrors(DRLDiagnosticHelper.parse(text).diagnostics)) {
+            logger.fine(() -> "Leaving " + saved + " out of the compile, it 
has syntax errors");
+            skipped.add(saved);
+        } else {
+            sources.put(saved, text);
+        }
+
+        Map<String, Path> fileByKfsPath = new LinkedHashMap<>();
+        Map<String, String> drlByKfsPath = new LinkedHashMap<>();
+        for (Map.Entry<Path, String> entry : sources.entrySet()) {
+            String kfsPath = kfsPathFor(root, entry.getKey(), 
fileByKfsPath.size());
+            fileByKfsPath.put(kfsPath, entry.getKey());
+            drlByKfsPath.put(kfsPath, entry.getValue());
+        }
+        logger.info(() -> "Compiling " + sources.size() + " DRL file(s) for " 
+ saved
+                + (fullScope ? " (whole workspace)" : ""));
+
+        List<Map<String, Object>> messages = target.build(drlByKfsPath);
+
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        for (Path file : sources.keySet()) {
+            perFile.put(file, new ArrayList<>());
+        }
+        Path fallback = sources.containsKey(saved) ? saved : 
sources.keySet().stream().findFirst().orElse(null);
+        if (fallback != null) {
+            for (Map<String, Object> m : messages) {
+                Path file = fileFor(m.get("path"), fileByKfsPath, fallback);
+                List<Diagnostic> bucket = perFile.get(file);
+                if (bucket == null) {
+                    file = fallback;
+                    bucket = perFile.get(fallback);
+                }
+                String messageText = m.get("text") == null ? "" : 
String.valueOf(m.get("text"));
+                Diagnostic d = new Diagnostic();
+                d.setSeverity(severityOf(m.get("level")));
+                d.setSource(SOURCE);
+                d.setMessage(messageText);
+                d.setRange(BuildMessageRanges.rangeFor(messageText, 
intOf(m.get("line")), intOf(m.get("column")),
+                        sources.get(file)));
+                bucket.add(d);
+            }
+        }
+        perFile.replaceAll((file, list) -> cap(dedupe(dropCascades(list))));
+        return new Compilation(sources, perFile, skipped);
+    }
+
+    private Compilation failure(Path current, String text, Throwable t) {
+        Diagnostic d = new Diagnostic();
+        d.setSource(SOURCE);
+        String missing = missingClass(t);
+        if (missing != null) {
+            d.setSeverity(DiagnosticSeverity.Warning);
+            d.setMessage("The Drools compile needs the project's compiled 
classes: " + missing
+                    + " is not on the classpath. Build the project, then save 
again.");
+        } else {
+            d.setSeverity(DiagnosticSeverity.Error);
+            d.setMessage("Drools compile failed: " + summarize(t));
+        }
+        d.setRange(BuildMessageRanges.firstRuleRange(text));
+        Map<Path, List<Diagnostic>> perFile = new LinkedHashMap<>();
+        perFile.put(current, List.of(d));
+        return new Compilation(Map.of(current, text), perFile, Set.of());
+    }
+
+    private boolean commit(Compilation result) {
+        boolean[] updated = {false};
+        result.perFile.forEach((file, list) -> {
+            String compiled = result.sources.get(file);
+            cached.compute(file, (k, previous) -> {
+                String now = openText.apply(file);
+                if (now != null && !now.equals(compiled)) {
+                    return previous;
+                }
+                updated[0] = true;
+                return list.isEmpty() ? null : List.copyOf(list);
+            });
+        });
+        for (Path file : result.skipped()) {
+            if (cached.remove(file) != null) {
+                updated[0] = true;
+            }
+        }
+        return updated[0];
+    }
+
+    private List<Path> scope(Path current, boolean fullScope) {
+        List<Path> files;
+        if (!fullScope) {
+            files = 
WorkspaceSiblingResolvers.active().resolveSiblings(current);
+        } else {
+            files = WorkspaceSiblingResolvers.active().workspaceDrlFiles();
+            if (files.isEmpty()) {
+                files = WorkspaceScan.of(rootFor(current)).drlFiles();
+            }
+        }
+        return withoutBuildOutput(files);
+    }
+
+    /** Build output holds copies of the rules; compiling them next to their 
sources only duplicates every rule. */
+    private List<Path> withoutBuildOutput(List<Path> files) {
+        List<Path> outputDirs = new ArrayList<>();
+        for (Path dir : server.getBuildOutputDirs()) {
+            outputDirs.add(normalize(dir));
+        }
+        if (outputDirs.isEmpty()) {
+            return files;
+        }
+        List<Path> kept = new ArrayList<>(files.size());
+        for (Path file : files) {
+            Path normalized = normalize(file);
+            if (outputDirs.stream().noneMatch(normalized::startsWith)) {
+                kept.add(file);
+            }
+        }
+        return kept;
+    }
+
+    private Path rootFor(Path current) {
+        Path root = server.workspaceRoot();
+        return root != null ? normalize(root) : current.getParent();
+    }
+
+    static String kfsPathFor(Path root, Path file, int index) {
+        Path abs = normalize(file);
+        if (root != null) {
+            Path r = normalize(root);
+            if (abs.startsWith(r)) {
+                return KFS_PREFIX + r.relativize(abs).toString().replace('\\', 
'/');
+            }
+        }
+        return KFS_PREFIX + "external/" + index + "/" + abs.getFileName();
+    }
+
+    private static Path fileFor(Object pathValue, Map<String, Path> 
fileByKfsPath, Path fallback) {
+        if (pathValue == null) {
+            return fallback;
+        }
+        String p = String.valueOf(pathValue).replace('\\', '/');
+        if (p.isBlank()) {
+            return fallback;
+        }
+        Path exact = fileByKfsPath.get(p);
+        if (exact != null) {
+            return exact;
+        }
+        for (Map.Entry<String, Path> entry : fileByKfsPath.entrySet()) {
+            String key = entry.getKey();
+            if (p.endsWith("/" + key) || key.endsWith("/" + p) || 
p.equals(key)) {
+                return entry.getValue();
+            }
+        }
+        return fallback;
+    }
+
+    static List<Diagnostic> merge(List<Diagnostic> fast, List<Diagnostic> 
compiled) {
+        if (compiled == null || compiled.isEmpty()) {
+            return fast == null ? List.of() : fast;
+        }
+        List<Diagnostic> merged = new ArrayList<>();
+        Set<String> seen = new HashSet<>();
+        if (fast != null) {
+            for (Diagnostic d : fast) {
+                merged.add(d);
+                seen.add(mergeKey(d));
+            }
+        }
+        for (Diagnostic d : compiled) {
+            if (seen.add(mergeKey(d))) {
+                merged.add(d);
+            }
+        }
+        return merged;
+    }
+
+    private static String mergeKey(Diagnostic d) {
+        Range r = d.getRange();
+        String where = r == null ? "" : r.getStart().getLine() + ":" + 
r.getStart().getCharacter() + "-"
+                + r.getEnd().getLine() + ":" + r.getEnd().getCharacter();
+        return where + "|" + d.getSeverity() + "|" + d.getMessage();
+    }
+
+    private static List<Diagnostic> dropCascades(List<Diagnostic> in) {
+        boolean hasRootError = in.stream().anyMatch(d -> d.getSeverity() == 
DiagnosticSeverity.Error
+                && (d.getMessage() == null || 
!NOISY_CASCADE.matcher(d.getMessage()).find()));
+        if (!hasRootError) {
+            return in;
+        }
+        List<Diagnostic> out = new ArrayList<>(in.size());
+        for (Diagnostic d : in) {
+            if (d.getMessage() == null || 
!NOISY_CASCADE.matcher(d.getMessage()).find()) {
+                out.add(d);
+            }
+        }

Review Comment:
   This filter treats any non-type error anywhere in the file as a root cause 
and then removes every unresolved-type diagnostic in that file. For example, an 
unrelated broken consequence in one rule hides a genuine unknown type in 
another rule. Cascade suppression must correlate messages by range/rule/type, 
or retain the type diagnostic when that correlation is unavailable.



-- 
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