diff --git a/CHANGELOG.md b/CHANGELOG.md index 08300be0f..f4ed8fb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Fixed issues: Breaking API changes: + * The Message class now has a new transient field, `jsonHandler`, to enable the `toString` implementation to properly format messages when custom type adapters are used. + * For consumers that have their own custom serializers or other reflective message processors they may need to be updated to ensure that transient fields are skipped, for example by using [`Modifier.isTransient`](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/reflect/Modifier.html#isTransient(int)) + * See [#768](https://github.com/eclipse-lsp4j/lsp4j/issues/768) for detailed discussion. * The name field in WorkspaceFolder is no longer optional according to the specification. * See [#741](https://github.com/eclipse-lsp4j/lsp4j/issues/741) for detailed discussion. diff --git a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugLauncher.java b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugLauncher.java index 6c60a43f7..ce1862fd5 100644 --- a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugLauncher.java +++ b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugLauncher.java @@ -218,6 +218,7 @@ protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { else remoteEndpoint = new DebugRemoteEndpoint(outgoingMessageStream, localEndpoint, exceptionHandler); jsonHandler.setMethodProvider(remoteEndpoint); + remoteEndpoint.setJsonHandler(jsonHandler); return remoteEndpoint; } diff --git a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugRemoteEndpoint.java b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugRemoteEndpoint.java index 2290681d0..4ee23dbb2 100644 --- a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugRemoteEndpoint.java +++ b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/DebugRemoteEndpoint.java @@ -38,6 +38,7 @@ public DebugRemoteEndpoint(MessageConsumer out, Endpoint localEndpoint, @Override protected DebugRequestMessage createRequestMessage(String method, Object parameter) { DebugRequestMessage requestMessage = new DebugRequestMessage(); + requestMessage.setJsonHandler(getJsonHandler()); requestMessage.setId(nextSeqId.incrementAndGet()); requestMessage.setMethod(method); requestMessage.setParams(parameter); @@ -47,6 +48,7 @@ protected DebugRequestMessage createRequestMessage(String method, Object paramet @Override protected DebugResponseMessage createResponseMessage(RequestMessage requestMessage) { DebugResponseMessage responseMessage = new DebugResponseMessage(); + responseMessage.setJsonHandler(getJsonHandler()); responseMessage.setResponseId(nextSeqId.incrementAndGet()); responseMessage.setRawId(requestMessage.getRawId()); responseMessage.setMethod(requestMessage.getMethod()); @@ -56,6 +58,7 @@ protected DebugResponseMessage createResponseMessage(RequestMessage requestMessa @Override protected DebugNotificationMessage createNotificationMessage(String method, Object parameter) { DebugNotificationMessage notificationMessage = new DebugNotificationMessage(); + notificationMessage.setJsonHandler(getJsonHandler()); notificationMessage.setId(nextSeqId.incrementAndGet()); notificationMessage.setMethod(method); notificationMessage.setParams(parameter); diff --git a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/adapters/DebugMessageTypeAdapter.java b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/adapters/DebugMessageTypeAdapter.java index e7b1a6b06..a25493cbc 100644 --- a/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/adapters/DebugMessageTypeAdapter.java +++ b/org.eclipse.lsp4j.jsonrpc.debug/src/main/java/org/eclipse/lsp4j/jsonrpc/debug/adapters/DebugMessageTypeAdapter.java @@ -343,6 +343,7 @@ private Message createMessage(String messageType, int seq, int request_seq, Stri switch (messageType) { case "request": { DebugRequestMessage message = new DebugRequestMessage(); + message.setJsonHandler(handler); message.setId(seq); message.setMethod(method); message.setParams(params); @@ -350,6 +351,7 @@ private Message createMessage(String messageType, int seq, int request_seq, Stri } case "event": { DebugNotificationMessage message = new DebugNotificationMessage(); + message.setJsonHandler(handler); message.setId(seq); message.setMethod(method); message.setParams(body); @@ -357,6 +359,7 @@ private Message createMessage(String messageType, int seq, int request_seq, Stri } case "response": { DebugResponseMessage message = new DebugResponseMessage(); + message.setJsonHandler(handler); message.setId(request_seq); message.setResponseId(seq); message.setMethod(method); diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java index b543a739e..600c1a4dd 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java @@ -317,6 +317,9 @@ public Launcher create() { // Create the JSON handler, remote endpoint and remote proxy MessageJsonHandler jsonHandler = createJsonHandler(); + if (messageTracer != null) { + messageTracer.setJsonHandler(jsonHandler); + } RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); T remoteProxy = createProxy(remoteEndpoint); @@ -352,6 +355,7 @@ protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { else remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, localEndpoint, exceptionHandler); jsonHandler.setMethodProvider(remoteEndpoint); + remoteEndpoint.setJsonHandler(jsonHandler); return remoteEndpoint; } diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/MessageTracer.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/MessageTracer.java index 8398f6f19..948323f66 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/MessageTracer.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/MessageTracer.java @@ -11,8 +11,6 @@ ******************************************************************************/ package org.eclipse.lsp4j.jsonrpc; -import org.eclipse.lsp4j.jsonrpc.TracingMessageConsumer.RequestMetadata; - import java.io.PrintWriter; import java.time.Clock; import java.util.HashMap; @@ -20,6 +18,9 @@ import java.util.Objects; import java.util.function.Function; +import org.eclipse.lsp4j.jsonrpc.TracingMessageConsumer.RequestMetadata; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; + /** * Wraps a {@link MessageConsumer} with one that logs in a way that the LSP Inspector can parse. * * https://microsoft.github.io/language-server-protocol/inspector/ @@ -28,14 +29,21 @@ public class MessageTracer implements Function private final PrintWriter printWriter; private final Map sentRequests = new HashMap<>(); private final Map receivedRequests = new HashMap<>(); + private MessageJsonHandler jsonHandler; MessageTracer(PrintWriter printWriter) { this.printWriter = Objects.requireNonNull(printWriter); } + public void setJsonHandler(MessageJsonHandler jsonHandler) { + this.jsonHandler = jsonHandler; + } + @Override public MessageConsumer apply(MessageConsumer messageConsumer) { - return new TracingMessageConsumer( + TracingMessageConsumer tracingMessageConsumer = new TracingMessageConsumer( messageConsumer, sentRequests, receivedRequests, printWriter, Clock.systemDefaultZone()); + tracingMessageConsumer.setJsonHandler(jsonHandler); + return tracingMessageConsumer; } } diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/RemoteEndpoint.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/RemoteEndpoint.java index 0907da8e3..8f8b42345 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/RemoteEndpoint.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/RemoteEndpoint.java @@ -74,6 +74,7 @@ private static ResponseError fallbackResponseError(String header, Throwable thro private final MessageConsumer out; private final Endpoint localEndpoint; private final Function exceptionHandler; + private MessageJsonHandler jsonHandler; private final AtomicInteger nextRequestId = new AtomicInteger(); private final Map sentRequestMap = new LinkedHashMap<>(); @@ -116,6 +117,14 @@ public RemoteEndpoint(MessageConsumer out, Endpoint localEndpoint) { this(out, localEndpoint, DEFAULT_EXCEPTION_HANDLER); } + public void setJsonHandler(MessageJsonHandler jsonHandler) { + this.jsonHandler = jsonHandler; + } + + public MessageJsonHandler getJsonHandler() { + return jsonHandler; + } + /** * Send a notification to the remote endpoint. */ @@ -132,6 +141,7 @@ public void notify(String method, Object parameter) { protected NotificationMessage createNotificationMessage(String method, Object parameter) { NotificationMessage notificationMessage = new NotificationMessage(); + notificationMessage.setJsonHandler(getJsonHandler()); notificationMessage.setJsonrpc(MessageConstants.JSONRPC_VERSION); notificationMessage.setMethod(method); notificationMessage.setParams(parameter); @@ -168,6 +178,7 @@ public boolean cancel(boolean mayInterruptIfRunning) { protected RequestMessage createRequestMessage(String method, Object parameter) { RequestMessage requestMessage = new RequestMessage(); + requestMessage.setJsonHandler(getJsonHandler()); requestMessage.setId(String.valueOf(nextRequestId.incrementAndGet())); requestMessage.setMethod(method); requestMessage.setParams(parameter); @@ -361,6 +372,7 @@ protected void handleResponseIssues(ResponseMessage responseMessage, List supportedMethods; private MethodProvider methodProvider; @@ -119,6 +121,8 @@ public Message parseMessage(Reader input) throws JsonParseException { Message message = gson.fromJson(jsonReader, Message.class); if (message != null) { + message.setJsonHandler(this); + // Check whether the input has been fully consumed try { if (jsonReader.peek() != JsonToken.END_DOCUMENT) { @@ -144,10 +148,32 @@ public String serialize(Message message) { public void serialize(Message message, Writer output) throws JsonIOException { gson.toJson(message, Message.class, output); } - - + + /** + * Perform JSON serialization of the given object using the configuration of JSON-RPC messages + * enhanced with the pretty printing option. + */ + public String format(Object object) { + StringWriter writer = new StringWriter(); + JsonWriter jsonWriter = null; + try { + jsonWriter = gson.newJsonWriter(writer); + // Equivalent to set pretty printing on the gson builder + jsonWriter.setIndent(" "); + } catch (IOException e) { + throw new JsonIOException(e); + } + if (object != null) { + gson.toJson(object, object.getClass(), jsonWriter); + } else { + gson.toJson(JsonNull.INSTANCE, jsonWriter); + } + return writer.toString(); + } + + private static MessageJsonHandler toStringInstance; - + /** * Perform JSON serialization of the given object using the default configuration of JSON-RPC messages * enhanced with the pretty printing option. @@ -160,5 +186,5 @@ public static String toString(Object object) { } return toStringInstance.gson.toJson(object); } - + } diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/adapters/MessageTypeAdapter.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/adapters/MessageTypeAdapter.java index 634761ea8..64f0c4910 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/adapters/MessageTypeAdapter.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/adapters/MessageTypeAdapter.java @@ -379,6 +379,7 @@ protected Message createMessage(String jsonrpc, Either id, Strin Object responseResult, ResponseError responseError) throws JsonParseException { if (id != null && method != null) { RequestMessage message = new RequestMessage(); + message.setJsonHandler(handler); message.setJsonrpc(jsonrpc); message.setRawId(id); message.setMethod(method); @@ -386,6 +387,7 @@ protected Message createMessage(String jsonrpc, Either id, Strin return message; } else if (id != null) { ResponseMessage message = new ResponseMessage(); + message.setJsonHandler(handler); message.setJsonrpc(jsonrpc); message.setRawId(id); if (responseError != null) @@ -395,6 +397,7 @@ protected Message createMessage(String jsonrpc, Either id, Strin return message; } else if (method != null) { NotificationMessage message = new NotificationMessage(); + message.setJsonHandler(handler); message.setJsonrpc(jsonrpc); message.setMethod(method); message.setParams(params); diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/CancelParams.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/CancelParams.java index 2c6a35ff6..c38bb25e7 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/CancelParams.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/CancelParams.java @@ -14,6 +14,8 @@ import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; import org.eclipse.lsp4j.jsonrpc.validation.NonNull; +import com.google.gson.JsonIOException; + /** * To cancel a request a notification message with the following properties is sent. */ @@ -55,7 +57,17 @@ public void setRawId(@NonNull Either id) { @Override public String toString() { - return MessageJsonHandler.toString(this); + try { + return MessageJsonHandler.toString(this); + } catch (JsonIOException e) { + return toStringFallback(); + } + } + + protected String toStringFallback() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.addAllFields(); + return builder.toString(); } @Override diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/Message.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/Message.java index 5d4be746c..7e51dc51e 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/Message.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/Message.java @@ -11,16 +11,34 @@ ******************************************************************************/ package org.eclipse.lsp4j.jsonrpc.messages; +import java.lang.reflect.Modifier; + import org.eclipse.lsp4j.jsonrpc.json.MessageConstants; import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; import org.eclipse.lsp4j.jsonrpc.validation.NonNull; +import com.google.gson.JsonIOException; + /** * A general message as defined by JSON-RPC. The language server protocol always * uses "2.0" as the jsonrpc version. */ public abstract class Message { + private transient MessageJsonHandler jsonHandler; + + // Note: 'getJsonHandler' is not used as the name of the accessor method + // to avoid treating 'jsonHandler' as a general property of the message + // by reflective code such as ReflectiveMessageValidator. + + public MessageJsonHandler jsonHandler() { + return jsonHandler; + } + + public void setJsonHandler(MessageJsonHandler jsonHandler) { + this.jsonHandler = jsonHandler; + } + @NonNull private String jsonrpc = MessageConstants.JSONRPC_VERSION; @@ -35,7 +53,17 @@ public void setJsonrpc(@NonNull String jsonrpc) { @Override public String toString() { - return MessageJsonHandler.toString(this); + try { + return jsonHandler != null ? jsonHandler.format(this) : MessageJsonHandler.toString(this); + } catch (JsonIOException e) { + return toStringFallback(); + } + } + + protected String toStringFallback() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.addAllFields(field -> !Modifier.isTransient(field.getModifiers())); + return builder.toString(); } @Override diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ResponseError.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ResponseError.java index 64b8888d5..1c1033b85 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ResponseError.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ResponseError.java @@ -15,6 +15,7 @@ import org.eclipse.lsp4j.jsonrpc.json.adapters.JsonElementTypeAdapter; import org.eclipse.lsp4j.jsonrpc.validation.NonNull; +import com.google.gson.JsonIOException; import com.google.gson.annotations.JsonAdapter; public class ResponseError { @@ -83,7 +84,17 @@ public ResponseError(int code, String message, Object data) { @Override public String toString() { - return MessageJsonHandler.toString(this); + try { + return MessageJsonHandler.toString(this); + } catch (JsonIOException e) { + return toStringFallback(); + } + } + + protected String toStringFallback() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.addAllFields(); + return builder.toString(); } @Override diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ToStringBuilder.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ToStringBuilder.java new file mode 100644 index 000000000..0ece764ce --- /dev/null +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/messages/ToStringBuilder.java @@ -0,0 +1,412 @@ +/** + * Copyright (c) 2014, 2018 itemis AG (http://www.itemis.eu) and others. + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.jsonrpc.messages; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.function.Predicate; + + +/** + * Helps with the construction of good {@link Object#toString()} representations. + *

You can customize the output using the builder-style methods {@link ToStringBuilder#singleLine()} {@link ToStringBuilder#skipNulls()} and {@link ToStringBuilder#hideFieldNames()}.

+ *

You can either directly list fields to include via {@link ToStringBuilder#add(String, Object)} and {@link ToStringBuilder#add(Object)} + * or you can let the builder do it automatically using reflection, either including the fields declared in this class or including all superclasses.

+ *

The builder will automatically handle cycles in the object tree. It also pretty prints arrays and Iterables.

+ * + * This class is not thread safe. + */ +final class ToStringBuilder { + + public static class ToStringContext { + + public final static ToStringContext INSTANCE = new ToStringContext(); + + private final static ThreadLocal> currentlyProcessed = new ThreadLocal>() { + @Override + public IdentityHashMap initialValue() { + return new IdentityHashMap(); + } + }; + + public boolean startProcessing(final Object obj) { + return ToStringContext.currentlyProcessed.get().put(obj, Boolean.TRUE) == null; + } + + public void endProcessing(final Object obj) { + ToStringContext.currentlyProcessed.get().remove(obj); + } + } + + private static ToStringContext toStringContext = ToStringContext.INSTANCE; + + private final Object instance; + + private final String typeName; + + private boolean multiLine = true; + + private boolean skipNulls = false; + + private boolean showFieldNames = true; + + private boolean prettyPrint = true; + + private final List parts = new ArrayList(); + + /** + * Creates a new ToStringBuilder for the given object. If you don't use reflection, then this instance + * is only used for obtaining its classes' simple name. + * + * @param instance the object to convert to a String + */ + public ToStringBuilder(final Object instance) { + this.instance = instance; + this.typeName = instance.getClass().getSimpleName(); + } + + /** + * Fields are printed on a single line, separated by commas instead of newlines + * @return this + */ + public ToStringBuilder singleLine() { + this.multiLine = false; + return this; + } + + /** + * Fields with null values will be excluded from the output + * @return this + */ + public ToStringBuilder skipNulls() { + this.skipNulls = true; + return this; + } + + /** + * Field names will not be included in the output. Useful for small classes. + * @return this + */ + public ToStringBuilder hideFieldNames() { + this.showFieldNames = false; + return this; + } + + /** + * By default, Iterables, Arrays and multiline Strings are pretty-printed. + * Switching to their normal representation makes the toString method significantly faster. + * @since 2.9 + * @return this + */ + public ToStringBuilder verbatimValues() { + this.prettyPrint = false; + return this; + } + + /** + * Adds all fields declared directly in the object's class to the output + * @return this + */ + public ToStringBuilder addDeclaredFields() { + Field[] fields = instance.getClass().getDeclaredFields(); + for(Field field : fields) { + addField(field); + } + return this; + } + + /** + * Adds all fields declared directly in the object's class + * that satisfy the given condition to the output + * @param condition + * @return this + */ + public ToStringBuilder addDeclaredFields(Predicate condition) { + Field[] fields = instance.getClass().getDeclaredFields(); + for(Field field : fields) { + if (condition.test(field)) { + addField(field); + } + } + return this; + } + + /** + * Adds all fields declared in the object's class and its superclasses to the output. + * @return this + */ + public ToStringBuilder addAllFields() { + List fields = getAllDeclaredFields(instance.getClass()); + for(Field field : fields) { + addField(field); + } + return this; + } + + /** + * Adds all fields declared in the object's class and its superclasses + * that satisfy the given condition to the output. + * @param condition + * @return this + */ + public ToStringBuilder addAllFields(Predicate condition) { + List fields = getAllDeclaredFields(instance.getClass()); + for(Field field : fields) { + if (condition.test(field)) { + addField(field); + } + } + return this; + } + + /** + * @param fieldName the name of the field to add to the output using reflection + * @return this + */ + public ToStringBuilder addField(final String fieldName) { + List fields = getAllDeclaredFields(instance.getClass()); + for(Field field : fields) { + if(fieldName.equals(field.getName())) { + addField(field); + break; + } + } + return this; + } + + private ToStringBuilder addField(final Field field) { + if (!Modifier.isStatic(field.getModifiers())) { + field.setAccessible(true); + try { + add(field.getName(), field.get(instance)); + } catch(IllegalAccessException e) { + sneakyThrow(e); + } + } + return this; + } + + @SuppressWarnings("unchecked") + private static void sneakyThrow(Throwable t) throws T { + throw (T) t; + } + + /** + * @param value the value to add to the output + * @param fieldName the field name to list the value under + * @return this + */ + public ToStringBuilder add(final String fieldName, final Object value) { + return addPart(fieldName, value); + } + + /** + * @param value the value to add to the output without a field name + * @return this + */ + public ToStringBuilder add(final Object value) { + return addPart(value); + } + + private Part addPart() { + final Part p = new Part(); + this.parts.add(p); + return p; + } + + private ToStringBuilder addPart(final Object value) { + final Part p = this.addPart(); + p.value = value; + return this; + } + + private ToStringBuilder addPart(final String fieldName, final Object value) { + final Part p = this.addPart(); + p.fieldName = fieldName; + p.value = value; + return this; + } + + /** + * @return the String representation of the processed object + */ + @Override + public String toString() { + boolean startProcessing = ToStringBuilder.toStringContext.startProcessing(this.instance); + if (!startProcessing) { + return this.toSimpleReferenceString(this.instance); + } + try { + final IndentationAwareStringBuilder builder = new IndentationAwareStringBuilder(); + builder.append(typeName).append(" "); + builder.append("["); + String nextSeparator = ""; + if (multiLine) { + builder.increaseIndent(); + } + for (Part part : parts) { + if (!skipNulls || part.value != null) { + if (multiLine) { + builder.newLine(); + } else { + builder.append(nextSeparator); + nextSeparator = ", "; + } + if (part.fieldName != null && this.showFieldNames) { + builder.append(part.fieldName).append(" = "); + } + this.internalToString(part.value, builder); + } + } + if (multiLine) { + builder.decreaseIndent().newLine(); + } + builder.append("]"); + return builder.toString(); + } finally { + ToStringBuilder.toStringContext.endProcessing(this.instance); + } + } + + private void internalToString(final Object object, final IndentationAwareStringBuilder sb) { + if (prettyPrint) { + if (object instanceof Iterable) { + serializeIterable((Iterable)object, sb); + } else if (object instanceof Object[]) { + sb.append(Arrays.toString((Object[])object)); + } else if (object instanceof byte[]) { + sb.append(Arrays.toString((byte[])object)); + } else if (object instanceof char[]) { + sb.append(Arrays.toString((char[])object)); + } else if (object instanceof int[]) { + sb.append(Arrays.toString((int[])object)); + } else if (object instanceof boolean[]) { + sb.append(Arrays.toString((boolean[])object)); + } else if (object instanceof long[]) { + sb.append(Arrays.toString((long[])object)); + } else if (object instanceof float[]) { + sb.append(Arrays.toString((float[])object)); + } else if (object instanceof double[]) { + sb.append(Arrays.toString((double[])object)); + } else if (object instanceof CharSequence) { + sb.append("\"").append(((CharSequence)object).toString().replace("\n", "\\n").replace("\r", "\\r")).append("\""); + } else if (object instanceof Enum) { + sb.append(((Enum)object).name()); + } else { + sb.append(String.valueOf(object)); + } + } else { + sb.append(String.valueOf(object)); + } + } + + private void serializeIterable(final Iterable object, final IndentationAwareStringBuilder sb) { + final Iterator iterator = object.iterator(); + sb.append(object.getClass().getSimpleName()).append(" ("); + if (multiLine) { + sb.increaseIndent(); + } + boolean wasEmpty = true; + while (iterator.hasNext()) { + wasEmpty = false; + if (multiLine) { + sb.newLine(); + } + this.internalToString(iterator.next(), sb); + if (iterator.hasNext()) { + sb.append(","); + } + } + if (multiLine) { + sb.decreaseIndent(); + } + if (!wasEmpty && this.multiLine) { + sb.newLine(); + } + sb.append(")"); + } + + private String toSimpleReferenceString(final Object obj) { + String simpleName = obj.getClass().getSimpleName(); + int identityHashCode = System.identityHashCode(obj); + return simpleName + "@" + Integer.valueOf(identityHashCode); + } + + private List getAllDeclaredFields(final Class clazz) { + final ArrayList result = new ArrayList<>(); + + for(Class current = clazz; current != null; current = current.getSuperclass()) { + Field[] declaredFields = current.getDeclaredFields(); + result.addAll(Arrays.asList(declaredFields)); + + } + return result; + } + + private static final class Part { + private String fieldName; + private Object value; + } + + private static class IndentationAwareStringBuilder { + private final StringBuilder builder = new StringBuilder(); + + private final String indentationString = " "; + + private final String newLineString = "\n"; + + private int indentation = 0; + + public IndentationAwareStringBuilder increaseIndent() { + indentation++; + return this; + } + + public IndentationAwareStringBuilder decreaseIndent() { + indentation--; + return this; + } + + public IndentationAwareStringBuilder append(final CharSequence string) { + if (indentation > 0) { + String indented = string.toString().replace( + newLineString, + newLineString + repeat(indentationString, indentation) + ); + builder.append(indented); + } else { + builder.append(string); + } + return this; + } + + public IndentationAwareStringBuilder newLine() { + builder.append(newLineString). + append(repeat(this.indentationString, this.indentation)); + return this; + } + + @Override + public String toString() { + return this.builder.toString(); + } + + private String repeat(String string, int count) { + StringBuilder result = new StringBuilder(); + for(int i=0; i < count; i++) { + result.append(string); + } + return result.toString(); + } + } +} \ No newline at end of file diff --git a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/IntegrationTest.java b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/IntegrationTest.java index d7ba4db7d..82f28f7a9 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/IntegrationTest.java +++ b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/IntegrationTest.java @@ -11,13 +11,14 @@ ******************************************************************************/ package org.eclipse.lsp4j.jsonrpc.test; -import static org.eclipse.lsp4j.jsonrpc.json.MessageConstants.CONTENT_LENGTH_HEADER; -import static org.eclipse.lsp4j.jsonrpc.json.MessageConstants.CRLF; - import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -31,7 +32,6 @@ import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint; import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; - import org.eclipse.lsp4j.jsonrpc.json.StreamMessageProducer; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.jsonrpc.services.GenericEndpoint; @@ -41,6 +41,9 @@ import org.junit.Assert; import org.junit.Test; +import static org.eclipse.lsp4j.jsonrpc.json.MessageConstants.CONTENT_LENGTH_HEADER; +import static org.eclipse.lsp4j.jsonrpc.json.MessageConstants.CRLF; + public class IntegrationTest { private static final long TIMEOUT = 2000; @@ -83,6 +86,16 @@ public Either getEither() { public void setEither(Either either) { this.either = either; } + + private Path path; + + public Path getPath() { + return path; + } + + public void setPath(Path path) { + this.path = path; + } } public static interface MyServer { @@ -727,6 +740,62 @@ public void testValidationIssue2() throws Exception { out.toString()); } + @Test + public void testMessageTracingWithCustomGsonAdapter() throws Exception { + // create client side + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in2 = new PipedInputStream(); + PipedOutputStream out2 = new PipedOutputStream(); + + in.connect(out2); + out.connect(in2); + + StringWriter clientTraceOut = new StringWriter(); + StringWriter serverTraceOut = new StringWriter(); + + MyClient client = new MyClientImpl(); + Launcher clientSideLauncher = new Launcher.Builder() + .setLocalService(client) + .setRemoteInterface(MyServer.class) + .setInput(in) + .setOutput(out) + .configureGson(gsonBuilder -> gsonBuilder.registerTypeHierarchyAdapter(Path.class, new PathTypeAdapter())) + .traceMessages(new PrintWriter(clientTraceOut)) + .create(); + + // create server side + MyServer server = new MyServerImpl(); + Launcher serverSideLauncher = new Launcher.Builder() + .setLocalService(server) + .setRemoteInterface(MyClient.class) + .setInput(in2) + .setOutput(out2) + .configureGson(gsonBuilder -> gsonBuilder.registerTypeHierarchyAdapter(Path.class, new PathTypeAdapter())) + .traceMessages(new PrintWriter(serverTraceOut)) + .create(); + + clientSideLauncher.startListening(); + serverSideLauncher.startListening(); + + var paramWithPath = new MyParam("FOO"); + paramWithPath.setPath(Paths.get("").toAbsolutePath()); + CompletableFuture fooFuture = clientSideLauncher.getRemoteProxy().askServer(paramWithPath); + CompletableFuture barFuture = serverSideLauncher.getRemoteProxy().askClient(new MyParam("BAR")); + + Assert.assertEquals(Paths.get("").toAbsolutePath(), fooFuture.get(TIMEOUT, TimeUnit.MILLISECONDS).getPath()); + Assert.assertEquals("BAR", barFuture.get(TIMEOUT, TimeUnit.MILLISECONDS).value); + + Assert.assertTrue("Unexpected content: " + clientTraceOut, clientTraceOut.toString().contains("Sending request 'askServer - (1)'\n" + + "Params: {\n" + + " \"value\": \"FOO\",\n" + + " \"path\": \"")); + Assert.assertTrue("Unexpected content: " + serverTraceOut, serverTraceOut.toString().contains("Received request 'askServer - (1)'\n" + + "Params: {\n" + + " \"value\": \"FOO\",\n" + + " \"path\": \"")); + } + protected String getHeader(int contentLength) { StringBuilder headerBuilder = new StringBuilder(); headerBuilder.append(CONTENT_LENGTH_HEADER).append(": ").append(contentLength).append(CRLF); diff --git a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/PathTypeAdapter.java b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/PathTypeAdapter.java new file mode 100644 index 000000000..c5d2a4b78 --- /dev/null +++ b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/PathTypeAdapter.java @@ -0,0 +1,42 @@ +/****************************************************************************** + * Copyright (c) 2023 SonarSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.jsonrpc.test; + +import java.io.IOException; +import java.nio.file.Path; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +public class PathTypeAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, Path value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(value.toString()); + } + } + + @Override + public Path read(JsonReader in) throws IOException { + var peek = in.peek(); + if (peek == JsonToken.NULL) { + in.nextNull(); + return null; + } + return Path.of(in.nextString()); + } +} diff --git a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/TracingMessageConsumerTest.java b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/TracingMessageConsumerTest.java index a51453266..b543c94c0 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/TracingMessageConsumerTest.java +++ b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/TracingMessageConsumerTest.java @@ -19,8 +19,11 @@ import org.junit.Test; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; @@ -34,13 +37,17 @@ public class TracingMessageConsumerTest { private static final RemoteEndpoint TEST_REMOTE_ENDPOINT = new EmptyRemoteEndpoint(); + private static final MessageJsonHandler TEST_JSON_HANDLER = new MessageJsonHandler(emptyMap(), gsonBuilder -> { + gsonBuilder.registerTypeHierarchyAdapter(Path.class, new PathTypeAdapter()); + }); private static final StreamMessageConsumer TEST_STREAM_MESSAGE_CONSUMER = new StreamMessageConsumer( - new ByteArrayOutputStream(), new MessageJsonHandler(emptyMap())); + new ByteArrayOutputStream(), TEST_JSON_HANDLER); private static final Clock TEST_CLOCK_1 = Clock.fixed(Instant.parse("2019-06-26T22:07:30.00Z"), ZoneId.of("America/New_York")); private static final Clock TEST_CLOCK_2 = Clock.fixed(Instant.parse("2019-06-26T22:07:30.10Z"), ZoneId.of("America/New_York")); + private static final String FILE_SEPARATOR_JSON_ESCAPED = File.separatorChar == '\\' ? "\\\\" : File.separator; @Test public void testReceivedRequest() { @@ -129,6 +136,40 @@ public void testReceivedErrorResponse() { assertEquals(expectedTrace, actualTrace); } + @Test + public void testReceivedErrorResponseWithCustomDataAdapter() { + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + + Map sentRequests = new HashMap<>(); + sentRequests.put("1", new RequestMetadata("foo", TEST_CLOCK_1.instant())); + + TracingMessageConsumer consumer = + new TracingMessageConsumer( + TEST_REMOTE_ENDPOINT, sentRequests, new HashMap<>(), printWriter, TEST_CLOCK_2, Locale.US); + consumer.setJsonHandler(TEST_JSON_HANDLER); + + ResponseMessage message = new ResponseMessage(); + message.setId("1"); + message.setError(new ResponseError(-32600, "bar", Paths.get("foo/Bar.java"))); + + consumer.consume(message); + + String actualTrace = stringWriter.toString(); + String expectedTrace = "" + + "[Trace - 06:07:30 PM] Received response 'foo - (1)' in 100ms\n" + + "Result: null\n" + + "Error: {\n" + + " \"code\": -32600,\n" + + " \"message\": \"bar\",\n" + + " \"data\": \"foo" + FILE_SEPARATOR_JSON_ESCAPED + "Bar.java\"\n" + + "}\n" + + "\n" + + "\n"; + + assertEquals(expectedTrace, actualTrace); + } + @Test public void testReceivedNotification() { StringWriter stringWriter = new StringWriter(); @@ -232,6 +273,32 @@ public void testSendingNotification() { assertEquals(expectedTrace, actualTrace); } + + @Test + public void testSendingNotificationWithCustomAdapter() { + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + + TracingMessageConsumer consumer = + new TracingMessageConsumer( + TEST_STREAM_MESSAGE_CONSUMER, emptyMap(), emptyMap(), printWriter, TEST_CLOCK_1, Locale.US); + consumer.setJsonHandler(TEST_JSON_HANDLER); + + NotificationMessage message = new NotificationMessage(); + message.setMethod("foo"); + message.setParams(Paths.get("foo/Bar.java")); + + consumer.consume(message); + + String actualTrace = stringWriter.toString(); + String expectedTrace = "" + + "[Trace - 06:07:30 PM] Sending notification 'foo'\n" + + "Params: \"foo" + FILE_SEPARATOR_JSON_ESCAPED + "Bar.java\"\n" + + "\n" + + "\n"; + + assertEquals(expectedTrace, actualTrace); + } } class EmptyRemoteEndpoint extends RemoteEndpoint { diff --git a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/json/MessageJsonHandlerTest.java b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/json/MessageJsonHandlerTest.java index b6d6a9f86..38dc5e79d 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/json/MessageJsonHandlerTest.java +++ b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/json/MessageJsonHandlerTest.java @@ -36,6 +36,7 @@ import org.junit.Test; import com.google.gson.JsonArray; +import com.google.gson.JsonIOException; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; @@ -153,7 +154,41 @@ public void testSerializeImmutableList() { String json = handler.serialize(message); Assert.assertEquals("{\"jsonrpc\":\"2.0\",\"method\":\"foo\",\"params\":[\"a\",\"b\"]}", json); } - + + @Test + public void testMessageToString() { + NotificationMessage message = new NotificationMessage(); + message.setMethod("foo"); + List list = new ArrayList<>(); + list.add("a"); + list.add("b"); + message.setParams(list); + Assert.assertEquals("{\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"method\": \"foo\",\n" + + " \"params\": [\n" + + " \"a\",\n" + + " \"b\"\n" + + " ]\n" + + "}", message.toString()); + + MessageJsonHandler handler = new MessageJsonHandler(Collections.emptyMap()) { + @Override + public String format(Object object) { + throw new JsonIOException("TEST"); + }; + }; + message.setJsonHandler(handler); + Assert.assertEquals("NotificationMessage [\n" + + " method = \"foo\"\n" + + " params = ArrayList (\n" + + " \"a\",\n" + + " \"b\"\n" + + " )\n" + + " jsonrpc = \"2.0\"\n" + + "]", message.toString()); + } + @SuppressWarnings({ "unchecked" }) @Test public void testEither_01() { diff --git a/org.eclipse.lsp4j.websocket.jakarta/src/main/java/org/eclipse/lsp4j/websocket/jakarta/WebSocketLauncherBuilder.java b/org.eclipse.lsp4j.websocket.jakarta/src/main/java/org/eclipse/lsp4j/websocket/jakarta/WebSocketLauncherBuilder.java index 7fdf1e3ae..974ab00d4 100644 --- a/org.eclipse.lsp4j.websocket.jakarta/src/main/java/org/eclipse/lsp4j/websocket/jakarta/WebSocketLauncherBuilder.java +++ b/org.eclipse.lsp4j.websocket.jakarta/src/main/java/org/eclipse/lsp4j/websocket/jakarta/WebSocketLauncherBuilder.java @@ -48,6 +48,9 @@ public Launcher create() { throw new IllegalStateException("Remote interface must be configured."); MessageJsonHandler jsonHandler = createJsonHandler(); + if (messageTracer != null) { + messageTracer.setJsonHandler(jsonHandler); + } RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); addMessageHandlers(jsonHandler, remoteEndpoint); T remoteProxy = createProxy(remoteEndpoint); @@ -65,6 +68,7 @@ protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { else remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, localEndpoint, exceptionHandler); jsonHandler.setMethodProvider(remoteEndpoint); + remoteEndpoint.setJsonHandler(jsonHandler); return remoteEndpoint; } diff --git a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java index 5a5288634..5faa8c8c8 100644 --- a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java +++ b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java @@ -48,6 +48,9 @@ public Launcher create() { throw new IllegalStateException("Remote interface must be configured."); MessageJsonHandler jsonHandler = createJsonHandler(); + if (messageTracer != null) { + messageTracer.setJsonHandler(jsonHandler); + } RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); addMessageHandlers(jsonHandler, remoteEndpoint); T remoteProxy = createProxy(remoteEndpoint); @@ -65,6 +68,7 @@ protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { else remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, localEndpoint, exceptionHandler); jsonHandler.setMethodProvider(remoteEndpoint); + remoteEndpoint.setJsonHandler(jsonHandler); return remoteEndpoint; }