From 42853b67068e300f7184ceffa2ff1cc63d5d0b95 Mon Sep 17 00:00:00 2001 From: Looly Date: Tue, 28 Mar 2023 02:04:37 +0800 Subject: [PATCH] add toml support --- .../cn/hutool/core/io/FastStringWriter.java | 37 +- .../main/java/cn/hutool/cron/CronUtil.java | 4 +- hutool-setting/pom.xml | 3 +- ...meException.java => SettingException.java} | 14 +- .../java/cn/hutool/setting/toml/Toml.java | 32 + .../cn/hutool/setting/toml/TomlReader.java | 676 ++++++++++++++++++ .../cn/hutool/setting/toml/TomlWriter.java | 353 +++++++++ .../cn/hutool/setting/toml/package-info.java | 25 + hutool-setting/src/test/resources/test.toml | 33 + 9 files changed, 1159 insertions(+), 18 deletions(-) rename hutool-setting/src/main/java/cn/hutool/setting/{SettingRuntimeException.java => SettingException.java} (61%) create mode 100644 hutool-setting/src/main/java/cn/hutool/setting/toml/Toml.java create mode 100644 hutool-setting/src/main/java/cn/hutool/setting/toml/TomlReader.java create mode 100644 hutool-setting/src/main/java/cn/hutool/setting/toml/TomlWriter.java create mode 100644 hutool-setting/src/main/java/cn/hutool/setting/toml/package-info.java create mode 100644 hutool-setting/src/test/resources/test.toml diff --git a/hutool-core/src/main/java/cn/hutool/core/io/FastStringWriter.java b/hutool-core/src/main/java/cn/hutool/core/io/FastStringWriter.java index 5fa10f97d..0a1930adb 100644 --- a/hutool-core/src/main/java/cn/hutool/core/io/FastStringWriter.java +++ b/hutool-core/src/main/java/cn/hutool/core/io/FastStringWriter.java @@ -25,7 +25,7 @@ public final class FastStringWriter extends Writer { private static final int DEFAULT_CAPACITY = 16; - private final StringBuilder builder; + private final StringBuilder stringBuilder; /** * 构造 @@ -43,31 +43,51 @@ public final class FastStringWriter extends Writer { if (initialSize < 0) { initialSize = DEFAULT_CAPACITY; } - this.builder = new StringBuilder(initialSize); + this.stringBuilder = new StringBuilder(initialSize); } + // region ----- append + @Override + public FastStringWriter append(final char c) { + this.stringBuilder.append(c); + return this; + } + @Override + public FastStringWriter append(final CharSequence csq, final int start, final int end) { + this.stringBuilder.append(csq, start, end); + return this; + } + + @Override + public FastStringWriter append(final CharSequence csq) { + this.stringBuilder.append(csq); + return this; + } + // endregion + + // region ----- write @Override public void write(final int c) { - this.builder.append((char) c); + this.stringBuilder.append((char) c); } @Override public void write(final String str) { - this.builder.append(str); + this.stringBuilder.append(str); } @Override public void write(final String str, final int off, final int len) { - this.builder.append(str, off, off + len); + this.stringBuilder.append(str, off, off + len); } @Override public void write(final char[] cbuf) { - this.builder.append(cbuf, 0, cbuf.length); + this.stringBuilder.append(cbuf, 0, cbuf.length); } @@ -79,8 +99,9 @@ public final class FastStringWriter extends Writer { } else if (len == 0) { return; } - this.builder.append(cbuf, off, len); + this.stringBuilder.append(cbuf, off, len); } + // endregion @Override @@ -97,7 +118,7 @@ public final class FastStringWriter extends Writer { @Override public String toString() { - return this.builder.toString(); + return this.stringBuilder.toString(); } } diff --git a/hutool-cron/src/main/java/cn/hutool/cron/CronUtil.java b/hutool-cron/src/main/java/cn/hutool/cron/CronUtil.java index 696179027..754d59e71 100644 --- a/hutool-cron/src/main/java/cn/hutool/cron/CronUtil.java +++ b/hutool-cron/src/main/java/cn/hutool/cron/CronUtil.java @@ -18,7 +18,7 @@ import cn.hutool.core.io.resource.NoResourceException; import cn.hutool.cron.pattern.CronPattern; import cn.hutool.cron.task.Task; import cn.hutool.setting.Setting; -import cn.hutool.setting.SettingRuntimeException; +import cn.hutool.setting.SettingException; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -58,7 +58,7 @@ public class CronUtil { public static void setCronSetting(final String cronSettingPath) { try { crontabSetting = new Setting(cronSettingPath, Setting.DEFAULT_CHARSET, false); - } catch (final SettingRuntimeException | NoResourceException e) { + } catch (final SettingException | NoResourceException e) { // ignore setting file parse error and no config error } } diff --git a/hutool-setting/pom.xml b/hutool-setting/pom.xml index 39fe4a107..1ef129316 100755 --- a/hutool-setting/pom.xml +++ b/hutool-setting/pom.xml @@ -43,10 +43,11 @@ hutool-log ${project.parent.version} + org.yaml snakeyaml - 1.32 + 2.0 true diff --git a/hutool-setting/src/main/java/cn/hutool/setting/SettingRuntimeException.java b/hutool-setting/src/main/java/cn/hutool/setting/SettingException.java similarity index 61% rename from hutool-setting/src/main/java/cn/hutool/setting/SettingRuntimeException.java rename to hutool-setting/src/main/java/cn/hutool/setting/SettingException.java index 426ac1b44..c5aeda431 100644 --- a/hutool-setting/src/main/java/cn/hutool/setting/SettingRuntimeException.java +++ b/hutool-setting/src/main/java/cn/hutool/setting/SettingException.java @@ -19,30 +19,30 @@ import cn.hutool.core.text.StrUtil; * * @author xiaoleilu */ -public class SettingRuntimeException extends RuntimeException { +public class SettingException extends RuntimeException { private static final long serialVersionUID = 7941096116780378387L; - public SettingRuntimeException(final Throwable e) { + public SettingException(final Throwable e) { super(e); } - public SettingRuntimeException(final String message) { + public SettingException(final String message) { super(message); } - public SettingRuntimeException(final String messageTemplate, final Object... params) { + public SettingException(final String messageTemplate, final Object... params) { super(StrUtil.format(messageTemplate, params)); } - public SettingRuntimeException(final String message, final Throwable throwable) { + public SettingException(final String message, final Throwable throwable) { super(message, throwable); } - public SettingRuntimeException(final String message, final Throwable throwable, final boolean enableSuppression, final boolean writableStackTrace) { + public SettingException(final String message, final Throwable throwable, final boolean enableSuppression, final boolean writableStackTrace) { super(message, throwable, enableSuppression, writableStackTrace); } - public SettingRuntimeException(final Throwable throwable, final String messageTemplate, final Object... params) { + public SettingException(final Throwable throwable, final String messageTemplate, final Object... params) { super(StrUtil.format(messageTemplate, params), throwable); } } diff --git a/hutool-setting/src/main/java/cn/hutool/setting/toml/Toml.java b/hutool-setting/src/main/java/cn/hutool/setting/toml/Toml.java new file mode 100644 index 000000000..782b4957a --- /dev/null +++ b/hutool-setting/src/main/java/cn/hutool/setting/toml/Toml.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 looly(loolly@aliyun.com) + * Hutool is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +package cn.hutool.setting.toml; + +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; + +public class Toml { + /** + * A DateTimeFormatter that uses the TOML format. + */ + public static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .optionalStart() + .appendLiteral('T') + .append(DateTimeFormatter.ISO_LOCAL_TIME) + .optionalStart() + .appendOffsetId() + .optionalEnd() + .optionalEnd() + .toFormatter(); +} diff --git a/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlReader.java b/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlReader.java new file mode 100644 index 000000000..5bf0a00d0 --- /dev/null +++ b/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlReader.java @@ -0,0 +1,676 @@ +/* + * Copyright (c) 2023 looly(loolly@aliyun.com) + * Hutool is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +package cn.hutool.setting.toml; + +import cn.hutool.setting.SettingException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.*; + +/** + * TOML文件读取 + *

DateTimes support

+ *

+ * The datetime support is more extended than in the TOML specification. This reader supports three kind of datetimes: + *

    + *
  1. Full RFC 3339. Examples: 1979-05-27T07:32:00Z, 1979-05-27T00:32:00-07:00, 1979-05-27T00:32:00.999999-07:00
  2. + *
  3. Without local offset. Examples: 1979-05-27T07:32:00, 1979-05-27T00:32:00.999999
  4. + *
  5. Without time (just the date). Example: 2015-03-20
  6. + *
+ * Moreover, parsing datetimes gives different objects according to the informations provided. For example, 2015-03-20 + * is parsed as a {@link LocalDate}, 2015-03-20T19:04:35 as a {@link LocalDateTime}, and 2015-03-20T19:04:35+01:00 as a + * {@link ZonedDateTime}. + *

+ *

Lenient bare keys

+ *

+ * This library allows "lenient" bare keys by default, as opposite to the "strict" bare keys required by the TOML + * specification. Strict bare keys may only contain letters, numbers, underscores, and dashes (A-Za-z0-9_-). Lenient + * bare keys may contain any character except those below the space character ' ' in the unicode table, '.', '[', ']' + * and '='. The behaviour of TomlReader regarding bare keys is set in its constructor. + *

+ * + * @author TheElectronWill + * + */ +public class TomlReader { + + private final String data; + private final boolean strictAsciiBareKeys; + private int pos = 0;// current position + private int line = 1;// current line + + /** + * Creates a new TomlReader. + * + * @param data the TOML data to read + * @param strictAsciiBareKeys true to allow only strict bare keys, {@code false} to allow lenient ones. + */ + public TomlReader(final String data, final boolean strictAsciiBareKeys) { + this.data = data; + this.strictAsciiBareKeys = strictAsciiBareKeys; + } + + private boolean hasNext() { + return pos < data.length(); + } + + private char next() { + return data.charAt(pos++); + } + + private char nextUseful(final boolean skipComments) { + char c = ' '; + while (hasNext() && (c == ' ' || c == '\t' || c == '\r' || c == '\n' || (c == '#' && skipComments))) { + c = next(); + if (skipComments && c == '#') { + final int nextLinebreak = data.indexOf('\n', pos); + if (nextLinebreak == -1) { + pos = data.length(); + } else { + pos = nextLinebreak + 1; + line++; + } + } else if (c == '\n') { + line++; + } + } + return c; + } + + private char nextUsefulOrLinebreak() { + char c = ' '; + while (c == ' ' || c == '\t' || c == '\r') { + if (!hasNext())// fixes error when no '\n' at the end of the file + return '\n'; + c = next(); + } + if (c == '\n') + line++; + return c; + } + + private Object nextValue(final char firstChar) { + switch (firstChar) { + case '+': + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + return nextNumberOrDate(firstChar); + case '"': + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '"' && c3 == '"') { + pos += 2; + return nextBasicMultilineString(); + } + } + return nextBasicString(); + case '\'': + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '\'' && c3 == '\'') { + pos += 2; + return nextLiteralMultilineString(); + } + } + return nextLiteralString(); + case '[': + return nextArray(); + case '{': + return nextInlineTable(); + case 't':// Must be "true" + if (pos + 3 > data.length() || next() != 'r' || next() != 'u' || next() != 'e') { + throw new SettingException("Invalid value at line " + line); + } + return true; + case 'f':// Must be "false" + if (pos + 4 > data.length() || next() != 'a' || next() != 'l' || next() != 's' || next() != 'e') { + throw new SettingException("Invalid value at line " + line); + } + return false; + default: + throw new SettingException("Invalid character '" + toString(firstChar) + "' at line " + line); + } + } + + public Map read() { + final Map map = nextTableContent(); + + if (!hasNext() && pos > 0 && data.charAt(pos - 1) == '[') + throw new SettingException("Invalid table declaration at line " + line + ": it never ends"); + + while (hasNext()) { + char c = nextUseful(true); + final boolean twoBrackets; + if (c == '[') { + twoBrackets = true; + c = nextUseful(false); + } else { + twoBrackets = false; + } + pos--; + + // --- Reads the key -- + final List keyParts = new ArrayList<>(4); + boolean insideSquareBrackets = true; + while (insideSquareBrackets) { + if (!hasNext()) + throw new SettingException("Invalid table declaration at line " + line + ": it never ends"); + + String name = null; + final char nameFirstChar = nextUseful(false); + switch (nameFirstChar) { + case '"': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '"' && c3 == '"') { + pos += 2; + name = nextBasicMultilineString(); + } + } + if (name == null) { + name = nextBasicString(); + } + break; + } + case '\'': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '\'' && c3 == '\'') { + pos += 2; + name = nextLiteralMultilineString(); + } + } + if (name == null) { + name = nextLiteralString(); + } + break; + } + default: + pos--;// to include the first (already read) non-space character + name = nextBareKey(']', '.').trim(); + if (data.charAt(pos) == ']') { + if (!name.isEmpty()) + keyParts.add(name); + insideSquareBrackets = false; + } else if (name.isEmpty()) { + throw new SettingException("Invalid empty key at line " + line); + } + + pos++;// to go after the character we stopped at in nextBareKey() + break; + } + if (insideSquareBrackets) + keyParts.add(name.trim()); + } + + // -- Checks -- + if (keyParts.isEmpty()) + throw new SettingException("Invalid empty key at line " + line); + + if (twoBrackets && next() != ']') {// 2 brackets at the start but only one at the end! + throw new SettingException("Missing character ']' at line " + line); + } + + // -- Reads the value (table content) -- + final Map value = nextTableContent(); + + // -- Saves the value -- + Map valueMap = map;// the map that contains the value + for (int i = 0; i < keyParts.size() - 1; i++) { + final String part = keyParts.get(i); + final Object child = valueMap.get(part); + final Map childMap; + if (child == null) {// implicit table + childMap = new HashMap<>(4); + valueMap.put(part, childMap); + } else if (child instanceof Map) {// table + childMap = (Map) child; + } else {// array + final List list = (List) child; + childMap = list.get(list.size() - 1); + } + valueMap = childMap; + } + if (twoBrackets) {// element of a table array + final String name = keyParts.get(keyParts.size() - 1); + Collection tableArray = (Collection) valueMap.get(name); + if (tableArray == null) { + tableArray = new ArrayList<>(2); + valueMap.put(name, tableArray); + } + tableArray.add(value); + } else {// just a table + valueMap.put(keyParts.get(keyParts.size() - 1), value); + } + + } + return map; + } + + private List nextArray() { + final ArrayList list = new ArrayList<>(); + while (true) { + final char c = nextUseful(true); + if (c == ']') { + pos++; + break; + } + final Object value = nextValue(c); + if (!list.isEmpty() && !(list.get(0).getClass().isAssignableFrom(value.getClass()))) + throw new SettingException("Invalid array at line " + line + ": all the values must have the same type"); + list.add(value); + + final char afterEntry = nextUseful(true); + if (afterEntry == ']') { + pos++; + break; + } + if (afterEntry != ',') { + throw new SettingException("Invalid array at line " + line + ": expected a comma after each value"); + } + } + pos--; + list.trimToSize(); + return list; + } + + private Map nextInlineTable() { + final Map map = new HashMap<>(); + while (true) { + final char nameFirstChar = nextUsefulOrLinebreak(); + String name = null; + switch (nameFirstChar) { + case '}': + return map; + case '"': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '"' && c3 == '"') { + pos += 2; + name = nextBasicMultilineString(); + } + } + if (name == null) + name = nextBasicString(); + break; + } + case '\'': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '\'' && c3 == '\'') { + pos += 2; + name = nextLiteralMultilineString(); + } + } + if (name == null) + name = nextLiteralString(); + break; + } + default: + pos--;// to include the first (already read) non-space character + name = nextBareKey(' ', '\t', '='); + if (name.isEmpty()) + throw new SettingException("Invalid empty key at line " + line); + break; + } + + final char separator = nextUsefulOrLinebreak();// tries to find the '=' sign + if (separator != '=') + throw new SettingException("Invalid character '" + toString(separator) + "' at line " + line + ": expected '='"); + + final char valueFirstChar = nextUsefulOrLinebreak(); + final Object value = nextValue(valueFirstChar); + map.put(name, value); + + final char after = nextUsefulOrLinebreak(); + if (after == '}' || !hasNext()) { + return map; + } else if (after != ',') { + throw new SettingException("Invalid inline table at line " + line + ": missing comma"); + } + } + } + + private Map nextTableContent() { + final Map map = new HashMap<>(); + while (true) { + final char nameFirstChar = nextUseful(true); + if (!hasNext() || nameFirstChar == '[') { + return map; + } + String name = null; + switch (nameFirstChar) { + case '"': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '"' && c3 == '"') { + pos += 2; + name = nextBasicMultilineString(); + } + } + if (name == null) { + name = nextBasicString(); + } + break; + } + case '\'': { + if (pos + 1 < data.length()) { + final char c2 = data.charAt(pos); + final char c3 = data.charAt(pos + 1); + if (c2 == '\'' && c3 == '\'') { + pos += 2; + name = nextLiteralMultilineString(); + } + } + if (name == null) { + name = nextLiteralString(); + } + break; + } + default: + pos--;// to include the first (already read) non-space character + name = nextBareKey(' ', '\t', '='); + if (name.isEmpty()) + throw new SettingException("Invalid empty key at line " + line); + break; + } + final char separator = nextUsefulOrLinebreak();// tries to find the '=' sign + if (separator != '=')// an other character + throw new SettingException("Invalid character '" + toString(separator) + "' at line " + line + ": expected '='"); + + final char valueFirstChar = nextUsefulOrLinebreak(); + if (valueFirstChar == '\n') { + throw new SettingException("Invalid newline before the value at line " + line); + } + final Object value = nextValue(valueFirstChar); + + final char afterEntry = nextUsefulOrLinebreak(); + if (afterEntry == '#') { + pos--;// to make the next nextUseful() call read the # character + } else if (afterEntry != '\n') { + throw new SettingException("Invalid character '" + toString(afterEntry) + "' after the value at line " + line); + } + if (map.containsKey(name)) + throw new SettingException("Duplicate key \"" + name + "\""); + + map.put(name, value); + } + } + + private Object nextNumberOrDate(final char first) { + boolean maybeDouble = true, maybeInteger = true, maybeDate = true; + final StringBuilder sb = new StringBuilder(); + sb.append(first); + char c; + whileLoop: while (hasNext()) { + c = next(); + switch (c) { + case ':': + case 'T': + case 'Z': + maybeInteger = maybeDouble = false; + break; + case 'e': + case 'E': + maybeInteger = maybeDate = false; + break; + case '.': + maybeInteger = false; + break; + case '-': + if (pos != 0 && data.charAt(pos - 1) != 'e' && data.charAt(pos - 1) != 'E') + maybeInteger = maybeDouble = false; + break; + case ',': + case ' ': + case '\t': + case '\n': + case '\r': + case ']': + case '}': + pos--; + break whileLoop; + } + if (c == '_') + maybeDate = false; + else + sb.append(c); + } + final String valueStr = sb.toString(); + try { + if (maybeInteger) { + if (valueStr.length() < 10) + return Integer.parseInt(valueStr); + return Long.parseLong(valueStr); + } + + if (maybeDouble) + return Double.parseDouble(valueStr); + + if (maybeDate) + return Toml.DATE_FORMATTER.parseBest(valueStr, ZonedDateTime::from, LocalDateTime::from, LocalDate::from); + + } catch (final Exception ex) { + throw new SettingException("Invalid value: \"" + valueStr + "\" at line " + line, ex); + } + + throw new SettingException("Invalid value: \"" + valueStr + "\" at line " + line); + } + + private String nextBareKey(final char... allowedEnds) { + final String keyName; + for (int i = pos; i < data.length(); i++) { + final char c = data.charAt(i); + for (final char allowedEnd : allowedEnds) { + if (c == allowedEnd) {// checks if this character allowed to end this bare key + keyName = data.substring(pos, i); + pos = i; + return keyName; + } + } + if (strictAsciiBareKeys) { + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-')) + throw new SettingException("Forbidden character '" + toString(c) + "' in strict bare-key at line " + line); + } else if (c <= ' ' || c == '#' || c == '=' || c == '.' || c == '[' || c == ']') {// lenient bare key + throw new SettingException("Forbidden character '" + toString(c) + "' in lenient bare-key at line " + line); + } // else continue reading + } + throw new SettingException( + "Invalid key/value pair at line " + line + " end of data reached before the value attached to the key was found"); + } + + private String nextLiteralString() { + final int index = data.indexOf('\'', pos); + if (index == -1) + throw new SettingException("Invalid literal String at line " + line + ": it never ends"); + + final String str = data.substring(pos, index); + if (str.indexOf('\n') != -1) + throw new SettingException("Invalid literal String at line " + line + ": newlines are not allowed here"); + + pos = index + 1; + return str; + } + + private String nextLiteralMultilineString() { + final int index = data.indexOf("'''", pos); + if (index == -1) + throw new SettingException("Invalid multiline literal String at line " + line + ": it never ends"); + final String str; + if (data.charAt(pos) == '\r' && data.charAt(pos + 1) == '\n') {// "\r\n" at the beginning of the string + str = data.substring(pos + 2, index); + line++; + } else if (data.charAt(pos) == '\n') {// '\n' at the beginning of the string + str = data.substring(pos + 1, index); + line++; + } else { + str = data.substring(pos, index); + } + for (int i = 0; i < str.length(); i++) {// count lines + final char c = str.charAt(i); + if (c == '\n') + line++; + } + pos = index + 3;// goes after the 3 quotes + return str; + } + + private String nextBasicString() { + final StringBuilder sb = new StringBuilder(); + boolean escape = false; + while (hasNext()) { + final char c = next(); + if (c == '\n' || c == '\r') + throw new SettingException("Invalid basic String at line " + line + ": newlines not allowed"); + if (escape) { + sb.append(unescape(c)); + escape = false; + } else if (c == '\\') { + escape = true; + } else if (c == '"') { + return sb.toString(); + } else { + sb.append(c); + } + } + throw new SettingException("Invalid basic String at line " + line + ": it nerver ends"); + } + + private String nextBasicMultilineString() { + final StringBuilder sb = new StringBuilder(); + boolean first = true, escape = false; + while (hasNext()) { + final char c = next(); + if (first && (c == '\r' || c == '\n')) { + if (c == '\r' && hasNext() && data.charAt(pos) == '\n')// "\r\n" + pos++;// so that it is NOT read by the next call to next() + else + line++; + first = false; + continue; + } + if (escape) { + if (c == '\r' || c == '\n' || c == ' ' || c == '\t') { + if (c == '\r' && hasNext() && data.charAt(pos) == '\n')// "\r\n" + pos++; + else if (c == '\n') + line++; + nextUseful(false); + pos--;// so that it is read by the next call to next() + } else { + sb.append(unescape(c)); + } + escape = false; + } else if (c == '\\') { + escape = true; + } else if (c == '"') { + if (pos + 1 >= data.length()) + break; + if (data.charAt(pos) == '"' && data.charAt(pos + 1) == '"') { + pos += 2; + return sb.toString(); + } + } else if (c == '\n') { + line++; + sb.append(c); + } else { + sb.append(c); + } + } + throw new SettingException("Invalid multiline basic String at line " + line + ": it never ends"); + } + + private char unescape(final char c) { + switch (c) { + case 'b': + return '\b'; + case 't': + return '\t'; + case 'n': + return '\n'; + case 'f': + return '\f'; + case 'r': + return '\r'; + case '"': + return '"'; + case '\\': + return '\\'; + case 'u': {// unicode uXXXX + if (data.length() - pos < 5) + throw new SettingException("Invalid unicode code point at line " + line); + final String unicode = data.substring(pos, pos + 4); + pos += 4; + try { + final int hexVal = Integer.parseInt(unicode, 16); + return (char) hexVal; + } catch (final NumberFormatException ex) { + throw new SettingException("Invalid unicode code point at line " + line, ex); + } + } + case 'U': {// unicode UXXXXXXXX + if (data.length() - pos < 9) + throw new SettingException("Invalid unicode code point at line " + line); + final String unicode = data.substring(pos, pos + 8); + pos += 8; + try { + final int hexVal = Integer.parseInt(unicode, 16); + return (char) hexVal; + } catch (final NumberFormatException ex) { + throw new SettingException("Invalid unicode code point at line " + line, ex); + } + } + default: + throw new SettingException("Invalid escape sequence: \"\\" + c + "\" at line " + line); + } + } + + /** + * Converts a char to a String. The char is escaped if needed. + */ + private String toString(final char c) { + switch (c) { + case '\b': + return "\\b"; + case '\t': + return "\\t"; + case '\n': + return "\\n"; + case '\r': + return "\\r"; + case '\f': + return "\\f"; + default: + return String.valueOf(c); + } + } + +} diff --git a/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlWriter.java b/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlWriter.java new file mode 100644 index 000000000..8d8feabe2 --- /dev/null +++ b/hutool-setting/src/main/java/cn/hutool/setting/toml/TomlWriter.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2023 looly(loolly@aliyun.com) + * Hutool is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +package cn.hutool.setting.toml; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.CharUtil; +import cn.hutool.setting.SettingException; + +import java.io.IOException; +import java.io.Writer; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Map; + +/** + * Class for writing TOML v0.4.0. + *

DateTimes support

+ *

+ * Any {@link TemporalAccessor} may be added in a Map passed to this writer, this writer can only write three + * kind of datetimes: {@link LocalDate}, {@link LocalDateTime} and {@link ZonedDateTime}. + *

+ *

Lenient bare keys

+ *

+ * The {@link TomlWriter} always outputs data that strictly follows the TOML specification. Any key that + * contains one + * or more non-strictly valid character is surrounded by quotes. + *

+ * + * @author TheElectronWill + */ +public class TomlWriter { + + private final Writer writer; + private final int indentSize; + private final char indentCharacter; + private final String lineSeparator; + private final LinkedList tablesNames = new LinkedList<>(); + private int lineBreaks = 0, indentationLevel = -1;// -1 to prevent indenting the first level + + /** + * Creates a new TomlWriter with the defaults parameters. The system line separator is used (ie '\n' on + * Linux and OSX, "\r\n" on Windows). This is exactly the same as + * {@code TomlWriter(writer, 1, false, System.lineSeparator()}. + * + * @param writer where to write the data + */ + public TomlWriter(final Writer writer) { + this(writer, 1, false, System.lineSeparator()); + } + + /** + * Creates a new TomlWriter with the specified parameters. The system line separator is used (ie '\n' on + * Linux and OSX, "\r\n" on Windows). This is exactly the same as + * {@code TomlWriter(writer, indentSize, indentWithSpaces, System.lineSeparator())}. + * + * @param writer where to write the data + * @param indentSize the size of each indent + * @param indentWithSpaces true to indent with spaces, false to indent with tabs + */ + public TomlWriter(final Writer writer, final int indentSize, final boolean indentWithSpaces) { + this(writer, indentSize, indentWithSpaces, System.lineSeparator()); + } + + /** + * Creates a new TomlWriter with the specified parameters. + * + * @param writer where to write the data + * @param indentSize the size of each indent + * @param indentWithSpaces true to indent with spaces, false to indent with tabs + * @param lineSeparator the String to write to break lines + */ + public TomlWriter(final Writer writer, final int indentSize, final boolean indentWithSpaces, final String lineSeparator) { + this.writer = writer; + this.indentSize = indentSize; + this.indentCharacter = indentWithSpaces ? CharUtil.SPACE : CharUtil.TAB; + this.lineSeparator = lineSeparator; + } + + /** + * Closes the underlying writer, flushing it first. + * + * @throws IOException if an error occurs + */ + public void close() throws IOException { + writer.close(); + } + + /** + * Flushes the underlying writer. + * + * @throws IOException if an error occurs + */ + public void flush() throws IOException { + writer.flush(); + } + + /** + * Writes the specified data in the TOML format. + * + * @param data the data to write + * @throws IOException if an error occurs + */ + public void write(final Map data) throws IOException { + writeTableContent(data); + } + + private void writeTableName() throws IOException { + final Iterator it = tablesNames.iterator(); + while (it.hasNext()) { + final String namePart = it.next(); + writeKey(namePart); + if (it.hasNext()) { + write('.'); + } + } + } + + private void writeTableContent(final Map table) throws IOException { + writeTableContent(table, true); + writeTableContent(table, false); + } + + /** + * Writes the content of a table. + * + * @param table the table to write + * @param simpleValues true to write only the simple values (and the normal arrays), false to write only + * the tables + * (and the arrays of tables). + */ + @SuppressWarnings("unchecked") + private void writeTableContent(final Map table, final boolean simpleValues) throws IOException { + for (final Map.Entry entry : table.entrySet()) { + final String name = entry.getKey(); + final Object value = entry.getValue(); + if (value instanceof Collection) {// array + final Collection c = (Collection) value; + if (false == c.isEmpty() && c.iterator().next() instanceof Map) {// array of tables + if (simpleValues) { + continue; + } + tablesNames.addLast(name); + indentationLevel++; + for (final Object element : c) { + indent(); + write("[["); + writeTableName(); + write("]]\n"); + final Map map = (Map) element; + writeTableContent(map); + } + indentationLevel--; + tablesNames.removeLast(); + } else {// normal array + if (false == simpleValues) { + continue; + } + indent(); + writeKey(name); + write(" = "); + writeArray(c); + } + } else if (value instanceof Object[]) {// array + final Object[] array = (Object[]) value; + if (array.length > 0 && array[0] instanceof Map) {// array of tables + if (simpleValues) { + continue; + } + tablesNames.addLast(name); + indentationLevel++; + for (final Object element : array) { + indent(); + write("[["); + writeTableName(); + write("]]\n"); + final Map map = (Map) element; + writeTableContent(map); + } + indentationLevel--; + tablesNames.removeLast(); + } else {// normal array + if (false == simpleValues) { + continue; + } + indent(); + writeKey(name); + write(" = "); + writeString(ArrayUtil.toString(array)); + } + } else if (value instanceof Map) {// table + if (simpleValues) { + continue; + } + tablesNames.addLast(name); + indentationLevel++; + + indent(); + write('['); + writeTableName(); + write(']'); + newLine(); + writeTableContent((Map) value); + + indentationLevel--; + tablesNames.removeLast(); + } else {// simple value + if (!simpleValues) { + continue; + } + indent(); + writeKey(name); + write(" = "); + writeValue(value); + } + newLine(); + } + newLine(); + } + + private void writeKey(final String key) throws IOException { + for (int i = 0; i < key.length(); i++) { + final char c = key.charAt(i); + if (false == isValidCharOfKey(c)) { + // 含有非法字符,包装之 + writeString(key); + return; + } + } + write(key); + } + + private static boolean isValidCharOfKey(final char c) { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || + c == '_'; + } + + private void writeString(final String str) throws IOException { + final StringBuilder sb = new StringBuilder(); + sb.append('"'); + for (int i = 0; i < str.length(); i++) { + final char c = str.charAt(i); + addEscaped(c, sb); + } + sb.append('"'); + write(sb.toString()); + } + + private void writeArray(final Collection c) throws IOException { + write('['); + for (final Object element : c) { + writeValue(element); + write(", "); + } + write(']'); + } + + private void writeValue(final Object value) throws IOException { + if (value instanceof String) { + writeString((String) value); + } else if (value instanceof Number || value instanceof Boolean) { + write(value.toString()); + } else if (value instanceof TemporalAccessor) { + String formatted = Toml.DATE_FORMATTER.format((TemporalAccessor) value); + if (formatted.endsWith("T"))// If the last character is a 'T' + { + formatted = formatted.substring(0, formatted.length() - 1);// removes it because it's invalid. + } + write(formatted); + } else if (value instanceof Collection) { + writeArray((Collection) value); + } else if (ArrayUtil.isArray(value)) { + write(ArrayUtil.toString(value)); + } else if (value instanceof Map) {// should not happen because an array of tables is detected by + // writeTableContent() + throw new IOException("Unexpected value " + value); + } else { + throw new SettingException("Unsupported value of type " + value.getClass().getCanonicalName()); + } + } + + private void newLine() throws IOException { + if (lineBreaks <= 1) { + writer.write(lineSeparator); + lineBreaks++; + } + } + + private void write(final char c) throws IOException { + writer.write(c); + lineBreaks = 0; + } + + private void write(final String str) throws IOException { + writer.write(str); + lineBreaks = 0; + } + + private void indent() throws IOException { + for (int i = 0; i < indentationLevel; i++) { + for (int j = 0; j < indentSize; j++) { + write(indentCharacter); + } + } + } + + static void addEscaped(final char c, final StringBuilder sb) { + switch (c) { + case '\b': + sb.append("\\b"); + break; + case '\t': + sb.append("\\t"); + break; + case '\n': + sb.append("\\n"); + break; + case '\\': + sb.append("\\\\"); + break; + case '\r': + sb.append("\\r"); + break; + case '\f': + sb.append("\\f"); + break; + case '"': + sb.append("\\\""); + break; + default: + sb.append(c); + break; + } + } + +} diff --git a/hutool-setting/src/main/java/cn/hutool/setting/toml/package-info.java b/hutool-setting/src/main/java/cn/hutool/setting/toml/package-info.java new file mode 100644 index 000000000..e131c4917 --- /dev/null +++ b/hutool-setting/src/main/java/cn/hutool/setting/toml/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 looly(loolly@aliyun.com) + * Hutool is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * TOML(Tom's Obvious, Minimal Language)配置文件解析和生成 + * + *

+ * 规范:https://toml.io/cn/ + *

+ *

+ * 参考实现:https://github.com/TheElectronWill/TOML-javalib + *

+ * + * @author looly + */ +package cn.hutool.setting.toml; diff --git a/hutool-setting/src/test/resources/test.toml b/hutool-setting/src/test/resources/test.toml new file mode 100644 index 000000000..4ff212f63 --- /dev/null +++ b/hutool-setting/src/test/resources/test.toml @@ -0,0 +1,33 @@ +# This is a TOML document. + +title = "TOML Example" + +[owner] +name = "Tom Preston-Werner" +dob = 1979-05-27T07:32:00-08:00 # First class dates + +[database] +server = "192.168.1.1" +ports = [ 8000, 8001, 8002 ] +connection_max = 5000 +enabled = true + +[servers] + +# Indentation (tabs and/or spaces) is allowed but not required +[servers.alpha] +ip = "10.0.0.1" +dc = "eqdc10" + +[servers.beta] +ip = "10.0.0.2" +dc = "eqdc10" + +[clients] +data = [ ["gamma", "delta"], [1, 2] ] + +# Line breaks are OK when inside arrays +hosts = [ + "alpha", + "omega" +]