diff --git a/README.md b/README.md
index 784155d..dc53772 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ the terms of this License".
A tiny JSON5 parsing library for Java 8, with a focus on simplicity and minimizing size.
## Usage
-First, include the library in your project. You can do this by adding the following to your build.gradle(.kts):
+First, include the library in your project. You can do this by adding the following to your `build.gradle(.kts)`:
Kotlin
@@ -19,7 +19,7 @@ repositories {
}
dependencies {
- implementation("dev.nolij:zson:version")
+ implementation("dev.nolij:zson:[version]")
}
```
@@ -32,14 +32,26 @@ repositories {
}
dependencies {
- implementation 'dev.nolij:zson:version'
+ implementation 'dev.nolij:zson:[version]'
}
```
-Replace `version` with the version of the library you want to use.
+Replace `[version]` with the version of the library you want to use.
You can find the latest version on the [releases page](https://github.com/Nolij/ZSON/releases).
+If you wish to use an older version of Java, we provide 2 downgraded jars for Java 17 and 8.
+You can use them by appending a classifier `downgraded-[java version]` to the dependency, for example:
+`implementation("dev.nolij:zson:[version]:downgraded-8")`.
+
+
+A note about Java 8
+
+The Java 8 version actually uses Java 5 bytecode (classfile version 49), but because we use Java 8 features (namely NIO),
+it still requires Java 8 to run. The reason for doing this is that Java 5 bytecode doesn't have stack maps, which significantly
+reduces the size of the resulting jar.
+
+
Then, you can use the library like so:
```java
import dev.nolij.zson.Zson; // static helper/parsing methods, instantiate for a writer
@@ -65,6 +77,7 @@ public class ZsonExample {
)),
entry("null", "comments can also\nbe multiple lines", null)
);
+ String jsonString = writer.stringify(map);
System.out.println(jsonString);
}
}
@@ -77,14 +90,14 @@ This prints out:
// comment
"key": 4,
// look, arrays work too!
- "arr": [ 1, 2, 3, ],
+ "arr": [ 1, 2, 3 ],
// and objects!
"obj": {
- "key": "value",
+ "key": "value"
},
// comments can also
// be multiple lines
- "null": null,
+ "null": null
}
```
@@ -108,7 +121,8 @@ public class Example {
public static void main(String[] args) {
Example example = new Example();
Map zson = Zson.obj2map(example);
- System.out.println(new Zson().stringify(zson));
+ System.out.println(new Zson()
+ .withQuoteKeys(false).stringify(zson));
}
}
```
@@ -117,8 +131,8 @@ This prints out:
```json5
{
// This is a comment
- "key": "value",
- "number": 4,
+ key: "value",
+ number: 4
}
```
diff --git a/build.gradle.kts b/build.gradle.kts
index 8c2c9be..59881e5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,9 @@
+import org.objectweb.asm.tools.Retrofitter
import xyz.wagyourtail.jvmdg.gradle.task.DowngradeJar
import java.time.ZonedDateTime
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+import java.util.zip.Deflater
plugins {
id("idea")
@@ -45,7 +49,7 @@ val releaseIncrement = if (isExternalCI) 0 else 1
val releaseChannel: ReleaseChannel =
if (isExternalCI) {
val tagName = releaseTags.first().name
- val suffix = """\-(\w+)\.\d+$""".toRegex().find(tagName)?.groupValues?.get(1)
+ val suffix = """-(\w+)\.\d+$""".toRegex().find(tagName)?.groupValues?.get(1)
if (suffix != null)
ReleaseChannel.values().find { channel -> channel.suffix == suffix }!!
else
@@ -89,6 +93,14 @@ val versionTagName = "${releaseTagPrefix}${versionString}"
version = versionString
println("ZSON Version: $versionString")
+java {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+
+ withSourcesJar()
+ withJavadocJar()
+}
+
repositories {
mavenCentral()
}
@@ -96,14 +108,48 @@ repositories {
dependencies {
compileOnly("org.jetbrains:annotations:${"jetbrains_annotations_version"()}")
- testImplementation("org.junit.jupiter:junit-jupiter:5.11.0-M1")
+ testImplementation("org.junit.jupiter:junit-jupiter:${"junit_version"()}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
+tasks.javadoc {
+ val options = options as StandardJavadocDocletOptions
+ options.addBooleanOption("Xdoclint:none", true)
+}
+
tasks.downgradeJar {
dependsOn(tasks.jar)
downgradeTo = JavaVersion.VERSION_1_8
archiveClassifier = "downgraded-8"
+
+ doLast {
+ val jar = archiveFile.get().asFile
+ val dir = temporaryDir.resolve("downgradeJar5")
+ dir.mkdirs()
+
+ copy {
+ from(zipTree(jar))
+ into(dir)
+ }
+
+ Retrofitter().run {
+ retrofit(dir.toPath())
+ //verify(dir.toPath())
+ }
+
+ JarOutputStream(archiveFile.get().asFile.outputStream()).use { jos ->
+ jos.setLevel(Deflater.BEST_COMPRESSION)
+ dir.walkTopDown().forEach { file ->
+ if (file.isFile) {
+ jos.putNextEntry(JarEntry(file.relativeTo(dir).toPath().toString()))
+ file.inputStream().use { it.copyTo(jos) }
+ jos.closeEntry()
+ }
+ }
+ jos.flush()
+ jos.finish()
+ }
+ }
}
val downgradeJar17 = tasks.register("downgradeJar17") {
@@ -114,9 +160,6 @@ val downgradeJar17 = tasks.register("downgradeJar17") {
}
tasks.jar {
- isPreserveFileTimestamps = false
- isReproducibleFileOrder = true
-
from(rootProject.file("LICENSE")) {
rename { "${it}_${rootProject.name}" }
}
@@ -124,19 +167,19 @@ tasks.jar {
finalizedBy(tasks.downgradeJar, downgradeJar17)
}
-java.withSourcesJar()
-val sourcesJar: Jar = tasks.withType()["sourcesJar"].apply {
+val sourcesJar = tasks.getByName("sourcesJar") {
from(rootProject.file("LICENSE")) {
rename { "${it}_${rootProject.name}" }
}
}
tasks.assemble {
- dependsOn(tasks.jar, sourcesJar)
+ dependsOn(tasks.jar, sourcesJar, downgradeJar17)
}
tasks.test {
useJUnitPlatform()
+ outputs.upToDateWhen { false }
}
tasks.withType {
@@ -151,6 +194,11 @@ tasks.withType {
}
}
+tasks.withType {
+ isPreserveFileTimestamps = false
+ isReproducibleFileOrder = true
+}
+
githubRelease {
setToken(providers.environmentVariable("GITHUB_TOKEN"))
setTagName(versionTagName)
@@ -175,6 +223,7 @@ publishing {
artifact(downgradeJar17) // java 17
artifact(tasks.downgradeJar) // java 8
artifact(sourcesJar) // java 21 sources
+ artifact(tasks["javadocJar"])
}
}
}
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000..a42205f
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,9 @@
+repositories.mavenCentral()
+dependencies {
+ implementation("org.ow2.asm:asm:9.7")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/java/org/objectweb/asm/tools/Retrofitter.java b/buildSrc/src/main/java/org/objectweb/asm/tools/Retrofitter.java
new file mode 100644
index 0000000..b165025
--- /dev/null
+++ b/buildSrc/src/main/java/org/objectweb/asm/tools/Retrofitter.java
@@ -0,0 +1,746 @@
+// ASM: a very small and fast Java bytecode manipulation framework
+// Copyright (c) 2000-2011 INRIA, France Telecom
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions
+// are met:
+// 1. Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// 2. Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the distribution.
+// 3. Neither the name of the copyright holders nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+// THE POSSIBILITY OF SUCH DAMAGE.
+package org.objectweb.asm.tools;
+
+import static java.lang.String.format;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static org.objectweb.asm.Opcodes.ACC_PRIVATE;
+import static org.objectweb.asm.Opcodes.ACC_STATIC;
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
+import static org.objectweb.asm.Opcodes.ARETURN;
+import static org.objectweb.asm.Opcodes.DUP;
+import static org.objectweb.asm.Opcodes.ILOAD;
+import static org.objectweb.asm.Opcodes.INVOKESPECIAL;
+import static org.objectweb.asm.Opcodes.INVOKESTATIC;
+import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL;
+import static org.objectweb.asm.Opcodes.NEW;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.LineNumberReader;
+import java.lang.module.ModuleDescriptor;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import java.util.zip.GZIPInputStream;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.ModuleVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * A tool to transform classes in order to make them compatible with Java 1.5, and to check that
+ * they use only the JDK 1.5 API and JDK 1.5 class file features. The original classes can either be
+ * transformed "in place", or be copied first to destination directory and transformed here (leaving
+ * the original classes unchanged).
+ *
+ * @author Eric Bruneton
+ * @author Eugene Kuleshov
+ */
+public final class Retrofitter {
+
+ /** The name of the module-info file. */
+ private static final String MODULE_INFO = "module-info.class";
+
+ /** The name of the java.base module. */
+ private static final String JAVA_BASE_MODULE = "java.base";
+
+ /** Bootstrap method for the string concatenation using indy. */
+ private static final Handle STRING_CONCAT_FACTORY_HANDLE =
+ new Handle(
+ Opcodes.H_INVOKESTATIC,
+ "java/lang/invoke/StringConcatFactory",
+ "makeConcatWithConstants",
+ "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;",
+ false);
+
+ /**
+ * The fields and methods of the JDK 1.5 API. Each string has the form
+ * "<owner><name><descriptor>".
+ */
+ private final HashSet jdkApi = new HashSet<>();
+
+ /**
+ * The class hierarchy of the JDK 1.5 API. Maps each class name to the name of its super class.
+ */
+ private final HashMap jdkHierarchy = new HashMap<>();
+
+ /** The internal names of the packages exported by the retrofitted classes. */
+ private final HashSet exports = new HashSet<>();
+
+ /** The internal names of the packages imported by the retrofitted classes. */
+ private final HashSet imports = new HashSet<>();
+
+ /**
+ * Transforms the class files in the given directory, in place, in order to make them compatible
+ * with the JDK 1.5. Also generates a module-info class in this directory, with the given module
+ * version.
+ *
+ * @param args a directory containing compiled classes and the ASM release version.
+ * @throws IOException if a file can't be read or written.
+ */
+ public static void main(final String[] args) throws IOException {
+ if (args.length == 2) {
+ new Retrofitter().retrofit(Paths.get(args[0]));
+ } else {
+ System.err.println("Usage: Retrofitter "); // NOPMD
+ }
+ }
+
+ /**
+ * Transforms the class files in the given directory, in place, in order to make them compatible
+ * with the JDK 1.5. Also generates a module-info class in this directory, with the given module
+ * version.
+ *
+ * @param classesDir a directory containing compiled classes.
+ * @throws IOException if a file can't be read or written.
+ */
+ // ZSON - remove `version` parameter and call to `generateModuleInfoClass`
+ public void retrofit(final Path classesDir) throws IOException {
+ for (Path classFile : getAllClasses(classesDir, /* includeModuleInfo= */ true)) {
+ ClassReader classReader = new ClassReader(Files.readAllBytes(classFile));
+ ClassWriter classWriter = new ClassWriter(0);
+ classReader.accept(new ClassRetrofitter(classWriter), ClassReader.SKIP_FRAMES);
+ Files.write(classFile, classWriter.toByteArray());
+ }
+ }
+
+ /**
+ * Verify that the class files in the given directory only use JDK 1.5 APIs, and that a
+ * module-info class is present with the expected content.
+ *
+ * @param classesDir a directory containing compiled classes.
+ * @param expectedVersion the expected module-info version.
+ * @param expectedExports the expected module-info exported packages.
+ * @param expectedRequires the expected module-info required modules.
+ * @throws IOException if a file can't be read.
+ * @throws IllegalArgumentException if the module-info class does not have the expected content.
+ */
+ public void verify(final Path classesDir) throws IOException {
+ if (jdkApi.isEmpty()) {
+ readJdkApi();
+ }
+
+ List classFiles = getAllClasses(classesDir, /* includeModuleInfo= */ false);
+ List classReaders = getClassReaders(classFiles);
+ for (ClassReader classReader : classReaders) {
+ classReader.accept(new ClassVerifier(), 0);
+ }
+ checkPrivateMemberAccess(classReaders);
+ }
+
+ private List getClassReaders(final List classFiles) throws IOException {
+ ArrayList classReaders = new ArrayList<>();
+ for (Path classFile : classFiles) {
+ classReaders.add(new ClassReader(Files.readAllBytes(classFile)));
+ }
+ return classReaders;
+ }
+
+ private List getAllClasses(final Path path, final boolean includeModuleInfo)
+ throws IOException {
+ try (Stream stream = Files.walk(path)) {
+ return stream
+ .filter(
+ child -> {
+ String filename = child.getFileName().toString();
+ return filename.endsWith(".class")
+ && (includeModuleInfo || !filename.equals("module-info.class"));
+ })
+ .collect(toList());
+ }
+ }
+
+ /**
+ * Checks that no code accesses to a private member from another class. If there is a private
+ * access, removing the nestmate attributes is not a legal transformation.
+ */
+ private static void checkPrivateMemberAccess(final List readers) {
+ // Compute all private members.
+ HashMap> privateMemberMap = new HashMap<>();
+ for (ClassReader reader : readers) {
+ HashSet privateMembers = new HashSet<>();
+ reader.accept(
+ new ClassVisitor(/* latest api =*/ Opcodes.ASM9) {
+ @Override
+ public void visit(
+ final int version,
+ final int access,
+ final String name,
+ final String signature,
+ final String superName,
+ final String[] interfaces) {
+ privateMemberMap.put(name, privateMembers);
+ }
+
+ @Override
+ public FieldVisitor visitField(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final Object value) {
+ if ((access & ACC_PRIVATE) != 0) {
+ privateMembers.add(name + '/' + descriptor);
+ }
+ return null;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final String[] exceptions) {
+ if ((access & ACC_PRIVATE) != 0) {
+ privateMembers.add(name + '/' + descriptor);
+ }
+ return null;
+ }
+ },
+ 0);
+ }
+
+ // Verify that there is no access to a private member of another class.
+ for (ClassReader reader : readers) {
+ reader.accept(
+ new ClassVisitor(/* latest api =*/ Opcodes.ASM9) {
+ /** The internal name of the visited class. */
+ String className;
+
+ /** The name and descriptor of the currently visited method. */
+ String currentMethodName;
+
+ @Override
+ public void visit(
+ final int version,
+ final int access,
+ final String name,
+ final String signature,
+ final String superName,
+ final String[] interfaces) {
+ className = name;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final String[] exceptions) {
+ currentMethodName = name + descriptor;
+ return new MethodVisitor(/* latest api =*/ Opcodes.ASM9) {
+
+ private void checkAccess(
+ final String owner, final String name, final String descriptor) {
+ if (owner.equals(className)) { // same class access
+ return;
+ }
+ HashSet members = privateMemberMap.get(owner);
+ if (members == null) { // not a known class
+ return;
+ }
+ if (members.contains(name + '/' + descriptor)) {
+ throw new IllegalArgumentException(
+ format(
+ "ERROR: illegal access to a private member %s.%s called in %s %s",
+ owner, name + " " + descriptor, className, currentMethodName));
+ }
+ }
+
+ @Override
+ public void visitFieldInsn(
+ final int opcode,
+ final String owner,
+ final String name,
+ final String descriptor) {
+ checkAccess(owner, name, descriptor);
+ }
+
+ @Override
+ public void visitMethodInsn(
+ final int opcode,
+ final String owner,
+ final String name,
+ final String descriptor,
+ final boolean isInterface) {
+ checkAccess(owner, name, descriptor);
+ }
+
+ @Override
+ public void visitLdcInsn(final Object value) {
+ if (value instanceof Handle) {
+ Handle handle = (Handle) value;
+ checkAccess(handle.getOwner(), handle.getName(), handle.getDesc());
+ }
+ }
+ };
+ }
+ },
+ 0);
+ }
+ }
+
+ private void generateModuleInfoClass(final Path dstDir, final String version) throws IOException {
+ ClassWriter classWriter = new ClassWriter(0);
+ classWriter.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null);
+ ArrayList moduleNames = new ArrayList<>();
+ for (String exportName : exports) {
+ if (isAsmModule(exportName)) {
+ moduleNames.add(exportName);
+ }
+ }
+ if (moduleNames.size() != 1) {
+ throw new IllegalArgumentException("Module name can't be infered from classes");
+ }
+ ModuleVisitor moduleVisitor =
+ classWriter.visitModule(moduleNames.get(0).replace('/', '.'), Opcodes.ACC_OPEN, version);
+
+ for (String importName : imports) {
+ if (isAsmModule(importName) && !exports.contains(importName)) {
+ moduleVisitor.visitRequire(importName.replace('/', '.'), Opcodes.ACC_TRANSITIVE, null);
+ }
+ }
+ moduleVisitor.visitRequire(JAVA_BASE_MODULE, Opcodes.ACC_MANDATED, null);
+
+ for (String exportName : exports) {
+ moduleVisitor.visitExport(exportName, 0);
+ }
+ moduleVisitor.visitEnd();
+ classWriter.visitEnd();
+ Files.write(dstDir.toAbsolutePath().resolve(MODULE_INFO), classWriter.toByteArray());
+ }
+
+ private void verifyModuleInfoClass(
+ final Path dstDir,
+ final String expectedVersion,
+ final Set expectedExports,
+ final Set expectedRequires)
+ throws IOException {
+ ModuleDescriptor module =
+ ModuleDescriptor.read(Files.newInputStream(dstDir.toAbsolutePath().resolve(MODULE_INFO)));
+ String version = module.version().map(ModuleDescriptor.Version::toString).orElse("");
+ if (!version.equals(expectedVersion)) {
+ throw new IllegalArgumentException(
+ format("Wrong module-info version '%s' (expected '%s')", version, expectedVersion));
+ }
+ Set exports =
+ module.exports().stream().map(ModuleDescriptor.Exports::source).collect(toSet());
+ if (!exports.equals(expectedExports)) {
+ throw new IllegalArgumentException(
+ format("Wrong module-info exports %s (expected %s)", exports, expectedExports));
+ }
+ Set requires =
+ module.requires().stream().map(ModuleDescriptor.Requires::name).collect(toSet());
+ if (!requires.equals(expectedRequires)) {
+ throw new IllegalArgumentException(
+ format("Wrong module-info requires %s (expected %s)", requires, expectedRequires));
+ }
+ }
+
+ private static boolean isAsmModule(final String packageName) {
+ return packageName.startsWith("org/objectweb/asm")
+ && !packageName.equals("org/objectweb/asm/signature");
+ }
+
+ private void readJdkApi() throws IOException {
+ try (InputStream inputStream =
+ new GZIPInputStream(
+ Retrofitter.class.getClassLoader().getResourceAsStream("jdk1.5.0.12.txt.gz"));
+ InputStreamReader inputStreamReader =
+ new InputStreamReader(inputStream, StandardCharsets.UTF_8);
+ BufferedReader reader = new LineNumberReader(inputStreamReader)) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.startsWith("class")) {
+ String className = line.substring(6, line.lastIndexOf(' '));
+ String superClassName = line.substring(line.lastIndexOf(' ') + 1);
+ jdkHierarchy.put(className, superClassName);
+ } else {
+ jdkApi.add(line);
+ }
+ }
+ }
+ }
+
+ /** A ClassVisitor that retrofits classes to 1.5 version. */
+ final class ClassRetrofitter extends ClassVisitor {
+ /** The internal name of the visited class. */
+ String owner;
+
+ /** An id used to generate the name of the synthetic string concatenation methods. */
+ int concatMethodId;
+
+ public ClassRetrofitter(final ClassVisitor classVisitor) {
+ super(/* latest api =*/ Opcodes.ASM9, classVisitor);
+ }
+
+ @Override
+ public void visit(
+ final int version,
+ final int access,
+ final String name,
+ final String signature,
+ final String superName,
+ final String[] interfaces) {
+ owner = name;
+ concatMethodId = 0;
+ addPackageReferences(Type.getObjectType(name), /* export= */ true);
+ super.visit(Opcodes.V1_5, access, name, signature, superName, interfaces);
+ }
+
+ @Override
+ public void visitNestHost(final String nestHost) {
+ // Remove the NestHost attribute.
+ }
+
+ @Override
+ public void visitNestMember(final String nestMember) {
+ // Remove the NestMembers attribute.
+ }
+
+ @Override
+ public FieldVisitor visitField(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final Object value) {
+ addPackageReferences(Type.getType(descriptor), /* export= */ false);
+ return super.visitField(access, name, descriptor, signature, value);
+ }
+
+ @Override
+ public MethodVisitor visitMethod(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final String[] exceptions) {
+ addPackageReferences(Type.getType(descriptor), /* export= */ false);
+ return new MethodVisitor(
+ api, super.visitMethod(access, name, descriptor, signature, exceptions)) {
+
+ @Override
+ public void visitParameter(final String name, final int access) {
+ // Javac 21 generates a Parameter attribute for the synthetic/mandated parameters.
+ // Remove the Parameter attribute.
+ }
+
+ @Override
+ public void visitFieldInsn(
+ final int opcode, final String owner, final String name, final String descriptor) {
+ addPackageReferences(Type.getType(descriptor), /* export= */ false);
+ super.visitFieldInsn(opcode, owner, name, descriptor);
+ }
+
+ @Override
+ public void visitMethodInsn(
+ final int opcode,
+ final String owner,
+ final String name,
+ final String descriptor,
+ final boolean isInterface) {
+ addPackageReferences(Type.getType(descriptor), /* export= */ false);
+ // Remove the addSuppressed() method calls generated for try-with-resources statements.
+ // This method is not defined in JDK1.5.
+ if (owner.equals("java/lang/Throwable")
+ && name.equals("addSuppressed")
+ && descriptor.equals("(Ljava/lang/Throwable;)V")) {
+ visitInsn(Opcodes.POP2);
+ } else {
+ super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
+ }
+ }
+
+ @Override
+ public void visitInvokeDynamicInsn(
+ final String name,
+ final String descriptor,
+ final Handle bootstrapMethodHandle,
+ final Object... bootstrapMethodArguments) {
+ // For simple recipe, (if there is no constant pool constants used), rewrite the
+ // concatenation using a StringBuilder instead.
+ if (STRING_CONCAT_FACTORY_HANDLE.equals(bootstrapMethodHandle)
+ && bootstrapMethodArguments.length == 1) {
+ String recipe = (String) bootstrapMethodArguments[0];
+ String methodName = "stringConcat$" + concatMethodId++;
+ generateConcatMethod(methodName, descriptor, recipe);
+ super.visitMethodInsn(INVOKESTATIC, owner, methodName, descriptor, false);
+ return;
+ }
+ super.visitInvokeDynamicInsn(
+ name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);
+ }
+
+ private void generateConcatMethod(
+ final String methodName, final String descriptor, final String recipe) {
+ MethodVisitor mv =
+ visitMethod(
+ ACC_STATIC | ACC_PRIVATE | ACC_SYNTHETIC, methodName, descriptor, null, null);
+ mv.visitCode();
+ mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
+ mv.visitInsn(DUP);
+ mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "", "()V", false);
+ int nexLocal = 0;
+ int typeIndex = 0;
+ int maxStack = 2;
+ Type[] types = Type.getArgumentTypes(descriptor);
+ StringBuilder text = new StringBuilder();
+ for (int i = 0; i < recipe.length(); i++) {
+ char c = recipe.charAt(i);
+ if (c == '\1') {
+ if (text.length() != 0) {
+ generateConstantTextAppend(mv, text.toString());
+ text.setLength(0);
+ }
+ Type type = types[typeIndex++];
+ mv.visitVarInsn(type.getOpcode(ILOAD), nexLocal);
+ maxStack = Math.max(maxStack, 1 + type.getSize());
+ String desc = stringBuilderAppendDescriptor(type);
+ mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", desc, false);
+ nexLocal += type.getSize();
+ } else {
+ text.append(c);
+ }
+ }
+ if (text.length() != 0) {
+ generateConstantTextAppend(mv, text.toString());
+ }
+ mv.visitMethodInsn(
+ INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
+ mv.visitInsn(ARETURN);
+ mv.visitMaxs(maxStack, nexLocal);
+ mv.visitEnd();
+ }
+
+ private void generateConstantTextAppend(final MethodVisitor mv, final String text) {
+ mv.visitLdcInsn(text);
+ mv.visitMethodInsn(
+ INVOKEVIRTUAL,
+ "java/lang/StringBuilder",
+ "append",
+ "(Ljava/lang/String;)Ljava/lang/StringBuilder;",
+ false);
+ }
+
+ private String stringBuilderAppendDescriptor(final Type type) {
+ switch (type.getSort()) {
+ case Type.BYTE:
+ case Type.SHORT:
+ case Type.INT:
+ return "(I)Ljava/lang/StringBuilder;";
+ case Type.OBJECT:
+ return type.getDescriptor().equals("Ljava/lang/String;")
+ ? "(Ljava/lang/String;)Ljava/lang/StringBuilder;"
+ : "(Ljava/lang/Object;)Ljava/lang/StringBuilder;";
+ default:
+ return '(' + type.getDescriptor() + ")Ljava/lang/StringBuilder;";
+ }
+ }
+
+ @Override
+ public void visitTypeInsn(final int opcode, final String type) {
+ addPackageReferences(Type.getObjectType(type), /* export= */ false);
+ super.visitTypeInsn(opcode, type);
+ }
+
+ @Override
+ public void visitMultiANewArrayInsn(final String descriptor, final int numDimensions) {
+ addPackageReferences(Type.getType(descriptor), /* export= */ false);
+ super.visitMultiANewArrayInsn(descriptor, numDimensions);
+ }
+
+ @Override
+ public void visitTryCatchBlock(
+ final Label start, final Label end, final Label handler, final String type) {
+ if (type != null) {
+ addPackageReferences(Type.getObjectType(type), /* export= */ false);
+ }
+ super.visitTryCatchBlock(start, end, handler, type);
+ }
+ };
+ }
+
+ private void addPackageReferences(final Type type, final boolean export) {
+ switch (type.getSort()) {
+ case Type.ARRAY:
+ addPackageReferences(type.getElementType(), export);
+ break;
+ case Type.METHOD:
+ for (Type argumentType : type.getArgumentTypes()) {
+ addPackageReferences(argumentType, export);
+ }
+ addPackageReferences(type.getReturnType(), export);
+ break;
+ case Type.OBJECT:
+ String internalName = type.getInternalName();
+ int lastSlashIndex = internalName.lastIndexOf('/');
+ if (lastSlashIndex != -1) {
+ (export ? exports : imports).add(internalName.substring(0, lastSlashIndex));
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ /**
+ * A ClassVisitor checking that a class uses only JDK 1.5 class file features and the JDK 1.5 API.
+ */
+ final class ClassVerifier extends ClassVisitor {
+
+ /** The internal name of the visited class. */
+ String className;
+
+ /** The name and descriptor of the currently visited method. */
+ String currentMethodName;
+
+ public ClassVerifier() {
+ // Make sure use we don't use Java 9 or higher classfile features.
+ // We also want to make sure we don't use Java 6, 7 or 8 classfile
+ // features (invokedynamic), but this can't be done in the same way.
+ // Instead, we use manual checks below.
+ super(Opcodes.ASM9, null);
+ }
+
+ @Override
+ public void visit(
+ final int version,
+ final int access,
+ final String name,
+ final String signature,
+ final String superName,
+ final String[] interfaces) {
+ if ((version & 0xFFFF) > Opcodes.V1_5) {
+ throw new IllegalArgumentException(format("ERROR: %d version is newer than 1.5", version));
+ }
+ className = name;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(
+ final int access,
+ final String name,
+ final String descriptor,
+ final String signature,
+ final String[] exceptions) {
+ currentMethodName = name + descriptor;
+ MethodVisitor methodVisitor =
+ super.visitMethod(access, name, descriptor, signature, exceptions);
+ return new MethodVisitor(Opcodes.ASM9, methodVisitor) {
+ @Override
+ public void visitFieldInsn(
+ final int opcode, final String owner, final String name, final String descriptor) {
+ check(owner, name);
+ }
+
+ @Override
+ public void visitMethodInsn(
+ final int opcode,
+ final String owner,
+ final String name,
+ final String descriptor,
+ final boolean isInterface) {
+ check(owner, name + descriptor);
+ }
+
+ @Override
+ public void visitLdcInsn(final Object value) {
+ if (value instanceof Type) {
+ int sort = ((Type) value).getSort();
+ if (sort == Type.METHOD) {
+ throw new IllegalArgumentException(
+ format(
+ "ERROR: ldc with a MethodType called in %s %s is not available in JDK 1.5",
+ className, currentMethodName));
+ }
+ } else if (value instanceof Handle) {
+ throw new IllegalArgumentException(
+ format(
+ "ERROR: ldc with a MethodHandle called in %s %s is not available in JDK 1.5",
+ className, currentMethodName));
+ }
+ }
+
+ @Override
+ public void visitInvokeDynamicInsn(
+ final String name,
+ final String descriptor,
+ final Handle bootstrapMethodHandle,
+ final Object... bootstrapMethodArguments) {
+ throw new IllegalArgumentException(
+ format(
+ "ERROR: invokedynamic called in %s %s is not available in JDK 1.5",
+ className, currentMethodName));
+ }
+ };
+ }
+
+ /**
+ * Checks whether or not a field or method is defined in the JDK 1.5 API.
+ *
+ * @param owner A class name.
+ * @param member A field name or a method name and descriptor.
+ */
+ private void check(final String owner, final String member) {
+ if (owner.startsWith("java/")) {
+ String currentOwner = owner;
+ while (currentOwner != null) {
+ if (jdkApi.contains(currentOwner + ' ' + member)) {
+ return;
+ }
+ currentOwner = jdkHierarchy.get(currentOwner);
+ }
+ throw new IllegalArgumentException(
+ format(
+ "ERROR: %s %s called in %s %s is not defined in the JDK 1.5 API",
+ owner, member, className, currentMethodName));
+ }
+ }
+ }
+}
diff --git a/buildSrc/src/main/resources/jdk1.5.0.12.txt.gz b/buildSrc/src/main/resources/jdk1.5.0.12.txt.gz
new file mode 100644
index 0000000..a3141e1
Binary files /dev/null and b/buildSrc/src/main/resources/jdk1.5.0.12.txt.gz differ
diff --git a/gradle.properties b/gradle.properties
index fb2d5a1..dc55186 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,9 +6,10 @@ org.gradle.caching = true
org.gradle.configuration-cache = false
maven_group = dev.nolij
-project_version = 0.4
+project_version = 0.5
project_name = zson
# Dependencies
# https://central.sonatype.com/artifact/org.jetbrains/annotations
-jetbrains_annotations_version = 24.1.0
\ No newline at end of file
+jetbrains_annotations_version = 24.1.0
+junit_version = 5.11.0-M1
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index b82aa23..09523c0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/multiline.json5 b/multiline.json5
new file mode 100644
index 0000000..7b2ffae
--- /dev/null
+++ b/multiline.json5
@@ -0,0 +1,11 @@
+{
+ cr: "new\
+line",
+ lf: "new\
+line",
+ crlf: "new\
+line",
+ u2028: "new line",
+ u2029: "new line",
+ escaped: "new\nline",
+}
\ No newline at end of file
diff --git a/src/main/java/dev/nolij/zson/Zson.java b/src/main/java/dev/nolij/zson/Zson.java
index ab82009..80c693d 100644
--- a/src/main/java/dev/nolij/zson/Zson.java
+++ b/src/main/java/dev/nolij/zson/Zson.java
@@ -12,6 +12,7 @@
import java.io.StringWriter;
import java.io.Writer;
+import java.lang.reflect.Array;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -20,28 +21,25 @@
import java.math.BigInteger;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
-@SuppressWarnings({"deprecation", "UnstableApiUsage"})
+import static dev.nolij.zson.ZsonValue.NO_COMMENT;
+
+@SuppressWarnings({"deprecation", "UnstableApiUsage", "BooleanMethodIsAlwaysInverted"})
public final class Zson {
- //region Helper Methods
+ //region -------------------- Helper Methods --------------------
/**
* Create a new entry with the given key, comment, and value.
*/
@NotNull
@Contract("_, _, _ -> new")
- public static Map.Entry entry(@NotNull String key, @Nullable String comment, @Nullable Object value) {
+ public static Map.Entry entry(@NotNull String key, @NotNull String comment, @Nullable Object value) {
return new AbstractMap.SimpleEntry<>(key, new ZsonValue(comment, value));
}
/**
- * Create a new entry with the given key and value. The comment will be null.
+ * Create a new entry with the given key and value, and no comment.
*/
@NotNull
@Contract(value = "_, _ -> new", pure = true)
@@ -123,6 +121,9 @@ public static String unescape(@Nullable String string) {
case 't' -> '\t';
case '\'', '\"', '\\', '\n', '\r' -> d;
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> {
+ if(d == '0' && (i + 1 >= chars.length || chars[i + 1] < '0' || chars[i + 1] > '9')) {
+ yield '\0';
+ }
int limit = d < '4' ? 2 : 1;
int code = d - '0';
for (int k = 1; k < limit; k++) {
@@ -142,7 +143,9 @@ public static String unescape(@Nullable String string) {
i += 4;
yield (char) Integer.parseInt(hex, 16);
}
- default -> throw new IllegalArgumentException(String.format("Invalid escape sequence: \\%c \\\\u%04X", d, (int) d));
+
+ // JSON5 spec says to ignore invalid escape sequences
+ default -> d;
};
chars[j++] = c;
@@ -230,12 +233,34 @@ public static Map obj2Map(@Nullable Object object) {
Map map = object();
for (Field field : object.getClass().getDeclaredFields()) {
if(!shouldInclude(field, true)) continue;
- ZsonField value = field.getAnnotation(ZsonField.class);
- String comment = value == null ? null : value.comment();
+ ZsonField annotation = field.getAnnotation(ZsonField.class);
+ String comment = annotation == null ? NO_COMMENT : annotation.comment();
try {
boolean accessible = field.isAccessible();
if (!accessible) field.setAccessible(true);
- map.put(field.getName(), new ZsonValue("\0".equals(comment) ? null : comment, field.get(object)));
+
+ Object value = field.get(object);
+
+ if(value instanceof Map) {
+ value = obj2Map(value);
+ } else if(value instanceof Iterable) {
+ List