Add Signature and Variant types.

Change-Id: I3579fb8745d7b021af7534d2f0c5a0aa6ce54518
diff --git a/src/main/java/eu/mulk/jgvariant/core/Decoder.java b/src/main/java/eu/mulk/jgvariant/core/Decoder.java
index 9833998..389ae85 100644
--- a/src/main/java/eu/mulk/jgvariant/core/Decoder.java
+++ b/src/main/java/eu/mulk/jgvariant/core/Decoder.java
@@ -7,7 +7,7 @@
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -47,8 +47,11 @@
   private Decoder() {}
 
   /**
+   * Decodes a {@link ByteBuffer} holding a serialized GVariant into a value of type {@code T}.
+   *
    * @throws java.nio.BufferUnderflowException if the byte buffer is shorter than the requested
    *     data.
+   * @throws IllegalArgumentException if the serialized GVariant is ill-formed
    */
   public abstract T decode(ByteBuffer byteSlice);
 
@@ -156,7 +159,7 @@
    *
    * @return a new {@link Decoder}.
    */
-  public static Decoder<Object> ofVariant() {
+  public static Decoder<Variant> ofVariant() {
     return new VariantDecoder();
   }
 
@@ -464,7 +467,7 @@
     }
   }
 
-  private static class VariantDecoder extends Decoder<Object> {
+  private static class VariantDecoder extends Decoder<Variant> {
 
     @Override
     public byte alignment() {
@@ -478,56 +481,27 @@
     }
 
     @Override
-    public Object decode(ByteBuffer byteSlice) {
+    public Variant decode(ByteBuffer byteSlice) {
       for (int i = byteSlice.limit() - 1; i >= 0; --i) {
         if (byteSlice.get(i) != 0) {
           continue;
         }
 
-        var data = byteSlice.slice(0, i);
-        var signature = byteSlice.slice(i + 1, byteSlice.limit() - (i + 1));
+        var dataBytes = byteSlice.slice(0, i);
+        var signatureBytes = byteSlice.slice(i + 1, byteSlice.limit() - (i + 1));
 
-        Decoder<?> decoder = parseSignature(signature);
-        return decoder.decode(data);
+        Signature signature;
+        try {
+          signature = Signature.parse(signatureBytes);
+        } catch (ParseException e) {
+          throw new IllegalArgumentException(e);
+        }
+
+        return new Variant(signature, signature.decoder().decode(dataBytes));
       }
 
       throw new IllegalArgumentException("variant signature not found");
     }
-
-    private static Decoder<?> parseSignature(ByteBuffer signature) {
-      char c = (char) signature.get();
-      return switch (c) {
-        case 'b' -> Decoder.ofBoolean();
-        case 'y' -> Decoder.ofByte();
-        case 'n', 'q' -> Decoder.ofShort();
-        case 'i', 'u' -> Decoder.ofInt();
-        case 'x', 't' -> Decoder.ofLong();
-        case 'd' -> Decoder.ofDouble();
-        case 's', 'o', 'g' -> Decoder.ofString(StandardCharsets.UTF_8);
-        case 'v' -> Decoder.ofVariant();
-        case 'm' -> Decoder.ofMaybe(parseSignature(signature));
-        case 'a' -> Decoder.ofArray(parseSignature(signature));
-        case '(', '{' -> Decoder.ofStructure(parseTupleTypes(signature).toArray(new Decoder<?>[0]));
-        default -> throw new IllegalArgumentException(
-            String.format("encountered unknown signature byte '%c'", c));
-      };
-    }
-
-    private static List<Decoder<?>> parseTupleTypes(ByteBuffer signature) {
-      List<Decoder<?>> decoders = new ArrayList<>();
-
-      while (true) {
-        char c = (char) signature.get(signature.position());
-        if (c == ')' || c == '}') {
-          signature.get();
-          break;
-        }
-
-        decoders.add(parseSignature(signature));
-      }
-
-      return decoders;
-    }
   }
 
   private static class BooleanDecoder extends Decoder<Boolean> {
diff --git a/src/main/java/eu/mulk/jgvariant/core/Signature.java b/src/main/java/eu/mulk/jgvariant/core/Signature.java
new file mode 100644
index 0000000..bb03b94
--- /dev/null
+++ b/src/main/java/eu/mulk/jgvariant/core/Signature.java
@@ -0,0 +1,113 @@
+package eu.mulk.jgvariant.core;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A GVariant signature string.
+ *
+ * <p>Describes a type in the GVariant type system. The type can be arbitrarily complex.
+ *
+ * <p><strong>Examples</strong>
+ *
+ * <dl>
+ *   <dt>{@code "i"}
+ *   <dd>a single 32-bit integer
+ *   <dt>{@code "ai"}
+ *   <dd>an array of 32-bit integers
+ *   <dt>{@code "(bbb(sai))"}
+ *   <dd>a record consisting of three booleans and a nested record, which consists of a string and
+ *       an array of 32-bit integers
+ * </dl>
+ */
+public final class Signature {
+
+  private final String signatureString;
+  private final Decoder<?> decoder;
+
+  Signature(ByteBuffer signatureBytes) throws ParseException {
+    this.decoder = parseSignature(signatureBytes);
+
+    signatureBytes.rewind();
+    this.signatureString = StandardCharsets.US_ASCII.decode(signatureBytes).toString();
+  }
+
+  static Signature parse(ByteBuffer signatureBytes) throws ParseException {
+    return new Signature(signatureBytes);
+  }
+
+  static Signature parse(String signatureString) throws ParseException {
+    var signatureBytes = ByteBuffer.wrap(signatureString.getBytes(StandardCharsets.US_ASCII));
+    return parse(signatureBytes);
+  }
+
+  /**
+   * Returns a {@link Decoder} that can decode values conforming to this signature.
+   *
+   * @return a {@link Decoder} for this signature
+   */
+  @SuppressWarnings("unchecked")
+  Decoder<Object> decoder() {
+    return (Decoder<Object>) decoder;
+  }
+
+  /**
+   * Returns the signature formatted as a GVariant signature string.
+   *
+   * @return a GVariant signature string.
+   */
+  @Override
+  public String toString() {
+    return signatureString;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof Signature signature)
+        && Objects.equals(signatureString, signature.signatureString);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(signatureString);
+  }
+
+  private static Decoder<?> parseSignature(ByteBuffer signature) throws ParseException {
+    char c = (char) signature.get();
+    return switch (c) {
+      case 'b' -> Decoder.ofBoolean();
+      case 'y' -> Decoder.ofByte();
+      case 'n', 'q' -> Decoder.ofShort();
+      case 'i', 'u' -> Decoder.ofInt();
+      case 'x', 't' -> Decoder.ofLong();
+      case 'd' -> Decoder.ofDouble();
+      case 's', 'o', 'g' -> Decoder.ofString(StandardCharsets.UTF_8);
+      case 'v' -> Decoder.ofVariant();
+      case 'm' -> Decoder.ofMaybe(parseSignature(signature));
+      case 'a' -> Decoder.ofArray(parseSignature(signature));
+      case '(', '{' -> Decoder.ofStructure(parseTupleTypes(signature).toArray(new Decoder<?>[0]));
+      default -> throw new ParseException(
+          String.format("encountered unknown signature byte '%c'", c), signature.position());
+    };
+  }
+
+  private static List<Decoder<?>> parseTupleTypes(ByteBuffer signature) throws ParseException {
+    List<Decoder<?>> decoders = new ArrayList<>();
+
+    while (true) {
+      char c = (char) signature.get(signature.position());
+      if (c == ')' || c == '}') {
+        signature.get();
+        break;
+      }
+
+      decoders.add(parseSignature(signature));
+    }
+
+    return decoders;
+  }
+}
diff --git a/src/main/java/eu/mulk/jgvariant/core/Variant.java b/src/main/java/eu/mulk/jgvariant/core/Variant.java
new file mode 100644
index 0000000..e2b4b68
--- /dev/null
+++ b/src/main/java/eu/mulk/jgvariant/core/Variant.java
@@ -0,0 +1,25 @@
+package eu.mulk.jgvariant.core;
+
+/**
+ * A dynamically typed GVariant value carrying a {@link Signature} describing its type.
+ *
+ * <p>{@link #value()} can be of one of the following types:
+ *
+ * <ul>
+ *   <li>{@link Boolean}
+ *   <li>{@link Byte}
+ *   <li>{@link Short}
+ *   <li>{@link Integer}
+ *   <li>{@link Long}
+ *   <li>{@link String}
+ *   <li>{@link java.util.Optional} (a GVariant {@code Maybe} type)
+ *   <li>{@link java.util.List} (a GVariant array)
+ *   <li>{@link Object[]} (a GVariant structure)
+ * </ul>
+ *
+ * @param signature the signature describing the type of the value.
+ * @param value the value itself; one of {@link Boolean}, {@link Byte}, {@link Short}, {@link
+ *     Integer}, {@link Long}, {@link String}, {@link java.util.Optional}, {@link java.util.List},
+ *     {@link Object[]}.
+ */
+public record Variant(Signature signature, Object value) {}
diff --git a/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java b/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java
index 3cae863..5cf1a1c 100644
--- a/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java
+++ b/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java
@@ -9,6 +9,7 @@
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.nio.ByteBuffer;
+import java.text.ParseException;
 import java.util.List;
 import java.util.Optional;
 import org.junit.jupiter.api.Test;
@@ -138,9 +139,11 @@
         };
 
     var decoder = Decoder.ofVariant();
-    var result = (Object[]) decoder.decode(ByteBuffer.wrap(data));
+    var variant = decoder.decode(ByteBuffer.wrap(data));
+    var result = (Object[]) variant.value();
 
     assertAll(
+        () -> assertEquals(Signature.parse("((ys)as)"), variant.signature()),
         () -> assertEquals(2, result.length),
         () -> assertArrayEquals(new Object[] {(byte) 0x69, "can"}, (Object[]) result[0]),
         () -> assertEquals(List.of("has", "strings?"), result[1]));
@@ -371,7 +374,7 @@
   }
 
   @Test
-  void testSimpleVariantRecord() {
+  void testSimpleVariantRecord() throws ParseException {
     // signature: "(bynqiuxtdsogvmiai)"
     var data =
         new byte[] {
@@ -413,10 +416,22 @@
           "hi",
           "hi",
           "hi",
-          9,
+          new Variant(Signature.parse("i"), 9),
           Optional.of(10),
           List.of(11, 12)
         },
-        (Object[]) decoder.decode(ByteBuffer.wrap(data)));
+        (Object[]) decoder.decode(ByteBuffer.wrap(data)).value());
+  }
+
+  @Test
+  void testSignatureString() throws ParseException {
+    var data =
+        new byte[] {
+          0x28, 0x62, 0x79, 0x6E, 0x71, 0x69, 0x75, 0x78, 0x74, 0x64, 0x73, 0x6F, 0x67, 0x76, 0x6D,
+          0x69, 0x61, 0x69, 0x29
+        };
+
+    var signature = Signature.parse(ByteBuffer.wrap(data));
+    assertEquals("(bynqiuxtdsogvmiai)", signature.toString());
   }
 }