diff --git a/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/plugin.xml b/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/plugin.xml index 1484a24d7..600093450 100644 --- a/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/plugin.xml +++ b/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/plugin.xml @@ -17,6 +17,7 @@ name="Convert .yaml to .properties"> + diff --git a/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/src/org/springframework/ide/eclipse/boot/refactoring/ConvertPropertiesToYamlHandler.java b/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/src/org/springframework/ide/eclipse/boot/refactoring/ConvertPropertiesToYamlHandler.java index 13a14d42b..96a70a0f3 100644 --- a/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/src/org/springframework/ide/eclipse/boot/refactoring/ConvertPropertiesToYamlHandler.java +++ b/eclipse-extensions/org.springframework.ide.eclipse.boot.refactoring/src/org/springframework/ide/eclipse/boot/refactoring/ConvertPropertiesToYamlHandler.java @@ -24,7 +24,6 @@ import org.eclipse.ltk.ui.refactoring.RefactoringWizard; import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; import org.eclipse.ui.handlers.HandlerUtil; import org.springsource.ide.eclipse.commons.livexp.util.Log; -import org.eclipse.ltk.core.refactoring.RefactoringContext; public class ConvertPropertiesToYamlHandler extends AbstractHandler { diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/icons/boot-icon.png b/eclipse-language-servers/org.springframework.tooling.boot.ls/icons/boot-icon.png new file mode 100644 index 000000000..de88ef0d5 Binary files /dev/null and b/eclipse-language-servers/org.springframework.tooling.boot.ls/icons/boot-icon.png differ diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml b/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml index de1ec1509..1d944e022 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/plugin.xml @@ -280,6 +280,14 @@ class="org.springframework.tooling.boot.ls.commands.AddStartersHandler" commandId="spring.initializr.addStarters"> + + + + @@ -329,6 +337,16 @@ typeId="org.eclipse.lsp4e.pathParameterType"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ls.getWorkspaceService().executeCommand(commandParams)); + + } + + return null; + } + + abstract protected String getTargetExtension(); + + abstract protected String getCommandId(); + + private IFile getTargetFile(IFile sourceFile, String ext) { + IProject project = sourceFile.getProject(); + IPath eclipsePath = sourceFile.getProjectRelativePath(); + IPath dir = eclipsePath.removeLastSegments(1); + String fileName = sourceFile.getName(); + String fileNoExt = fileName.substring(0, fileName.length() - sourceFile.getFileExtension().length() - 1); + IFile target = project.getFile(dir.append("%s.%s".formatted(fileNoExt, ext))); + for (int i = 1; i < Integer.MAX_VALUE && target.exists(); i++) { + target = project.getFile(dir.append("%s-%d.%s".formatted(fileNoExt, i, ext))); + } + return target; + } + + private IFile getSourceFile(ExecutionEvent event) { + ISelection selection = HandlerUtil.getActiveMenuSelection(event); + IStructuredSelection ss = null; + if (selection instanceof IStructuredSelection) { + ss = (IStructuredSelection) selection; + } else { + selection = HandlerUtil.getActiveMenuEditorInput(event); + if (selection instanceof IStructuredSelection) { + ss = (IStructuredSelection) selection; + } + } + if (ss!=null && !ss.isEmpty()) { + return asFile(ss.getFirstElement()); + } + return null; + } + + private IFile asFile(Object selectedElement) { + if (selectedElement instanceof IFile) { + return (IFile) selectedElement; + } + if (selectedElement instanceof IAdaptable) { + return ((IAdaptable) selectedElement).getAdapter(IFile.class); + } + return null; + } + + public static class ConvertPropertiesToYamlHandler extends ConvertBootPropertiesHanlder { + + @Override + protected String getTargetExtension() { + return "yml"; + } + + @Override + protected String getCommandId() { + return "sts/boot/props-to-yaml"; + } + + } + + public static class ConvertYamlToPropertiesHanlder extends ConvertBootPropertiesHanlder { + + @Override + protected String getTargetExtension() { + return "properties"; + } + + @Override + protected String getCommandId() { + return "sts/boot/yaml-to-props"; + } + + } + + +} diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/prefs/PrefsInitializer.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/prefs/PrefsInitializer.java index 2b05ad3ba..391c91bae 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/prefs/PrefsInitializer.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/prefs/PrefsInitializer.java @@ -61,6 +61,7 @@ public class PrefsInitializer extends AbstractPreferenceInitializer { })); preferenceStore.setDefault(Constants.PREF_MODULITH, true); + } } diff --git a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/composable/CompositeLanguageServerComponents.java b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/composable/CompositeLanguageServerComponents.java index 793e25a90..c095971ed 100644 --- a/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/composable/CompositeLanguageServerComponents.java +++ b/headless-services/commons/commons-language-server/src/main/java/org/springframework/ide/vscode/commons/languageserver/composable/CompositeLanguageServerComponents.java @@ -10,6 +10,7 @@ *******************************************************************************/ package org.springframework.ide.vscode.commons.languageserver.composable; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -33,6 +34,7 @@ import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.WorkspaceSymbol; import org.eclipse.lsp4j.jsonrpc.CancelChecker; import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.springframework.context.ApplicationContext; import org.springframework.ide.vscode.commons.languageserver.reconcile.IProblemCollector; import org.springframework.ide.vscode.commons.languageserver.reconcile.IReconcileEngine; import org.springframework.ide.vscode.commons.languageserver.util.CodeActionHandler; @@ -40,11 +42,13 @@ import org.springframework.ide.vscode.commons.languageserver.util.CodeLensHandle import org.springframework.ide.vscode.commons.languageserver.util.DocumentSymbolHandler; import org.springframework.ide.vscode.commons.languageserver.util.HoverHandler; import org.springframework.ide.vscode.commons.languageserver.util.InlayHintHandler; +import org.springframework.ide.vscode.commons.languageserver.util.LanguageComputer; import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; import org.springframework.ide.vscode.commons.languageserver.util.SimpleTextDocumentService; import org.springframework.ide.vscode.commons.util.text.IDocument; import org.springframework.ide.vscode.commons.util.text.IRegion; import org.springframework.ide.vscode.commons.util.text.LanguageId; +import org.springframework.ide.vscode.commons.util.text.LazyTextDocument; import org.springframework.ide.vscode.commons.util.text.TextDocument; import com.google.common.collect.ImmutableMap; @@ -65,8 +69,8 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen } } - public CompositeLanguageServerComponents build(SimpleLanguageServer server) { - return new CompositeLanguageServerComponents(server, this); + public CompositeLanguageServerComponents build(ApplicationContext appContext) { + return new CompositeLanguageServerComponents(appContext, this); } } @@ -78,7 +82,8 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen private final DocumentSymbolHandler docSymbolHandler; private final InlayHintHandler inlayHintHandler; - public CompositeLanguageServerComponents(SimpleLanguageServer server, Builder builder) { + public CompositeLanguageServerComponents(ApplicationContext appContext, Builder builder) { + SimpleLanguageServer server = appContext.getBean(SimpleLanguageServer.class); this.componentsByLanguageId = ImmutableMap.copyOf(builder.componentsByLanguageId); //Create composite Reconcile engine if (componentsByLanguageId.values().stream().flatMap(l -> l.stream()).map(LanguageServerComponents::getReconcileEngine).anyMatch(Optional::isPresent)) { @@ -145,7 +150,7 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen @Override public List handle(CancelChecker cancelToken, CodeLensParams params) { - TextDocument doc = server.getTextDocumentService().getLatestSnapshot(params.getTextDocument().getUri()); + TextDocument doc = getDoc(appContext, params.getTextDocument().getUri()); LanguageId language = doc.getLanguageId(); List subComponents = componentsByLanguageId.get(language); if (subComponents != null) { @@ -183,17 +188,15 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen @Override public List handle(DocumentSymbolParams params) { - TextDocument doc = server.getTextDocumentService().getLatestSnapshot(params.getTextDocument().getUri()); - if (doc != null) { - LanguageId language = doc.getLanguageId(); - List subComponents = componentsByLanguageId.get(language); - if (subComponents != null) { - return subComponents.stream() - .map(sc -> sc.getDocumentSymbolProvider()) - .filter(ds -> ds.isPresent()) - .flatMap(ds -> ds.get().handle(params).stream()) - .collect(Collectors.toList()); - } + TextDocument doc = getDoc(appContext, params.getTextDocument().getUri()); + LanguageId language = doc.getLanguageId(); + List subComponents = componentsByLanguageId.get(language); + if (subComponents != null) { + return subComponents.stream() + .map(sc -> sc.getDocumentSymbolProvider()) + .filter(ds -> ds.isPresent()) + .flatMap(ds -> ds.get().handle(params).stream()) + .collect(Collectors.toList()); } //No applicable subEngine... return Collections.emptyList(); @@ -244,5 +247,22 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen public Optional getInlayHintHandler() { return Optional.of(inlayHintHandler); } + + private static TextDocument getDoc(ApplicationContext appContext, String uri) { + + TextDocument doc = appContext.getBean(SimpleLanguageServer.class).getTextDocumentService().getLatestSnapshot(uri); + + if (doc == null) { + LanguageComputer languageComputer = appContext.getBean(LanguageComputer.class); + if (languageComputer != null) { + LanguageId language = languageComputer.computeLanguage(URI.create(uri)); + if (language != null) { + doc = new LazyTextDocument(uri, language); + } + } + } + + return doc; + } } diff --git a/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/text/LazyTextDocument.java b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/text/LazyTextDocument.java index 9f3af82fa..224c03472 100644 --- a/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/text/LazyTextDocument.java +++ b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/text/LazyTextDocument.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2022 VMware, Inc. + * Copyright (c) 2022, 2024 VMware, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -39,12 +39,6 @@ public class LazyTextDocument extends TextDocument { }); } - @Override - public String get() { - // TODO Auto-generated method stub - return super.get(); - } - @Override protected synchronized Text getText() { if (!loaded) { diff --git a/headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/path/YamlPath.java b/headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/path/YamlPath.java index 7844dd5f8..62d72d0dc 100644 --- a/headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/path/YamlPath.java +++ b/headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/path/YamlPath.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015, 2016 Pivotal, Inc. + * Copyright (c) 2015, 2024 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -13,6 +13,8 @@ package org.springframework.ide.vscode.commons.yaml.path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.NoSuchElementException; +import java.util.StringTokenizer; import java.util.stream.Stream; import org.springframework.ide.vscode.commons.yaml.ast.NodeRef; @@ -22,6 +24,8 @@ import org.springframework.ide.vscode.commons.yaml.ast.NodeRef.TupleValueRef; import org.springframework.ide.vscode.commons.yaml.ast.NodeUtil; import org.springframework.ide.vscode.commons.yaml.path.YamlPathSegment.YamlPathSegmentType; +import com.google.common.collect.ImmutableList; + import reactor.core.publisher.Flux; /** @@ -74,14 +78,49 @@ public class YamlPath extends AbstractYamlTraversal { * Parse a YamlPath from a dotted property name. The segments are obtained * by spliting the name at each dot. */ +// public static YamlPath fromProperty(String propName) { +// ArrayList segments = new ArrayList(); +// for (String s : propName.split("\\.")) { +// segments.add(YamlPathSegment.valueAt(s)); +// } +// return new YamlPath(segments); +// } + /** + * Parse a YamlPath from a dotted property name. The segments are obtained + * by splitting the name at each dot. + */ public static YamlPath fromProperty(String propName) { - ArrayList segments = new ArrayList(); - for (String s : propName.split("\\.")) { - segments.add(YamlPathSegment.valueAt(s)); + ImmutableList.Builder segments = ImmutableList.builder(); + String delim = ".[]"; + StringTokenizer tokens = new StringTokenizer(propName, delim, true); + try { + while (tokens.hasMoreTokens()) { + String token = tokens.nextToken(delim); + if (token.equals(".") || token.equals("]")) { + //Skip it silently + } else if (token.equals("[")) { + String bracketed = tokens.nextToken("]"); + if (bracketed.equals("]")) { + //empty string between []? Makes no sense, so ignore that. + } else { + try { + int index = Integer.parseInt(bracketed); + segments.add(YamlPathSegment.valueAt(index)); + } catch (NumberFormatException e) { + segments.add(YamlPathSegment.valueAt(bracketed)); + } + } + } else { + segments.add(YamlPathSegment.valueAt(token)); + } + } + } catch (NoSuchElementException e) { + //Ran out of tokens. } - return new YamlPath(segments); + return new YamlPath(segments.build()); } + /** * Create a YamlPath with a single segment (i.e. like 'fromProperty', but does * not parse '.' as segment separators. diff --git a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java index 8287f9370..c1bd11940 100644 --- a/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java +++ b/headless-services/commons/language-server-test-harness/src/main/java/org/springframework/ide/vscode/languageserver/testharness/LanguageServerHarness.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2016, 2023 Pivotal, Inc. + * Copyright (c) 2016, 2024 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -87,6 +87,8 @@ import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.RenameFile; import org.eclipse.lsp4j.ResourceOperation; import org.eclipse.lsp4j.ResourceOperationKind; +import org.eclipse.lsp4j.ShowDocumentParams; +import org.eclipse.lsp4j.ShowDocumentResult; import org.eclipse.lsp4j.ShowMessageRequestParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentClientCapabilities; @@ -442,6 +444,11 @@ public class LanguageServerHarness { public void liveProcessLogLevelUpdated(LiveProcessLoggersSummary processKey) { } + @Override + public CompletableFuture showDocument(ShowDocumentParams params) { + return CompletableFuture.completedFuture(new ShowDocumentResult(true)); + } + }); } @@ -810,15 +817,14 @@ public class LanguageServerHarness { .collect(Collectors.toList()); } - @SuppressWarnings({ "unchecked", "rawtypes" }) - public void perform(Command command) throws Exception { + public Object perform(Command command) throws Exception { List args = command.getArguments(); //Note convert the params to a 'typeless' Object because that is more representative on how it will be // received when we get it in a real client/server setting (i.e. parsed from json). JsonArray jsonArray = gson.toJsonTree(args).getAsJsonArray(); List untypedParams = new ArrayList<>(jsonArray.size()); jsonArray.forEach(e -> untypedParams.add(e)); - getServer().getWorkspaceService() + return getServer().getWorkspaceService() .executeCommand(new ExecuteCommandParams(command.getCommand(), untypedParams)) .get(); } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerInitializer.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerInitializer.java index 232e27819..3f0ae6940 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerInitializer.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootLanguageServerInitializer.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2018, 2023 Pivotal, Inc. + * Copyright (c) 2018, 2024 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -129,7 +129,7 @@ public class BootLanguageServerInitializer implements InitializingBean { builder.add(c); } - components = builder.build(server); + components = builder.build(appContext); final SimpleTextDocumentService documents = server.getTextDocumentService(); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/BootPropertiesLanguageServerComponents.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/BootPropertiesLanguageServerComponents.java index 99cd8658d..51251ed4c 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/BootPropertiesLanguageServerComponents.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/BootPropertiesLanguageServerComponents.java @@ -105,7 +105,12 @@ public class BootPropertiesLanguageServerComponents implements LanguageServerCom }); }); }); - + + // Register YAML -> Props conversion command + new YamlToPropertiesCommand(server); + + // Register Props -> YAML conversion command + new PropertiesToYamlCommand(server); } @Override diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommand.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommand.java new file mode 100644 index 000000000..5c2832386 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommand.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.regex.Pattern; + +import org.eclipse.lsp4j.ApplyWorkspaceEditParams; +import org.eclipse.lsp4j.ShowDocumentParams; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; +import org.springframework.ide.vscode.commons.util.BadLocationException; +import org.springframework.ide.vscode.commons.util.text.LanguageId; +import org.springframework.ide.vscode.commons.util.text.LazyTextDocument; +import org.springframework.ide.vscode.commons.util.text.TextDocument; + +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.google.gson.JsonElement; +import com.google.gson.internal.LinkedTreeMap; + +public class PropertiesToYamlCommand { + + static final String CMD_PROPS_TO_YAML = "sts/boot/props-to-yaml"; + + private static final Pattern COMMENT = Pattern.compile("(?m)^\\s*(\\#|\\!)"); + + private final SimpleLanguageServer server; + + PropertiesToYamlCommand(SimpleLanguageServer server) { + this.server = server; + server.onCommand(CMD_PROPS_TO_YAML, params -> execute(params.getArguments())); + } + + private CompletableFuture execute(List arguments) { + String propsUri = arguments.get(0) instanceof JsonElement ? ((JsonElement) arguments.get(0)).getAsString() : (String) arguments.get(0); + String yamlUri = arguments.get(1) instanceof JsonElement ? ((JsonElement) arguments.get(1)).getAsString() : (String) arguments.get(1); + Boolean replace = arguments.get(2) instanceof JsonElement ? ((JsonElement) arguments.get(2)).getAsBoolean() : (Boolean) arguments.get(2); + return CompletableFuture.supplyAsync(() -> { + try { + return createWorkspaceEdit(propsUri, yamlUri, replace); + } catch (IOException | BadLocationException e) { + throw new CompletionException(e); + } + }) + .thenCompose(we -> server.getClient().applyEdit(new ApplyWorkspaceEditParams(we, "Convert .properties to .yaml"))) + .thenCompose(res -> res.isApplied() ? server.getClient().showDocument(new ShowDocumentParams(yamlUri)) : CompletableFuture.completedFuture(new ShowDocumentResult(false))); + } + + private WorkspaceEdit createWorkspaceEdit(String propsUri, String yamlUri, Boolean replace) throws IOException, BadLocationException { + Path yamlFile = Paths.get(URI.create(yamlUri)); + if (Files.exists(yamlFile)) { + throw new IOException("File %s already exists!".formatted(yamlFile.toString())); + } + + TextDocument doc = getDocument(propsUri, LanguageId.BOOT_PROPERTIES); + String propsContent = doc.get(); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + if (hasComments(propsContent)) { + warnings.add("The yaml file had comments which are lost in the refactoring!"); + } + + Multimap properties = load(new StringReader(propsContent)); + PropertiesToYamlConverter converter = new PropertiesToYamlConverter(properties); + + StringBuilder yamlContent = new StringBuilder(); + errors.addAll(converter.getErrors()); + warnings.addAll(converter.getWarnings()); + YamlToPropertiesCommand.addReportHeaderComment(yamlContent, errors, warnings); + yamlContent.append(converter.getYaml()); + + return replace ? YamlToPropertiesCommand.createReplaceFileWorkspaceEdit(propsUri, yamlUri, doc, yamlContent.toString()) : YamlToPropertiesCommand.createNewFileWorkspaceEdit(yamlUri, yamlContent.toString()); + } + + private Multimap load(Reader content) throws IOException { + Multimap map = Multimaps.newMultimap( + new LinkedTreeMap>(), + LinkedHashSet::new + ); + Properties loader = new Properties() { + private static final long serialVersionUID = 1L; + public synchronized Object put(Object key, Object value) { + map.put((String)key, (String)value); + return super.put(key, value); + } + }; + loader.load(content); + return map; + } + + private static boolean hasComments(String propsContent) { + return COMMENT.matcher(propsContent).find(); + } + + private TextDocument getDocument(String uri, LanguageId language) { + TextDocument doc = server.getTextDocumentService().getLatestSnapshot(uri); + return doc == null ? new LazyTextDocument(uri, language) : doc; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlConverter.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlConverter.java new file mode 100644 index 000000000..833df2325 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlConverter.java @@ -0,0 +1,184 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.eclipse.core.runtime.Assert; +import org.springframework.ide.vscode.commons.yaml.path.YamlPath; +import org.springframework.ide.vscode.commons.yaml.path.YamlPathSegment; +import org.springframework.ide.vscode.commons.yaml.path.YamlPathSegment.AtIndex; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.representer.Representer; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Multimap; +import com.google.gson.internal.LinkedTreeMap; + +/** + * Helper class to convert (Spring Boot) .properties file content into equivalent + * .yml file content. + * + * @author Kris De Volder + */ +class PropertiesToYamlConverter { + + private String output; + private ImmutableList.Builder errors; + private ImmutableList.Builder warnings; + + class YamlBuilder { + final YamlPath path; + final List scalars = new ArrayList<>(); + final LinkedTreeMap listItems = new LinkedTreeMap<>(); + final LinkedTreeMap mapEntries = new LinkedTreeMap<>(); + + public YamlBuilder(YamlPath path) { + this.path = path; + } + + void addProperty(YamlPath path, String value) { + if (path.isEmpty()) { + scalars.add(objectify(value)); + } else { + YamlPathSegment segment = path.getSegment(0); + YamlBuilder subBuilder; + if (segment instanceof AtIndex) { + subBuilder = getSubBuilder(listItems, segment, segment.toIndex()); + } else { + subBuilder = getSubBuilder(mapEntries, segment, segment.toPropString()); + } + subBuilder.addProperty(path.dropFirst(1), value); + } + } + + private Object objectify(String value) { + if (value != null) { + Object parsed = null; + try { + parsed = new BigInteger(value); + } catch (NumberFormatException e) { + try { + parsed = new BigDecimal(value); + } catch (NumberFormatException e2) { + if (value.equals("true")) { + parsed = true; + } else if (value.equals("false")) { + parsed = false; + } + } + } + if (parsed!=null && parsed.toString().equals(value)) { + return parsed; + } + } + return value; + } + + private YamlBuilder getSubBuilder(LinkedTreeMap subBuilders, YamlPathSegment segment, T key) { + YamlBuilder existing = subBuilders.get(key); + if (existing==null) { + subBuilders.put(key, existing = new YamlBuilder(path.append(segment))); + } + return existing; + } + + public Object build() { + if (!scalars.isEmpty()) { + if (listItems.isEmpty() && mapEntries.isEmpty()) { + if (scalars.size()>1) { + warnings.add("Multiple values "+ scalars +" assigned to '"+path.toPropString()+"'. Values are merged into a yaml sequence node."); + return scalars; + } else { + return scalars.get(0); + } + } else { + if (!mapEntries.isEmpty()) { + errors.add( + "Direct assignment '"+path.toPropString()+"="+scalars.get(0)+"' can not be combined " + + "with sub-property assignment '"+path.toPropString()+"." + mapEntries.keySet().iterator().next()+"...'. "+ + "Direct assignment is dropped!" + ); + } else { + errors.add( + "Direct assignment '"+path.toPropString()+"="+scalars.get(0)+"' can not be combined " + + "with sequence assignment '"+path.toPropString()+"[" + listItems.keySet().iterator().next()+"]...'. "+ + "Direct assignments are dropped!" + ); + } + scalars.clear(); + } + } + Assert.isLegal(scalars.isEmpty()); + if (!listItems.isEmpty() && !mapEntries.isEmpty()) { + warnings.add("'"+path.toPropString()+"' has some entries that look like list items and others that look like map entries. " + + "All these entries are treated as map entries!"); + for (Entry listItem : listItems.entrySet()) { + mapEntries.put(listItem.getKey().toString(), listItem.getValue()); + } + listItems.clear(); + } + if (!listItems.isEmpty()) { + return listItems.values().stream() + .map(childBuilder -> childBuilder.build()) + .collect(Collectors.toList()); + } else { + LinkedTreeMap map = new LinkedTreeMap<>(); + for (Entry entry : mapEntries.entrySet()) { + map.put(entry.getKey(), entry.getValue().build()); + } + return map; + } + } + } + + public PropertiesToYamlConverter(Multimap properties) { + this.errors = ImmutableList.builder(); + this.warnings = ImmutableList.builder(); + if (properties.isEmpty()) { + output = ""; + return; + } + YamlBuilder root = new YamlBuilder(YamlPath.EMPTY); + for (Entry e : properties.entries()) { + root.addProperty(YamlPath.fromProperty(e.getKey()), e.getValue()); + } + Object object = root.build(); + + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + + Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()), new Representer(options), options); + this.output = yaml.dump(object); + } + + public List getErrors() { + return errors.build(); + } + + public List getWarnings() { + return warnings.build(); + } + + public String getYaml() { + return output; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommand.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommand.java new file mode 100644 index 000000000..8542bb150 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommand.java @@ -0,0 +1,204 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import org.eclipse.lsp4j.AnnotatedTextEdit; +import org.eclipse.lsp4j.ApplyWorkspaceEditParams; +import org.eclipse.lsp4j.ChangeAnnotation; +import org.eclipse.lsp4j.CreateFile; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.RenameFile; +import org.eclipse.lsp4j.ShowDocumentParams; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.TextDocumentEdit; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; +import org.springframework.ide.vscode.commons.util.BadLocationException; +import org.springframework.ide.vscode.commons.util.text.LanguageId; +import org.springframework.ide.vscode.commons.util.text.LazyTextDocument; +import org.springframework.ide.vscode.commons.util.text.TextDocument; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.comments.CommentType; +import org.yaml.snakeyaml.events.CommentEvent; +import org.yaml.snakeyaml.events.Event; + +import com.google.gson.JsonElement; + +class YamlToPropertiesCommand { + + private static final String LABEL = "Convert .yaml to .properties"; + + static final String CMD_YAML_TO_PROPS = "sts/boot/yaml-to-props"; + + private final SimpleLanguageServer server; + + YamlToPropertiesCommand(SimpleLanguageServer server) { + this.server = server; + server.onCommand(CMD_YAML_TO_PROPS, params -> execute(params.getArguments())); + } + + private CompletableFuture execute(List arguments) { + String yamlUri = arguments.get(0) instanceof JsonElement ? ((JsonElement) arguments.get(0)).getAsString() : (String) arguments.get(0); + String propsUri = arguments.get(1) instanceof JsonElement ? ((JsonElement) arguments.get(1)).getAsString() : (String) arguments.get(1); + Boolean replace = arguments.get(2) instanceof JsonElement ? ((JsonElement) arguments.get(2)).getAsBoolean() : (Boolean) arguments.get(2); + return CompletableFuture.supplyAsync(() -> { + try { + return createWorkspaceEdit(yamlUri, propsUri, replace); + } catch (IOException | BadLocationException e) { + throw new CompletionException(e); + } + }) + .thenCompose(we -> server.getClient().applyEdit(new ApplyWorkspaceEditParams(we, LABEL))) + .thenCompose(res -> res.isApplied() ? server.getClient().showDocument(new ShowDocumentParams(propsUri)) : CompletableFuture.completedFuture(new ShowDocumentResult(false))); + } + + private WorkspaceEdit createWorkspaceEdit(String yamlUri, String propsUri, boolean replace) throws IOException, BadLocationException { + Path propsFile = Paths.get(URI.create(propsUri)); + if (Files.exists(propsFile)) { + throw new IOException("File %s already exists!".formatted(propsFile.toString())); + } + + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + TextDocument doc = getDocument(server, yamlUri, LanguageId.BOOT_PROPERTIES_YAML); + String yamlContent = doc.get(); + + if (hasComments(yamlContent)) { + warnings.add("The yaml file had comments which are lost in the refactoring!"); + } + + StringBuilder propsContent = new StringBuilder(); + for (Object d : new Yaml().loadAll(new StringReader(yamlContent))) { + if (d instanceof Map) { + // Add doc divider if not empty + @SuppressWarnings("unchecked") + Map o = (Map) d; + YamlToPropertiesConverter converter = new YamlToPropertiesConverter(o); + Properties props = converter.getProperties(); + StringWriter write = new StringWriter(); + props.store(write, null); + if (!propsContent.isEmpty()) { + propsContent.append("#---\n"); + } else { + addReportHeaderComment(propsContent, errors, warnings); + } + // Skip over the date header. Comments are not present but date header is. + if (write.getBuffer().charAt(0) == '#') { + int idx = write.getBuffer().indexOf("\n"); + propsContent.append(idx >= 0 && idx < write.getBuffer().length() ? write.getBuffer().substring(idx + 1) : write.getBuffer().toString()); + } else { + propsContent.append(write.getBuffer().toString()); + } + } else if (d == null) { + if (!propsContent.isEmpty()) { + propsContent.append("#---\n"); + } + } + } + + return replace ? createReplaceFileWorkspaceEdit(yamlUri, propsUri, doc, propsContent.toString()) : createNewFileWorkspaceEdit(propsUri, propsContent.toString()); + } + + + private static boolean hasComments(String yamlContent) { + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setProcessComments(true); + for (Event e : new Yaml(loaderOptions).parse(new StringReader(yamlContent))) { + if (e instanceof CommentEvent ce) { + if (ce.getCommentType() == CommentType.BLANK_LINE) { + // document separator + } else { + return true; + } + } + } + return false; + } + + static WorkspaceEdit createReplaceFileWorkspaceEdit(String sourceUri, String targetUri, TextDocument oldDoc, String newContent) throws BadLocationException { + String changeAnnotationId = UUID.randomUUID().toString(); + ChangeAnnotation changeAnnotation = new ChangeAnnotation(LABEL); + changeAnnotation.setNeedsConfirmation(true); + + RenameFile renameFile = new RenameFile(sourceUri, targetUri); + renameFile.setAnnotationId(changeAnnotationId); + WorkspaceEdit we = new WorkspaceEdit(List.of( + Either.forLeft(new TextDocumentEdit( + new VersionedTextDocumentIdentifier(sourceUri, oldDoc.getVersion()), + List.of(new AnnotatedTextEdit(oldDoc.toRange(0, oldDoc.getLength()), newContent, changeAnnotationId)) + )), + Either.forRight(renameFile) + )); + we.setChangeAnnotations(Map.of(changeAnnotationId, changeAnnotation)); + return we; + } + + static WorkspaceEdit createNewFileWorkspaceEdit(String uri, String content) { + String changeAnnotationId = UUID.randomUUID().toString(); + ChangeAnnotation changeAnnotation = new ChangeAnnotation(LABEL); + changeAnnotation.setNeedsConfirmation(false); // VSCode refactor preview errors out showing diff for newly added file + + CreateFile createFile = new CreateFile(uri); + createFile.setAnnotationId(changeAnnotationId); + WorkspaceEdit we = new WorkspaceEdit(List.of( + Either.forRight(createFile), + Either.forLeft(new TextDocumentEdit( + new VersionedTextDocumentIdentifier(uri, null), + List.of(new AnnotatedTextEdit(new Range(new Position(0,0), new Position(0,0)), content, changeAnnotationId)) + )) + )); + we.setChangeAnnotations(Map.of(changeAnnotationId, changeAnnotation)); + return we; + } + + static TextDocument getDocument(SimpleLanguageServer server, String uri, LanguageId language) { + TextDocument doc = server.getTextDocumentService().getLatestSnapshot(uri); + return doc == null ? new LazyTextDocument(uri, language) : doc; + } + + static void addReportHeaderComment(StringBuilder content, List errors, List warnings) { + if (!errors.isEmpty() || !warnings.isEmpty()) { + content.append("# Conversion to YAML from Properties formar report\n"); + if (!errors.isEmpty()) { + content.append("# Errors:\n"); + for (String e : errors) { + content.append("# - %s\n".formatted(e)); + } + } + if (!warnings.isEmpty()) { + content.append("# Warnings:\n"); + for (String w : warnings) { + content.append("# - %s\n".formatted(w)); + } + } + } + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesConverter.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesConverter.java new file mode 100644 index 000000000..a42bdb6cd --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesConverter.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +class YamlToPropertiesConverter { + + private final Properties properties; + + + public YamlToPropertiesConverter(Map yaml) { + this.properties = new Properties() { + + private static final long serialVersionUID = 1L; + + private LinkedHashMap delegate = new LinkedHashMap<>(); + + @Override + public synchronized Object put(Object key, Object value) { + delegate.put(key, value); + return super.put(key, value); + } + + @Override + public Set> entrySet() { + return delegate.entrySet(); + } + + }; + + for (Map.Entry e : yaml.entrySet()) { + readProperties(e.getValue(), e.getKey()); + } + } + + private void readPropertiesFromYamlMap(Map map, String prefix) { + for (Map.Entry e : map.entrySet()) { + readProperties(e.getValue(), "%s.%s".formatted(prefix, e.getKey())); + } + } + + private void readPropertiesFromYamlList(List l, String prefix) { + for (int i = 0; i < l.size(); i++) { + readProperties(l.get(i), "%s[%d]".formatted(prefix, i)); + } + } + + @SuppressWarnings("unchecked") + private void readProperties(Object o, String prefix) { + if (o instanceof Map) { + readPropertiesFromYamlMap((Map) o, prefix); + } else if ( o instanceof List) { + readPropertiesFromYamlList((List) o, prefix); + } else { + properties.put(prefix, o.toString()); + } + } + + public Properties getProperties() { + return properties; + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommandTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommandTest.java new file mode 100644 index 000000000..e15c09107 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/PropertiesToYamlCommandTest.java @@ -0,0 +1,313 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; + +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.ide.vscode.boot.bootiful.BootLanguageServerTest; +import org.springframework.ide.vscode.boot.bootiful.SymbolProviderTestConf; +import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; +import org.springframework.ide.vscode.languageserver.testharness.LanguageServerHarness; +import org.springframework.ide.vscode.project.harness.ProjectsHarness; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@BootLanguageServerTest +@Import({SymbolProviderTestConf.class}) +public class PropertiesToYamlCommandTest { + + private static final String FILENAME_SUFFIX = "-conversion-test"; + + @Autowired private LanguageServerHarness harness; + @Autowired private JavaProjectFinder projectFinder; + + private File directory; + + @BeforeEach public void setup() throws Exception { + harness.intialize(null); + + directory = new File(ProjectsHarness.class.getResource("/test-projects/test-spring-validations/").toURI()); + + Files.walk(directory.toPath(), Integer.MAX_VALUE).filter(Files::isRegularFile).filter(p -> p.getFileName().toString().contains(FILENAME_SUFFIX)).forEach(t -> { + try { + Files.delete(t); + } catch (IOException e) { + + } + }); + + String projectDir = directory.toURI().toString(); + + // trigger project creation + projectFinder.find(new TextDocumentIdentifier(projectDir)).get(); + } + + @AfterEach public void tearDown() throws Exception { + if (directory != null && directory.exists()) { + Files.walk(directory.toPath(), Integer.MAX_VALUE).filter(Files::isRegularFile).filter(p -> p.getFileName().toString().contains(FILENAME_SUFFIX)).forEach(t -> { + try { + Files.delete(t); + } catch (IOException e) { + + } + }); + } + } + + void assertConversion(String propsContent, String yamlContent, boolean replace) throws Exception { + Path yamlFilePath = directory.toPath().resolve("src/main/resources/application" + FILENAME_SUFFIX + ".yml"); + Path propsFilePath = directory.toPath().resolve("src/main/resources/application" + FILENAME_SUFFIX + ".properties"); + + if (!Files.exists(propsFilePath)) { + Files.createDirectories(propsFilePath.getParent()); + Files.createFile(propsFilePath); + Files.write(propsFilePath, propsContent.getBytes(), StandardOpenOption.APPEND); + } + + Command cmd = new Command(); + cmd.setCommand(PropertiesToYamlCommand.CMD_PROPS_TO_YAML); + cmd.setArguments(List.of(propsFilePath.toUri().toASCIIString(), yamlFilePath.toUri().toASCIIString(), replace)); + cmd.setTitle("Convert .propeties to .yaml"); + + ShowDocumentResult res = (ShowDocumentResult) harness.perform(cmd); + + assertThat(res.isSuccess()).isTrue(); + + assertThat(Files.exists(yamlFilePath)).isTrue(); + if (replace) { + assertThat(Files.exists(propsFilePath)).isFalse(); + } else { + assertThat(Files.exists(propsFilePath)).isTrue(); + } + + assertThat(Files.readString(yamlFilePath)).isEqualTo(yamlContent); + } + + @Test void almostHasComments() throws Exception { + assertConversion( + "my.hello=Good morning!\n" + + "my.goodbye=See ya # later\n" + , // ==> + "my:\n" + + " hello: Good morning!\n" + + " goodbye: 'See ya # later'\n", + true + ); + } + + + @Test void listItems() throws Exception { + assertConversion( + "some.thing[0].a=first-a\n" + + "some.thing[0].b=first-b\n" + + "some.thing[1].a=second-a\n" + + "some.thing[1].b=second-b\n" + , // ==> + "some:\n" + + " thing:\n" + + " - a: first-a\n" + + " b: first-b\n" + + " - a: second-a\n" + + " b: second-b\n", + true + ); + } + + @Test void simple() throws Exception { + assertConversion( + "some.thing=vvvv\n" + + "some.other.thing=blah\n" + , // ==> + "some:\n" + + " thing: vvvv\n" + + " other:\n" + + " thing: blah\n", + true + ); + } + + @Test void noReplacement() throws Exception { + assertConversion( + "some.thing=vvvv\n" + + "some.other.thing=blah\n" + , // ==> + "some:\n" + + " thing: vvvv\n" + + " other:\n" + + " thing: blah\n", + false + ); + } + + @Test void nonStringyValueConversion() throws Exception { + //See: https://www.pivotaltracker.com/story/show/154181583 + //Test that we do not add unnecessary quotes around certain types of values. + assertConversion( + "exponated=123.4E-12\n" + + "server.port=8888\n" + + "foobar.enabled=true\n" + + "foobar.nice=false\n" + + "fractional=0.78\n" + + "largenumber=989898989898989898989898989898989898989898989898989898989898\n" + + "longfractional=-0.989898989898989898989898989898989898989898989898989898989898\n" + , // ==> + "exponated: '123.4E-12'\n" + //quotes are added because conversion to number changes the string value + "server:\n" + + " port: 8888\n" + + "foobar:\n" + + " enabled: true\n" + + " nice: false\n" + + "fractional: 0.78\n" + + "largenumber: 989898989898989898989898989898989898989898989898989898989898\n" + + "longfractional: -0.989898989898989898989898989898989898989898989898989898989898\n", + true + ); + } + + @Test void emptyFileConversion() throws Exception { + assertConversion( + "" + , // ==> + "", + true + ); + } + + @Test void multipleAssignmentProblem() throws Exception { + assertConversion( + "some.property=something\n" + + "some.property=something-else" + , // ==> + "# Conversion to YAML from Properties formar report\n" + + "# Warnings:\n" + + "# - Multiple values [something, something-else] assigned to 'some.property'. Values are merged into a yaml sequence node.\n" + + "some:\n" + + " property:\n" + + " - something\n" + + " - something-else\n" + , + true + ); + } + + @Test void scalarAndMapConflict() throws Exception { + assertConversion( + "some.property=a-scalar\n" + + "some.property.sub=sub-value" + , + "# Conversion to YAML from Properties formar report\n" + + "# Errors:\n" + + "# - Direct assignment 'some.property=a-scalar' can not be combined with sub-property assignment 'some.property.sub...'. Direct assignment is dropped!\n" + + "some:\n" + + " property:\n" + + " sub: sub-value\n" + , + true + ); + } + + @Test void scalarAndSequenceConflict() throws Exception { + assertConversion( + "some.property=a-scalar\n" + + "some.property[0]=zero\n" + + "some.property[1]=one\n" + , + "# Conversion to YAML from Properties formar report\n" + + "# Errors:\n" + + "# - Direct assignment 'some.property=a-scalar' can not be combined with sequence assignment 'some.property[0]...'. Direct assignments are dropped!\n" + + "some:\n" + + " property:\n" + + " - zero\n" + + " - one\n" + , + true + ); + } + + @Test public void mapAndSequenceConflict() throws Exception { + assertConversion( + "some.property.abc=val1\n" + + "some.property.def=val2\n" + + "some.property[0]=zero\n" + + "some.property[1]=one\n" + , + "# Conversion to YAML from Properties formar report\n" + + "# Warnings:\n" + + "# - 'some.property' has some entries that look like list items and others that look like map entries. All these entries are treated as map entries!\n" + + "some:\n" + + " property:\n" + + " abc: val1\n" + + " def: val2\n" + + " '0': zero\n" + + " '1': one\n" + , + true + ); + } + + @Test public void scalarAndMapAndSequenceConflict() throws Exception { + assertConversion( + "some.property=a-scalar\n" + + "some.property.abc=val1\n" + + "some.property.def=val2\n" + + "some.property[0]=zero\n" + + "some.property[1]=one\n" + , + "# Conversion to YAML from Properties formar report\n" + + "# Errors:\n" + + "# - Direct assignment 'some.property=a-scalar' can not be combined with sub-property assignment 'some.property.abc...'. Direct assignment is dropped!\n" + + "# Warnings:\n" + + "# - 'some.property' has some entries that look like list items and others that look like map entries. All these entries are treated as map entries!\n" + + "some:\n" + + " property:\n" + + " abc: val1\n" + + " def: val2\n" + + " '0': zero\n" + + " '1': one\n" + , + true + ); + } + + @Test void lineComment() throws Exception { + assertConversion( + "some.thing=vvvv\n" + + "# Line comment\n" + + "some.other.thing=blah\n" + , // ==> + "# Conversion to YAML from Properties formar report\n" + + "# Warnings:\n" + + "# - The yaml file had comments which are lost in the refactoring!\n" + + "some:\n" + + " thing: vvvv\n" + + " other:\n" + + " thing: blah\n", + true + ); + } + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommandTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommandTest.java new file mode 100644 index 000000000..fccbe4286 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/properties/YamlToPropertiesCommandTest.java @@ -0,0 +1,263 @@ +/******************************************************************************* + * Copyright (c) 2024 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.properties; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; + +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.ShowDocumentResult; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.ide.vscode.boot.bootiful.BootLanguageServerTest; +import org.springframework.ide.vscode.boot.bootiful.SymbolProviderTestConf; +import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; +import org.springframework.ide.vscode.languageserver.testharness.LanguageServerHarness; +import org.springframework.ide.vscode.project.harness.ProjectsHarness; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@BootLanguageServerTest +@Import({SymbolProviderTestConf.class}) +public class YamlToPropertiesCommandTest { + + private static final String FILENAME_SUFFIX = "-conversion-test"; + + @Autowired private LanguageServerHarness harness; + @Autowired private JavaProjectFinder projectFinder; + + private File directory; + + @BeforeEach public void setup() throws Exception { + harness.intialize(null); + + directory = new File(ProjectsHarness.class.getResource("/test-projects/test-spring-validations/").toURI()); + + Files.walk(directory.toPath(), Integer.MAX_VALUE).filter(Files::isRegularFile).filter(p -> p.getFileName().toString().contains(FILENAME_SUFFIX)).forEach(t -> { + try { + Files.delete(t); + } catch (IOException e) { + + } + }); + + String projectDir = directory.toURI().toString(); + + // trigger project creation + projectFinder.find(new TextDocumentIdentifier(projectDir)).get(); + } + + @AfterEach public void tearDown() throws Exception { + if (directory != null && directory.exists()) { + Files.walk(directory.toPath(), Integer.MAX_VALUE).filter(Files::isRegularFile).filter(p -> p.getFileName().toString().contains(FILENAME_SUFFIX)).forEach(t -> { + try { + Files.delete(t); + } catch (IOException e) { + + } + }); + } + } + + @Test void simple() throws Exception { + assertConversion(""" + some: + other: + thing: blah + thing: vvvv + """, + """ + some.other.thing=blah + some.thing=vvvv + """, + true); + } + + @Test void noReplacement() throws Exception { + assertConversion(""" + some: + other: + thing: blah + thing: vvvv + """, + """ + some.other.thing=blah + some.thing=vvvv + """, + false); + } + + @Test public void almostHasComments() throws Exception { + assertConversion( + "my:\n" + + " goodbye: 'See ya # later'\n" + + " hello: Good morning!\n" + , // ==> + "my.goodbye=See ya \\# later\n" + + "my.hello=Good morning\\!\n", + true + ); + } + + @Test public void listItems() throws Exception { + assertConversion( + "some:\n" + + " thing:\n" + + " - a: first-a\n" + + " b: first-b\n" + + " - a: second-a\n" + + " b: second-b\n" + , // ==> + "some.thing[0].a=first-a\n" + + "some.thing[0].b=first-b\n" + + "some.thing[1].a=second-a\n" + + "some.thing[1].b=second-b\n", + true + ); + } + + @Test public void list() throws Exception { + assertConversion( + "some:\n" + + " property:\n" + + " - something\n" + + " - something-else\n" + , // ==> + "some.property[0]=something\n" + + "some.property[1]=something-else\n", + true + ); + } + + @Test public void mapAndSequenceConflict() throws Exception { + assertConversion( + "some:\n" + + " property:\n" + + " '0': zero\n" + + " '1': one\n" + + " abc: val1\n" + + " def: val2\n" + , + "some.property.0=zero\n" + + "some.property.1=one\n" + + "some.property.abc=val1\n" + + "some.property.def=val2\n", + true + ); + } + + @Test public void multipleDocsConversion() throws Exception { + assertConversion( + "some:\n" + + " other:\n" + + " thing: blah\n" + + " thing: vvvv\n" + + "\n" + + "---\n" + + "some:\n" + + " other:\n" + + " thing: blah\n" + + " thing: vvvv\n" + + "\n" + + "---\n" + + "some:\n" + + " other:\n" + + " thing: blah\n" + + " thing: vvvv\n" + , // ==> + "some.other.thing=blah\n" + + "some.thing=vvvv\n" + + "#---\n" + + "some.other.thing=blah\n" + + "some.thing=vvvv\n" + + "#---\n" + + "some.other.thing=blah\n" + + "some.thing=vvvv\n", + true + ); + } + + @Test void lineComment() throws Exception { + assertConversion(""" + some: + # Comment about line + other: + thing: blah + thing: vvvv + """, + """ + # Conversion to YAML from Properties formar report + # Warnings: + # - The yaml file had comments which are lost in the refactoring! + some.other.thing=blah + some.thing=vvvv + """, + true); + } + + @Test void inlineComment() throws Exception { + assertConversion(""" + some: + other: + thing: blah # Inline Comment + thing: vvvv + """, + """ + # Conversion to YAML from Properties formar report + # Warnings: + # - The yaml file had comments which are lost in the refactoring! + some.other.thing=blah + some.thing=vvvv + """, + true); + } + + void assertConversion(String yamlContent, String propsContent, boolean replace) throws Exception { + Path yamlFilePath = directory.toPath().resolve("src/main/resources/application" + FILENAME_SUFFIX + ".yml"); + Path propsFilePath = directory.toPath().resolve("src/main/resources/application" + FILENAME_SUFFIX + ".properties"); + + if (!Files.exists(yamlFilePath)) { + Files.createDirectories(yamlFilePath.getParent()); + Files.createFile(yamlFilePath); + Files.write(yamlFilePath, yamlContent.getBytes(), StandardOpenOption.APPEND); + } + + Command cmd = new Command(); + cmd.setCommand(YamlToPropertiesCommand.CMD_YAML_TO_PROPS); + cmd.setArguments(List.of(yamlFilePath.toUri().toASCIIString(), propsFilePath.toUri().toASCIIString(), replace)); + cmd.setTitle("Convert .yaml to .properties"); + + ShowDocumentResult res = (ShowDocumentResult) harness.perform(cmd); + + assertThat(res.isSuccess()).isTrue(); + + assertThat(Files.exists(propsFilePath)).isTrue(); + if (replace) { + assertThat(Files.exists(yamlFilePath)).isFalse(); + } else { + assertThat(Files.exists(yamlFilePath)).isTrue(); + } + + assertThat(Files.readString(propsFilePath)).isEqualTo(propsContent); + } + +} diff --git a/vscode-extensions/vscode-spring-boot/lib/Main.ts b/vscode-extensions/vscode-spring-boot/lib/Main.ts index fbf49f803..51769e1b2 100644 --- a/vscode-extensions/vscode-spring-boot/lib/Main.ts +++ b/vscode-extensions/vscode-spring-boot/lib/Main.ts @@ -20,6 +20,7 @@ import {registerClasspathService} from "@pivotal-tools/commons-vscode/lib/classp import {registerJavaDataService} from "@pivotal-tools/commons-vscode/lib/java-data"; import * as setLogLevelUi from './set-log-levels-ui'; import { startTestJarSupport } from "./test-jar-launch"; +import { startPropertiesConversionSupport } from "./convert-props-yaml"; const PROPERTIES_LANGUAGE_ID = "spring-boot-properties"; const YAML_LANGUAGE_ID = "spring-boot-properties-yaml"; @@ -157,7 +158,16 @@ export function activate(context: ExtensionContext): Thenable { liveHoverUi.activate(client, options, context); rewrite.activate(client, options, context); setLogLevelUi.activate(client, options, context); + startPropertiesConversionSupport(context); + registerMiscCommands(context); + + return new ApiManager(client).api; + }); +} + +function registerMiscCommands(context: ExtensionContext) { + context.subscriptions.push( commands.registerCommand('vscode-spring-boot.spring.modulith.metadata.refresh', async () => { const modulithProjects = await commands.executeCommand('sts/modulith/projects'); const projectNames = Object.keys(modulithProjects); @@ -170,14 +180,12 @@ export function activate(context: ExtensionContext): Thenable { ); commands.executeCommand('sts/modulith/metadata/refresh', modulithProjects[projectName]); } - }); + }), commands.registerCommand('vscode-spring-boot.open.url', (openUrl) => { const openWithExternalBrowser = workspace.getConfiguration("spring.tools").get("openWith") === "external"; const browserCommand = openWithExternalBrowser ? "vscode.open" : "simpleBrowser.api.open"; return commands.executeCommand(browserCommand, Uri.parse(openUrl)); - }); - - return new ApiManager(client).api; - }); + }), + ); } diff --git a/vscode-extensions/vscode-spring-boot/lib/convert-props-yaml.ts b/vscode-extensions/vscode-spring-boot/lib/convert-props-yaml.ts new file mode 100644 index 000000000..857daefa1 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/convert-props-yaml.ts @@ -0,0 +1,56 @@ +import * as path from "path"; +import { existsSync } from "fs"; +import { ExtensionContext, Uri, commands, window, workspace } from "vscode"; + +export function startPropertiesConversionSupport(extension: ExtensionContext) { + extension.subscriptions.push( + commands.registerCommand('vscode-spring-boot.props-to-yaml', async (uri) => { + + if (!uri && window.activeTextEditor) { + const activeUri = window.activeTextEditor.document.uri; + if (".properties" === path.extname(activeUri.path)) { + uri = activeUri; + } + } + + if (!uri) { + throw new Error("No '.properties' file selected"); + } + + return await commands.executeCommand("sts/boot/props-to-yaml", uri.toString(), Uri.file(getTargetFile(uri.path, "yml")).toString(), shouldReplace()); + }), + + commands.registerCommand('vscode-spring-boot.yaml-to-props', async (uri) => { + + if (!uri && window.activeTextEditor) { + const activeUri = window.activeTextEditor.document.uri; + const ext = path.extname(activeUri.path) + if (".yml" === ext || ".yaml" === ext) { + uri = activeUri; + } + } + + if (!uri) { + throw new Error("No '.yaml' file selected"); + } + + return await commands.executeCommand("sts/boot/yaml-to-props", uri.toString(), Uri.file(getTargetFile(uri.path, "properties")).toString(), shouldReplace()); + }) + ); +} + +function getTargetFile(sourcePath: string, ext: string): string { + const dir = path.dirname(sourcePath); + const fileName = path.basename(sourcePath); + const filenameNoExt = path.basename(sourcePath).substring(0, fileName.length - path.extname(fileName).length); + let targetPath = path.join(dir, `${filenameNoExt}.${ext}`); + for (let i = 1; i < Number.MAX_SAFE_INTEGER && existsSync(targetPath); i++) { + targetPath = path.join(dir, `${filenameNoExt}-${i}.${ext}`) + } + return targetPath; +} + +function shouldReplace(): boolean { + return workspace.getConfiguration("spring.tools.properties").get("replace-converted-file"); +} + diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json index 76e4f7dfb..13f67e130 100644 --- a/vscode-extensions/vscode-spring-boot/package.json +++ b/vscode-extensions/vscode-spring-boot/package.json @@ -82,6 +82,16 @@ "when": "resourceFilename == pom.xml || resourceFilename == build.gradle", "command": "vscode-spring-boot.rewrite.list.boot-upgrades", "group": "SpringBoot" + }, + { + "when": "editorLangId == spring-boot-properties", + "command": "vscode-spring-boot.props-to-yaml", + "group": "SpringBoot" + }, + { + "when": "editorLangId == spring-boot-properties-yaml", + "command": "vscode-spring-boot.yaml-to-props", + "group": "SpringBoot" } ], "explorer/context": [ @@ -94,6 +104,26 @@ "when": "(resourceFilename == pom.xml || resourceFilename == build.gradle) && config.boot-java.rewrite.refactorings.on == true", "command": "vscode-spring-boot.rewrite.list.boot-upgrades", "group": "SpringBoot" + }, + { + "when": "resourceExtname == .properties", + "command": "vscode-spring-boot.props-to-yaml", + "group": "SpringBoot" + }, + { + "when": "resourceExtname == .yml || resourceExtname == .yaml", + "command": "vscode-spring-boot.yaml-to-props", + "group": "SpringBoot" + } + ], + "commandPalette": [ + { + "command": "vscode-spring-boot.props-to-yaml", + "when": "false" + }, + { + "command": "vscode-spring-boot.yaml-to-props", + "when": "false" } ] }, @@ -140,6 +170,16 @@ "title": "Open Boot App Page URL", "category": "Spring Boot", "enablement": "never" + }, + { + "command": "vscode-spring-boot.props-to-yaml", + "title": "Convert .properties to .yaml", + "category": "Spring Boot" + }, + { + "command": "vscode-spring-boot.yaml-to-props", + "title": "Convert .yaml to .properties", + "category": "Spring Boot" } ], "configuration": [ @@ -245,6 +285,11 @@ "default": true, "description": "Enable/Disable Spring Boot TestJars launch environment variables" }, + "spring.tools.properties.replace-converted-file": { + "type": "boolean", + "default": false, + "description": "Replace converted properties file" + }, "spring.tools.openWith": { "default": "integrated", "type": "string",