Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ This project targets Java 17 and above.
- Add JavaDoc comments for public APIs
- Format code consistently (use IDE auto-formatting)

### Null Safety

This project enforces null safety at compile time using **[NullAway](https://github.com/uber/nullaway)** (an Error Prone checker) and **[JSpecify](https://jspecify.dev/)** annotations.

- All packages are annotated `@NullMarked` — types are non-null by default
- Use `@Nullable` to explicitly mark parameters, fields, or return values that may be `null`
- The `./gradlew compileJava` build step will fail if NullAway detects any null-safety violations
- Do **not** suppress NullAway warnings with `@SuppressWarnings("NullAway")` unless there is a well-documented reason (e.g., a third-party API that returns nullable values without annotations)

### Testing

- All new features must include JUnit tests
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

Compact, human-readable serialization format for LLM contexts with **30-60% token reduction** vs JSON. Combines YAML-like indentation with CSV-like tabular arrays. Working towards full compatibility with the [official TOON specification](https://github.com/toon-format/spec).

**Key Features:** Minimal syntax • TOON Encoding and Decoding • Tabular arrays for uniform data • Array length validation • Java 17 • full [Jackson Annotation](https://github.com/FasterXML/jackson-annotations) Support • Comprehensive test coverage.
**Key Features:** Minimal syntax • TOON Encoding and Decoding • Tabular arrays for uniform data • Array length validation • Java 17 • full [Jackson Annotation](https://github.com/FasterXML/jackson-annotations) Support • Null-safe by default (NullAway + JSpecify) • Comprehensive test coverage.

## Installation

Expand Down
39 changes: 37 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id 'java'
id 'java-library'
id 'maven-publish'
id 'signing'
id 'jacoco'
Expand All @@ -9,6 +9,7 @@ plugins {
id 'org.owasp.dependencycheck' version '12.2.2'
id 'org.cyclonedx.bom' version '3.2.4'
id 'info.solidsoft.pitest' version '1.19.0'
id 'net.ltgt.errorprone' version '5.1.0'
}

group = 'dev.toonformat'
Expand Down Expand Up @@ -125,7 +126,7 @@ dependencyCheck {
}

tasks.cyclonedxBom {
projectType = 'library'
projectType = Component.Type.LIBRARY
jsonOutput.set(file("build/reports/jtoon-bom.json"))
xmlOutput.unsetConvention()
}
Expand All @@ -146,6 +147,17 @@ dependencies {
implementation 'tools.jackson.core:jackson-databind:3.2.0'
implementation 'tools.jackson.module:jackson-module-blackbird:3.2.0'
compileOnly 'com.github.spotbugs:spotbugs-annotations:4.10.2'

// NullAway + Error Prone for compile-time null safety
errorprone 'com.uber.nullaway:nullaway:0.13.7'
// Pin error_prone_core to 2.42.0 (Java 17 compatible; 2.50.0+ requires Java 21)
errorprone('com.google.errorprone:error_prone_core:2.42.0') {
version {
strictly '2.42.0'
}
}
api 'org.jspecify:jspecify:1.0.0'

testImplementation platform('org.junit:junit-bom:6.1.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand All @@ -156,6 +168,29 @@ dependencies {
testAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}

// ---- NullAway / Error Prone Configuration ----
import net.ltgt.gradle.errorprone.CheckSeverity
import org.cyclonedx.model.Component

tasks.withType(JavaCompile).configureEach {
options.errorprone {
// NullAway as ERROR-Level for main source set
check('NullAway', CheckSeverity.ERROR)
// Annotate Packages
option('NullAway:AnnotatedPackages', 'dev.toonformat')
// Generated-Code ignore (Jackson, etc.)
option('NullAway:TreatGeneratedAsUnannotated', 'true')
}
}

// no NullAway for tests; exclude JMH-generated sources from ErrorProne
tasks.compileTestJava {
options.errorprone {
disable('NullAway')
excludedPaths = '.*/build/generated/.*'
}
}

// Check if running in CI or coverage is explicitly requested
boolean isCi = System.getenv('CI') == 'true'
boolean withCoverage = project.hasProperty('withCoverage') || isCi
Expand Down
2,979 changes: 2,979 additions & 0 deletions gradle/verification-metadata.dryrun.xml

Large diffs are not rendered by default.

370 changes: 222 additions & 148 deletions gradle/verification-metadata.xml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/main/java/dev/toonformat/jtoon/DecodeOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public DecodeOptions() {
if (indent > MAX_ALLOWED_INDENT) {
throw new IllegalArgumentException("indent must be <= " + MAX_ALLOWED_INDENT + ", got: " + indent);
}
delimiter = Objects.requireNonNull(delimiter, "delimiter cannot be null");
Objects.requireNonNull(delimiter, "delimiter cannot be null");
if (maxDepth <= 0) {
throw new IllegalArgumentException("maxDepth must be positive, got: " + maxDepth);
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/dev/toonformat/jtoon/EncodeOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public EncodeOptions() {
if (indent > MAX_ALLOWED_INDENT) {
throw new IllegalArgumentException("indent must be <= " + MAX_ALLOWED_INDENT + ", got: " + indent);
}
delimiter = Objects.requireNonNull(delimiter, "delimiter cannot be null");
Objects.requireNonNull(delimiter, "delimiter cannot be null");
if (flattenDepth < 0) {
throw new IllegalArgumentException("flattenDepth must be non-negative, got: " + flattenDepth);
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/dev/toonformat/jtoon/JToon.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dev.toonformat.jtoon.decoder.ValueDecoder;
import dev.toonformat.jtoon.encoder.ValueEncoder;
import dev.toonformat.jtoon.normalizer.JsonNormalizer;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
import java.util.Objects;

Expand Down Expand Up @@ -99,6 +100,7 @@ public static String encodeJson(final String json, final EncodeOptions options)
* @throws IllegalArgumentException if strict mode is enabled and input is
* invalid
*/
@Nullable
public static Object decode(final String toon) {
return decode(toon, DecodeOptions.DEFAULT);
}
Expand All @@ -118,6 +120,7 @@ public static Object decode(final String toon) {
* invalid
* @throws NullPointerException if options is null
*/
@Nullable
public static Object decode(final String toon, final DecodeOptions options) {
Objects.requireNonNull(options, "DecodeOptions cannot be null");
return ValueDecoder.decode(toon, options);
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/dev/toonformat/jtoon/decoder/ArrayDecoder.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.toonformat.jtoon.decoder;

import dev.toonformat.jtoon.Delimiter;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -104,7 +105,7 @@ static List<Object> parseArrayWithDelimiter(final String header, final int depth
context.options.maxArraySize(), context.options.maxStringLength());
validateArrayLength(header, result.size(), context.options.maxArraySize());
context.currentLine++;
return result;
return Collections.unmodifiableList(result);
}
}

Expand All @@ -123,18 +124,18 @@ static List<Object> parseArrayWithDelimiter(final String header, final int depth

if (nextContent.startsWith(LIST_ITEM_PREFIX)) {
context.currentLine--;
return parseListArray(depth, header, context);
return Collections.unmodifiableList(parseListArray(depth, header, context));
} else {
context.currentLine++;
final List<Object> result = parseArrayValues(nextContent, arrayDelimiter,
context.options.maxArraySize(), context.options.maxStringLength());
validateArrayLength(header, result.size(), context.options.maxArraySize());
return result;
return Collections.unmodifiableList(result);
}
}
final List<Object> empty = new ArrayList<>();
validateArrayLength(header, 0, context.options.maxArraySize());
return empty;
return Collections.unmodifiableList(empty);
}

if (context.options.strict()) {
Expand Down Expand Up @@ -165,6 +166,7 @@ static void validateArrayLength(final String header, final int actualLength, fin
* @param maxArraySize maximum allowed array size
* @return extracted length from header, or null if not found
*/
@Nullable
private static Integer extractLengthFromHeader(final String header, final int maxArraySize) {
final Matcher matcher = ARRAY_HEADER_PATTERN.matcher(header);
if (matcher.find()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public class DecodeContext {
*/
public DecodeContext() {
this.currentDepth = 0;
this.lines = new String[0];
this.options = DecodeOptions.DEFAULT;
this.delimiter = Delimiter.COMMA;
}

void incrementDepth() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.toonformat.jtoon.decoder;

import dev.toonformat.jtoon.Delimiter;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.Map;
import static dev.toonformat.jtoon.util.Constants.BACKSLASH;
Expand Down Expand Up @@ -137,7 +138,7 @@ static int findNextNonBlankLine(final int startIndex, final DecodeContext contex
* @param context decode an object to deal with lines, delimiter, and options
* @throws IllegalArgumentException in case there's a expansion conflict
*/
static void checkFinalValueConflict(final String finalSegment, final Object existing,
static void checkFinalValueConflict(final String finalSegment, @Nullable final Object existing,
final Object value, final DecodeContext context) {
if (existing != null && context.options.strict()) {
// Check for conflicts in strict mode
Expand Down Expand Up @@ -195,7 +196,7 @@ static void checkDuplicateKey(final Map<String, Object> map, final String key, f
* @param context decode an object to deal with lines, delimiter and options
* @return the depth of the next non-blank line, or null if none exists
*/
static Integer findNextNonBlankLineDepth(final DecodeContext context) {
static @Nullable Integer findNextNonBlankLineDepth(final DecodeContext context) {
int nextLineIdx = context.currentLine;
while (nextLineIdx < context.lines.length && isBlankLine(context.lines[nextLineIdx])) {
nextLineIdx++;
Expand Down
34 changes: 29 additions & 5 deletions src/main/java/dev/toonformat/jtoon/decoder/KeyDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
import dev.toonformat.jtoon.Delimiter;
import dev.toonformat.jtoon.PathExpansion;
import dev.toonformat.jtoon.util.StringEscaper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.MatchResult;
import static dev.toonformat.jtoon.util.Constants.DOT;
import static dev.toonformat.jtoon.util.Headers.KEYED_ARRAY_PATTERN;

/**
* Handles decoding of key values/arrays to JSON format.
*/
public final class KeyDecoder {

private static final char DOT = '.';

private KeyDecoder() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
Expand Down Expand Up @@ -56,7 +58,7 @@ static void processKeyedArrayLine(final Map<String, Object> result, final String
*/
static void expandPathIntoMap(final Map<String, Object> current, final String dottedKey, final Object value,
final DecodeContext context) {
final String[] segments = dottedKey.split("\\.");
final String[] segments = splitByDot(dottedKey);
Map<String, Object> currentMap = current;

// Navigate/create nested structure
Expand All @@ -71,7 +73,9 @@ static void expandPathIntoMap(final Map<String, Object> current, final String do
currentMap = nested;
} else if (existing instanceof Map<?, ?> existingMap) {
// Use existing nested object; map was created as LinkedHashMap<String, Object>
currentMap = (Map<String, Object>) existingMap;
@SuppressWarnings("unchecked")
final Map<String, Object> typedMap = (Map<String, Object>) existingMap;
currentMap = typedMap;
} else {
// Conflict: existing is not a Map
if (context.options.strict()) {
Expand Down Expand Up @@ -160,13 +164,13 @@ static boolean shouldExpandKey(final String key, final DecodeContext context) {
return false;
}
// Check if a key contains dots and is a valid identifier pattern
if (!key.contains(DOT)) {
if (key.indexOf(DOT) < 0) {
return false;
}
// Valid identifier: starts with a letter or underscore, followed by letters,
// digits, underscores
// Each segment must match this pattern
final String[] segments = key.split("\\.");
final String[] segments = splitByDot(key);
for (String segment : segments) {
if (!segment.matches("^[a-zA-Z_]\\w*$")) {
return false;
Expand Down Expand Up @@ -367,4 +371,24 @@ static boolean parseKeyValueField(final String fieldContent, final Map<String, O
// parseFieldValue manages currentLine appropriately
return true;
}

/**
* Splits a dot-separated string into segments without using regex.
* Matching the default behavior of {@code String.split("\\.")}.
*/
private static String[] splitByDot(final String input) {
final List<String> result = new ArrayList<>();
int start = 0;
for (int i = 0; i < input.length(); i++) {
if (input.charAt(i) == DOT) {
result.add(input.substring(start, i));
start = i + 1;
}
}
final String last = input.substring(start);
if (!last.isEmpty()) {
result.add(last);
}
return result.toArray(new String[0]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,17 @@ private PrimitiveDecoder() {
* @return The parsed value as {@code Boolean}, {@code Long}, {@code Double},
* {@code String}, or {@code null}
*/
@SuppressWarnings("NullAway")
static Object parse(final String value) {
return parse(value, Integer.MAX_VALUE);
}

@SuppressWarnings("NullAway")
static Object parse(final String value, final DecodeContext context) {
return parse(value, context.options.maxStringLength());
}

@SuppressWarnings("NullAway")
static Object parse(final String value, final int maxStringLength) {
if (value == null || value.isEmpty()) {
return "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static List<Object> parseTabularArray(final String header, final int dept
}

ArrayDecoder.validateArrayLength(header, result.size(), context.options.maxArraySize());
return result;
return Collections.unmodifiableList(result);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dev.toonformat.jtoon.DecodeOptions;
import dev.toonformat.jtoon.util.ObjectMapperSingleton;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.regex.Matcher;
Expand Down Expand Up @@ -45,6 +46,7 @@ private ValueDecoder() {
* @throws IllegalArgumentException if strict mode is enabled and input is
* invalid
*/
@Nullable
public static Object decode(final String toon, final DecodeOptions options) {
try {
return decodeInternal(toon, options);
Expand All @@ -56,6 +58,7 @@ public static Object decode(final String toon, final DecodeOptions options) {
}
}

@Nullable
private static Object decodeInternal(final String toon, final DecodeOptions options) {
if (toon == null || toon.isBlank()) {
return new LinkedHashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,7 @@
* @see dev.toonformat.jtoon.JToon#decode(String)
* @see dev.toonformat.jtoon.JToon#decodeToJson(String)
*/
@NullMarked
package dev.toonformat.jtoon.decoder;

import org.jspecify.annotations.NullMarked;
Loading
Loading