This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 9539dd969c INI marshalling support
9539dd969c is described below
commit 9539dd969c6351165af1f35bbbe1aee04c29b6cc
Author: James Bognar <[email protected]>
AuthorDate: Wed Mar 4 11:07:40 2026 -0500
INI marshalling support
---
AGENTS.md | 2 +-
.../org/apache/juneau/commons/utils/Utils.java | 9 +-
.../src/main/java/org/apache/juneau/Context.java | 2 +
.../org/apache/juneau/ini/IniBeanPropertyMeta.java | 82 +++++
.../java/org/apache/juneau/ini/IniClassMeta.java | 53 ++++
.../org/apache/juneau/ini/IniMetaProvider.java | 41 +++
.../main/java/org/apache/juneau/ini/IniParser.java | 168 +++++++++++
.../org/apache/juneau/ini/IniParserSession.java | 272 +++++++++++++++++
.../java/org/apache/juneau/ini/IniSerializer.java | 276 +++++++++++++++++
.../apache/juneau/ini/IniSerializerSession.java | 330 +++++++++++++++++++++
.../main/java/org/apache/juneau/ini/IniWriter.java | 168 +++++++++++
.../java/org/apache/juneau/ini/annotation/Ini.java | 81 +++++
.../juneau/ini/annotation/IniAnnotation.java | 66 +++++
.../apache/juneau/ini/annotation/IniConfig.java | 81 +++++
.../juneau/ini/annotation/IniConfigAnnotation.java | 73 +++++
.../java/org/apache/juneau/ini/package-info.java | 39 +++
.../java/org/apache/juneau/marshaller/Ini.java | 180 +++++++++++
.../org/apache/juneau/rest/client/RestClient.java | 3 +
.../juneau/rest/config/BasicUniversalConfig.java | 3 +
.../org/apache/juneau/ComboRoundTrip_Tester.java | 4 +
.../org/apache/juneau/ComboSerialize_Tester.java | 3 +
.../juneau/a/rttests/RoundTripDateTime_Test.java | 11 +-
.../java/org/apache/juneau/ini/IniParser_Test.java | 129 ++++++++
.../org/apache/juneau/ini/IniRoundTrip_Test.java | 186 ++++++++++++
.../org/apache/juneau/ini/IniSerializer_Test.java | 221 ++++++++++++++
.../org/apache/juneau/marshaller/Ini_Test.java | 69 +++++
...i_implementation.md => 1_ini_implementation.md} | 19 +-
...implementation.md => 2_hjson_implementation.md} | 0
...s_implementation.md => 3_jcs_implementation.md} | 0
..._implementation.md => 4_bson_implementation.md} | 0
..._implementation.md => 5_cbor_implementation.md} | 0
...implementation.md => 6_hocon_implementation.md} | 0
...plementation.md => 7_parquet_implementation.md} | 0
33 files changed, 2546 insertions(+), 25 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 3bb7c9a80c..8b15dde388 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -470,7 +470,7 @@ timeout 120s sh -c 'mvn clean install 2>&1 | tail -20'
- **TODO Identifiers**: When adding new TODO items, assign them a unique
"TODO-#" identifier (e.g., "TODO-1", "TODO-2", etc.)
- **TODO References**: When the user asks to "fix TODO-X" or "work on TODO-X",
they are referring to the specific identifier in the TODO.md file
- **TODO Completion**: When TODOs are completed, remove them from the TODO
list entirely
-- **Plans**: Implementation plans (e.g., `jsonl_implementation.md`,
`ini_implementation.md`) are also located in the `/todo` folder alongside
TODO.md
+- **Plans**: Implementation plans (e.g., `1_ini_implementation.md`,
`2_hjson_implementation.md`) are also located in the `/todo` folder alongside
TODO.md
### 12. Release Notes Management
- When the user says "add to release notes" or "add this to the release
notes", this refers to the release notes in the `/juneau-docs` directory
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Utils.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Utils.java
index 6ce172a46e..1ad2eb413e 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Utils.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/Utils.java
@@ -54,7 +54,8 @@ import org.apache.juneau.commons.settings.*;
*/
@SuppressWarnings({
"java:S115", // Constants use UPPER_snakeCase convention
- "java:S1118" // Utility class with static methods only
+ "java:S1118", // Utility class with static methods only
+ "java:S1135" // TODO comment retained as documentation for future
refactoring
})
public class Utils {
@@ -246,7 +247,8 @@ public class Utils {
* Returns <c>0</c> if objects are not of the same type or do
not implement the {@link Comparable} interface.
*/
@SuppressWarnings({
- "unchecked" // Type erasure requires unchecked casts
+ "unchecked", // Type erasure requires unchecked casts
+ "java:S3740" // Raw Comparable; parameterizing causes compile
error in compareTo
})
public static int cmp(Object o1, Object o2) {
if (o1 == null) {
@@ -1525,7 +1527,6 @@ public class Utils {
* @return <jk>true</jk> if the objects are not equal.
* @see #eq(Object, Object)
*/
- // TODO - Rename this to neq, then add a ne for not-empty
public static <T> boolean neq(T s1, T s2) {
return ! eq(s1, s2);
}
@@ -1552,7 +1553,6 @@ public class Utils {
* @return <jk>true</jk> if the objects are not equal based on the
test, or if one is <jk>null</jk> and the other is not.
* @see #eq(Object, Object, BiPredicate)
*/
- // TODO - Rename this to neq, then add a ne for not-empty
public static <T,U> boolean neq(T o1, U o2, BiPredicate<T,U> test) {
if (o1 == null)
return nn(o2);
@@ -1582,7 +1582,6 @@ public class Utils {
* @return <jk>true</jk> if the strings are not equal ignoring case.
* @see #eqic(String, String)
*/
- // TODO - Rename this to neqic, then add a ne for not-empty
public static boolean neqic(String s1, String s2) {
return ! eqic(s1, s2);
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
index ce8c71ed16..4456d88810 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
@@ -32,6 +32,7 @@ import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.csv.annotation.*;
import org.apache.juneau.html.annotation.*;
+import org.apache.juneau.ini.annotation.*;
import org.apache.juneau.json.annotation.*;
import org.apache.juneau.jsonl.annotation.*;
import org.apache.juneau.jsonschema.annotation.*;
@@ -402,6 +403,7 @@ public abstract class Context {
* <li class ='ja'>{@link CsvConfig}
* <li class ='ja'>{@link HtmlConfig}
* <li class ='ja'>{@link HtmlDocConfig}
+ * <li class ='ja'>{@link IniConfig}
* <li class ='ja'>{@link JsonConfig}
* <li class ='ja'>{@link JsonlConfig}
* <li class ='ja'>{@link JsonSchemaConfig}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniBeanPropertyMeta.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniBeanPropertyMeta.java
new file mode 100644
index 0000000000..dcb97169e5
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniBeanPropertyMeta.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.ini.annotation.*;
+
+/**
+ * Metadata on bean properties specific to INI serializers and parsers, from
{@link Ini @Ini}.
+ */
+public class IniBeanPropertyMeta extends ExtendedBeanPropertyMeta {
+
+ /** Default instance. */
+ public static final IniBeanPropertyMeta DEFAULT = new
IniBeanPropertyMeta();
+
+ private final String section;
+ private final String comment;
+ private final boolean json5Encoding;
+
+ /**
+ * Constructor.
+ *
+ * @param bpm The bean property metadata.
+ * @param mp INI metadata provider.
+ */
+ public IniBeanPropertyMeta(BeanPropertyMeta bpm, IniMetaProvider mp) {
+ super(bpm);
+ var a =
bpm.getAnnotations(Ini.class).map(AnnotationInfo::inner).reduce((first, second)
-> second).orElse(null);
+ section = a != null && !a.section().isEmpty() ? a.section() :
"";
+ comment = a != null && !a.comment().isEmpty() ? a.comment() :
"";
+ json5Encoding = a != null && a.json5Encoding();
+ }
+
+ private IniBeanPropertyMeta() {
+ super(null);
+ section = "";
+ comment = "";
+ json5Encoding = false;
+ }
+
+ /**
+ * Returns the custom section name for this property.
+ *
+ * @return The section name, or empty string if not specified.
+ */
+ public String getSection() {
+ return section;
+ }
+
+ /**
+ * Returns the comment text to emit before this property.
+ *
+ * @return The comment, or empty string if not specified.
+ */
+ public String getComment() {
+ return comment;
+ }
+
+ /**
+ * Returns whether this property should be JSON5-encoded even when
normally simple.
+ *
+ * @return Whether JSON5 encoding is forced.
+ */
+ public boolean isJson5Encoding() {
+ return json5Encoding;
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniClassMeta.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniClassMeta.java
new file mode 100644
index 0000000000..c230d258b7
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniClassMeta.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.ini.annotation.*;
+
+/**
+ * Metadata on classes specific to INI serializers and parsers, from {@link
Ini @Ini}.
+ */
+public class IniClassMeta extends ExtendedClassMeta {
+
+ private final String section;
+
+ /**
+ * Constructor.
+ *
+ * @param cm The class metadata.
+ * @param mp INI metadata provider.
+ */
+ public IniClassMeta(ClassMeta<?> cm, IniMetaProvider mp) {
+ super(cm);
+ var ref = new AtomicReference<Ini>();
+ cm.forEachAnnotation(Ini.class, null, ref::set);
+ var a = ref.get();
+ section = a != null && !a.section().isEmpty() ? a.section() :
"";
+ }
+
+ /**
+ * Returns the custom section name for this class.
+ *
+ * @return The section name, or empty string if not specified.
+ */
+ public String getSection() {
+ return section;
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniMetaProvider.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniMetaProvider.java
new file mode 100644
index 0000000000..e48f2ab233
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniMetaProvider.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import org.apache.juneau.*;
+
+/**
+ * Interface for providing access to {@link IniClassMeta} and {@link
IniBeanPropertyMeta} objects.
+ */
+public interface IniMetaProvider {
+
+ /**
+ * Returns the INI-specific metadata on the specified bean property.
+ *
+ * @param bpm The bean property to return the metadata on.
+ * @return The metadata.
+ */
+ IniBeanPropertyMeta getIniBeanPropertyMeta(BeanPropertyMeta bpm);
+
+ /**
+ * Returns the INI-specific metadata on the specified class.
+ *
+ * @param cm The class to return the metadata on.
+ * @return The metadata.
+ */
+ IniClassMeta getIniClassMeta(ClassMeta<?> cm);
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParser.java
new file mode 100644
index 0000000000..41f7078b08
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParser.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.collections.*;
+import org.apache.juneau.parser.*;
+
+/**
+ * Parses INI-formatted text into POJO models.
+ *
+ * <p>
+ * Parses INI files (section/key-value format) into Java beans and maps.
+ *
+ * <h5 class='topic'>Media types</h5>
+ * <p>
+ * Consumes: <bc>text/ini, text/x-ini</bc>
+ *
+ * <h5 class='topic'>Value parsing</h5>
+ * <ul class='spaced-list'>
+ * <li>Unquoted <c>null</c> → Java <jk>null</jk>
+ * <li>Unquoted <c>true</c>/<c>false</c> → Boolean
+ * <li>Unquoted numbers → Number (int, long, float, double as appropriate)
+ * <li>Single-quoted strings (<c>'...'</c>) → String (with <c>''</c>
unescaped to single quote)
+ * <li>Values starting with <c>[</c> or <c>{</c> → Delegated to JSON parser
+ * <li>ISO 8601 strings → Date, Calendar, or <c>java.time.*</c> when
target requires it
+ * <li>ISO 8601 duration strings → {@link java.time.Duration}
+ * <li>Other unquoted tokens → String
+ * </ul>
+ *
+ * <h5 class='topic'>Sections</h5>
+ * <p>
+ * Sections (e.g. <c>[address]</c>) map to nested bean properties or Map
properties.
+ * Section paths like <c>[employment/company]</c> map to deeply nested bean
properties.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Parse INI into a bean</jc>
+ * MyConfig <jv>config</jv> =
IniParser.<jsf>DEFAULT</jsf>.parse(<jv>ini</jv>, MyConfig.<jk>class</jk>);
+ *
+ * <jc>// Parse into a Map</jc>
+ * Map<String, Object> <jv>map</jv> =
IniParser.<jsf>DEFAULT</jsf>.parse(<jv>ini</jv>, Map.<jk>class</jk>,
String.<jk>class</jk>, Object.<jk>class</jk>);
+ * </p>
+ *
+ * <h5 class='figure'>Example input:</h5>
+ * <p class='bini'>
+ * <ck>name</ck> = <cv>Alice</cv>
+ * <ck>age</ck> = <cv>30</cv>
+ *
+ * <cs>[address]</cs>
+ * <ck>street</ck> = <cv>123 Main St</cv>
+ * <ck>city</ck> = <cv>Boston</cv>
+ * <ck>state</ck> = <cv>MA</cv>
+ * </p>
+ *
+ * <h5 class='topic'>Limitations</h5>
+ * <p>
+ * Parsing into top-level collections, arrays, or scalar types is not
supported.
+ * The target type must be a bean class or <c>Map<String,?></c>.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>This class is thread safe and reusable.
+ * <li class='note'>Values starting with <c>[</c> or <c>{</c> are
delegated to the JSON parser.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/IniBasics">INI Basics</a>
+ * </ul>
+ */
+@SuppressWarnings({
+ "java:S110", "java:S115"
+})
+public class IniParser extends ReaderParser implements IniMetaProvider {
+
+ private final java.util.concurrent.ConcurrentHashMap<ClassMeta<?>,
IniClassMeta> iniClassMetas = new java.util.concurrent.ConcurrentHashMap<>();
+ private final java.util.concurrent.ConcurrentHashMap<BeanPropertyMeta,
IniBeanPropertyMeta> iniBeanPropertyMetas = new
java.util.concurrent.ConcurrentHashMap<>();
+
+ private static final String ARG_copyFrom = "copyFrom";
+
+ /**
+ * Builder for {@link IniParser}.
+ */
+ public static class Builder extends ReaderParser.Builder {
+
+ private static final Cache<HashKey,IniParser> CACHE =
Cache.of(HashKey.class, IniParser.class).build();
+
+ protected Builder() {
+ consumes("text/ini,text/x-ini");
+ }
+
+ protected Builder(Builder copyFrom) {
+ super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ }
+
+ protected Builder(IniParser copyFrom) {
+ super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ }
+
+ @Override
+ public IniParser build() {
+ return cache(CACHE).build(IniParser.class);
+ }
+
+ @Override
+ public Builder copy() {
+ return new Builder(this);
+ }
+ }
+
+ /** Default parser instance. */
+ public static final IniParser DEFAULT = new IniParser(create());
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param builder The builder.
+ */
+ public IniParser(Builder builder) {
+ super(builder);
+ }
+
+ @Override
+ public IniBeanPropertyMeta getIniBeanPropertyMeta(BeanPropertyMeta bpm)
{
+ if (bpm == null)
+ return IniBeanPropertyMeta.DEFAULT;
+ return iniBeanPropertyMetas.computeIfAbsent(bpm, k -> new
IniBeanPropertyMeta(k, this));
+ }
+
+ @Override
+ public IniClassMeta getIniClassMeta(ClassMeta<?> cm) {
+ return iniClassMetas.computeIfAbsent(cm, k -> new
IniClassMeta(k, this));
+ }
+
+ @Override
+ public IniParserSession.Builder createSession() {
+ return IniParserSession.create(this);
+ }
+
+ @Override
+ public Builder copy() {
+ return new Builder(this);
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParserSession.java
new file mode 100644
index 0000000000..ff61d45613
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniParserSession.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.Map.*;
+import java.util.regex.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.collections.*;
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.parser.*;
+import org.apache.juneau.utils.Iso8601Utils;
+
+/**
+ * Session for parsing INI format into POJOs.
+ */
+@SuppressWarnings({
+ "unchecked",
+ "java:S115", // ARG_ctx follows project assertion-param naming
convention (ARG_<param>)
+ "java:S3776", "java:S6541", "java:S135"
+})
+public class IniParserSession extends ReaderParserSession {
+
+ private static final String ARG_ctx = "ctx";
+
+ /** Pattern for key=value or key = value. */
+ private static final Pattern KV_PATTERN =
Pattern.compile("^([^=#\\s][^=]*?)\\s*[=:]\\s*(.*)$", Pattern.DOTALL);
+
+ /** Delimiter for nested section names (e.g. {@code address/street}). */
+ private static final String SECTION_PATH_DELIMITER = "/";
+
+ /**
+ * Builder for INI parser session.
+ */
+ public static class Builder extends ReaderParserSession.Builder {
+
+ protected Builder(IniParser ctx) {
+ super(assertArgNotNull(ARG_ctx, ctx));
+ }
+
+ @Override
+ public IniParserSession build() {
+ return new IniParserSession(this);
+ }
+ }
+
+ /**
+ * Creates a session builder.
+ *
+ * @param ctx The parser context.
+ * @return The builder.
+ */
+ public static Builder create(IniParser ctx) {
+ return new Builder(assertArgNotNull(ARG_ctx, ctx));
+ }
+
+ protected IniParserSession(Builder builder) {
+ super(builder);
+ }
+
+ @Override
+ protected <T> T doParse(ParserPipe pipe, ClassMeta<T> type) throws
IOException, ParseException, ExecutableException {
+ try (Reader r = pipe.getParserReader()) {
+ if (r == null)
+ return null;
+ var sections = parseIniContent(r);
+ if (sections.isEmpty())
+ return type.canCreateNewBean(getOuter()) ?
type.newInstance(getOuter()) : null;
+ if (!type.isBean() && !type.isMap())
+ throw new ParseException(this, "INI format
requires bean or Map<String,?> target. Got: {0}", type.inner().getName());
+ if (type.isMap()) {
+ var result = buildMapFromSections(sections, "");
+ return (T) convertMapToTarget(result, type);
+ }
+ var bm = toBeanMap(type.newInstance(getOuter()));
+ populateBean(bm, sections, "");
+ return type.cast(bm.getBean());
+ }
+ }
+
+ /**
+ * Parses INI content into section -> (key -> raw value) structure.
+ *
+ * @param r The reader.
+ * @return Map of section name to key-value map. Default section uses
"".
+ */
+ protected Map<String, Map<String, String>> parseIniContent(Reader r)
throws IOException {
+ var sections = new LinkedHashMap<String, Map<String, String>>();
+ var current = sections.computeIfAbsent("", k -> new
LinkedHashMap<>());
+ var br = r instanceof BufferedReader r2 ? r2 : new
BufferedReader(r);
+ String line;
+ while ((line = br.readLine()) != null) {
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#"))
+ continue;
+ if (line.startsWith("[")) {
+ var end = line.indexOf(']');
+ if (end < 0)
+ continue;
+ var sectionName = line.substring(1, end).trim();
+ current = sections.computeIfAbsent(sectionName,
k -> new LinkedHashMap<>());
+ continue;
+ }
+ var m = KV_PATTERN.matcher(line);
+ if (m.matches()) {
+ var key = m.group(1).trim();
+ var value = m.group(2).trim();
+ // Strip inline comment (# ...) from value
(when not inside quotes)
+ value = stripInlineComment(value);
+ current.put(key, value);
+ }
+ }
+ return sections;
+ }
+
+ private void populateBean(BeanMap<?> bm, Map<String, Map<String,
String>> sections, String sectionPath) throws ParseException,
ExecutableException {
+ var defaultSection = sections.get(sectionPath);
+ if (defaultSection != null) {
+ for (Entry<String, String> e :
defaultSection.entrySet()) {
+ var key = e.getKey();
+ var rawValue = e.getValue();
+ var pMeta =
bm.getMeta().getProperties().get(key);
+ if (pMeta == null &&
isIgnoreUnknownBeanProperties())
+ continue;
+ if (pMeta == null)
+ throw new ParseException(this, "Unknown
property ''{0}''", key);
+ var value = parseValue(rawValue,
pMeta.getClassMeta());
+ bm.put(key, value);
+ }
+ }
+ // Process nested sections
+ for (var entry : sections.entrySet()) {
+ var sectionName = entry.getKey();
+ var sub = entry.getValue();
+ if (sectionName.equals(sectionPath))
+ continue;
+ var isChild = sectionPath.isEmpty() ?
!sectionName.contains(SECTION_PATH_DELIMITER)
+ : sectionName.startsWith(sectionPath +
SECTION_PATH_DELIMITER);
+ if (!isChild)
+ continue;
+ var childName = sectionPath.isEmpty() ? sectionName
+ : sectionName.substring(sectionPath.length() +
SECTION_PATH_DELIMITER.length()).split(Pattern.quote(SECTION_PATH_DELIMITER))[0];
+ var pMeta = bm.getMeta().getProperties().get(childName);
+ if (pMeta == null && isIgnoreUnknownBeanProperties())
+ continue;
+ if (pMeta == null)
+ continue;
+ var cMeta = pMeta.getClassMeta();
+ var childPath = sectionPath.isEmpty() ? childName :
sectionPath + SECTION_PATH_DELIMITER + childName;
+ if (cMeta.isBean()) {
+ var child =
toBeanMap(cMeta.newInstance(getOuter()));
+ populateBean(child, sections, childPath);
+ bm.put(childName, child.getBean());
+ } else if (cMeta.isMap() && sub != null) {
+ var valueType = cMeta.getValueType();
+ if (valueType == null)
+ valueType = object();
+ var map = new LinkedHashMap<String, Object>();
+ for (Entry<String, String> e : sub.entrySet())
+ map.put(e.getKey(),
parseValue(e.getValue(), valueType));
+ bm.put(childName, convertMapToTarget(map,
cMeta));
+ }
+ }
+ }
+
+ private Map<String, Object> buildMapFromSections(Map<String,
Map<String, String>> sections, String sectionPath) throws ParseException,
ExecutableException {
+ var result = new LinkedHashMap<String, Object>();
+ var defaultSection = sections.get(sectionPath);
+ if (defaultSection != null) {
+ for (Entry<String, String> e :
defaultSection.entrySet())
+ result.put(e.getKey(), parseValue(e.getValue(),
object()));
+ }
+ for (var entry : sections.entrySet()) {
+ var sectionName = entry.getKey();
+ var childSection = entry.getValue();
+ if (sectionName.equals(sectionPath))
+ continue;
+ var isChild = sectionPath.isEmpty() ?
!sectionName.contains(SECTION_PATH_DELIMITER)
+ : sectionName.startsWith(sectionPath +
SECTION_PATH_DELIMITER);
+ if (!isChild)
+ continue;
+ var childName = sectionPath.isEmpty() ? sectionName
+ : sectionName.substring(sectionPath.length() +
SECTION_PATH_DELIMITER.length()).split(Pattern.quote(SECTION_PATH_DELIMITER))[0];
+ if (result.containsKey(childName))
+ continue;
+ var childPath = sectionPath.isEmpty() ? childName :
sectionPath + SECTION_PATH_DELIMITER + childName;
+ var hasNested = sections.keySet().stream().anyMatch(s
-> s.startsWith(childPath + SECTION_PATH_DELIMITER));
+ if (hasNested) {
+ result.put(childName,
buildMapFromSections(sections, childPath));
+ } else if (childSection != null) {
+ var map = new LinkedHashMap<String, Object>();
+ for (Entry<String, String> e :
childSection.entrySet())
+ map.put(e.getKey(),
parseValue(e.getValue(), object()));
+ result.put(childName, map);
+ }
+ }
+ return result;
+ }
+
+ private Object parseValue(String raw, ClassMeta<?> targetType) throws
ParseException, ExecutableException {
+ if (raw == null)
+ return null;
+ var trimmed = raw.trim();
+ if (trimmed.equals("null"))
+ return null;
+ if (trimmed.equalsIgnoreCase("true"))
+ return true;
+ if (trimmed.equalsIgnoreCase("false"))
+ return false;
+ if (trimmed.startsWith("'") && trimmed.endsWith("'") &&
trimmed.length() >= 2) {
+ var inner = trimmed.substring(1, trimmed.length() -
1).replace("''", "'");
+ return convertToMemberType(null, inner, targetType);
+ }
+ if (trimmed.startsWith("[") || trimmed.startsWith("{"))
+ return getJsonParser().parse(trimmed, targetType);
+ if (targetType.isNumber()) {
+ try {
+ if (trimmed.contains(".") ||
trimmed.toLowerCase().contains("e"))
+ return Double.parseDouble(trimmed);
+ return Long.parseLong(trimmed);
+ } catch (@SuppressWarnings("unused")
NumberFormatException e) {
+ return convertToMemberType(null, trimmed,
targetType);
+ }
+ }
+ if (targetType.isDateOrCalendarOrTemporal() ||
targetType.isDuration())
+ return Iso8601Utils.parse(trimmed, targetType,
getTimeZone());
+ return convertToMemberType(null, trimmed, targetType);
+ }
+
+ private static JsonParser getJsonParser() {
+ return Json5Parser.DEFAULT;
+ }
+
+ private static Object convertMapToTarget(Map<String, Object> map,
ClassMeta<?> type) throws ParseException, ExecutableException {
+ if (JsonMap.class.isAssignableFrom(type.inner()))
+ return new JsonMap(map);
+ return map;
+ }
+
+ private static String stripInlineComment(String value) {
+ if (value == null || !value.contains("#"))
+ return value;
+ var inQuote = false;
+ for (var i = 0; i < value.length(); i++) {
+ var c = value.charAt(i);
+ if (c == '\'')
+ inQuote = !inQuote;
+ else if (c == '#' && !inQuote)
+ return value.substring(0, i).trim();
+ }
+ return value;
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializer.java
new file mode 100644
index 0000000000..770e49f167
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializer.java
@@ -0,0 +1,276 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.collections.*;
+import org.apache.juneau.serializer.*;
+
+/**
+ * Serializes POJO models to INI format.
+ *
+ * <p>
+ * Converts Java POJOs to INI format (section/key-value structure).
+ *
+ * <h5 class='topic'>Media types</h5>
+ * <p>
+ * Produces: <bc>text/ini</bc>
+ * <br>Accepts: <bc>text/ini, text/x-ini</bc>
+ *
+ * <h5 class='topic'>Bean-to-INI mapping</h5>
+ * <ul class='spaced-list'>
+ * <li>Simple properties → key-value pairs in the default section
+ * <li>Nested beans → <c>[sectionName]</c> sections
+ * <li>Deeply nested beans → <c>[path/to/section]</c> section paths
+ * <li>Collections and complex values → JSON5-encoded inline values
+ * <li>Maps with string keys → <c>[sectionName]</c> sections
+ * <li>Null values → unquoted <c>null</c> token
+ * </ul>
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Serialize a bean to INI</jc>
+ * String <jv>ini</jv> =
IniSerializer.<jsf>DEFAULT</jsf>.serialize(<jv>myBean</jv>);
+ *
+ * <jc>// Create a custom serializer</jc>
+ * IniSerializer <jv>s</jv> =
IniSerializer.<jsm>create</jsm>().useComments().build();
+ * <jv>ini</jv> = <jv>s</jv>.serialize(<jv>myBean</jv>);
+ * </p>
+ *
+ * <h5 class='figure'>Example output (Map of name/age):</h5>
+ * <p class='bini'>
+ * <ck>name</ck> = <cv>Alice</cv>
+ * <ck>age</ck> = <cv>30</cv>
+ * </p>
+ *
+ * <h5 class='figure'>Complex (nested object + array):</h5>
+ * <p class='bini'>
+ * <ck>name</ck> = <cv>Alice</cv>
+ * <ck>age</ck> = <cv>30</cv>
+ * <ck>tags</ck> = <cv>['a','b','c']</cv>
+ *
+ * <cs>[address]</cs>
+ * <ck>street</ck> = <cv>123 Main St</cv>
+ * <ck>city</ck> = <cv>Boston</cv>
+ * <ck>state</ck> = <cv>MA</cv>
+ * </p>
+ *
+ * <h5 class='topic'>Limitations</h5>
+ * <p>
+ * <ul class='spaced-list'>
+ * <li>Top-level collections, arrays, and scalar values are not supported.
The root must be a bean or
+ * <c>Map<String,?></c>. Throws {@link SerializeException}
for unsupported root types.
+ * <li>{@link java.io.Reader} and {@link java.io.InputStream} passthrough
is not supported.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>This class is thread safe and reusable.
+ * <li class='note'>Complex values (collections, maps with complex values)
are embedded as JSON5 inline
+ * strings and remain fully round-trippable.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/IniBasics">INI Basics</a>
+ * </ul>
+ */
+@SuppressWarnings({
+ "java:S110", "java:S115"
+})
+public class IniSerializer extends WriterSerializer implements IniMetaProvider
{
+
+ private final java.util.concurrent.ConcurrentHashMap<ClassMeta<?>,
IniClassMeta> iniClassMetas = new java.util.concurrent.ConcurrentHashMap<>();
+ private final java.util.concurrent.ConcurrentHashMap<BeanPropertyMeta,
IniBeanPropertyMeta> iniBeanPropertyMetas = new
java.util.concurrent.ConcurrentHashMap<>();
+
+ private static final String PROP_kvSeparator = "kvSeparator";
+ private static final String PROP_spacedSeparator = "spacedSeparator";
+ private static final String PROP_useComments = "useComments";
+ private static final String ARG_copyFrom = "copyFrom";
+
+ /**
+ * Builder class.
+ */
+ public static class Builder extends WriterSerializer.Builder {
+
+ private static final Cache<HashKey,IniSerializer> CACHE =
Cache.of(HashKey.class, IniSerializer.class).build();
+
+ private char kvSeparator = '=';
+ private boolean spacedSeparator = true;
+ private boolean useComments = false;
+
+ protected Builder() {
+ produces("text/ini");
+ accept("text/ini,text/x-ini");
+ }
+
+ protected Builder(Builder copyFrom) {
+ super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ kvSeparator = copyFrom.kvSeparator;
+ spacedSeparator = copyFrom.spacedSeparator;
+ useComments = copyFrom.useComments;
+ }
+
+ protected Builder(IniSerializer copyFrom) {
+ super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ kvSeparator = copyFrom.kvSeparator;
+ spacedSeparator = copyFrom.spacedSeparator;
+ useComments = copyFrom.useComments;
+ }
+
+ /**
+ * Key-value separator character.
+ *
+ * @param value <c>=</c> (default) or <c>:</c>.
+ * @return This object.
+ */
+ public Builder kvSeparator(char value) {
+ kvSeparator = value;
+ return this;
+ }
+
+ /**
+ * Whether to add spaces around the separator.
+ *
+ * @param value <jk>true</jk> for <c>key = value</c>,
<jk>false</jk> for <c>key=value</c>.
+ * @return This object.
+ */
+ public Builder spacedSeparator(boolean value) {
+ spacedSeparator = value;
+ return this;
+ }
+
+ /**
+ * Whether to emit <c>#</c> comments from bean property
descriptions.
+ *
+ * @param value The flag.
+ * @return This object.
+ */
+ public Builder useComments(boolean value) {
+ useComments = value;
+ return this;
+ }
+
+ /**
+ * Emit <c>#</c> comments from bean property descriptions.
+ *
+ * @return This object.
+ */
+ public Builder useComments() {
+ useComments = true;
+ return this;
+ }
+
+ @Override
+ public Builder useWhitespace() {
+ super.useWhitespace();
+ return this;
+ }
+
+ @Override
+ public Builder useWhitespace(boolean value) {
+ super.useWhitespace(value);
+ return this;
+ }
+
+ @Override
+ public Builder ws() {
+ return useWhitespace();
+ }
+
+ @Override
+ public IniSerializer build() {
+ return cache(CACHE).build(IniSerializer.class);
+ }
+
+ @Override
+ public Builder copy() {
+ return new Builder(this);
+ }
+
+ @Override
+ public HashKey hashKey() {
+ return HashKey.of(super.hashKey(), kvSeparator,
spacedSeparator, useComments);
+ }
+ }
+
+ /** Default serializer. */
+ public static final IniSerializer DEFAULT = new IniSerializer(create());
+
+ /** Default serializer with blank lines between sections. */
+ public static final IniSerializer DEFAULT_READABLE = new
IniSerializer(create().ws());
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /** Key-value separator. */
+ protected final char kvSeparator;
+
+ /** Whether to add spaces around the separator. */
+ protected final boolean spacedSeparator;
+
+ /** Whether to emit property descriptions as comments. */
+ protected final boolean useComments;
+
+ /**
+ * Constructor.
+ *
+ * @param builder The builder.
+ */
+ public IniSerializer(Builder builder) {
+ super(builder);
+ kvSeparator = builder.kvSeparator;
+ spacedSeparator = builder.spacedSeparator;
+ useComments = builder.useComments;
+ }
+
+ @Override
+ public IniSerializerSession.Builder createSession() {
+ return IniSerializerSession.create(this);
+ }
+
+ @Override
+ public Builder copy() {
+ return new Builder(this);
+ }
+
+ @Override
+ public IniBeanPropertyMeta getIniBeanPropertyMeta(BeanPropertyMeta bpm)
{
+ if (bpm == null)
+ return IniBeanPropertyMeta.DEFAULT;
+ return iniBeanPropertyMetas.computeIfAbsent(bpm, k -> new
IniBeanPropertyMeta(k, this));
+ }
+
+ @Override
+ public IniClassMeta getIniClassMeta(ClassMeta<?> cm) {
+ return iniClassMetas.computeIfAbsent(cm, k -> new
IniClassMeta(k, this));
+ }
+
+ @Override
+ protected FluentMap<String,Object> properties() {
+ return super.properties()
+ .a(PROP_kvSeparator, String.valueOf(kvSeparator))
+ .a(PROP_spacedSeparator, spacedSeparator)
+ .a(PROP_useComments, useComments);
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializerSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializerSession.java
new file mode 100644
index 0000000000..1918aa50a4
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniSerializerSession.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.serializer.*;
+import org.apache.juneau.utils.Iso8601Utils;
+
+/**
+ * Session for serializing objects to INI format.
+ */
+@SuppressWarnings({
+ "resource", // IniWriter lifecycle managed by SerializerPipe
+ "java:S110", // Inheritance depth acceptable for serializer session
hierarchy
+ "java:S3776", // Cognitive complexity acceptable for serialization logic
+ "java:S6541" // Acceptable for session implementation
+})
+public class IniSerializerSession extends WriterSerializerSession {
+
+ private static final String ARG_ctx = "ctx";
+
+ /**
+ * Builder for INI serializer session.
+ */
+ public static class Builder extends WriterSerializerSession.Builder {
+
+ private IniSerializer ctx;
+
+ protected Builder(IniSerializer ctx) {
+ super(assertArgNotNull(ARG_ctx, ctx));
+ this.ctx = ctx;
+ }
+
+ @Override
+ public IniSerializerSession build() {
+ return new IniSerializerSession(this);
+ }
+ }
+
+ /**
+ * Creates a session builder.
+ *
+ * @param ctx The serializer context.
+ * @return The builder.
+ */
+ public static Builder create(IniSerializer ctx) {
+ return new Builder(assertArgNotNull(ARG_ctx, ctx));
+ }
+
+ private final IniSerializer ctx;
+
+ protected IniSerializerSession(Builder builder) {
+ super(builder);
+ ctx = builder.ctx;
+ }
+
+ @Override
+ protected void doSerialize(SerializerPipe out, Object o) throws
IOException, SerializeException {
+ if (o == null)
+ return;
+ var aType = getClassMetaForObject(o);
+ if (!aType.isBean() && !aType.isMap())
+ throw new SerializeException(this, "INI format requires
a bean or Map<String,?> at root. Got: {0}", aType.inner().getName());
+ var w = getIniWriter(out);
+ var eType = getExpectedRootType(o);
+ if (isAddBeanTypes() || isAddRootType()) {
+ var typeName = getBeanTypeName(this, eType, aType,
null);
+ if (typeName == null)
+ typeName = aType.inner().getSimpleName();
+ var typeKey = getBeanTypePropertyName(eType);
+ w.keyValue(typeKey, typeName);
+ }
+ if (aType.isBean())
+ serializeBean(w, toBeanMap(o), "");
+ else
+ serializeMapAtRoot(w, (Map<?,?>)o, eType);
+ }
+
+ protected final IniWriter getIniWriter(SerializerPipe out) {
+ var output = out.getRawOutput();
+ if (output instanceof IniWriter w)
+ return w;
+ var w = new IniWriter(out.getWriter(), isUseWhitespace(),
getMaxIndent(), isTrimStrings(),
+ ctx.kvSeparator, ctx.spacedSeparator, getUriResolver());
+ out.setWriter(w);
+ return w;
+ }
+
+ private void serializeBean(IniWriter w, BeanMap<?> m, String
sectionPath) throws IOException, SerializeException {
+ Predicate<Object> checkNull = x -> isKeepNullProperties() ||
nn(x);
+ var simple = new ArrayList<Map.Entry<BeanPropertyMeta,
Object>>();
+ var sections = new ArrayList<Map.Entry<BeanPropertyMeta,
Object>>();
+
+ m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
+ if (nn(thrown))
+ onBeanGetterException(pMeta, thrown);
+ if (canIgnoreValue(pMeta.getClassMeta(), key, value))
+ return;
+ var cMeta = pMeta.getClassMeta();
+ var aType = value == null ? cMeta :
getClassMetaForObject(value, cMeta);
+ var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
+ // Collections/arrays are written as inline key-value
(not sections), so they must appear in
+ // the default section before any [section] headers for
correct parsing
+ if (iniMeta.isJson5Encoding() ||
isSimpleOrJson5Inline(aType, value)
+ || aType.isCollectionOrArrayOrOptional()) {
+ simple.add(new AbstractMap.SimpleEntry<>(pMeta,
value));
+ } else {
+ sections.add(new
AbstractMap.SimpleEntry<>(pMeta, value));
+ }
+ });
+
+ // Pass 1: simple properties and JSON5-inline values
+ for (var e : simple) {
+ BeanPropertyMeta pMeta = e.getKey();
+ if (ctx.useComments) {
+ var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
+ if (ne(iniMeta.getComment()))
+ w.comment(iniMeta.getComment());
+ }
+ writeKeyValue(w, pMeta.getName(), e.getValue(), pMeta);
+ }
+
+ // Pass 2: bean and Map sections
+ for (var e : sections) {
+ BeanPropertyMeta pMeta = e.getKey();
+ Object value = e.getValue();
+ var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
+ var key = ne(iniMeta.getSection()) ?
iniMeta.getSection() : pMeta.getName();
+ var cMeta = pMeta.getClassMeta();
+ var newPath = sectionPath.isEmpty() ? key : sectionPath
+ "/" + key;
+
+ if (nn(value)) {
+ if (isUseWhitespace() && !sectionPath.isEmpty())
+ w.blankLine();
+ var aType = getClassMetaForObject(value, cMeta);
+ if (aType.isBean()) {
+ w.section(newPath);
+ serializeBean(w, toBeanMap(value),
newPath);
+ } else if (aType.isMap()) {
+ var map = (Map<?,?>)value;
+ if (isSimpleMap(map, aType)) {
+ w.section(newPath);
+ serializeMapSection(w, map,
aType);
+ } else {
+ if (ctx.useComments &&
ne(iniMeta.getComment()))
+
w.comment(iniMeta.getComment());
+ writeKeyValue(w,
pMeta.getName(), value, pMeta);
+ }
+ } else if (aType.isCollection() ||
aType.isArray()) {
+ if (ctx.useComments &&
ne(iniMeta.getComment()))
+ w.comment(iniMeta.getComment());
+ writeKeyValue(w, pMeta.getName(),
value, pMeta);
+ }
+ } else if (isKeepNullProperties()) {
+ if (ctx.useComments && ne(iniMeta.getComment()))
+ w.comment(iniMeta.getComment());
+ writeKeyValue(w, pMeta.getName(), null, pMeta);
+ }
+ }
+ }
+
+ private void serializeMapAtRoot(IniWriter w, Map<?,?> map, ClassMeta<?>
type) throws SerializeException {
+ Predicate<Object> checkNull = x -> isKeepNullProperties() ||
nn(x);
+ forEachEntry(map, e -> {
+ var k = toString(e.getKey());
+ var v = e.getValue();
+ if (!checkNull.test(v))
+ return;
+ try {
+ var aType = getClassMetaForObject(v, type);
+ if (aType.isBean()) {
+ if (isUseWhitespace())
+ w.blankLine();
+ w.section(k);
+ serializeBean(w, toBeanMap(v), k);
+ } else if (aType.isMap() && v != null) {
+ var nested = (Map<?,?>)v;
+ if (isSimpleMap(nested, aType)) {
+ if (isUseWhitespace())
+ w.blankLine();
+ w.section(k);
+ serializeMapSection(w, nested,
aType);
+ } else {
+ writeKeyValue(w, k, v, null);
+ }
+ } else {
+ writeKeyValue(w, k, v, null);
+ }
+ } catch (SerializeException | IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ });
+ }
+
+ private void serializeMapSection(IniWriter w, Map<?,?> map,
ClassMeta<?> type) throws SerializeException {
+ Predicate<Object> checkNull = x -> isKeepNullProperties() ||
nn(x);
+ forEachEntry(map, e -> {
+ var k = toString(e.getKey());
+ var v = e.getValue();
+ if (!checkNull.test(v))
+ return;
+ try {
+ writeKeyValue(w, k, v, null);
+ } catch (SerializeException ex) {
+ throw new RuntimeException(ex);
+ }
+ });
+ }
+
+ private void writeKeyValue(IniWriter w, String key, Object value,
BeanPropertyMeta pMeta) throws SerializeException {
+ var cMeta = pMeta != null ? pMeta.getClassMeta() : (value !=
null ? getClassMetaForObject(value) : object());
+ var aType = value == null ? cMeta :
getClassMetaForObject(value, cMeta);
+
+ var swap = aType.getSwap(this);
+ if (nn(swap)) {
+ value = swap(swap, value);
+ aType = swap.getSwapClassMeta(this);
+ if (aType.isObject())
+ aType = getClassMetaForObject(value);
+ }
+
+ if (value == null && !isKeepNullProperties())
+ return;
+
+ var valueStr = value == null ? "null"
+ : isSimpleOrJson5Inline(aType, value) ?
formatSimpleValue(value, aType)
+ : encodeComplexValue(value);
+ w.keyValue(key, valueStr);
+ }
+
+ private String formatSimpleValue(Object value, ClassMeta<?> aType)
throws SerializeException {
+ if (aType.isNumber())
+ return value.toString();
+ if (aType.isBoolean())
+ return ((Boolean)value).toString();
+ if (aType.isDateOrCalendarOrTemporal())
+ return Iso8601Utils.format(value, aType, getTimeZone());
+ if (aType.isDuration())
+ return value.toString();
+ if (aType.isEnum())
+ return ((Enum<?>)value).name();
+ if (aType.isCharSequence() || aType.isUri()) {
+ var s = toString(value);
+ return needsQuoting(s) ? "'" + s.replace("'", "''") +
"'" : s;
+ }
+ return toString(value);
+ }
+
+ private static boolean needsQuoting(String s) {
+ if (s == null || s.isEmpty())
+ return true;
+ if (s.equals("null") || s.equalsIgnoreCase("true") ||
s.equalsIgnoreCase("false"))
+ return true;
+ if (s.matches("-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?"))
+ return true;
+ if (s.contains("=") || s.contains("[") || s.contains("]") ||
s.contains("#") || s.contains("\n"))
+ return true;
+ var trimmed = s.trim();
+ if (!trimmed.equals(s))
+ return true;
+ return false;
+ }
+
+ private String encodeComplexValue(Object value) throws
SerializeException {
+ var json5 = getJson5Serializer().serialize(value);
+ return json5;
+ }
+
+ private JsonSerializer getJson5Serializer() {
+ var b =
Json5Serializer.create().beanContext((BeanContext)getContext());
+ if (isAddBeanTypes())
+ b.addBeanTypes();
+ if (isAddRootType())
+ b.addRootType();
+ return b.build();
+ }
+
+ private static boolean isSimpleOrJson5Inline(ClassMeta<?> aType, Object
value) {
+ if (aType.isBean() || aType.isMap())
+ return false;
+ if (aType.isCollection() || aType.isArray())
+ return false;
+ if (aType.isStreamable())
+ return false;
+ return true;
+ }
+
+ private boolean isSimpleMap(Map<?,?> map, ClassMeta<?> mapType) {
+ if (map == null)
+ return false;
+ var keyType = mapType.getKeyType();
+ // When keyType is null or Object, check keys at runtime;
otherwise require CharSequence
+ if (keyType != null && !keyType.isCharSequence() &&
!keyType.isObject())
+ return false;
+ for (Object k : map.keySet()) {
+ if (k != null && !(k instanceof CharSequence))
+ return false;
+ }
+ for (Object v : map.values()) {
+ if (v != null) {
+ ClassMeta<?> vType = getClassMetaForObject(v);
+ if (vType.isBean() || vType.isMap() ||
vType.isCollectionOrArray())
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniWriter.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniWriter.java
new file mode 100644
index 0000000000..801ac1287d
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/IniWriter.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.serializer.*;
+
+/**
+ * Specialized writer for serializing INI format.
+ *
+ * <p>
+ * Extends {@link SerializerWriter} with INI-specific methods for sections,
key-value pairs,
+ * comments, and quoted strings.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>This class is not intended for external use.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/IniBasics">INI Basics</a>
+ * </ul>
+ */
+@SuppressWarnings({
+ "resource" // Writer resource managed by calling code
+})
+public class IniWriter extends SerializerWriter {
+
+ /** Key-value separator character. */
+ protected final char kvSeparator;
+
+ /** Whether to add spaces around the separator. */
+ protected final boolean spacedSeparator;
+
+ /**
+ * Constructor.
+ *
+ * @param out The writer being wrapped.
+ * @param useWhitespace If <jk>true</jk>, use blank lines between
sections.
+ * @param maxIndent The maximum indentation level (unused for INI).
+ * @param trimStrings If <jk>true</jk>, trim strings before
serialization.
+ * @param kvSeparator The key-value separator ('=' or ':').
+ * @param spacedSeparator If <jk>true</jk>, add spaces around separator.
+ * @param uriResolver The URI resolver.
+ */
+ protected IniWriter(Writer out, boolean useWhitespace, int maxIndent,
boolean trimStrings, char kvSeparator, boolean spacedSeparator, UriResolver
uriResolver) {
+ super(out, useWhitespace, maxIndent, trimStrings, '\'',
uriResolver);
+ this.kvSeparator = kvSeparator;
+ this.spacedSeparator = spacedSeparator;
+ }
+
+ /**
+ * Writes a section header with optional preceding blank line.
+ *
+ * @param name The section name or path (e.g. "address" or
"employment/company").
+ * @return This object.
+ */
+ public IniWriter section(String name) {
+ w('\n');
+ w('[').w(name).w(']').w('\n');
+ return this;
+ }
+
+ /**
+ * Writes a comment line.
+ *
+ * @param text The comment text.
+ * @return This object.
+ */
+ public IniWriter comment(String text) {
+ w("# ");
+ if (text != null && !text.isEmpty()) {
+ var lines = text.split("\n");
+ for (var i = 0; i < lines.length; i++) {
+ if (i > 0)
+ w("\n# ");
+ w(lines[i]);
+ }
+ }
+ w('\n');
+ return this;
+ }
+
+ /**
+ * Writes a key-value pair.
+ *
+ * @param key The key name.
+ * @param value The value (already formatted as string).
+ * @return This object.
+ */
+ public IniWriter keyValue(String key, String value) {
+ w(key);
+ if (spacedSeparator)
+ w(" ").w(kvSeparator).w(" ");
+ else
+ w(kvSeparator);
+ w(value != null ? value : "null");
+ w('\n');
+ return this;
+ }
+
+ /**
+ * Writes a single-quoted string value with escaping.
+ *
+ * <p>
+ * Single quotes within the value are escaped as <c>''</c>.
+ *
+ * @param value The raw string value.
+ * @return This object.
+ */
+ public IniWriter quotedString(String value) {
+ if (value == null) {
+ w("null");
+ return this;
+ }
+ w('\'').w(value.replace("'", "''")).w('\'');
+ return this;
+ }
+
+ /**
+ * Writes a blank line.
+ *
+ * @return This object.
+ */
+ public IniWriter blankLine() {
+ w('\n');
+ return this;
+ }
+
+ // Override return types for chaining
+ @Override public IniWriter append(char c) { super.append(c); return
this; }
+ @Override public IniWriter append(char[] value) { super.append(value);
return this; }
+ @Override public IniWriter append(int indent, char c) {
super.append(indent, c); return this; }
+ @Override public IniWriter append(int indent, String text) {
super.append(indent, text); return this; }
+ @Override public IniWriter append(Object text) { super.append(text);
return this; }
+ @Override public IniWriter append(String text) { super.append(text);
return this; }
+ @Override public IniWriter appendIf(boolean b, char c) {
super.appendIf(b, c); return this; }
+ @Override public IniWriter appendIf(boolean b, String text) {
super.appendIf(b, text); return this; }
+ @Override public IniWriter appendln(int indent, String text) {
super.appendln(indent, text); return this; }
+ @Override public IniWriter appendln(String text) {
super.appendln(text); return this; }
+ @Override public IniWriter appendUri(Object value) {
super.appendUri(value); return this; }
+ @Override public IniWriter cr(int depth) { super.cr(depth); return
this; }
+ @Override public IniWriter cre(int depth) { super.cre(depth); return
this; }
+ @Override public IniWriter i(int indent) { super.i(indent); return
this; }
+ @Override public IniWriter ie(int indent) { super.ie(indent); return
this; }
+ @Override public IniWriter nl(int indent) { super.nl(indent); return
this; }
+ @Override public IniWriter nlIf(boolean flag, int indent) {
super.nlIf(flag, indent); return this; }
+ @Override public IniWriter q() { super.q(); return this; }
+ @Override public IniWriter s() { super.s(); return this; }
+ @Override public IniWriter sIf(boolean flag) { super.sIf(flag); return
this; }
+ @Override public IniWriter w(char value) { super.w(value); return this;
}
+ @Override public IniWriter w(String value) { super.w(value); return
this; }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/Ini.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/Ini.java
new file mode 100644
index 0000000000..1c35a085e2
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/Ini.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini.annotation;
+
+import static java.lang.annotation.ElementType.*;
+import static java.lang.annotation.RetentionPolicy.*;
+
+import java.lang.annotation.*;
+
+import org.apache.juneau.annotation.*;
+
+/**
+ * Annotation for customizing INI serialization and parsing behavior on
classes, methods, and fields.
+ *
+ * <p>
+ * Can be used in the following locations:
+ * <ul>
+ * <li>Marshalled classes/methods/fields.
+ * <li><ja>@Rest</ja>-annotated classes and <ja>@RestOp</ja>-annotated
methods when an {@link #on()} value is specified.
+ * </ul>
+ */
+@Documented
+@Target({ TYPE, FIELD, METHOD })
+@Retention(RUNTIME)
+@Inherited
+@Repeatable(IniAnnotation.Array.class)
+@ContextApply(IniAnnotation.Apply.class)
+public @interface Ini {
+
+ /**
+ * Custom section name override.
+ *
+ * <p>
+ * For beans: overrides the property name as the section header.
+ *
+ * @return The annotation value.
+ */
+ String section() default "";
+
+ /**
+ * Comment text to emit before this property.
+ *
+ * @return The annotation value.
+ */
+ String comment() default "";
+
+ /**
+ * Force JSON5 encoding even for simple values.
+ *
+ * @return The annotation value.
+ */
+ boolean json5Encoding() default false;
+
+ /**
+ * Dynamically apply this annotation to the specified
classes/methods/fields.
+ *
+ * @return The annotation value.
+ */
+ String[] on() default {};
+
+ /**
+ * Dynamically apply this annotation to the specified classes.
+ *
+ * @return The annotation value.
+ */
+ Class<?>[] onClass() default {};
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniAnnotation.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniAnnotation.java
new file mode 100644
index 0000000000..4cedf4b79e
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniAnnotation.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini.annotation;
+
+import java.lang.annotation.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.svl.*;
+
+/**
+ * Utility classes and methods for the {@link Ini @Ini} annotation.
+ */
+public class IniAnnotation {
+
+ /**
+ * Applies {@link Ini} annotations to a {@link Context.Builder}.
+ */
+ public static class Apply extends AnnotationApplier<Ini,
Context.Builder> {
+
+ /**
+ * Constructor.
+ *
+ * @param vr The resolver for resolving values in annotations.
+ */
+ public Apply(VarResolverSession vr) {
+ super(Ini.class, Context.Builder.class, vr);
+ }
+
+ @Override /* AnnotationApplier */
+ public void apply(AnnotationInfo<Ini> ai, Context.Builder b) {
+ // No-op: @Ini settings are read at serialization/parse
time via IniMetaProvider.
+ }
+ }
+
+ /**
+ * A collection of {@link Ini} annotations.
+ */
+ @Documented
+ @Target({ java.lang.annotation.ElementType.TYPE,
java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD
})
+ @Retention(RetentionPolicy.RUNTIME)
+ @Inherited
+ public @interface Array {
+
+ /**
+ * The child annotations.
+ *
+ * @return The annotation value.
+ */
+ Ini[] value();
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfig.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfig.java
new file mode 100644
index 0000000000..3d9d33d4e6
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfig.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini.annotation;
+
+import static java.lang.annotation.ElementType.*;
+import static java.lang.annotation.RetentionPolicy.*;
+
+import java.lang.annotation.*;
+
+import org.apache.juneau.annotation.*;
+import org.apache.juneau.ini.*;
+
+/**
+ * Annotation for specifying config properties defined in {@link
IniSerializer} and {@link IniParser}.
+ *
+ * <p>
+ * Used primarily for specifying bean configuration properties on REST classes
and methods.
+ */
+@Target({ TYPE, METHOD })
+@Retention(RUNTIME)
+@Inherited
+@ContextApply({ IniConfigAnnotation.SerializerApply.class,
IniConfigAnnotation.ParserApply.class })
+public @interface IniConfig {
+
+ /**
+ * Key-value separator character.
+ *
+ * <ul class='values'>
+ * <li><js>"="</js> (default)
+ * <li><js>":"</js>
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String kvSeparator() default "";
+
+ /**
+ * Whether to add spaces around the separator.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> (default) — <c>key = value</c>
+ * <li><js>"false"</js> — <c>key=value</c>
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String spacedSeparator() default "";
+
+ /**
+ * Whether to emit property descriptions as <c>#</c> comments.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js>
+ * <li><js>"false"</js> (default)
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String useComments() default "";
+
+ /**
+ * Optional rank for this config.
+ *
+ * @return The annotation value.
+ */
+ int rank() default 0;
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfigAnnotation.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfigAnnotation.java
new file mode 100644
index 0000000000..257ad458f3
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/annotation/IniConfigAnnotation.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.ini.annotation;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.ini.*;
+import org.apache.juneau.svl.*;
+
+/**
+ * Utility classes and methods for the {@link IniConfig @IniConfig} annotation.
+ */
+public class IniConfigAnnotation {
+
+ private IniConfigAnnotation() {}
+
+ /**
+ * Applies {@link IniConfig} annotations to an {@link
IniParser.Builder}.
+ */
+ public static class ParserApply extends AnnotationApplier<IniConfig,
IniParser.Builder> {
+
+ /**
+ * Constructor.
+ *
+ * @param vr The resolver for resolving values in annotations.
+ */
+ public ParserApply(VarResolverSession vr) {
+ super(IniConfig.class, IniParser.Builder.class, vr);
+ }
+
+ @Override
+ public void apply(AnnotationInfo<IniConfig> ai,
IniParser.Builder b) {
+ // No-op: Parser accepts both = and :; no
format-specific settings needed.
+ }
+ }
+
+ /**
+ * Applies {@link IniConfig} annotations to an {@link
IniSerializer.Builder}.
+ */
+ public static class SerializerApply extends
AnnotationApplier<IniConfig, IniSerializer.Builder> {
+
+ /**
+ * Constructor.
+ *
+ * @param vr The resolver for resolving values in annotations.
+ */
+ public SerializerApply(VarResolverSession vr) {
+ super(IniConfig.class, IniSerializer.Builder.class, vr);
+ }
+
+ @Override
+ public void apply(AnnotationInfo<IniConfig> ai,
IniSerializer.Builder b) {
+ IniConfig a = ai.inner();
+ string(a.kvSeparator()).filter(s ->
!s.isEmpty()).ifPresent(s -> b.kvSeparator(s.charAt(0)));
+ bool(a.spacedSeparator()).ifPresent(b::spacedSeparator);
+ bool(a.useComments()).ifPresent(b::useComments);
+ }
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/package-info.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/package-info.java
new file mode 100644
index 0000000000..d1caa25dc5
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ini/package-info.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * INI format serializer and parser for Apache Juneau.
+ *
+ * <p>
+ * INI is a classic configuration file format with sections
(<c>[sectionName]</c>) and key-value pairs.
+ * Beans map to INI with simple properties as key-value pairs and nested beans
as named sections.
+ *
+ * <h5 class='section'>Usage:</h5>
+ * <p class='bjava'>
+ * String ini =
IniSerializer.<jsf>DEFAULT</jsf>.serialize(<jv>myBean</jv>);
+ * MyBean bean = IniParser.<jsf>DEFAULT</jsf>.parse(<jv>ini</jv>,
MyBean.<jk>class</jk>);
+ *
+ * <jc>// Or use the marshaller:</jc>
+ * String ini = Ini.<jsf>of</jsf>(<jv>myBean</jv>);
+ * MyBean bean = Ini.<jsf>to</jsf>(<jv>ini</jv>, MyBean.<jk>class</jk>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/IniBasics">INI Basics</a>
+ * </ul>
+ */
+package org.apache.juneau.ini;
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshaller/Ini.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshaller/Ini.java
new file mode 100644
index 0000000000..77d7002bce
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshaller/Ini.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.juneau.marshaller;
+
+import java.io.*;
+import java.lang.reflect.*;
+
+import org.apache.juneau.ini.*;
+import org.apache.juneau.parser.*;
+import org.apache.juneau.serializer.*;
+
+/**
+ * A pairing of an {@link IniSerializer} and {@link IniParser} into a single
class with convenience read/write methods.
+ *
+ * <p>
+ * The general idea is to combine a single serializer and parser inside a
simplified API for reading and writing POJOs.
+ *
+ * <h5 class='figure'>Examples:</h5>
+ * <p class='bjava'>
+ * <jc>// Using instance.</jc>
+ * Ini <jv>ini</jv> = <jk>new</jk> Ini();
+ * MyPojo <jv>myPojo</jv> = <jv>ini</jv>.read(<jv>string</jv>,
MyPojo.<jk>class</jk>);
+ * String <jv>string</jv> = <jv>ini</jv>.write(<jv>myPojo</jv>);
+ * </p>
+ * <p class='bjava'>
+ * <jc>// Using DEFAULT instance.</jc>
+ * MyPojo <jv>myPojo</jv> = Ini.<jsf>DEFAULT</jsf>.read(<jv>string</jv>,
MyPojo.<jk>class</jk>);
+ * String <jv>string</jv> = Ini.<jsf>DEFAULT</jsf>.write(<jv>myPojo</jv>);
+ * </p>
+ *
+ * <h5 class='figure'>Example output (Map of name/age):</h5>
+ * <p class='bini'>
+ * <ck>name</ck> = <cv>Alice</cv>
+ * <ck>age</ck> = <cv>30</cv>
+ * </p>
+ *
+ * <h5 class='figure'>Complex (nested object + array):</h5>
+ * <p class='bini'>
+ * <ck>name</ck> = <cv>Alice</cv>
+ * <ck>age</ck> = <cv>30</cv>
+ * <ck>tags</ck> = <cv>['a','b','c']</cv>
+ *
+ * <cs>[address]</cs>
+ * <ck>street</ck> = <cv>123 Main St</cv>
+ * <ck>city</ck> = <cv>Boston</cv>
+ * <ck>state</ck> = <cv>MA</cv>
+ * </p>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>The top-level object must be a bean or
<c>Map<String,?></c>.
+ * <li class='note'>Collections and complex map values are embedded as
JSON5 inline strings.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/Marshallers">Marshallers</a>
+ * </ul>
+ */
+public class Ini extends CharMarshaller {
+
+ /** Default reusable instance. */
+ public static final Ini DEFAULT = new Ini();
+
+ /** Default reusable instance, readable format. */
+ public static final Ini DEFAULT_READABLE = new
Ini(IniSerializer.DEFAULT_READABLE, IniParser.DEFAULT);
+
+ /**
+ * Serializes a Java object to an INI string.
+ *
+ * @param object The object to serialize.
+ * @return The serialized object.
+ * @throws SerializeException If a problem occurred trying to convert
the output.
+ */
+ public static String of(Object object) throws SerializeException {
+ return DEFAULT.write(object);
+ }
+
+ /**
+ * Serializes a Java object to INI output.
+ *
+ * @param object The object to serialize.
+ * @param output The output (Writer, OutputStream, File, StringBuilder).
+ * @return The output object.
+ * @throws SerializeException If a problem occurred trying to convert
the output.
+ * @throws IOException Thrown by underlying stream.
+ */
+ public static Object of(Object object, Object output) throws
SerializeException, IOException {
+ DEFAULT.write(object, output);
+ return output;
+ }
+
+ /**
+ * Parses INI input to the specified Java type.
+ *
+ * @param <T> The class type of the object being created.
+ * @param input The input.
+ * @param type The object type to create.
+ * @return The parsed object.
+ * @throws ParseException Malformed input encountered.
+ * @throws IOException Thrown by underlying stream.
+ */
+ public static <T> T to(Object input, Class<T> type) throws
ParseException, IOException {
+ return DEFAULT.read(input, type);
+ }
+
+ /**
+ * Parses INI input to the specified Java type.
+ *
+ * @param <T> The class type of the object to create.
+ * @param input The input.
+ * @param type The object type to create.
+ * @param args The type arguments for maps and collections.
+ * @return The parsed object.
+ * @throws ParseException Malformed input encountered.
+ * @throws IOException Thrown by underlying stream.
+ */
+ public static <T> T to(Object input, Type type, Type...args) throws
ParseException, IOException {
+ return DEFAULT.read(input, type, args);
+ }
+
+ /**
+ * Parses an INI string to the specified type.
+ *
+ * @param <T> The class type of the object being created.
+ * @param input The input.
+ * @param type The object type to create.
+ * @return The parsed object.
+ * @throws ParseException Malformed input encountered.
+ */
+ public static <T> T to(String input, Class<T> type) throws
ParseException {
+ return DEFAULT.read(input, type);
+ }
+
+ /**
+ * Parses an INI string to the specified Java type.
+ *
+ * @param <T> The class type of the object to create.
+ * @param input The input.
+ * @param type The object type to create.
+ * @param args The type arguments for maps and collections.
+ * @return The parsed object.
+ * @throws ParseException Malformed input encountered.
+ */
+ public static <T> T to(String input, Type type, Type...args) throws
ParseException {
+ return DEFAULT.read(input, type, args);
+ }
+
+ /**
+ * Constructor.
+ *
+ * <p>
+ * Uses {@link IniSerializer#DEFAULT} and {@link IniParser#DEFAULT}.
+ */
+ public Ini() {
+ this(IniSerializer.DEFAULT, IniParser.DEFAULT);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param s The serializer to use.
+ * @param p The parser to use.
+ */
+ public Ini(IniSerializer s, IniParser p) {
+ super(s, p);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
index ef62eda74d..d7ffe9cb1c 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
@@ -92,6 +92,7 @@ import org.apache.juneau.parser.ParseException;
import org.apache.juneau.plaintext.*;
import org.apache.juneau.rest.client.assertion.*;
import org.apache.juneau.toml.*;
+import org.apache.juneau.ini.*;
import org.apache.juneau.proto.*;
import org.apache.juneau.rest.client.remote.*;
import org.apache.juneau.serializer.*;
@@ -5567,6 +5568,7 @@ public class RestClient extends BeanContextable
implements HttpClient, Closeable
MsgPackSerializer.class,
PlainTextSerializer.class,
TomlSerializer.class,
+ IniSerializer.class,
ProtoSerializer.class,
MarkdownSerializer.class
)
@@ -5582,6 +5584,7 @@ public class RestClient extends BeanContextable
implements HttpClient, Closeable
MsgPackParser.class,
PlainTextParser.class,
TomlParser.class,
+ IniParser.class,
ProtoParser.class,
MarkdownParser.class
);
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/BasicUniversalConfig.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/BasicUniversalConfig.java
index 47fc894764..7d2bf82567 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/BasicUniversalConfig.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/BasicUniversalConfig.java
@@ -31,6 +31,7 @@ import org.apache.juneau.serializer.annotation.*;
import org.apache.juneau.soap.*;
import org.apache.juneau.yaml.*;
import org.apache.juneau.toml.*;
+import org.apache.juneau.ini.*;
import org.apache.juneau.proto.*;
import org.apache.juneau.markdown.*;
import org.apache.juneau.uon.*;
@@ -154,6 +155,7 @@ import org.apache.juneau.xml.*;
CsvSerializer.class,
YamlSerializer.class,
TomlSerializer.class,
+ IniSerializer.class,
ProtoSerializer.class,
MarkdownSerializer.class
},
@@ -173,6 +175,7 @@ import org.apache.juneau.xml.*;
CsvParser.class,
YamlParser.class,
TomlParser.class,
+ IniParser.class,
ProtoParser.class,
MarkdownParser.class
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/ComboRoundTrip_Tester.java
b/juneau-utest/src/test/java/org/apache/juneau/ComboRoundTrip_Tester.java
index 2ccc2edb0a..96c77d4db2 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/ComboRoundTrip_Tester.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/ComboRoundTrip_Tester.java
@@ -39,6 +39,7 @@ import org.apache.juneau.urlencoding.*;
import org.apache.juneau.xml.*;
import org.apache.juneau.yaml.*;
import org.apache.juneau.toml.*;
+import org.apache.juneau.ini.*;
import org.apache.juneau.markdown.*;
/**
@@ -143,6 +144,7 @@ public class ComboRoundTrip_Tester<T> {
public Builder<T> yamlR(String value) { expected.put("yamlR",
value); return this; }
public Builder<T> csv(String value) { expected.put("csv",
value); return this; }
public Builder<T> toml(String value) { expected.put("toml",
value); return this; }
+ public Builder<T> ini(String value) { expected.put("ini",
value); return this; }
public Builder<T> markdown(String value) {
expected.put("markdown", value); return this; }
public ComboRoundTrip_Tester<T> build() {
@@ -211,6 +213,7 @@ public class ComboRoundTrip_Tester<T> {
serializers.put("yamlR", create(b,
YamlSerializer.DEFAULT_READABLE.copy().addBeanTypes().addRootType()));
serializers.put("csv", create(b, CsvSerializer.create()));
serializers.put("toml", create(b,
TomlSerializer.DEFAULT.copy().addBeanTypes().addRootType()));
+ serializers.put("ini", create(b,
IniSerializer.DEFAULT.copy().addBeanTypes().addRootType()));
serializers.put("markdown", create(b,
MarkdownSerializer.create().keepNullProperties().addBeanTypes().addRootType()));
parsers.put("json", create(b, JsonParser.DEFAULT.copy()));
@@ -253,6 +256,7 @@ public class ComboRoundTrip_Tester<T> {
parsers.put("yamlR", create(b, YamlParser.DEFAULT.copy()));
parsers.put("csv", create(b, CsvParser.create()));
parsers.put("toml", create(b, TomlParser.DEFAULT.copy()));
+ parsers.put("ini", create(b, IniParser.DEFAULT.copy()));
parsers.put("markdown", create(b, MarkdownParser.create()));
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/ComboSerialize_Tester.java
b/juneau-utest/src/test/java/org/apache/juneau/ComboSerialize_Tester.java
index 71f1a53012..0eb4590e5c 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/ComboSerialize_Tester.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/ComboSerialize_Tester.java
@@ -38,6 +38,7 @@ import org.apache.juneau.urlencoding.*;
import org.apache.juneau.xml.*;
import org.apache.juneau.yaml.*;
import org.apache.juneau.toml.*;
+import org.apache.juneau.ini.*;
import org.apache.juneau.markdown.*;
/**
@@ -128,6 +129,7 @@ public class ComboSerialize_Tester<T> {
public Builder<T> yamlT(String value) { expected.put("yamlT",
value); return this; }
public Builder<T> yamlR(String value) { expected.put("yamlR",
value); return this; }
public Builder<T> toml(String value) { expected.put("toml",
value); return this; }
+ public Builder<T> ini(String value) { expected.put("ini",
value); return this; }
public Builder<T> markdown(String value) {
expected.put("markdown", value); return this; }
public ComboSerialize_Tester<T> build() {
@@ -189,6 +191,7 @@ public class ComboSerialize_Tester<T> {
serializers.put("yamlT", create(b,
YamlSerializer.create().typePropertyName("t").addBeanTypes().addRootType()));
serializers.put("yamlR", create(b,
YamlSerializer.DEFAULT_READABLE.copy().addBeanTypes().addRootType()));
serializers.put("toml", create(b,
TomlSerializer.DEFAULT.copy().addBeanTypes().addRootType()));
+ serializers.put("ini", create(b,
IniSerializer.DEFAULT.copy().addBeanTypes().addRootType()));
serializers.put("markdown", create(b,
MarkdownSerializer.create().keepNullProperties().addBeanTypes().addRootType()));
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripDateTime_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripDateTime_Test.java
index 107ea66fc2..99695bdba8 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripDateTime_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripDateTime_Test.java
@@ -36,6 +36,7 @@ import org.apache.juneau.urlencoding.*;
import org.apache.juneau.xml.*;
import org.apache.juneau.yaml.*;
import org.apache.juneau.toml.*;
+import org.apache.juneau.ini.*;
import org.apache.juneau.proto.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
@@ -176,16 +177,20 @@ class RoundTripDateTime_Test extends TestBase {
.serializer(TomlSerializer.create())
.parser(TomlParser.create())
.build(),
- tester(32, "Csv - default")
+ tester(32, "Ini - default")
+ .serializer(IniSerializer.create())
+ .parser(IniParser.create())
+ .build(),
+ tester(33, "Csv - default")
.serializer(CsvSerializer.create().keepNullProperties())
.skipIf(o -> o == null || (o.getClass().isArray() &&
o.getClass().getComponentType().isPrimitive()))
.returnOriginalObject()
.build(),
- tester(33, "Markdown - default")
+ tester(34, "Markdown - default")
.serializer(MarkdownSerializer.create().keepNullProperties().addBeanTypes().addRootType())
.parser(MarkdownParser.create())
.build(),
- tester(34, "Proto - default")
+ tester(35, "Proto - default")
.serializer(ProtoSerializer.create().keepNullProperties().addBeanTypes().addRootType())
.parser(ProtoParser.create())
.build(),
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/ini/IniParser_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/ini/IniParser_Test.java
new file mode 100644
index 0000000000..de22e1d38f
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/ini/IniParser_Test.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 FOR A PARTICULAR PURPOSE. See the
+ * License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.ini;
+
+import static org.apache.juneau.junit.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link IniParser}.
+ */
+@SuppressWarnings("unchecked")
+class IniParser_Test extends TestBase {
+
+
//====================================================================================================
+ // a - Simple flat parsing
+
//====================================================================================================
+
+ @Test
+ void a01_simpleBean() throws Exception {
+ var ini = "host = localhost\nport = 8080\ndebug = true";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "host,port,debug", "localhost,8080,true");
+ }
+
+ @Test
+ void a02_nestedBean() throws Exception {
+ var ini = "name = myapp\n\n[database]\nhost = localhost\nport =
5432";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "name,database{host,port}",
"myapp,{localhost,5432}");
+ }
+
+ @Test
+ void a03_nullValues() throws Exception {
+ var ini = "name = Alice\nmiddle = null";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "name,middle", "Alice,<null>");
+ }
+
+ @Test
+ void a04_quotedStrings() throws Exception {
+ var ini = "a = 'hello'\nb = 'world'";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "a,b", "hello,world");
+ }
+
+ @Test
+ void a05_numbersAndBooleans() throws Exception {
+ var ini = "count = 42\nratio = 3.14\nflag = true";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "count,ratio,flag", "42,3.14,true");
+ }
+
+ @Test
+ void a06_listOfStrings() throws Exception {
+ var ini = "tags = ['a','b','c']";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "tags", "[a,b,c]");
+ }
+
+ @Test
+ void a07_commentsIgnored() throws Exception {
+ var ini = "# comment\nname = Alice\n# another";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "name", "Alice");
+ }
+
+ @Test
+ void a08_blankLinesIgnored() throws Exception {
+ var ini = "a = 1\n\n\nb = 2";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "a,b", "1,2");
+ }
+
+ @Test
+ void a09_deeplyNestedBean() throws Exception {
+ var ini = "name = John\n\n[employment]\ntitle =
Engineer\n\n[employment/company]\nname = Acme\nticker = ACME";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "name,employment{title,company{name,ticker}}",
"John,{Engineer,{Acme,ACME}}");
+ }
+
+ @Test
+ void a10_emptyInput() throws Exception {
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse("",
Map.class, String.class, Object.class);
+ assertNotNull(m);
+ assertTrue(m.isEmpty());
+ }
+
+ @Test
+ void a11_colonSeparator() throws Exception {
+ var ini = "key: value";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "key", "value");
+ }
+
+ @Test
+ void a12_noSpaceSeparator() throws Exception {
+ var ini = "key=value";
+ var m = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(m, "key", "value");
+ }
+
+ @Test
+ void a13_topLevelCollectionThrows() throws Exception {
+ var ex = assertThrows(Exception.class, () ->
+ IniParser.DEFAULT.parse("[]", List.class,
String.class));
+ assertTrue(ex.getMessage().contains("not supported") ||
ex.getMessage().contains("bean")
+ || ex.getClass().getSimpleName().contains("Parse"));
+ }
+
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/ini/IniRoundTrip_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/ini/IniRoundTrip_Test.java
new file mode 100644
index 0000000000..d10fdf51dd
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/ini/IniRoundTrip_Test.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 FOR A PARTICULAR PURPOSE. See the
+ * License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.ini;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.junit.bct.BctAssertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.annotation.*;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Round-trip tests for {@link IniSerializer} and {@link IniParser}.
+ */
+@SuppressWarnings({
+ "unchecked" // Parser returns Object; cast to Map/bean in tests
+})
+class IniRoundTrip_Test extends TestBase {
+
+ @Bean(properties = "name,age")
+ public static class Person {
+ public String name;
+ public int age;
+
+ public Person() {}
+ public Person(String name, int age) {
+ this.name = name;
+ this.age = age;
+ }
+ }
+
+ @Bean(properties = "name,address,tags")
+ public static class ComplexPerson {
+ public String name;
+ public Address address;
+ public List<String> tags;
+
+ public ComplexPerson() {}
+ public ComplexPerson(String name, Address address, List<String>
tags) {
+ this.name = name;
+ this.address = address;
+ this.tags = tags;
+ }
+ }
+
+ @Bean(properties = "street,city")
+ public static class Address {
+ public String street;
+ public String city;
+
+ public Address() {}
+ public Address(String street, String city) {
+ this.street = street;
+ this.city = city;
+ }
+ }
+
+
//====================================================================================================
+ // a - Simple bean round-trip
+
//====================================================================================================
+
+ @Test
+ void a01_simpleBeanRoundTrip() throws Exception {
+ var a = new Person("Alice", 30);
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = IniParser.DEFAULT.parse(ini, Person.class);
+ assertBean(b, "name,age", "Alice,30");
+ }
+
+ @Test
+ void a02_nestedBeanRoundTrip() throws Exception {
+ var a = new ComplexPerson("Alice", new Address("123 Main",
"Boston"), list("a", "b", "c"));
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = IniParser.DEFAULT.parse(ini, ComplexPerson.class);
+ assertBean(b, "name,address{street,city},tags", "Alice,{123
Main,Boston},[a,b,c]");
+ }
+
+ @Test
+ void a03_mapRoundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "test");
+ a.put("count", 42);
+ a.put("flag", true);
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,count,flag", "test,42,true");
+ }
+
+ @Test
+ void a04_nestedMapRoundTrip() throws Exception {
+ var nested = new LinkedHashMap<String, Object>();
+ nested.put("host", "localhost");
+ nested.put("port", 8080);
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "app");
+ a.put("database", nested);
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,database{host,port}",
"app,{localhost,8080}");
+ }
+
+ @Test
+ void a05_nullPropertiesRoundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "Alice");
+ a.put("middle", null);
+ var s = IniSerializer.create().keepNullProperties().build();
+ var ini = s.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,middle", "Alice,<null>");
+ }
+
+ @Test
+ void a06_collectionsRoundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "x");
+ a.put("tags", list("a", "b", "c"));
+ a.put("counts", list(1, 2, 3));
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,tags,counts", "x,[a,b,c],[1,2,3]");
+ }
+
+ @Test
+ void a07_deepNestingRoundTrip() throws Exception {
+ var company = new LinkedHashMap<String, Object>();
+ company.put("name", "Acme");
+ company.put("ticker", "ACME");
+ var employment = new LinkedHashMap<String, Object>();
+ employment.put("title", "Engineer");
+ employment.put("company", company);
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "John");
+ a.put("employment", employment);
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,employment{title,company{name,ticker}}",
"John,{Engineer,{Acme,ACME}}");
+ }
+
+ @Test
+ void a08_emptyCollectionsRoundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "x");
+ a.put("tags", list());
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,tags", "x,[]");
+ }
+
+ @Test
+ void a09_stringEdgeCases() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("empty", "");
+ a.put("numeric", "123");
+ a.put("boolean", "true");
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "empty,numeric,boolean", ",123,true");
+ }
+
+ @Test
+ void a10_unicodeRoundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "José");
+ a.put("emoji", "Hello \uD83D\uDE00");
+ var ini = IniSerializer.DEFAULT.serialize(a);
+ var b = (Map<String, Object>) IniParser.DEFAULT.parse(ini,
Map.class, String.class, Object.class);
+ assertBean(b, "name,emoji", "José,Hello \uD83D\uDE00");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/ini/IniSerializer_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/ini/IniSerializer_Test.java
new file mode 100644
index 0000000000..cdb2d2156e
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/ini/IniSerializer_Test.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 FOR A PARTICULAR PURPOSE. See the
+ * License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.ini;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link IniSerializer}.
+ */
+class IniSerializer_Test {
+
+
//====================================================================================================
+ // a - Simple bean and flat properties
+
//====================================================================================================
+
+ @Test
+ void a01_simpleBean() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("host", "localhost");
+ m.put("port", 8080);
+ m.put("debug", true);
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("host = localhost") ||
ini.contains("host=localhost"));
+ assertTrue(ini.contains("port = 8080") ||
ini.contains("port=8080"));
+ assertTrue(ini.contains("debug = true") ||
ini.contains("debug=true"));
+ }
+
+ @Test
+ void a02_nestedBean() throws Exception {
+ var db = new LinkedHashMap<String, Object>();
+ db.put("host", "localhost");
+ db.put("port", 5432);
+ var config = new LinkedHashMap<String, Object>();
+ config.put("name", "myapp");
+ config.put("database", db);
+ var ini = IniSerializer.DEFAULT.serialize(config);
+ assertNotNull(ini);
+ assertTrue(ini.contains("name") && ini.contains("myapp"));
+ assertTrue(ini.contains("[database]"));
+ assertTrue(ini.contains("host") && ini.contains("localhost"));
+ assertTrue(ini.contains("port") && ini.contains("5432"));
+ }
+
+ @Test
+ void a03_beanWithListOfStrings() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("tags", List.of("web", "api", "rest"));
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("tags =") || ini.contains("tags="));
+ assertTrue(ini.contains("web") && ini.contains("api") &&
ini.contains("rest"));
+ }
+
+ @Test
+ void a04_nullValues() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("name", "Alice");
+ m.put("middle", null);
+ var s = IniSerializer.create().keepNullProperties().build();
+ var ini = s.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("name") && ini.contains("Alice"));
+ assertTrue(ini.contains("null") || ini.contains("middle"));
+ }
+
+ @Test
+ void a05_stringQuoting() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("key", "123");
+ m.put("flag", "true");
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("'123'") || ini.contains("123"));
+ assertTrue(ini.contains("'true'") || ini.contains("true"));
+ }
+
+ @Test
+ void a06_emptyStrings() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("empty", "");
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("empty = ''") ||
ini.contains("empty=''") || ini.contains("empty ="));
+ }
+
+ @Test
+ void a07_beanWithMap() throws Exception {
+ var settings = new LinkedHashMap<String, String>();
+ settings.put("timeout", "30");
+ settings.put("retries", "3");
+ var config = new LinkedHashMap<String, Object>();
+ config.put("name", "app");
+ config.put("settings", settings);
+ var ini = IniSerializer.DEFAULT.serialize(config);
+ assertNotNull(ini);
+ assertTrue(ini.contains("[settings]"));
+ assertTrue(ini.contains("timeout") && ini.contains("30"));
+ assertTrue(ini.contains("retries") && ini.contains("3"));
+ }
+
+ @Test
+ void a08_deeplyNestedBean() throws Exception {
+ var company = new LinkedHashMap<String, Object>();
+ company.put("name", "Acme");
+ company.put("ticker", "ACME");
+ var employment = new LinkedHashMap<String, Object>();
+ employment.put("title", "Engineer");
+ employment.put("company", company);
+ var person = new LinkedHashMap<String, Object>();
+ person.put("name", "John");
+ person.put("employment", employment);
+ var ini = IniSerializer.DEFAULT.serialize(person);
+ assertNotNull(ini);
+ assertTrue(ini.contains("name") && ini.contains("John"));
+ assertTrue(ini.contains("employment") &&
(ini.contains("[employment]") || ini.contains("employment")));
+ assertTrue(ini.contains("Acme"));
+ }
+
+ @Test
+ void a09_multipleNestedBeans() throws Exception {
+ var addr1 = new LinkedHashMap<String, Object>();
+ addr1.put("city", "Boston");
+ var addr2 = new LinkedHashMap<String, Object>();
+ addr2.put("city", "NYC");
+ var m = new LinkedHashMap<String, Object>();
+ m.put("name", "x");
+ m.put("home", addr1);
+ m.put("work", addr2);
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("[home]"));
+ assertTrue(ini.contains("[work]"));
+ assertTrue(ini.contains("Boston") && ini.contains("NYC"));
+ }
+
+ @Test
+ void a10_topLevelCollectionThrows() throws Exception {
+ var list = List.of("a", "b", "c");
+ var ex = assertThrows(Exception.class, () ->
IniSerializer.DEFAULT.serialize(list));
+ assertTrue(ex.getMessage().contains("Collection") ||
ex.getMessage().contains("not supported")
+ || ex.getClass().getSimpleName().contains("Serialize"));
+ }
+
+ @Test
+ void a11_topLevelScalarThrows() throws Exception {
+ var ex = assertThrows(Exception.class, () ->
IniSerializer.DEFAULT.serialize("hello"));
+ assertTrue(ex.getMessage().contains("not supported") ||
ex.getMessage().contains("bean")
+ || ex.getClass().getSimpleName().contains("Serialize"));
+ }
+
+ @Test
+ void a12_emptyBean() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.trim().isEmpty() || ini.contains("\n\n"));
+ }
+
+ @Test
+ void a13_kvSeparator() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("key", "value");
+ var s = IniSerializer.create().kvSeparator(':').build();
+ var ini = s.serialize(m);
+ assertTrue(ini.contains(":") && ini.contains("value"));
+ }
+
+ @Test
+ void a14_addBeanTypes() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("name", "test");
+ var s =
IniSerializer.create().addBeanTypes().addRootType().build();
+ var ini = s.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("name") && ini.contains("test"));
+ }
+
+
//====================================================================================================
+ // b - Date/time and enum
+
//====================================================================================================
+
+ @Test
+ void b01_dateValues() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("date", LocalDate.of(2024, 3, 15));
+ m.put("instant", Instant.parse("2024-03-15T12:00:00Z"));
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertNotNull(ini);
+ assertTrue(ini.contains("2024"));
+ }
+
+ @Test
+ void b02_enumValues() throws Exception {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("status", TestEnum.ACTIVE);
+ var ini = IniSerializer.DEFAULT.serialize(m);
+ assertTrue(ini.contains("ACTIVE"));
+ }
+
+ enum TestEnum { ACTIVE, INACTIVE }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/marshaller/Ini_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/marshaller/Ini_Test.java
new file mode 100644
index 0000000000..a6dd9fd388
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/marshaller/Ini_Test.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 FOR A PARTICULAR PURPOSE. See the
+ * License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshaller;
+
+import static org.apache.juneau.junit.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+@SuppressWarnings({
+ "unchecked" // Parser returns Object; cast to Map in tests
+})
+class Ini_Test extends TestBase {
+
+ @Test
+ void a01_of() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "test");
+ a.put("count", 42);
+ var ini = Ini.of(a);
+ assertNotNull(ini);
+ assertTrue(ini.contains("name") && ini.contains("test"));
+ assertTrue(ini.contains("count") && ini.contains("42"));
+ }
+
+ @Test
+ void a02_to() throws Exception {
+ var ini = "name = Alice\nage = 30";
+ var m = (Map<String, Object>) Ini.to(ini, Map.class,
String.class, Object.class);
+ assertBean(m, "name,age", "Alice,30");
+ }
+
+ @Test
+ void a03_roundTrip() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "foo");
+ a.put("value", 123);
+ var ini = Ini.of(a);
+ var b = (Map<String, Object>) Ini.to(ini, Map.class,
String.class, Object.class);
+ assertBean(b, "name,value", "foo,123");
+ }
+
+ @Test
+ void a04_defaultInstance() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("k", "v");
+ var ini = Ini.DEFAULT.write(a);
+ assertTrue(ini.contains("k") && ini.contains("v"));
+ var b = (Map<String, Object>) Ini.DEFAULT.read(ini, Map.class,
String.class, Object.class);
+ assertBean(b, "k", "v");
+ }
+}
diff --git a/todo/ini_implementation.md b/todo/1_ini_implementation.md
similarity index 98%
rename from todo/ini_implementation.md
rename to todo/1_ini_implementation.md
index e3f634e2cd..cc4368f121 100644
--- a/todo/ini_implementation.md
+++ b/todo/1_ini_implementation.md
@@ -454,26 +454,13 @@ Path:
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/confi
Add `IniSerializer.class` to `serializers` array and `IniParser.class` to
`parsers` array.
-### 2. `BasicIniConfig.java` (New)
-
-Path:
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/BasicIniConfig.java`
-
-```java
-@Rest(
- serializers={IniSerializer.class},
- parsers={IniParser.class},
- defaultAccept="text/ini"
-)
-public interface BasicIniConfig extends DefaultConfig {}
-```
-
-### 3. `RestClient.java`
+### 2. `RestClient.java`
Path:
`juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java`
Add `IniSerializer.class` and `IniParser.class` to the `universal()` method's
serializers/parsers lists.
-### 4. Context: `Context.java`
+### 3. Context: `Context.java`
Path:
`juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java`
@@ -973,7 +960,7 @@ the same level of detail.
13. **Annotation tests** (`IniAnnotation_Test.java`,
`IniConfigAnnotation_Test.java`)
14. **Edge case tests** (`IniEdgeCases_Test.java`) -- 13 test cases
15. **Media type tests** (`IniMediaType_Test.java`)
-16. **REST integration** (`BasicUniversalConfig`, `BasicIniConfig`,
`RestClient`)
+16. **REST integration** (`BasicUniversalConfig`, `RestClient`)
17. **Context registration** (`IniConfig`, `IniConfigAnnotation`,
`Context.java`)
18. **Final documentation review**
diff --git a/todo/hjson_implementation.md b/todo/2_hjson_implementation.md
similarity index 100%
rename from todo/hjson_implementation.md
rename to todo/2_hjson_implementation.md
diff --git a/todo/jcs_implementation.md b/todo/3_jcs_implementation.md
similarity index 100%
rename from todo/jcs_implementation.md
rename to todo/3_jcs_implementation.md
diff --git a/todo/bson_implementation.md b/todo/4_bson_implementation.md
similarity index 100%
rename from todo/bson_implementation.md
rename to todo/4_bson_implementation.md
diff --git a/todo/cbor_implementation.md b/todo/5_cbor_implementation.md
similarity index 100%
rename from todo/cbor_implementation.md
rename to todo/5_cbor_implementation.md
diff --git a/todo/hocon_implementation.md b/todo/6_hocon_implementation.md
similarity index 100%
rename from todo/hocon_implementation.md
rename to todo/6_hocon_implementation.md
diff --git a/todo/parquet_implementation.md b/todo/7_parquet_implementation.md
similarity index 100%
rename from todo/parquet_implementation.md
rename to todo/7_parquet_implementation.md