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 list = new ArrayList<>(); + for (Object o : (Iterable) value) { + list.add(o); + } + value = list; + } else if(value != null) { + if(value.getClass().isArray()) { + List list = new ArrayList<>(); + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + list.add(Array.get(value, i)); + } + value = list; + } + } + + map.put(field.getName(), new ZsonValue(comment, value)); if (!accessible) field.setAccessible(false); } catch (IllegalAccessException e) { throw new RuntimeException("Failed to get field " + field.getName(), e); @@ -254,9 +279,15 @@ public static Map obj2Map(@Nullable Object object) { */ @NotNull @Contract("_ , _ -> new") + @SuppressWarnings("unchecked") public static T map2Obj(@NotNull Map map, @NotNull Class type) { try { - T object = type.getDeclaredConstructor().newInstance(); + T object; + if(type.isArray()) { + object = (T) Array.newInstance(type.getComponentType(), map.size()); + } else { + object = type.getDeclaredConstructor().newInstance(); + } for (Field field : type.getDeclaredFields()) { if(!shouldInclude(field, false)) continue; if(!map.containsKey(field.getName())) { @@ -279,7 +310,6 @@ public static T map2Obj(@NotNull Map map, @NotNull Class< * otherwise they are not included at all. * @return true if the field should be included in a JSON map, false otherwise. */ - @SuppressWarnings("BooleanMethodIsAlwaysInverted") private static boolean shouldInclude(Field field, boolean forDeserialization) { ZsonField value = field.getAnnotation(ZsonField.class); @@ -314,12 +344,52 @@ private static void setField(Field field, Object object, Object value) { case "double" -> field.setDouble(object, ((Number) value).doubleValue()); case "long" -> field.setLong(object, ((Number) value).longValue()); case "byte" -> field.setByte(object, ((Number) value).byteValue()); - case "char" -> field.setChar(object, (char) value); + case "char" -> field.setChar(object, ((String) value).charAt(0)); } } else { Object finalValue = value; if (type.isEnum() && value instanceof String) { finalValue = Enum.valueOf((Class) type, (String) value); + } else if (type.isArray() && value instanceof Iterable itr) { + int size = 0; + for (Object ignored : itr) size++; + + Object array = Array.newInstance(type.getComponentType(), size); + int i = 0; + for (Object o : itr) { + Array.set(array, i++, o); + } + finalValue = array; + } else if (value instanceof Map && !Map.class.isAssignableFrom(type)) { + finalValue = map2Obj((Map) value, type); + } else if(Collection.class.isAssignableFrom(type)) { + Collection collection; + if(type.isInterface()) { + if(List.class.isAssignableFrom(type)) { + collection = new ArrayList<>(); + } else if(Set.class.isAssignableFrom(type)) { + collection = new LinkedHashSet<>(); + } else { + throw new IllegalArgumentException("Unsupported collection type: " + type); + } + } else { + collection = (Collection) type.getDeclaredConstructor().newInstance(); + } + + if(value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + collection.add(Array.get(value, i)); + } + } else if(value instanceof Iterable itr) { + for (Object o : itr) { + collection.add(o); + } + } else { + throw new IllegalArgumentException("Expected array or iterable, got " + value.getClass()); + } + + finalValue = collection; } field.set(object, finalValue); } @@ -335,7 +405,7 @@ private static void setField(Field field, Object object, Object value) { //endregion - //region Parser + //region -------------------- Parser -------------------- /** * Parses a JSON value from the contents of the given {@link Path}. * If the file contains multiple JSON values, only the first one will be parsed. @@ -555,12 +625,16 @@ private static String parseString(Reader input, char start) throws IOException { escapes--; } - if (c == '\n') { - if (escapes == 0) - throw new IllegalArgumentException("Unexpected newline"); + if (isLineTerminator(c)) { + if (escapes == 0) { + if (c == '\u2028' || c == '\u2029') { + System.err.println("[ZSON] Warning: unescaped line separator in string literal"); + } else { + throw new IllegalArgumentException("Unexpected newline"); + } + } escapes = 0; - output.append('\n'); continue; } @@ -651,6 +725,22 @@ private static boolean isIdentifierChar(int c) { type == Character.CONNECTOR_PUNCTUATION; } + /** + * @see ECMAScript 5.1 §7.2 + */ + private static boolean isWhitespace(int c) { + return c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' ' + || c == 0x00A0 || c == 0xFEFF || Character.getType(c) == Character.SPACE_SEPARATOR; + } + + /** + * @see ECMAScript 5.1 §7.3 + + */ + private static boolean isLineTerminator(int c) { + return c == '\n' || c == '\r' || c == '\u2028' || c == '\u2029'; + } + /** * Parses a JSON boolean from the given {@link Reader}. The reader should be positioned at the start of the boolean. * @param input The reader to parse the boolean from @@ -740,7 +830,12 @@ private static Number parseNumber(Reader input, char start) throws IOException { hexValueBuilder.append(Character.toChars(c)); } else { input.reset(); - return Integer.parseInt(hexValueBuilder.toString(), 16); + long l = Long.parseLong(hexValueBuilder.toString().toUpperCase(Locale.ROOT), 16); + if(l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) { + return (int) l; + } else { + return l; + } } } @@ -817,7 +912,7 @@ private static boolean skipWhitespace(Reader input) throws IOException { int c; var skipped = 0; while ((c = input.read()) != -1) { - if (!Character.isWhitespace(c)) { + if (!isWhitespace(c) && !isLineTerminator(c)) { input.reset(); return skipped != 0; @@ -873,9 +968,9 @@ private static IllegalArgumentException unexpectedEOF() { } //endregion - //region Writer + //region -------------------- Writer -------------------- public String indent; - public boolean expandArrays; + public boolean expandArrays; // whether to put each array element on its own line public boolean quoteKeys; public Zson() { @@ -926,11 +1021,19 @@ public void write(@NotNull Map data, @NotNull Path path) thro public void write(@NotNull Map data, @NotNull Appendable output) throws IOException { output.append("{\n"); + boolean first = true; + for (var entry : data.entrySet()) { + if (first) { + first = false; + } else { + output.append(",\n"); + } + ZsonValue zv = entry.getValue(); String comment = zv.comment; - if (comment != null) { + if (!NO_COMMENT.equals(comment)) { for (String line : comment.split("\n")) { output .append(indent) @@ -948,10 +1051,10 @@ public void write(@NotNull Map data, @NotNull Appendable outp if (quoteKeys) output.append('"'); - output.append(": ").append(value(zv.value)).append(",\n"); + output.append(": ").append(value(zv.value)); } - output.append("}"); + output.append("\n}"); } /** @@ -977,10 +1080,10 @@ private String checkIdentifier(String key) { * @param value The value to convert. * @return a JSON5-compatible string representation of the value. */ - private String value(Object value) { + @SuppressWarnings("unchecked") + public String value(Object value) { if (value instanceof Map) { try { - //noinspection unchecked return stringify((Map) value).replace("\n", "\n" + indent); } catch (ClassCastException e) { if(e.getMessage().contains("cannot be cast to")) { @@ -993,35 +1096,51 @@ private String value(Object value) { // rethrow but without the recursive cause throw new StackOverflowError("Map is circular"); } - } else if (value instanceof String stringValue) { - return '"' + escape(stringValue, '"') + '"'; + } else if (value instanceof String || value instanceof Character) { + return '"' + escape(value.toString(), '"') + '"'; } else if (value instanceof Number || value instanceof Boolean || value == null) { return String.valueOf(value); } else if (value instanceof Iterable iterableValue) { StringBuilder output = new StringBuilder("["); - output.append(expandArrays ? "\n" : " "); + String indent = expandArrays ? this.indent : " "; + output.append(indent); + + boolean first = true; for (Object obj : iterableValue) { - if (expandArrays) + if (!first) { + output.append(",").append(indent); + } else { + first = false; + } + + if (expandArrays) { output.append(indent).append(indent); - output.append(value(obj).replace("\n", "\n" + indent + indent)) - .append(",") - .append(expandArrays ? "\n" : " "); + } + + output.append(value(obj).replace("\n", "\n" + indent + indent)); } + output.append(indent); + if (expandArrays) output.append(indent); return output.append("]").toString(); } else if(value instanceof Enum enumValue) { return '"' + enumValue.name() + '"'; + } else { + return value(obj2Map(value)); } - - throw new IllegalArgumentException("Unsupported value type: " + value.getClass().getName()); } @Contract(value = "_ -> this", mutates = "this") public Zson withIndent(String indent) { + for(char c : indent.toCharArray()) { + if(!isWhitespace(c)) { + throw new IllegalArgumentException("Indent '" + indent + "' must be a whitespace string"); + } + } this.indent = indent; return this; } diff --git a/src/main/java/dev/nolij/zson/ZsonField.java b/src/main/java/dev/nolij/zson/ZsonField.java index 329e0ef..7f2a486 100644 --- a/src/main/java/dev/nolij/zson/ZsonField.java +++ b/src/main/java/dev/nolij/zson/ZsonField.java @@ -5,10 +5,26 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * ZsonField is an annotation that can be used to specify properties + * about a field in a class that is being serialized or deserialized to ZSON. + */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface ZsonField { - String comment() default "\0"; + + /** + * @return a comment that describes this field, to be included in the ZSON output. + */ + String comment() default ZsonValue.NO_COMMENT; + + /** + * @return whether to include this field when (de)serializing, even if it is private or static. + */ boolean include() default false; + + /** + * @return whether to exclude this field when (de)serializing. + */ boolean exclude() default false; } diff --git a/src/main/java/dev/nolij/zson/ZsonValue.java b/src/main/java/dev/nolij/zson/ZsonValue.java index e6df600..62716a0 100644 --- a/src/main/java/dev/nolij/zson/ZsonValue.java +++ b/src/main/java/dev/nolij/zson/ZsonValue.java @@ -1,5 +1,6 @@ package dev.nolij.zson; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Objects; @@ -10,9 +11,14 @@ public final class ZsonValue { /** - * The comment for this value. If null, there is no comment. + * The value for {@link #comment} when there is no comment, represented as the null character. */ - @Nullable + public static final String NO_COMMENT = "\0"; + + /** + * The comment for this value. If the comment is {@link #NO_COMMENT}, then there is no comment. + */ + @NotNull public String comment; /** @@ -21,13 +27,13 @@ public final class ZsonValue { @Nullable public Object value; - public ZsonValue(@Nullable String comment, @Nullable Object value) { + public ZsonValue(@NotNull String comment, @Nullable Object value) { this.comment = comment; this.value = value; } public ZsonValue(Object value) { - this(null, value); + this(NO_COMMENT, value); } @Override @@ -42,7 +48,10 @@ public boolean equals(Object other) { @Override public String toString() { - // TODO: maybe the comment should be added here too - return String.valueOf(value); + StringBuilder sb = new StringBuilder("ZsonValue{"); + if(!comment.equals(NO_COMMENT)) { + sb.append("comment='").append(comment).append("', "); + } + return sb.append("value=").append(value).append('}').toString(); } } diff --git a/src/test/java/ZsonTest.java b/src/test/java/ZsonTest.java index c5db64b..9a8cc34 100644 --- a/src/test/java/ZsonTest.java +++ b/src/test/java/ZsonTest.java @@ -6,8 +6,10 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static dev.nolij.zson.Zson.*; @@ -15,6 +17,11 @@ @SuppressWarnings({"unused", "DataFlowIssue", "FieldMayBeFinal"}) public class ZsonTest { + @Test + public void makeSureTestsRun() { + System.out.println("Tests are running!"); + } + @Test public void json5Spec() throws IOException { @@ -23,7 +30,7 @@ public void json5Spec() throws IOException { assertEquals(map, object( entry("unquoted", "and you can quote me on that"), entry("singleQuotes", "I can use \"double quotes\" here"), - entry("lineBreaks", "Look, Mom! \nNo \\n's!"), + entry("lineBreaks", "Look, Mom! No \\n's!"), entry("hexadecimal", 0xdecaf), entry("leadingDecimalPoint", .8675309), entry("andTrailing", 8675309.), @@ -72,13 +79,13 @@ public void testReadWrite() { // The state of the address "state": "IL", // The zip code of the address - "zip": 62701, + "zip": 62701 }, // The phone numbers of the person "phoneNumbers": { "home": "217-555-1234", - "cell": "217-555-5678", - }, + "cell": "217-555-5678" + } }"""; assertEquals(expected, json); @@ -143,7 +150,7 @@ public void testRead() { assertEquals(Double.POSITIVE_INFINITY, map.get("inf").value); assertEquals(Double.NEGATIVE_INFINITY, map.get("neginf").value); assertTrue(Double.isNaN((double) map.get("nan").value)); - assertEquals("wow look\n\ta multiline string", map.get("multiline-string").value); + assertEquals("wow look\ta multiline string", map.get("multiline-string").value); } @Test @@ -152,11 +159,12 @@ public void testNumbers() { { "int": 42, "float": 3.14, - "exp": 6.022e23, + "exp": 6.022E23, "neg": -1, "hex": 0x2A, "inf": Infinity, "w": NaN, + java: 0XcAfeBabE, "neginf": -Infinity, }"""; @@ -169,6 +177,7 @@ public void testNumbers() { assertEquals(42, map.get("hex").value); assertEquals(Double.POSITIVE_INFINITY, map.get("inf").value); assertTrue(Double.isNaN((Double) map.get("w").value)); + assertEquals(0xcAfeBabEL, map.get("java").value); // the extra L is because it's a long assertEquals(Double.NEGATIVE_INFINITY, map.get("neginf").value); assertEquals(""" @@ -180,7 +189,8 @@ public void testNumbers() { "hex": 42, "inf": Infinity, "w": NaN, - "neginf": -Infinity, + "java": 3405691582, + "neginf": -Infinity }""", new Zson().stringify(map)); } @@ -194,7 +204,7 @@ public void testObject() { "such": "amaze", "very": true, "constant": "wow", - "testEnum": "ONE", + "testEnum": "ONE" }"""; String actual = new Zson().stringify(json); @@ -296,7 +306,7 @@ public void testUnquotedKeys() { such: "amaze", very: true, constant: "wow", - testEnum: "TWO", + testEnum: "TWO" }"""; String actual = new Zson().withQuoteKeys(false).stringify(json); @@ -312,6 +322,32 @@ public void testUnquotedKeys() { assertEquals(obj, obj2); } + @Test + public void newlinesInStrings() throws IOException { + Map map = parseFile(Path.of("multiline.json5")); // kept in a separate file because the newlines are weird + + assertEquals("newline", map.get("cr").value); + assertEquals("newline", map.get("lf").value); + assertEquals("newline", map.get("crlf").value); + assertEquals("newline", map.get("u2028").value); + assertEquals("newline", map.get("u2029").value); + assertEquals("new\nline", map.get("escaped").value); + } + + @Test + public void otherRandomStuff() { + Map map = parseString(""" + { + weirdEscapes: "\\A\\C\\/\\D\\C", + + // contains all "valid" whitespace characters + whitespace:\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u2028\u2029\ufeff "" + } + """); // weirdEscapes is actually \A\C/\D\C but we need to escape the backslashes for java + + assertEquals("AC/DC", map.get("weirdEscapes").value); + } + public static class TestObject { @ZsonField(comment = "look a comment") public int wow = 42; @@ -340,6 +376,40 @@ public boolean equals(Object obj) { } } + @Test + public void testObjectFields() { + Map json = Zson.obj2Map(new ObjectFields()); + String expected = """ + { + "a": 0, + "set": [ "a", "b", "c" ], + "b": { + "bool": false, + "b": 0, + "s": 0, + "i": 0, + "l": 0, + "f": 0.0, + "d": 0.0, + "c": "\\0", + "str": null, + "e": null + }, + "c": "ONE" + }"""; + + String actual = new Zson().stringify(json); + + assertEquals(expected, actual); + + json = Zson.parseString(actual); + + ObjectFields obj = Zson.map2Obj(json, ObjectFields.class); + assertEquals(0, obj.a); + assertEquals(0, obj.b.i); + assertEquals(TestEnum.ONE, obj.c); + } + public static class AllTypes { public boolean bool; public byte b; @@ -353,6 +423,19 @@ public static class AllTypes { public TestEnum e; } + public static class ObjectFields { + public int a; + public Set set = new HashSet<>(); + public AllTypes b = new AllTypes(); + public TestEnum c = TestEnum.ONE; + + { + set.add("a"); + set.add("b"); + set.add("c"); + } + } + public enum TestEnum { ONE, TWO, THREE }