SimpleJsonParser.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.internal.css.core;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Small dependency-free JSON parser used for CSS tool configuration and reports. */
final class SimpleJsonParser {
private final String input;
private int offset;
private SimpleJsonParser(String input) {
this.input = input;
}
static Object parse(String input) {
if (input == null) {
throw new IllegalArgumentException("JSON input must not be null"); //$NON-NLS-1$
}
SimpleJsonParser parser = new SimpleJsonParser(input);
Object value = parser.parseValue();
parser.skipWhitespace();
if (parser.offset != input.length()) {
throw parser.error("Unexpected trailing content"); //$NON-NLS-1$
}
return value;
}
@SuppressWarnings("unchecked")
static Map<String, Object> parseObject(String input) {
Object value = parse(input);
if (!(value instanceof Map<?, ?>)) {
throw new IllegalArgumentException("Expected a JSON object"); //$NON-NLS-1$
}
return (Map<String, Object>) value;
}
private Object parseValue() {
skipWhitespace();
if (offset >= input.length()) {
throw error("Expected JSON value"); //$NON-NLS-1$
}
return switch (input.charAt(offset)) {
case '{' -> parseObjectValue();
case '[' -> parseArray();
case '"' -> parseString();
case 't' -> parseLiteral("true", Boolean.TRUE); //$NON-NLS-1$
case 'f' -> parseLiteral("false", Boolean.FALSE); //$NON-NLS-1$
case 'n' -> parseLiteral("null", null); //$NON-NLS-1$
default -> parseNumber();
};
}
private Map<String, Object> parseObjectValue() {
expect('{');
Map<String, Object> result = new LinkedHashMap<>();
skipWhitespace();
if (consume('}')) {
return result;
}
while (true) {
skipWhitespace();
if (offset >= input.length() || input.charAt(offset) != '"') {
throw error("Expected object key"); //$NON-NLS-1$
}
String key = parseString();
skipWhitespace();
expect(':');
result.put(key, parseValue());
skipWhitespace();
if (consume('}')) {
return result;
}
expect(',');
}
}
private List<Object> parseArray() {
expect('[');
List<Object> result = new ArrayList<>();
skipWhitespace();
if (consume(']')) {
return result;
}
while (true) {
result.add(parseValue());
skipWhitespace();
if (consume(']')) {
return result;
}
expect(',');
}
}
private String parseString() {
expect('"');
StringBuilder result = new StringBuilder();
while (offset < input.length()) {
char current = input.charAt(offset++);
if (current == '"') {
return result.toString();
}
if (current != '\\') {
if (current < 0x20) {
throw error("Control character in JSON string"); //$NON-NLS-1$
}
result.append(current);
continue;
}
if (offset >= input.length()) {
throw error("Unterminated JSON escape"); //$NON-NLS-1$
}
char escaped = input.charAt(offset++);
switch (escaped) {
case '"', '\\', '/' -> result.append(escaped);
case 'b' -> result.append('\b');
case 'f' -> result.append('\f');
case 'n' -> result.append('\n');
case 'r' -> result.append('\r');
case 't' -> result.append('\t');
case 'u' -> result.append(parseUnicodeEscape());
default -> throw error("Unsupported JSON escape: \\" + escaped); //$NON-NLS-1$
}
}
throw error("Unterminated JSON string"); //$NON-NLS-1$
}
private char parseUnicodeEscape() {
if (offset + 4 > input.length()) {
throw error("Incomplete Unicode escape"); //$NON-NLS-1$
}
int value = 0;
for (int i = 0; i < 4; i++) {
int digit = Character.digit(input.charAt(offset++), 16);
if (digit < 0) {
throw error("Invalid Unicode escape"); //$NON-NLS-1$
}
value = (value << 4) | digit;
}
return (char) value;
}
private Object parseLiteral(String literal, Object value) {
if (!input.startsWith(literal, offset)) {
throw error("Expected " + literal); //$NON-NLS-1$
}
offset += literal.length();
return value;
}
private BigDecimal parseNumber() {
int start = offset;
consume('-');
if (consume('0')) {
// A leading zero is complete unless a fraction/exponent follows.
} else {
consumeDigits(true);
}
if (consume('.')) {
consumeDigits(true);
}
if (consume('e') || consume('E')) {
if (!consume('+')) {
consume('-');
}
consumeDigits(true);
}
if (start == offset) {
throw error("Expected JSON value"); //$NON-NLS-1$
}
try {
return new BigDecimal(input.substring(start, offset));
} catch (NumberFormatException e) {
throw error("Invalid JSON number"); //$NON-NLS-1$
}
}
private void consumeDigits(boolean atLeastOne) {
int start = offset;
while (offset < input.length() && Character.isDigit(input.charAt(offset))) {
offset++;
}
if (atLeastOne && start == offset) {
throw error("Expected digit"); //$NON-NLS-1$
}
}
private boolean consume(char expected) {
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void expect(char expected) {
if (!consume(expected)) {
throw error("Expected '" + expected + "'"); //$NON-NLS-1$ //$NON-NLS-2$
}
}
private void skipWhitespace() {
while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) {
offset++;
}
}
private IllegalArgumentException error(String message) {
return new IllegalArgumentException(message + " at character " + offset); //$NON-NLS-1$
}
}