diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManager.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManager.java new file mode 100644 index 000000000000..03fd5e7992f9 --- /dev/null +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManager.java @@ -0,0 +1,205 @@ +/* + * 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.netbeans.modules.java.lsp.server.protocol; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import org.eclipse.lsp4j.ConfigurationItem; +import org.eclipse.lsp4j.ConfigurationParams; +import org.netbeans.api.project.FileOwnerQuery; +import org.netbeans.api.project.Project; +import org.netbeans.modules.java.lsp.server.Utils; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.Lookup; + +/** + * Manages configuration settings for LSP clients + * + * @author atalati + */ +public class ClientConfigurationManager { + + final ConfigValueCache cache = new ConfigValueCache(); + final NbCodeLanguageClient client; + + public ClientConfigurationManager(NbCodeLanguageClient client) { + this.client = client; + } + + public void registerConfigChangeListener(String section, BiConsumer consumer) { + cache.registerListener(section, consumer); + } + + public CompletableFuture getConfigurationUsingAltPrefix(String config) { + return lookupCacheAndGetConfig(List.of(config), null, true, true) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfigurationUsingAltPrefix(String config, String scope) { + return lookupCacheAndGetConfig(List.of(config), scope, true, true) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfigurationUsingAltPrefixWithoutCaching(String config) { + return lookupCacheAndGetConfig(List.of(config), null, false, false) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfigurationUsingAltPrefixWithoutCaching(String config, String scope) { + return lookupCacheAndGetConfig(List.of(config), null, false, false) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture> getConfigurationsUsingAltPrefix(List configs) { + return lookupCacheAndGetConfig(configs, null, true, true); + } + + public CompletableFuture getConfiguration(String config) { + return lookupCacheAndGetConfig(List.of(config), null, false, true) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfigurationWithoutCaching(String config) { + return lookupCacheAndGetConfig(List.of(config), null, false, false) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfigurationWithoutCaching(String config, String scope) { + return lookupCacheAndGetConfig(List.of(config), null, false, false) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture getConfiguration(String config, String scope) { + return lookupCacheAndGetConfig(List.of(config), scope, false, true) + .thenApply(result -> result.isEmpty() ? null : result.get(0)); + } + + public CompletableFuture> getConfigurations(List configs) { + return lookupCacheAndGetConfig(configs, null, false, true); + } + + public CompletableFuture> getConfigurations(List configs, String scope) { + return lookupCacheAndGetConfig(configs, scope, false, true); + } + + private CompletableFuture> lookupCacheAndGetConfig(List configs, String scope, boolean isAltPrefix, boolean isCachingRequired) { + final String configPrefix = isAltPrefix ? client.getNbCodeCapabilities().getAltConfigurationPrefix() : client.getNbCodeCapabilities().getConfigurationPrefix(); + List itemsToRequest = new ArrayList<>(); + List result = new ArrayList<>(); + String prjScope = getProjectFromFileScope(scope); + + for (int i = 0; i < configs.size(); i++) { + String config = configs.get(i); + String prefixedConfig = configPrefix + config; + JsonElement cachedValue = cache.getConfigValue(prefixedConfig, prjScope); + if (cachedValue != null) { + result.add(cachedValue); + } else { + ConfigurationItem item = new ConfigurationItem(); + if (scope != null) { + item.setScopeUri(scope); + } + item.setSection(prefixedConfig); + itemsToRequest.add(item); + result.add(null); + } + } + + if (itemsToRequest.isEmpty()) { + return CompletableFuture.completedFuture(result); + } + + return client.configuration(new ConfigurationParams(itemsToRequest)) + .thenApply(clientConfigs -> { + if (clientConfigs != null && clientConfigs.size() == result.size()) { + int j = 0; + for (int i = 0; i < result.size(); i++) { + if (result.get(i) == null) { + JsonElement value = (JsonElement) clientConfigs.get(j); + result.set(i, value); + String prefixedConfig = configPrefix + configs.get(i); + if (isCachingRequired) { + cache.cacheConfigValue(prefixedConfig, value, prjScope); + } + j++; + } + } + } else { + for (int i = 0; i < result.size(); i++) { + if (result.get(i) == null) { + result.set(i, new JsonObject()); + } + } + } + return result; + }); + } + + public void handleConfigurationChange(JsonObject settings) { + cache.updateCache(client, null, cache, settings); + } + + private String getProjectFromFileScope(String scope) { + try { + if (scope == null) { + return null; + } + FileObject fo = Utils.fromUri(scope); + if (fo == null) { + return null; + } + Project prj = FileOwnerQuery.getOwner(fo); + if (prj == null) { + return findWorkspaceFolder(fo); + } + + return Utils.toUri(prj.getProjectDirectory()); + } catch (MalformedURLException ignored) { + } + return null; + } + + // Copied from abstract class SingleFileOptionsQueryImpl + private String findWorkspaceFolder(FileObject file) { + Workspace workspace = Lookup.getDefault().lookup(Workspace.class); + if (workspace == null) { + return null; + } + for (FileObject workspaceFolder : workspace.getClientWorkspaceFolders()) { + if (FileUtil.isParentOf(workspaceFolder, file) || workspaceFolder == file) { + return Utils.toUri(workspaceFolder); + } + } + + //in case file is a source root, and the workspace folder is nested inside the root: + for (FileObject workspaceFolder : workspace.getClientWorkspaceFolders()) { + if (FileUtil.isParentOf(file, workspaceFolder)) { + return Utils.toUri(workspaceFolder); + } + } + + return null; + } +} diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCache.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCache.java new file mode 100644 index 000000000000..781553770957 --- /dev/null +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCache.java @@ -0,0 +1,302 @@ +/* + * 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.netbeans.modules.java.lsp.server.protocol; + +import com.google.gson.JsonElement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.eclipse.lsp4j.ConfigurationItem; +import org.eclipse.lsp4j.ConfigurationParams; + +/** + * Cache for configuration values with support for hierarchical keys and change + * notifications + * + * @author atalati + */ +public class ConfigValueCache { + + private final ConcurrentHashMap rootCache = new ConcurrentHashMap<>(); + private static final String ROOT_KEY_VALUE = ""; + private static final Logger LOG = Logger.getLogger(ConfigValueCache.class.getName()); + + public void registerListener(String section, BiConsumer listener) { + ConfigData configData = getCachedConfigData(section); + if (configData != null) { + configData.setConsumer(listener); + } else { + setConfigInTree(section, new ConfigData(listener)); + } + } + + public JsonElement getConfigValue(String section, String scope) { + ConfigData configData = getCachedConfigData(section); + if (configData == null) { + return null; + } else if (scope == null) { + return configData.getRootValue(); + } + return configData.getScopedValue(scope); + } + + public void updateCache(NbCodeLanguageClient client, String section, Object cacheValue, JsonElement tree) { + if (tree == null || cacheValue == null) { + return; + } + + ConfigData rootData; + ConfigValueCache currentCache = null; + + if (isConfigDataInstance(cacheValue)) { + rootData = (ConfigData) cacheValue; + } else if (isConfigValueInstance(cacheValue)) { + currentCache = (ConfigValueCache) cacheValue; + rootData = currentCache.getRootCacheValue(); + } else { + return; + } + + if (rootData != null) { + rootData.setRootValue(tree); + try { + BiConsumer consumer = rootData.getConsumer(); + if (consumer != null) { + consumer.accept(section, tree); + } + } catch (RuntimeException e) { + LOG.log(Level.SEVERE, "Exception occurred while calling config change consumer handler, config: {0} and excpetion: {1}", new Object[]{section, e.getMessage()}); + } + Map scopedValuesMap = rootData.getAllScopedValues(); + if (scopedValuesMap != null && !scopedValuesMap.isEmpty()) { + List configs = new ArrayList<>(); + scopedValuesMap.forEach((key, value) -> { + ConfigurationItem item = new ConfigurationItem(); + item.setScopeUri(key); + item.setSection(section); + configs.add(item); + }); + + client.configuration(new ConfigurationParams(configs)) + .thenAccept(results -> { + if (results != null) { + for (int i = 0; i < results.size(); i++) { + if (results.get(i) != null) { + String scopeUri = configs.get(i).getScopeUri(); + rootData.setScopedValue(scopeUri, (JsonElement) results.get(i)); + } + } + } + }); + } + } + + if (currentCache != null && tree.isJsonObject()) { + if (currentCache.getRootCacheValue() != null) { + currentCache.getRootCacheValue().setRootValue(tree); + } + Map entries + = new HashMap<>(tree.getAsJsonObject().asMap()); + for (Map.Entry entry : entries.entrySet()) { + Object child = currentCache.getCacheData(entry.getKey()); + String newSection = section != null ? section + "." + entry.getKey() : entry.getKey(); + updateCache(client, newSection, child, entry.getValue()); + } + } + } + + public void cacheConfigValue(String section, JsonElement value, String scope) { + registerCache(section); + ConfigData configData = getCachedConfigData(section); + if (configData != null) { + if (scope != null) { + configData.setScopedValue(scope, value); + } else { + configData.setRootValue(value); + } + } + } + + // Method used only in unit test + protected void registerCache(String section){ + ConfigData configData = getCachedConfigData(section); + if (configData == null) { + setConfigInTree(section, new ConfigData()); + } + } + + // Method used only in unit test + protected ConfigData getConfigData(String section) { + return getCachedConfigData(section); + } + + private ConfigData getCachedConfigData(String section) { + if (section == null || section.isEmpty()) { + return null; + } + + Object current = this; + String[] parts = section.split("\\."); + + for (int i = 0; i <= parts.length; i++) { + if (!isConfigValueInstance(current)) { + return i == parts.length ? (ConfigData) current : null; + } + if (i == parts.length) { + break; + } + + ConfigValueCache currentCache = (ConfigValueCache) current; + current = currentCache.getCacheData(parts[i]); + + if (current == null) { + return null; + } + } + + if (isConfigValueInstance(current)) { + return ((ConfigValueCache) current).getRootCacheValue(); + } + + return isConfigDataInstance(current) ? (ConfigData) current : null; + } + + private Object getCacheData(String section) { + return rootCache.get(section); + } + + private ConfigData getRootCacheValue() { + return (ConfigData) rootCache.get(ROOT_KEY_VALUE); + } + + private void setRootCacheValue(ConfigData configData) { + rootCache.put(ROOT_KEY_VALUE, configData); + } + + private Object put(String section, Object value) { + return rootCache.merge(section, value, (oldValue, newValue) -> { + if (value == null) { + return null; + } else if (isConfigValueInstance(oldValue) && isConfigDataInstance(newValue)) { + ((ConfigValueCache) oldValue).setRootCacheValue((ConfigData) newValue); + return oldValue; + } else if (isConfigDataInstance(oldValue) && isConfigValueInstance(newValue)) { + ((ConfigValueCache) newValue).setRootCacheValue((ConfigData) oldValue); + } + return newValue; + }); + } + + private void setConfigInTree(String section, ConfigData configData) { + if (section == null || section.isEmpty() || configData == null) { + return; + } + + Object current = this; + String[] parts = section.split("\\."); + + for (String part : Arrays.asList(parts).subList(0, parts.length - 1)) { + if (!isConfigValueInstance(current)) { + return; + } + + Object child = ((ConfigValueCache) current).getCacheData(part); + if (isConfigValueInstance(child)) { + current = child; + } else { + ConfigValueCache configValueCache = new ConfigValueCache(); + if (child != null) { + configValueCache.setRootCacheValue((ConfigData) child); + } + current = ((ConfigValueCache) current).put(part, configValueCache); + } + } + if (!isConfigValueInstance(current)) { + return; + } + ((ConfigValueCache) current).put(parts[parts.length - 1], configData); + } + + private boolean isConfigValueInstance(Object o) { + return o instanceof ConfigValueCache; + } + + private boolean isConfigDataInstance(Object o) { + return o instanceof ConfigData; + } + + // protected due to unit test requirements + protected class ConfigData { + + private JsonElement rootValue; + private BiConsumer consumer; + private final ConcurrentHashMap scopedValues = new ConcurrentHashMap<>(); + + public ConfigData() { + } + + public ConfigData(BiConsumer consumer) { + this(null, consumer); + } + + public ConfigData(JsonElement value) { + this(value, null); + } + + public ConfigData(JsonElement value, BiConsumer consumer) { + this.rootValue = value; + this.consumer = consumer; + } + + public JsonElement getRootValue() { + return rootValue; + } + + public void setRootValue(JsonElement value) { + this.rootValue = value; + } + + public BiConsumer getConsumer() { + return consumer; + } + + public void setConsumer(BiConsumer consumer) { + this.consumer = consumer; + } + + public JsonElement getScopedValue(String fo) { + return scopedValues.get(fo); + } + + public void setScopedValue(String fo, JsonElement value) { + this.scopedValues.put(fo, value); + } + + public Map getAllScopedValues() { + return Collections.unmodifiableMap(new HashMap<>(scopedValues)); + } + } +} diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java index d82646afb16c..dd552b369106 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java @@ -20,24 +20,19 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; -import org.eclipse.lsp4j.ConfigurationItem; -import org.eclipse.lsp4j.ConfigurationParams; import org.eclipse.lsp4j.MessageType; import org.eclipse.lsp4j.services.LanguageClient; import org.netbeans.api.java.queries.CompilerOptionsQuery; @@ -166,15 +161,13 @@ private boolean isEnablePreivew(FileObject source, String prjType) { if (client == null) { return false; } - AtomicBoolean isEnablePreviewSet = new AtomicBoolean(false); - ConfigurationItem conf = new ConfigurationItem(); - conf.setSection(client.getNbCodeCapabilities().getAltConfigurationPrefix() + "runConfig.vmOptions"); - client.configuration(new ConfigurationParams(Collections.singletonList(conf))).thenAccept(c -> { - String config = ((JsonPrimitive) ((List) c).get(0)).getAsString(); - isEnablePreviewSet.set(config.contains(this.ENABLE_PREVIEW)); + boolean[] isEnablePreviewSet = {false}; + client.getClientConfigurationManager().getConfigurationUsingAltPrefix("runConfig.vmOptions").thenAccept(c -> { + isEnablePreviewSet[0] = c != null && !c.getAsJsonArray().isEmpty() + && c.getAsJsonArray().get(0).getAsString().contains(ENABLE_PREVIEW); }); - return isEnablePreviewSet.get(); + return isEnablePreviewSet[0]; } Result result = CompilerOptionsQuery.getOptions(source); diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientWrapper.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientWrapper.java index a0c4f672ca32..e99137e69acc 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientWrapper.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientWrapper.java @@ -54,10 +54,12 @@ class NbCodeClientWrapper implements NbCodeLanguageClient { private final NbCodeLanguageClient remote; private volatile NbCodeClientCapabilities clientCaps; + private final ClientConfigurationManager confManager; public NbCodeClientWrapper(NbCodeLanguageClient remote) { this.remote = remote; this.clientCaps = new NbCodeClientCapabilities(); + this.confManager = new ClientConfigurationManager(this); } public void setClientCaps(NbCodeClientCapabilities clientCaps) { @@ -243,5 +245,9 @@ public CompletableFuture closeOutput(String outputName) { public CompletableFuture resetOutput(String outputName) { return remote.resetOutput(outputName); } - + + @Override + public ClientConfigurationManager getClientConfigurationManager() { + return confManager; + } } diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java index 767edb696c28..01cdb6182cf0 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java @@ -138,6 +138,12 @@ public interface NbCodeLanguageClient extends LanguageClient { * @return code capabilities. */ public NbCodeClientCapabilities getNbCodeCapabilities(); + + /** + * Returns client configuration manager + * @return ClientConfigurationManager + */ + public ClientConfigurationManager getClientConfigurationManager(); public default boolean isRequestDispatcherThread() { return Boolean.TRUE.equals(Server.DISPATCHERS.get()); diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java index 8da9733288d3..9607cbcb42a8 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java @@ -59,8 +59,6 @@ import org.eclipse.lsp4j.CodeActionOptions; import org.eclipse.lsp4j.CodeLensOptions; import org.eclipse.lsp4j.CompletionOptions; -import org.eclipse.lsp4j.ConfigurationItem; -import org.eclipse.lsp4j.ConfigurationParams; import org.eclipse.lsp4j.ExecuteCommandOptions; import org.eclipse.lsp4j.FoldingRangeProviderOptions; import org.eclipse.lsp4j.InitializeParams; @@ -958,6 +956,7 @@ private InitializeResult constructInitResponse(InitializeParams init, JavaSource public CompletableFuture initialize(InitializeParams init) { NbCodeClientCapabilities capa = NbCodeClientCapabilities.get(init); client.setClientCaps(capa); + workspaceService.registerConfigChangeListeners(); hackConfigureGroovySupport(capa); hackNoReuseOfOutputsForAntProjects(); List projectCandidates = new ArrayList<>(); @@ -1052,44 +1051,49 @@ private void collectProjectCandidates(FileObject fo, List candidates private void initializeOptions() { getWorkspaceProjects().thenAccept(projects -> { - ConfigurationItem item = new ConfigurationItem(); - // PENDING: what about doing just one roundtrip to the client- we may request multiple ConfiguratonItems in one message ? - item.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_JAVA_HINTS); - client.configuration(new ConfigurationParams(Collections.singletonList(item))).thenAccept(c -> { - if (c != null && !c.isEmpty() && c.get(0) instanceof JsonObject) { - textDocumentService.updateJavaHintPreferences((JsonObject) c.get(0)); - } - else { - textDocumentService.hintsSettingsRead = true; - textDocumentService.reRunDiagnostics(); - } - }); - item.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_PROJECT_JDKHOME); - client.configuration(new ConfigurationParams(Collections.singletonList(item))).thenAccept(c -> { - JsonPrimitive newProjectJDKHomePath = null; + List defaultConfigs = List.of(NETBEANS_JAVA_HINTS, NETBEANS_PROJECT_JDKHOME); + List projectConfigs = List.of(NETBEANS_FORMAT, NETBEANS_JAVA_IMPORTS); + + final FileObject projectDirectory = projects != null && projects.length > 0 ? projects[0].getProjectDirectory(): null; + + List allConfigs = new ArrayList<>(defaultConfigs); + if (projectDirectory != null) { + allConfigs.addAll(projectConfigs); + } + + client.getClientConfigurationManager().getConfigurations( + allConfigs, + projectDirectory != null ? Utils.toUri(projectDirectory) : null + ).thenAccept(configs -> { + if (configs != null && !configs.isEmpty()) { + if (configs.get(0) instanceof JsonObject) { + textDocumentService.updateJavaHintPreferences((JsonObject) configs.get(0)); + } else { + textDocumentService.hintsSettingsRead = true; + textDocumentService.reRunDiagnostics(); + } - if (c != null && !c.isEmpty() && c.get(0) instanceof JsonPrimitive) { - newProjectJDKHomePath = (JsonPrimitive) c.get(0); + JsonPrimitive newProjectJDKHomePath = null; + if (configs.size() > 1 && configs.get(1) instanceof JsonPrimitive) { + newProjectJDKHomePath = (JsonPrimitive) configs.get(1); + } + textDocumentService.updateProjectJDKHome(newProjectJDKHomePath); + + if (projectDirectory != null) { + if (configs.size() > 2 && configs.get(2) instanceof JsonObject) { + workspaceService.updateJavaFormatPreferences(projectDirectory, (JsonObject) configs.get(2)); + } + + if (configs.size() > 3 && configs.get(3) instanceof JsonObject) { + workspaceService.updateJavaImportPreferences(projectDirectory, (JsonObject) configs.get(3)); + } + } } else { + textDocumentService.hintsSettingsRead = true; + textDocumentService.reRunDiagnostics(); + textDocumentService.updateProjectJDKHome(null); } - textDocumentService.updateProjectJDKHome(newProjectJDKHomePath); }); - if (projects != null && projects.length > 0) { - FileObject fo = projects[0].getProjectDirectory(); - item.setScopeUri(Utils.toUri(fo)); - item.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_FORMAT); - client.configuration(new ConfigurationParams(Collections.singletonList(item))).thenAccept(c -> { - if (c != null && !c.isEmpty() && c.get(0) instanceof JsonObject) { - workspaceService.updateJavaFormatPreferences(fo, (JsonObject) c.get(0)); - } - }); - item.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_JAVA_IMPORTS); - client.configuration(new ConfigurationParams(Collections.singletonList(item))).thenAccept(c -> { - if (c != null && !c.isEmpty() && c.get(0) instanceof JsonObject) { - workspaceService.updateJavaImportPreferences(fo, (JsonObject) c.get(0)); - } - }); - } }); } @@ -1252,6 +1256,7 @@ public List getClientWorkspaceFolders() { static final NbCodeLanguageClient STUB_CLIENT = new NbCodeLanguageClient() { private final NbCodeClientCapabilities caps = new NbCodeClientCapabilities(); + private final ClientConfigurationManager confManager = new ClientConfigurationManager(this); private void logWarning(Object... args) { LOG.log(Level.WARNING, "LSP Client called without proper context with param(s): {0}", @@ -1390,6 +1395,11 @@ public CompletableFuture resetOutput(String outputName) { logWarning("Reset output: " + outputName); //NOI18N return CompletableFuture.completedFuture(null); } + + @Override + public ClientConfigurationManager getClientConfigurationManager() { + return confManager; + } }; diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/TextDocumentServiceImpl.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/TextDocumentServiceImpl.java index 969e230c9c17..db008f570c0f 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/TextDocumentServiceImpl.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/TextDocumentServiceImpl.java @@ -114,8 +114,6 @@ import org.eclipse.lsp4j.CompletionItemTag; import org.eclipse.lsp4j.CompletionList; import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.ConfigurationItem; -import org.eclipse.lsp4j.ConfigurationParams; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DiagnosticSeverity; @@ -371,7 +369,7 @@ public void indexingComplete(Set indexedRoots) { private static final int DEFAULT_COMPLETION_WARNING_LENGTH = 10_000; private static final RequestProcessor COMPLETION_SAMPLER_WORKER = new RequestProcessor("java-lsp-completion-sampler", 1, false, false); private static final AtomicReference RUNNING_SAMPLER = new AtomicReference<>(); - + @Override @Messages({ "# {0} - the timeout elapsed", @@ -422,24 +420,16 @@ public CompletableFuture, CompletionList>> completio return CompletableFuture.completedFuture(Either.forRight(completionList)); } StyledDocument doc = (StyledDocument)rawDoc; - ConfigurationItem conf = new ConfigurationItem(); - conf.setScopeUri(uri); - conf.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_JAVADOC_LOAD_TIMEOUT); - ConfigurationItem completionWarningLength = new ConfigurationItem(); - completionWarningLength.setScopeUri(uri); - completionWarningLength.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_COMPLETION_WARNING_TIME); - ConfigurationItem commitCharacterConfig = new ConfigurationItem(); - commitCharacterConfig.setScopeUri(uri); - commitCharacterConfig.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_CODE_COMPLETION_COMMIT_CHARS); - return client.configuration(new ConfigurationParams(Arrays.asList(conf, completionWarningLength, commitCharacterConfig))).thenApply(c -> { + List configValues = List.of(NETBEANS_JAVADOC_LOAD_TIMEOUT, NETBEANS_COMPLETION_WARNING_TIME, NETBEANS_CODE_COMPLETION_COMMIT_CHARS); + return client.getClientConfigurationManager().getConfigurations(configValues, uri).thenApply(c -> { if (c != null && !c.isEmpty()) { - if (c.get(0) instanceof JsonPrimitive) { - JsonPrimitive javadocTimeSetting = (JsonPrimitive) c.get(0); + if (c.get(0).isJsonPrimitive()) { + JsonPrimitive javadocTimeSetting = c.get(0).getAsJsonPrimitive(); javadocTimeout.set(javadocTimeSetting.getAsInt()); } - if (c.get(1) instanceof JsonPrimitive) { - JsonPrimitive samplingWarningsLengthSetting = (JsonPrimitive) c.get(1); + if (c.get(1).isJsonPrimitive()) { + JsonPrimitive samplingWarningsLengthSetting = c.get(1).getAsJsonPrimitive(); samplingWarningLength.set(samplingWarningsLengthSetting.getAsLong()); } @@ -1937,11 +1927,9 @@ public CompletableFuture> willSaveWaitUntil(WillSaveTextDocumentP if (js == null) { return CompletableFuture.completedFuture(Collections.emptyList()); } - ConfigurationItem conf = new ConfigurationItem(); - conf.setScopeUri(uri); - conf.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_JAVA_ON_SAVE_ORGANIZE_IMPORTS); - return client.configuration(new ConfigurationParams(Collections.singletonList(conf))).thenApply(c -> { - if (c != null && !c.isEmpty() && ((JsonPrimitive) c.get(0)).getAsBoolean()) { + + return client.getClientConfigurationManager().getConfiguration(NETBEANS_JAVA_ON_SAVE_ORGANIZE_IMPORTS, uri).thenApply(c -> { + if (c != null && c.isJsonPrimitive() && c.getAsJsonPrimitive().getAsBoolean()) { try { List edits = TextDocumentServiceImpl.modify2TextEdits(js, wc -> { wc.toPhase(JavaSource.Phase.RESOLVED); @@ -2828,10 +2816,8 @@ protected CallHierarchyOutgoingCall createResultItem(CallHierarchyItem item, Lis @Override public CompletableFuture> inlayHint(InlayHintParams params) { String uri = params.getTextDocument().getUri(); - ConfigurationItem conf = new ConfigurationItem(); - conf.setScopeUri(uri); - conf.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + NETBEANS_INLAY_HINT); - return client.configuration(new ConfigurationParams(Collections.singletonList(conf))).thenCompose(c -> { + + return client.getClientConfigurationManager().getConfiguration(NETBEANS_INLAY_HINT, uri).thenCompose(c -> { FileObject file; try { file = Utils.fromUri(uri); @@ -2839,13 +2825,13 @@ public CompletableFuture> inlayHint(InlayHintParams params) { return CompletableFuture.failedFuture(ex); } Set enabled = null; - if (c != null && !c.isEmpty()) { + if (c != null && c.isJsonArray()) { enabled = new HashSet<>(); - JsonArray actualSettings = ((JsonArray) c.get(0)); + JsonArray actualSettings = c.getAsJsonArray(); for (JsonElement el : actualSettings) { - enabled.add(((JsonPrimitive) el).getAsString()); + enabled.add(el.getAsJsonPrimitive().getAsString()); } } org.netbeans.api.lsp.Range range = new org.netbeans.api.lsp.Range(Utils.getOffset(file, params.getRange().getStart()), diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceServiceImpl.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceServiceImpl.java index eaff1013cdf0..fc273780e7b2 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceServiceImpl.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceServiceImpl.java @@ -1374,37 +1374,62 @@ private static String getSimpleName ( @Override public void didChangeConfiguration(DidChangeConfigurationParams params) { - String fullConfigPrefix = client.getNbCodeCapabilities().getConfigurationPrefix(); - String configPrefix = fullConfigPrefix.substring(0, fullConfigPrefix.length() - 1); - server.openedProjects().thenAccept(projects -> { - // PENDING: invent a pluggable mechanism for this, this does not scale and the typecast to serviceImpl is ugly - ((TextDocumentServiceImpl)server.getTextDocumentService()).updateJavaHintPreferences(((JsonObject) params.getSettings()).getAsJsonObject(configPrefix).getAsJsonObject(NETBEANS_JAVA_HINTS)); - ((TextDocumentServiceImpl)server.getTextDocumentService()).updateProjectJDKHome(((JsonObject) params.getSettings()).getAsJsonObject(configPrefix).getAsJsonObject("project").getAsJsonPrimitive("jdkhome")); - if (projects != null && projects.length > 0) { - updateJavaFormatPreferences(projects[0].getProjectDirectory(), ((JsonObject) params.getSettings()).getAsJsonObject(configPrefix).getAsJsonObject("format")); - updateJavaImportPreferences(projects[0].getProjectDirectory(), ((JsonObject) params.getSettings()).getAsJsonObject(configPrefix).getAsJsonObject("java").getAsJsonObject("imports")); - } - }); - String fullAltConfigPrefix = client.getNbCodeCapabilities().getAltConfigurationPrefix(); - String altConfigPrefix = fullAltConfigPrefix.substring(0, fullAltConfigPrefix.length() - 1); - boolean modified = false; - String newVMOptions = ""; - String newWorkingDirectory = null; - JsonObject javaPlus = ((JsonObject) params.getSettings()).getAsJsonObject(altConfigPrefix); - if (javaPlus != null) { - JsonObject runConfig = javaPlus.getAsJsonObject("runConfig"); + client.getClientConfigurationManager().handleConfigurationChange((JsonObject)params.getSettings()); + } + + private BiConsumer getRunConfigChangeListener() { + return (config, newValue) -> { + boolean modified = false; + String newVMOptions = ""; + String newWorkingDirectory = null; + JsonObject runConfig = newValue.isJsonObject() ? newValue.getAsJsonObject() : null; if (runConfig != null) { newVMOptions = runConfig.getAsJsonPrimitive("vmOptions").getAsString(); JsonPrimitive cwd = runConfig.getAsJsonPrimitive("cwd"); newWorkingDirectory = cwd != null ? cwd.getAsString() : null; } - } - for (SingleFileOptionsQueryImpl query : Lookup.getDefault().lookupAll(SingleFileOptionsQueryImpl.class)) { - modified |= query.setConfiguration(workspace, newVMOptions, newWorkingDirectory); - } - if (modified) { - ((TextDocumentServiceImpl)server.getTextDocumentService()).reRunDiagnostics(); - } + for (SingleFileOptionsQueryImpl query : Lookup.getDefault().lookupAll(SingleFileOptionsQueryImpl.class)) { + modified |= query.setConfiguration(workspace, newVMOptions, newWorkingDirectory); + } + if (modified) { + ((TextDocumentServiceImpl) server.getTextDocumentService()).reRunDiagnostics(); + } + }; + } + + void registerConfigChangeListeners() { + String fullConfigPrefix = client.getNbCodeCapabilities().getConfigurationPrefix(); + String fullAltConfigPrefix = client.getNbCodeCapabilities().getAltConfigurationPrefix(); + ClientConfigurationManager confManager = client.getClientConfigurationManager(); + + BiConsumer formatPrefsListener = (config, newValue) + -> server.openedProjects().thenAccept(projects -> { + if (projects != null && projects.length > 0) { + updateJavaFormatPreferences(projects[0].getProjectDirectory(), newValue.getAsJsonObject()); + } + }); + + BiConsumer importPrefsListener = (config, newValue) + -> server.openedProjects().thenAccept(projects -> { + if (projects != null && projects.length > 0) { + updateJavaImportPreferences(projects[0].getProjectDirectory(), newValue.getAsJsonObject()); + } + }); + + // PENDING: The typecast to serviceImpl is ugly + BiConsumer hintPrefsListener = (config, newValue) + -> ((TextDocumentServiceImpl) server.getTextDocumentService()).updateJavaHintPreferences(newValue.getAsJsonObject()); + + BiConsumer projectJdkHomeListener = (config, newValue) + -> ((TextDocumentServiceImpl) server.getTextDocumentService()).updateProjectJDKHome(newValue.getAsJsonPrimitive()); + + + + confManager.registerConfigChangeListener(fullConfigPrefix + "hints", hintPrefsListener); + confManager.registerConfigChangeListener(fullConfigPrefix + "project.jdkhome", projectJdkHomeListener); + confManager.registerConfigChangeListener(fullConfigPrefix + "format", formatPrefsListener); + confManager.registerConfigChangeListener(fullConfigPrefix + "java.imports", importPrefsListener); + confManager.registerConfigChangeListener(fullAltConfigPrefix + "runConfig", getRunConfigChangeListener()); } void updateJavaFormatPreferences(FileObject fo, JsonObject configuration) { diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/singlesourcefile/EnablePreviewSingleSourceFile.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/singlesourcefile/EnablePreviewSingleSourceFile.java index 019e46d782d9..9cb55c7c1367 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/singlesourcefile/EnablePreviewSingleSourceFile.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/singlesourcefile/EnablePreviewSingleSourceFile.java @@ -23,8 +23,6 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.eclipse.lsp4j.ConfigurationItem; -import org.eclipse.lsp4j.ConfigurationParams; import org.netbeans.api.annotations.common.NonNull; import org.netbeans.api.project.FileOwnerQuery; import org.openide.filesystems.FileObject; @@ -63,11 +61,8 @@ public void enablePreview(String newSourceLevel) throws Exception { return ; } - ConfigurationItem conf = new ConfigurationItem(); - conf.setScopeUri(Utils.toUri(file)); - conf.setSection(client.getNbCodeCapabilities().getAltConfigurationPrefix() + "runConfig.vmOptions"); - client.configuration(new ConfigurationParams(Collections.singletonList(conf))).thenApply(c -> { - String compilerArgs = ((JsonPrimitive) ((List) c).get(0)).getAsString(); + client.getClientConfigurationManager().getConfigurationUsingAltPrefix("runConfig.vmOptions", Utils.toUri(file)).thenApply(c -> { + String compilerArgs = ((JsonPrimitive) c).getAsString(); if (compilerArgs == null) { compilerArgs = ""; } diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/TestCodeLanguageClient.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/TestCodeLanguageClient.java index 31f9a128ece6..788d46ec5ea9 100644 --- a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/TestCodeLanguageClient.java +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/TestCodeLanguageClient.java @@ -43,6 +43,7 @@ import org.netbeans.modules.java.lsp.server.input.ShowInputBoxParams; import org.netbeans.modules.java.lsp.server.input.ShowMutliStepInputParams; import org.netbeans.modules.java.lsp.server.input.ShowQuickPickParams; +import org.netbeans.modules.java.lsp.server.protocol.ClientConfigurationManager; import org.netbeans.modules.java.lsp.server.protocol.OutputMessage; import org.netbeans.modules.java.lsp.server.protocol.SaveDocumentRequestParams; import org.netbeans.modules.java.lsp.server.protocol.ShowStatusMessageParams; @@ -124,6 +125,11 @@ public void disposeTextEditorDecoration(String params) { public NbCodeClientCapabilities getNbCodeCapabilities() { throw new UnsupportedOperationException(); } + + @Override + public ClientConfigurationManager getClientConfigurationManager() { + throw new UnsupportedOperationException(); + } @Override public void telemetryEvent(Object params) { diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/explorer/ProjectViewTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/explorer/ProjectViewTest.java index a8edbcb45bb9..eab4f007ffee 100644 --- a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/explorer/ProjectViewTest.java +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/explorer/ProjectViewTest.java @@ -77,6 +77,7 @@ import org.netbeans.modules.java.lsp.server.input.ShowInputBoxParams; import org.netbeans.modules.java.lsp.server.input.ShowMutliStepInputParams; import org.netbeans.modules.java.lsp.server.input.ShowQuickPickParams; +import org.netbeans.modules.java.lsp.server.protocol.ClientConfigurationManager; import org.netbeans.modules.java.lsp.server.protocol.OutputMessage; import org.netbeans.modules.java.lsp.server.protocol.SaveDocumentRequestParams; import org.netbeans.modules.java.lsp.server.protocol.SetTextEditorDecorationParams; @@ -150,6 +151,7 @@ class LspClient implements NbCodeLanguageClient { Semaphore nodeChanges = new Semaphore(0); NbCodeClientCapabilities caps = new NbCodeClientCapabilities(); List loggedMessages = new ArrayList<>(); + ClientConfigurationManager confManager = new ClientConfigurationManager(this); @Override public CompletableFuture createProgress(WorkDoneProgressCreateParams params) { @@ -306,6 +308,11 @@ public CompletableFuture closeOutput(String outputName) { public CompletableFuture resetOutput(String outputName) { return CompletableFuture.completedFuture(null); } + + @Override + public ClientConfigurationManager getClientConfigurationManager() { + return confManager; + } } private static Launcher createLauncher(NbCodeLanguageClient client, InputStream in, OutputStream out, diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManagerTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManagerTest.java new file mode 100644 index 000000000000..d862335c841d --- /dev/null +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ClientConfigurationManagerTest.java @@ -0,0 +1,234 @@ +/* + * 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.netbeans.modules.java.lsp.server.protocol; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.ConfigurationParams; +import org.junit.Test; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.java.lsp.server.TestCodeLanguageClient; + +public class ClientConfigurationManagerTest extends NbTestCase { + private MockClient mockClient; + + public ClientConfigurationManagerTest(String name) { + super(name); + } + + @Override + protected void setUp() throws Exception { + mockClient = new MockClient(); + } + + @Test + public void testGetConfiguration() throws InterruptedException, ExecutionException { + String section = "project.jdkhome"; + JsonElement expectedValue = new JsonPrimitive("test JDK path"); + mockClient.addConfig(mockClient.codeCapa.getConfigurationPrefix() + section, expectedValue); + + CompletableFuture future = mockClient.getClientConfigurationManager().getConfiguration(section); + JsonElement actualValue = future.get(); + + assertEquals("Config value mismatch", expectedValue, actualValue); + } + + @Test + public void testGetConfigurationWithScope() throws InterruptedException, ExecutionException, URISyntaxException { + String section = "project.jdkhome"; + JsonElement expectedValue = new JsonPrimitive("test JDK path"); + URI expectedScope = new URI("file://scope1"); + mockClient.addConfig(mockClient.codeCapa.getConfigurationPrefix() + section, expectedValue); + + CompletableFuture future = mockClient.getClientConfigurationManager().getConfiguration(section, expectedScope.toString()); + JsonElement actualValue = future.get(); + + assertEquals("Scoped value mismatch", expectedValue, actualValue.getAsJsonObject().get("value")); + assertEquals("Scope URI mismatch", expectedScope.toString(), actualValue.getAsJsonObject().get("scope").getAsString()); + } + + @Test + public void testGetAltConfiguration() throws InterruptedException, ExecutionException { + String section = "project.jdkhome"; + JsonElement expectedValue = new JsonPrimitive("test JDK path"); + mockClient.addConfig(mockClient.codeCapa.getAltConfigurationPrefix() + section, expectedValue); + + CompletableFuture future = mockClient.getClientConfigurationManager().getConfigurationUsingAltPrefix(section); + JsonElement actualValue = future.get(); + + assertEquals("Alt config value mismatch", expectedValue, actualValue); + } + + @Test + public void testGetConfigurations() throws ExecutionException, InterruptedException { + List configKeys = List.of("format.enabled", "debug.enabled"); + List configValues = List.of(new JsonPrimitive(Boolean.TRUE), new JsonPrimitive(Boolean.FALSE)); + + for (int i = 0; i < configValues.size(); i++) { + mockClient.addConfig(mockClient.codeCapa.getConfigurationPrefix() + configKeys.get(i), configValues.get(i)); + } + + CompletableFuture> future = mockClient.getClientConfigurationManager().getConfigurations(configKeys); + List results = future.get(); + + assertEquals("Config list size mismatch", configValues.size(), results.size()); + for (int i = 0; i < configValues.size(); i++) { + assertEquals("Config value mismatch at index " + i, configValues.get(i), results.get(i)); + } + } + + @Test + public void testGetConfigurationsWithScope() throws ExecutionException, InterruptedException, URISyntaxException { + List configKeys = List.of("format.enabled", "debug.enabled"); + List configValues = List.of(new JsonPrimitive(Boolean.TRUE), new JsonPrimitive(Boolean.FALSE)); + URI expectedScope = new URI("file://scope1"); + + for (int i = 0; i < configValues.size(); i++) { + mockClient.addConfig(mockClient.codeCapa.getConfigurationPrefix() + configKeys.get(i), configValues.get(i)); + } + + CompletableFuture> future = mockClient.getClientConfigurationManager().getConfigurations(configKeys, expectedScope.toString()); + List results = future.get(); + + assertEquals("Scoped config list size mismatch", configValues.size(), results.size()); + for (int i = 0; i < configValues.size(); i++) { + assertEquals("Scoped value mismatch at index " + i, configValues.get(i), results.get(i).getAsJsonObject().get("value")); + assertEquals("Scope URI mismatch at index " + i, expectedScope.toString(), results.get(i).getAsJsonObject().get("scope").getAsString()); + } + } + + @Test + public void testRegisterConfigListener() { + String expectedSection = "project.jdkhome"; + JsonPrimitive expectedValue = new JsonPrimitive("New Value"); + + BiConsumer listener = (actualSection, actualValue) -> { + assertEquals("Section mismatch in listener", expectedSection, actualSection); + assertEquals("Value mismatch in listener", expectedValue, actualValue); + }; + + mockClient.getClientConfigurationManager().registerConfigChangeListener(expectedSection, listener); + + mockClient.addConfig(expectedSection, new JsonPrimitive("Old Value")); + JsonObject newConfigTree = mockClient.updateSectionAndGetDeepCopy(expectedSection, expectedValue); + mockClient.getClientConfigurationManager().handleConfigurationChange(newConfigTree); + } + + private class MockClient extends TestCodeLanguageClient { + + NbCodeClientCapabilities codeCapa = new NbCodeClientCapabilities(); + JsonObject rootConfiguration = new JsonObject(); + ClientConfigurationManager confManager; + + public MockClient() { + codeCapa.setConfigurationPrefix("jdk"); + codeCapa.setAltConfigurationPrefix("java+"); + confManager = new ClientConfigurationManager(this); + } + + public void addConfig(String section, JsonElement value) { + addConfigToObject(rootConfiguration, section, value); + } + + public JsonObject updateSectionAndGetDeepCopy(String section, JsonElement newValue) { + JsonObject obj = rootConfiguration.deepCopy(); + addConfigToObject(obj, section, newValue); + return obj; + } + + public JsonElement getConfigurationValue(String section) { + String[] keys = section.split("\\."); + JsonObject current = rootConfiguration; + + for (int i = 0; i < keys.length; i++) { + String key = keys[i]; + + if (!current.has(key)) { + return null; + } + + if (i == keys.length - 1) { + return current.get(key); + } + + JsonElement next = current.get(key); + if (!next.isJsonObject()) { + return null; + } + + current = next.getAsJsonObject(); + } + + return null; + } + + private void addConfigToObject(JsonObject root, String section, JsonElement value) { + String[] keys = section.split("\\."); + JsonObject current = root; + + for (int i = 0; i < keys.length; i++) { + String key = keys[i]; + + if (i == keys.length - 1) { + current.add(key, value); + } else { + if (!current.has(key) || !current.get(key).isJsonObject()) { + current.add(key, new JsonObject()); + } + current = current.getAsJsonObject(key); + } + } + } + + @Override + public NbCodeClientCapabilities getNbCodeCapabilities() { + return codeCapa; + } + + @Override + public ClientConfigurationManager getClientConfigurationManager() { + return confManager; + } + + @Override + public CompletableFuture> configuration(ConfigurationParams params) { + List result = params.getItems().stream() + .map(item -> { + if (item.getScopeUri() == null) { + return getConfigurationValue(item.getSection()); + } + JsonObject o = new JsonObject(); + o.add("value", getConfigurationValue(item.getSection())); + o.add("scope", new JsonPrimitive(item.getScopeUri())); + return o; + }) + .collect(Collectors.toList()); + + return CompletableFuture.completedFuture(result); + } + } +} diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCacheTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCacheTest.java new file mode 100644 index 000000000000..5db4504b2b81 --- /dev/null +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ConfigValueCacheTest.java @@ -0,0 +1,371 @@ +/* + * 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.netbeans.modules.java.lsp.server.protocol; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.ConfigurationParams; +import org.junit.Test; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.java.lsp.server.TestCodeLanguageClient; + +public class ConfigValueCacheTest extends NbTestCase { + + public ConfigValueCacheTest(String name) { + super(name); + } + + @Test + public void testRegistrationOfListener() { + ConfigValueCache cache = new ConfigValueCache(); + BiConsumer expectedListener = (section, value) -> { + }; + String section = "jdk.test.section"; + + assertNull("Config should be null before registering listener", cache.getConfigData(section)); + + cache.registerListener(section, expectedListener); + ConfigValueCache.ConfigData configData = cache.getConfigData(section); + + assertNotNull("Config should exist after registering listener", configData); + assertEquals("Listener should match", expectedListener, configData.getConsumer()); + } + + @Test + public void testGetConfigValueWithInvalidSections() { + ConfigValueCache cache = new ConfigValueCache(); + + JsonElement result1 = cache.getConfigValue(null, null); + assertNull("Result should be null for null section", result1); + + JsonElement result2 = cache.getConfigValue("non.existent.section", null); + assertNull("Result should be null for non-existent section", result2); + } + + @Test + public void testGetConfigValueWithDifferentScopes() { + ConfigValueCache cache = new ConfigValueCache(); + String section = "project.jdkhome"; + String scope1 = "file:///project1"; + String scope2 = "file:///project2"; + String nonExistentScope = "file:///non-existent"; + + JsonElement rootValue = new JsonPrimitive("default"); + JsonElement scope1Value = new JsonPrimitive("project1"); + JsonElement scope2Value = new JsonPrimitive("project2"); + + cache.registerCache(section); + ConfigValueCache.ConfigData configData = cache.getConfigData(section); + configData.setRootValue(rootValue); + configData.setScopedValue(scope1, scope1Value); + configData.setScopedValue(scope2, scope2Value); + + JsonElement rootResult = cache.getConfigValue(section, null); + JsonElement scope1Result = cache.getConfigValue(section, scope1); + JsonElement scope2Result = cache.getConfigValue(section, scope2); + JsonElement nonExistentResult = cache.getConfigValue(section, nonExistentScope); + + assertEquals("Should return root value with null scope", rootValue, rootResult); + assertEquals("Should return scope1 value", scope1Value, scope1Result); + assertEquals("Should return scope2 value", scope2Value, scope2Result); + assertNull("Should return null for non-existent scope", nonExistentResult); + } + + @Test + public void testGetConfigValueWithHierarchicalSections() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + + String section1 = "editor"; + String section2 = "editor.format"; + String section3 = "editor.format.indentation"; + String section4 = "editor.size"; + + JsonElement value3 = new JsonPrimitive("indentation value"); + JsonElement value4 = new JsonPrimitive("editor size value"); + + mockClient.addConfig(section3, value3); + mockClient.addConfig(section4, value4); + + cache.registerCache(section1); + cache.registerCache(section2); + cache.registerCache(section3); + cache.registerCache(section4); + + cache.updateCache(mockClient, section1, cache, mockClient.getTree()); + + JsonElement result1 = cache.getConfigValue(section1, null); + JsonElement result2 = cache.getConfigValue(section2, null); + JsonElement result3 = cache.getConfigValue(section3, null); + JsonElement result4 = cache.getConfigValue(section4, null); + + assertEquals("Should return level 1 value", mockClient.getConfigurationValue(section1), result1); + assertEquals("Should return level 2 value", mockClient.getConfigurationValue(section2), result2); + assertEquals("Should return level 2 value", value4, result4); + assertEquals("Should return level 3 value", value3, result3); + } + + @Test + public void testGetConfigValueWithHierarchicalSectionsRegisterInReverseOrder() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + + String section1 = "editor"; + String section2 = "editor.format"; + String section3 = "editor.format.indentation"; + String section4 = "editor.size"; + + JsonElement value3 = new JsonPrimitive("indentation value"); + JsonElement value4 = new JsonPrimitive("editor size value"); + + mockClient.addConfig(section3, value3); + mockClient.addConfig(section4, value4); + + cache.registerCache(section4); + cache.registerCache(section3); + cache.registerCache(section2); + cache.registerCache(section1); + + cache.updateCache(mockClient, section1, cache, mockClient.getTree()); + + JsonElement result1 = cache.getConfigValue(section1, null); + JsonElement result2 = cache.getConfigValue(section2, null); + JsonElement result3 = cache.getConfigValue(section3, null); + JsonElement result4 = cache.getConfigValue(section4, null); + + assertEquals("Should return level 1 value", mockClient.getConfigurationValue(section1), result1); + assertEquals("Should return level 2 value", mockClient.getConfigurationValue(section2), result2); + assertEquals("Should return level 2 value", value4, result4); + assertEquals("Should return level 3 value", value3, result3); + } + + @Test + public void testCacheConfigValue() { + ConfigValueCache cache = new ConfigValueCache(); + String section = "project.jdkhome"; + String scope1 = "file:///project1"; + JsonElement rootValue = new JsonPrimitive("root value"); + JsonElement scopedValue = new JsonPrimitive("scoped value"); + + cache.cacheConfigValue(section, rootValue, null); + JsonElement result1 = cache.getConfigValue(section, null); + assertEquals("Root value should be updated", rootValue, result1); + + cache.cacheConfigValue(section, scopedValue, scope1); + JsonElement result2 = cache.getConfigValue(section, scope1); + assertEquals("Scoped value should be updated", scopedValue, result2); + + JsonElement result3 = cache.getConfigValue(section, null); + assertEquals("Root value should be unchanged", rootValue, result3); + } + + @Test + public void testUpdateCacheWithNullValues() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + String section = "project.jdkhome"; + JsonElement tree = new JsonPrimitive("test JDK value"); + cache.updateCache(mockClient, section, new ConfigValueCache().new ConfigData(), null); + assertNull("Cache should not be updated with null tree", cache.getConfigValue(section, null)); + + cache.updateCache(mockClient, section, null, tree); + assertNull("Cache should not be updated with null cacheValue", cache.getConfigValue(section, null)); + } + + @Test + public void testUpdateCacheWithConfigDataInstance() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + String section = "project.jdkhome"; + JsonElement tree = new JsonPrimitive("test JDK value"); + AtomicReference listenerValue = new AtomicReference<>(); + + cache.registerListener(section, (s, v) -> listenerValue.set(v)); + ConfigValueCache.ConfigData configData = cache.getConfigData(section); + + cache.updateCache(mockClient, section, configData, tree); + + JsonElement result = cache.getConfigValue(section, null); + assertEquals("Root value should be updated", tree, result); + + assertEquals("Listener should be called with tree value", tree, listenerValue.get()); + } + + @Test + public void testUpdateCacheWithConfigValueInstance() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + String parentSection = "editor"; + String fontColorSection = parentSection + ".fontColor"; + String fontSizeSection = parentSection + ".fontSize"; + + cache.registerCache(fontColorSection); + cache.registerCache(fontSizeSection); + + JsonObject tree = new JsonObject(); + JsonObject childTree = new JsonObject(); + tree.add(parentSection, childTree); + JsonElement expectedFontColorValue = new JsonPrimitive("blue"); + JsonElement expectedFontSizeValue = new JsonPrimitive(12); + childTree.add("fontColor", expectedFontColorValue); + childTree.add("fontSize", expectedFontSizeValue); + + mockClient.addConfig(fontColorSection, expectedFontColorValue); + mockClient.addConfig(fontSizeSection, expectedFontSizeValue); + + cache.updateCache(mockClient, parentSection, cache, tree); + + JsonElement fontColorValue = cache.getConfigValue(fontColorSection, null); + JsonElement fontSizeValue = cache.getConfigValue(fontSizeSection, null); + + assertNotNull("Nested section should be created", cache.getConfigData(fontColorSection)); + assertNotNull("Nested section should be created", cache.getConfigData(fontSizeSection)); + assertEquals("Nested value should be updated", expectedFontColorValue, fontColorValue); + assertEquals("Nested value should be updated", expectedFontSizeValue, fontSizeValue); + } + + @Test + public void testUpdateCacheWithScopedValues() { + ConfigValueCache cache = new ConfigValueCache(); + MockClient mockClient = new MockClient(); + String section = "project.jdkhome"; + JsonElement tree = new JsonPrimitive("updated value"); + + cache.registerCache(section); + ConfigValueCache.ConfigData configData = cache.getConfigData(section); + + String scope1 = "file:///project1"; + String scope2 = "file:///project2"; + JsonElement scope1Value = new JsonPrimitive("scope1 value"); + JsonElement scope2Value = new JsonPrimitive("scope2 value"); + configData.setScopedValue(scope1, scope1Value); + configData.setScopedValue(scope2, scope2Value); + + mockClient.resetRequests(); + cache.updateCache(mockClient, section, configData, tree); + Map> requests = mockClient.getRequestsReceived(); + + assertTrue("Should request client for updated scoped values", requests.get(section).containsKey(scope1)); + assertTrue("Should request client for updated scoped values", requests.get(section).containsKey(scope2)); + assertEquals("Scope1 should have updated value", scope1Value, cache.getConfigValue(section, scope1)); + assertEquals("Scope2 should have updated value", scope2Value, cache.getConfigValue(section, scope2)); + } + + private class MockClient extends TestCodeLanguageClient { + + NbCodeClientCapabilities codeCapa = new NbCodeClientCapabilities(); + JsonObject rootConfiguration = new JsonObject(); + Map> requestsReceived = new HashMap<>(); + + public MockClient() { + codeCapa.setConfigurationPrefix("jdk"); + } + + public void addConfig(String section, JsonElement value) { + String[] keys = section.split("\\."); + JsonObject current = rootConfiguration; + + for (int i = 0; i < keys.length; i++) { + String key = keys[i]; + + if (i == keys.length - 1) { + current.add(key, value); + } else { + if (!current.has(key) || !current.get(key).isJsonObject()) { + current.add(key, new JsonObject()); + } + current = current.getAsJsonObject(key); + } + } + } + + public JsonElement getConfigurationValue(String section) { + String[] keys = section.split("\\."); + JsonObject current = rootConfiguration; + + for (int i = 0; i < keys.length; i++) { + String key = keys[i]; + + if (!current.has(key)) { + return null; + } + + if (i == keys.length - 1) { + return current.get(key); + } + + JsonElement next = current.get(key); + if (!next.isJsonObject()) { + return null; + } + + current = next.getAsJsonObject(); + } + + return null; + } + + public void resetRequests() { + requestsReceived = new HashMap<>(); + } + + public Map> getRequestsReceived() { + return Collections.unmodifiableMap(requestsReceived); + } + + public JsonObject getTree(){ + return rootConfiguration; + } + + @Override + public NbCodeClientCapabilities getNbCodeCapabilities() { + return codeCapa; + } + + @Override + public CompletableFuture> configuration(ConfigurationParams params) { + List result = params.getItems().stream() + .map(item -> { + if (item.getScopeUri() == null) { + return getConfigurationValue(item.getSection()); + } + JsonElement value = getConfigurationValue(item.getSection()); + if (requestsReceived.containsKey(item.getSection())) { + requestsReceived.get(item.getSection()).put(item.getScopeUri(), value); + } else { + requestsReceived.put(item.getSection(), new HashMap<>()); + requestsReceived.get(item.getSection()).put(item.getScopeUri(), value); + } + return value; + }) + .collect(Collectors.toList()); + + return CompletableFuture.completedFuture(result); + } + } +} diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ServerTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ServerTest.java index 7dfc74d8a3d3..39c69f6c251e 100644 --- a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ServerTest.java +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/protocol/ServerTest.java @@ -19,6 +19,7 @@ package org.netbeans.modules.java.lsp.server.protocol; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; @@ -88,6 +89,7 @@ import org.eclipse.lsp4j.CreateFile; import org.eclipse.lsp4j.DefinitionParams; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; @@ -5948,7 +5950,15 @@ public CompletableFuture> configuration(ConfigurationParams configu assertEquals(expectedHints, convertHints.apply(hints)); } { - settings[0] = "[\"chained\"]"; + String jsonString = "{\n" + + " \"netbeans\": {\n" + + " \"inlay\": {\n" + + " \"enabled\": [\"chained\"]\n" + + " }\n" + + " }\n" + + "}"; + JsonElement j = JsonParser.parseString(jsonString); + server.getWorkspaceService().didChangeConfiguration(new DidChangeConfigurationParams(j)); List hints = server.getTextDocumentService().inlayHint(new InlayHintParams(id, new Range(new Position(0, 0), new Position(9, 1)))).get(); Set expectedHints = new HashSet<>(Arrays.asList( "4:33: List", @@ -5958,14 +5968,30 @@ public CompletableFuture> configuration(ConfigurationParams configu assertEquals(expectedHints, convertHints.apply(hints)); } { - settings[0] = "[\"parameter\"]"; + String jsonString = "{\n" + + " \"netbeans\": {\n" + + " \"inlay\": {\n" + + " \"enabled\": [\"parameter\"]\n" + + " }\n" + + " }\n" + + "}"; + JsonElement j = JsonParser.parseString(jsonString); + server.getWorkspaceService().didChangeConfiguration(new DidChangeConfigurationParams(j)); List hints = server.getTextDocumentService().inlayHint(new InlayHintParams(id, new Range(new Position(0, 0), new Position(9, 1)))).get(); Set expectedHints = new HashSet<>(Arrays.asList( "4:29:a:")); assertEquals(expectedHints, convertHints.apply(hints)); } { - settings[0] = "[\"var\"]"; + String jsonString = "{\n" + + " \"netbeans\": {\n" + + " \"inlay\": {\n" + + " \"enabled\": [\"var\"]\n" + + " }\n" + + " }\n" + + "}"; + JsonElement j = JsonParser.parseString(jsonString); + server.getWorkspaceService().didChangeConfiguration(new DidChangeConfigurationParams(j)); List hints = server.getTextDocumentService().inlayHint(new InlayHintParams(id, new Range(new Position(0, 0), new Position(9, 1)))).get(); Set expectedHints = new HashSet<>(Arrays.asList( "3:13: : int"));