diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67676e9..c581a8b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,8 +6,8 @@ updates: schedule: interval: monthly - # Maintain dependencies for Maven - - package-ecosystem: maven + # Maintain dependencies for Gradle + - package-ecosystem: gradle directory: / schedule: - interval: monthly \ No newline at end of file + interval: monthly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bb19a63 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,171 @@ +# GitHub Actions Workflow is created for testing and preparing the library release in the following steps: +# - Validate Gradle Wrapper. +# - Run 'test' task. +# - Run Qodana inspections. +# - Run the 'buildLibrary' task and prepare artifact for further tests. +# - Create a draft release. +# +# The workflow is triggered on push and pull_request events. +# +# GitHub Actions reference: https://help.github.com/en/actions + +name: Build +on: + # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) + push: + branches: [ main ] + # Trigger the workflow on any pull request + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + + # Prepare the environment and build the library + build: + name: Build + runs-on: ubuntu-latest + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: adopt + java-version: 11 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Build the library + - name: Build Library + run: ./gradlew build + + # Store an already-built library as an artifact for downloading + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + path: ./build/libs/* + + # Run tests and upload a code coverage report + test: + name: Test + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: adopt + java-version: 11 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + # Run tests + - name: Run Tests + run: ./gradlew check + + # Collect Tests Result of failed tests + - name: Collect Tests Result + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: tests-result + path: ${{ github.workspace }}/build/reports/tests + + # Upload the jacoco report to CodeCov + - name: Upload Code Coverage Report + uses: codecov/codecov-action@v5 + with: + files: ${{ github.workspace }}/build/reports/jacoco/jacocoTestReport.xml + token: ${{ secrets.CODECOV_TOKEN }} + + # Upload the test results to CodeCov + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + files: ${{ github.workspace }}/build/test-results/test/*.xml + token: ${{ secrets.CODECOV_TOKEN }} + + # Run Qodana inspections and provide a report + inspectCode: + name: Inspect code + needs: [ build ] + runs-on: ubuntu-latest + permissions: + contents: write + checks: write + pull-requests: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit + fetch-depth: 0 # a full history is required for pull request analysis + + # Run Qodana inspections + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2025.2 + with: + pr-mode: false + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN_2073039781 }} + QODANA_ENDPOINT: 'https://qodana.cloud' + + # Prepare a draft release for GitHub Releases page for the manual verification + # If accepted and published, the release workflow would be triggered + releaseDraft: + name: Release draft + if: github.event_name != 'pull_request' + needs: [ build, test, inspectCode ] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Remove old release drafts by using the curl request for the available releases with a draft flag + - name: Remove Old Release Drafts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api repos/{owner}/{repo}/releases \ + --jq '.[] | select(.draft == true) | .id' \ + | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} + + # Create a new release draft which is not publicly visible and requires manual acceptance + - name: Create Release Draft + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(./gradlew properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') + RELEASE_NOTE="./build/tmp/release_note.txt" + ./gradlew getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE + + gh release create $VERSION \ + --draft \ + --title $VERSION \ + --notes-file $RELEASE_NOTE diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index cec54df..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Maven CD - -on: - push: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Set up JDK 11 - uses: actions/setup-java@v5 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - server-id: central - server-username: MAVEN_USERNAME - server-password: MAVEN_PASSWORD - gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - - - name: Deploy with Maven - run: mvn -B clean deploy -Pci-cd - env: - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e833119..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Maven CI - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Set up JDK 11 - uses: actions/setup-java@v5 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - - name: Build with Maven - run: mvn --batch-mode --fail-at-end --update-snapshots verify - - - name: Publish Test-Report - if: ${{ always() }} - uses: scacap/action-surefire-report@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: ./target/site/jacoco/ - fail_ci_if_error: true - verbose: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..86db6e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +# GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. + +name: Release +on: + release: + types: [prereleased, released] + +jobs: + + # Prepare and publish the library to Maven Central and GitHub Packages + release: + name: Publish Library + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: adopt + java-version: 11 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + # Update the Unreleased section with the current release note + - name: Patch Changelog + if: ${{ github.event.release.body != '' }} + env: + CHANGELOG: ${{ github.event.release.body }} + run: | + RELEASE_NOTE="./build/tmp/release_note.txt" + mkdir -p "$(dirname "$RELEASE_NOTE")" + echo "$CHANGELOG" > $RELEASE_NOTE + + ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE + + # Publish the library to Maven Central + - name: Publish Library + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralUsername }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralPassword }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_signingInMemoryKey }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword }} + run: ./gradlew publishToMavenCentral + + # Upload an artifact as a release asset + - name: Upload Release Asset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload ${{ github.event.release.tag_name }} ./build/libs/* + + # Create a pull request + - name: Create Pull Request + if: ${{ steps.properties.outputs.changelog != '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ github.event.release.tag_name }}" + BRANCH="changelog-update-$VERSION" + LABEL="release changelog" + + git config user.email "action@github.com" + git config user.name "GitHub Action" + + git checkout -b $BRANCH + git commit -am "Changelog update - $VERSION" + git push --set-upstream origin $BRANCH + + gh label create "$LABEL" \ + --description "Pull requests with release changelog update" \ + --force \ + || true + + gh pr create \ + --title "Changelog update - \`$VERSION\`" \ + --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ + --label "$LABEL" \ + --head $BRANCH diff --git a/.gitignore b/.gitignore index cea2657..ca643f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ target/ -pom.xml.tag -pom.xml.releaseBackup -pom.xml.versionsBackup -pom.xml.next -release.properties -dependency-reduced-pom.xml -buildNumber.properties -.mvn/timing.properties -# https://github.com/takari/maven-wrapper#usage-without-binary-jar -.mvn/wrapper/maven-wrapper.jar +build +.gradle # Eclipse m2e generated files # Eclipse Core @@ -17,4 +9,4 @@ buildNumber.properties .classpath # IntelliJ IDE -.idea \ No newline at end of file +.idea diff --git a/.run/Run build.run.xml b/.run/Run build.run.xml new file mode 100644 index 0000000..cd36cb8 --- /dev/null +++ b/.run/Run build.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.run/Run tests.run.xml b/.run/Run tests.run.xml new file mode 100644 index 0000000..83d988a --- /dev/null +++ b/.run/Run tests.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c6e31..3550b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Option: `stringifyUnixInstants` +- Option: `stringifyAscii` +- Option: `allowNaN` +- Option: `allowInfinity` +- Option: `quoteSingle` +- Option: `quoteless` +- Option: `allowBinaryLiterals` +- Option: `allowOctalLiterals` +- Option: `allowHexFloatingLiterals` +- Option: `allowLongUnicodeEscapes` +- Option: `allowTrailingData` +- Option: `parseComments` +- Option: `writeComments` +- Option: `insertFinalNewline` +- Option: `digitSeparatorStrategy` +- Option: `duplicateBehaviour` +### Changed +- `Json5Null` is no longer a singleton as it allows individual comments +- `Json5Primitive` holds any primitive value besides `Json5Null` +- `Json5Options` with more advanced builder +### Removed +- `Json5Boolean`, `Json5Hexadecimal`, `Json5Number` and `Json5String` in favor of `Json5Primitive`. ## [2.0.1] - 2025-09-03 ### Changed diff --git a/README.md b/README.md index ad27a17..2d960be 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,55 @@ # json5-java -[![Build](https://img.shields.io/github/actions/workflow/status/marhali/json5-java/cd.yml?branch=main)](https://github.com/marhali/json5-java/actions) -[![JavaDoc](https://javadoc.io/badge2/de.marhali/json5-java/javadoc.svg)](https://javadoc.io/doc/de.marhali/json5-java) -[![Coverage](https://img.shields.io/codecov/c/github/marhali/json5-java)](https://codecov.io/gh/marhali/json5-java) -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/marhalide) +[![Build](https://img.shields.io/github/actions/workflow/status/marhali/json5-java/ci.yml?branch=main&style=for-the-badge)](https://github.com/marhali/json5-java/actions) +[![Release](https://img.shields.io/github/v/release/marhali/json5-java?style=for-the-badge)](https://github.com/marhali/json5-java/releases) +[![JavaDoc](https://javadoc.io/badge2/de.marhali/json5-java/javadoc.svg?style=for-the-badge)](https://javadoc.io/doc/de.marhali/json5-java) +[![Coverage](https://img.shields.io/codecov/c/github/marhali/json5-java?style=for-the-badge)](https://codecov.io/gh/marhali/json5-java) +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?style=for-the-badge)](https://paypal.me/marhalide) This is a reference implementation of the [JSON5 standard](https://json5.org/) in Java 11+, capable of parsing and serialization of JSON5 data. This library is an enhanced version of [Synt4xErr0r4 / json5](https://github.com/Synt4xErr0r4/json5), -which provides a better full-fledged API inspired by the [GSON](https://github.com/google/gson) library. +which provides a better full-fledged API inspired by Google's [Gson](https://github.com/google/gson) library. + +## Features + +- Fully supports JSON5 according to the [specification](https://spec.json5.org/) +- Extensive API for interacting with elements, inspired by Google's [Gson](https://github.com/google/gson) library +- Supports comment parsing and writing (if they can be associated with an [Json5Element](src/main/java/de/marhali/json5/Json5Element.java)) +- Fine-grained [configuration options](#configuration-options) +- No runtime dependencies – ensures a clean supply chain + +## Installation + +Download the [latest release](https://github.com/marhali/json5-java/releases/latest) manually or add it as a Maven dependency. +Don't worry the project is already in the [Maven Central Repository](https://central.sonatype.com/artifact/de.marhali/json5-java). See the configuration below for your favorite build system. + +### Add via Maven -## Download -Download the [latest release](https://search.maven.org/artifact/de.marhali/json5-java) manually or add a Maven dependency. -Don't worry the project is already in the Maven Central Repository. Just add the following configuration: ```xml de.marhali json5-java - 2.0.0 + 3.0.0 ``` +### Add via Gradle + +```kotlin +repositories { + mavenCentral() +} + +dependencies { + implementation("de.marhali:json5-java:3.0.0") +} +``` + ## Usage + This library can be used by either configuring a [Json5](src/main/java/de/marhali/json5/Json5.java) instance or by using the underlying [Json5Parser](src/main/java/de/marhali/json5/stream/Json5Parser.java) and [Json5Writer](src/main/java/de/marhali/json5/stream/Json5Writer.java). @@ -32,29 +58,48 @@ The following section describes how to use this library with the [Json5](src/main/java/de/marhali/json5/Json5.java) core class. ### Configure Json5 instance -See [Parsing & Serialization Options](#parsing--serialization-options) to see a list of possible configuration options. + +See [Configuration Options](#configuration-options) for a full overview of possible options. + ```java -// Using builder pattern -Json5 json5 = Json5.builder(options -> - options.allowInvalidSurrogate().quoteSingle().prettyPrinting().build()); +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.Json5; + +// Create Json5 instance using builder pattern to configure desired options +Json5 json5 = Json5.builder(builder -> builder + .quoteless() + .quoteSingle() + .parseComments() + .writeComments() + .prettyPrinting() + .build() +); // Using configuration object -Json5Options options = new Json5Options(true, true, true, 2); +Json5Options options = Json5Options.builder() + // ... + .build(); Json5 json5 = new Json5(options); ``` -### Parsing +### Parsing + +During parsing, a JSON5 file or string is converted into the corresponding [Json5Element's](src/main/java/de/marhali/json5/Json5Element.java). + ```java +import de.marhali.json5.Json5; +import de.marhali.json5.Json5Element; + Json5 json5 = ... -// Parse from a String literal +// Parse from a String Json5Element element = json5.parse("{ 'key': 'value', 'array': ['first val','second val'] }"); // ... // Parse from a Reader or InputStream -try(InputStream stream = ...) { +try (InputStream stream = ...) { Json5Element element = json5.parse(stream); // ... } catch (IOException e) { @@ -63,7 +108,13 @@ try(InputStream stream = ...) { ``` ### Serialization + +During serialization, [Json5Element's](src/main/java/de/marhali/json5/Json5Element.java) are converted to their string representation so that they can be written to a file, for example. + ```java +import de.marhali.json5.Json5; +import de.marhali.json5.Json5Element; + Json5Element element = ... // Serialize to a String literal @@ -72,7 +123,7 @@ String jsonString = json5.serialize(element); // ... // Serialize to a Writer or OutputStream -try(OutputStream stream = ...) { +try (OutputStream stream = ...) { json5.serialize(element, stream); // ... } catch (IOException e) { @@ -83,16 +134,57 @@ try(OutputStream stream = ...) { ## Documentation Detailed javadoc documentation can be found at [javadoc.io](https://javadoc.io/doc/de.marhali/json5-java). -### Parsing & Serialization Options +### API + +This library provides a few core classes to interact with JSON5 elements. + +- [Json5](src/main/java/de/marhali/json5/Json5.java): Core class for parsing and serialization +- [Json5Options](src/main/java/de/marhali/json5/config/Json5Options.java): Library configuration and options builder +- [Json5Element](src/main/java/de/marhali/json5/Json5Element.java): Root class for every JSON5 element +- [Json5Null](src/main/java/de/marhali/json5/Json5Null.java): Class representing the JSON5 `null` value +- [Json5Object](src/main/java/de/marhali/json5/Json5Object.java): Represents a JSON5 object +- [Json5Array](src/main/java/de/marhali/json5/Json5Array.java): Represents a JSON5 array +- [Json5Primitive](src/main/java/de/marhali/json5/Json5Primitive.java): Holds any primitive value (`Boolean`, `Number` or `String`) + +> For a better understanding of how to use the API, take a look at the [unit tests](src/test/java/de/marhali/json5). + +### Configuration Options This library supports a few customizations to adjust the behaviour of parsing and serialization. -For a detailed explanation see the [Json5Options](src/main/java/de/marhali/json5/Json5Options.java) class. +For a detailed explanation see the [Json5Options](src/main/java/de/marhali/json5/config/Json5Options.java) class. +- stringifyUnixInstants +- stringifyAscii +- allowNaN +- allowInfinity - allowInvalidSurrogates - quoteSingle +- quoteless +- allowBinaryLiterals +- allowOctalLiterals +- allowHexFloatingLiterals +- allowLongUnicodeEscapes +- allowTrailingData +- parseComments +- writeComments - trailingComma +- insertFinalNewline +- digitSeparatorStrategy +- duplicateBehaviour - indentFactor +> To get started using this library, `Json5Options.DEFAULT` may be a good starting point, as these are the recommended options. + ## License This library is released under the [Apache 2.0 license](LICENSE). -Partial parts of the project are based on [GSON](https://github.com/google/gson) and [Synt4xErr0r4 / json5](https://github.com/Synt4xErr0r4/json5). The affected classes contain the respective license notice. +Partial parts of the project are based on [Gson](https://github.com/google/gson) and [Synt4xErr0r4 / json5](https://github.com/Synt4xErr0r4/json5). The affected classes contain the respective license notice. + +## Contact + +Marcel Haßlinger - [@marhali_de](https://twitter.com/marhali_de) - [Portfolio Website](https://marhali.de) + +Project Link: [https://github.com/marhali/json5-java](https://github.com/marhali/json5-java) + +## Donation + +If this library helps you to reduce development time, you can give me a [cup of coffee](https://paypal.me/marhalide) :) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c1ff359 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + `java-library` + jacoco + id("org.jetbrains.changelog") version "2.4.0" + id("com.vanniktech.maven.publish") version "0.34.0" +} + +group = "de.marhali" +version = "3.0.0" +description = "A lightweight library to parse and serialize JSON5 data" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } + withSourcesJar() + withJavadocJar() +} + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + signAllPublications() + + coordinates(project.group.toString(), project.name, project.version.toString()) + + pom { + name.set(project.name) + description.set(project.description) + inceptionYear.set("2022") + url.set("https://github.com/marhali/json5-java") + licenses { + licenses { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("marhali") + name.set("Marcel Haßlinger") + email.set("code@shield.marhali.de") + url.set("https://marhali.de") + } + } + scm { + connection.set("scm:git:git@github.com:marhali/json5-java.git") + developerConnection.set("scm:git:ssh://git@github.com/marhali/json5-java.git") + url.set("https://github.com/marhali/json5-java/tree/main") + } + } +} + +tasks { + named("test") { + useJUnitPlatform() + finalizedBy("jacocoTestReport") + } + + named("jacocoTestReport") { + dependsOn("test") + reports { + xml.required.set(true) + html.required.set(true) + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5ad6974 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e42625a --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,6 @@ +[versions] +junit = "5.13.4" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pom.xml b/pom.xml deleted file mode 100644 index 1154fc5..0000000 --- a/pom.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - 4.0.0 - - de.marhali - json5-java - 2.0.1 - ${project.groupId}:${project.artifactId} - A lightweight library to parse and serialize JSON5 data - https://github.com/marhali/json5-java - - - - The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - - - - - - marhali - Marcel Haßlinger - contact@marhali.de - - - - - scm:git:git@github.com:marhali/json5-java.git - scm:git:ssh://github.com:marhali/json5-java.git - https://github.com/marhali/json5-java/tree/main - - - - 11 - 11 - - - - - ossrh - https://s01.oss.sonatype.org/content/repositories/snapshots - - - ossrh - https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ - - - - - - - org.junit.jupiter - junit-jupiter-engine - 5.13.4 - test - - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.3.1 - - - attach-sources - - jar-no-fork - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.11.3 - - - attach-javadocs - - jar - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.3 - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.5.3 - - - org.jacoco - jacoco-maven-plugin - 0.8.13 - - - - prepare-agent - - - - report - prepare-package - - report - - - - - - org.sonatype.central - central-publishing-maven-plugin - 0.8.0 - true - - central - - - - - - - - ci-cd - - - - org.apache.maven.plugins - maven-gpg-plugin - 3.2.8 - - - sign-artifact - verify - - sign - - - - - --pinentry-mode - loopback - - - - - - - - - - diff --git a/qodana.yml b/qodana.yml new file mode 100644 index 0000000..2e1c995 --- /dev/null +++ b/qodana.yml @@ -0,0 +1,9 @@ +# Qodana configuration: +# https://www.jetbrains.com/help/qodana/qodana-yaml.html + +version: "1.0" +linter: jetbrains/qodana-jvm-community:2025.2 +profile: + name: qodana.recommended +include: + - name: All diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a93277d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +rootProject.name = "json5-java" + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} diff --git a/src/main/java/de/marhali/json5/Json5.java b/src/main/java/de/marhali/json5/Json5.java index 1aa56ad..ac8a677 100644 --- a/src/main/java/de/marhali/json5/Json5.java +++ b/src/main/java/de/marhali/json5/Json5.java @@ -16,6 +16,7 @@ package de.marhali.json5; +import de.marhali.json5.config.Json5Options; import de.marhali.json5.stream.Json5Lexer; import de.marhali.json5.stream.Json5Parser; import de.marhali.json5.stream.Json5Writer; @@ -25,39 +26,79 @@ import java.util.function.Function; /** - * This is the main class for using JSON5. This class provides methods to parse and + * This is the main class for using Json5. This class provides methods to parse and * serialize Json5 data according to the specification and the configured {@link Json5Options options}. * *

- * You can create a Json5 instance by invoking {@link #Json5(Json5Options)} - * or by using {@link #builder(Function)}. - *

+ * You can create a Json5 instance by invoking {@link #Json5(Json5Options)} + * or by using {@link #builder(Function)}. + *

* *

- * This class contains several utility methods to parse and serialize json5 data by passing - * {@link Reader}, {@link Writer} or simple {@link String} instances. + * This class contains several utility methods to parse and serialize json5 data by passing + * {@link Reader}, {@link Writer} or simple {@link String} instances. *

* + *
+ * {@code
+ * // Create Json5 instance using builder pattern to configure desired options
+ * Json5 json5 = Json5.builder(builder -> builder
+ *     .quoteless()
+ *     .quoteSingle()
+ *     .parseComments()
+ *     .writeComments()
+ *     .prettyPrinting()
+ *     .build()
+ *     );
+ *
+ * // Parse from a String
+ * Json5Element element =
+ *         json5.parse("{ 'key': 'value', 'array': ['first val','second val'] }");
+ *
+ * // Or parse from a Reader or InputStream
+ * try (InputStream stream = ...) {
+ *     Json5Element element = json5.parse(stream);
+ *     // ...
+ * } catch (IOException e) {
+ *     // ...
+ * }
+ *
+ * // Serialize to a String
+ * String json5String = json5.serialize(element);
+ *
+ * // Serialize to a Writer or OutputStream
+ * try (OutputStream stream = ...) {
+ *     json5.serialize(element, stream);
+ *     // ...
+ * } catch (IOException e) {
+ *     // ...
+ * }
+ *
+ * }
+ * 
+ * * @author Marcel Haßlinger - * @see JSON5 Specification + * @see Json5 Specification * @see Json5Parser * @see Json5Writer */ public final class Json5 { /** - * Constructs a new json5 instance by using the {@link Json5OptionsBuilder}. + * Constructs a new json5 instance by using the {@link Json5Options#builder()}. + * * @param builder Options builder - * @return Provide built options by returning {@link Json5OptionsBuilder#build()} method + * @return Built options */ - public static Json5 builder(Function builder) { - return new Json5(builder.apply(new Json5OptionsBuilder())); + public static Json5 builder(Function builder) { + return new Json5(builder.apply(Json5Options.builder())); } private final Json5Options options; /** * Constructs a new json5 instance with custom configuration for parsing and serialization. + * * @param options Configuration options * @see #builder(Function) */ @@ -67,6 +108,7 @@ public Json5(Json5Options options) { /** * Constructs a json5 instance by using {@link Json5Options#DEFAULT} as configuration. + * * @see #Json5(Json5Options) */ public Json5() { @@ -74,9 +116,9 @@ public Json5() { } /** - * Parses the data from the {@link InputStream} into a tree of {@link Json5Element}'s. There must be - * a root element based on a {@link Json5Object} or {@link Json5Array}. + * Parses the data from the {@link InputStream} into a tree of {@link Json5Element}'s. *

Note: The stream must be closed after operation

+ * * @param in Can be any applicable {@link InputStream} * @return Parsed json5 tree. Can be {@code null} if the provided stream does not contain any data * @see #parse(Reader) @@ -87,12 +129,12 @@ public Json5Element parse(InputStream in) { } /** - * Parses the provided read-stream into a tree of {@link Json5Element}'s. There must be - * a root element based on a {@link Json5Object} or {@link Json5Array}. + * Parses the provided read-stream into a tree of {@link Json5Element}'s. *

Note: The reader must be closed after operation

+ * * @param reader Can be any applicable {@link Reader} * @return Parsed json5 tree. Can be {@code null} if the provided stream does not contain any data - * @see Json5Parser#parse(Json5Lexer) + * @see Json5Parser#parse(Json5Lexer) */ public Json5Element parse(Reader reader) { Objects.requireNonNull(reader); @@ -103,15 +145,15 @@ public Json5Element parse(Reader reader) { /** * Parses the provided json5-encoded {@link String} into a parse tree of {@link Json5Element}'s. - * There must be a root element based on a {@link Json5Object} or {@link Json5Array}. - * @param jsonString Json5 encoded {@link String} + * + * @param string Json5 encoded {@link String} * @return Parsed json5 tree. Can be {@code null} if the provided {@link String} is empty - * @see #parse(Reader) + * @see #parse(Reader) */ - public Json5Element parse(String jsonString) { - Objects.requireNonNull(jsonString); + public Json5Element parse(String string) { + Objects.requireNonNull(string); - StringReader reader = new StringReader(jsonString); + StringReader reader = new StringReader(string); Json5Element element = this.parse(reader); reader.close(); return element; @@ -120,8 +162,9 @@ public Json5Element parse(String jsonString) { /** * Encodes the provided element into its character literal representation by using an output-stream. *

Note: The stream must be closed after operation ({@link OutputStream#close()})!

+ * * @param element {@link Json5Element} to serialize - * @param out Can be any applicable {@link OutputStream} + * @param out Can be any applicable {@link OutputStream} * @throws IOException If an I/O error occurs * @see #serialize(Json5Element, Writer) */ @@ -135,10 +178,11 @@ public void serialize(Json5Element element, OutputStream out) throws IOException /** * Encodes the provided element into its character literal representation by using a write-stream. *

Note: The writer must be closed after operation ({@link Writer#close()})!

+ * * @param element {@link Json5Element} to serialize - * @param writer Can be any applicable {@link Writer} + * @param writer Can be any applicable {@link Writer} * @throws IOException If an I/O error occurs - * @see Json5Writer#write(Json5Element) + * @see Json5Writer#write(Json5Element) */ public void serialize(Json5Element element, Writer writer) throws IOException { Objects.requireNonNull(element); @@ -150,10 +194,11 @@ public void serialize(Json5Element element, Writer writer) throws IOException { /** * Encodes the provided element into its character literal representation. + * * @param element {@link Json5Element} to serialize * @return Json5 encoded {@link String} * @throws IOException If an I/O error occurs - * @see #serialize(Json5Element, Writer) + * @see #serialize(Json5Element, Writer) */ public String serialize(Json5Element element) throws IOException { Objects.requireNonNull(element); @@ -163,4 +208,4 @@ public String serialize(Json5Element element) throws IOException { writer.close(); return writer.toString(); } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/Json5Array.java b/src/main/java/de/marhali/json5/Json5Array.java index 86349e9..9110c50 100644 --- a/src/main/java/de/marhali/json5/Json5Array.java +++ b/src/main/java/de/marhali/json5/Json5Array.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2008 Google Inc. - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,48 +17,73 @@ package de.marhali.json5; +import de.marhali.json5.internal.NonNullElementWrapperList; +import de.marhali.json5.internal.RadixNumber; + import java.math.BigDecimal; import java.math.BigInteger; +import java.time.Instant; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Objects; /** * A class representing an array type in Json5. An array is a list of {@link Json5Element}s each of * which can be of a different type. This is an ordered list, meaning that the order in which - * elements are added is preserved. + * elements are added is preserved. This class does not support {@code null} values. If {@code null} + * is provided as element argument to any of the methods, it is converted to a {@link Json5Null}. + * + *

{@code Json5Array} only implements the {@link Iterable} interface but not the {@link List} + * interface. A {@code List} view of it can be obtained with {@link #asList()}. + * + *

See the {@link Json5} documentation for details on how to convert {@code Json5Array} and + * generally any {@code Json5Element} from and to Json5. * - * @author Marcel Haßlinger * @author Inderjeet Singh * @author Joel Leitch + * @author Marcel Haßlinger */ public final class Json5Array extends Json5Element implements Iterable { - private final List elements; + private final ArrayList elements; /** * Creates an empty Json5Array. */ public Json5Array() { - elements = new ArrayList(); + elements = new ArrayList<>(); } + /** + * Creates an empty Json5Array with the desired initial capacity. + * + * @param capacity initial capacity. + * @throws IllegalArgumentException if the {@code capacity} is negative + */ public Json5Array(int capacity) { - elements = new ArrayList(capacity); + elements = new ArrayList<>(capacity); } /** - * Creates a deep copy of this element and all its children + * Creates a deep copy of this element and all its children. */ @Override public Json5Array deepCopy() { - if (!elements.isEmpty()) { - Json5Array result = new Json5Array(elements.size()); - for (Json5Element element : elements) { - result.add(element.deepCopy()); - } - return result; + Json5Array result = new Json5Array(elements.size()); + for (Json5Element element : elements) { + result.add(element.deepCopy()); } - return new Json5Array(); + result.setComment(comment); + return result; + } + + /** + * Adds the specified {@link Instant} to self. + * + * @param instant the {@link Instant} that needs to be added to the array. + */ + public void add(Instant instant) { + elements.add(instant == null ? Json5Primitive.fromNull() : Json5Primitive.fromInstant(instant)); } /** @@ -67,7 +92,7 @@ public Json5Array deepCopy() { * @param bool the boolean that needs to be added to the array. */ public void add(Boolean bool) { - elements.add(bool == null ? Json5Null.INSTANCE : new Json5Boolean(bool)); + elements.add(bool == null ? Json5Primitive.fromNull() : Json5Primitive.fromBoolean(bool)); } /** @@ -76,7 +101,7 @@ public void add(Boolean bool) { * @param character the character that needs to be added to the array. */ public void add(Character character) { - elements.add(character == null ? Json5Null.INSTANCE : new Json5String(character.toString())); + elements.add(character == null ? Json5Primitive.fromNull() : Json5Primitive.fromCharacter(character)); } /** @@ -85,7 +110,11 @@ public void add(Character character) { * @param number the number that needs to be added to the array. */ public void add(Number number) { - elements.add(number == null ? Json5Null.INSTANCE : new Json5Number(number)); + elements.add(number == null ? Json5Primitive.fromNull() : Json5Primitive.fromNumber(number)); + } + + public void add(Number number, int radix) { + elements.add(number == null ? Json5Primitive.fromNull() : Json5Primitive.fromNumber(number, radix)); } /** @@ -94,7 +123,7 @@ public void add(Number number) { * @param string the string that needs to be added to the array. */ public void add(String string) { - elements.add(string == null ? Json5Null.INSTANCE : new Json5String(string)); + elements.add(string == null ? Json5Primitive.fromNull() : Json5Primitive.fromString(string)); } /** @@ -104,7 +133,7 @@ public void add(String string) { */ public void add(Json5Element element) { if (element == null) { - element = Json5Null.INSTANCE; + element = Json5Primitive.fromNull(); } elements.add(element); } @@ -120,19 +149,20 @@ public void addAll(Json5Array array) { /** * Replaces the element at the specified position in this array with the specified element. - * Element can be null. - * @param index index of the element to replace + * + * @param index index of the element to replace * @param element element to be stored at the specified position * @return the element previously at the specified position * @throws IndexOutOfBoundsException if the specified index is outside the array bounds */ public Json5Element set(int index, Json5Element element) { - return elements.set(index, element); + return elements.set(index, element == null ? Json5Primitive.fromNull() : element); } /** - * Removes the first occurrence of the specified element from this array, if it is present. - * If the array does not contain the element, it is unchanged. + * Removes the first occurrence of the specified element from this array, if it is present. If the + * array does not contain the element, it is unchanged. + * * @param element element to be removed from this array, if present * @return true if this array contained the specified element, false otherwise */ @@ -141,9 +171,10 @@ public boolean remove(Json5Element element) { } /** - * Removes the element at the specified position in this array. Shifts any subsequent elements - * to the left (subtracts one from their indices). Returns the element that was removed from - * the array. + * Removes the element at the specified position in this array. Shifts any subsequent elements to + * the left (subtracts one from their indices). Returns the element that was removed from the + * array. + * * @param index index the index of the element to be removed * @return the element previously at the specified position * @throws IndexOutOfBoundsException if the specified index is outside the array bounds @@ -154,8 +185,9 @@ public Json5Element remove(int index) { /** * Returns true if this array contains the specified element. - * @return true if this array contains the specified element. + * * @param element whose presence in this array is to be tested + * @return true if this array contains the specified element. */ public boolean contains(Json5Element element) { return elements.contains(element); @@ -171,9 +203,9 @@ public int size() { } /** - * Returns true if the array is empty + * Returns true if the array is empty. * - * @return true if the array is empty + * @return true if the array is empty. */ public boolean isEmpty() { return elements.isEmpty(); @@ -185,197 +217,268 @@ public boolean isEmpty() { * * @return an iterator to navigate the elements of the array. */ + @Override public Iterator iterator() { return elements.iterator(); } /** - * Returns the ith element of the array. + * Returns the i-th element of the array. * * @param i the index of the element that is being sought. - * @return the element present at the ith index. - * @throws IndexOutOfBoundsException if i is negative or greater than or equal to the - * {@link #size()} of the array. + * @return the element present at the i-th index. + * @throws IndexOutOfBoundsException if {@code i} is negative or greater than or equal to the + * {@link #size()} of the array. */ public Json5Element get(int i) { return elements.get(i); } + private Json5Element getAsSingleElement() { + int size = elements.size(); + if (size == 1) { + return elements.get(0); + } + throw new IllegalStateException("Array must have size 1, but has size " + size); + } + + @Override + public Json5Null getAsJson5Null() { + return getAsSingleElement().getAsJson5Null(); + } + /** - * convenience method to get this array as a {@link Number} if it contains a single element. + * Convenience method to get this array as a {@link Number} if it contains a single element. This + * method calls {@link Json5Element#getAsNumber()} on the element, therefore any of the exceptions + * declared by that method can occur. * - * @return get this element as a number if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid Number. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a number if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public Number getAsNumber() { - if (elements.size() == 1) { - return elements.get(0).getAsNumber(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsNumber(); + } + + /** + * Convenience method to get this array as a {@link RadixNumber} if it contains a single element. This + * method calls {@link Json5Element#getAsRadixNumber()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a radix number if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ + @Override + public RadixNumber getAsRadixNumber() { + return getAsSingleElement().getAsRadixNumber(); + } + + /** + * Convenience method to get this array as a binary number string if it contains a single element. This + * method calls {@link Json5Element#getAsBinaryString()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a binary number string if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ + @Override + public String getAsBinaryString() { + return getAsSingleElement().getAsBinaryString(); + } + + /** + * Convenience method to get this array as a octal number string if it contains a single element. This + * method calls {@link Json5Element#getAsOctalString()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a octal number string if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ + @Override + public String getAsOctalString() { + return getAsSingleElement().getAsOctalString(); + } + + /** + * Convenience method to get this array as a hex number string if it contains a single element. This + * method calls {@link Json5Element#getAsHexString()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a hex number string if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ + @Override + public String getAsHexString() { + return getAsSingleElement().getAsHexString(); } /** - * convenience method to get this array as a {@link String} if it contains a single element. + * Convenience method to get this array as a {@link String} if it contains a single element. This + * method calls {@link Json5Element#getAsString()} on the element, therefore any of the exceptions + * declared by that method can occur. * - * @return get this element as a String if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid String. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a String if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public String getAsString() { - if (elements.size() == 1) { - return elements.get(0).getAsString(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsString(); } /** - * convenience method to get this array as a double if it contains a single element. + * Convenience method to get this array as a double if it contains a single element. This method + * calls {@link Json5Element#getAsDouble()} on the element, therefore any of the exceptions + * declared by that method can occur. * - * @return get this element as a double if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid double. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a double if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public double getAsDouble() { - if (elements.size() == 1) { - return elements.get(0).getAsDouble(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsDouble(); } /** - * convenience method to get this array as a {@link BigDecimal} if it contains a single element. + * Convenience method to get this array as a {@link BigDecimal} if it contains a single element. + * This method calls {@link Json5Element#getAsBigDecimal()} on the element, therefore any of the + * exceptions declared by that method can occur. * - * @return get this element as a {@link BigDecimal} if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive}. - * @throws NumberFormatException if the element at index 0 is not a valid {@link BigDecimal}. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a {@link BigDecimal} if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public BigDecimal getAsBigDecimal() { - if (elements.size() == 1) { - return elements.get(0).getAsBigDecimal(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsBigDecimal(); } /** - * convenience method to get this array as a {@link BigInteger} if it contains a single element. + * Convenience method to get this array as a {@link BigInteger} if it contains a single element. + * This method calls {@link Json5Element#getAsBigInteger()} on the element, therefore any of the + * exceptions declared by that method can occur. * - * @return get this element as a {@link BigInteger} if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive}. - * @throws NumberFormatException if the element at index 0 is not a valid {@link BigInteger}. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a {@link BigInteger} if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public BigInteger getAsBigInteger() { - if (elements.size() == 1) { - return elements.get(0).getAsBigInteger(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsBigInteger(); } /** - * convenience method to get this array as a float if it contains a single element. + * Convenience method to get this array as a float if it contains a single element. This method + * calls {@link Json5Element#getAsFloat()} on the element, therefore any of the exceptions declared + * by that method can occur. * - * @return get this element as a float if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid float. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a float if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public float getAsFloat() { - if (elements.size() == 1) { - return elements.get(0).getAsFloat(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsFloat(); } /** - * convenience method to get this array as a long if it contains a single element. + * Convenience method to get this array as a long if it contains a single element. This method + * calls {@link Json5Element#getAsLong()} on the element, therefore any of the exceptions declared + * by that method can occur. * - * @return get this element as a long if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid long. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a long if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public long getAsLong() { - if (elements.size() == 1) { - return elements.get(0).getAsLong(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsLong(); } /** - * convenience method to get this array as an integer if it contains a single element. + * Convenience method to get this array as an integer if it contains a single element. This method + * calls {@link Json5Element#getAsInt()} on the element, therefore any of the exceptions declared + * by that method can occur. * - * @return get this element as an integer if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid integer. - * @throws IllegalStateException if the array has more than one element. + * @return this element as an integer if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public int getAsInt() { - if (elements.size() == 1) { - return elements.get(0).getAsInt(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsInt(); } + /** + * Convenience method to get this array as a primitive byte if it contains a single element. This + * method calls {@link Json5Element#getAsByte()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a primitive byte if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ @Override public byte getAsByte() { - if (elements.size() == 1) { - return elements.get(0).getAsByte(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsByte(); } /** - * convenience method to get this array as a primitive short if it contains a single element. + * Convenience method to get this array as a primitive short if it contains a single element. This + * method calls {@link Json5Element#getAsShort()} on the element, therefore any of the exceptions + * declared by that method can occur. * - * @return get this element as a primitive short if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid short. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a primitive short if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public short getAsShort() { - if (elements.size() == 1) { - return elements.get(0).getAsShort(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsShort(); } /** - * convenience method to get this array as a boolean if it contains a single element. + * Convenience method to get this array as a Insta if it contains a single element. This method + * calls {@link Json5Element#getAsBoolean()} on the element, therefore any of the exceptions + * declared by that method can occur. * - * @return get this element as a boolean if it is single element array. - * @throws ClassCastException if the element in the array is of not a {@link Json5Primitive} and - * is not a valid boolean. - * @throws IllegalStateException if the array has more than one element. + * @return this element as a boolean if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. + */ + @Override + public Instant getAsInstant() { + return getAsSingleElement().getAsInstant(); + } + + /** + * Convenience method to get this array as a boolean if it contains a single element. This method + * calls {@link Json5Element#getAsBoolean()} on the element, therefore any of the exceptions + * declared by that method can occur. + * + * @return this element as a boolean if it is single element array. + * @throws IllegalStateException if the array is empty or has more than one element. */ @Override public boolean getAsBoolean() { - if (elements.size() == 1) { - return elements.get(0).getAsBoolean(); - } - throw new IllegalStateException(); + return getAsSingleElement().getAsBoolean(); + } + + /** + * Returns a mutable {@link List} view of this {@code Json5Array}. Changes to the {@code List} are + * visible in this {@code Json5Array} and the other way around. + * + *

The {@code List} does not permit {@code null} elements. Unlike {@code Json5Array}'s {@code + * null} handling, a {@link NullPointerException} is thrown when trying to add {@code null}. Use + * {@link Json5Null} for Json5 null values. + * + * @return mutable {@code List} view + */ + public List asList() { + return new NonNullElementWrapperList<>(elements); } @Override public boolean equals(Object o) { - return (o == this) || (o instanceof Json5Array && ((Json5Array) o).elements.equals(elements)); + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + Json5Array that = (Json5Array) o; + return Objects.equals(elements, that.elements); } @Override public int hashCode() { - return elements.hashCode(); + return Objects.hash(super.hashCode(), elements); } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/Json5Boolean.java b/src/main/java/de/marhali/json5/Json5Boolean.java deleted file mode 100644 index 374fc0b..0000000 --- a/src/main/java/de/marhali/json5/Json5Boolean.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -/** - * A class representing a json5 boolean value. - * - * @author Marcel Haßlinger - */ -public final class Json5Boolean extends Json5Primitive { - public Json5Boolean(Boolean value) { - super(value); - } -} diff --git a/src/main/java/de/marhali/json5/Json5Element.java b/src/main/java/de/marhali/json5/Json5Element.java index bd7167a..db1305c 100644 --- a/src/main/java/de/marhali/json5/Json5Element.java +++ b/src/main/java/de/marhali/json5/Json5Element.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2008 Google Inc. - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,31 +17,77 @@ package de.marhali.json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.internal.RadixNumber; import de.marhali.json5.stream.Json5Writer; import java.io.IOException; import java.io.StringWriter; import java.math.BigDecimal; import java.math.BigInteger; +import java.time.Instant; import java.util.Objects; /** - * A class representing an element of Json5. It could either be a {@link Json5Object}, a - * {@link Json5Array}, a {@link Json5Primitive} or a {@link Json5Null}. + * A class representing an element of Json5. It could either be a {@link Json5Object}, a {@link + * Json5Array}, a {@link Json5Primitive} or a {@link Json5Null}. + * + *

This class provides multiple {@code getAs} methods which allow + * + *

    + *
  • obtaining the represented primitive value, for example {@link #getAsString()} + *
  • casting to the {@code Json5Element} subclasses in a convenient way, for example {@link + * #getAsJson5Object()} + *
* - * @author Marcel Haßlinger * @author Inderjeet Singh * @author Joel Leitch + * @author Marcel Haßlinger */ public abstract class Json5Element { /** - * @return A deep copy of this element. Immutable elements like primitives - * and nulls are not copied. + * Associated comment on this element. Can be null to omit. + * Supports multi-line comments by using the break-line control character \n. + */ + protected String comment; + + /** + * Provides a check for verifying if this element has an associated comment. + * + * @return true if this element has an associated comment, false otherwise. + */ + public boolean hasComment() { + return this.comment != null; + } + + /** + * Returns the associated comment on this element. Can be null if not set. + * + * @return optional comment string + */ + public String getComment() { + return comment; + } + + /** + * Updates the associated comment on this element. + * Supports multi-line comments with break-line control character. + * + * @param comment Comment to set. Can be null to omit. + */ + public void setComment(String comment) { + this.comment = comment; + } + + /** + * Returns a deep copy of this element. + * + * @return Deep copy */ public abstract Json5Element deepCopy(); /** - * provides check for verifying if this element is an array or not. + * Provides a check for verifying if this element is a Json5 array or not. * * @return true if this element is of type {@link Json5Array}, false otherwise. */ @@ -50,7 +96,7 @@ public boolean isJson5Array() { } /** - * provides check for verifying if this element is a Json object or not. + * Provides a check for verifying if this element is a Json5 object or not. * * @return true if this element is of type {@link Json5Object}, false otherwise. */ @@ -59,7 +105,7 @@ public boolean isJson5Object() { } /** - * provides check for verifying if this element is a primitive or not. + * Provides a check for verifying if this element is a primitive or not. * * @return true if this element is of type {@link Json5Primitive}, false otherwise. */ @@ -68,7 +114,7 @@ public boolean isJson5Primitive() { } /** - * provides check for verifying if this element represents a null value or not. + * Provides a check for verifying if this element represents a null value or not. * * @return true if this element is of type {@link Json5Null}, false otherwise. */ @@ -77,226 +123,297 @@ public boolean isJson5Null() { } /** - * convenience method to get this element as a {@link Json5Object}. If the element is of some - * other type, a {@link IllegalStateException} will result. Hence it is best to use this method + * Convenience method to get this element as a {@link Json5Object}. If this element is of some + * other type, an {@link IllegalStateException} will result. Hence it is best to use this method * after ensuring that this element is of the desired type by calling {@link #isJson5Object()} * first. * - * @return get this element as a {@link Json5Object}. - * @throws IllegalStateException if the element is of another type. + * @return this element as a {@link Json5Object}. + * @throws IllegalStateException if this element is of another type. */ public Json5Object getAsJson5Object() { if (isJson5Object()) { return (Json5Object) this; } - throw new IllegalStateException("Not a JSON Object: " + this); + throw new IllegalStateException("Not a Json5Object: " + this); } /** - * convenience method to get this element as a {@link Json5Array}. If the element is of some - * other type, a {@link IllegalStateException} will result. Hence it is best to use this method - * after ensuring that this element is of the desired type by calling {@link #isJson5Array()} - * first. + * Convenience method to get this element as a {@link Json5Array}. If this element is of some other + * type, an {@link IllegalStateException} will result. Hence it is best to use this method after + * ensuring that this element is of the desired type by calling {@link #isJson5Array()} first. * - * @return get this element as a {@link Json5Array}. - * @throws IllegalStateException if the element is of another type. + * @return this element as a {@link Json5Array}. + * @throws IllegalStateException if this element is of another type. */ public Json5Array getAsJson5Array() { if (isJson5Array()) { return (Json5Array) this; } - throw new IllegalStateException("Not a JSON Array: " + this); + throw new IllegalStateException("Not a Json5Array: " + this); } /** - * convenience method to get this element as a {@link Json5Primitive}. If the element is of some - * other type, a {@link IllegalStateException} will result. Hence it is best to use this method + * Convenience method to get this element as a {@link Json5Primitive}. If this element is of some + * other type, an {@link IllegalStateException} will result. Hence it is best to use this method * after ensuring that this element is of the desired type by calling {@link #isJson5Primitive()} * first. * - * @return get this element as a {@link Json5Primitive}. - * @throws IllegalStateException if the element is of another type. + * @return this element as a {@link Json5Primitive}. + * @throws IllegalStateException if this element is of another type. */ public Json5Primitive getAsJson5Primitive() { if (isJson5Primitive()) { return (Json5Primitive) this; } - throw new IllegalStateException("Not a JSON Primitive: " + this); + throw new IllegalStateException("Not a Json5Primitive: " + this); } /** - * convenience method to get this element as a {@link Json5Null}. If the element is of some - * other type, a {@link IllegalStateException} will result. Hence it is best to use this method - * after ensuring that this element is of the desired type by calling {@link #isJson5Null()} - * first. + * Convenience method to get this element as a {@link Json5Null}. If this element is of some other + * type, an {@link IllegalStateException} will result. Hence it is best to use this method after + * ensuring that this element is of the desired type by calling {@link #isJson5Null()} first. * - * @return get this element as a {@link Json5Null}. - * @throws IllegalStateException if the element is of another type. + * @return this element as a {@link Json5Null}. + * @throws IllegalStateException if this element is of another type. */ public Json5Null getAsJson5Null() { if (isJson5Null()) { return (Json5Null) this; } - throw new IllegalStateException("Not a JSON Null: " + this); + throw new IllegalStateException("Not a Json5Null: " + this); } /** - * convenience method to get this element as a boolean value. + * Convenience method to get this element as a boolean value. * - * @return get this element as a primitive boolean value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * boolean value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive boolean value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public boolean getAsBoolean() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a {@link Number}. + * Convenience method to get this element as a {@link Instant} value. + * + * @return this element as a primitive {@link Instant} value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. + */ + public Instant getAsInstant() { + throw new UnsupportedOperationException(getClass().getSimpleName()); + } + + /** + * Convenience method to get this element as a {@link Number}. * - * @return get this element as a {@link Number}. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * number. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a {@link Number}. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}, or cannot be converted to a number. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public Number getAsNumber() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a string value. + * Convenience method to get this element as a {@link RadixNumber}. + * + * @return this element as a {@link RadixNumber}. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}, or cannot be converted to a radix number. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. + */ + public RadixNumber getAsRadixNumber() { + throw new UnsupportedOperationException(getClass().getSimpleName()); + } + + /** + * Convenience method to get this element as a string value. * - * @return get this element as a string value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * string value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a string value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public String getAsString() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive double value. + * Convenience method to get this element as a primitive double value. * - * @return get this element as a primitive double value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * double value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive double value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid double. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public double getAsDouble() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive float value. + * Convenience method to get this element as a primitive float value. * - * @return get this element as a primitive float value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * float value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive float value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid float. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public float getAsFloat() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive long value. + * Convenience method to get this element as a primitive long value. * - * @return get this element as a primitive long value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * long value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive long value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid long. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public long getAsLong() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive integer value. + * Convenience method to get this element as a primitive integer value. * - * @return get this element as a primitive integer value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * integer value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive integer value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid integer. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public int getAsInt() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive byte value. + * Convenience method to get this element as a primitive byte value. * - * @return get this element as a primitive byte value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * byte value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive byte value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid byte. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public byte getAsByte() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a {@link BigDecimal}. + * Convenience method to get this element as a {@link BigDecimal}. * - * @return get this element as a {@link BigDecimal}. - * @throws ClassCastException if the element is of not a {@link Json5Primitive}. - * * @throws NumberFormatException if the element is not a valid {@link BigDecimal}. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a {@link BigDecimal}. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if this element is not a valid {@link BigDecimal}. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public BigDecimal getAsBigDecimal() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a {@link BigInteger}. + * Convenience method to get this element as a {@link BigInteger}. * - * @return get this element as a {@link BigInteger}. - * @throws ClassCastException if the element is of not a {@link Json5Primitive}. - * @throws NumberFormatException if the element is not a valid {@link BigInteger}. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a {@link BigInteger}. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if this element is not a valid {@link BigInteger}. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public BigInteger getAsBigInteger() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * convenience method to get this element as a primitive short value. + * Convenience method to get this element as a primitive short value. * - * @return get this element as a primitive short value. - * @throws ClassCastException if the element is of not a {@link Json5Primitive} and is not a valid - * short value. - * @throws IllegalStateException if the element is of the type {@link Json5Array} but contains - * more than a single element. + * @return this element as a primitive short value. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid short. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ public short getAsShort() { throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * Returns a simple String representation of this element. - * For pretty-printing use {@link Json5Writer} with custom configuration options. - * @see #toString(Json5Options) + * Convenience method to get this element as a primitive binary number (radix base {@code 2}) value. + *

+ * This is an extension that is not compliant to the official Json5 spec. + * + * @return this element as a primitive binary number value string. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid short. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. */ - @Override - public String toString() { - return toString(Json5Options.DEFAULT); + public String getAsBinaryString() { + throw new UnsupportedOperationException(getClass().getSimpleName()); + + } + + /** + * Convenience method to get this element as a primitive octal number (radix base {@code 8}) value. + *

+ * This is an extension that is not compliant to the official Json5 spec. + * + * @return this element as a primitive octal number value string. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid short. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. + */ + public String getAsOctalString() { + throw new UnsupportedOperationException(getClass().getSimpleName()); + + } + + /** + * Convenience method to get this element as a primitive hex number (radix base {@code 16}) value. + * + * @return this element as a primitive hex number value string. + * @throws UnsupportedOperationException if this element is not a {@link Json5Primitive} or {@link + * Json5Array}. + * @throws NumberFormatException if the value contained is not a valid short. + * @throws IllegalStateException if this element is of the type {@link Json5Array} but contains + * more than a single element. + */ + public String getAsHexString() { + throw new UnsupportedOperationException(getClass().getSimpleName()); } /** - * Returns the String representation of this element. - * @param options Configured serialization behaviour - * @return Stringified representation of this element + * Converts this element to a Json5 string using the provided configuration options for formatting. + * + * @param options Configuration options. + * @return Json5 string representation of this element. */ public String toString(Json5Options options) { Objects.requireNonNull(options); @@ -306,9 +423,54 @@ public String toString(Json5Options options) { Json5Writer json5Writer = new Json5Writer(options, stringWriter); json5Writer.write(this); return stringWriter.toString(); - } catch (IOException e) { throw new AssertionError(e); } } -} \ No newline at end of file + + /** + * Converts this element to a Json5 string. + * + *

For example: + * + *

+     * Json5Object object = new Json5Object();
+     * object.add("a", new Json5Null());
+     * Json5Array array = new Json5Array();
+     * array.add(1);
+     * object.add("b", array);
+     *
+     * String json = object.toString();
+     * // json: {"a":null,"b":[1]}
+     * 
+ * + *

To get the contained String value (without enclosing {@code "} and without escaping), use + * {@link #getAsString()} instead: + * + *

+     * Json5Primitive Json5Primitive = new Json5Primitive("with \" quote");
+     * String json = Json5Primitive.toString();
+     * // json: "with \" quote"
+     * String value = Json5Primitive.getAsString();
+     * // value: with " quote
+     * 
+ * + * @see #toString(Json5Options) + */ + @Override + public String toString() { + return toString(Json5Options.DEFAULT); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Json5Element that = (Json5Element) o; + return Objects.equals(comment, that.comment); + } + + @Override + public int hashCode() { + return Objects.hashCode(comment); + } +} diff --git a/src/main/java/de/marhali/json5/Json5Hexadecimal.java b/src/main/java/de/marhali/json5/Json5Hexadecimal.java deleted file mode 100644 index 391a905..0000000 --- a/src/main/java/de/marhali/json5/Json5Hexadecimal.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * A class representing a hexadecimal json5 value. Hex values will be stored as {@link BigInteger} internally. - * - * @author Marcel Haßlinger - */ -public final class Json5Hexadecimal extends Json5Primitive { - - /** - * Converts the provided hex string into it's number representation. - * Allowed is the character representation of a hex key. Format must be: 0x..., +0x... or -0x... - * - * @param hex the hexadecimal value including prefix - * @return Number representation of hexadecimal string - */ - public static BigInteger parseHexString(String hex) { - Objects.requireNonNull(hex); - - switch (hex.charAt(0)) { - case '+': // +0x... - return new BigInteger(hex.substring(3), 16); - case '-': // -0x... - return new BigInteger(hex.substring(3), 16).negate(); - default: // 0x... - return new BigInteger(hex.substring(2), 16); - } - } - - /** - * Converts the provided number into it's hex literal character representation. - * - * @param bigInteger the number value - * @param prefixPositive Prefix positive values with {@code +0x...} if true otherwise {@code 0x...}. - * @return Hex character string including prefix - */ - public static String serializeHexString(BigInteger bigInteger, boolean prefixPositive) { - Objects.requireNonNull(bigInteger); - - if(bigInteger.signum() >= 0) { - return (prefixPositive ? "+0x" : "0x") + bigInteger.toString(16); - } else { - return "-0x" + bigInteger.abs().toString(16); - } - } - - /** - * Creates a primitive containing a hex value. - * - * @param hex the value to create the primitive with. - */ - public Json5Hexadecimal(BigInteger hex) { - super(hex); - } - - /** - * Creates a primitive containing a hex value. For String to Number conversion see {@link #parseHexString(String)} - * - * @param hex the value to create the primitive with. - */ - public Json5Hexadecimal(String hex) { - super(parseHexString(hex)); - } - - /** - * Constructs the string representation of the stored hex value. - * - * @return Hex value as character literal. - */ - @Override - public String getAsString() { - return serializeHexString(super.getAsBigInteger(), false); - } -} diff --git a/src/main/java/de/marhali/json5/Json5Null.java b/src/main/java/de/marhali/json5/Json5Null.java index 1e1f2bc..a883302 100644 --- a/src/main/java/de/marhali/json5/Json5Null.java +++ b/src/main/java/de/marhali/json5/Json5Null.java @@ -1,5 +1,6 @@ /* * Copyright (C) 2008 Google Inc. + * Copyright (C) 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,39 +22,21 @@ * * @author Inderjeet Singh * @author Joel Leitch + * @author Marcel Haßlinger */ public final class Json5Null extends Json5Element { - /** - * Singleton for json {@code null} literal - */ - public static final Json5Null INSTANCE = new Json5Null(); - - /** - * Constructor for internal use only. Use {@link #INSTANCE} instead. - */ - private Json5Null() {} - - /** - * Returns the same instance since it is an immutable value - */ - @Override - public Json5Null deepCopy() { - return INSTANCE; + public Json5Null() { } - /** - * All instances of JsonNull have the same hash code since they are indistinguishable - */ @Override - public int hashCode() { - return Json5Null.class.hashCode(); + public Json5Element deepCopy() { + Json5Null copy = new Json5Null(); + copy.setComment(comment); + return copy; } - /** - * All instances of JsonNull are the same - */ @Override - public boolean equals(Object other) { - return this == other || other instanceof Json5Null; + public String getAsString() { + return "null"; } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/Json5Object.java b/src/main/java/de/marhali/json5/Json5Object.java index 88a3341..390d1f9 100644 --- a/src/main/java/de/marhali/json5/Json5Object.java +++ b/src/main/java/de/marhali/json5/Json5Object.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2008 Google Inc. - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,23 +19,39 @@ import de.marhali.json5.internal.LinkedTreeMap; +import java.time.Instant; import java.util.Map; +import java.util.Objects; import java.util.Set; /** - * A class representing an object type in Json. An object consists of name-value pairs where names + * A class representing an object type in Json5. An object consists of name-value pairs where names * are strings, and values are any other type of {@link Json5Element}. This allows for a creating a * tree of Json5Elements. The member elements of this object are maintained in order they were added. + * This class does not support {@code null} values. If {@code null} is provided as value argument to + * any of the methods, it is converted to a {@link Json5Null}. + * + *

{@code Json5Object} does not implement the {@link Map} interface, but a {@code Map} view of it + * can be obtained with {@link #asMap()}. + * + *

See the {@link Json5} documentation for details on how to convert {@code Json5Object} and + * generally any {@code Json5Element} from and to Json5. * * @author Inderjeet Singh * @author Joel Leitch + * @author Marcel Haßlinger */ public final class Json5Object extends Json5Element { - private final LinkedTreeMap members = - new LinkedTreeMap(); + private final LinkedTreeMap members = new LinkedTreeMap<>(false); + + /** + * Creates a new instance of a {@link Json5Object}. + */ + public Json5Object() { + } /** - * Creates a deep copy of this element and all its children + * Creates a deep copy of this element and all its children. */ @Override public Json5Object deepCopy() { @@ -43,73 +59,90 @@ public Json5Object deepCopy() { for (Map.Entry entry : members.entrySet()) { result.add(entry.getKey(), entry.getValue().deepCopy()); } + result.setComment(comment); return result; } /** * Adds a member, which is a name-value pair, to self. The name must be a String, but the value - * can be an arbitrary Json5Element, thereby allowing you to build a full tree of Json5Elements - * rooted at this node. + * can be an arbitrary {@link Json5Element}, thereby allowing you to build a full tree of + * {@link Json5Element Json5Elements} rooted at this node. * * @param property name of the member. - * @param value the member object. + * @param value the member object. */ public void add(String property, Json5Element value) { - members.put(property, value == null ? Json5Null.INSTANCE : value); + members.put(property, value == null ? Json5Primitive.fromNull() : value); } /** - * Removes the {@code property} from this {@link Json5Object}. + * Removes the {@code property} from this object. * * @param property name of the member that should be removed. - * @return the {@link Json5Element} object that is being removed. + * @return the {@link Json5Element} object that is being removed, or {@code null} if no member with + * this name exists. */ public Json5Element remove(String property) { return members.remove(property); } /** - * Convenience method to add a primitive member. The specified value is converted to a - * Json5Primitive of String. + * Convenience method to add a char member. The specified value is converted to a {@link + * Json5Primitive} of Character. * * @param property name of the member. - * @param value the string value associated with the member. + * @param value the char value associated with the member. + */ + public void addProperty(String property, Character value) { + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromCharacter(value)); + } + + /** + * Convenience method to add a string member. The specified value is converted to a {@link + * Json5Primitive} of String. + * + * @param property name of the member. + * @param value the string value associated with the member. */ public void addProperty(String property, String value) { - add(property, value == null ? Json5Null.INSTANCE : new Json5String(value)); + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromString(value)); } /** - * Convenience method to add a primitive member. The specified value is converted to a - * Json5Primitive of Number. + * Convenience method to add a number member. The specified value is converted to a {@link + * Json5Primitive} of Number. * * @param property name of the member. - * @param value the number value associated with the member. + * @param value the number value associated with the member. */ public void addProperty(String property, Number value) { - add(property, value == null ? Json5Null.INSTANCE : new Json5Number(value)); + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromNumber(value)); + } + + public void addProperty(String property, Number value, int radix) { + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromNumber(value, radix)); } /** - * Convenience method to add a boolean member. The specified value is converted to a - * Json5Primitive of Boolean. + * Convenience method to add a {@link Instant} member. The specified value is converted to a {@link + * Json5Primitive} of {@link Instant}. * * @param property name of the member. - * @param value the number value associated with the member. + * @param value the {@link Instant} value associated with the member. */ - public void addProperty(String property, Boolean value) { - add(property, value == null ? Json5Null.INSTANCE : new Json5Boolean(value)); + public void addProperty(String property, Instant value) { + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromInstant(value)); } /** - * Convenience method to add a char member. The specified value is converted to a - * Json5Primitive of Character. + * Convenience method to add a boolean member. The specified value is converted to a {@link + * Json5Primitive} of Boolean. * * @param property name of the member. - * @param value the number value associated with the member. + * @param value the boolean value associated with the member. */ - public void addProperty(String property, Character value) { - add(property, value == null ? Json5Null.INSTANCE : new Json5String(value.toString())); + public void addProperty(String property, Boolean value) { + add(property, value == null ? Json5Primitive.fromNull() : Json5Primitive.fromBoolean(value)); } /** @@ -140,6 +173,15 @@ public int size() { return members.size(); } + /** + * Returns true if the number of key/value pairs in the object is zero. + * + * @return true if the number of key/value pairs in the object is zero. + */ + public boolean isEmpty() { + return members.isEmpty(); + } + /** * Convenience method to check if a member with the specified name is present in this object. * @@ -154,50 +196,73 @@ public boolean has(String memberName) { * Returns the member with the specified name. * * @param memberName name of the member that is being requested. - * @return the member matching the name. Null if no such member exists. + * @return the member matching the name, or {@code null} if no such member exists. */ public Json5Element get(String memberName) { return members.get(memberName); } /** - * Convenience method to get the specified member as a Json5Primitive element. + * Convenience method to get the specified member as a {@link Json5Primitive}. * * @param memberName name of the member being requested. - * @return the Json5Primitive corresponding to the specified member. + * @return the {@code Json5Primitive} corresponding to the specified member, or {@code null} if no + * member with this name exists. + * @throws ClassCastException if the member is not of type {@code Json5Primitive}. */ public Json5Primitive getAsJson5Primitive(String memberName) { return (Json5Primitive) members.get(memberName); } /** - * Convenience method to get the specified member as a Json5Array. + * Convenience method to get the specified member as a {@link Json5Array}. * * @param memberName name of the member being requested. - * @return the Json5Array corresponding to the specified member. + * @return the {@code Json5Array} corresponding to the specified member, or {@code null} if no + * member with this name exists. + * @throws ClassCastException if the member is not of type {@code Json5Array}. */ public Json5Array getAsJson5Array(String memberName) { return (Json5Array) members.get(memberName); } /** - * Convenience method to get the specified member as a Json5Object. + * Convenience method to get the specified member as a {@link Json5Object}. * * @param memberName name of the member being requested. - * @return the Json5Object corresponding to the specified member. + * @return the {@code Json5Object} corresponding to the specified member, or {@code null} if no + * member with this name exists. + * @throws ClassCastException if the member is not of type {@code Json5Object}. */ public Json5Object getAsJson5Object(String memberName) { return (Json5Object) members.get(memberName); } + /** + * Returns a mutable {@link Map} view of this {@code Json5Object}. Changes to the {@code Map} are + * visible in this {@code Json5Object} and the other way around. + * + *

The {@code Map} does not permit {@code null} keys or values. Unlike {@code Json5Object}'s + * {@code null} handling, a {@link NullPointerException} is thrown when trying to add {@code + * null}. Use {@link Json5Null} for Json5 null values. + * + * @return mutable {@code Map} view + */ + public Map asMap() { + // It is safe to expose the underlying map because it disallows null keys and values + return members; + } + @Override public boolean equals(Object o) { - return (o == this) || (o instanceof Json5Object - && ((Json5Object) o).members.equals(members)); + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + Json5Object that = (Json5Object) o; + return Objects.equals(members, that.members); } @Override public int hashCode() { - return members.hashCode(); + return Objects.hash(super.hashCode(), members); } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/Json5Options.java b/src/main/java/de/marhali/json5/Json5Options.java deleted file mode 100644 index c73704f..0000000 --- a/src/main/java/de/marhali/json5/Json5Options.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -/** - * Configuration options for Json5 parsing and serialization. - * - * @author Marcel Haßlinger - */ -public class Json5Options { - - /** - * Default configuration options. Allow invalid surrogate, disabled pretty-printing. - */ - public static final Json5Options DEFAULT = new Json5OptionsBuilder() - .allowInvalidSurrogate() - .trailingComma() - .prettyPrinting() - .build(); - - /** - * @return Builder pattern to configure json5 behaviour. - */ - public static Json5OptionsBuilder builder() { - return new Json5OptionsBuilder(); - } - - /** - * Whether invalid unicode surrogate pairs should be allowed - */ - private final boolean allowInvalidSurrogates; - - /** - * Whether strings should be single-quoted ({@code '}) instead of double-quoted ({@code "}). - * This also includes all member names of {@link Json5Object}. - */ - private final boolean quoteSingle; - - /** - * Whether all Json5 values should be marked with a trailing comma ({@code ,}) or only where it is mandatory. - */ - private final boolean trailingComma; - - /** - * Defines how many spaces ({@code ' '}) should be placed before each key/value pair. - * A factor of {@code < 1} disables pretty-printing and discards any optional whitespace characters. - */ - private final int indentFactor; - - public Json5Options(boolean allowInvalidSurrogates, boolean quoteSingle, boolean trailingComma, int indentFactor) { - this.allowInvalidSurrogates = allowInvalidSurrogates; - this.quoteSingle = quoteSingle; - this.trailingComma = trailingComma; - this.indentFactor = Math.max(0, indentFactor); - } - - public boolean isAllowInvalidSurrogates() { - return allowInvalidSurrogates; - } - - public boolean isQuoteSingle() { - return quoteSingle; - } - - public boolean isTrailingComma() { - return trailingComma; - } - - public int getIndentFactor() { - return indentFactor; - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/json5/Json5OptionsBuilder.java b/src/main/java/de/marhali/json5/Json5OptionsBuilder.java deleted file mode 100644 index 69dedf2..0000000 --- a/src/main/java/de/marhali/json5/Json5OptionsBuilder.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -/** - * Options builder to configure behaviour of json5 parsing and serialization. - * - * @author Marcel Haßlinger - * @see Json5Options - */ -public class Json5OptionsBuilder { - - private boolean allowInvalidSurrogates = false; - private boolean quoteSingle = false; - private boolean trailingComma = false; - - private int indentFactor = 0; - - /** - * Constructs a new builder instance. - */ - public Json5OptionsBuilder() {} - - /** - * @see Json5Options#isAllowInvalidSurrogates() - * @return Current builder instance - */ - public Json5OptionsBuilder allowInvalidSurrogate() { - this.allowInvalidSurrogates = true; - return this; - } - - /** - * @see Json5Options#isQuoteSingle() - * @return Current builder instance - */ - public Json5OptionsBuilder quoteSingle() { - this.quoteSingle = true; - return this; - } - - /** - * @see Json5Options#isTrailingComma() - * @return Current builder instance - */ - public Json5OptionsBuilder trailingComma() { - this.trailingComma = true; - return this; - } - - /** - * The indentation factor enables pretty-printing and defines - * how many spaces ( {@code ' '}) should be placed before each key/value pair. - * A factor of {@code < 1} disables pretty-printing and discards - * any optional whitespace characters. - * @see Json5Options#getIndentFactor() - * @param indentFactor Indent factor to apply - * @return Current builder instance - */ - public Json5OptionsBuilder indentFactor(int indentFactor) { - this.indentFactor = indentFactor; - return this; - } - - /** - * Configures to output Json5 that fits in a page for pretty printing. This option only affects Json serialization. - * Applies an indent factor of 2. - * @see #indentFactor(int) - * @return Current builder instance - */ - public Json5OptionsBuilder prettyPrinting() { - this.indentFactor = 2; - return this; - } - - /** - * @return Configured {@link Json5Options} - */ - public Json5Options build() { - return new Json5Options(allowInvalidSurrogates, quoteSingle, trailingComma, indentFactor); - } -} diff --git a/src/main/java/de/marhali/json5/Json5Primitive.java b/src/main/java/de/marhali/json5/Json5Primitive.java index f442a53..316f7d1 100644 --- a/src/main/java/de/marhali/json5/Json5Primitive.java +++ b/src/main/java/de/marhali/json5/Json5Primitive.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2008 Google Inc. - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,72 +18,215 @@ package de.marhali.json5; import de.marhali.json5.internal.LazilyParsedNumber; +import de.marhali.json5.internal.NumberLimits; +import de.marhali.json5.internal.RadixNumber; import java.math.BigDecimal; import java.math.BigInteger; +import java.time.Instant; import java.util.Objects; /** - * A class representing a Json primitive value. A primitive value - * is either a String, a Hexadecimal, a Java primitive, or a Java primitive - * wrapper type. + * A class representing a Json5 primitive value. A primitive value is either a String, a Java + * primitive, or a Java primitive wrapper type. + * + *

See the {@link Json5Element} documentation for details on how to convert {@code Json5Primitive} + * and generally any {@code Json5Element} from and to Json5. * - * @author Marcel Haßlinger * @author Inderjeet Singh * @author Joel Leitch + * @author Marcel Haßlinger */ -public abstract class Json5Primitive extends Json5Element { +public final class Json5Primitive extends Json5Element { + + private final Object value; /** - * Quick creator for a primitive with boolean value. - * @param value Boolean value to apply. - * @return Corresponding primitive with provided value. + * Create a primitive containing a {@code null} value. + * + * @return New {@link Json5Null} value */ - public static Json5Primitive of(Boolean value) { - return new Json5Boolean(value); + public static Json5Null fromNull() { + return new Json5Null(); } /** - * Quick creator for a primitive with number value. - * @param value Number value to apply. - * @return Corresponding primitive with provided value. + * Create a primitive containing a boolean value. + * + * @param bool the value to create the primitive with. + * @return Json5Primitive containing the provided {@link Boolean} value */ - public static Json5Primitive of(Number value) { - return new Json5Number(value); + public static Json5Primitive fromBoolean(Boolean bool) { + return new Json5Primitive(Objects.requireNonNull(bool)); } /** - * Quick creator for a primitive with string value. - * Set hexadecimal to true to receive a {@link Json5Hexadecimal}. - * @param value String value to apply. - * @param hexadecimal Is the provided value a hex string literal? - * @return Corresponding primitive with provided value. + * Create a primitive containing a {@link Instant} value. + * + * @param instant the value to create the primitive with. + * @return Json5Primitive containing the provided {@link Instant} value + *

+ * This is an extension that is not compliant to the official Json5 spec. */ - public static Json5Primitive of(String value, boolean hexadecimal) { - return hexadecimal ? new Json5Hexadecimal(value) : new Json5String(value); + public static Json5Primitive fromInstant(Instant instant) { + return new Json5Primitive(Objects.requireNonNull(instant)); } /** - * Quick creator for a primitive with string value. - * @param value String value to apply. - * @return Corresponding primitive with provided value. + * Creates a primitive containing a {@link Number} with specified radix base. + * If a radix base of {@code 2}, {@code 8} or {@code 16} is set, + * this method will ensure that the underlying number implementation is a {@link BigInteger}. + * + * @param number The number + * @param radix Radix base + * @return Json5Primitive containing the provided {@link Number} with specified radix base */ - public static Json5Primitive of(String value) { - return new Json5String(value); + public static Json5Primitive fromNumber(Number number, int radix) { + Objects.requireNonNull(number); + + if ((radix == 2 || radix == 8 || radix == 16) && !(number instanceof BigInteger)) { + // Ensure that every binary, octal or hex number is stored as a big integer + return new Json5Primitive(new RadixNumber(BigInteger.valueOf(number.longValue()), radix)); + } + + return new Json5Primitive(new RadixNumber(number, radix)); } - protected final Object value; + /** + * Create a primitive containing a decimal {@link Number} (radix base {@code 10}). + * + * @param number the value to create the primitive with. + * @return Json5Primitive containing the provided {@link Number} with radix base {@code 10} + */ + public static Json5Primitive fromNumber(Number number) { + return Json5Primitive.fromNumber(Objects.requireNonNull(number), 10); + } - public Json5Primitive(Object value) { - this.value = Objects.requireNonNull(value); + /** + * Create a primitive containing a binary number (radix base {@code 2}). + * For example {@code +0b1010...}, {@code 0b1010...} or {@code -0b1010...}. + *

+ * This is an extension that is not compliant to the official Json5 spec. + * + * @param binaryString the value to create the primitive with. + * @return Json5Primitive containing the provided binary number with radix base {@code 2} + */ + public static Json5Primitive fromBinaryString(String binaryString) { + Objects.requireNonNull(binaryString); + + BigInteger hexInteger; + + switch (binaryString.charAt(0)) { + case '+': // +0b... + hexInteger = new BigInteger(binaryString.substring(3), 2); + break; + case '-': // -0b... + hexInteger = new BigInteger(binaryString.substring(3), 2).negate(); + break; + default: // 0b... + hexInteger = new BigInteger(binaryString.substring(2), 2); + break; + } + + return new Json5Primitive(new RadixNumber(hexInteger, 2)); + } + + /** + * Create a primitive containing an octal number (radix base {@code 8}). + * For example {@code +0o107...}, {@code 0o107...} or {@code -0o107...}. + *

+ * This is an extension that is not compliant to the official Json5 spec. + * + * @param octalString the value to create the primitive with. + * @return Json5Primitive containing the provided octal number with radix base {@code 8} + */ + public static Json5Primitive fromOctalString(String octalString) { + Objects.requireNonNull(octalString); + + BigInteger hexInteger; + + switch (octalString.charAt(0)) { + case '+': // +0b... + hexInteger = new BigInteger(octalString.substring(3), 8); + break; + case '-': // -0b... + hexInteger = new BigInteger(octalString.substring(3), 8).negate(); + break; + default: // 0b... + hexInteger = new BigInteger(octalString.substring(2), 8); + break; + } + + return new Json5Primitive(new RadixNumber(hexInteger, 8)); + } + + /** + * Create a primitive containing a binary number (radix base {@code 16}). + * For example {@code +0x09af...}, {@code 0x09af...} or {@code -0x09af...}. + * + * @param hexString the value to create the primitive with. + * @return Json5Primitive containing the provided hex number with radix base {@code 16} + */ + public static Json5Primitive fromHexString(String hexString) { + Objects.requireNonNull(hexString); + + BigInteger hexInteger; + + switch (hexString.charAt(0)) { + case '+': // +0x... + hexInteger = new BigInteger(hexString.substring(3), 16); + break; + case '-': // -0x... + hexInteger = new BigInteger(hexString.substring(3), 16).negate(); + break; + default: // 0x... + hexInteger = new BigInteger(hexString.substring(2), 16); + break; + } + + return new Json5Primitive(new RadixNumber(hexInteger, 16)); + } + + /** + * Create a primitive containing a String value. + * + * @param string the value to create the primitive with. + * @return Json5Primitive containing the provided {@link String} + */ + public static Json5Primitive fromString(String string) { + return new Json5Primitive(Objects.requireNonNull(string)); + } + + /** + * Create a primitive containing a character. The character is turned into a one character String + * since Json5 only supports String. + * + * @param c the value to create the primitive with. + * @return Json5Primitive containing the provided {@link Character} + */ + public static Json5Primitive fromCharacter(Character c) { + // convert characters to strings since in Json5, characters are represented as a single + // character string + return new Json5Primitive(Objects.requireNonNull(c).toString()); + } + + /** + * Internal constructor with primitive value + * + * @param value Internal value + */ + private Json5Primitive(Object value) { + this.value = value; } /** * Returns the same value as primitives are immutable. */ @Override - public Json5Element deepCopy() { - return this; + public Json5Primitive deepCopy() { + Json5Primitive copy = new Json5Primitive(value); + copy.setComment(comment); + return copy; } /** @@ -96,37 +239,129 @@ public boolean isBoolean() { } /** - * convenience method to get this element as a boolean value. - * - * @return get this element as a primitive boolean value. + * Convenience method to get this element as a boolean value. If this primitive {@linkplain + * #isBoolean() is not a boolean}, the string value is parsed using {@link + * Boolean#parseBoolean(String)}. This means {@code "true"} (ignoring case) is considered {@code + * true} and any other value is considered {@code false}. */ @Override public boolean getAsBoolean() { if (isBoolean()) { - return ((Boolean) value).booleanValue(); + return (Boolean) value; } // Check to see if the value as a String is "true" in any case. return Boolean.parseBoolean(getAsString()); } + /** + * Check whether this primitive contains a {@link Instant} value. + * + * @return true if this primitive contains a {@link Instant} value, false otherwise. + */ + public boolean isInstant() { + return value instanceof Instant; + } + + @Override + public Instant getAsInstant() { + if (isInstant()) { + return (Instant) value; + } else if (isString()) { + return Instant.parse((String) value); + } else if (isNumber()) { + var radixNumber = getAsRadixNumber(); + var number = radixNumber.getNumber(); + + if (number instanceof Byte || number instanceof Short || number instanceof Integer || number instanceof Long) + return Instant.ofEpochSecond((long) value); + + if (number instanceof BigInteger) + return Instant.ofEpochSecond(((BigInteger) number).longValueExact()); + } + throw new UnsupportedOperationException("Primitive is neither a number nor a string"); + } + /** * Check whether this primitive contains a Number. * * @return true if this primitive contains a Number, false otherwise. */ public boolean isNumber() { - return value instanceof Number; + return value instanceof RadixNumber; + } + + @Override + public RadixNumber getAsRadixNumber() { + if (isNumber()) { + return (RadixNumber) value; + } + throw new UnsupportedOperationException("Primitive is not a number"); + } + + public int getNumberRadix() { + return getAsRadixNumber().getRadix(); + } + + public boolean isBinaryNumber() { + return isNumber() && getNumberRadix() == 2; + } + + public boolean isOctalNumber() { + return isNumber() && getNumberRadix() == 8; + } + + public boolean isHexNumber() { + return isNumber() && getNumberRadix() == 16; } /** - * convenience method to get this element as a Number. + * Convenience method to get this element as a {@link Number}. If this primitive {@linkplain + * #isString() is a string}, a lazily parsed {@code Number} is constructed which parses the string + * when any of its methods are called (which can lead to a {@link NumberFormatException}). * - * @return get this element as a Number. - * @throws NumberFormatException if the value contained is not a valid Number. + * @throws UnsupportedOperationException if this primitive is neither a number nor a string. */ @Override public Number getAsNumber() { - return value instanceof String ? new LazilyParsedNumber((String) value) : (Number) value; + if (isNumber()) { + return getAsRadixNumber().getNumber(); + } else if (isString()) { + return new LazilyParsedNumber((String) value); + } + throw new UnsupportedOperationException("Primitive is neither a number nor a string"); + } + + @Override + public String getAsBinaryString() { + BigInteger bigInteger = getAsBigInteger(); + + if (bigInteger.signum() >= 0) { + return "0b" + bigInteger.toString(2); + } else { + return "-0b" + bigInteger.abs().toString(2); + } + } + + @Override + public String getAsOctalString() { + BigInteger bigInteger = getAsBigInteger(); + + if (bigInteger.signum() >= 0) { + return "0o" + bigInteger.toString(8); + } else { + return "-0o" + bigInteger.abs().toString(8); + } + } + + @Override + public String getAsHexString() { + BigInteger bigInteger = getAsBigInteger(); + + if (bigInteger.signum() >= 0) { + return "0x" + bigInteger.toString(16); + } else { + return "-0x" + bigInteger.abs().toString(16); + } } /** @@ -138,27 +373,32 @@ public boolean isString() { return value instanceof String; } - /** - * convenience method to get this element as a String. - * - * @return get this element as a String. - */ + // Don't add Javadoc, inherit it from super implementation; no exceptions are thrown here @Override public String getAsString() { - if (isNumber()) { - return getAsNumber().toString(); + if (isString()) { + return (String) value; + } else if (isInstant()) { + return ((Instant) value).toString(); } else if (isBoolean()) { return ((Boolean) value).toString(); - } else { - return (String) value; + } else if (isNumber()) { + if (isBinaryNumber()) { + return getAsBinaryString(); + } else if (isOctalNumber()) { + return getAsOctalString(); + } else if (isHexNumber()) { + return getAsHexString(); + } else { + return getAsNumber().toString(); + } } + + throw new AssertionError("Unexpected value type: " + value.getClass()); } /** - * convenience method to get this element as a primitive double. - * - * @return get this element as a primitive double. - * @throws NumberFormatException if the value contained is not a valid double. + * @throws NumberFormatException {@inheritDoc} */ @Override public double getAsDouble() { @@ -166,33 +406,39 @@ public double getAsDouble() { } /** - * convenience method to get this element as a {@link BigDecimal}. - * - * @return get this element as a {@link BigDecimal}. - * @throws NumberFormatException if the value contained is not a valid {@link BigDecimal}. + * @throws NumberFormatException {@inheritDoc} */ @Override public BigDecimal getAsBigDecimal() { - return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(value.toString()); + if (isNumber()) { + var number = getAsRadixNumber().getNumber(); + if (number instanceof BigDecimal) { + return (BigDecimal) number; + } + } + + return NumberLimits.parseBigDecimal(getAsString()); } /** - * convenience method to get this element as a {@link BigInteger}. - * - * @return get this element as a {@link BigInteger}. - * @throws NumberFormatException if the value contained is not a valid {@link BigInteger}. + * @throws NumberFormatException {@inheritDoc} */ @Override public BigInteger getAsBigInteger() { - return value instanceof BigInteger ? - (BigInteger) value : new BigInteger(value.toString()); + if (isNumber()) { + var number = getAsRadixNumber().getNumber(); + if (number instanceof BigInteger) { + if (isIntegral(this)) { + return BigInteger.valueOf(number.longValue()); + } + } + } + + return NumberLimits.parseBigInteger(getAsString()); } /** - * convenience method to get this element as a float. - * - * @return get this element as a float. - * @throws NumberFormatException if the value contained is not a valid float. + * @throws NumberFormatException {@inheritDoc} */ @Override public float getAsFloat() { @@ -200,10 +446,10 @@ public float getAsFloat() { } /** - * convenience method to get this element as a primitive long. + * Convenience method to get this element as a primitive long. * - * @return get this element as a primitive long. - * @throws NumberFormatException if the value contained is not a valid long. + * @return this element as a primitive long. + * @throws NumberFormatException {@inheritDoc} */ @Override public long getAsLong() { @@ -211,10 +457,7 @@ public long getAsLong() { } /** - * convenience method to get this element as a primitive short. - * - * @return get this element as a primitive short. - * @throws NumberFormatException if the value contained is not a valid short value. + * @throws NumberFormatException {@inheritDoc} */ @Override public short getAsShort() { @@ -222,16 +465,16 @@ public short getAsShort() { } /** - * convenience method to get this element as a primitive integer. - * - * @return get this element as a primitive integer. - * @throws NumberFormatException if the value contained is not a valid integer. + * @throws NumberFormatException {@inheritDoc} */ @Override public int getAsInt() { return isNumber() ? getAsNumber().intValue() : Integer.parseInt(getAsString()); } + /** + * @throws NumberFormatException {@inheritDoc} + */ @Override public byte getAsByte() { return isNumber() ? getAsNumber().byteValue() : Byte.parseByte(getAsString()); @@ -239,55 +482,29 @@ public byte getAsByte() { @Override public int hashCode() { - if (value == null) { - return 31; - } - // Using recommended hashing algorithm from Effective Java for longs and doubles - if (isIntegral(this)) { - long value = getAsNumber().longValue(); - return (int) (value ^ (value >>> 32)); - } - if (value instanceof Number) { - long value = Double.doubleToLongBits(getAsNumber().doubleValue()); - return (int) (value ^ (value >>> 32)); - } - return value.hashCode(); + return Objects.hash(super.hashCode(), value); } @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - Json5Primitive other = (Json5Primitive)obj; - if (value == null) { - return other.value == null; - } - if (isIntegral(this) && isIntegral(other)) { - return getAsNumber().longValue() == other.getAsNumber().longValue(); - } - if (value instanceof Number && other.value instanceof Number) { - double a = getAsNumber().doubleValue(); - // Java standard types other than double return true for two NaN. So, need - // special handling for double. - double b = other.getAsNumber().doubleValue(); - return a == b || (Double.isNaN(a) && Double.isNaN(b)); - } - return value.equals(other.value); + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + Json5Primitive that = (Json5Primitive) o; + return Objects.equals(value, that.value); } /** - * Returns true if the specified number is an integral type - * (Long, Integer, Short, Byte, BigInteger) + * Returns true if the specified number is an integral type (Long, Integer, Short, Byte, + * BigInteger) */ private static boolean isIntegral(Json5Primitive primitive) { - if (primitive.value instanceof Number) { - Number number = (Number) primitive.value; - return number instanceof BigInteger || number instanceof Long || number instanceof Integer - || number instanceof Short || number instanceof Byte; + if (primitive.value instanceof RadixNumber) { + Number number = ((RadixNumber) primitive.value).getNumber(); + return number instanceof BigInteger + || number instanceof Long + || number instanceof Integer + || number instanceof Short + || number instanceof Byte; } return false; } diff --git a/src/main/java/de/marhali/json5/Json5String.java b/src/main/java/de/marhali/json5/config/DigitSeparatorStrategy.java similarity index 57% rename from src/main/java/de/marhali/json5/Json5String.java rename to src/main/java/de/marhali/json5/config/DigitSeparatorStrategy.java index d774bd3..a1f9c46 100644 --- a/src/main/java/de/marhali/json5/Json5String.java +++ b/src/main/java/de/marhali/json5/config/DigitSeparatorStrategy.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,28 @@ * limitations under the License. */ -package de.marhali.json5; +package de.marhali.json5.config; /** - * A class representing a json5 string value. + * An enum containing all supported behaviors for handling digit separators. * * @author Marcel Haßlinger */ -public final class Json5String extends Json5Primitive { - public Json5String(String string) { - super(string); - } +public enum DigitSeparatorStrategy { + + /** + * Expect no digit separators + */ + NONE, + + /** + * Uses Java-style digit separators (e.g. {@code 123_456}). + */ + JAVA_STYLE, + + /** + * Uses C-style digit separators (e.g. {@code 123'456}). + */ + C_STYLE, + } diff --git a/src/main/java/de/marhali/json5/config/DuplicateKeyStrategy.java b/src/main/java/de/marhali/json5/config/DuplicateKeyStrategy.java new file mode 100644 index 0000000..70df845 --- /dev/null +++ b/src/main/java/de/marhali/json5/config/DuplicateKeyStrategy.java @@ -0,0 +1,54 @@ +/* + * MIT License + * + * Copyright (C) 2021 SyntaxError404 + * Copyright (C) 2025 Marcel Haßlinger + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.marhali.json5.config; + +/** + * An enum containing all supported behaviors for duplicate keys + * + * @author SyntaxError404 + * @author Marcel Haßlinger + */ +public enum DuplicateKeyStrategy { + + /** + * Throws an {@link de.marhali.json5.exception.Json5Exception exception} when a key + * is encountered multiple times within the same object + */ + UNIQUE, + + /** + * Only the last encountered value is significant, + * all previous occurrences are silently discarded + */ + LAST_WINS, + + /** + * Wraps duplicate values inside an {@link de.marhali.json5.Json5Array array}, + * effectively treating them as if they were declared as one + */ + DUPLICATE + +} diff --git a/src/main/java/de/marhali/json5/config/Json5Options.java b/src/main/java/de/marhali/json5/config/Json5Options.java new file mode 100644 index 0000000..0c3df92 --- /dev/null +++ b/src/main/java/de/marhali/json5/config/Json5Options.java @@ -0,0 +1,573 @@ +/* + * MIT License + * + * Copyright (C) 2021 SyntaxError404 + * Copyright (C) 2022 - 2025 Marcel Haßlinger + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.marhali.json5.config; + +import de.marhali.json5.Json5Array; +import de.marhali.json5.Json5Object; + +import java.util.Objects; + +/** + * Definition of all configuration options for parsing and writing Json5 data. + * + * @author SyntaxError404 + * @author Marcel Haßlinger + */ +public final class Json5Options { + + /** + * Whether instants should be stringifyed as unix timestamps. + * If this is {@code false}, instants will be stringifyed as strings + * (according to RFC 3339, Section 5.6). + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean stringifyUnixInstants; + + /** + * Whether stringifying should only yield ASCII strings. + * All non-ASCII characters will be converted to their + * Unicode escape sequence (\uXXXX). + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean stringifyAscii; + + /** + * Whether {@code NaN} should be allowed as a number + */ + private final boolean allowNaN; + + /** + * Whether {@code Infinity} should be allowed as a number. + * This applies to both {@code +Infinity} and {@code -Infinity} + */ + private final boolean allowInfinity; + + /** + * Whether invalid unicode surrogate pairs should be allowed + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowInvalidSurrogates; + + /** + * Whether strings should be single-quoted ({@code '}) instead of double-quoted ({@code "}). + * This also includes a {@link Json5Object JSON5Object's} member names + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean quoteSingle; + + /** + * Whether member names of {@link Json5Object Json5Object's} should be quoteless. + * E.g. { enabled: true } instead of { "enabled": true }. + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean quoteless; + + /** + * Whether binary literals ({@code 0b10101...}) should be allowed + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowBinaryLiterals; + + /** + * Whether octal literals ({@code 0o567...}) should be allowed + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowOctalLiterals; + + /** + * Whether hexadecimal floating-point literals (e.g. {@code 0xA.BCp+12}) should be allowed + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowHexFloatingLiterals; + + /** + * Whether 32-bit unicode escape sequences ({@code \U00123456}) should be allowed + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowLongUnicodeEscapes; + + /** + * Specifies whether trailing data should be allowed.
+ * If {@code false}, parsing the following will produce an error + * due to the trailing {@code abc}: + * + *

{ }abc
+ *

+ * If {@code true}, however, this will be interpreted as an empty + * {@link Json5Object} and any trailing will be ignored. + *

+ * Whitespace never counts as trailing data. + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean allowTrailingData; + + /** + * Specifies whether comments on {@link de.marhali.json5.Json5Element Json5Element's} should be parsed. + * If {@code false}, no comments will be parsed. + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final boolean parseComments; + + /** + * Specifies whether comments on {@link de.marhali.json5.Json5Element Json5Element's} should be written. + * If {@code false}, no set comments will be written. + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean writeComments; + + /** + * Specifies whether to apply trailing commas whenever possible or not. + * If {@code false}, commas are only written when necessary. + *

+     * {@code
+     * // trailingComma: false
+     * {
+     *   "firstKey": "myValue",
+     *   "secondKey": "myValue" // <-- no comma
+     * }
+     *
+     * // trailingComma: true
+     * {
+     *   "firstKey": "myValue",
+     *   "secondKey": "myValue", // <-- trailing comma
+     * }
+     * }
+     * 
+ *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean trailingComma; + + /** + * Specifies whether a final newline (empty line) is appended at the end of written Json5 data. + * If {@code true}, this option inserts the final newline after a root {@link Json5Object} or {@link Json5Array}. + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final boolean insertFinalNewline; + + /** + * Specifies the behaviour for digit separator's on numbers. + *

+ * This option applies to both {@link de.marhali.json5.stream.Json5Parser parsing} and {@link de.marhali.json5.stream.Json5Writer writing}. + */ + private final DigitSeparatorStrategy digitSeparatorStrategy; + + /** + * Specifies the behaviour when the same key is encountered multiple times within the same {@link Json5Object} + *

+ * This is a {@link de.marhali.json5.stream.Json5Parser parser}-only option + */ + private final DuplicateKeyStrategy duplicateBehaviour; + + + /** + * Defines the amount of whitespace's to use for the indentation of {@link Json5Object}'s or {@link Json5Array}'s. + * A factor of {@code < 1} disables pretty-printing and discards any optional whitespace characters. + *

+ * This is a {@link de.marhali.json5.stream.Json5Writer writer}-only option + */ + private final int indentFactor; + + /** + * Configure options using the builder pattern. + * + * @return builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Recommended default configuration options. + *

+ * Defaults: + *

    + *
  • allowNaN: true
  • + *
  • allowInfinity: true
  • + *
  • allowInvalidSurrogates: true
  • + *
  • quoteless: true
  • + *
  • parseComments: true
  • + *
  • writeComments: true
  • + *
  • trailingComma: true
  • + *
  • digitSeparatorStrategy: NONE
  • + *
  • duplicateKeyStrategy: UNIQUE
  • + *
  • prettyPrinting: true
  • + *
+ */ + public static Json5Options DEFAULT = builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .quoteless() + .parseComments() + .writeComments() + .trailingComma() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); + + private Json5Options(Builder builder) { + this.stringifyUnixInstants = builder.stringifyUnixInstants; + this.stringifyAscii = builder.stringifyAscii; + this.allowNaN = builder.allowNaN; + this.allowInfinity = builder.allowInfinity; + this.allowInvalidSurrogates = builder.allowInvalidSurrogates; + this.quoteSingle = builder.quoteSingle; + this.quoteless = builder.quoteless; + this.allowBinaryLiterals = builder.allowBinaryLiterals; + this.allowOctalLiterals = builder.allowOctalLiterals; + this.allowHexFloatingLiterals = builder.allowHexFloatingLiterals; + this.allowLongUnicodeEscapes = builder.allowLongUnicodeEscapes; + this.allowTrailingData = builder.allowTrailingData; + this.parseComments = builder.parseComments; + this.writeComments = builder.writeComments; + this.trailingComma = builder.trailingComma; + this.insertFinalNewline = builder.insertFinalNewline; + this.digitSeparatorStrategy = builder.digitSeparatorStrategy; + this.duplicateBehaviour = builder.duplicateKeyStrategy; + this.indentFactor = builder.indentFactor; + } + + public boolean isStringifyUnixInstants() { + return stringifyUnixInstants; + } + + public boolean isStringifyAscii() { + return stringifyAscii; + } + + public boolean isAllowNaN() { + return allowNaN; + } + + public boolean isAllowInfinity() { + return allowInfinity; + } + + public boolean isAllowInvalidSurrogates() { + return allowInvalidSurrogates; + } + + public boolean isQuoteSingle() { + return quoteSingle; + } + + public boolean isQuoteless() { + return quoteless; + } + + public boolean isAllowBinaryLiterals() { + return allowBinaryLiterals; + } + + public boolean isAllowOctalLiterals() { + return allowOctalLiterals; + } + + public boolean isAllowHexFloatingLiterals() { + return allowHexFloatingLiterals; + } + + public boolean isAllowLongUnicodeEscapes() { + return allowLongUnicodeEscapes; + } + + public boolean isAllowTrailingData() { + return allowTrailingData; + } + + public boolean isWriteComments() { + return writeComments; + } + + public boolean isParseComments() { + return parseComments; + } + + public boolean isTrailingComma() { + return trailingComma; + } + + public boolean isInsertFinalNewline() { + return insertFinalNewline; + } + + public DigitSeparatorStrategy getDigitSeparatorStrategy() { + return digitSeparatorStrategy; + } + + public DuplicateKeyStrategy getDuplicateBehaviour() { + return duplicateBehaviour; + } + + public int getIndentFactor() { + return indentFactor; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Json5Options that = (Json5Options) o; + return stringifyUnixInstants == that.stringifyUnixInstants && stringifyAscii == that.stringifyAscii && allowNaN == that.allowNaN && allowInfinity == that.allowInfinity && allowInvalidSurrogates == that.allowInvalidSurrogates && quoteSingle == that.quoteSingle && quoteless == that.quoteless && allowBinaryLiterals == that.allowBinaryLiterals && allowOctalLiterals == that.allowOctalLiterals && allowHexFloatingLiterals == that.allowHexFloatingLiterals && allowLongUnicodeEscapes == that.allowLongUnicodeEscapes && allowTrailingData == that.allowTrailingData && parseComments == that.parseComments && writeComments == that.writeComments && trailingComma == that.trailingComma && insertFinalNewline == that.insertFinalNewline && indentFactor == that.indentFactor && digitSeparatorStrategy == that.digitSeparatorStrategy && duplicateBehaviour == that.duplicateBehaviour; + } + + @Override + public int hashCode() { + return Objects.hash(stringifyUnixInstants, stringifyAscii, allowNaN, allowInfinity, allowInvalidSurrogates, quoteSingle, quoteless, allowBinaryLiterals, allowOctalLiterals, allowHexFloatingLiterals, allowLongUnicodeEscapes, allowTrailingData, parseComments, writeComments, trailingComma, insertFinalNewline, digitSeparatorStrategy, duplicateBehaviour, indentFactor); + } + + public static final class Builder { + private boolean stringifyUnixInstants = false; + private boolean stringifyAscii = false; + private boolean allowNaN = false; + private boolean allowInfinity = false; + private boolean allowInvalidSurrogates = false; + private boolean quoteSingle = false; + private boolean quoteless = false; + private boolean allowBinaryLiterals = false; + private boolean allowOctalLiterals = false; + private boolean allowHexFloatingLiterals = false; + private boolean allowLongUnicodeEscapes = false; + private boolean allowTrailingData = false; + private boolean parseComments = false; + private boolean writeComments = false; + private boolean trailingComma = false; + private boolean insertFinalNewline = false; + private DigitSeparatorStrategy digitSeparatorStrategy = DigitSeparatorStrategy.NONE; + private DuplicateKeyStrategy duplicateKeyStrategy = DuplicateKeyStrategy.UNIQUE; + private int indentFactor = 0; + + private Builder() { + } + + /** + * @return built {@link Json5Options} + */ + public Json5Options build() { + return new Json5Options(this); + } + + /** + * @return builder + * @see Json5Options#stringifyUnixInstants + */ + public Builder stringifyUnixInstants() { + this.stringifyUnixInstants = true; + return this; + } + + /** + * @return builder + * @see Json5Options#stringifyAscii + */ + public Builder stringifyAscii() { + this.stringifyAscii = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowNaN + */ + public Builder allowNaN() { + this.allowNaN = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowInfinity + */ + public Builder allowInfinity() { + this.allowInfinity = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowInvalidSurrogates + */ + public Builder allowInvalidSurrogates() { + this.allowInvalidSurrogates = true; + return this; + } + + /** + * @return builder + * @see Json5Options#quoteSingle + */ + public Builder quoteSingle() { + this.quoteSingle = true; + return this; + } + + /** + * @return builder + * @see Json5Options#quoteless + */ + public Builder quoteless() { + this.quoteless = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowBinaryLiterals + */ + public Builder allowBinaryLiterals() { + this.allowBinaryLiterals = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowOctalLiterals + */ + public Builder allowOctalLiterals() { + this.allowOctalLiterals = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowHexFloatingLiterals + */ + public Builder allowHexFloatingLiterals() { + this.allowHexFloatingLiterals = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowLongUnicodeEscapes + */ + public Builder allowLongUnicodeEscapes() { + this.allowLongUnicodeEscapes = true; + return this; + } + + /** + * @return builder + * @see Json5Options#allowTrailingData + */ + public Builder allowTrailingData() { + this.allowTrailingData = true; + return this; + } + + /** + * @return builder + * @see Json5Options#parseComments + */ + public Builder parseComments() { + this.parseComments = true; + return this; + } + + /** + * @return builder + * @see Json5Options#writeComments + */ + public Builder writeComments() { + this.writeComments = true; + return this; + } + + /** + * @return builder + * @see Json5Options#trailingComma + */ + public Builder trailingComma() { + this.trailingComma = true; + return this; + } + + /** + * @return builder + * @see Json5Options#insertFinalNewline + */ + public Builder insertFinalNewline() { + this.insertFinalNewline = true; + return this; + } + + /** + * @param digitSeparatorStrategy Strategy to apply + * @return builder + * @see Json5Options#digitSeparatorStrategy + */ + public Builder digitSeparatorStrategy(DigitSeparatorStrategy digitSeparatorStrategy) { + this.digitSeparatorStrategy = digitSeparatorStrategy; + return this; + } + + /** + * @param duplicateKeyStrategy Strategy to apply + * @return builder + * @see Json5Options#duplicateBehaviour + */ + public Builder duplicateKeyStrategy(DuplicateKeyStrategy duplicateKeyStrategy) { + this.duplicateKeyStrategy = duplicateKeyStrategy; + return this; + } + + /** + * @param indentFactor Indent factor to apply + * @return builder + * @see Json5Options#indentFactor + */ + public Builder indentFactor(int indentFactor) { + this.indentFactor = indentFactor; + return this; + } + + /** + * Configures pretty printing using 2 whitespaces for serialization (writing). + * Shorthand for {@code indentFactor(2)}. + * + * @return builder + * @see Json5Options#indentFactor + * @see #indentFactor(int) + */ + public Builder prettyPrinting() { + return indentFactor(2); + } + } +} diff --git a/src/main/java/de/marhali/json5/exception/Json5Exception.java b/src/main/java/de/marhali/json5/exception/Json5Exception.java index faa04cf..2a47f7e 100644 --- a/src/main/java/de/marhali/json5/exception/Json5Exception.java +++ b/src/main/java/de/marhali/json5/exception/Json5Exception.java @@ -59,10 +59,10 @@ public Json5Exception(Throwable cause) { * Constructs a new JSONException with a detail message and a causing exception * * @param message the detail message - * @param cause the causing exception + * @param cause the causing exception */ public Json5Exception(String message, Throwable cause) { super(message, cause); } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/internal/EcmaScriptIdentifier.java b/src/main/java/de/marhali/json5/internal/EcmaScriptIdentifier.java new file mode 100644 index 0000000..403a3b1 --- /dev/null +++ b/src/main/java/de/marhali/json5/internal/EcmaScriptIdentifier.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +/** + * @author Marcel Haßlinger + */ +public class EcmaScriptIdentifier { + // Zero Width Non-Joiner / Joiner + private static final int ZWNJ = 0x200C; + private static final int ZWJ = 0x200D; + + private EcmaScriptIdentifier() { + } + + /** + * Checks whether the provided {@link String} is a valid ES5.1 IdentifierName. + * + * @param raw Input to check + * @return true if valid identifier, otherwise false + * @see https://262.ecma-international.org/5.1/#sec-7.6 + */ + public static boolean isValid(String raw) { + if (raw == null || raw.isEmpty()) return false; + + // Transform \\uXXXX-Escapes into real codepoints (ES5.1 allows escape in IdentifierName + String unescaped = decodeEs5UnicodeEscapes(raw); + if (unescaped == null || unescaped.isEmpty()) return false; + + int i = 0; + int cp = unescaped.codePointAt(i); + if (!isIdentifierStartES5(cp)) return false; + i += Character.charCount(cp); + + while (i < unescaped.length()) { + cp = unescaped.codePointAt(i); + if (!isIdentifierPartES5(cp)) return false; + i += Character.charCount(cp); + } + return true; + } + + private static boolean isIdentifierStartES5(int cp) { + // '$' and '_' explicit + if (cp == '$' || cp == '_') return true; + + int t = Character.getType(cp); + // Unicode categories: Lu, Ll, Lt, Lm, Lo, Nl + switch (t) { + case Character.UPPERCASE_LETTER: // Lu + case Character.LOWERCASE_LETTER: // Ll + case Character.TITLECASE_LETTER: // Lt + case Character.MODIFIER_LETTER: // Lm + case Character.OTHER_LETTER: // Lo + case Character.LETTER_NUMBER: // Nl + return true; + default: + return false; + } + } + + private static boolean isIdentifierPartES5(int cp) { + if (isIdentifierStartES5(cp)) return true; + if (cp == ZWNJ || cp == ZWJ) return true; // U+200C/U+200D are alowed + + int t = Character.getType(cp); + // Additional categories: Mn, Mc, Nd, Pc + switch (t) { + case Character.NON_SPACING_MARK: // Mn + case Character.COMBINING_SPACING_MARK: // Mc + case Character.DECIMAL_DIGIT_NUMBER: // Nd + case Character.CONNECTOR_PUNCTUATION: // Pc (e.g. underline, but already covered) + return true; + default: + return false; + } + } + + /** + * Decodes ES5-style Unicode-Escapes \\uXXXX inside a Identifier. + * + * @return {@code null}, if an escape is syntactically invalid + */ + private static String decodeEs5UnicodeEscapes(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); ) { + char ch = s.charAt(i); + if (ch == '\\') { + if (i + 1 < s.length() && s.charAt(i + 1) == 'u') { + // Expect 4 hex chars (ES5.1; no \\u{...} syntax) + if (i + 6 > s.length()) return null; + String hex = s.substring(i + 2, i + 6); + int codeUnit = parse4Hex(hex); + if (codeUnit < 0) return null; + out.append((char) codeUnit); + i += 6; + } else { + // Other backslashes are not allowed + return null; + } + } else { + out.append(ch); + i++; + } + } + // Maybe Surrogates... + return out.toString(); + } + + private static int parse4Hex(String hex4) { + if (hex4.length() != 4) return -1; + int val = 0; + for (int i = 0; i < 4; i++) { + char c = hex4.charAt(i); + int d = Character.digit(c, 16); + if (d < 0) return -1; + val = (val << 4) | d; + } + return val; + } +} diff --git a/src/main/java/de/marhali/json5/internal/LazilyParsedNumber.java b/src/main/java/de/marhali/json5/internal/LazilyParsedNumber.java index 19f1b9a..be57aef 100644 --- a/src/main/java/de/marhali/json5/internal/LazilyParsedNumber.java +++ b/src/main/java/de/marhali/json5/internal/LazilyParsedNumber.java @@ -29,7 +29,9 @@ public final class LazilyParsedNumber extends Number { private final String value; - /** @param value must not be null */ + /** + * @param value must not be null + */ public LazilyParsedNumber(String value) { this.value = value; } @@ -75,6 +77,7 @@ public String toString() { * If somebody is unlucky enough to have to serialize one of these, serialize * it as a BigDecimal so that they won't need Gson on the other side to * deserialize it. + * * @return Value as {@link BigDecimal} * @throws ObjectStreamException Stream exception */ @@ -103,4 +106,4 @@ public boolean equals(Object obj) { } return false; } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/internal/LinkedTreeMap.java b/src/main/java/de/marhali/json5/internal/LinkedTreeMap.java index edd7ad7..1dba093 100644 --- a/src/main/java/de/marhali/json5/internal/LinkedTreeMap.java +++ b/src/main/java/de/marhali/json5/internal/LinkedTreeMap.java @@ -29,78 +29,103 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Set; /** - * A map of comparable keys to values. Unlike {@code TreeMap}, this class uses - * insertion order for iteration order. Comparison order is only used as an - * optimization for efficient insertion and removal. + * A map of comparable keys to values. Unlike {@code TreeMap}, this class uses insertion order for + * iteration order. Comparison order is only used as an optimization for efficient insertion and + * removal. * *

This implementation was derived from Android 4.1's TreeMap class. */ +@SuppressWarnings("serial") // ignore warning about missing serialVersionUID public final class LinkedTreeMap extends AbstractMap implements Serializable { - @SuppressWarnings({ "unchecked", "rawtypes" }) // to avoid Comparable>> - private static final Comparator NATURAL_ORDER = new Comparator() { - public int compare(Comparable a, Comparable b) { - return a.compareTo(b); - } - }; + @SuppressWarnings({"unchecked", "rawtypes"}) // to avoid Comparable>> + private static final Comparator NATURAL_ORDER = + new Comparator() { + @Override + public int compare(Comparable a, Comparable b) { + return a.compareTo(b); + } + }; - Comparator comparator; + private final Comparator comparator; + private final boolean allowNullValues; Node root; int size = 0; int modCount = 0; // Used to preserve iteration order - final Node header = new Node(); + final Node header; /** - * Create a natural order, empty tree map whose keys must be mutually - * comparable and non-null. + * Create a natural order, empty tree map whose keys must be mutually comparable and non-null, and + * whose values can be {@code null}. */ @SuppressWarnings("unchecked") // unsafe! this assumes K is comparable public LinkedTreeMap() { - this((Comparator) NATURAL_ORDER); + this((Comparator) NATURAL_ORDER, true); + } + + /** + * Create a natural order, empty tree map whose keys must be mutually comparable and non-null. + * + * @param allowNullValues whether {@code null} is allowed as entry value + */ + @SuppressWarnings("unchecked") // unsafe! this assumes K is comparable + public LinkedTreeMap(boolean allowNullValues) { + this((Comparator) NATURAL_ORDER, allowNullValues); } /** - * Create a tree map ordered by {@code comparator}. This map's keys may only - * be null if {@code comparator} permits. + * Create a tree map ordered by {@code comparator}. This map's keys may only be null if {@code + * comparator} permits. * - * @param comparator the comparator to order elements with, or {@code null} to - * use the natural ordering. + * @param comparator the comparator to order elements with, or {@code null} to use the natural + * ordering. + * @param allowNullValues whether {@code null} is allowed as entry value */ - @SuppressWarnings({ "unchecked", "rawtypes" }) // unsafe! if comparator is null, this assumes K is comparable - public LinkedTreeMap(Comparator comparator) { - this.comparator = comparator != null - ? comparator - : (Comparator) NATURAL_ORDER; + // unsafe! if comparator is null, this assumes K is comparable + @SuppressWarnings({"unchecked", "rawtypes"}) + public LinkedTreeMap(Comparator comparator, boolean allowNullValues) { + this.comparator = comparator != null ? comparator : (Comparator) NATURAL_ORDER; + this.allowNullValues = allowNullValues; + this.header = new Node<>(allowNullValues); } - @Override public int size() { + @Override + public int size() { return size; } - @Override public V get(Object key) { + @Override + public V get(Object key) { Node node = findByObject(key); return node != null ? node.value : null; } - @Override public boolean containsKey(Object key) { + @Override + public boolean containsKey(Object key) { return findByObject(key) != null; } - @Override public V put(K key, V value) { + @Override + public V put(K key, V value) { if (key == null) { throw new NullPointerException("key == null"); } + if (value == null && !allowNullValues) { + throw new NullPointerException("value == null"); + } Node created = find(key, true); V result = created.value; created.value = value; return result; } - @Override public void clear() { + @Override + public void clear() { root = null; size = 0; modCount++; @@ -110,7 +135,8 @@ public LinkedTreeMap(Comparator comparator) { header.next = header.prev = header; } - @Override public V remove(Object key) { + @Override + public V remove(Object key) { Node node = removeInternalByKey(key); return node != null ? node.value : null; } @@ -118,8 +144,7 @@ public LinkedTreeMap(Comparator comparator) { /** * Returns the node at or adjacent to the given key, creating it if requested. * - * @throws ClassCastException if {@code key} and the tree's keys aren't - * mutually comparable. + * @throws ClassCastException if {@code key} and the tree's keys aren't mutually comparable. */ Node find(K key, boolean create) { Comparator comparator = this.comparator; @@ -129,12 +154,12 @@ Node find(K key, boolean create) { if (nearest != null) { // Micro-optimization: avoid polymorphic calls to Comparator.compare(). @SuppressWarnings("unchecked") // Throws a ClassCastException below if there's trouble. - Comparable comparableKey = (comparator == NATURAL_ORDER) - ? (Comparable) key - : null; + Comparable comparableKey = + (comparator == NATURAL_ORDER) ? (Comparable) key : null; while (true) { - comparison = (comparableKey != null) + comparison = + (comparableKey != null) ? comparableKey.compareTo(nearest.key) : comparator.compare(key, nearest.key); @@ -166,10 +191,10 @@ Node find(K key, boolean create) { if (comparator == NATURAL_ORDER && !(key instanceof Comparable)) { throw new ClassCastException(key.getClass().getName() + " is not Comparable"); } - created = new Node(nearest, key, header, header.prev); + created = new Node<>(allowNullValues, nearest, key, header, header.prev); root = created; } else { - created = new Node(nearest, key, header, header.prev); + created = new Node<>(allowNullValues, nearest, key, header, header.prev); if (comparison < 0) { // nearest.key is higher nearest.left = created; } else { // comparison > 0, nearest.key is lower @@ -193,13 +218,12 @@ Node findByObject(Object key) { } /** - * Returns this map's entry that has the same key and value as {@code - * entry}, or null if this map has no such entry. + * Returns this map's entry that has the same key and value as {@code entry}, or null if this map + * has no such entry. * - *

This method uses the comparator for key equality rather than {@code - * equals}. If this map's comparator isn't consistent with equals (such as - * {@code String.CASE_INSENSITIVE_ORDER}), then {@code remove()} and {@code - * contains()} will violate the collections API. + *

This method uses the comparator for key equality rather than {@code equals}. If this map's + * comparator isn't consistent with equals (such as {@code String.CASE_INSENSITIVE_ORDER}), then + * {@code remove()} and {@code contains()} will violate the collections API. */ Node findByEntry(Entry entry) { Node mine = findByObject(entry.getKey()); @@ -207,13 +231,12 @@ Node findByEntry(Entry entry) { return valuesEqual ? mine : null; } - private boolean equal(Object a, Object b) { - return a == b || (a != null && a.equals(b)); + private static boolean equal(Object a, Object b) { + return Objects.equals(a, b); } /** - * Removes {@code node} from this tree, rearranging the tree's structure as - * necessary. + * Removes {@code node} from this tree, rearranging the tree's structure as necessary. * * @param unlink true to also unlink this node from the iteration linked list. */ @@ -284,6 +307,7 @@ Node removeInternalByKey(Object key) { return node; } + @SuppressWarnings("ReferenceEquality") private void replaceInParent(Node node, Node replacement) { Node parent = node.parent; node.parent = null; @@ -295,7 +319,7 @@ private void replaceInParent(Node node, Node replacement) { if (parent.left == node) { parent.left = replacement; } else { - assert (parent.right == node); + assert parent.right == node; parent.right = replacement; } } else { @@ -304,11 +328,10 @@ private void replaceInParent(Node node, Node replacement) { } /** - * Rebalances the tree by making any AVL rotations necessary between the - * newly-unbalanced node and the tree's root. + * Rebalances the tree by making any AVL rotations necessary between the newly-unbalanced node and + * the tree's root. * - * @param insert true if the node was unbalanced by an insert; false if it - * was by a removal. + * @param insert true if the node was unbalanced by an insert; false if it was by a removal. */ private void rebalance(Node unbalanced, boolean insert) { for (Node node = unbalanced; node != null; node = node.parent) { @@ -392,10 +415,9 @@ private void rotateLeft(Node root) { root.parent = pivot; // fix heights - root.height = Math.max(left != null ? left.height : 0, - pivotLeft != null ? pivotLeft.height : 0) + 1; - pivot.height = Math.max(root.height, - pivotRight != null ? pivotRight.height : 0) + 1; + root.height = + Math.max(left != null ? left.height : 0, pivotLeft != null ? pivotLeft.height : 0) + 1; + pivot.height = Math.max(root.height, pivotRight != null ? pivotRight.height : 0) + 1; } /** @@ -420,23 +442,30 @@ private void rotateRight(Node root) { root.parent = pivot; // fixup heights - root.height = Math.max(right != null ? right.height : 0, - pivotRight != null ? pivotRight.height : 0) + 1; - pivot.height = Math.max(root.height, - pivotLeft != null ? pivotLeft.height : 0) + 1; + root.height = + Math.max(right != null ? right.height : 0, pivotRight != null ? pivotRight.height : 0) + 1; + pivot.height = Math.max(root.height, pivotLeft != null ? pivotLeft.height : 0) + 1; } private EntrySet entrySet; private KeySet keySet; - @Override public Set> entrySet() { + @Override + public Set> entrySet() { EntrySet result = entrySet; - return result != null ? result : (entrySet = new EntrySet()); + if (result == null) { + result = entrySet = new EntrySet(); + } + return result; } - @Override public Set keySet() { + @Override + public Set keySet() { KeySet result = keySet; - return result != null ? result : (keySet = new KeySet()); + if (result == null) { + result = keySet = new KeySet(); + } + return result; } static final class Node implements Entry { @@ -446,19 +475,26 @@ static final class Node implements Entry { Node next; Node prev; final K key; + final boolean allowNullValue; V value; int height; - /** Create the header entry */ - Node() { + /** + * Create the header entry + */ + Node(boolean allowNullValue) { key = null; + this.allowNullValue = allowNullValue; next = prev = this; } - /** Create a regular entry */ - Node(Node parent, K key, Node next, Node prev) { + /** + * Create a regular entry + */ + Node(boolean allowNullValue, Node parent, K key, Node next, Node prev) { this.parent = parent; this.key = key; + this.allowNullValue = allowNullValue; this.height = 1; this.next = next; this.prev = prev; @@ -466,36 +502,43 @@ static final class Node implements Entry { next.prev = this; } + @Override public K getKey() { return key; } + @Override public V getValue() { return value; } + @Override public V setValue(V value) { + if (value == null && !allowNullValue) { + throw new NullPointerException("value == null"); + } V oldValue = this.value; this.value = value; return oldValue; } - @SuppressWarnings("rawtypes") - @Override public boolean equals(Object o) { + @Override + public boolean equals(Object o) { if (o instanceof Entry) { - Entry other = (Entry) o; + Entry other = (Entry) o; return (key == null ? other.getKey() == null : key.equals(other.getKey())) - && (value == null ? other.getValue() == null : value.equals(other.getValue())); + && (value == null ? other.getValue() == null : value.equals(other.getValue())); } return false; } - @Override public int hashCode() { - return (key == null ? 0 : key.hashCode()) - ^ (value == null ? 0 : value.hashCode()); + @Override + public int hashCode() { + return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode()); } - @Override public String toString() { + @Override + public String toString() { return key + "=" + value; } @@ -534,10 +577,13 @@ private abstract class LinkedTreeMapIterator implements Iterator { LinkedTreeMapIterator() { } + @Override + @SuppressWarnings("ReferenceEquality") public final boolean hasNext() { return next != header; } + @SuppressWarnings("ReferenceEquality") final Node nextNode() { Node e = next; if (e == header) { @@ -547,9 +593,11 @@ final Node nextNode() { throw new ConcurrentModificationException(); } next = e.next; - return lastReturned = e; + lastReturned = e; + return e; } + @Override public final void remove() { if (lastReturned == null) { throw new IllegalStateException(); @@ -561,23 +609,28 @@ public final void remove() { } class EntrySet extends AbstractSet> { - @Override public int size() { + @Override + public int size() { return size; } - @Override public Iterator> iterator() { + @Override + public Iterator> iterator() { return new LinkedTreeMapIterator>() { + @Override public Entry next() { return nextNode(); } }; } - @Override public boolean contains(Object o) { + @Override + public boolean contains(Object o) { return o instanceof Entry && findByEntry((Entry) o) != null; } - @Override public boolean remove(Object o) { + @Override + public boolean remove(Object o) { if (!(o instanceof Entry)) { return false; } @@ -590,51 +643,59 @@ public Entry next() { return true; } - @Override public void clear() { + @Override + public void clear() { LinkedTreeMap.this.clear(); } } final class KeySet extends AbstractSet { - @Override public int size() { + @Override + public int size() { return size; } - @Override public Iterator iterator() { + @Override + public Iterator iterator() { return new LinkedTreeMapIterator() { + @Override public K next() { return nextNode().key; } }; } - @Override public boolean contains(Object o) { + @Override + public boolean contains(Object o) { return containsKey(o); } - @Override public boolean remove(Object key) { + @Override + public boolean remove(Object key) { return removeInternalByKey(key) != null; } - @Override public void clear() { + @Override + public void clear() { LinkedTreeMap.this.clear(); } } /** - * If somebody is unlucky enough to have to serialize one of these, serialize - * it as a LinkedHashMap so that they won't need Gson on the other side to - * deserialize it. Using serialization defeats our DoS defence, so most apps - * shouldn't use it. - * @return LinkedHashMap - * @throws ObjectStreamException Stream exception + * If somebody is unlucky enough to have to serialize one of these, serialize it as a + * LinkedHashMap so that they won't need Gson on the other side to deserialize it. Using + * serialization defeats our DoS defence, so most apps shouldn't use it. + * + * @return Object + * @throws ObjectStreamException If an I/O error occurs */ private Object writeReplace() throws ObjectStreamException { - return new LinkedHashMap(this); + return new LinkedHashMap<>(this); } private void readObject(ObjectInputStream in) throws IOException { - // Don't permit directly deserializing this class; writeReplace() should have written a replacement + // Don't permit directly deserializing this class; writeReplace() should have written a + // replacement throw new InvalidObjectException("Deserialization is unsupported"); } -} \ No newline at end of file +} diff --git a/src/main/java/de/marhali/json5/internal/NonNullElementWrapperList.java b/src/main/java/de/marhali/json5/internal/NonNullElementWrapperList.java new file mode 100644 index 0000000..5c3def1 --- /dev/null +++ b/src/main/java/de/marhali/json5/internal/NonNullElementWrapperList.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.RandomAccess; + +/** + * {@link List} which wraps another {@code List} but prevents insertion of {@code null} elements. + * Methods which only perform checks with the element argument (e.g. {@link #contains(Object)}) do + * not throw exceptions for {@code null} arguments. + */ +public class NonNullElementWrapperList extends AbstractList implements RandomAccess { + // Explicitly specify ArrayList as type to guarantee that delegate implements RandomAccess + private final ArrayList delegate; + + @SuppressWarnings("NonApiType") + public NonNullElementWrapperList(ArrayList delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + @Override + public E get(int index) { + return delegate.get(index); + } + + @Override + public int size() { + return delegate.size(); + } + + private E nonNull(E element) { + if (element == null) { + throw new NullPointerException("Element must be non-null"); + } + return element; + } + + @Override + public E set(int index, E element) { + return delegate.set(index, nonNull(element)); + } + + @Override + public void add(int index, E element) { + delegate.add(index, nonNull(element)); + } + + @Override + public E remove(int index) { + return delegate.remove(index); + } + + /* The following methods are overridden because their default implementation is inefficient */ + + @Override + public void clear() { + delegate.clear(); + } + + @SuppressWarnings("UngroupedOverloads") // this is intentionally ungrouped, see comment above + @Override + public boolean remove(Object o) { + return delegate.remove(o); + } + + @Override + public boolean removeAll(Collection c) { + return delegate.removeAll(c); + } + + @Override + public boolean retainAll(Collection c) { + return delegate.retainAll(c); + } + + @Override + public boolean contains(Object o) { + return delegate.contains(o); + } + + @Override + public int indexOf(Object o) { + return delegate.indexOf(o); + } + + @Override + public int lastIndexOf(Object o) { + return delegate.lastIndexOf(o); + } + + @Override + public Object[] toArray() { + return delegate.toArray(); + } + + @Override + public T[] toArray(T[] a) { + return delegate.toArray(a); + } + + @Override + public boolean equals(Object o) { + return delegate.equals(o); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + // Maybe also delegate List#sort and List#spliterator in the future, but that + // requires Android API level 24 +} diff --git a/src/main/java/de/marhali/json5/internal/NumberLimits.java b/src/main/java/de/marhali/json5/internal/NumberLimits.java new file mode 100644 index 0000000..07a4f70 --- /dev/null +++ b/src/main/java/de/marhali/json5/internal/NumberLimits.java @@ -0,0 +1,37 @@ +package de.marhali.json5.internal; + +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * This class enforces limits on numbers parsed from Json5 to avoid potential performance problems + * when extremely large numbers are used. + */ +public class NumberLimits { + private NumberLimits() { + } + + private static final int MAX_NUMBER_STRING_LENGTH = 10_000; + + private static void checkNumberStringLength(String s) { + if (s.length() > MAX_NUMBER_STRING_LENGTH) { + throw new NumberFormatException("Number string too large: " + s.substring(0, 30) + "..."); + } + } + + public static BigDecimal parseBigDecimal(String s) throws NumberFormatException { + checkNumberStringLength(s); + BigDecimal decimal = new BigDecimal(s); + + // Cast to long to avoid issues with abs when value is Integer.MIN_VALUE + if (Math.abs((long) decimal.scale()) >= 10_000) { + throw new NumberFormatException("Number has unsupported scale: " + s); + } + return decimal; + } + + public static BigInteger parseBigInteger(String s) throws NumberFormatException { + checkNumberStringLength(s); + return new BigInteger(s); + } +} diff --git a/src/main/java/de/marhali/json5/internal/RadixNumber.java b/src/main/java/de/marhali/json5/internal/RadixNumber.java new file mode 100644 index 0000000..d633c21 --- /dev/null +++ b/src/main/java/de/marhali/json5/internal/RadixNumber.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import java.util.Objects; + +/** + * Simple wrapper around {@link Number} that tracks the used radix base. + * + * @author Marcel Haßlinger + */ +public class RadixNumber { + + /** + * Referenced number. + */ + private final Number number; + + /** + * Radix base to use. + *

+ * Supported values are: + *

    + *
  • Binary: 2
  • + *
  • Octal: 8
  • + *
  • Decimal: 10
  • + *
  • Hex: 16
  • + * + */ + private final int radix; + + public RadixNumber(Number number, int radix) { + this.number = number; + this.radix = radix; + } + + public Number getNumber() { + return number; + } + + public int getRadix() { + return radix; + } + + @Override + public String toString() { + return "RadixNumber{" + + "number=" + number + + ", radix=" + radix + + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + RadixNumber that = (RadixNumber) o; + return radix == that.radix && Objects.equals(number, that.number); + } + + @Override + public int hashCode() { + return Objects.hash(number, radix); + } +} diff --git a/src/main/java/de/marhali/json5/stream/Json5Lexer.java b/src/main/java/de/marhali/json5/stream/Json5Lexer.java index 98da822..8779d16 100644 --- a/src/main/java/de/marhali/json5/stream/Json5Lexer.java +++ b/src/main/java/de/marhali/json5/stream/Json5Lexer.java @@ -2,7 +2,7 @@ * MIT License * * Copyright (C) 2021 SyntaxError404 - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -26,45 +26,35 @@ package de.marhali.json5.stream; import de.marhali.json5.*; +import de.marhali.json5.config.DigitSeparatorStrategy; +import de.marhali.json5.config.Json5Options; import de.marhali.json5.exception.Json5Exception; +import de.marhali.json5.internal.RadixNumber; import java.io.BufferedReader; import java.io.Reader; import java.math.BigDecimal; import java.math.BigInteger; import java.util.Objects; -import java.util.regex.Pattern; /** * This is a lexer to convert the provided data into tokens according to the json5 specification. * The resulting tokens can then be used in an appropriate parser to construct * {@link Json5Object}'s and {@link Json5Array}'s. * - * @author Marcel Haßlinger * @author SyntaxError404 + * @author Marcel Haßlinger * @see Json5 Standard. */ public class Json5Lexer { - private static final Pattern PATTERN_BOOLEAN = Pattern.compile( - "true|false" - ); - - private static final Pattern PATTERN_NUMBER_FLOAT = Pattern.compile( - "[+-]?((0|[1-9]\\d*)(\\.\\d*)?|\\.\\d+)([eE][+-]?\\d+)?" - ); - private static final Pattern PATTERN_NUMBER_INTEGER = Pattern.compile( - "[+-]?(0|[1-9]\\d*)" - ); - private static final Pattern PATTERN_NUMBER_HEX = Pattern.compile( - "[+-]?0[xX][0-9a-fA-F]+" - ); - private static final Pattern PATTERN_NUMBER_SPECIAL = Pattern.compile( - "[+-]?(Infinity|NaN)" - ); - private final Reader reader; - private final Json5Options options; + protected final Json5Options options; + + /** + * whether we're currently parsing the root object/array + */ + protected boolean root; /** * whether the end of the file has been reached @@ -98,9 +88,15 @@ public class Json5Lexer { */ private char current; + /** + * Current comment. Can be null. + */ + private StringBuilder comment; + /** * Constructs a new lexer from a specific {@link Reader}. *

    Note: The reader must be closed after operation ({@link Reader#close()})!

    + * * @param reader a reader. * @param options the options for lexing. */ @@ -117,6 +113,24 @@ public Json5Lexer(Reader reader, Json5Options options) { previous = 0; current = 0; + comment = null; + + root = true; + } + + /** + * Returns the last comment that was read and clears it. + * + * @return The captured comment content, or null if no comment was found. + */ + public String consumeComment() { + if (comment == null) { + return null; + } + + String result = comment.toString(); + comment = null; + return result; } private boolean more() { @@ -226,22 +240,66 @@ private boolean isDecimalDigit(char c) { } private void nextMultiLineComment() { + if (comment == null) { + comment = new StringBuilder(); + } else if (comment.length() != 0) { + comment.append('\n'); + } + + long offset = character; + while (true) { char n = next(); + if (n == 0) { + throw syntaxError("Unterminated multi-line comment"); + } + if (n == '*' && peek() == '/') { next(); + if (isLineTerminator(comment.charAt(comment.length() - 1))) { + // Remove trailing line-terminators + comment.deleteCharAt(comment.length() - 1); + } return; } + + if (comment.length() == 0 && isLineTerminator(n)) { + // Skip heading line-terminators + continue; + } else if (character == offset + 1 && isWhitespace(n)) { + // Skip stylistic whitespace between comment indicator and actual comment content + continue; + } else if (character <= offset && (isWhitespace(n) || n == '*') && !isLineTerminator(n)) { + // Skip whitespace offset until comments start in a new-line + continue; + } + + comment.append(n); } } private void nextSingleLineComment() { + if (comment == null) { + comment = new StringBuilder(); + } else if (comment.length() != 0) { + comment.append('\n'); + } + + long offset = character; + while (true) { char n = next(); if (isLineTerminator(n) || n == 0) return; + + if (character == offset + 1 && isWhitespace(n)) { + // Skip stylistic whitespace between comment indicator and actual comment content + continue; + } + + comment.append(n); } } @@ -253,12 +311,8 @@ private void nextSingleLineComment() { */ public char nextClean() { while (true) { - if (!more()) { - if(index == -1) { // Empty stream - return 0; - } - throw syntaxError("Unexpected end of data"); - } + if (!more()) + return 0; char n = next(); @@ -281,11 +335,11 @@ private String nextCleanTo(String delimiters) { StringBuilder result = new StringBuilder(); while (true) { - if (!more()) - throw syntaxError("Unexpected end of data"); - char n = nextClean(); + if (n == 0) + return null; + if (delimiters.indexOf(n) > -1 || isWhitespace(n)) { back(); break; @@ -297,54 +351,45 @@ private String nextCleanTo(String delimiters) { return result.toString(); } - private int dehex(char c) { - if (c >= '0' && c <= '9') - return c - '0'; - - if (c >= 'a' && c <= 'f') - return c - 'a' + 0xA; - - if (c >= 'A' && c <= 'F') - return c - 'A' + 0xA; - - return -1; - } + private char[] unicodeEscape(boolean member, boolean part, boolean utf32) { + if (utf32 && !options.isAllowLongUnicodeEscapes()) + throw syntaxError("Long unicode escape sequences are not allowed"); - private char unicodeEscape(boolean member, boolean part) { String where = member ? "key" : "string"; + String escChar = utf32 ? "U" : "u"; String value = ""; int codepoint = 0; - for (int i = 0; i < 4; ++i) { + int numDigits = utf32 ? 8 : 4; + + for (int i = 0; i < numDigits; ++i) { char n = next(); value += n; int hex = dehex(n); if (hex == -1) - throw syntaxError("Illegal unicode escape sequence '\\u" + value + "' in " + where); + throw syntaxError("Illegal unicode escape sequence '\\" + escChar + value + "' in " + where); - codepoint |= hex << ((3 - i) << 2); + codepoint |= hex << ((numDigits - i - 1) << 2); } if (member && !isMemberNameChar((char) codepoint, part)) - throw syntaxError("Illegal unicode escape sequence '\\u" + value + "' in key"); + throw syntaxError("Illegal unicode escape sequence '\\" + escChar + value + "' in key"); - return (char) codepoint; + return Character.toChars(codepoint); } private void checkSurrogate(char hi, char lo) { if (options.isAllowInvalidSurrogates()) return; - if (!Character.isHighSurrogate(hi) || !Character.isLowSurrogate(lo)) - return; - - if (!Character.isSurrogatePair(hi, lo)) + if ((Character.isHighSurrogate(hi) && !Character.isSurrogate(lo)) || + (!Character.isSurrogate(hi) && Character.isLowSurrogate(lo))) throw syntaxError(String.format( - "Invalid surrogate pair: U+%04X and U+%04X", - hi, lo + "Invalid surrogate pair: U+%04X and U+%04X", + (int) hi, (int) lo )); } @@ -360,13 +405,15 @@ private String nextString(char quote) { while (true) { if (!more()) - throw syntaxError("Unexpected end of data"); + throw syntaxError("Expected '" + quote + "' to close string, got EOF instead"); prev = n; n = next(); - if (n == quote) + if (n == quote) { + checkSurrogate(prev, (char) 0); break; + } if (isLineTerminator(n) && n != 0x2028 && n != 0x2029) throw syntaxError("Unescaped line terminator in string"); @@ -381,6 +428,9 @@ private String nextString(char quote) { // escaped line terminator/ line continuation continue; } else switch (n) { + case 0: + throw syntaxError("Expected escape sequence in string, got EOF instead"); + case '\'': case '"': case '\\': @@ -420,6 +470,10 @@ private String nextString(char quote) { for (int i = 0; i < 2; ++i) { n = next(); + + if (n == 0) + throw syntaxError("Expected hexadecimal digit for hexadecimal escape sequence in string, got EOF instead"); + value += n; int hex = dehex(n); @@ -433,8 +487,18 @@ private String nextString(char quote) { n = (char) codepoint; break; - case 'u': // Unicode escape sequence - n = unicodeEscape(false, false); + case 'u': // Unicode escape sequence (16-bit) + case 'U': // Unicode escape sequence (32-bit) + char[] chars = unicodeEscape(false, false, n == 'U'); + + if (chars.length == 2) { + checkSurrogate(prev, chars[0]); + prev = chars[0]; + n = chars[1]; + + result.append(prev); + } else n = chars[0]; + break; default: @@ -492,16 +556,16 @@ public String nextMemberName() { char prev; char n = next(); + if (n == 0) + throw syntaxError("Expected key, got EOF instead"); + if (n == '"' || n == '\'') return nextString(n); back(); n = 0; - while (true) { - if (!more()) - throw syntaxError("Unexpected end of data"); - + do { boolean part = result.length() > 0; prev = n; @@ -510,22 +574,34 @@ public String nextMemberName() { if (n == '\\') { // unicode escape sequence n = next(); - if (n != 'u') + if (n == 0) + throw syntaxError("Expected escape sequence in key, got EOF instead"); + + if (n != 'u' && n != 'U') throw syntaxError("Illegal escape sequence '\\" + n + "' in key"); - n = unicodeEscape(true, part); + char[] chars = unicodeEscape(true, part, n == 'U'); + + if (chars.length == 2) { + checkSurrogate(prev, chars[0]); + prev = chars[0]; + n = chars[1]; + + result.append(prev); + } else n = chars[0]; } else if (!isMemberNameChar(n, part)) { back(); + checkSurrogate(prev, (char) 0); break; } checkSurrogate(prev, n); result.append(n); - } + } while (more()); if (result.length() == 0) - throw syntaxError("Empty key"); + throw syntaxError("Expected key"); return result.toString(); } @@ -534,102 +610,430 @@ public String nextMemberName() { * Reads a value from the source according to the * JSON5 Specification * - * @return an member name + * @return a {@link Json5Element} value */ public Json5Element nextValue() { char n = nextClean(); + boolean wasRoot = root; - switch (n) { - case '"': - case '\'': - String string = nextString(n); - return new Json5String(string); - case '{': - back(); - return Json5Parser.parseObject(this); - case '[': - back(); - return Json5Parser.parseArray(this); + try { + switch (n) { + case '"': + case '\'': + return Json5Primitive.fromString(nextString(n)); + case '{': + back(); + root = false; + return Json5Parser.parseObject(this); + case '[': + back(); + root = false; + return Json5Parser.parseArray(this); + } + } finally { + root = wasRoot; } back(); String string = nextCleanTo(",]}"); + if (string == null) + throw syntaxError("Expected value, got EOF instead"); + if (string.equals("null")) - return Json5Null.INSTANCE; + return Json5Primitive.fromNull(); + + if (string.equals("true")) + return Json5Primitive.fromBoolean(true); + + if (string.equals("false")) + return Json5Primitive.fromBoolean(false); + + if (!string.isEmpty()) { + char leading = string.charAt(0); + String rest = string; - if (PATTERN_BOOLEAN.matcher(string).matches()) - return new Json5Boolean(string.equals("true")); + double sign = 1; - if (PATTERN_NUMBER_INTEGER.matcher(string).matches()) { - BigInteger bigint = new BigInteger(string); - return new Json5Number(bigint); + if (leading == '+') { + rest = string.substring(1); + } else if (leading == '-') { + rest = string.substring(1); + sign = -1; + } + + if (rest.equals("Infinity")) { + if (!options.isAllowInfinity()) + throw syntaxError("Infinity is not allowed"); + + return Json5Primitive.fromNumber(Math.copySign(Double.POSITIVE_INFINITY, sign)); + } + + if (rest.equals("NaN")) { + if (!options.isAllowNaN()) + throw syntaxError("NaN is not allowed"); + + return Json5Primitive.fromNumber(Math.copySign(Double.NaN, sign)); + } + + if (!rest.isEmpty()) { + leading = rest.charAt(0); + + if ((leading >= '0' && leading <= '9') || leading == '.') { + RadixNumber parsedNum = parseNumber(leading, rest); + Number num = parsedNum.getNumber(); + int radix = parsedNum.getRadix(); + + if (sign < 0) { + if (num instanceof BigInteger) + return Json5Primitive.fromNumber(((BigInteger) num).negate(), radix); + + if (num instanceof BigDecimal) + return Json5Primitive.fromNumber(((BigDecimal) num).negate(), radix); + } + + return Json5Primitive.fromNumber(num, radix); + } + } } - if (PATTERN_NUMBER_FLOAT.matcher(string).matches()) - return new Json5Number(new BigDecimal(string)); + throw new Json5Exception("Illegal value '" + string + "'"); + } - if (PATTERN_NUMBER_SPECIAL.matcher(string).matches()) { - String special; + private RadixNumber parseNumber(char leading, String input) { + BigInteger intValue = BigInteger.ZERO; + + int n = input.length(); + boolean floating = false; + boolean hex = false; + int off = 0; + char c = 0; + + if (leading == '0') { + if (n == 1) + return new RadixNumber(intValue, 10); + + /************ + * PREFIXES * + ************/ + switch (c = input.charAt(1)) { + /********** + * BINARY * + **********/ + case 'b': + case 'B': + if (!options.isAllowBinaryLiterals()) + throw syntaxError("Binary literals are not allowed"); + + off = 2; + + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (off == 3 || off >= n || !isbin(input.charAt(off))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } - int factor; - double d = 0; + if (!isbin(c)) + throw syntaxError("Expected binary digit for literal"); - switch (string.charAt(0)) { // +, -, or 0 - case '+': - special = string.substring(1); // + - factor = 1; - break; + intValue = intValue.shiftLeft(1); + + if (c == '1') + intValue = intValue.setBit(0); + } + + if (off == 2) + throw syntaxError("Expected binary digit after '0b'"); + + return new RadixNumber(intValue, 2); + + /********* + * OCTAL * + *********/ + case 'o': + case 'O': + if (!options.isAllowOctalLiterals()) + throw syntaxError("Octal literals are not allowed"); + + off = 2; + + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (off == 3 || off >= n || !isoct(input.charAt(off))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } + + if (!isoct(c)) + throw syntaxError("Expected octal digit for literal"); + + intValue = intValue.shiftLeft(3); + + if (c != '0') + intValue = intValue.or(BigInteger.valueOf(c - '0')); + } + + if (off == 2) + throw syntaxError("Expected octal digit after '0o'"); + + return new RadixNumber(intValue, 8); + + + /*************** + * HEXADECIMAL * + ***************/ + case 'x': + case 'X': + off = 2; + hex = true; + + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (off == 3 || off >= n || !ishex(input.charAt(off))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } + + if (c == '.' || c == 'p' || c == 'P') { + if (!options.isAllowHexFloatingLiterals()) + throw syntaxError("Hexadecimal floating-point literals are not allowed"); + + floating = true; + break; + } + + if (!ishex(c)) + throw syntaxError("Expected hexadecimal digit for literal"); + + intValue = intValue.shiftLeft(4); + + if (c != '0') + intValue = intValue.or(BigInteger.valueOf(dehex(c))); + } + + if (off == 2) + throw syntaxError("Expected hexadecimal digit after '0x'"); + + if (!floating) + return new RadixNumber(intValue, 16); - case '-': - special = string.substring(1); // - - factor = -1; break; default: - special = string; - factor = 1; break; } + } - switch (special) { - case "NaN": - d = Double.NaN; - break; - case "Infinity": - d = Double.POSITIVE_INFINITY; + StringBuilder num = new StringBuilder(); + + if (!hex) { + /*********** + * DECIMAL * + ***********/ + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (num.length() == 0 || off >= n || !isDecimalDigit(input.charAt(off))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } + + if (c == '.' || c == 'e' || c == 'E') { + floating = true; break; + } + + if (!isDecimalDigit(c)) + throw syntaxError("Expected decimal digit for literal"); + + num.append(c); + } + + if (off >= n) { + if (options.getDigitSeparatorStrategy() == DigitSeparatorStrategy.JAVA_STYLE) + input = input.replace("_", ""); + + if (options.getDigitSeparatorStrategy() == DigitSeparatorStrategy.C_STYLE) + input = input.replace("'", ""); + + return new RadixNumber(new BigInteger(input), 10); + } + } + + BigInteger fractionInt = BigInteger.ZERO; + int numFracDigits = 0; + + if (c == '.') { + /************ + * FRACTION * + ************/ + if (!hex) + num.append('.'); + + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (numFracDigits == 0 || off >= n) + throw syntaxError("Illegal position for digit separator"); + + c = input.charAt(off); + + if ((!hex && !isDecimalDigit(c)) || (hex && !ishex(c))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } + + if (hex) { + if (c == 'p' || c == 'P') + break; + + if (!ishex(c)) + throw syntaxError("Expected hexadecimal digit for literal"); + + fractionInt = fractionInt.shiftLeft(4); + + if (c != '0') + fractionInt = fractionInt.or(BigInteger.valueOf(dehex(c))); + } else { + if (c == 'e' || c == 'E') + break; + + if (!isDecimalDigit(c)) + throw syntaxError("Expected decimal digit for literal"); + + num.append(c); + } + + ++numFracDigits; } - return new Json5Number(factor * d); + if (off >= n && !hex) + return new RadixNumber(new BigDecimal(num.toString()), 10); } - if (PATTERN_NUMBER_HEX.matcher(string).matches()) { - return new Json5Hexadecimal(string); + /************ + * EXPONENT * + ************/ + if (hex && c != 'p' && c != 'P') + throw syntaxError("Expected exponent for hexadecimal floating-point literal"); + + if (!hex) + num.append('e'); + + int numExpDigits = 0; + + if (++off >= n) + throw syntaxError("Expected digit sequence for exponent"); + + c = input.charAt(off); + + if (c == '+' || c == '-') { + num.append(c); + ++off; } - throw new Json5Exception("Illegal value '" + string + "'"); + while (off < n) { + c = input.charAt(off++); + + if (checkDigitSeparator(c)) { + if (numExpDigits == 0 || off >= n || !isDecimalDigit(input.charAt(off))) + throw syntaxError("Illegal position for digit separator"); + + continue; + } + + if (!isDecimalDigit(c)) + throw syntaxError("Expected decimal digit for exponent"); + + num.append(c); + ++numExpDigits; + } + + if (numExpDigits == 0) + throw syntaxError("Expected digit sequence for exponent"); + + if (!hex) + return new RadixNumber(new BigDecimal(num.toString()), 10); + + /****************************** + * HEXADECIMAL FLOATING-POINT * + ******************************/ + BigInteger exponent = new BigInteger(num.toString()); + BigDecimal value = new BigDecimal(intValue); + + BigDecimal two = BigDecimal.valueOf(2); + BigDecimal frac = BigDecimal.valueOf(.5); + + for (int i = (4 * numFracDigits) - 1; i >= 0; --i) { + if (fractionInt.testBit(i)) { + value = value.add(frac); + } + + frac = frac.divide(two); + } + + BigDecimal scale; + + try { + scale = new BigDecimal(BigInteger.TWO.pow(exponent.intValueExact())); + } catch (Exception e) { + throw syntaxError("Hexadecimal floating-point literal's exponent is too large"); + } + + return new RadixNumber(value.multiply(scale), 16); + } + + private boolean checkDigitSeparator(char c) { + if (c == '_') { + if (options.getDigitSeparatorStrategy() != DigitSeparatorStrategy.JAVA_STYLE) + throw syntaxError("Java-style digit separators are not allowed"); + + return true; + } + + if (c == '\'') { + if (options.getDigitSeparatorStrategy() != DigitSeparatorStrategy.C_STYLE) + throw syntaxError("C-style digit separators are not allowed"); + + return true; + } + + return false; } /** - * Constructs a new JSONException with a detail message and a causing exception + * Constructs a new {@link Json5Exception} with a detail message and a causing exception * * @param message the detail message * @param cause the causing exception - * @return a JSONException + * @return a {@link Json5Exception} */ - protected Json5Exception syntaxError(String message, Throwable cause) { + public Json5Exception syntaxError(String message, Throwable cause) { return new Json5Exception(message + this, cause); } /** - * Constructs a new JSONException with a detail message + * Constructs a new {@link Json5Exception} with a detail message * * @param message the detail message - * @return a JSONException + * @return a {@link Json5Exception} */ - protected Json5Exception syntaxError(String message) { + public Json5Exception syntaxError(String message) { return new Json5Exception(message + this); } @@ -637,4 +1041,63 @@ protected Json5Exception syntaxError(String message) { public String toString() { return " at index " + index + " [character " + character + " in line " + line + "]"; } + + private static int dehex(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + + if (c >= 'a' && c <= 'f') + return c - 'a' + 0xA; + + if (c >= 'A' && c <= 'F') + return c - 'A' + 0xA; + + return -1; + } + + private static boolean isbin(char c) { + return c == '0' || c == '1'; + } + + private static boolean isoct(char c) { + return c >= '0' && c <= '7'; + } + + private static boolean ishex(char c) { + return (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + } + + /** + * Converts a character into a string representation: + * + *
      + *
    • if {@code c == 0}, {@code "EOF"} is returned
    • + *
    • if {@code c} fulfills one of the following conditions, {@code "'x'"} + * is returned, where {@code x} is the value returned by {@link Character#toString(char)}: + *
        + *
      • if {@code c} is an extended ASCII character ({@code U+0001-U+00FF}), + * except {@link Character#isISOControl(char) control characters}
      • + *
      • if {@code c} is a {@link Character#isLetter(char) Unicode letter}
      • + *
      • if {@code c} is a {@link Character#isDigit(char) Unicode digit}
      • + *
      + *
    • + *
    • otherwise, {@code "U+XXXX"} is returned, where {@code XXXX} is the uppercase + * hexadecimal representation of {@code c}'s Unicode codepoint, padded with zeros + * ({@code 0}) to a length of 4 characters
    • + *
    + * + * @param c the character + * @return the string representation + */ + protected static String charToString(char c) { + if (c == 0) + return "EOF"; + + if ((c <= 0xFF && !Character.isISOControl(c)) || Character.isLetterOrDigit(c)) + return "'" + c + "'"; + + return String.format("U+%04X", (int) c); + } } diff --git a/src/main/java/de/marhali/json5/stream/Json5Parser.java b/src/main/java/de/marhali/json5/stream/Json5Parser.java index c7422ca..7b94b0b 100644 --- a/src/main/java/de/marhali/json5/stream/Json5Parser.java +++ b/src/main/java/de/marhali/json5/stream/Json5Parser.java @@ -28,9 +28,12 @@ import de.marhali.json5.Json5Array; import de.marhali.json5.Json5Element; import de.marhali.json5.Json5Object; +import de.marhali.json5.config.DuplicateKeyStrategy; import de.marhali.json5.exception.Json5Exception; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; /** * A parser to parse tokenized Json5 data into a parse tree of {@link Json5Element}'s. @@ -40,34 +43,49 @@ */ public final class Json5Parser { - private Json5Parser() {} + private Json5Parser() { + } /** * Parses the specified {@link Json5Lexer lexer} into a parse tree of {@link Json5Element}'s. * Thereby it does not matter if the provided root element is an array or object. + * * @param lexer Tokenized json5 data * @return a parse tree of {@link Json5Element}'s corresponding to the specified JSON5 or {@code null} if lexer does not provide any data */ public static Json5Element parse(Json5Lexer lexer) { Objects.requireNonNull(lexer); - switch (lexer.nextClean()) { + char control = lexer.nextClean(); + String comment = lexer.consumeComment(); + Json5Element element; + + switch (control) { case '{': lexer.back(); - return parseObject(lexer); + element = parseObject(lexer); + break; case '[': lexer.back(); - return parseArray(lexer); + element = parseArray(lexer); + break; case 0: return null; default: throw lexer.syntaxError("Unknown or unexpected control character"); } + + if (lexer.options.isParseComments() && comment != null) { + element.setComment(comment); + } + + return element; } /** * Parses the specified {@link Json5Lexer lexer} into a parse tree of an {@link Json5Object}. - * If the provided data does not correspond to a json object a {@link Json5Exception} will be thrown. + * If the provided data does not correspond to a {@link Json5Object} a {@link Json5Exception} will be thrown. + * * @param lexer Tokenized json5 data. * @return a parse tree of {@link Json5Object} corresponding to the specified JSON5. * @see #parse(Json5Lexer) @@ -75,51 +93,84 @@ public static Json5Element parse(Json5Lexer lexer) { public static Json5Object parseObject(Json5Lexer lexer) { Objects.requireNonNull(lexer); - if(lexer.nextClean() != '{') { - throw lexer.syntaxError("A json object must begin with '{'"); + if (lexer.nextClean() != '{') { + throw lexer.syntaxError("A Json5Object must begin with '{'"); } Json5Object object = new Json5Object(); + DuplicateKeyStrategy duplicateKeyStrategy = lexer.options.getDuplicateBehaviour(); + Set duplicates = new HashSet<>(); + char control; + String comment; String key; while (true) { control = lexer.nextClean(); + comment = lexer.consumeComment(); + switch (control) { case 0: - throw lexer.syntaxError("A json object must end with '}'"); + throw lexer.syntaxError("A Json5Object must end with '}'"); case '}': + if (lexer.root && !lexer.options.isAllowTrailingData() && lexer.nextClean() != 0) { + throw lexer.syntaxError("Trailing data after Json5Object"); + } return object; default: lexer.back(); key = lexer.nextMemberName(); } - if(object.has(key)) { - throw new Json5Exception("Duplicate key " + key); + boolean duplicate = object.has(key); + + if (duplicate && duplicateKeyStrategy == DuplicateKeyStrategy.UNIQUE) + throw lexer.syntaxError("Duplicate key " + Json5Writer.quote(key, lexer.options)); + + control = lexer.nextClean(); + + if (control != ':') + throw lexer.syntaxError("Expected ':' after a key, got " + Json5Lexer.charToString(control) + " instead"); + + Json5Element value = lexer.nextValue(); + + if (lexer.options.isParseComments() && comment != null) { + value.setComment(comment); } - if(lexer.nextClean() != ':') { - throw lexer.syntaxError("Expected ':' after a key, got '" + control + "' instead"); + if (duplicate && duplicateKeyStrategy == DuplicateKeyStrategy.DUPLICATE) { + Json5Array array; + + if (duplicates.contains(key)) + array = object.getAsJson5Array(key); + + else { + array = new Json5Array(); + array.add(object.get(key)); + duplicates.add(key); + } + + array.add(value); + value = array; } - object.add(key, lexer.nextValue()); + object.add(key, value); + control = lexer.nextClean(); - if(control == '}') { + if (control == '}') return object; - } - if(control != ',') { - throw lexer.syntaxError("Expected ',' or '}' after value, got '" + control + "' instead"); - } + if (control != ',') + throw lexer.syntaxError("Expected ',' or '}' after value, got " + Json5Lexer.charToString(control) + " instead"); } } /** * Parses the specified {@link Json5Lexer lexer} into a parse tree of an {@link Json5Array}. * If the provided data does not correspond to a json array a {@link Json5Exception} will be thrown. + * * @param lexer Tokenized json5 data. * @return a parse tree of {@link Json5Array} corresponding to the specified JSON5. * @see #parse(Json5Lexer) @@ -127,34 +178,45 @@ public static Json5Object parseObject(Json5Lexer lexer) { public static Json5Array parseArray(Json5Lexer lexer) { Objects.requireNonNull(lexer); - if(lexer.nextClean() != '[') { - throw lexer.syntaxError("A json array must begin with '['"); + if (lexer.nextClean() != '[') { + throw lexer.syntaxError("A Json5Array must begin with '['"); } Json5Array array = new Json5Array(); char control; + String comment; - while(true) { + while (true) { control = lexer.nextClean(); + comment = lexer.consumeComment(); + switch (control) { case 0: - throw lexer.syntaxError("A json array must end with ']'"); + throw lexer.syntaxError("A Json5Array must end with ']'"); case ']': + if (lexer.root && !lexer.options.isAllowTrailingData() && lexer.nextClean() != 0) { + throw lexer.syntaxError("Trailing data after Json5Array"); + } return array; default: lexer.back(); } - array.add(lexer.nextValue()); + Json5Element value = lexer.nextValue(); + + if (lexer.options.isParseComments() && comment != null) { + value.setComment(comment); + } + + array.add(value); + control = lexer.nextClean(); - if(control == ']') { + if (control == ']') return array; - } - if(control != ',') { - throw lexer.syntaxError("Expected ',' or ']' after value, got '" + control + "' instead"); - } + if (control != ',') + throw lexer.syntaxError("Expected ',' or ']' after value, got " + Json5Lexer.charToString(control) + " instead"); } } } diff --git a/src/main/java/de/marhali/json5/stream/Json5Writer.java b/src/main/java/de/marhali/json5/stream/Json5Writer.java index 7f2a2ae..d39a9f0 100644 --- a/src/main/java/de/marhali/json5/stream/Json5Writer.java +++ b/src/main/java/de/marhali/json5/stream/Json5Writer.java @@ -2,7 +2,8 @@ * MIT License * * Copyright (C) 2021 SyntaxError404 - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2024 Ultreon Team + * Copyright (C) 2022 - 2025 Marcel Haßlinger * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -26,6 +27,8 @@ package de.marhali.json5.stream; import de.marhali.json5.*; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.internal.EcmaScriptIdentifier; import java.io.IOException; import java.io.Writer; @@ -36,8 +39,9 @@ * Writes a tree of {@link Json5Element}'s into their * string literal representation by using a {@link Writer write} stream. * - * @author Marcel Haßlinger * @author SyntaxError404 + * @author Ultreon Team + * @author Marcel Haßlinger */ public final class Json5Writer { @@ -47,71 +51,136 @@ public final class Json5Writer { /** * Creates a new instance that writes a JSON5-encoded stream to {@code writer}. *

    Note: The writer must be closed after operation ({@link Writer#close()})!

    + * * @param options Parsing and serialization options - * @param writer Output stream. For best performance, use a {@link java.io.BufferedWriter} + * @param writer Output stream. For the best performance, use a {@link java.io.BufferedWriter}. */ public Json5Writer(Json5Options options, Writer writer) { - this.options = Objects.requireNonNull(options); - this.writer = Objects.requireNonNull(writer); + Objects.requireNonNull(options); + Objects.requireNonNull(writer); + + this.options = options; + this.writer = writer; } /** * Encodes and writes the provided {@link Json5Element} into json5 according to the specification * and the configured options. The element can be any json5 element. All child trees will be included. + * This function writes with depth {@code 0} and expects a root element. + * For nested elements, see the other write methods. + * * @param element Element to encode * @throws IOException If an I/O error occurs * @see #Json5Writer(Json5Options, Writer) Configuration options - * @see #write(Json5Element, String) + * @see #write(Json5Element, int) */ public void write(Json5Element element) throws IOException { - write(element, ""); + write(element, 0); } /** * Encodes and writes the provided {@link Json5Element} into json5 according to the specification * and the configured options. The element can be any json5 element. All child trees will be included. + * * @param element Element to encode - * @param indent Indent to apply (for nested elements) + * @param depth Depth of the current Json5 tree. Root node is {@code 0}. Counts {@code +1} on every child element. * @throws IOException If an I/O error occurs * @see #Json5Writer(Json5Options, Writer) Configuration options - * @see #write(Json5Element) without indent + * @see #write(Json5Element) */ - public void write(Json5Element element, String indent) throws IOException { + public void write(Json5Element element, int depth) throws IOException { Objects.requireNonNull(element); - Objects.requireNonNull(indent); - if(element.isJson5Null()) { + if (element.isJson5Null()) { writeNull(); - } else if(element.isJson5Object()) { - writeObject(element.getAsJson5Object(), indent); - } else if(element.isJson5Array()) { - writeArray(element.getAsJson5Array(), indent); - } else if(element.isJson5Primitive()) { + } else if (element.isJson5Object()) { + writeObject(element.getAsJson5Object(), depth); + } else if (element.isJson5Array()) { + writeArray(element.getAsJson5Array(), depth); + } else if (element.isJson5Primitive()) { writePrimitive(element.getAsJson5Primitive()); } else { throw new UnsupportedOperationException("Unknown json element with type class " - + element.getClass().getName()); + + element.getClass().getName()); + } + + if (depth == 0 && options.isInsertFinalNewline()) { + writer.write('\n'); + } + } + + /** + * Writes any associated comments for the provided {@link Json5Element}. + * Checks if {@link Json5Options#isWriteComments()} is {@code true} and if the element has any comment assigned. + * + * @param element Element target + * @param depth Depth to use for writing + * @throws IOException If an I/O error occurs + */ + public void writeComment(Json5Element element, int depth) throws IOException { + if (!options.isWriteComments() || !element.hasComment()) { + return; + } + + String indent = depthToIndent(depth); + String comment = element.getComment(); + String[] lines = comment.split("\n"); + boolean multiLineComment = lines.length > 1; + + if (options.getIndentFactor() > 0) { + // pretty-printing + if (multiLineComment) { + writer.append(indent).append("/*\n"); + for (String line : lines) { + writer.append(indent).append(" * ").append(line).append("\n"); + } + writer.append(indent).append(" */\n"); + } else { + writer.append(indent).append("// ").append(comment).append("\n"); + } + } else { + // write in the shortest possible style + writer.append("/*"); + for (int i = 0; i < lines.length; i++) { + writer.append(lines[i]); + if (i != lines.length - 1) { + // Use whitespace for simulated break-lines + writer.append(" "); + } + } + writer.append("*/"); } } /** * Writes the equivalent of a {@link de.marhali.json5.Json5Null}({@code null}) value. + * * @throws IOException If an I/O error occurs. */ public void writeNull() throws IOException { - writer.write("null"); + writer.append("null"); } /** * Writes the provided primitive to the stream and encodes it if necessary. + * * @param primitive Primitive value. * @throws IOException If an I/O error occurs. */ public void writePrimitive(Json5Primitive primitive) throws IOException { Objects.requireNonNull(primitive); - if(primitive instanceof Json5String) { + if (primitive.isString()) { writer.append(quote(primitive.getAsString())); + } else if (primitive.isInstant()) { + var instant = primitive.getAsInstant(); + if (options.isStringifyUnixInstants()) { + writer.write(String.valueOf(instant.getEpochSecond())); + } else { + writer.write(quote(instant.toString())); + } + } else if (primitive.isNumber()) { + writer.append(formatNumberString(primitive.getAsString(), primitive.getNumberRadix())); } else { writer.append(primitive.getAsString()); } @@ -119,106 +188,191 @@ public void writePrimitive(Json5Primitive primitive) throws IOException { /** * Writes the provided {@link Json5Object} to the stream. + * * @param object Object to encode - * @param indent Indent to apply (for nested elements) + * @param depth Depth to use for writing * @throws IOException If an I/O error occurs. * @see #write(Json5Element) */ - public void writeObject(Json5Object object, String indent) throws IOException { + public void writeObject(Json5Object object, int depth) throws IOException { Objects.requireNonNull(object); - Objects.requireNonNull(indent); - String childIndent = indent + " ".repeat(options.getIndentFactor()); + if (depth == 0) { + // Root element comment + writeComment(object, depth); + } + + int childDepth = depth + 1; + String indent = depthToIndent(depth); + String childIndent = depthToIndent(childDepth); + + writer.append('{'); - writer.write("{"); + int index = -1; + for (Map.Entry entry : object.entrySet()) { + index++; - int index = 0; - for(Map.Entry entry : object.entrySet()) { - if(options.getIndentFactor() > 0) { - writer.append('\n').append(childIndent); + if (options.getIndentFactor() > 0) { + writer.append("\n"); } - writer.append(quote(entry.getKey())).append(":"); + writeComment(entry.getValue(), childDepth); + + if (options.getIndentFactor() > 0) + writer.append(childIndent); - if(options.getIndentFactor() > 0) { + writer.append(quoteKey(entry.getKey())) + .append(':'); + + if (options.getIndentFactor() > 0) writer.append(' '); - } - write(entry.getValue(), childIndent); + write(entry.getValue(), childDepth); - if(options.isTrailingComma() || index < object.size() - 1) { - writer.append(','); + if (options.isTrailingComma() || index < object.size() - 1) { + writer.append(","); } - - index++; } - if(options.getIndentFactor() > 0 && object.size() > 0) { + if (options.getIndentFactor() > 0) writer.append('\n').append(indent); - } writer.append('}'); } /** * Writes the provided {@link Json5Array} to the stream. + * * @param array Array to encode - * @param indent Indent to apply (for nested elements) + * @param depth Depth to use for writing * @throws IOException If an I/O error occurs. * @see #write(Json5Element) */ - public void writeArray(Json5Array array, String indent) throws IOException { + public void writeArray(Json5Array array, int depth) throws IOException { Objects.requireNonNull(array); - Objects.requireNonNull(indent); - String childIndent = indent + " ".repeat(options.getIndentFactor()); + if (depth == 0) { + // Root element comment + writeComment(array, depth); + } - writer.write('['); + int childDepth = depth + 1; + String indent = depthToIndent(depth); + String childIndent = depthToIndent(childDepth); - for(int i = 0; i < array.size(); i++) { - Json5Element currentElement = array.get(i); + writer.append('['); - if(options.getIndentFactor() > 0) { - writer.append('\n').append(childIndent); - } + int index = -1; + for (Json5Element value : array) { + index++; + + if (options.getIndentFactor() > 0) + writer.append('\n'); + + writeComment(value, childDepth); + + writer.append(childIndent); - write(currentElement, childIndent); + write(value, childDepth); - if(options.isTrailingComma() || i < array.size() - 1) { - writer.append(','); + if (options.isTrailingComma() || index < array.size() - 1) { + writer.append(","); } } - if(options.getIndentFactor() > 0 && !array.isEmpty()) { + if (options.getIndentFactor() > 0) writer.append('\n').append(indent); + + writer.append(']'); + } + + private String applyNumberSeparator(String numberString, int numberRadix, char separator) { + StringBuilder sb = new StringBuilder(numberString); + + boolean hasSign = numberString.startsWith("+") || numberString.startsWith("-"); + int offset, separatorOffset; + + switch (numberRadix) { + case 2: + offset = hasSign ? 3 : 2; + separatorOffset = 4; + break; + case 8: + offset = hasSign ? 3 : 2; + separatorOffset = 3; + break; + case 10: + offset = 0; + separatorOffset = 3; + break; + case 16: + offset = hasSign ? 3 : 2; + separatorOffset = 2; + break; + default: + throw new IllegalArgumentException("Invalid number radix: " + numberRadix); } - writer.write(']'); + int len = sb.length(); + + for (int i = len - separatorOffset; i > offset; i -= separatorOffset) { + sb.insert(i, separator); + } + + return sb.toString(); + } + + public String formatNumberString(String numberString, int numberRadix) { + var strategy = options.getDigitSeparatorStrategy(); + switch (strategy) { + case JAVA_STYLE: + return applyNumberSeparator(numberString, numberRadix, '_'); + case C_STYLE: + return applyNumberSeparator(numberString, numberRadix, '\''); + default: + return numberString; + } + } + + public String quoteKey(String key) { + if (options.isQuoteless() && EcmaScriptIdentifier.isValid(key)) { + return key; + } else { + return quote(key); + } + } + + public String quote(String string) { + return quote(string, options); } /** * Quotes the provided string according to the json5 specification. + * * @param string String to quote - * @return Quoted string + * @return quoted string */ - public String quote(String string) { - final char qt = options.isQuoteSingle() ? '\'' : '"'; + static String quote(String string, Json5Options options) { + final char quote = options.isQuoteSingle() ? '\'' : '"'; - if(string == null || string.isEmpty()) { - return String.valueOf(qt).repeat(2); - } + if (string == null || string.isEmpty()) + return String.valueOf(quote).repeat(2); StringBuilder quoted = new StringBuilder(string.length() + 2); - quoted.append(qt); + boolean ascii = options.isStringifyAscii(); - for(char c : string.toCharArray()) { - if(c == qt) { + quoted.append(quote); + + for (int i = 0, n = string.length(); i < n; ++i) { + char c = string.charAt(i); + + if (c == quote) { quoted.append('\\'); quoted.append(c); continue; } - switch(c) { + switch (c) { case '\\': quoted.append("\\\\"); break; @@ -241,26 +395,42 @@ public String quote(String string) { quoted.append("\\v"); break; default: - // escape non-graphical characters (https://www.unicode.org/versions/Unicode13.0.0/ch02.pdf#G286941) - switch(Character.getType(c)) { - case Character.FORMAT: - case Character.LINE_SEPARATOR: - case Character.PARAGRAPH_SEPARATOR: - case Character.CONTROL: - case Character.PRIVATE_USE: - case Character.SURROGATE: - case Character.UNASSIGNED: - quoted.append("\\u"); - quoted.append(String.format("%04X", c)); - break; - default: - quoted.append(c); - break; - } + boolean unicode = false; + + if (!ascii) { + // escape non-graphical characters (https://www.unicode.org/versions/Unicode13.0.0/ch02.pdf#G286941) + switch (Character.getType(c)) { + case Character.FORMAT: + case Character.LINE_SEPARATOR: + case Character.PARAGRAPH_SEPARATOR: + case Character.CONTROL: + case Character.PRIVATE_USE: + case Character.SURROGATE: + case Character.UNASSIGNED: + unicode = true; + break; + default: + break; + } + } else unicode = c > 0x7F; + + if (unicode) { + quoted.append("\\u"); + quoted.append(String.format("%04X", (int) c)); + } else quoted.append(c); } } - quoted.append(qt); + quoted.append(quote); + return quoted.toString(); } -} \ No newline at end of file + + private String depthToIndent(int depth) { + if (options.getIndentFactor() > 0) { + return " ".repeat(depth * options.getIndentFactor()); + } + + return ""; + } +} diff --git a/src/test/java/de/marhali/json5/Json5ArrayTest.java b/src/test/java/de/marhali/json5/Json5ArrayTest.java new file mode 100644 index 0000000..5cab78e --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5ArrayTest.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2022 - 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5ArrayTest { + @Test + void constructors_and_capacity_validation() { + assertDoesNotThrow(() -> new Json5Array()); + assertDoesNotThrow(() -> new Json5Array(4)); + assertThrows(IllegalArgumentException.class, () -> new Json5Array(-1)); + } + + @Nested + class AddOverloadsAndNullConversions { + + @Test + void add_Instant_boolean_char_number_string_element_and_nulls() { + Json5Array arr = new Json5Array(); + + arr.add(Instant.EPOCH); + arr.add(true); + arr.add('X'); + arr.add(123); + arr.add("hi"); + + // nulls -> Json5Null + arr.add((Instant) null); + arr.add((Boolean) null); + arr.add((Character) null); + arr.add((Number) null); + arr.add((String) null); + arr.add((Json5Element) null); // generic add null + + assertEquals(11, arr.size()); + assertEquals(Json5Primitive.fromInstant(Instant.EPOCH), arr.get(0)); + assertEquals(Json5Primitive.fromBoolean(true), arr.get(1)); + assertEquals(Json5Primitive.fromCharacter('X'), arr.get(2)); + assertEquals(Json5Primitive.fromNumber(123), arr.get(3)); + assertEquals(Json5Primitive.fromString("hi"), arr.get(4)); + + assertEquals(Json5Primitive.fromNull(), arr.get(5)); + assertEquals(Json5Primitive.fromNull(), arr.get(6)); + assertEquals(Json5Primitive.fromNull(), arr.get(7)); + assertEquals(Json5Primitive.fromNull(), arr.get(8)); + assertEquals(Json5Primitive.fromNull(), arr.get(9)); + } + + @Test + void add_number_with_radix() { + Json5Array arr = new Json5Array(); + arr.add(255, 16); + assertEquals(Json5Primitive.fromNumber(255, 16), arr.get(0)); + } + + @Test + void addAll_appends_in_order() { + Json5Array a = new Json5Array(); + a.add(1); + a.add(2); + + Json5Array b = new Json5Array(); + b.add(3); + b.add(4); + + a.addAll(b); + assertEquals(4, a.size()); + assertEquals(Json5Primitive.fromNumber(1), a.get(0)); + assertEquals(Json5Primitive.fromNumber(2), a.get(1)); + assertEquals(Json5Primitive.fromNumber(3), a.get(2)); + assertEquals(Json5Primitive.fromNumber(4), a.get(3)); + } + } + + @Nested + class SetRemoveContainsAndGet { + + @Test + void set_replaces_and_returns_previous_converts_null() { + Json5Array arr = new Json5Array(); + arr.add("a"); + arr.add("b"); + + Json5Element previous = arr.set(1, Json5Primitive.fromNumber(7)); + assertEquals(Json5Primitive.fromString("b"), previous); + assertEquals(Json5Primitive.fromNumber(7), arr.get(1)); + + // set null -> Json5Null + previous = arr.set(0, null); + assertEquals(Json5Primitive.fromString("a"), previous); + assertEquals(Json5Primitive.fromNull(), arr.get(0)); + } + + @Test + void remove_by_element_and_index_and_contains() { + Json5Array arr = new Json5Array(); + Json5Element one = Json5Primitive.fromNumber(1); + arr.add(one); + arr.add(one.deepCopy()); // same value, other instance + arr.add("x"); + + assertTrue(arr.contains(Json5Primitive.fromNumber(1))); + assertEquals(3, arr.size()); + + // remove(element) remove first occurrence + boolean removed = arr.remove(Json5Primitive.fromNumber(1)); + assertTrue(removed); + assertEquals(2, arr.size()); + assertEquals(Json5Primitive.fromNumber(1), arr.get(0)); // second element stays + assertEquals(Json5Primitive.fromString("x"), arr.get(1)); + + // remove(index) + Json5Element rem = arr.remove(0); + assertEquals(Json5Primitive.fromNumber(1), rem); + assertEquals(1, arr.size()); + assertEquals(Json5Primitive.fromString("x"), arr.get(0)); + + // remove not existing + assertFalse(arr.remove(Json5Primitive.fromBoolean(true))); + } + + @Test + void get_and_bounds() { + Json5Array arr = new Json5Array(); + arr.add("a"); + assertEquals(Json5Primitive.fromString("a"), arr.get(0)); + assertThrows(IndexOutOfBoundsException.class, () -> arr.get(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> arr.get(1)); + assertThrows(IndexOutOfBoundsException.class, () -> arr.remove(1)); + assertThrows(IndexOutOfBoundsException.class, () -> arr.set(1, Json5Primitive.fromNull())); + } + + @Test + void size_and_isEmpty_and_iterator_order() { + Json5Array arr = new Json5Array(); + assertTrue(arr.isEmpty()); + assertEquals(0, arr.size()); + + arr.add(10); + arr.add(20); + arr.add(30); + + assertFalse(arr.isEmpty()); + assertEquals(3, arr.size()); + + Iterator it = arr.iterator(); + assertTrue(it.hasNext()); + assertEquals(Json5Primitive.fromNumber(10), it.next()); + assertEquals(Json5Primitive.fromNumber(20), it.next()); + assertEquals(Json5Primitive.fromNumber(30), it.next()); + assertFalse(it.hasNext()); + } + } + + @Nested + class SingleElementGetters { + + @Test + void getters_throw_if_not_singleton() { + Json5Array empty = new Json5Array(); + Json5Array multi = new Json5Array(); + multi.add(1); + multi.add(2); + + assertAll( + () -> assertThrows(IllegalStateException.class, empty::getAsBoolean), + () -> assertThrows(IllegalStateException.class, empty::getAsString), + () -> assertThrows(IllegalStateException.class, multi::getAsNumber), + () -> assertThrows(IllegalStateException.class, multi::getAsJson5Null) + ); + } + + @Test + void getters_delegate_when_singleton_primitive_number() { + Json5Primitive prim = Json5Primitive.fromNumber(42); + Json5Array arr = new Json5Array(); + arr.add(prim); + + assertEquals(42, arr.getAsInt()); + assertEquals(42L, arr.getAsLong()); + assertEquals(42.0, arr.getAsDouble()); + assertEquals((short) 42, arr.getAsShort()); + assertEquals((byte) 42, arr.getAsByte()); + assertEquals(42.0f, arr.getAsFloat()); + assertEquals(new BigInteger("42"), arr.getAsBigInteger()); + assertEquals(new BigDecimal("42"), arr.getAsBigDecimal()); + assertEquals("42", arr.getAsString()); + assertFalse(arr.getAsBoolean()); + } + + @Test + void getters_delegate_radix_and_null_and_boolean() { + Json5Array radix = new Json5Array(); + radix.add(255, 16); + assertEquals(radix.get(0).getAsHexString(), radix.getAsHexString()); + assertEquals(radix.get(0).getAsOctalString(), radix.getAsOctalString()); + assertEquals(radix.get(0).getAsBinaryString(), radix.getAsBinaryString()); + assertEquals(radix.get(0).getAsRadixNumber().toString(), + radix.getAsRadixNumber().toString()); + + Json5Array nul = new Json5Array(); + nul.add((String) null); + assertEquals(Json5Primitive.fromNull(), nul.getAsJson5Null()); + + Json5Array bool = new Json5Array(); + bool.add(true); + assertTrue(bool.getAsBoolean()); + } + } + + @Nested + class ListView { + + @Test + void asList_is_mutable_and_bidirectional_and_disallows_nulls() { + Json5Array arr = new Json5Array(); + arr.add("a"); + + List view = arr.asList(); + + view.add(Json5Primitive.fromNumber(7)); + assertEquals(2, arr.size()); + assertEquals(Json5Primitive.fromNumber(7), arr.get(1)); + + arr.add(false); + assertEquals(Json5Primitive.fromBoolean(false), view.get(2)); + + assertThrows(NullPointerException.class, () -> view.add(null)); + } + } + + @Nested + class DeepCopyEqualsHashCode { + + @Test + void deepCopy_is_deep_and_copies_comment() { + Json5Array original = new Json5Array(); + original.add(1); + Json5Array inner = new Json5Array(); + inner.add("x"); + original.add(inner); + original.setComment("note!"); + + Json5Array copy = original.deepCopy(); + + assertNotSame(original, copy); + assertNotSame(original.get(1), copy.get(1)); // inner array deep-copied + assertEquals(original, copy); + assertEquals(original.hashCode(), copy.hashCode()); + assertEquals("note!", copy.getComment()); + + // independent mutation + ((Json5Array) copy.get(1)).set(0, Json5Primitive.fromString("changed")); + assertNotEquals(original, copy); + } + + @Test + void equals_and_hashCode_contract() { + Json5Array a = new Json5Array(); + a.add(1); + a.add("x"); + + Json5Array b = new Json5Array(); + b.add(1); + b.add("x"); + + Json5Array c = new Json5Array(); + c.add(1); + c.add("x"); + + assertEquals(a, a); // reflexive + assertEquals(a, b); // symmetric + assertEquals(b, a); + assertEquals(b, c); // transitive + assertEquals(a, c); + + assertEquals(a.hashCode(), b.hashCode()); + assertEquals(a.hashCode(), c.hashCode()); + + Json5Array d = new Json5Array(); + d.add(2); + assertNotEquals(a, d); + } + } +} diff --git a/src/test/java/de/marhali/json5/Json5ElementTest.java b/src/test/java/de/marhali/json5/Json5ElementTest.java new file mode 100644 index 0000000..70cbfa6 --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5ElementTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2022 - 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5ElementTest { + @Test + void getAsJson5Array_throws_on_non_object() { + var ex = assertThrows(IllegalStateException.class, () -> Json5Primitive.fromString("anyString").getAsJson5Object()); + assertEquals("Not a Json5Object: \"anyString\"", ex.getMessage()); + } + + @Test + void getAsJson5Object_throws_on_non_array() { + var ex = assertThrows(IllegalStateException.class, () -> Json5Primitive.fromString("anyString").getAsJson5Array()); + assertEquals("Not a Json5Array: \"anyString\"", ex.getMessage()); + } + + @Test + void getAsJson5Primitive_throws_on_non_primitive() { + var ex = assertThrows(IllegalStateException.class, () -> new Json5Object().getAsJson5Primitive()); + assertEquals("Not a Json5Primitive: {\n}", ex.getMessage()); + } + + @Test + void getAsJson5Null_throws_on_null() { + var ex = assertThrows(IllegalStateException.class, () -> Json5Primitive.fromString("anyString").getAsJson5Null()); + assertEquals("Not a Json5Null: \"anyString\"", ex.getMessage()); + } + + @Test + void getAsBoolean_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsBoolean()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsInstant_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsInstant()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsNumber_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsNumber()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsRadixNumber_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsRadixNumber()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsString_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsString()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsDouble_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsDouble()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsFloat_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsFloat()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsLong_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsLong()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsInt_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsInt()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsByte_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsByte()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsBigDecimal_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsBigDecimal()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsBigInteger_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsBigInteger()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsShort_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsShort()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsBinaryString_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsBinaryString()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsOctalString_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsOctalString()); + assertEquals("Json5Object", ex.getMessage()); + } + + @Test + void getAsHexString_throws_on_non_boolean() { + var ex = assertThrows(UnsupportedOperationException.class, () -> new Json5Object().getAsHexString()); + assertEquals("Json5Object", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/Json5NullTest.java b/src/test/java/de/marhali/json5/Json5NullTest.java new file mode 100644 index 0000000..d78ec18 --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5NullTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import de.marhali.json5.fixtures.ToStringFixtures; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5NullTest { + @Test + @DisplayName("deepCopy(): it should just copy comment") + void test_deepCopy() { + Json5Null source = new Json5Null(); + String sourceComment = "my comment"; + source.setComment(sourceComment); + + Json5Element copy = source.deepCopy(); + String newComment = "new comment"; + source.setComment(newComment); + + assertTrue(copy.isJson5Null()); + assertEquals(sourceComment, copy.getComment()); + assertEquals(newComment, source.getComment()); + } + + @Test + @DisplayName("getAsString(): it should return null value") + void test_getAsString() { + var element = new Json5Null(); + assertEquals("null", element.getAsString()); + } + + @Test + void test_toString() { + var element = new Json5Null(); + assertEquals("null", element.toString(ToStringFixtures.OPTIONS)); + } +} diff --git a/src/test/java/de/marhali/json5/Json5ObjectTest.java b/src/test/java/de/marhali/json5/Json5ObjectTest.java new file mode 100644 index 0000000..eb0c704 --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5ObjectTest.java @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2022 - 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5ObjectTest { + + @Test + void add_and_get_and_has_and_size() { + Json5Object obj = new Json5Object(); + assertTrue(obj.isEmpty()); + assertEquals(0, obj.size()); + + obj.add("a", Json5Primitive.fromString("x")); + obj.add("b", Json5Primitive.fromNumber(42)); + + assertFalse(obj.isEmpty()); + assertEquals(2, obj.size()); + + assertTrue(obj.has("a")); + assertTrue(obj.has("b")); + assertFalse(obj.has("c")); + + assertEquals(Json5Primitive.fromString("x"), obj.get("a")); + assertEquals(Json5Primitive.fromNumber(42), obj.get("b")); + assertNull(obj.get("c")); + } + + @Test + void add_converts_null_to_json5null() { + Json5Object obj = new Json5Object(); + obj.add("n", null); + + Json5Element got = obj.get("n"); + assertNotNull(got); + assertEquals(Json5Primitive.fromNull(), got); // should point to Json5Null + } + + @Test + void remove_returns_removed_value_and_unsets() { + Json5Object obj = new Json5Object(); + obj.add("k", Json5Primitive.fromBoolean(true)); + + Json5Element removed = obj.remove("k"); + assertEquals(Json5Primitive.fromBoolean(true), removed); + assertFalse(obj.has("k")); + assertNull(obj.get("k")); + + assertNull(obj.remove("doesNotExist")); + } + + @Test + void entrySet_and_keySet_preserve_insertion_order() { + Json5Object obj = new Json5Object(); + obj.add("first", Json5Primitive.fromNumber(1)); + obj.add("second", Json5Primitive.fromNumber(2)); + obj.add("third", Json5Primitive.fromNumber(3)); + + // keySet order + List keys = new ArrayList<>(obj.keySet()); + assertEquals(List.of("first", "second", "third"), keys); + + // entrySet order + Iterator> it = obj.entrySet().iterator(); + assertTrue(it.hasNext()); + assertEquals("first", it.next().getKey()); + assertEquals("second", it.next().getKey()); + assertEquals("third", it.next().getKey()); + assertFalse(it.hasNext()); + } + + @Nested + class AddPropertyOverloads { + + @Test + void addProperty_string() { + Json5Object obj = new Json5Object(); + obj.addProperty("s", "hello"); + assertEquals(Json5Primitive.fromString("hello"), obj.get("s")); + + obj.addProperty("sn", (String) null); + assertEquals(Json5Primitive.fromNull(), obj.get("sn")); + } + + @Test + void addProperty_number_default() { + Json5Object obj = new Json5Object(); + obj.addProperty("n", 123); + assertEquals(Json5Primitive.fromNumber(123), obj.get("n")); + + obj.addProperty("nn", (Number) null); + assertEquals(Json5Primitive.fromNull(), obj.get("nn")); + } + + @Test + void addProperty_number_with_radix() { + Json5Object obj = new Json5Object(); + obj.addProperty("r", 255, 16); + assertEquals(Json5Primitive.fromNumber(255, 16), obj.get("r")); + } + + @Test + void addProperty_Instant() { + Json5Object obj = new Json5Object(); + obj.addProperty("i", Instant.EPOCH); + assertEquals(Json5Primitive.fromInstant(Instant.EPOCH), obj.get("i")); + } + + @Test + void addProperty_boolean() { + Json5Object obj = new Json5Object(); + obj.addProperty("b1", true); + obj.addProperty("b2", false); + assertEquals(Json5Primitive.fromBoolean(true), obj.get("b1")); + assertEquals(Json5Primitive.fromBoolean(false), obj.get("b2")); + + obj.addProperty("bn", (Boolean) null); + assertEquals(Json5Primitive.fromNull(), obj.get("bn")); + } + + @Test + void addProperty_character() { + Json5Object obj = new Json5Object(); + obj.addProperty("c", 'Z'); + assertEquals(Json5Primitive.fromCharacter('Z'), obj.get("c")); + + obj.addProperty("cn", (Character) null); + assertEquals(Json5Primitive.fromNull(), obj.get("cn")); + } + } + + @Nested + class TypedGetters { + + @Test + void getAsJson5Primitive_and_type_casting() { + Json5Object obj = new Json5Object(); + obj.addProperty("p", "str"); + assertEquals(Json5Primitive.fromString("str"), obj.getAsJson5Primitive("p")); + + // wrong type -> ClassCastException + obj.add("arr", new Json5Array()); + assertThrows(ClassCastException.class, () -> obj.getAsJson5Primitive("arr")); + } + + @Test + void getAsJson5Array_and_getAsJson5Object() { + Json5Object obj = new Json5Object(); + + Json5Array array = new Json5Array(); + array.add(Json5Primitive.fromNumber(1)); + obj.add("a", array); + + Json5Object inner = new Json5Object(); + inner.addProperty("x", 7); + obj.add("o", inner); + + assertSame(array, obj.getAsJson5Array("a")); + assertSame(inner, obj.getAsJson5Object("o")); + + assertThrows(ClassCastException.class, () -> obj.getAsJson5Array("o")); + assertThrows(ClassCastException.class, () -> obj.getAsJson5Object("a")); + } + } + + @Nested + class MapView { + + @Test + void asMap_is_mutable_and_bidirectional() { + Json5Object obj = new Json5Object(); + obj.addProperty("a", 1); + + Map view = obj.asMap(); + + view.put("b", Json5Primitive.fromString("x")); + assertTrue(obj.has("b")); + assertEquals(Json5Primitive.fromString("x"), obj.get("b")); + + obj.addProperty("c", true); + assertEquals(Json5Primitive.fromBoolean(true), view.get("c")); + } + + @Test + void asMap_disallows_null_keys_and_values() { + Json5Object obj = new Json5Object(); + Map view = obj.asMap(); + + assertThrows(NullPointerException.class, () -> view.put(null, Json5Primitive.fromNull())); + assertThrows(NullPointerException.class, () -> view.put("k", null)); + } + } + + @Nested + class DeepCopy_Equals_HashCode { + + @Test + void deepCopy_is_deep_and_copies_comment() { + Json5Object original = new Json5Object(); + original.addProperty("num", 1); + Json5Object inner = new Json5Object(); + inner.addProperty("s", "t"); + original.add("inner", inner); + original.setComment("note"); + + Json5Object copy = original.deepCopy(); + + // difference instances + assertNotSame(original, copy); + assertNotSame(original.getAsJson5Object("inner"), copy.getAsJson5Object("inner")); + + // same content + assertEquals(original, copy); + assertEquals(original.hashCode(), copy.hashCode()); + + // same comment + assertEquals("note", copy.getComment()); + + // independent mutations + copy.getAsJson5Object("inner").addProperty("s", "changed"); + assertNotEquals(original, copy); + assertEquals("t", + ((Json5Primitive) original.getAsJson5Object("inner").get("s")).getAsString()); + } + + @Test + void equals_and_hashCode_contract() { + Json5Object a = new Json5Object(); + a.addProperty("x", 1); + a.addProperty("y", "z"); + + Json5Object b = new Json5Object(); + b.addProperty("x", 1); + b.addProperty("y", "z"); + + Json5Object c = new Json5Object(); + c.addProperty("x", 1); + c.addProperty("y", "z"); + + // reflexive + assertEquals(a, a); + // symmetric + assertEquals(a, b); + assertEquals(b, a); + // transitive + assertEquals(b, c); + assertEquals(a, c); + + // consistent to hashCode + assertEquals(a.hashCode(), b.hashCode()); + assertEquals(a.hashCode(), c.hashCode()); + + // not equals on not equal content + Json5Object d = new Json5Object(); + d.addProperty("x", 2); + assertNotEquals(a, d); + } + } + +} diff --git a/src/test/java/de/marhali/json5/Json5PrimitiveTest.java b/src/test/java/de/marhali/json5/Json5PrimitiveTest.java new file mode 100644 index 0000000..1954287 --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5PrimitiveTest.java @@ -0,0 +1,367 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import de.marhali.json5.fixtures.ToStringFixtures; +import de.marhali.json5.internal.RadixNumber; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5PrimitiveTest { + @Test + @DisplayName("deepCopy(): it should copy primitive value and comment") + void test_deepCopy() { + var source = Json5Primitive.fromBoolean(true); + String sourceComment = "my comment"; + source.setComment(sourceComment); + + Json5Element copy = source.deepCopy(); + String newComment = "new comment"; + source.setComment(newComment); + + assertTrue(copy.isJson5Primitive()); + assertTrue(copy.getAsJson5Primitive().isBoolean()); + assertTrue(copy.getAsJson5Primitive().getAsBoolean()); + assertEquals(sourceComment, copy.getComment()); + assertEquals(newComment, source.getComment()); + } + + @Test + void test_equals() { + var source = Json5Primitive.fromBoolean(true); + source.setComment("my comment"); + + var other = Json5Primitive.fromBoolean(true); + other.setComment("my comment"); + + assertEquals(source, other); + assertNotEquals(source, Json5Primitive.fromBoolean(true)); + } + + @Nested + class NullPrimitive { + @Test + @DisplayName("fromNull(): it should provide shorthand initializer to create Json5Null") + void fromNull() { + var element = Json5Primitive.fromNull(); + assertInstanceOf(Json5Null.class, element); + assertEquals("null", element.getAsString()); + assertEquals("null", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class InstantPrimitive { + @Test + @DisplayName("fromInstant(): it should provide shorthand initializer to create primitive from Instant") + void fromInstant() { + var element = Json5Primitive.fromInstant(Instant.EPOCH); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isInstant()); + assertEquals(Instant.EPOCH, element.getAsInstant()); + assertEquals("1970-01-01T00:00:00Z", element.getAsString()); + assertEquals("'1970-01-01T00:00:00Z'", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class BooleanPrimitive { + @Test + @DisplayName("fromBoolean(): it should provide shorthand initializer to create primitive from Boolean") + void fromBoolean() { + var element = Json5Primitive.fromBoolean(true); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isBoolean()); + assertTrue(element.getAsBoolean()); + assertEquals("true", element.getAsString()); + assertEquals("true", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class StringPrimitive { + @Test + @DisplayName("fromCharacter(): it should provide shorthand initializer to create primitive from Character") + void fromCharacter() { + var element = Json5Primitive.fromCharacter('a'); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isString()); + assertEquals("a", element.getAsString()); + assertEquals("'a'", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + @DisplayName("fromString(): it should provide shorthand initializer to create primitive from String") + void fromString() { + var element = Json5Primitive.fromString("myString"); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isString()); + assertEquals("myString", element.getAsString()); + assertEquals("'myString'", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class NumberPrimitive { + @Test + void getAsNumberThrows() { + assertThrows(UnsupportedOperationException.class, () -> Json5Primitive.fromBoolean(true).getAsNumber(), "Primitive is not a number nor a string"); + } + + @Test + void getAsRadixNumberThrows() { + assertThrows(UnsupportedOperationException.class, () -> Json5Primitive.fromBoolean(true).getAsRadixNumber(), "Primitive is not a number"); + } + + @Test + @DisplayName("fromNumber(): it should provide shorthand initializer to create primitive from Number") + void fromNumber() { + var element = Json5Primitive.fromNumber(187); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isNumber()); + assertEquals(187, element.getAsNumber()); + assertEquals(10, element.getNumberRadix()); + assertEquals("187", element.getAsString()); + assertEquals("187", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + @DisplayName("fromNumber(): it should provide shorthand initializer to create primitive from Number and radix base") + void fromNumberWithRadix() { + var element = Json5Primitive.fromNumber(187, 10); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isNumber()); + assertEquals(new RadixNumber(187, 10), element.getAsRadixNumber()); + assertEquals(187, element.getAsNumber()); + assertEquals(10, element.getNumberRadix()); + assertEquals("187", element.getAsString()); + assertEquals("187", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void getAsDouble() { + var element = Json5Primitive.fromNumber(1.87); + assertEquals(1.87, element.getAsDouble()); + } + + @Test + void getAsDoubleFromString() { + var element = Json5Primitive.fromString("1.87"); + assertEquals(1.87, element.getAsDouble()); + } + + @Test + void getAsBigDecimal() { + var element = Json5Primitive.fromNumber(1.87); + assertEquals(new BigDecimal("1.87"), element.getAsBigDecimal()); + } + + @Test + void getAsBigDecimalFromString() { + var element = Json5Primitive.fromString("1.87"); + assertEquals(new BigDecimal("1.87"), element.getAsBigDecimal()); + } + + @Test + void getAsBigInteger() { + var element = Json5Primitive.fromNumber(187); + assertEquals(new BigInteger("187"), element.getAsBigInteger()); + } + + @Test + void getAsBigIntegerFromString() { + var element = Json5Primitive.fromString("187"); + assertEquals(new BigInteger("187"), element.getAsBigInteger()); + } + + @Test + void getAsFloat() { + var element = Json5Primitive.fromNumber(1.87); + assertEquals(1.87f, element.getAsFloat()); + } + + @Test + void getAsFloatFromString() { + var element = Json5Primitive.fromString("1.87"); + assertEquals(1.87f, element.getAsFloat()); + } + + @Test + void getAsLong() { + var element = Json5Primitive.fromNumber(187L); + assertEquals(187L, element.getAsLong()); + } + + @Test + void getAsLongFromString() { + var element = Json5Primitive.fromString("187"); + assertEquals(187L, element.getAsLong()); + } + + @Test + void getAsShort() { + var element = Json5Primitive.fromNumber((short) 187); + assertEquals((short) 187, element.getAsShort()); + } + + @Test + void getAsShortFromString() { + var element = Json5Primitive.fromString("187"); + assertEquals((short) 187, element.getAsShort()); + } + + @Test + void getAsInt() { + var element = Json5Primitive.fromNumber(187); + assertEquals((int) 187, element.getAsInt()); + } + + @Test + void getAsIntFromString() { + var element = Json5Primitive.fromString("187"); + assertEquals((int) 187, element.getAsInt()); + } + + @Test + void getAsByte() { + var element = Json5Primitive.fromNumber((byte) 187); + assertEquals((byte) 187, element.getAsByte()); + } + + @Test + void getAsByteFromString() { + // 187 does not work, because it is a signed byte (+ / -) + var element = Json5Primitive.fromString("87"); + assertEquals((byte) 87, element.getAsByte()); + } + } + + @Nested + class BinaryPrimitive { + @Test + @DisplayName("fromBinaryString(): it should provide shorthand initializer to create primitive from binary number string") + void fromBinaryString() { + var element = Json5Primitive.fromBinaryString("0b1010"); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isNumber()); + assertEquals(BigInteger.valueOf(10), element.getAsNumber()); + assertEquals(2, element.getNumberRadix()); + assertEquals("0b1010", element.getAsString()); + assertEquals("0b1010", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromPositiveBinaryString() { + var element = Json5Primitive.fromBinaryString("+0b1010"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(10), element.getAsNumber()); + assertEquals(2, element.getNumberRadix()); + assertEquals("0b1010", element.getAsString()); + assertEquals("0b1010", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromNegateBinaryString() { + var element = Json5Primitive.fromBinaryString("-0b1010"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(-10), element.getAsNumber()); + assertEquals(2, element.getNumberRadix()); + assertEquals("-0b1010", element.getAsString()); + assertEquals("-0b1010", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class OctalPrimitive { + @Test + @DisplayName("fromOctalString(): it should provide shorthand initializer to create primitive from octal number string") + void fromOctalString() { + var element = Json5Primitive.fromOctalString("0o273"); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isNumber()); + assertEquals(BigInteger.valueOf(187), element.getAsNumber()); + assertEquals(8, element.getNumberRadix()); + assertEquals("0o273", element.getAsString()); + assertEquals("0o273", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromPositiveOctalBinaryString() { + var element = Json5Primitive.fromOctalString("+0o273"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(187), element.getAsNumber()); + assertEquals(8, element.getNumberRadix()); + assertEquals("0o273", element.getAsString()); + assertEquals("0o273", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromNegateOctalString() { + var element = Json5Primitive.fromOctalString("-0o273"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(-187), element.getAsNumber()); + assertEquals(8, element.getNumberRadix()); + assertEquals("-0o273", element.getAsString()); + assertEquals("-0o273", element.toString(ToStringFixtures.OPTIONS)); + } + } + + @Nested + class HexPrimitive { + @Test + @DisplayName("fromHexString(): it should provide shorthand initializer to create primitive from hex number string") + void fromHexString() { + var element = Json5Primitive.fromHexString("0xBB"); + assertInstanceOf(Json5Primitive.class, element); + assertTrue(element.isNumber()); + assertEquals(BigInteger.valueOf(187), element.getAsNumber()); + assertEquals(16, element.getNumberRadix()); + assertEquals("0xbb", element.getAsString()); + assertEquals("0xbb", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromPositiveHexBinaryString() { + var element = Json5Primitive.fromHexString("+0xBB"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(187), element.getAsNumber()); + assertEquals(16, element.getNumberRadix()); + assertEquals("0xbb", element.getAsString()); + assertEquals("0xbb", element.toString(ToStringFixtures.OPTIONS)); + } + + @Test + void fromNegateHexString() { + var element = Json5Primitive.fromHexString("-0xBB"); + assertInstanceOf(Json5Primitive.class, element); + assertEquals(BigInteger.valueOf(-187), element.getAsNumber()); + assertEquals(16, element.getNumberRadix()); + assertEquals("-0xbb", element.getAsString()); + assertEquals("-0xbb", element.toString(ToStringFixtures.OPTIONS)); + } + } +} diff --git a/src/test/java/de/marhali/json5/Json5Test.java b/src/test/java/de/marhali/json5/Json5Test.java new file mode 100644 index 0000000..10fe1ca --- /dev/null +++ b/src/test/java/de/marhali/json5/Json5Test.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5; + +import de.marhali.json5.config.Json5Options; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class Json5Test { + + // --- helpers --- + private static Json5Object sampleObject() { + Json5Object o = new Json5Object(); + o.addProperty("n", 42); + o.addProperty("s", "hi"); + o.addProperty("b", true); + Json5Array a = new Json5Array(); + a.add(1); + a.add("x"); + o.add("arr", a); + return o; + } + + @Test + void builder_applies_function_and_returns_instance() { + Function fn = b -> Json5Options.DEFAULT; + Json5 j = Json5.builder(fn); + assertNotNull(j); + assertInstanceOf(Json5Object.class, j.parse("{}")); + } + + @Test + void constructor_with_options_and_default_and_null_checks() { + assertDoesNotThrow(() -> new Json5(Json5Options.DEFAULT)); + assertDoesNotThrow(() -> new Json5()); + + assertThrows(NullPointerException.class, () -> new Json5(null)); + } + + @Test + void parse_from_string_object_array_and_empty() { + Json5 json5 = new Json5(); + + assertInstanceOf(Json5Object.class, json5.parse("{ }")); + assertInstanceOf(Json5Array.class, json5.parse("[ ]")); + + assertNull(json5.parse("")); + } + + @Test + void parse_from_reader_and_stream() { + Json5 json5 = new Json5(); + + // Reader + Reader r = new StringReader("{a:1}"); + Json5Element e1 = json5.parse(r); + assertInstanceOf(Json5Object.class, e1); + + // InputStream + InputStream in = new ByteArrayInputStream("[1,2]".getBytes()); + Json5Element e2 = json5.parse(in); + assertInstanceOf(Json5Array.class, e2); + } + + @Test + void parse_null_arguments_throw_npe() { + Json5 json5 = new Json5(); + assertThrows(NullPointerException.class, () -> json5.parse((String) null)); + assertThrows(NullPointerException.class, () -> json5.parse((Reader) null)); + assertThrows(NullPointerException.class, () -> json5.parse((InputStream) null)); + } + + @Test + void serialize_null_arguments_throw_npe() { + Json5 json5 = new Json5(); + Json5Element elem = sampleObject(); + + assertThrows(NullPointerException.class, () -> json5.serialize(null)); + assertThrows(NullPointerException.class, () -> json5.serialize(null, new StringWriter())); + assertThrows(NullPointerException.class, () -> json5.serialize(elem, (Writer) null)); + assertThrows(NullPointerException.class, () -> json5.serialize(null, new ByteArrayOutputStream())); + assertThrows(NullPointerException.class, () -> json5.serialize(elem, (OutputStream) null)); + } +} diff --git a/src/test/java/de/marhali/json5/TestJson5.java b/src/test/java/de/marhali/json5/TestJson5.java deleted file mode 100644 index 164bd5d..0000000 --- a/src/test/java/de/marhali/json5/TestJson5.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for the {@link Json5} core class. - * - * @author Marcel Haßlinger - */ -public class TestJson5 { - - private Json5 json5; - - private InputStream getTestResource(String fileName) { - return Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName); - } - - private String getTestResourceContent(String fileName) throws IOException { - try (BufferedInputStream bis = new BufferedInputStream(getTestResource(fileName))) { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - - for (int result = bis.read(); result != -1; result = bis.read()) { - buf.write((byte) result); - } - - return Optional.ofNullable(buf.toString(StandardCharsets.UTF_8)) - .map(s->s.replace("\r\n","\n")) - .map(s->s.replace("\r","\n")) - .orElse(null); - } - } - - @BeforeEach - void setup() { - json5 = Json5.builder(builder -> - builder.allowInvalidSurrogate().quoteSingle().indentFactor(2).build()); - } - - @Test - void parseString() { - String jsonString = "{\n 'key': 'value',\n 'bool': true,\n 'hex': 0x100\n}"; - Json5Element element = json5.parse(jsonString); - - assertTrue(element.isJson5Object()); - assertEquals("value", element.getAsJson5Object().get("key").getAsString()); - assertTrue(element.getAsJson5Object().get("bool").getAsBoolean()); - assertInstanceOf(Json5Hexadecimal.class, element.getAsJson5Object().get("hex").getAsJson5Primitive()); - } - - @Test - void serializeString() throws IOException { - Json5Object element = new Json5Object(); - element.addProperty("key", "value"); - element.addProperty("bool", true); - element.add("hex", new Json5Hexadecimal("0x100")); - - String jsonString = json5.serialize(element); - String expect = "{\n 'key': 'value',\n 'bool': true,\n 'hex': 0x100\n}"; - - assertEquals(expect, jsonString); - } - - @Test - void ioArrayFile() throws IOException { - try(InputStream stream = getTestResource("test.array.json5")) { - Json5Element element = json5.parse(stream); - assertTrue(element.isJson5Array()); - assertEquals(getTestResourceContent("expect.array.json5"), json5.serialize(element)); - } - } - - @Test - void ioObjectFile() throws IOException { - try(InputStream stream = getTestResource("test.object.json5")) { - Json5Element element = json5.parse(stream); - assertTrue(element.isJson5Object()); - assertEquals(getTestResourceContent("expect.object.json5"), json5.serialize(element)); - } - } -} diff --git a/src/test/java/de/marhali/json5/TestJson5Array.java b/src/test/java/de/marhali/json5/TestJson5Array.java deleted file mode 100644 index 0c4c39a..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Array.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import org.junit.jupiter.api.Test; - -import java.math.BigDecimal; -import java.math.BigInteger; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link Json5Array} - * - * @author Marcel Haßlinger - */ -public class TestJson5Array { - - @Test - void deepCopy() { - Json5Array array = new Json5Array(); - array.add(true); - array.add(123); - array.add(new Json5Hexadecimal("0x100")); - array.add("Lorem ipsum"); - array.add(Json5Null.INSTANCE); - array.add(new Json5Object()); - array.add(new Json5String("Lorem ipsum")); - - Json5Array target = array.deepCopy(); - - assertEquals(new Json5Array(), new Json5Array()); - assertNotEquals(new Json5Array(), array); - assertEquals(target, array); - array.remove(1); - assertNotEquals(target, array); - } - - @Test - void notAArray() { - Json5Element element = new Json5Object(); - assertThrows(IllegalStateException.class, element::getAsJson5Array); - } - - @Test - void add() { - Json5Array array = new Json5Array(); - array.add("Lorem ipsum"); - assertEquals(1, array.size()); - assertTrue(array.contains(Json5Primitive.of("Lorem ipsum"))); - } - - @Test - void addAll() { - Json5Array array = new Json5Array(); - array.add("Lorem ipsum"); - - Json5Array target = new Json5Array(); - target.add("Lorem ipsum"); - target.add("Lorem ipsum"); - - array.addAll(target); - assertEquals(3, array.size()); - } - - @Test - void getAsThrows() { - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsNumber())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsBigInteger())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsBigDecimal())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsDouble())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsFloat())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsLong())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsInt())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsShort())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsByte())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsBoolean())); - assertThrows(IllegalStateException.class, (() -> new Json5Array().getAsString())); - } - - @Test - void getAsNumber() { - Json5Array array = new Json5Array(); - array.add(123); - assertEquals(123, array.getAsNumber()); - } - - @Test - void getAsBigInt() { - Json5Array array = new Json5Array(); - array.add(new BigInteger("123")); - assertEquals(new BigInteger("123"), array.getAsBigInteger()); - } - - @Test - void getAsBigDecimal() { - Json5Array array = new Json5Array(); - array.add(new BigDecimal("123")); - assertEquals(new BigDecimal("123"), array.getAsBigDecimal()); - } - - @Test - void getAsDouble() { - Json5Array array = new Json5Array(); - array.add(1.23d); - assertEquals(1.23d, array.getAsDouble()); - } - - @Test - void getAsFloat() { - Json5Array array = new Json5Array(); - array.add(1.23f); - assertEquals(1.23f, array.getAsFloat()); - } - - @Test - void getAsLong() { - Json5Array array = new Json5Array(); - array.add(123L); - assertEquals(123L, array.getAsLong()); - } - - @Test - void getAsInt() { - Json5Array array = new Json5Array(); - array.add(123); - assertEquals(123, array.getAsInt()); - } - - @Test - void getAsShort() { - Json5Array array = new Json5Array(); - array.add(Short.parseShort("123")); - assertEquals(Short.parseShort("123"), array.getAsShort()); - } - - @Test - void getAsByte() { - Json5Array array = new Json5Array(); - array.add((byte)0x100); - assertEquals((byte)0x100, array.getAsByte()); - } - - @Test - void getAsBoolean() { - Json5Array array = new Json5Array(); - array.add(true); - assertTrue(array.getAsBoolean()); - } - - @Test - void getAsString() { - Json5Array array = new Json5Array(); - array.add("Lorem ipsum"); - assertEquals("Lorem ipsum", array.getAsString()); - } -} diff --git a/src/test/java/de/marhali/json5/TestJson5Hexadecimal.java b/src/test/java/de/marhali/json5/TestJson5Hexadecimal.java deleted file mode 100644 index 17dceb8..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Hexadecimal.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link Json5Hexadecimal} primitive data type. - * - * @author Marcel Haßlinger - */ -public class TestJson5Hexadecimal { - - @Test - void neutralHex() { - String hex = "0x100"; - Json5Primitive primitive = new Json5Hexadecimal(hex); - assertEquals(primitive.getAsInt(), 256); - assertEquals(hex, primitive.getAsString()); - } - - @Test - void positiveHex() { - String hex = "+0x100"; - Json5Primitive primitive = new Json5Hexadecimal(hex); - assertEquals(primitive.getAsInt(), 256); - assertEquals("0x100", primitive.getAsString()); // We cut the '+' by default since we cannot track this state - } - - @Test - void positivePrefixedHex() { - String hex = "0x100"; - Json5Primitive primitive = new Json5Hexadecimal(hex); - assertEquals("+0x100", Json5Hexadecimal.serializeHexString(primitive.getAsBigInteger(), true)); - } - - @Test - void negateHex() { - String hex = "-0x100"; - Json5Primitive primitive = new Json5Hexadecimal(hex); - assertEquals(primitive.getAsInt(), -256); - assertEquals(hex, primitive.getAsString()); - } - - @Test - void instance() { - assertTrue(Json5Primitive.of("0x100", true) instanceof Json5Hexadecimal); - assertFalse(Json5Primitive.of("0x100", false) instanceof Json5Hexadecimal); - } -} diff --git a/src/test/java/de/marhali/json5/TestJson5Object.java b/src/test/java/de/marhali/json5/TestJson5Object.java deleted file mode 100644 index 6801455..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Object.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link Json5Object} - * - * @author Marcel Haßlinger - */ -public class TestJson5Object { - - @Test - void remove() { - Json5Object element = new Json5Object(); - element.addProperty("str", "Lorem ipsum"); - element.remove("str"); - assertEquals(0, element.size()); - assertFalse(element.has("str")); - } - - @Test - void notAObject() { - Json5Element element = new Json5Array(); - assertThrows(IllegalStateException.class, element::getAsJson5Object); - } - - @Test - void deepCopy() { - Json5Object element = new Json5Object(); - element.addProperty("str", "Lorem ipsum"); - element.addProperty("bool", true); - - Json5Object target = element.deepCopy(); - - element.remove("str"); - element.remove("bool"); - assertEquals(0, element.size()); - - assertEquals(2, target.size()); - assertInstanceOf(Json5String.class, target.getAsJson5Primitive("str")); - assertInstanceOf(Json5Boolean.class, target.getAsJson5Primitive("bool")); - } - - @Test - void add() { - Json5Object object = new Json5Object(); - object.addProperty("char", 'c'); - object.add("obj", new Json5Object()); - object.add("array", new Json5Array()); - - assertInstanceOf(Json5String.class, object.get("char")); - assertEquals(new Json5Object(), object.getAsJson5Object("obj")); - assertEquals(Set.of("char", "obj", "array"), object.keySet()); - assertInstanceOf(Json5Array.class, object.getAsJson5Array("array")); - assertEquals(0, object.getAsJson5Array("array").size()); - } -} diff --git a/src/test/java/de/marhali/json5/TestJson5Options.java b/src/test/java/de/marhali/json5/TestJson5Options.java deleted file mode 100644 index c0302a2..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Options.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import de.marhali.json5.stream.Json5Lexer; -import de.marhali.json5.stream.Json5Parser; - -import org.junit.jupiter.api.Test; - -import java.io.StringReader; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests to check if all {@link Json5Options} are applied correctly. - * - * @author Marcel Haßlinger - */ -public class TestJson5Options { - - @Test - void singleQuoted() { - String payload = "['hello',1,'two']"; - - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - - assertEquals("['hello',1,'two']", element.toString(options)); - } - - @Test - void doubleQuoted() { - String payload = "['hello',1,'two']"; - - Json5Options options = new Json5Options(true, false, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - - assertEquals("[\"hello\",1,\"two\"]", element.toString(options)); - } - - @Test - void trailingComma() { - String payload = "['hello',1,'two']"; - - Json5Options options = new Json5Options(true, true, true, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - - assertEquals("['hello',1,'two',]", element.toString(options)); - } - - @Test - void prettyPrinting() { - String payload = "['hello',1,'two']"; - - Json5Options options = new Json5Options(true, true, true, 2); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - - assertEquals("[\n 'hello',\n 1,\n 'two',\n]", element.toString(options)); - } -} \ No newline at end of file diff --git a/src/test/java/de/marhali/json5/TestJson5Parser.java b/src/test/java/de/marhali/json5/TestJson5Parser.java deleted file mode 100644 index 47b536e..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Parser.java +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import de.marhali.json5.exception.Json5Exception; -import de.marhali.json5.stream.Json5Lexer; -import de.marhali.json5.stream.Json5Parser; - -import org.junit.jupiter.api.Test; - -import java.io.StringReader; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for the {@link Json5Parser}. - * - * @author Marcel Haßlinger - */ -public class TestJson5Parser { - - @Test - void array() { - String payload = "['hello',1,'two',{'key':'value'}]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals(payload, element.toString(options)); - assertTrue(element.get(0).getAsJson5Primitive() instanceof Json5String); - assertTrue(element.get(1).getAsJson5Primitive() instanceof Json5Number); - assertTrue(element.get(3).isJson5Object()); - } - - @Test - void object() { - String payload = "{'key':'value','array':['first','second'],'nested':{'key':'value'}}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertEquals(payload, element.toString(options)); - assertEquals("value", element.get("key").getAsString()); - assertTrue(element.get("array").isJson5Array()); - } - - @Test - void determineArrayType() { - String payload = "['first','second']"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Element element = Json5Parser.parse(lexer); - assertTrue(element.isJson5Array()); - assertInstanceOf(Json5Array.class, element); - } - - @Test - void determineObjectType() { - String payload = "{'key':'value'}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Element element = Json5Parser.parse(lexer); - assertTrue(element.isJson5Object()); - assertInstanceOf(Json5Object.class, element); - } - - @Test - void hexadecimal() { - String payload = "{'key':0x100}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertEquals(payload, element.toString(options)); - assertInstanceOf(Json5Hexadecimal.class, element.getAsJson5Primitive("key")); - } - - @Test - void insideQuotes() { - String payload = "[\"example\",'other']"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals("['example','other']", element.toString(options)); - } - - @Test - void mixedQuotes() { - String payload = "{ a: \"Test \\' 123\" }"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertEquals("Test ' 123", element.get("a").getAsString()); - } - - @Test - void escapeChars() { - String payload = "{ a: \"\\n\\r\\f\\b\\t\\v\\0\u12fa\\x7F\" }"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertEquals("\n\r\f\b\t\u000B\0\u12fa\u007F", element.get("a").getAsString()); - assertInstanceOf(Json5String.class, element.get("a")); - } - - @Test - void specialNumbers() { - String payload = "[+NaN,NaN,-NaN,+Infinity,Infinity,-Infinity]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals("[NaN,NaN,NaN,Infinity,Infinity,-Infinity]", element.toString(options)); - assertInstanceOf(Json5Number.class, element.get(0)); - } - - @Test - void malformed() { - String payload = "[10}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parse(lexer)); - } - - @Test - void notAObject() { - String payload = "[]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); - } - - @Test - void incompleteObject() { - String payload = "{"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); - } - - @Test - void notAArray() { - String payload = "{}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseArray(lexer)); - } - - @Test - void incompleteArray() { - String payload = "["; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseArray(lexer)); - } - - @Test - void duplicateObjectKeys() { - String payload = "{'key':'value','key':'value'}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); - } - - @Test - void noDivider() { - String payload = "{'key''value'}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); - } - - @Test - void noComma() { - String payload = "{'key':'value''otherKey':'value'}"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); - } - - @Test - void unknownControlCharacter() { - String payload = "|"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertThrows(Json5Exception.class, () -> Json5Parser.parse(lexer)); - } - - @Test - void empty() { - String payload = ""; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - assertNull(Json5Parser.parse(lexer)); - } - - @Test - void memberNames() { - String payload = "{ $Lorem\\u0041_Ipsum123指事字: 0 }"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertTrue(element.has("$LoremA_Ipsum123指事字")); - } - - @Test - void multiComments() { - String payload = "/**/{/**/a/**/:/**/'b'/**/}/**/"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertTrue(element.has("a")); - } - - @Test - void singleComments() { - String payload = "// test\n{ // lorem ipsum\n a: 'b'\n// test\n}// test"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Object element = Json5Parser.parseObject(lexer); - assertTrue(element.has("a")); - } - - @Test - void booleans() { - String payload = "[true,false]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals(payload, element.toString(options)); - assertInstanceOf(Json5Boolean.class, element.get(0)); - } - - @Test - void numbers() { - String payload = "[123e+45,-123e45,123]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals(123e+45, element.get(0).getAsNumber().doubleValue()); - assertEquals(-123e45, element.get(1).getAsNumber().doubleValue()); - assertEquals(123, element.get(2).getAsNumber().doubleValue()); - assertTrue(element.get(0).isJson5Primitive()); - assertTrue(element.get(0).getAsJson5Primitive().isNumber()); - assertInstanceOf(Json5Number.class, element.get(2)); - } - - @Test - void nullLiteral() { - String payload = "[null,{'key':null}]"; - Json5Options options = new Json5Options(true, true, false, 0); - Json5Lexer lexer = new Json5Lexer(new StringReader(payload), options); - Json5Array element = Json5Parser.parseArray(lexer); - assertEquals(payload, element.toString(options)); - assertTrue(element.get(0).isJson5Null()); - assertInstanceOf(Json5Null.class, element.get(0)); - } -} diff --git a/src/test/java/de/marhali/json5/TestJson5Writer.java b/src/test/java/de/marhali/json5/TestJson5Writer.java deleted file mode 100644 index 6b59f0c..0000000 --- a/src/test/java/de/marhali/json5/TestJson5Writer.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2022 Marcel Haßlinger - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.marhali.json5; - -import de.marhali.json5.stream.Json5Writer; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.io.StringWriter; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for the {@link Json5Writer}. - * - * @author Marcel Haßlinger - */ -public class TestJson5Writer { - - private final Json5Options options - = new Json5Options(true, true, false, 0); - - private StringWriter stringWriter; - private Json5Writer json5Writer; - - @BeforeEach - void beforeEach() { - stringWriter = new StringWriter(); - json5Writer = new Json5Writer(options, stringWriter); - } - - @Test - void quoteEmpty() { - assertEquals("''", json5Writer.quote(null)); - assertEquals("''", json5Writer.quote("")); - } - - @Test - void quoteEscape() { - assertEquals("'\\\\n'", json5Writer.quote("\\n")); - } - - @Test - void array() throws IOException { - Json5Array array = new Json5Array(); - array.add(true); - array.add(123); - array.add(new Json5Hexadecimal("0x100")); - array.add("Lorem ipsum"); - array.add(Json5Null.INSTANCE); - array.add(new Json5Object()); - - json5Writer.write(array); - - assertEquals("[true,123,0x100,'Lorem ipsum',null,{}]", stringWriter.toString()); - } - - @Test - void object() throws IOException { - Json5Object object = new Json5Object(); - object.add("bool", Json5Primitive.of(false)); - object.add("num", Json5Primitive.of(123)); - object.add("hex", new Json5Hexadecimal("0x100")); - object.add("str", Json5Primitive.of("Lorem ipsum")); - object.add("nulled", Json5Null.INSTANCE); - object.add("array", new Json5Array()); - - json5Writer.write(object); - - assertEquals("{'bool':false,'num':123,'hex':0x100,'str':'Lorem ipsum','nulled':null,'array':[]}", - stringWriter.toString()); - } - - @Test - void nullLiteral() throws IOException { - json5Writer.write(Json5Null.INSTANCE); - assertEquals("null", stringWriter.toString()); - } - - @Test - void booleans() throws IOException { - json5Writer.write(new Json5Boolean(false)); - assertEquals("false", stringWriter.toString()); - } - - @Test - void largeNumber() throws IOException { - json5Writer.write(new Json5Number(123e+45)); - assertEquals("1.23E47", stringWriter.toString()); - } - - @Test - void largeNumberNegate() throws IOException { - json5Writer.write(new Json5Number(-123e+45)); - assertEquals("-1.23E47", stringWriter.toString()); - } - - @Test - void hexadecimal() throws IOException { - json5Writer.write(new Json5Hexadecimal("0x100")); - assertEquals("0x100", stringWriter.toString()); - } - - @Test - void number() throws IOException { - json5Writer.write(new Json5Number(123)); - assertEquals("123", stringWriter.toString()); - } - - @Test - void string() throws IOException { - json5Writer.write(new Json5String("Lorem ipsum")); - assertEquals("'Lorem ipsum'", stringWriter.toString()); - } - - @Test - void memberNames() throws IOException { - Json5Object object = new Json5Object(); - object.addProperty("$LoremA_Ipsum123指事字", 0); - json5Writer.write(object); - assertEquals("{'$LoremA_Ipsum123指事字':0}", stringWriter.toString()); - } - - @Test - void escapeChars() throws IOException { - Json5Array array = new Json5Array(); - array.add("\\\n\r\f\b\t\u000B\u12fa\u000b"); - json5Writer.write(array); - assertEquals("['\\\\\\n\\r\\f\\b\\t\\v\u12fa\\v']", stringWriter.toString()); - // Cannot test \x7F - } -} diff --git a/src/test/java/de/marhali/json5/e2e/TestResourceHelper.java b/src/test/java/de/marhali/json5/e2e/TestResourceHelper.java new file mode 100644 index 0000000..c37c340 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/TestResourceHelper.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** + * @author Marcel Haßlinger + */ +public class TestResourceHelper { + public static InputStream getTestResource(String fileName) { + return Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName); + } + + public static String getTestResourceContent(String fileName) throws IOException { + try (BufferedInputStream bis = new BufferedInputStream(getTestResource(fileName))) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + + for (int result = bis.read(); result != -1; result = bis.read()) { + buf.write((byte) result); + } + + return Optional.ofNullable(buf.toString(StandardCharsets.UTF_8)) + .map(s -> s.replace("\r\n", "\n")) + .map(s -> s.replace("\r", "\n")) + .orElse(null); + } + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowBinaryNumberTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowBinaryNumberTest.java new file mode 100644 index 0000000..205def5 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowBinaryNumberTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowBinaryNumberTest { + @Test + @DisplayName("Parse: disallowed binary number throws exception") + void disallowBinaryNumber() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-binary-number.json5"))); + + assertEquals("Binary literals are not allowed at index 25 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowCDigitSeparatorTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowCDigitSeparatorTest.java new file mode 100644 index 0000000..17375e7 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowCDigitSeparatorTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowCDigitSeparatorTest { + @Test + @DisplayName("Parse: disallowed C-style digit separators throws exception") + void disallowCCDigitSeparator() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-c-digit-separator.json5"))); + + assertEquals("C-style digit separators are not allowed at index 28 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowHexFloatingTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowHexFloatingTest.java new file mode 100644 index 0000000..af5e616 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowHexFloatingTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowHexFloatingTest { + @Test + @DisplayName("Parse: disallowed hex floating-point throws exception") + void disallowHexFloating() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-hex-floating.json5"))); + + assertEquals("Hexadecimal floating-point literals are not allowed at index 28 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowInfinityTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowInfinityTest.java new file mode 100644 index 0000000..169ce96 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowInfinityTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowInfinityTest { + @Test + @DisplayName("Parse: disallowed Infinity throws exception") + void disallowInfinity() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-Infinity.json5"))); + + assertEquals("Infinity is not allowed at index 27 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowJavaDigitSeparatorTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowJavaDigitSeparatorTest.java new file mode 100644 index 0000000..02a4de3 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowJavaDigitSeparatorTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowJavaDigitSeparatorTest { + @Test + @DisplayName("Parse: disallowed Java-style digit separators throws exception") + void disallowJavaDigitSeparator() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-java-digit-separator.json5"))); + + assertEquals("Java-style digit separators are not allowed at index 28 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowNaNTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowNaNTest.java new file mode 100644 index 0000000..4134aab --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowNaNTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class DisallowNaNTest { + @Test + @DisplayName("Parse: disallowed NaN throws exception") + void disallowNaN() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-NaN.json5"))); + + assertEquals("NaN is not allowed at index 22 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowOctalNumberTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowOctalNumberTest.java new file mode 100644 index 0000000..31ace44 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowOctalNumberTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowOctalNumberTest { + @Test + @DisplayName("Parse: disallowed octal number throws exception") + void disallowOctalNumber() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-octal-number.json5"))); + + assertEquals("Octal literals are not allowed at index 24 [character 1 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingArrayTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingArrayTest.java new file mode 100644 index 0000000..0697040 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingArrayTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowTrailingArrayTest { + @Test + @DisplayName("Parse: disallowed trailing data on array throws exception") + void disallowTrailingArray() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-trailing-array.json5"))); + + assertEquals("Trailing data after Json5Array at index 2 [character 3 in line 1]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingObjectTest.java b/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingObjectTest.java new file mode 100644 index 0000000..baebbed --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DisallowTrailingObjectTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class DisallowTrailingObjectTest { + @Test + @DisplayName("Parse: disallowed trailing data on object throws exception") + void disallowTrailingObject() { + var json5 = Json5.builder(Json5Options.Builder::build); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/disallow-trailing-object.json5"))); + + assertEquals("Trailing data after Json5Object at index 2 [character 3 in line 1]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/DuplicateObjectKeyTest.java b/src/test/java/de/marhali/json5/e2e/failures/DuplicateObjectKeyTest.java new file mode 100644 index 0000000..25056ab --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/DuplicateObjectKeyTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.Json5Element; +import de.marhali.json5.Json5Primitive; +import de.marhali.json5.config.DuplicateKeyStrategy; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class DuplicateObjectKeyTest { + @Test + @DisplayName("Parse: duplicate key on object throws with DuplicateStrategy.UNIQUE") + void disallowDuplicateObjectKey() { + var json5 = Json5.builder(builder -> builder.duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE).build()); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/duplicate-object-key.json5"))); + + assertEquals("Duplicate key \"alpha\" at index 37 [character 8 in line 3]", ex.getMessage()); + } + + @Test + @DisplayName("Parse: duplicate key on object, but last one wins with DuplicateStrategy.LAST_WINS") + void duplicateObjectKeyLastWins() { + var json5 = Json5.builder(builder -> builder.duplicateKeyStrategy(DuplicateKeyStrategy.LAST_WINS).build()); + + Json5Element element = json5.parse(TestResourceHelper.getTestResource("e2e/failures/duplicate-object-key.json5")); + + assertTrue(element.isJson5Object()); + var object = element.getAsJson5Object(); + + assertEquals(3, object.size()); + assertEquals("secondAlphaValue", object.get("alpha").getAsString()); + assertEquals("bravoValue", object.get("bravo").getAsString()); + assertEquals("secondCharlieValue", object.get("charlie").getAsString()); + } + + @Test + @DisplayName("Parse: duplicate key on object, but all entries are converted to array with DuplicateStrategy.DUPLICATE") + void duplicateObjectKeyAsArray() { + var json5 = Json5.builder(builder -> builder.duplicateKeyStrategy(DuplicateKeyStrategy.DUPLICATE).build()); + + Json5Element element = json5.parse(TestResourceHelper.getTestResource("e2e/failures/duplicate-object-key.json5")); + + assertTrue(element.isJson5Object()); + var object = element.getAsJson5Object(); + + assertEquals(3, object.size()); + assertTrue(object.get("alpha").isJson5Array()); + assertEquals(Json5Primitive.fromString("firstAlphaValue"), object.get("alpha").getAsJson5Array().get(0)); + assertEquals(Json5Primitive.fromString("secondAlphaValue"), object.get("alpha").getAsJson5Array().get(1)); + assertEquals("bravoValue", object.get("bravo").getAsString()); + assertEquals(Json5Primitive.fromString("firstCharlieValue"), object.get("charlie").getAsJson5Array().get(0)); + assertEquals(Json5Primitive.fromString("secondCharlieValue"), object.get("charlie").getAsJson5Array().get(1)); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/InvalidArrayParserTest.java b/src/test/java/de/marhali/json5/e2e/failures/InvalidArrayParserTest.java new file mode 100644 index 0000000..ba0249b --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/InvalidArrayParserTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.DuplicateKeyStrategy; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import de.marhali.json5.stream.Json5Lexer; +import de.marhali.json5.stream.Json5Parser; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Marcel Haßlinger + */ +public class InvalidArrayParserTest { + @Test + void invalid_array_opening_tags_throws() { + var reader = new StringReader("{}"); + var lexer = new Json5Lexer(reader, Json5Options.DEFAULT); + + var ex = assertThrows(Json5Exception.class, () -> Json5Parser.parseArray(lexer)); + + assertEquals("A Json5Array must begin with '[' at index 0 [character 1 in line 1]", ex.getMessage()); + } + + @Test + void invalid_array_closing_tags_throws() { + var json5 = new Json5(); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/invalid-array-closing-tags.json5"))); + + assertEquals("A Json5Array must end with ']' at index 17 [character 0 in line 3]", ex.getMessage()); + } + + @Test + void invalid_array_missing_comma_throws() { + var json5 = new Json5(); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/invalid-array-missing-comma.json5"))); + + assertEquals("Expected ',' or ']' after value, got '[' instead at index 9 [character 3 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/failures/InvalidObjectParserTest.java b/src/test/java/de/marhali/json5/e2e/failures/InvalidObjectParserTest.java new file mode 100644 index 0000000..4a0fe50 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/failures/InvalidObjectParserTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.failures; + +import de.marhali.json5.Json5; +import de.marhali.json5.config.DuplicateKeyStrategy; +import de.marhali.json5.config.Json5Options; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.exception.Json5Exception; +import de.marhali.json5.stream.Json5Lexer; +import de.marhali.json5.stream.Json5Parser; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class InvalidObjectParserTest { + @Test + void invalid_object_opening_tags_throws() { + var reader = new StringReader("[]"); + var lexer = new Json5Lexer(reader, Json5Options.DEFAULT); + + var ex = assertThrows(Json5Exception.class, () -> Json5Parser.parseObject(lexer)); + + assertEquals("A Json5Object must begin with '{' at index 0 [character 1 in line 1]", ex.getMessage()); + } + + @Test + void invalid_object_closing_tags_throws() { + var json5 = new Json5(); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/invalid-object-closing-tags.json5"))); + + assertEquals("A Json5Object must end with '}' at index 42 [character 0 in line 4]", ex.getMessage()); + } + + @Test + void invalid_object_missing_key_suffix_throws() { + var json5 = new Json5(); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/invalid-object-missing-key-suffix.json5"))); + + assertEquals("Expected ':' after a key, got 'f' instead at index 12 [character 11 in line 2]", ex.getMessage()); + } + + @Test + void invalid_object_missing_comma_throws() { + var json5 = new Json5(); + + var ex = assertThrows(Json5Exception.class, () -> json5.parse(TestResourceHelper.getTestResourceContent("e2e/failures/invalid-object-missing-comma.json5"))); + + assertEquals("Expected ',' or '}' after value, got 's' instead at index 22 [character 3 in line 3]", ex.getMessage()); + } +} diff --git a/src/test/java/de/marhali/json5/e2e/roundtrips/ParserAndWriterTest.java b/src/test/java/de/marhali/json5/e2e/roundtrips/ParserAndWriterTest.java new file mode 100644 index 0000000..1d6a936 --- /dev/null +++ b/src/test/java/de/marhali/json5/e2e/roundtrips/ParserAndWriterTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.e2e.roundtrips; + +import de.marhali.json5.Json5; +import de.marhali.json5.e2e.TestResourceHelper; +import de.marhali.json5.fixtures.Json5OptionsFixtures; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class ParserAndWriterTest { + @Test + void objectRoundtrip() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/object.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/object.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void objectRoundtripNoComment() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY_NO_COMMENTS); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/object.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/object-no-comment.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void objectRoundtripMinifyNoComment() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.MINIFY_NO_COMMENTS); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/object.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/object-minify-no-comment.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void objectRoundtripMinify() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.MINIFY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/object.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/object-minify.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void objectRoundtripMinifyToPretty() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/object-minify.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/object-minify-to-prettify.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void arrayRoundtrip() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/array.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/array.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void arrayRoundtripNoComment() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY_NO_COMMENTS); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/array.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/array-no-comment.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void arrayRoundtripMinifyNoComment() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.MINIFY_NO_COMMENTS); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/array.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/array-minify-no-comment.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void arrayRoundtripMinify() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.MINIFY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/array.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/array-minify.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void arrayRoundtripMinifyToPretty() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.PRETTY); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/array-minify.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/array-minify-to-prettify.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void extensionsRoundtripCStyle() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.EXTENSIONS_C_STYLE); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/extensions-c-style.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/extensions-c-style.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } + + @Test + void extensionsRoundtripJavaStyle() throws IOException { + var json5 = new Json5(Json5OptionsFixtures.EXTENSIONS_JAVA_STYLE); + + var element = json5.parse(TestResourceHelper.getTestResource("e2e/roundtrips/extensions-java-style.parser.json5")); + var stringifiedElement = json5.serialize(element); + + var expectedStringifiedElement = TestResourceHelper.getTestResourceContent("e2e/roundtrips/extensions-java-style.writer.json5"); + + assertEquals(expectedStringifiedElement, stringifiedElement); + } +} diff --git a/src/test/java/de/marhali/json5/fixtures/Json5OptionsFixtures.java b/src/test/java/de/marhali/json5/fixtures/Json5OptionsFixtures.java new file mode 100644 index 0000000..2c494ed --- /dev/null +++ b/src/test/java/de/marhali/json5/fixtures/Json5OptionsFixtures.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.fixtures; + +import de.marhali.json5.config.DigitSeparatorStrategy; +import de.marhali.json5.config.DuplicateKeyStrategy; +import de.marhali.json5.config.Json5Options; + +/** + * @author Marcel Haßlinger + */ +public class Json5OptionsFixtures { + public static Json5Options PRETTY = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .quoteless() + .parseComments() + .writeComments() + .trailingComma() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); + + public static Json5Options PRETTY_NO_COMMENTS = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .quoteless() + .trailingComma() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); + + public static Json5Options MINIFY = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .quoteless() + .parseComments() + .writeComments() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .indentFactor(0) + .build(); + + public static Json5Options MINIFY_NO_COMMENTS = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .quoteless() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .indentFactor(0) + .build(); + + public static Json5Options EXTENSIONS_C_STYLE = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .allowBinaryLiterals() + .allowOctalLiterals() + .allowHexFloatingLiterals() + .allowLongUnicodeEscapes() + .quoteless() + .parseComments() + .writeComments() + .trailingComma() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.C_STYLE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); + + public static Json5Options EXTENSIONS_JAVA_STYLE = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .allowBinaryLiterals() + .allowOctalLiterals() + .allowHexFloatingLiterals() + .allowLongUnicodeEscapes() + .quoteless() + .parseComments() + .writeComments() + .trailingComma() + .insertFinalNewline() + .digitSeparatorStrategy(DigitSeparatorStrategy.JAVA_STYLE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); +} diff --git a/src/test/java/de/marhali/json5/fixtures/ToStringFixtures.java b/src/test/java/de/marhali/json5/fixtures/ToStringFixtures.java new file mode 100644 index 0000000..bda7fa3 --- /dev/null +++ b/src/test/java/de/marhali/json5/fixtures/ToStringFixtures.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.fixtures; + +import de.marhali.json5.config.DigitSeparatorStrategy; +import de.marhali.json5.config.DuplicateKeyStrategy; +import de.marhali.json5.config.Json5Options; + +/** + * @author Marcel Haßlinger + */ +public class ToStringFixtures { + /** + * Options to use for testing {@link de.marhali.json5.Json5Element#toString(Json5Options)} methods. + */ + public static Json5Options OPTIONS = Json5Options.builder() + .allowNaN() + .allowInfinity() + .allowInvalidSurrogates() + .parseComments() + .writeComments() + .trailingComma() + .quoteSingle() + .digitSeparatorStrategy(DigitSeparatorStrategy.NONE) + .duplicateKeyStrategy(DuplicateKeyStrategy.UNIQUE) + .prettyPrinting() + .build(); +} diff --git a/src/test/java/de/marhali/json5/internal/EcmaScriptIdentifierTest.java b/src/test/java/de/marhali/json5/internal/EcmaScriptIdentifierTest.java new file mode 100644 index 0000000..9e3a70a --- /dev/null +++ b/src/test/java/de/marhali/json5/internal/EcmaScriptIdentifierTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +class EcmaScriptIdentifierTest { + + @Test + @DisplayName("ASCII: valid identifiers") + void asciiValid() { + assertTrue(EcmaScriptIdentifier.isValid("foo")); + assertTrue(EcmaScriptIdentifier.isValid("_bar")); + assertTrue(EcmaScriptIdentifier.isValid("$baz")); + assertTrue(EcmaScriptIdentifier.isValid("a1")); + assertTrue(EcmaScriptIdentifier.isValid("A9_$_x")); + } + + @Test + @DisplayName("ASCII: invalid forms") + void asciiInvalid() { + assertFalse(EcmaScriptIdentifier.isValid("")); // empty identifier + assertFalse(EcmaScriptIdentifier.isValid("1abc")); // starts with digit + assertFalse(EcmaScriptIdentifier.isValid("with space")); // space + assertFalse(EcmaScriptIdentifier.isValid("some-key")); // hyphen + assertFalse(EcmaScriptIdentifier.isValid("foo.bar")); // dot + assertFalse(EcmaScriptIdentifier.isValid("a,b")); // comma + assertFalse(EcmaScriptIdentifier.isValid("😀face")); // symbol + } + + @Test + @DisplayName("Reserved words are allowed") + void reservedWordsAllowed() { + assertTrue(EcmaScriptIdentifier.isValid("class")); + assertTrue(EcmaScriptIdentifier.isValid("default")); + assertTrue(EcmaScriptIdentifier.isValid("function")); + assertTrue(EcmaScriptIdentifier.isValid("if")); + assertTrue(EcmaScriptIdentifier.isValid("true")); + assertTrue(EcmaScriptIdentifier.isValid("false")); + assertTrue(EcmaScriptIdentifier.isValid("null")); + assertTrue(EcmaScriptIdentifier.isValid("NaN")); + assertTrue(EcmaScriptIdentifier.isValid("Infinity")); + } + + @Test + @DisplayName("Unicode letters as start are allowed") + void unicodeLettersStart() { + assertTrue(EcmaScriptIdentifier.isValid("café")); // Lo + assertTrue(EcmaScriptIdentifier.isValid("naïve")); // Lo + combining + assertTrue(EcmaScriptIdentifier.isValid("äpfel")); // Ll + assertTrue(EcmaScriptIdentifier.isValid("Русский")); // Cyrillic + assertTrue(EcmaScriptIdentifier.isValid("你好")); // CJK + assertTrue(EcmaScriptIdentifier.isValid("ʰello")); // Lm (U+02B0) + assertTrue(EcmaScriptIdentifier.isValid("Ⅻwert")); // Nl (U+216B) + } + + @Test + @DisplayName("Digit at start is invalid (even Unicode Nd)") + void unicodeDigitStartInvalid() { + assertFalse(EcmaScriptIdentifier.isValid("١abc")); // U+0661 ARABIC-INDIC DIGIT ONE (Nd) at start + } + + @Test + @DisplayName("IdentifierPart categories") + void identifierPartCategories() { + // digits (Nd) in part + assertTrue(EcmaScriptIdentifier.isValid("foo1")); + assertTrue(EcmaScriptIdentifier.isValid("a\u0661")); // arabic-indic digit one in part + + // combining marks (Mn/Mc) in part + assertTrue(EcmaScriptIdentifier.isValid("e\u0301")); // 'e' + COMBINING ACUTE ACCENT (Mn) + assertFalse(EcmaScriptIdentifier.isValid("\u0301e")); // mark at start -> invalid + + // connector punctuation (Pc) in part + assertTrue(EcmaScriptIdentifier.isValid("a_b")); // U+005F LOW LINE + assertTrue(EcmaScriptIdentifier.isValid("a\u203Fbc")); // U+203F UNDERTIE (Pc) + assertTrue(EcmaScriptIdentifier.isValid("a\u2054bc")); // U+2054 INVERTED UNDERTIE (Pc) + + // Pc at start (not '_') is not allowed + assertFalse(EcmaScriptIdentifier.isValid("\u203Fabc")); + } + + @Test + @DisplayName("ZWNJ/ZWJ allowed in part, not at start") + void zwnjZwjRules() { + assertTrue(EcmaScriptIdentifier.isValid("a\u200Cbc")); // ZWNJ + assertTrue(EcmaScriptIdentifier.isValid("a\u200Dbc")); // ZWJ + assertFalse(EcmaScriptIdentifier.isValid("\u200Cabc")); + assertFalse(EcmaScriptIdentifier.isValid("\u200Dabc")); + } + + @Test + @DisplayName("Other Cf (format) not allowed") + void otherCfNotAllowed() { + assertFalse(EcmaScriptIdentifier.isValid("a\u00ADb")); // SOFT HYPHEN (Cf) + } + + @Test + @DisplayName("\\uXXXX escapes: valid") + void unicodeEscapesValid() { + assertTrue(EcmaScriptIdentifier.isValid("\\u0061bc")); // 'a'bc + assertTrue(EcmaScriptIdentifier.isValid("\\u00E4pfel")); // 'ä'pfel + assertTrue(EcmaScriptIdentifier.isValid("a\\u0301")); // combining accent in part + assertTrue(EcmaScriptIdentifier.isValid("a\\u200C")); // ZWNJ in part + assertTrue(EcmaScriptIdentifier.isValid("a\\u200D")); // ZWJ in part + assertTrue(EcmaScriptIdentifier.isValid("a\\u005Fb")); // '_' in part + assertTrue(EcmaScriptIdentifier.isValid("\\u0041\\u0030x")); // 'A''0'x + // valid surrogate pair literal (astral letter) as UTF-16, not via \\u{...} + assertTrue(EcmaScriptIdentifier.isValid("\uD801\uDC00abc")); // U+10400 + } + + @Test + @DisplayName("\\uXXXX escapes: invalid or disallowed") + void unicodeEscapesInvalid() { + assertFalse(EcmaScriptIdentifier.isValid("\\u00G1")); // not hex + assertFalse(EcmaScriptIdentifier.isValid("\\u12")); // too short + assertFalse(EcmaScriptIdentifier.isValid("a\\x61")); // \x not allowed + assertFalse(EcmaScriptIdentifier.isValid("abc\\")); // bare backslash + assertFalse(EcmaScriptIdentifier.isValid("\\u200Cabc")); // ZWNJ at start + assertFalse(EcmaScriptIdentifier.isValid("\\uD800abc")); // lone high surrogate + } + + @Test + @DisplayName("$ behaves like in JS") + void dollarRules() { + assertTrue(EcmaScriptIdentifier.isValid("$")); + assertTrue(EcmaScriptIdentifier.isValid("$x")); + assertTrue(EcmaScriptIdentifier.isValid("a$1")); + assertTrue(EcmaScriptIdentifier.isValid("ä$")); + } + + @Test + @DisplayName("Null/empty") + void nullAndEmpty() { + assertFalse(EcmaScriptIdentifier.isValid(null)); + assertFalse(EcmaScriptIdentifier.isValid("")); + } +} diff --git a/src/test/java/de/marhali/json5/internal/LazilyParsedNumberTest.java b/src/test/java/de/marhali/json5/internal/LazilyParsedNumberTest.java new file mode 100644 index 0000000..3f6a0be --- /dev/null +++ b/src/test/java/de/marhali/json5/internal/LazilyParsedNumberTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +class LazilyParsedNumberTest { + + @Test + void toString_returnsOriginal() { + assertEquals("123", new LazilyParsedNumber("123").toString()); + assertEquals("1.50", new LazilyParsedNumber("1.50").toString()); + } + + @Test + void int_long_parse_directInteger() { + LazilyParsedNumber n = new LazilyParsedNumber("123"); + assertEquals(123, n.intValue()); + assertEquals(123L, n.longValue()); + } + + @Test + void float_double_parse_decimal() { + LazilyParsedNumber n = new LazilyParsedNumber("1.5"); + assertEquals(1.5f, n.floatValue(), 0.000001f); + assertEquals(1.5, n.doubleValue(), 0.0000000001); + // Integer/Long parse fails -> BigDecimal.intValue() + assertEquals(new BigDecimal("1.5").intValue(), n.intValue()); + } + + @Test + void intValue_fallsBackToLongAndNarrows() { + // 2147483648 = Integer.MAX_VALUE + 1 + String s = "2147483648"; + LazilyParsedNumber n = new LazilyParsedNumber(s); + + // Long.parseLong works, intValue uses (int) long → Overflows to MIN_VALUE + long expectedLong = Long.parseLong(s); + int expectedInt = (int) expectedLong; + + assertEquals(expectedLong, n.longValue()); + assertEquals(expectedInt, n.intValue()); + } + + @Test + void longAndInt_fallBackToBigDecimalWhenTooLargeForLong() { + // Long.MAX_VALUE + 1 -> Long.parseLong causes NFE, BigDecimal will be used + String s = "9223372036854775808"; + LazilyParsedNumber n = new LazilyParsedNumber(s); + + long expectedLong = new BigDecimal(s).longValue(); + int expectedInt = new BigDecimal(s).intValue(); + + assertEquals(expectedLong, n.longValue()); + assertEquals(expectedInt, n.intValue()); + assertEquals(Double.parseDouble(s), n.doubleValue(), 0.0); + } + + @Test + void equals_and_hashCode_behave() { + LazilyParsedNumber a1 = new LazilyParsedNumber("42"); + LazilyParsedNumber a2 = new LazilyParsedNumber("42"); + LazilyParsedNumber b = new LazilyParsedNumber("043"); + + assertEquals(a1, a1); // reflexive + assertEquals(a1, a2); // same string content + assertNotEquals(a1, b); // other content + assertNotEquals(a1, new Object()); // other type + + assertEquals("42".hashCode(), a1.hashCode()); + } + + @Test + void serialization_usesWriteReplace_returnsBigDecimalOnRead() throws Exception { + LazilyParsedNumber n = new LazilyParsedNumber("123.45"); + Object roundTripped = roundTrip(n); + + assertTrue(roundTripped instanceof BigDecimal, "Deserialize should return BigDecimal"); + assertEquals(new BigDecimal("123.45"), roundTripped); + } + + private static Object roundTrip(Object o) throws IOException, ClassNotFoundException { + byte[] bytes; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(o); + oos.flush(); + bytes = baos.toByteArray(); + } + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + return ois.readObject(); + } + } +} diff --git a/src/test/java/de/marhali/json5/internal/NonNullElementWrapperListTest.java b/src/test/java/de/marhali/json5/internal/NonNullElementWrapperListTest.java new file mode 100644 index 0000000..7a43866 --- /dev/null +++ b/src/test/java/de/marhali/json5/internal/NonNullElementWrapperListTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +class NonNullElementWrapperListTest { + + private static NonNullElementWrapperList wrap(ArrayList delegate) { + return new NonNullElementWrapperList<>(delegate); + } + + @Test + void constructor_nullDelegate_throwsNPE() { + assertThrows(NullPointerException.class, () -> new NonNullElementWrapperList<>(null)); + } + + @Test + void isRandomAccess() { + NonNullElementWrapperList list = wrap(new ArrayList<>()); + assertTrue(list instanceof RandomAccess, "Wrapper should implement RandomAccess"); + } + + @Test + void size_get_add_nonNull_ok() { + NonNullElementWrapperList list = wrap(new ArrayList<>()); + assertEquals(0, list.size()); + list.add(0, "a"); + list.add(1, "b"); + assertEquals(2, list.size()); + assertEquals("a", list.get(0)); + assertEquals("b", list.get(1)); + } + + @Test + void add_null_throwsNPE() { + NonNullElementWrapperList list = wrap(new ArrayList<>()); + NullPointerException ex = assertThrows(NullPointerException.class, () -> list.add(0, null)); + assertTrue(ex.getMessage() == null || ex.getMessage().toLowerCase().contains("non-null")); + } + + @Test + void set_replacesAndReturnsOld_nonNull_ok() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("x", "y"))); + String old = list.set(1, "z"); + assertEquals("y", old); + assertEquals(Arrays.asList("x", "z"), new ArrayList<>(list)); + } + + @Test + void set_null_throwsNPE() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("x"))); + assertThrows(NullPointerException.class, () -> list.set(0, null)); + assertEquals(Collections.singletonList("x"), new ArrayList<>(list)); + } + + @Test + void contains_and_indexOf_withNull_doNotThrow() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("a", "b"))); + assertFalse(list.contains(null)); + assertEquals(-1, list.indexOf(null)); + assertEquals(-1, list.lastIndexOf(null)); + } + + @Test + void remove_byIndex_and_byObject_behave() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("a", "b", "c"))); + assertEquals("b", list.remove(1)); + assertEquals(Arrays.asList("a", "c"), new ArrayList<>(list)); + + // remove(Object) with null must not throw and should return false + assertDoesNotThrow(() -> { + boolean removed = list.remove((Object) null); + assertFalse(removed); + }); + + // remove existing object + assertTrue(list.remove("a")); + assertEquals(Collections.singletonList("c"), new ArrayList<>(list)); + } + + @Test + void clear_removeAll_retainAll_delegateCorrectly() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("a", "b", "c", "d"))); + assertTrue(list.removeAll(Arrays.asList("b", "x"))); // removes b + assertEquals(Arrays.asList("a", "c", "d"), new ArrayList<>(list)); + + assertTrue(list.retainAll(new HashSet<>(Arrays.asList("a", "d")))); // keeps a,d + assertEquals(Arrays.asList("a", "d"), new ArrayList<>(list)); + + list.clear(); + assertTrue(list.isEmpty()); + } + + @Test + void toArray_variants_matchDelegate() { + NonNullElementWrapperList list = wrap(new ArrayList<>(Arrays.asList("a", "b"))); + Object[] arr = list.toArray(); + assertArrayEquals(new Object[]{"a", "b"}, arr); + + String[] into = new String[0]; + String[] arr2 = list.toArray(into); + assertArrayEquals(new String[]{"a", "b"}, arr2); + } + + @Test + void equals_and_hashCode_delegateBehavior_and_symmetry() { + ArrayList base = new ArrayList<>(Arrays.asList("a", "b")); + NonNullElementWrapperList w1 = wrap(new ArrayList<>(base)); + NonNullElementWrapperList w2 = wrap(new ArrayList<>(base)); + ArrayList otherList = new ArrayList<>(base); + + // equals compares by elements via delegate.equals(o) + assertEquals(w1, w2); // wrapper vs wrapper + assertEquals(w1, otherList); // wrapper vs plain List + assertEquals(otherList, w1); // symmetry from the other side + + assertEquals(otherList.hashCode(), w1.hashCode()); + // change elements -> not equal + w2.add(2, "c"); + assertNotEquals(w1, w2); + } +} diff --git a/src/test/java/de/marhali/json5/internal/NumberLimitsTest.java b/src/test/java/de/marhali/json5/internal/NumberLimitsTest.java new file mode 100644 index 0000000..456d5eb --- /dev/null +++ b/src/test/java/de/marhali/json5/internal/NumberLimitsTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2025 Marcel Haßlinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.marhali.json5.internal; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Marcel Haßlinger + */ +public class NumberLimitsTest { + private static String repeat(char c, int n) { + StringBuilder sb = new StringBuilder(n); + for (int i = 0; i < n; i++) sb.append(c); + return sb.toString(); + } + + @Test + void parseBigInteger_allows_exactly_10000_chars() { + String s = repeat('7', 10_000); + BigInteger bi = assertDoesNotThrow(() -> NumberLimits.parseBigInteger(s)); + assertEquals(new BigInteger(s), bi); + } + + @Test + void parseBigInteger_rejects_length_over_10000_and_includes_prefix_in_message() { + String s = repeat('9', 10_001); + NumberFormatException ex = + assertThrows(NumberFormatException.class, () -> NumberLimits.parseBigInteger(s)); + String expectedPrefix = "Number string too large: " + s.substring(0, 30) + "..."; + assertTrue(ex.getMessage().startsWith(expectedPrefix), + () -> "Msg mismatch.\nExpected prefix: " + expectedPrefix + "\nActual: " + ex.getMessage()); + } + + @Test + void parseBigDecimal_allows_exactly_10000_chars() { + String s = repeat('1', 10_000); + BigDecimal bd = assertDoesNotThrow(() -> NumberLimits.parseBigDecimal(s)); + assertEquals(new BigDecimal(s), bd); + } + + @Test + void parseBigDecimal_rejects_length_over_10000_and_includes_prefix_in_message() { + String s = repeat('3', 10_001); + NumberFormatException ex = + assertThrows(NumberFormatException.class, () -> NumberLimits.parseBigDecimal(s)); + String expectedPrefix = "Number string too large: " + s.substring(0, 30) + "..."; + assertTrue(ex.getMessage().startsWith(expectedPrefix)); + } + + @Test + void parseBigDecimal_allows_scale_abs_9999_both_signs() { + BigDecimal bdNegExp = assertDoesNotThrow(() -> NumberLimits.parseBigDecimal("1E-9999")); + assertEquals(9_999, bdNegExp.scale()); + + BigDecimal bdPosExp = assertDoesNotThrow(() -> NumberLimits.parseBigDecimal("1E+9999")); + assertEquals(-9_999, bdPosExp.scale()); + } + + @Test + void parseBigDecimal_rejects_scale_10000_or_more_negative() { + NumberFormatException ex = + assertThrows(NumberFormatException.class, () -> NumberLimits.parseBigDecimal("1E-10000")); + assertTrue(ex.getMessage().contains("unsupported scale")); + } + + @Test + void parseBigDecimal_rejects_scale_10000_or_more_positive() { + NumberFormatException ex = + assertThrows(NumberFormatException.class, () -> NumberLimits.parseBigDecimal("1E+10000")); + assertTrue(ex.getMessage().contains("unsupported scale")); + } + + @Test + void parseBigDecimal_parses_normal_numbers() { + BigDecimal bd = assertDoesNotThrow(() -> NumberLimits.parseBigDecimal("12345.6789")); + assertEquals(new BigDecimal("12345.6789"), bd); + } + + @Test + void parseBigInteger_parses_normal_numbers() { + BigInteger bi = assertDoesNotThrow(() -> NumberLimits.parseBigInteger("-9007199254740993")); + assertEquals(new BigInteger("-9007199254740993"), bi); + } +} diff --git a/src/main/java/de/marhali/json5/Json5Number.java b/src/test/java/de/marhali/json5/internal/RadixNumberTest.java similarity index 53% rename from src/main/java/de/marhali/json5/Json5Number.java rename to src/test/java/de/marhali/json5/internal/RadixNumberTest.java index a988d6b..566e4c6 100644 --- a/src/main/java/de/marhali/json5/Json5Number.java +++ b/src/test/java/de/marhali/json5/internal/RadixNumberTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Marcel Haßlinger + * Copyright (C) 2025 Marcel Haßlinger * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,30 @@ * limitations under the License. */ -package de.marhali.json5; +package de.marhali.json5.internal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; /** - * A class representing a json5 number value. - * * @author Marcel Haßlinger */ -public final class Json5Number extends Json5Primitive { - public Json5Number(Number number) { - super(number); +public class RadixNumberTest { + + @Test + void test_getNumber() { + assertEquals(187, new RadixNumber(187, 10).getNumber()); } + + @Test + void test_getRadix() { + assertEquals(10, new RadixNumber(187, 10).getRadix()); + } + + @Test + void test_equals() { + assertEquals(new RadixNumber(187, 10), new RadixNumber(187, 10)); + } + } diff --git a/src/test/resources/e2e/failures/disallow-Infinity.json5 b/src/test/resources/e2e/failures/disallow-Infinity.json5 new file mode 100644 index 0000000..1ef65d0 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-Infinity.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": Infinity +} diff --git a/src/test/resources/e2e/failures/disallow-NaN.json5 b/src/test/resources/e2e/failures/disallow-NaN.json5 new file mode 100644 index 0000000..69d0425 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-NaN.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": NaN +} diff --git a/src/test/resources/e2e/failures/disallow-binary-number.json5 b/src/test/resources/e2e/failures/disallow-binary-number.json5 new file mode 100644 index 0000000..b3e13fe --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-binary-number.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": 0b1010 +} diff --git a/src/test/resources/e2e/failures/disallow-c-digit-separator.json5 b/src/test/resources/e2e/failures/disallow-c-digit-separator.json5 new file mode 100644 index 0000000..b530fc4 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-c-digit-separator.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": 1'123'456 +} diff --git a/src/test/resources/e2e/failures/disallow-hex-floating.json5 b/src/test/resources/e2e/failures/disallow-hex-floating.json5 new file mode 100644 index 0000000..ca3f3ee --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-hex-floating.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": 0x1.8p-12 +} diff --git a/src/test/resources/e2e/failures/disallow-java-digit-separator.json5 b/src/test/resources/e2e/failures/disallow-java-digit-separator.json5 new file mode 100644 index 0000000..3034c92 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-java-digit-separator.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": 1_123_456 +} diff --git a/src/test/resources/e2e/failures/disallow-octal-number.json5 b/src/test/resources/e2e/failures/disallow-octal-number.json5 new file mode 100644 index 0000000..881acc6 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-octal-number.json5 @@ -0,0 +1,3 @@ +{ + "disallowed": 0o167 +} diff --git a/src/test/resources/e2e/failures/disallow-trailing-array.json5 b/src/test/resources/e2e/failures/disallow-trailing-array.json5 new file mode 100644 index 0000000..4498874 --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-trailing-array.json5 @@ -0,0 +1 @@ +[]abc diff --git a/src/test/resources/e2e/failures/disallow-trailing-object.json5 b/src/test/resources/e2e/failures/disallow-trailing-object.json5 new file mode 100644 index 0000000..30e290e --- /dev/null +++ b/src/test/resources/e2e/failures/disallow-trailing-object.json5 @@ -0,0 +1 @@ +{}abc diff --git a/src/test/resources/e2e/failures/duplicate-object-key.json5 b/src/test/resources/e2e/failures/duplicate-object-key.json5 new file mode 100644 index 0000000..5f87fe1 --- /dev/null +++ b/src/test/resources/e2e/failures/duplicate-object-key.json5 @@ -0,0 +1,7 @@ +{ + alpha: "firstAlphaValue", + alpha: "secondAlphaValue", + bravo: "bravoValue", + charlie: "firstCharlieValue", + charlie: "secondCharlieValue" +} diff --git a/src/test/resources/e2e/failures/invalid-array-closing-tags.json5 b/src/test/resources/e2e/failures/invalid-array-closing-tags.json5 new file mode 100644 index 0000000..6fcfba7 --- /dev/null +++ b/src/test/resources/e2e/failures/invalid-array-closing-tags.json5 @@ -0,0 +1,2 @@ +[ + "some value", diff --git a/src/test/resources/e2e/failures/invalid-array-missing-comma.json5 b/src/test/resources/e2e/failures/invalid-array-missing-comma.json5 new file mode 100644 index 0000000..895dc69 --- /dev/null +++ b/src/test/resources/e2e/failures/invalid-array-missing-comma.json5 @@ -0,0 +1,4 @@ +[ + [] + [] +] diff --git a/src/test/resources/e2e/failures/invalid-object-closing-tags.json5 b/src/test/resources/e2e/failures/invalid-object-closing-tags.json5 new file mode 100644 index 0000000..e094acc --- /dev/null +++ b/src/test/resources/e2e/failures/invalid-object-closing-tags.json5 @@ -0,0 +1,3 @@ +{ + alpha: "firstAlphaValue", + array: [], diff --git a/src/test/resources/e2e/failures/invalid-object-missing-comma.json5 b/src/test/resources/e2e/failures/invalid-object-missing-comma.json5 new file mode 100644 index 0000000..5e302dd --- /dev/null +++ b/src/test/resources/e2e/failures/invalid-object-missing-comma.json5 @@ -0,0 +1,4 @@ +{ + firstObject: {} + secondObject: {} +} diff --git a/src/test/resources/e2e/failures/invalid-object-missing-key-suffix.json5 b/src/test/resources/e2e/failures/invalid-object-missing-key-suffix.json5 new file mode 100644 index 0000000..96d63ee --- /dev/null +++ b/src/test/resources/e2e/failures/invalid-object-missing-key-suffix.json5 @@ -0,0 +1,3 @@ +{ + "myKey" false, +} diff --git a/src/test/resources/e2e/roundtrips/array-minify-no-comment.writer.json5 b/src/test/resources/e2e/roundtrips/array-minify-no-comment.writer.json5 new file mode 100644 index 0000000..2f2ea62 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array-minify-no-comment.writer.json5 @@ -0,0 +1 @@ +[true,false,"Test \" 123","Test ' 123","Test ' 123","\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",true,0x303030,0x303030,-0x303030,NaN,NaN,NaN,Infinity,Infinity,-Infinity,123,123,-123,{key:"value",array:[true,false]}] diff --git a/src/test/resources/e2e/roundtrips/array-minify-to-prettify.writer.json5 b/src/test/resources/e2e/roundtrips/array-minify-to-prettify.writer.json5 new file mode 100644 index 0000000..79993e7 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array-minify-to-prettify.writer.json5 @@ -0,0 +1,44 @@ +// Multi-line comment on root array element +[ + // Comment on truly primitive boolean + true, + // Comment on falsy primitive boolean + false, + // Double quoted string with escaped quote + "Test \" 123", + // Single quoted string with escaped quote + "Test ' 123", + // String with mixed quotes + "Test ' 123", + // Escapes + "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + // Multi-line comment on primitive value + true, + // Neutral hex number + 0x303030, + // Positive hex number + 0x303030, + // Negative hex number + -0x303030, + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + 123, + 123, + -123, + // Object within array + { + // Sample member + key: "value", + // Nested array + array: [ + // any true + true, + // any false + false, + ], + }, +] diff --git a/src/test/resources/e2e/roundtrips/array-minify.parser.json5 b/src/test/resources/e2e/roundtrips/array-minify.parser.json5 new file mode 100644 index 0000000..e76fde5 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array-minify.parser.json5 @@ -0,0 +1 @@ +/*Multi-line comment on root array element*/[/*Comment on truly primitive boolean*/true,/*Comment on falsy primitive boolean*/false,/*Double quoted string with escaped quote*/"Test \" 123",/*Single quoted string with escaped quote*/'Test \' 123',/*String with mixed quotes*/"Test ' 123",/*Escapes*/"\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",/*Multi-line comment on primitive value*/true,/*Neutral hex number*/0x303030,/*Positive hex number*/0x303030,/*Negative hex number*/-0x303030,NaN,+NaN,-NaN,Infinity,+Infinity,-Infinity,123,+123,-123,/*Object within array*/{/*Sample member*/key:"value",/*Nested array*/array:[/*any true*/true,/*any false*/false]}] diff --git a/src/test/resources/e2e/roundtrips/array-minify.writer.json5 b/src/test/resources/e2e/roundtrips/array-minify.writer.json5 new file mode 100644 index 0000000..fb2c547 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array-minify.writer.json5 @@ -0,0 +1 @@ +/*Multi-line comment on root array element*/[/*Comment on truly primitive boolean*/true,/*Comment on falsy primitive boolean*/false,/*Double quoted string with escaped quote*/"Test \" 123",/*Single quoted string with escaped quote*/"Test ' 123",/*String with mixed quotes*/"Test ' 123",/*Escapes*/"\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",/*Multi-line comment on primitive value*/true,/*Neutral hex number*/0x303030,/*Positive hex number*/0x303030,/*Negative hex number*/-0x303030,NaN,NaN,NaN,Infinity,Infinity,-Infinity,123,123,-123,/*Object within array*/{/*Sample member*/key:"value",/*Nested array*/array:[/*any true*/true,/*any false*/false]}] diff --git a/src/test/resources/e2e/roundtrips/array-no-comment.writer.json5 b/src/test/resources/e2e/roundtrips/array-no-comment.writer.json5 new file mode 100644 index 0000000..e876b8f --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array-no-comment.writer.json5 @@ -0,0 +1,28 @@ +[ + true, + false, + "Test \" 123", + "Test ' 123", + "Test ' 123", + "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + true, + 0x303030, + 0x303030, + -0x303030, + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + 123, + 123, + -123, + { + key: "value", + array: [ + true, + false, + ], + }, +] diff --git a/src/test/resources/e2e/roundtrips/array.parser.json5 b/src/test/resources/e2e/roundtrips/array.parser.json5 new file mode 100644 index 0000000..2c661fb --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array.parser.json5 @@ -0,0 +1,50 @@ +/* + * Multi-line comment + * on root array element + */ +[ + // Comment on truly primitive boolean + true, + // Comment on falsy primitive boolean + false, + // Double quoted string with escaped quote + "Test \" 123", + // Single quoted string with escaped quote + 'Test \' 123', + // String with mixed quotes + "Test ' 123", + // Escapes + "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + /* + * Multi-line comment + * on primitive value + */ + true, + // Neutral hex number + 0x303030, + // Positive hex number + +0x303030, + // Negative hex number + -0x303030, + NaN, + +NaN, + -NaN, + Infinity, + +Infinity, + -Infinity, + 123, + +123, + -123, + // Object within array + { + // Sample member + key: "value", + // Nested array + array: [ + // any true + true, + // any false + false + ] + } +] diff --git a/src/test/resources/e2e/roundtrips/array.writer.json5 b/src/test/resources/e2e/roundtrips/array.writer.json5 new file mode 100644 index 0000000..94d83af --- /dev/null +++ b/src/test/resources/e2e/roundtrips/array.writer.json5 @@ -0,0 +1,50 @@ +/* + * Multi-line comment + * on root array element + */ +[ + // Comment on truly primitive boolean + true, + // Comment on falsy primitive boolean + false, + // Double quoted string with escaped quote + "Test \" 123", + // Single quoted string with escaped quote + "Test ' 123", + // String with mixed quotes + "Test ' 123", + // Escapes + "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + /* + * Multi-line comment + * on primitive value + */ + true, + // Neutral hex number + 0x303030, + // Positive hex number + 0x303030, + // Negative hex number + -0x303030, + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + 123, + 123, + -123, + // Object within array + { + // Sample member + key: "value", + // Nested array + array: [ + // any true + true, + // any false + false, + ], + }, +] diff --git a/src/test/resources/e2e/roundtrips/extensions-c-style.parser.json5 b/src/test/resources/e2e/roundtrips/extensions-c-style.parser.json5 new file mode 100644 index 0000000..50ccc37 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/extensions-c-style.parser.json5 @@ -0,0 +1,16 @@ +// Number extensions with digit separator C-style +{ + decimalNumber: 1234567, + decimalNumberNegative: -11234567, + decimalNumberPositive: +111234567, + binaryNumber: 0b10101100, + binaryNumberNegative: -0b101100, + binaryNumberPositive: +0b1101100, + octalNumber: 0o1234567, + octalNumberNegative: -0o12345671, + octalNumberPositive: +0o123456712, + hexNumber: 0xDEADBEEF, + hexNumberNegative: -0xADBEEFA, + hexNumberPositive: +0xEADBEEFB, + hexFloatingPoint: 0x1.8p-12 +} diff --git a/src/test/resources/e2e/roundtrips/extensions-c-style.writer.json5 b/src/test/resources/e2e/roundtrips/extensions-c-style.writer.json5 new file mode 100644 index 0000000..d5408a7 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/extensions-c-style.writer.json5 @@ -0,0 +1,16 @@ +// Number extensions with digit separator C-style +{ + decimalNumber: 1'234'567, + decimalNumberNegative: -11'234'567, + decimalNumberPositive: 111'234'567, + binaryNumber: 0b1010'1100, + binaryNumberNegative: -0b10'1100, + binaryNumberPositive: 0b110'1100, + octalNumber: 0o1'234'567, + octalNumberNegative: -0o12'345'671, + octalNumberPositive: 0o123'456'712, + hexNumber: 0xde'ad'be'ef, + hexNumberNegative: -0xa'db'ee'fa, + hexNumberPositive: 0xea'db'ee'fb, + hexFloatingPoint: 0x18'00, +} diff --git a/src/test/resources/e2e/roundtrips/extensions-java-style.parser.json5 b/src/test/resources/e2e/roundtrips/extensions-java-style.parser.json5 new file mode 100644 index 0000000..42beedd --- /dev/null +++ b/src/test/resources/e2e/roundtrips/extensions-java-style.parser.json5 @@ -0,0 +1,16 @@ +// Number extensions with digit separator Java-style +{ + decimalNumber: 1234567, + decimalNumberNegative: -11234567, + decimalNumberPositive: +111234567, + binaryNumber: 0b10101100, + binaryNumberNegative: -0b101100, + binaryNumberPositive: +0b1101100, + octalNumber: 0o1234567, + octalNumberNegative: -0o12345671, + octalNumberPositive: +0o123456712, + hexNumber: 0xDEADBEEF, + hexNumberNegative: -0xADBEEFA, + hexNumberPositive: +0xEADBEEFB, + hexFloatingPoint: 0x1800, +} diff --git a/src/test/resources/e2e/roundtrips/extensions-java-style.writer.json5 b/src/test/resources/e2e/roundtrips/extensions-java-style.writer.json5 new file mode 100644 index 0000000..566f46f --- /dev/null +++ b/src/test/resources/e2e/roundtrips/extensions-java-style.writer.json5 @@ -0,0 +1,16 @@ +// Number extensions with digit separator Java-style +{ + decimalNumber: 1_234_567, + decimalNumberNegative: -11_234_567, + decimalNumberPositive: 111_234_567, + binaryNumber: 0b1010_1100, + binaryNumberNegative: -0b10_1100, + binaryNumberPositive: 0b110_1100, + octalNumber: 0o1_234_567, + octalNumberNegative: -0o12_345_671, + octalNumberPositive: 0o123_456_712, + hexNumber: 0xde_ad_be_ef, + hexNumberNegative: -0xa_db_ee_fa, + hexNumberPositive: 0xea_db_ee_fb, + hexFloatingPoint: 0x18_00, +} diff --git a/src/test/resources/e2e/roundtrips/object-minify-no-comment.writer.json5 b/src/test/resources/e2e/roundtrips/object-minify-no-comment.writer.json5 new file mode 100644 index 0000000..bbcb535 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object-minify-no-comment.writer.json5 @@ -0,0 +1 @@ +{quotes:{doubleQuoted:"Test \" 123",singleQuoted:"Test ' 123",testMixedQuoted:"Test ' 123"},falsy:false,truly:true,escapes:"\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",memberNames:{$LoremA_Ipsum123指事字:0},multiLineComment:false,hexNumbers:[0x303030,0x303030,-0x303030],specialNumbers:[NaN,NaN,NaN,Infinity,Infinity,-Infinity],literals:[123,123,-123]} diff --git a/src/test/resources/e2e/roundtrips/object-minify-to-prettify.writer.json5 b/src/test/resources/e2e/roundtrips/object-minify-to-prettify.writer.json5 new file mode 100644 index 0000000..4741edf --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object-minify-to-prettify.writer.json5 @@ -0,0 +1,49 @@ +// Object comment +{ + // Quotes tests + quotes: { + // Test double-quoted member name and string value + doubleQuoted: "Test \" 123", + // Test single-quoted member name and string value + singleQuoted: "Test ' 123", + // Test mixed quotes with double- and single-quotes + testMixedQuoted: "Test ' 123", + }, + // Falsy boolean, + falsy: false, + // Truly boolean + truly: true, + // Test escape characters + escapes: "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + // Test member names + memberNames: { + // Test member names + $LoremA_Ipsum123指事字: 0, + }, + // Some multi-line comment + multiLineComment: false, + // Hex numbers + hexNumbers: [ + // Neutral hex number + 0x303030, + // Positive hex number + 0x303030, + // Negative hex number + -0x303030, + ], + // Special numbers + specialNumbers: [ + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + ], + // Literals + literals: [ + 123, + 123, + -123, + ], +} diff --git a/src/test/resources/e2e/roundtrips/object-minify.parser.json5 b/src/test/resources/e2e/roundtrips/object-minify.parser.json5 new file mode 100644 index 0000000..75cd438 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object-minify.parser.json5 @@ -0,0 +1 @@ +/*Object comment*/{/*Quotes tests*/quotes:{/*Test double-quoted member name and string value*/"doubleQuoted":"Test \" 123",/*Test single-quoted member name and string value*/'singleQuoted':'Test \' 123',/*Test mixed quotes with double- and single-quotes*/testMixedQuoted:"Test ' 123"},/*Falsy boolean,*/falsy:false,/*Truly boolean*/truly:true,/*Test escape characters*/escapes:"\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",/*Test member names*/memberNames:{/*Test member names*/"$Lorem\u0041_Ipsum123指事字":0},/*Some multi-line comment*/multiLineComment:false,/*Hex numbers*/hexNumbers:[/*Neutral hex number*/0x303030,/*Positive hex number*/+0x303030,/*Negative hex number*/-0x303030],/*Special numbers*/specialNumbers:[NaN,+NaN,-NaN,Infinity,+Infinity,-Infinity],/*Literals*/literals:[123,+123,-123]} diff --git a/src/test/resources/e2e/roundtrips/object-minify.writer.json5 b/src/test/resources/e2e/roundtrips/object-minify.writer.json5 new file mode 100644 index 0000000..cfedbda --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object-minify.writer.json5 @@ -0,0 +1 @@ +/*Object comment*/{/*Quotes tests*/quotes:{/*Test double-quoted member name and string value*/doubleQuoted:"Test \" 123",/*Test single-quoted member name and string value*/singleQuoted:"Test ' 123",/*Test mixed quotes with double- and single-quotes*/testMixedQuoted:"Test ' 123"},/*Falsy boolean,*/falsy:false,/*Truly boolean*/truly:true,/*Test escape characters*/escapes:"\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F",/*Test member names*/memberNames:{/*Test member names*/$LoremA_Ipsum123指事字:0},/*Some multi-line comment*/multiLineComment:false,/*Hex numbers*/hexNumbers:[/*Neutral hex number*/0x303030,/*Positive hex number*/0x303030,/*Negative hex number*/-0x303030],/*Special numbers*/specialNumbers:[NaN,NaN,NaN,Infinity,Infinity,-Infinity],/*Literals*/literals:[123,123,-123]} diff --git a/src/test/resources/e2e/roundtrips/object-no-comment.writer.json5 b/src/test/resources/e2e/roundtrips/object-no-comment.writer.json5 new file mode 100644 index 0000000..0930cc1 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object-no-comment.writer.json5 @@ -0,0 +1,32 @@ +{ + quotes: { + doubleQuoted: "Test \" 123", + singleQuoted: "Test ' 123", + testMixedQuoted: "Test ' 123", + }, + falsy: false, + truly: true, + escapes: "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + memberNames: { + $LoremA_Ipsum123指事字: 0, + }, + multiLineComment: false, + hexNumbers: [ + 0x303030, + 0x303030, + -0x303030, + ], + specialNumbers: [ + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + ], + literals: [ + 123, + 123, + -123, + ], +} diff --git a/src/test/resources/e2e/roundtrips/object.parser.json5 b/src/test/resources/e2e/roundtrips/object.parser.json5 new file mode 100644 index 0000000..18de9ca --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object.parser.json5 @@ -0,0 +1,52 @@ +// Object comment +{ + // Quotes tests + quotes: { + // Test double-quoted member name and string value + "doubleQuoted": "Test \" 123", + // Test single-quoted member name and string value + 'singleQuoted': 'Test \' 123', + // Test mixed quotes with double- and single-quotes + testMixedQuoted: "Test ' 123" + }, + // Falsy boolean, + falsy: false, + // Truly boolean + truly: true, + // Test escape characters + "escapes": "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + // Test member names + memberNames: { + // Test member names + "$Lorem\u0041_Ipsum123指事字": 0 + }, + /* + * Some multi-line + * comment + */ + multiLineComment: false, + // Hex numbers + hexNumbers: [ + // Neutral hex number + 0x303030, + // Positive hex number + +0x303030, + // Negative hex number + -0x303030 + ], + // Special numbers + specialNumbers: [ + NaN, + +NaN, + -NaN, + Infinity, + +Infinity, + -Infinity + ], + // Literals + literals: [ + 123, + +123, + -123 + ] +} diff --git a/src/test/resources/e2e/roundtrips/object.writer.json5 b/src/test/resources/e2e/roundtrips/object.writer.json5 new file mode 100644 index 0000000..7787e25 --- /dev/null +++ b/src/test/resources/e2e/roundtrips/object.writer.json5 @@ -0,0 +1,52 @@ +// Object comment +{ + // Quotes tests + quotes: { + // Test double-quoted member name and string value + doubleQuoted: "Test \" 123", + // Test single-quoted member name and string value + singleQuoted: "Test ' 123", + // Test mixed quotes with double- and single-quotes + testMixedQuoted: "Test ' 123", + }, + // Falsy boolean, + falsy: false, + // Truly boolean + truly: true, + // Test escape characters + escapes: "\\n\\r\\f\\b\\t\\u000B\\0\\u12Fa\\u007F", + // Test member names + memberNames: { + // Test member names + $LoremA_Ipsum123指事字: 0, + }, + /* + * Some multi-line + * comment + */ + multiLineComment: false, + // Hex numbers + hexNumbers: [ + // Neutral hex number + 0x303030, + // Positive hex number + 0x303030, + // Negative hex number + -0x303030, + ], + // Special numbers + specialNumbers: [ + NaN, + NaN, + NaN, + Infinity, + Infinity, + -Infinity, + ], + // Literals + literals: [ + 123, + 123, + -123, + ], +} diff --git a/src/test/resources/expect.array.json5 b/src/test/resources/expect.array.json5 deleted file mode 100644 index cffdc2a..0000000 --- a/src/test/resources/expect.array.json5 +++ /dev/null @@ -1,18 +0,0 @@ -[ - 'Lorem ipsum', - 'Lorem ipsum', - true, - false, - 123, - -123, - 0x100, - -0x100, - NaN, - Infinity, - null, - { - 'key': 'value', - 'array': [], - 'obj': {} - } -] \ No newline at end of file diff --git a/src/test/resources/expect.object.json5 b/src/test/resources/expect.object.json5 deleted file mode 100644 index 5f84ee9..0000000 --- a/src/test/resources/expect.object.json5 +++ /dev/null @@ -1,25 +0,0 @@ -{ - 'str': 'Lorem ipsum', - 'bool': [ - true, - false - ], - 'numbers': { - 'pos': 123, - 'neg': -123, - 'hex': 0x100, - 'hex-neg': -0x100, - 'nan': NaN, - 'infinity': Infinity - }, - 'null': null, - 'array': [], - 'obj': {}, - 'arrayVariant': [ - 'Lorem ipsum', - [ - 'first', - 'second' - ] - ] -} \ No newline at end of file diff --git a/src/test/resources/test.array.json5 b/src/test/resources/test.array.json5 deleted file mode 100644 index ffcae97..0000000 --- a/src/test/resources/test.array.json5 +++ /dev/null @@ -1,18 +0,0 @@ -[ - "Lorem ipsum", - 'Lorem ipsum', - true, - false, - 123, - -123, - 0x100, - -0x100, - NaN, - Infinity, - null, - { - "key": "value", - "array": [], - "obj": {}, - } -] \ No newline at end of file diff --git a/src/test/resources/test.object.json5 b/src/test/resources/test.object.json5 deleted file mode 100644 index ac83c20..0000000 --- a/src/test/resources/test.object.json5 +++ /dev/null @@ -1,25 +0,0 @@ -{ - 'str': "Lorem ipsum", - "bool": [ - true, - false - ], - "numbers": { - "pos": 123, - "neg": -123, - "hex": 0x100, - "hex-neg": -0x100, - "nan": NaN, - "infinity": Infinity - }, - "null": null, - 'array': [], - 'obj': {}, - "arrayVariant": [ - "Lorem ipsum", - [ - "first", - "second" - ] - ] -} \ No newline at end of file