Yaml<->Props conversion commands, tests, integration in VSCode, Eclipse

This commit is contained in:
aboyko
2024-03-26 23:37:29 -04:00
parent e528454f2c
commit aed212796f
22 changed files with 1622 additions and 40 deletions

View File

@@ -17,6 +17,7 @@
name="Convert .yaml to .properties">
</command>
</extension>
<!--
<extension
point="org.eclipse.ui.menus">
<menuContribution
@@ -90,6 +91,7 @@
</menuContribution>
</extension>
-->
<extension point="org.eclipse.ltk.core.refactoring.renameParticipants">
<renameParticipant class="org.springframework.ide.eclipse.boot.refactoring.typerename.TypeRenameParticipant" id="org.springframework.ide.eclipse.boot.refactoring.TypeRenameParticipant" name="Rename Companion Type Rename Participant">

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

View File

@@ -280,6 +280,14 @@
class="org.springframework.tooling.boot.ls.commands.AddStartersHandler"
commandId="spring.initializr.addStarters">
</handler>
<handler
class="org.springframework.tooling.boot.ls.commands.ConvertBootPropertiesHanlder$ConvertYamlToPropertiesHanlder"
commandId="org.springframework.tooling.boot.ls.properties.convert-yaml-to-props">
</handler>
<handler
class="org.springframework.tooling.boot.ls.commands.ConvertBootPropertiesHanlder$ConvertPropertiesToYamlHandler"
commandId="org.springframework.tooling.boot.ls.properties.convert-props-to-yaml">
</handler>
</extension>
<extension
point="org.eclipse.ui.commands">
@@ -329,6 +337,16 @@
typeId="org.eclipse.lsp4e.pathParameterType">
</commandParameter>
</command>
<command
description="Converts Spring Boot properties file from .properties format to .yaml format"
id="org.springframework.tooling.boot.ls.properties.convert-yaml-to-props"
name="Convert .yaml to .properties">
</command>
<command
description="Converts Spring Boot properties file from .yaml format to .properties format"
id="org.springframework.tooling.boot.ls.properties.convert-props-to-yaml"
name="Convert .properties to .yaml">
</command>
</extension>
<extension
@@ -765,6 +783,72 @@
</and>
</visibleWhen>
</command>
<command
commandId="org.springframework.tooling.boot.ls.properties.convert-props-to-yaml"
icon="icons/boot-icon.png">
<visibleWhen
checkEnabled="false">
<and>
<count
value="1">
</count>
<with
variable="activeMenuSelection">
<iterate
ifEmpty="false">
<adapt
type="org.eclipse.core.resources.IResource">
<and>
<test
property="org.springsource.ide.eclipse.boot.isBootResource">
</test>
<test
property="org.eclipse.core.resources.extension"
value="properties">
</test>
</and>
</adapt>
</iterate>
</with>
</and>
</visibleWhen>
</command>
<command
commandId="org.springframework.tooling.boot.ls.properties.convert-yaml-to-props"
icon="icons/boot-icon.png">
<visibleWhen
checkEnabled="false">
<and>
<count
value="1">
</count>
<with
variable="activeMenuSelection">
<iterate
ifEmpty="false">
<adapt
type="org.eclipse.core.resources.IResource">
<and>
<test
property="org.springsource.ide.eclipse.boot.isBootResource">
</test>
<or>
<test
property="org.eclipse.core.resources.extension"
value="yml">
</test>
<test
property="org.eclipse.core.resources.extension"
value="yaml">
</test>
</or>
</and>
</adapt>
</iterate>
</with>
</and>
</visibleWhen>
</command>
</menuContribution>
</extension>
<extension

View File

@@ -46,4 +46,5 @@ public class Constants {
public static final String PREF_COMMON_PROPS_METADATA = "boot-java.common.properties-metadata";
public static final String PREF_MODULITH = "boot-java.modulith-project-tracking";
}

View File

@@ -0,0 +1,158 @@
/*******************************************************************************
* 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.tooling.boot.ls.commands;
import java.util.List;
import org.eclipse.core.commands.AbstractHandler;
import org.eclipse.core.commands.ExecutionEvent;
import org.eclipse.core.commands.ExecutionException;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IAdaptable;
import org.eclipse.core.runtime.IPath;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.jface.viewers.IStructuredSelection;
import org.eclipse.lsp4e.LanguageServers;
import org.eclipse.lsp4e.LanguageServersRegistry;
import org.eclipse.lsp4e.LanguageServersRegistry.LanguageServerDefinition;
import org.eclipse.lsp4j.ExecuteCommandParams;
import org.eclipse.ui.handlers.HandlerUtil;
import org.springframework.tooling.boot.ls.BootLanguageServerPlugin;
@SuppressWarnings("restriction")
public abstract class ConvertBootPropertiesHanlder extends AbstractHandler {
@Override
public Object execute(ExecutionEvent event) throws ExecutionException {
IFile sourceFile = getSourceFile(event);
IProject project = sourceFile.getProject();
if (project != null) {
// No replace functionality for Eclipse due to issues with undoing the change
final boolean replace = false;
// ITextFileBuffer buffer = FileBuffers.getTextFileBufferManager().getTextFileBuffer(sourceFile.getFullPath(), LocationKind.IFILE);
// if (buffer != null && buffer.isDirty()) {
// try {
// buffer.commit(null, true);
// } catch (CoreException e) {
// BootLanguageServerPlugin.getDefault().getLog().log(e.getStatus());
// }
// }
//
// final boolean replace = BootLanguageServerPlugin.getDefault().getPreferenceStore().getBoolean(Constants.PREF_REPLACE_CONVERTED_PROPS_FILE);
//
// if (replace) {
// // Workaround: LSP4E LSPTextEdit doesn't work well with non opened files in the workspace in this particular case
// // Therefore, open the file in an editor thus changes are applied to the document.
// try {
// IDE.openEditor(UI.getActivePage(), sourceFile);
// } catch (PartInitException e) {
// BootLanguageServerPlugin.getDefault().getLog().error("", e);
// }
// }
IFile targetFile = getTargetFile(sourceFile, getTargetExtension());
LanguageServerDefinition def = LanguageServersRegistry.getInstance().getDefinition(BootLanguageServerPlugin.BOOT_LS_DEFINITION_ID);
Assert.isLegal(def != null, "No definition found for Boot Language Server");
ExecuteCommandParams commandParams = new ExecuteCommandParams();
commandParams.setCommand(getCommandId());
commandParams.setArguments(List.of(
sourceFile.getLocationURI().toASCIIString(),
targetFile.getLocationURI().toASCIIString(),
replace
));
LanguageServers.forProject(project).withPreferredServer(def).computeFirst(ls -> 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";
}
}
}

View File

@@ -61,6 +61,7 @@ public class PrefsInitializer extends AbstractPreferenceInitializer {
}));
preferenceStore.setDefault(Constants.PREF_MODULITH, true);
}
}

View File

@@ -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<? extends CodeLens> 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<LanguageServerComponents> subComponents = componentsByLanguageId.get(language);
if (subComponents != null) {
@@ -183,17 +188,15 @@ public class CompositeLanguageServerComponents implements LanguageServerComponen
@Override
public List<? extends WorkspaceSymbol> handle(DocumentSymbolParams params) {
TextDocument doc = server.getTextDocumentService().getLatestSnapshot(params.getTextDocument().getUri());
if (doc != null) {
LanguageId language = doc.getLanguageId();
List<LanguageServerComponents> 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<LanguageServerComponents> 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<InlayHintHandler> 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;
}
}

View File

@@ -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) {

View File

@@ -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<YamlPathSegment> segments = new ArrayList<YamlPathSegment>();
// 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<YamlPathSegment> segments = new ArrayList<YamlPathSegment>();
for (String s : propName.split("\\.")) {
segments.add(YamlPathSegment.valueAt(s));
ImmutableList.Builder<YamlPathSegment> 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.

View File

@@ -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<ShowDocumentResult> 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<Object> 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<Object> untypedParams = new ArrayList<>(jsonArray.size());
jsonArray.forEach(e -> untypedParams.add(e));
getServer().getWorkspaceService()
return getServer().getWorkspaceService()
.executeCommand(new ExecuteCommandParams(command.getCommand(), untypedParams))
.get();
}

View File

@@ -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();

View File

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

View File

@@ -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<ShowDocumentResult> execute(List<Object> 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<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (hasComments(propsContent)) {
warnings.add("The yaml file had comments which are lost in the refactoring!");
}
Multimap<String, String> 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<String, String> load(Reader content) throws IOException {
Multimap<String, String> map = Multimaps.newMultimap(
new LinkedTreeMap<String, Collection<String>>(),
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;
}
}

View File

@@ -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<String> errors;
private ImmutableList.Builder<String> warnings;
class YamlBuilder {
final YamlPath path;
final List<Object> scalars = new ArrayList<>();
final LinkedTreeMap<Integer, YamlBuilder> listItems = new LinkedTreeMap<>();
final LinkedTreeMap<String, YamlBuilder> 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 <T> YamlBuilder getSubBuilder(LinkedTreeMap<T, YamlBuilder> 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<Integer, YamlBuilder> 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<String, Object> map = new LinkedTreeMap<>();
for (Entry<String, YamlBuilder> entry : mapEntries.entrySet()) {
map.put(entry.getKey(), entry.getValue().build());
}
return map;
}
}
}
public PropertiesToYamlConverter(Multimap<String, String> properties) {
this.errors = ImmutableList.builder();
this.warnings = ImmutableList.builder();
if (properties.isEmpty()) {
output = "";
return;
}
YamlBuilder root = new YamlBuilder(YamlPath.EMPTY);
for (Entry<String, String> 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<String> getErrors() {
return errors.build();
}
public List<String> getWarnings() {
return warnings.build();
}
public String getYaml() {
return output;
}
}

View File

@@ -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<ShowDocumentResult> execute(List<Object> 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<String> errors = new ArrayList<>();
List<String> 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<String, ?> o = (Map<String, ?>) 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<String> errors, List<String> 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));
}
}
}
}
}

View File

@@ -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<String, ?> yaml) {
this.properties = new Properties() {
private static final long serialVersionUID = 1L;
private LinkedHashMap<Object, Object> delegate = new LinkedHashMap<>();
@Override
public synchronized Object put(Object key, Object value) {
delegate.put(key, value);
return super.put(key, value);
}
@Override
public Set<Map.Entry<Object, Object>> entrySet() {
return delegate.entrySet();
}
};
for (Map.Entry<String, ?> e : yaml.entrySet()) {
readProperties(e.getValue(), e.getKey());
}
}
private void readPropertiesFromYamlMap(Map<String, ?> map, String prefix) {
for (Map.Entry<String, ?> 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<String, ?>) o, prefix);
} else if ( o instanceof List) {
readPropertiesFromYamlList((List<?>) o, prefix);
} else {
properties.put(prefix, o.toString());
}
}
public Properties getProperties() {
return properties;
}
}

View File

@@ -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
);
}
}

View File

@@ -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);
}
}

View File

@@ -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<ExtensionAPI> {
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<ExtensionAPI> {
);
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;
});
}),
);
}

View File

@@ -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");
}

View File

@@ -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",